Unified Agent + reusable Go agent core.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: preserve structured tool errors

Lyric 1b38bb6c c9e14e18

+192 -3
+59
agent/engine_execute_tool_test.go
··· 1 + package agent 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "testing" 7 + 8 + "github.com/quailyquaily/mistermorph/tools" 9 + ) 10 + 11 + type executeToolStub struct { 12 + name string 13 + out string 14 + err error 15 + } 16 + 17 + func (t *executeToolStub) Name() string { return t.name } 18 + func (t *executeToolStub) Description() string { return "execute tool stub" } 19 + func (t *executeToolStub) ParameterSchema() string { return "{}" } 20 + func (t *executeToolStub) Execute(_ context.Context, _ map[string]any) (string, error) { 21 + return t.out, t.err 22 + } 23 + 24 + func TestExecuteTool_PreservesStructuredObservationOnError(t *testing.T) { 25 + reg := tools.NewRegistry() 26 + reg.Register(&executeToolStub{ 27 + name: "structured", 28 + out: `{"status":"failed","error":"boom"}`, 29 + err: tools.PreserveObservationError(errors.New("boom")), 30 + }) 31 + 32 + engine := New(nil, reg, Config{}, DefaultPromptSpec()) 33 + observation, err := engine.executeTool(context.Background(), &engineLoopState{}, &ToolCall{Name: "structured"}) 34 + if err == nil { 35 + t.Fatal("expected error, got nil") 36 + } 37 + if observation != `{"status":"failed","error":"boom"}` { 38 + t.Fatalf("observation = %q, want unchanged JSON envelope", observation) 39 + } 40 + } 41 + 42 + func TestExecuteTool_AppendsErrorForPlainObservation(t *testing.T) { 43 + reg := tools.NewRegistry() 44 + reg.Register(&executeToolStub{ 45 + name: "plain", 46 + out: "partial output", 47 + err: errors.New("boom"), 48 + }) 49 + 50 + engine := New(nil, reg, Config{}, DefaultPromptSpec()) 51 + observation, err := engine.executeTool(context.Background(), &engineLoopState{}, &ToolCall{Name: "plain"}) 52 + if err == nil { 53 + t.Fatal("expected error, got nil") 54 + } 55 + want := "partial output\n\nerror: boom" 56 + if observation != want { 57 + t.Fatalf("observation = %q, want %q", observation, want) 58 + } 59 + }
+2 -1
agent/engine_loop.go
··· 12 12 "github.com/quailyquaily/mistermorph/guard" 13 13 "github.com/quailyquaily/mistermorph/internal/jsonutil" 14 14 "github.com/quailyquaily/mistermorph/llm" 15 + "github.com/quailyquaily/mistermorph/tools" 15 16 "golang.org/x/sync/errgroup" 16 17 ) 17 18 ··· 657 658 if toolErr != nil { 658 659 if strings.TrimSpace(observation) == "" { 659 660 observation = fmt.Sprintf("error: %s", toolErr.Error()) 660 - } else { 661 + } else if !tools.ShouldPreserveObservationOnError(toolErr) { 661 662 observation = fmt.Sprintf("%s\n\nerror: %s", observation, toolErr.Error()) 662 663 } 663 664 }
+35 -1
cmd/mistermorph/benchmark.go
··· 236 236 237 237 func benchmarkProfileNames(values llmutil.RuntimeValues) []string { 238 238 names := make([]string, 0, 1+len(values.Profiles)) 239 - names = append(names, llmutil.RouteProfileDefault) 239 + if hasBenchmarkableDefaultProfile(values) { 240 + names = append(names, llmutil.RouteProfileDefault) 241 + } 240 242 for name := range values.Profiles { 241 243 name = strings.TrimSpace(name) 242 244 if name == "" || name == llmutil.RouteProfileDefault { ··· 248 250 sort.Strings(names[1:]) 249 251 } 250 252 return names 253 + } 254 + 255 + func hasBenchmarkableDefaultProfile(values llmutil.RuntimeValues) bool { 256 + if !hasExplicitDefaultProfile(values) { 257 + return false 258 + } 259 + resolved, err := llmutil.ResolveProfile(values, llmutil.RouteProfileDefault) 260 + if err != nil { 261 + return false 262 + } 263 + _, err = buildBenchmarkClient(resolved, nil) 264 + return err == nil 265 + } 266 + 267 + func hasExplicitDefaultProfile(values llmutil.RuntimeValues) bool { 268 + return strings.TrimSpace(values.Provider) != "" || 269 + strings.TrimSpace(values.Endpoint) != "" || 270 + strings.TrimSpace(values.APIKey) != "" || 271 + strings.TrimSpace(values.Model) != "" || 272 + len(values.Headers) > 0 || 273 + strings.TrimSpace(values.AzureDeployment) != "" || 274 + strings.TrimSpace(values.RequestTimeoutRaw) != "" || 275 + strings.TrimSpace(values.ToolsEmulationMode) != "" || 276 + strings.TrimSpace(values.TemperatureRaw) != "" || 277 + strings.TrimSpace(values.ReasoningEffortRaw) != "" || 278 + strings.TrimSpace(values.ReasoningBudgetRaw) != "" || 279 + strings.TrimSpace(values.BedrockAWSKey) != "" || 280 + strings.TrimSpace(values.BedrockAWSSecret) != "" || 281 + strings.TrimSpace(values.BedrockAWSRegion) != "" || 282 + strings.TrimSpace(values.BedrockModelARN) != "" || 283 + strings.TrimSpace(values.CloudflareAccountID) != "" || 284 + strings.TrimSpace(values.CloudflareAPIToken) != "" 251 285 } 252 286 253 287 func summarizeBenchmarkResults(results []llmbench.ProfileResult) benchmarkSummary {
+51
cmd/mistermorph/benchmark_test.go
··· 8 8 "testing" 9 9 10 10 "github.com/quailyquaily/mistermorph/internal/llmbench" 11 + "github.com/quailyquaily/mistermorph/internal/llmutil" 11 12 ) 12 13 13 14 func TestBenchmarkCmdTextOutput(t *testing.T) { ··· 197 198 } 198 199 } 199 200 } 201 + 202 + func TestBenchmarkProfileNames_SkipsUnusableDefault(t *testing.T) { 203 + values := llmutil.RuntimeValues{ 204 + Profiles: map[string]llmutil.ProfileConfig{ 205 + "cheap": { 206 + Provider: "openai", 207 + Model: "gpt-5-mini", 208 + }, 209 + "strong": { 210 + Provider: "openai", 211 + Model: "gpt-5.2", 212 + }, 213 + }, 214 + } 215 + 216 + got := benchmarkProfileNames(values) 217 + want := []string{"cheap", "strong"} 218 + if len(got) != len(want) { 219 + t.Fatalf("len(got) = %d, want %d (%v)", len(got), len(want), got) 220 + } 221 + for i := range want { 222 + if got[i] != want[i] { 223 + t.Fatalf("got[%d] = %q, want %q (all=%v)", i, got[i], want[i], got) 224 + } 225 + } 226 + } 227 + 228 + func TestBenchmarkProfileNames_IncludesUsableDefaultFirst(t *testing.T) { 229 + values := llmutil.RuntimeValues{ 230 + Provider: "openai", 231 + Model: "gpt-5", 232 + Profiles: map[string]llmutil.ProfileConfig{ 233 + "cheap": { 234 + Provider: "openai", 235 + Model: "gpt-5-mini", 236 + }, 237 + }, 238 + } 239 + 240 + got := benchmarkProfileNames(values) 241 + want := []string{"default", "cheap"} 242 + if len(got) != len(want) { 243 + t.Fatalf("len(got) = %d, want %d (%v)", len(got), len(want), got) 244 + } 245 + for i := range want { 246 + if got[i] != want[i] { 247 + t.Fatalf("got[%d] = %q, want %q (all=%v)", i, got[i], want[i], got) 248 + } 249 + } 250 + }
+2 -1
tools/builtin/bash.go
··· 16 16 17 17 "github.com/quailyquaily/mistermorph/agent" 18 18 "github.com/quailyquaily/mistermorph/internal/pathutil" 19 + "github.com/quailyquaily/mistermorph/tools" 19 20 ) 20 21 21 22 type BashTool struct { ··· 311 312 return "", err 312 313 } 313 314 if execErr != nil { 314 - return string(b), execErr 315 + return string(b), tools.PreserveObservationError(execErr) 315 316 } 316 317 return string(b), nil 317 318 }
+43
tools/errors.go
··· 1 + package tools 2 + 3 + import "errors" 4 + 5 + // ExecutionError carries execution-specific error handling hints for the engine. 6 + type ExecutionError struct { 7 + Err error 8 + PreserveObservation bool 9 + } 10 + 11 + func (e *ExecutionError) Error() string { 12 + if e == nil || e.Err == nil { 13 + return "" 14 + } 15 + return e.Err.Error() 16 + } 17 + 18 + func (e *ExecutionError) Unwrap() error { 19 + if e == nil { 20 + return nil 21 + } 22 + return e.Err 23 + } 24 + 25 + func PreserveObservationError(err error) error { 26 + if err == nil { 27 + return nil 28 + } 29 + var execErr *ExecutionError 30 + if errors.As(err, &execErr) { 31 + execErr.PreserveObservation = true 32 + return err 33 + } 34 + return &ExecutionError{ 35 + Err: err, 36 + PreserveObservation: true, 37 + } 38 + } 39 + 40 + func ShouldPreserveObservationOnError(err error) bool { 41 + var execErr *ExecutionError 42 + return errors.As(err, &execErr) && execErr.PreserveObservation 43 + }