Unified Agent + reusable Go agent core.
0
fork

Configure Feed

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

Merge pull request #25 from quailyquaily/feat/concurrent-tools-and-spawn

feat: concurrent tool execution and spawn sub-agent

authored by

Lyric Wai and committed by
GitHub
bf349474 09e638c7

+1044 -137
+2
.goreleaser.yaml
··· 19 19 goarch: 20 20 - amd64 21 21 - arm64 22 + tags: 23 + - noembedconsole 22 24 ldflags: 23 25 - -s -w -X main.version={{ .Version }} -X main.commit={{ .Commit }} -X main.date={{ .Date }} 24 26
+34
agent/engine.go
··· 53 53 } 54 54 } 55 55 56 + func WithOnToolStart(fn func(*Context, string)) Option { 57 + return func(e *Engine) { 58 + if fn != nil { 59 + e.onToolStart = fn 60 + } 61 + } 62 + } 63 + 56 64 func WithOnToolSuccess(fn func(*Context, string)) Option { 57 65 return func(e *Engine) { 58 66 if fn != nil { ··· 77 85 } 78 86 } 79 87 88 + // SubClientFactory creates an LLM client for a sub-agent with the given prefix 89 + // (used for inspection dump filenames). The returned cleanup function must be 90 + // called after the sub-agent completes to close any resources (e.g. dump files). 91 + type SubClientFactory func(prefix string) (client llm.Client, cleanup func()) 92 + 93 + func WithSubClientFactory(fn SubClientFactory) Option { 94 + return func(e *Engine) { 95 + if fn != nil { 96 + e.subClientFactory = fn 97 + } 98 + } 99 + } 100 + 80 101 type Config struct { 81 102 MaxSteps int 82 103 MaxTokenBudget int 83 104 ParseRetries int 84 105 ToolRepeatLimit int 106 + DefaultModel string 107 + ToolCallTimeout time.Duration 108 + SpawnEnabled bool 85 109 } 86 110 87 111 type Engine struct { ··· 95 119 96 120 promptBuilder func(registry *tools.Registry, task string) string 97 121 paramsBuilder func(opts RunOptions) map[string]any 122 + onToolStart func(ctx *Context, toolName string) 98 123 onToolSuccess func(ctx *Context, toolName string) 99 124 onPlanStepUpdate func(ctx *Context, update PlanStepUpdate) 100 125 fallbackFinal func() *Final 101 126 127 + subClientFactory SubClientFactory 128 + 102 129 guard *guard.Guard 103 130 } 104 131 ··· 128 155 opt(e) 129 156 } 130 157 } 158 + 159 + if cfg.SpawnEnabled { 160 + e.registry.Register(&spawnTool{engine: e}) 161 + } 131 162 return e 132 163 } 133 164 ··· 135 166 agentCtx := NewContext(task, e.config.MaxSteps) 136 167 137 168 model := strings.TrimSpace(opts.Model) 169 + if model == "" { 170 + model = strings.TrimSpace(e.config.DefaultModel) 171 + } 138 172 139 173 runID := llmstats.RunIDFromContext(ctx) 140 174 if runID == "" {
+515
agent/engine_concurrent_test.go
··· 1 + package agent 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "sync/atomic" 7 + "testing" 8 + "time" 9 + 10 + "github.com/quailyquaily/mistermorph/llm" 11 + "github.com/quailyquaily/mistermorph/tools" 12 + ) 13 + 14 + // --- concurrent-specific mock tools --- 15 + 16 + type slowTool struct { 17 + name string 18 + delay time.Duration 19 + result string 20 + started atomic.Int32 21 + finished atomic.Int32 22 + } 23 + 24 + func (t *slowTool) Name() string { return t.name } 25 + func (t *slowTool) Description() string { return "slow mock tool" } 26 + func (t *slowTool) ParameterSchema() string { return "{}" } 27 + func (t *slowTool) Execute(ctx context.Context, _ map[string]any) (string, error) { 28 + t.started.Add(1) 29 + select { 30 + case <-time.After(t.delay): 31 + case <-ctx.Done(): 32 + return "", ctx.Err() 33 + } 34 + t.finished.Add(1) 35 + return t.result, nil 36 + } 37 + 38 + type stopAfterSuccessTool struct { 39 + name string 40 + result string 41 + } 42 + 43 + func (t *stopAfterSuccessTool) Name() string { return t.name } 44 + func (t *stopAfterSuccessTool) Description() string { return "mock tool that stops after success" } 45 + func (t *stopAfterSuccessTool) ParameterSchema() string { return "{}" } 46 + func (t *stopAfterSuccessTool) Execute(_ context.Context, _ map[string]any) (string, error) { 47 + return t.result, nil 48 + } 49 + func (t *stopAfterSuccessTool) StopAfterSuccess() bool { return true } 50 + 51 + // --- helpers --- 52 + 53 + func multiToolCallResponse(names ...string) llm.Result { 54 + calls := make([]llm.ToolCall, len(names)) 55 + for i, name := range names { 56 + calls[i] = llm.ToolCall{ 57 + ID: fmt.Sprintf("call_%d", i), 58 + Name: name, 59 + Arguments: map[string]any{}, 60 + } 61 + } 62 + return llm.Result{ToolCalls: calls} 63 + } 64 + 65 + // --- tests --- 66 + 67 + func TestConcurrentToolExecution_AllToolsRun(t *testing.T) { 68 + t.Parallel() 69 + 70 + toolA := &slowTool{name: "tool_a", delay: 50 * time.Millisecond, result: "result_a"} 71 + toolB := &slowTool{name: "tool_b", delay: 50 * time.Millisecond, result: "result_b"} 72 + 73 + reg := tools.NewRegistry() 74 + reg.Register(toolA) 75 + reg.Register(toolB) 76 + 77 + client := newMockClient( 78 + multiToolCallResponse("tool_a", "tool_b"), 79 + finalResponse("done"), 80 + ) 81 + 82 + e := New(client, reg, baseCfg(), DefaultPromptSpec()) 83 + 84 + start := time.Now() 85 + f, agentCtx, err := e.Run(context.Background(), "test concurrent", RunOptions{}) 86 + elapsed := time.Since(start) 87 + 88 + if err != nil { 89 + t.Fatalf("unexpected error: %v", err) 90 + } 91 + if f == nil || f.Output != "done" { 92 + t.Fatalf("unexpected final: %v", f) 93 + } 94 + 95 + if toolA.finished.Load() != 1 { 96 + t.Fatalf("tool_a finished count = %d, want 1", toolA.finished.Load()) 97 + } 98 + if toolB.finished.Load() != 1 { 99 + t.Fatalf("tool_b finished count = %d, want 1", toolB.finished.Load()) 100 + } 101 + 102 + // Both tools take 50ms; if run concurrently, total should be well under 2*50ms = 100ms. 103 + if elapsed > 150*time.Millisecond { 104 + t.Logf("warning: concurrent execution took %v, expected < 150ms", elapsed) 105 + } 106 + 107 + if len(agentCtx.Steps) != 2 { 108 + t.Fatalf("steps = %d, want 2", len(agentCtx.Steps)) 109 + } 110 + if agentCtx.Steps[0].Action != "tool_a" { 111 + t.Fatalf("step[0].Action = %q, want tool_a", agentCtx.Steps[0].Action) 112 + } 113 + if agentCtx.Steps[1].Action != "tool_b" { 114 + t.Fatalf("step[1].Action = %q, want tool_b", agentCtx.Steps[1].Action) 115 + } 116 + } 117 + 118 + func TestConcurrentToolExecution_ResultsInOrder(t *testing.T) { 119 + t.Parallel() 120 + 121 + reg := tools.NewRegistry() 122 + reg.Register(&slowTool{name: "fast", delay: 10 * time.Millisecond, result: "fast_result"}) 123 + reg.Register(&slowTool{name: "slow", delay: 80 * time.Millisecond, result: "slow_result"}) 124 + 125 + client := newMockClient( 126 + multiToolCallResponse("slow", "fast"), 127 + finalResponse("done"), 128 + ) 129 + 130 + e := New(client, reg, baseCfg(), DefaultPromptSpec()) 131 + _, _, err := e.Run(context.Background(), "test order", RunOptions{}) 132 + if err != nil { 133 + t.Fatalf("unexpected error: %v", err) 134 + } 135 + 136 + calls := client.allCalls() 137 + if len(calls) < 2 { 138 + t.Fatalf("expected at least 2 LLM calls, got %d", len(calls)) 139 + } 140 + 141 + secondCall := calls[1] 142 + toolMsgs := make([]llm.Message, 0) 143 + for _, m := range secondCall.Messages { 144 + if m.Role == "tool" { 145 + toolMsgs = append(toolMsgs, m) 146 + } 147 + } 148 + if len(toolMsgs) != 2 { 149 + t.Fatalf("tool messages = %d, want 2", len(toolMsgs)) 150 + } 151 + if toolMsgs[0].ToolCallID != "call_0" { 152 + t.Fatalf("first tool message ID = %q, want call_0", toolMsgs[0].ToolCallID) 153 + } 154 + if toolMsgs[1].ToolCallID != "call_1" { 155 + t.Fatalf("second tool message ID = %q, want call_1", toolMsgs[1].ToolCallID) 156 + } 157 + } 158 + 159 + func TestConcurrentToolExecution_StopAfterSuccess(t *testing.T) { 160 + t.Parallel() 161 + 162 + stopper := &stopAfterSuccessTool{name: "stopper", result: "stopped"} 163 + slow := &slowTool{name: "slow", delay: 5 * time.Second, result: "should_not_finish"} 164 + 165 + reg := tools.NewRegistry() 166 + reg.Register(stopper) 167 + reg.Register(slow) 168 + 169 + client := newMockClient( 170 + multiToolCallResponse("stopper", "slow"), 171 + ) 172 + 173 + e := New(client, reg, baseCfg(), DefaultPromptSpec()) 174 + 175 + start := time.Now() 176 + f, _, err := e.Run(context.Background(), "test stop", RunOptions{}) 177 + elapsed := time.Since(start) 178 + 179 + if err != nil { 180 + t.Fatalf("unexpected error: %v", err) 181 + } 182 + if f == nil { 183 + t.Fatal("expected non-nil final from StopAfterSuccess") 184 + } 185 + if elapsed > 2*time.Second { 186 + t.Fatalf("StopAfterSuccess should have cancelled slow tool quickly, took %v", elapsed) 187 + } 188 + } 189 + 190 + func TestConcurrentToolExecution_ToolCallTimeout(t *testing.T) { 191 + t.Parallel() 192 + 193 + slow := &slowTool{name: "slow", delay: 10 * time.Second, result: "should_timeout"} 194 + reg := tools.NewRegistry() 195 + reg.Register(slow) 196 + 197 + client := newMockClient( 198 + llm.Result{ToolCalls: []llm.ToolCall{{ID: "c1", Name: "slow", Arguments: map[string]any{}}}}, 199 + finalResponse("done"), 200 + ) 201 + 202 + cfg := baseCfg() 203 + cfg.ToolCallTimeout = 100 * time.Millisecond 204 + e := New(client, reg, cfg, DefaultPromptSpec()) 205 + 206 + start := time.Now() 207 + f, agentCtx, err := e.Run(context.Background(), "test timeout", RunOptions{}) 208 + elapsed := time.Since(start) 209 + 210 + if err != nil { 211 + t.Fatalf("unexpected error: %v", err) 212 + } 213 + if f == nil || f.Output != "done" { 214 + t.Fatalf("unexpected final: %v", f) 215 + } 216 + if elapsed > 2*time.Second { 217 + t.Fatalf("ToolCallTimeout should have cancelled after ~100ms, took %v", elapsed) 218 + } 219 + if len(agentCtx.Steps) == 0 { 220 + t.Fatal("expected at least 1 step") 221 + } 222 + if agentCtx.Steps[0].Error == nil { 223 + t.Fatal("expected tool to have error from timeout") 224 + } 225 + } 226 + 227 + func TestConcurrentToolExecution_MixedSuccessAndFailure(t *testing.T) { 228 + t.Parallel() 229 + 230 + reg := tools.NewRegistry() 231 + reg.Register(&mockTool{name: "good", result: "ok"}) 232 + reg.Register(&mockTool{name: "bad", result: "", err: fmt.Errorf("tool failed")}) 233 + 234 + client := newMockClient( 235 + multiToolCallResponse("good", "bad"), 236 + finalResponse("done"), 237 + ) 238 + 239 + e := New(client, reg, baseCfg(), DefaultPromptSpec()) 240 + f, agentCtx, err := e.Run(context.Background(), "test mixed", RunOptions{}) 241 + if err != nil { 242 + t.Fatalf("unexpected error: %v", err) 243 + } 244 + if f == nil || f.Output != "done" { 245 + t.Fatalf("unexpected final: %v", f) 246 + } 247 + if len(agentCtx.Steps) != 2 { 248 + t.Fatalf("steps = %d, want 2", len(agentCtx.Steps)) 249 + } 250 + if agentCtx.Steps[0].Error != nil { 251 + t.Fatalf("step[0] should succeed, got error: %v", agentCtx.Steps[0].Error) 252 + } 253 + if agentCtx.Steps[1].Error == nil { 254 + t.Fatal("step[1] should have error") 255 + } 256 + } 257 + 258 + func TestSpawnTool_BasicExecution(t *testing.T) { 259 + t.Parallel() 260 + 261 + reg := tools.NewRegistry() 262 + reg.Register(&mockTool{name: "read_file", result: "file content"}) 263 + 264 + subClient := newMockClient( 265 + toolCallResponse("read_file"), 266 + finalResponse("sub-agent done"), 267 + ) 268 + 269 + cfg := baseCfg() 270 + cfg.SpawnEnabled = true 271 + cfg.DefaultModel = "test-model" 272 + e := New(subClient, reg, cfg, DefaultPromptSpec()) 273 + 274 + spawnT, ok := e.registry.Get("spawn") 275 + if !ok { 276 + t.Fatal("spawn tool should be registered when SpawnEnabled=true") 277 + } 278 + 279 + result, err := spawnT.Execute(context.Background(), map[string]any{ 280 + "task": "read the file", 281 + "tools": []any{"read_file"}, 282 + }) 283 + if err != nil { 284 + t.Fatalf("spawn Execute error: %v", err) 285 + } 286 + if result == "" { 287 + t.Fatal("spawn should return non-empty result") 288 + } 289 + } 290 + 291 + func TestSpawnTool_NotRegisteredByDefault(t *testing.T) { 292 + t.Parallel() 293 + 294 + reg := tools.NewRegistry() 295 + e := New(newMockClient(finalResponse("ok")), reg, baseCfg(), DefaultPromptSpec()) 296 + 297 + if _, ok := e.registry.Get("spawn"); ok { 298 + t.Fatal("spawn tool should NOT be registered when SpawnEnabled is false (default)") 299 + } 300 + } 301 + 302 + func TestSpawnTool_CannotIncludeSpawnInSubAgent(t *testing.T) { 303 + t.Parallel() 304 + 305 + reg := tools.NewRegistry() 306 + reg.Register(&mockTool{name: "read_file", result: "content"}) 307 + 308 + subClient := newMockClient(finalResponse("sub done")) 309 + 310 + cfg := baseCfg() 311 + cfg.SpawnEnabled = true 312 + cfg.DefaultModel = "test-model" 313 + e := New(subClient, reg, cfg, DefaultPromptSpec()) 314 + 315 + spawnT, _ := e.registry.Get("spawn") 316 + result, err := spawnT.Execute(context.Background(), map[string]any{ 317 + "task": "test", 318 + "tools": []any{"read_file", "spawn"}, 319 + }) 320 + if err != nil { 321 + t.Fatalf("unexpected error: %v", err) 322 + } 323 + if result == "" { 324 + t.Fatal("expected non-empty result") 325 + } 326 + } 327 + 328 + func TestSpawnTool_EmptyToolsError(t *testing.T) { 329 + t.Parallel() 330 + 331 + reg := tools.NewRegistry() 332 + cfg := baseCfg() 333 + cfg.SpawnEnabled = true 334 + e := New(newMockClient(), reg, cfg, DefaultPromptSpec()) 335 + 336 + spawnT, _ := e.registry.Get("spawn") 337 + _, err := spawnT.Execute(context.Background(), map[string]any{ 338 + "task": "test", 339 + "tools": []any{}, 340 + }) 341 + if err == nil { 342 + t.Fatal("expected error for empty tools") 343 + } 344 + } 345 + 346 + func TestSpawnTool_MissingTaskError(t *testing.T) { 347 + t.Parallel() 348 + 349 + reg := tools.NewRegistry() 350 + cfg := baseCfg() 351 + cfg.SpawnEnabled = true 352 + e := New(newMockClient(), reg, cfg, DefaultPromptSpec()) 353 + 354 + spawnT, _ := e.registry.Get("spawn") 355 + _, err := spawnT.Execute(context.Background(), map[string]any{ 356 + "tools": []any{"read_file"}, 357 + }) 358 + if err == nil { 359 + t.Fatal("expected error for missing task") 360 + } 361 + } 362 + 363 + func TestSpawnTool_SubClientFactory_Called(t *testing.T) { 364 + t.Parallel() 365 + 366 + reg := tools.NewRegistry() 367 + reg.Register(&mockTool{name: "read_file", result: "content"}) 368 + 369 + parentClient := newMockClient() 370 + subClient := newMockClient(finalResponse("sub done")) 371 + 372 + var factoryPrefix string 373 + cleanupCalled := false 374 + 375 + cfg := baseCfg() 376 + cfg.SpawnEnabled = true 377 + cfg.DefaultModel = "test-model" 378 + e := New(parentClient, reg, cfg, DefaultPromptSpec(), 379 + WithSubClientFactory(func(prefix string) (llm.Client, func()) { 380 + factoryPrefix = prefix 381 + return subClient, func() { cleanupCalled = true } 382 + }), 383 + ) 384 + 385 + spawnT, _ := e.registry.Get("spawn") 386 + result, err := spawnT.Execute(context.Background(), map[string]any{ 387 + "task": "test sub-client factory", 388 + "tools": []any{"read_file"}, 389 + }) 390 + if err != nil { 391 + t.Fatalf("spawn Execute error: %v", err) 392 + } 393 + if result == "" { 394 + t.Fatal("expected non-empty result") 395 + } 396 + if factoryPrefix != "spawn" { 397 + t.Fatalf("factory prefix = %q, want %q", factoryPrefix, "spawn") 398 + } 399 + if !cleanupCalled { 400 + t.Fatal("cleanup function should have been called after sub-agent completes") 401 + } 402 + 403 + parentCalls := parentClient.allCalls() 404 + if len(parentCalls) != 0 { 405 + t.Fatalf("parent client should not have been called, got %d calls", len(parentCalls)) 406 + } 407 + subCalls := subClient.allCalls() 408 + if len(subCalls) == 0 { 409 + t.Fatal("sub client should have been called") 410 + } 411 + } 412 + 413 + func TestOnToolStart_CalledBeforeExecution(t *testing.T) { 414 + t.Parallel() 415 + 416 + reg := tools.NewRegistry() 417 + reg.Register(&mockTool{name: "search", result: "found"}) 418 + reg.Register(&mockTool{name: "fetch", result: "fetched"}) 419 + 420 + client := newMockClient( 421 + multiToolCallResponse("search", "fetch"), 422 + finalResponse("done"), 423 + ) 424 + 425 + var startedTools []string 426 + e := New(client, reg, baseCfg(), DefaultPromptSpec(), 427 + WithOnToolStart(func(_ *Context, toolName string) { 428 + startedTools = append(startedTools, toolName) 429 + }), 430 + ) 431 + 432 + _, _, err := e.Run(context.Background(), "test", RunOptions{}) 433 + if err != nil { 434 + t.Fatalf("unexpected error: %v", err) 435 + } 436 + if len(startedTools) != 2 { 437 + t.Fatalf("onToolStart called %d times, want 2", len(startedTools)) 438 + } 439 + if startedTools[0] != "search" { 440 + t.Fatalf("startedTools[0] = %q, want search", startedTools[0]) 441 + } 442 + if startedTools[1] != "fetch" { 443 + t.Fatalf("startedTools[1] = %q, want fetch", startedTools[1]) 444 + } 445 + } 446 + 447 + func TestOnToolStart_NotCalledForSkippedTools(t *testing.T) { 448 + t.Parallel() 449 + 450 + reg := tools.NewRegistry() 451 + reg.Register(&mockTool{name: "search", result: "found"}) 452 + 453 + client := newMockClient( 454 + llm.Result{ToolCalls: []llm.ToolCall{ 455 + {ID: "c1", Name: "search", Arguments: map[string]any{"q": "same"}}, 456 + }}, 457 + llm.Result{ToolCalls: []llm.ToolCall{ 458 + {ID: "c2", Name: "search", Arguments: map[string]any{"q": "same"}}, 459 + }}, 460 + finalResponse("done"), 461 + ) 462 + 463 + var startCount int 464 + e := New(client, reg, Config{MaxSteps: 5, ToolRepeatLimit: 10}, DefaultPromptSpec(), 465 + WithOnToolStart(func(_ *Context, toolName string) { 466 + startCount++ 467 + }), 468 + ) 469 + 470 + _, _, err := e.Run(context.Background(), "test", RunOptions{}) 471 + if err != nil { 472 + t.Fatalf("unexpected error: %v", err) 473 + } 474 + if startCount != 1 { 475 + t.Fatalf("onToolStart called %d times, want 1 (second call is duplicate)", startCount) 476 + } 477 + } 478 + 479 + func TestOnToolStart_NilIgnored(t *testing.T) { 480 + t.Parallel() 481 + 482 + client := newMockClient(finalResponse("ok")) 483 + e := New(client, baseRegistry(), baseCfg(), DefaultPromptSpec(), WithOnToolStart(nil)) 484 + if e.onToolStart != nil { 485 + t.Fatal("expected onToolStart to remain nil for nil input") 486 + } 487 + } 488 + 489 + func TestSpawnTool_SubClientFactory_Nil_UsesEngineClient(t *testing.T) { 490 + t.Parallel() 491 + 492 + reg := tools.NewRegistry() 493 + reg.Register(&mockTool{name: "read_file", result: "content"}) 494 + 495 + client := newMockClient(finalResponse("done")) 496 + 497 + cfg := baseCfg() 498 + cfg.SpawnEnabled = true 499 + cfg.DefaultModel = "test-model" 500 + e := New(client, reg, cfg, DefaultPromptSpec()) 501 + 502 + spawnT, _ := e.registry.Get("spawn") 503 + _, err := spawnT.Execute(context.Background(), map[string]any{ 504 + "task": "test no factory", 505 + "tools": []any{"read_file"}, 506 + }) 507 + if err != nil { 508 + t.Fatalf("spawn Execute error: %v", err) 509 + } 510 + 511 + calls := client.allCalls() 512 + if len(calls) == 0 { 513 + t.Fatal("engine client should have been called when no SubClientFactory is set") 514 + } 515 + }
+229 -129
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 + "golang.org/x/sync/errgroup" 15 16 ) 16 17 17 18 type engineLoopState struct { ··· 266 267 }) 267 268 assistantTextAdded = true 268 269 } 270 + 271 + // --- Phase 1: serial pre-check (dedup, repeat limit, guard) --- 272 + type toolExecItem struct { 273 + tc ToolCall 274 + sig string 275 + toolNameKey string 276 + skip bool 277 + observation string 278 + err error 279 + executed bool 280 + stepStart time.Time 281 + duration time.Duration 282 + } 283 + 284 + items := make([]toolExecItem, len(toolCalls)) 285 + var pausedFinal *Final 286 + paused := false 287 + 269 288 for i := range toolCalls { 270 289 tc := toolCalls[i] 271 - stepStart := time.Now() 272 290 sig := toolCallSignature(tc) 273 291 toolNameKey := normalizedToolName(tc.Name) 292 + items[i] = toolExecItem{tc: tc, sig: sig, toolNameKey: toolNameKey, stepStart: time.Now()} 274 293 275 294 debugMode := log.Enabled(ctx, slog.LevelDebug) 276 295 fields := []any{"step", step, "tool", tc.Name, "args", toolArgsSummary(tc.Name, tc.Params, e.logOpts, debugMode)} ··· 298 317 log.Debug("tool_thought_len", "step", step, "tool", tc.Name, "thought_len", len(tc.Thought)) 299 318 } 300 319 320 + switch { 321 + case sig != "" && st.seenToolCallSignatures[sig]: 322 + items[i].observation = duplicateToolCallObservation(tc.Name) 323 + items[i].err = fmt.Errorf("duplicate tool call blocked") 324 + items[i].skip = true 325 + case e.config.ToolRepeatLimit > 0 && toolNameKey != "" && st.toolRunCounts[toolNameKey] >= e.config.ToolRepeatLimit: 326 + items[i].observation = toolRepeatLimitObservation(tc.Name, e.config.ToolRepeatLimit) 327 + items[i].err = fmt.Errorf("tool repeat limit reached") 328 + items[i].skip = true 329 + default: 301 330 remaining := toolCalls[i+1:] 302 - var ( 303 - observation string 304 - toolErr error 305 - pausedFinal *Final 306 - paused bool 307 - executed bool 308 - ) 331 + obs, denied, pFinal, pPaused := e.guardPreCheck(ctx, st, step, result.Text, &tc, remaining, assistantTextAdded) 332 + if pPaused { 333 + pausedFinal = pFinal 334 + paused = true 335 + } 336 + if denied { 337 + items[i].observation = obs 338 + items[i].err = fmt.Errorf("blocked by guard") 339 + items[i].skip = true 340 + } else if !paused { 341 + // Reserve signature/count so later items in this batch 342 + // are correctly deduped and repeat-limited. 343 + if sig != "" { 344 + st.seenToolCallSignatures[sig] = true 345 + } 346 + if toolNameKey != "" { 347 + st.toolRunCounts[toolNameKey] = st.toolRunCounts[toolNameKey] + 1 348 + } 349 + } 350 + } 351 + if paused { 352 + break 353 + } 354 + } 309 355 310 - switch { 311 - case sig != "" && st.seenToolCallSignatures[sig]: 312 - observation = duplicateToolCallObservation(tc.Name) 313 - toolErr = fmt.Errorf("duplicate tool call blocked") 314 - case e.config.ToolRepeatLimit > 0 && toolNameKey != "" && st.toolRunCounts[toolNameKey] >= e.config.ToolRepeatLimit: 315 - observation = toolRepeatLimitObservation(tc.Name, e.config.ToolRepeatLimit) 316 - toolErr = fmt.Errorf("tool repeat limit reached") 317 - default: 318 - observation, toolErr, pausedFinal, paused = e.executeToolWithGuard(ctx, st, step, result.Text, &tc, stepStart, remaining, assistantTextAdded) 319 - if paused { 320 - return pausedFinal, st.agentCtx, nil 356 + if paused { 357 + return pausedFinal, st.agentCtx, nil 358 + } 359 + 360 + if e.onToolStart != nil { 361 + for i := range items { 362 + if !items[i].skip { 363 + e.onToolStart(st.agentCtx, items[i].tc.Name) 321 364 } 322 - executed = true 365 + } 366 + } 367 + 368 + // --- Phase 2: concurrent execution --- 369 + execCtx := ctx 370 + var execCancel context.CancelFunc 371 + if e.config.ToolCallTimeout > 0 { 372 + execCtx, execCancel = context.WithTimeout(ctx, e.config.ToolCallTimeout) 373 + } else { 374 + execCtx, execCancel = context.WithCancel(ctx) 375 + } 376 + 377 + g, gCtx := errgroup.WithContext(execCtx) 378 + for i := range items { 379 + if items[i].skip { 380 + items[i].duration = time.Since(items[i].stepStart) 381 + continue 323 382 } 383 + item := &items[i] 384 + g.Go(func() error { 385 + obs, toolErr := e.executeTool(gCtx, st, &item.tc) 386 + item.observation = obs 387 + item.err = toolErr 388 + item.executed = true 389 + item.duration = time.Since(item.stepStart) 324 390 325 - if executed && toolErr == nil { 326 - if toolNameKey != "" { 327 - st.toolRunCounts[toolNameKey] = st.toolRunCounts[toolNameKey] + 1 391 + if toolErr == nil { 392 + if t, ok := e.registry.Get(item.tc.Name); ok { 393 + if stopper, ok := t.(interface{ StopAfterSuccess() bool }); ok && stopper.StopAfterSuccess() { 394 + execCancel() 395 + } 396 + } 328 397 } 329 - if sig != "" { 330 - st.seenToolCallSignatures[sig] = true 331 - } 398 + return nil 399 + }) 400 + } 401 + _ = g.Wait() 402 + execCancel() 403 + 404 + // --- Phase 3: serial post-processing (in original order) --- 405 + var earlyStop bool 406 + for i := range items { 407 + item := &items[i] 408 + tc := item.tc 409 + 410 + if item.executed { 411 + item.observation, item.err = e.guardPostRedact(ctx, st, step, &tc, item.observation, item.err) 412 + } 413 + 414 + if item.executed && item.err != nil { 415 + // Roll back pre-reserved counts from Phase 1 for failed executions. 416 + if item.toolNameKey != "" && st.toolRunCounts[item.toolNameKey] > 0 { 417 + st.toolRunCounts[item.toolNameKey] = st.toolRunCounts[item.toolNameKey] - 1 418 + } 419 + if item.sig != "" { 420 + delete(st.seenToolCallSignatures, item.sig) 332 421 } 422 + } 333 423 334 424 st.agentCtx.RecordStep(Step{ 335 425 StepNumber: step, 336 426 Thought: tc.Thought, 337 427 Action: tc.Name, 338 428 ActionInput: tc.Params, 339 - Observation: observation, 340 - Error: toolErr, 341 - Duration: time.Since(stepStart), 429 + Observation: item.observation, 430 + Error: item.err, 431 + Duration: item.duration, 342 432 }) 343 433 344 - if toolErr == nil && tc.Name == "plan_create" && st.agentCtx.Plan == nil { 345 - if plan := parsePlanCreateObservation(observation); plan != nil { 434 + if item.err == nil && tc.Name == "plan_create" && st.agentCtx.Plan == nil { 435 + if plan := parsePlanCreateObservation(item.observation); plan != nil { 346 436 NormalizePlanSteps(plan) 347 437 st.agentCtx.Plan = plan 348 438 log.Info("plan", "step", step, "steps", len(plan.Steps)) ··· 361 451 } 362 452 } 363 453 364 - if toolErr == nil && e.onToolSuccess != nil { 454 + if item.err == nil && e.onToolSuccess != nil { 365 455 e.onToolSuccess(st.agentCtx, tc.Name) 366 456 } 367 457 368 - if toolErr == nil && st.agentCtx.Plan != nil && tc.Name != "plan_create" { 458 + if item.err == nil && st.agentCtx.Plan != nil && tc.Name != "plan_create" { 369 459 completedIdx, completedStep, startedIdx, startedStep, ok := AdvancePlanOnSuccess(st.agentCtx.Plan) 370 460 if ok { 371 461 planFields := []any{ ··· 393 483 } 394 484 } 395 485 396 - if toolErr != nil { 486 + if item.err != nil { 397 487 log.Warn("tool_done", 398 488 "step", step, 399 489 "tool", tc.Name, 400 - "duration_ms", time.Since(stepStart).Milliseconds(), 401 - "observation_len", len(observation), 402 - "error", toolErr.Error(), 490 + "duration_ms", item.duration.Milliseconds(), 491 + "observation_len", len(item.observation), 492 + "error", item.err.Error(), 403 493 ) 404 494 } else { 405 495 log.Info("tool_done", 406 496 "step", step, 407 497 "tool", tc.Name, 408 - "duration_ms", time.Since(stepStart).Milliseconds(), 409 - "observation_len", len(observation), 498 + "duration_ms", item.duration.Milliseconds(), 499 + "observation_len", len(item.observation), 410 500 ) 411 501 } 412 502 413 - if toolErr == nil { 503 + if item.err == nil { 414 504 if t, ok := e.registry.Get(tc.Name); ok { 415 505 if stopper, ok := t.(interface{ StopAfterSuccess() bool }); ok && stopper.StopAfterSuccess() { 416 - return &Final{Output: "", Plan: st.agentCtx.Plan}, st.agentCtx, nil 506 + earlyStop = true 417 507 } 418 508 } 419 509 } 420 510 421 - observationForModel := observation 422 - if toolErr == nil && isUntrustedTool(tc.Name) { 423 - observationForModel = wrapUntrustedToolObservation(tc.Name, observation) 511 + observationForModel := item.observation 512 + if item.err == nil && isUntrustedTool(tc.Name) { 513 + observationForModel = wrapUntrustedToolObservation(tc.Name, item.observation) 424 514 } 425 515 426 516 if strings.TrimSpace(tc.ID) != "" { ··· 436 526 } 437 527 } 438 528 439 - // If this step came from a stored pending tool call, clear it and move on. 440 529 st.pendingTool = nil 441 530 st.approvedPendingTool = false 531 + 532 + if earlyStop { 533 + return &Final{Output: "", Plan: st.agentCtx.Plan}, st.agentCtx, nil 534 + } 442 535 default: 443 536 log.Error("unexpected_response_type", "step", step, "type", resp.Type) 444 537 return nil, st.agentCtx, ErrParseFailure ··· 448 541 return e.forceConclusion(ctx, st.messages, st.model, st.scene, st.agentCtx, st.extraParams, st.onStream, log) 449 542 } 450 543 451 - func (e *Engine) executeToolWithGuard(ctx context.Context, st *engineLoopState, step int, assistantText string, tc *ToolCall, stepStart time.Time, remaining []ToolCall, assistantTextAdded bool) (string, error, *Final, bool) { 452 - var observation string 453 - var toolErr error 544 + // guardPreCheck runs the guard pre-tool decision serially. It returns: 545 + // - observation: non-empty if denied 546 + // - denied: true if the tool call was blocked 547 + // - pausedFinal: non-nil if approval is required (run should pause) 548 + // - paused: true if the run should pause for approval 549 + func (e *Engine) guardPreCheck(ctx context.Context, st *engineLoopState, step int, assistantText string, tc *ToolCall, remaining []ToolCall, assistantTextAdded bool) (observation string, denied bool, pausedFinal *Final, paused bool) { 550 + if _, found := e.registry.Get(tc.Name); !found { 551 + return fmt.Sprintf("Error: tool '%s' not found. Available tools: %s", tc.Name, e.registry.ToolNames()), true, nil, false 552 + } 454 553 455 - tool, found := e.registry.Get(tc.Name) 456 - if !found { 457 - observation = fmt.Sprintf("Error: tool '%s' not found. Available tools: %s", tc.Name, e.registry.ToolNames()) 458 - return observation, fmt.Errorf("tool not found"), nil, false 554 + if e.guard == nil || !e.guard.Enabled() { 555 + return "", false, nil, false 459 556 } 460 557 461 - // Guard pre-tool decision. 462 - if e.guard != nil && e.guard.Enabled() { 463 - gr, _ := e.guard.Evaluate(ctx, guard.Meta{RunID: st.runID, Step: step, Time: time.Now().UTC()}, guard.Action{ 558 + gr, _ := e.guard.Evaluate(ctx, guard.Meta{RunID: st.runID, Step: step, Time: time.Now().UTC()}, guard.Action{ 559 + Type: guard.ActionToolCallPre, 560 + ToolName: tc.Name, 561 + ToolParams: tc.Params, 562 + }) 563 + switch gr.Decision { 564 + case guard.DecisionDeny: 565 + return fmt.Sprintf("Error: blocked by guard (%s)", strings.Join(gr.Reasons, "; ")), true, nil, false 566 + case guard.DecisionRequireApproval: 567 + if st.approvedPendingTool { 568 + return "", false, nil, false 569 + } 570 + rs := resumeStateV1{ 571 + RunID: st.runID, 572 + Model: st.model, 573 + Scene: st.scene, 574 + Step: step, 575 + PlanRequired: st.planRequired, 576 + ParseFailures: st.parseFailures, 577 + Messages: st.messages, 578 + ExtraParams: st.extraParams, 579 + AgentCtx: snapshotFromContext(st.agentCtx), 580 + PendingTool: pendingToolSnapshot{ 581 + AssistantText: assistantText, 582 + AssistantTextAdded: assistantTextAdded, 583 + ToolCall: *tc, 584 + RemainingToolCalls: append([]ToolCall{}, remaining...), 585 + }, 586 + } 587 + b, err := marshalResumeState(rs) 588 + if err != nil { 589 + return fmt.Sprintf("Error: marshal resume state failed: %s", err.Error()), true, nil, false 590 + } 591 + sum := fmt.Sprintf("ToolCallPre tool=%s", tc.Name) 592 + id, err := e.guard.RequestApproval(ctx, guard.Meta{RunID: st.runID, Step: step, Time: time.Now().UTC()}, guard.Action{ 464 593 Type: guard.ActionToolCallPre, 465 594 ToolName: tc.Name, 466 595 ToolParams: tc.Params, 467 - }) 468 - switch gr.Decision { 469 - case guard.DecisionDeny: 470 - observation = fmt.Sprintf("Error: blocked by guard (%s)", strings.Join(gr.Reasons, "; ")) 471 - return observation, fmt.Errorf("blocked by guard"), nil, false 472 - case guard.DecisionRequireApproval: 473 - if st.approvedPendingTool { 474 - // Already approved; proceed. 475 - break 476 - } 477 - // Pause run and return a pending final. 478 - rs := resumeStateV1{ 479 - RunID: st.runID, 480 - Model: st.model, 481 - Scene: st.scene, 482 - Step: step, 483 - PlanRequired: st.planRequired, 484 - ParseFailures: st.parseFailures, 485 - Messages: st.messages, 486 - ExtraParams: st.extraParams, 487 - AgentCtx: snapshotFromContext(st.agentCtx), 488 - PendingTool: pendingToolSnapshot{ 489 - AssistantText: assistantText, 490 - AssistantTextAdded: assistantTextAdded, 491 - ToolCall: *tc, 492 - RemainingToolCalls: append([]ToolCall{}, remaining...), 493 - }, 494 - } 495 - b, err := marshalResumeState(rs) 496 - if err != nil { 497 - return "", err, nil, false 498 - } 499 - sum := fmt.Sprintf("ToolCallPre tool=%s", tc.Name) 500 - id, err := e.guard.RequestApproval(ctx, guard.Meta{RunID: st.runID, Step: step, Time: time.Now().UTC()}, guard.Action{ 501 - Type: guard.ActionToolCallPre, 502 - ToolName: tc.Name, 503 - ToolParams: tc.Params, 504 - }, gr, sum, b) 505 - if err != nil { 506 - observation = fmt.Sprintf("Error: approval request failed: %s", err.Error()) 507 - return observation, err, nil, false 508 - } 509 - final := &Final{ 510 - Output: PendingOutput{ 511 - Status: "pending", 512 - ApprovalRequestID: id, 513 - Message: fmt.Sprintf("Approval required to execute tool %q at step %d.", tc.Name, step), 514 - }, 515 - Plan: st.agentCtx.Plan, 516 - } 517 - return "", nil, final, true 596 + }, gr, sum, b) 597 + if err != nil { 598 + return fmt.Sprintf("Error: approval request failed: %s", err.Error()), true, nil, false 599 + } 600 + final := &Final{ 601 + Output: PendingOutput{ 602 + Status: "pending", 603 + ApprovalRequestID: id, 604 + Message: fmt.Sprintf("Approval required to execute tool %q at step %d.", tc.Name, step), 605 + }, 606 + Plan: st.agentCtx.Plan, 518 607 } 608 + return "", false, final, true 609 + } 610 + return "", false, nil, false 611 + } 612 + 613 + // executeTool runs the tool. Safe for concurrent use. 614 + func (e *Engine) executeTool(ctx context.Context, st *engineLoopState, tc *ToolCall) (string, error) { 615 + tool, found := e.registry.Get(tc.Name) 616 + if !found { 617 + return fmt.Sprintf("Error: tool '%s' not found. Available tools: %s", tc.Name, e.registry.ToolNames()), fmt.Errorf("tool not found") 519 618 } 520 619 521 620 toolCtx := ctx 522 621 if e.guard != nil && e.guard.Enabled() && strings.EqualFold(tc.Name, "url_fetch") { 523 - // Only enforce guard-level URL allowlists for unauthenticated url_fetch calls. 524 622 authProfile, _ := tc.Params["auth_profile"].(string) 525 623 if strings.TrimSpace(authProfile) == "" { 526 624 if p, ok := e.guard.NetworkPolicyForURLFetch(); ok && len(p.AllowedURLPrefixes) > 0 { ··· 529 627 } 530 628 } 531 629 532 - observation, toolErr = tool.Execute(toolCtx, tc.Params) 630 + observation, toolErr := tool.Execute(toolCtx, tc.Params) 533 631 if toolErr != nil { 534 632 if strings.TrimSpace(observation) == "" { 535 633 observation = fmt.Sprintf("error: %s", toolErr.Error()) ··· 537 635 observation = fmt.Sprintf("%s\n\nerror: %s", observation, toolErr.Error()) 538 636 } 539 637 } 638 + return observation, toolErr 639 + } 540 640 541 - // Guard post-tool redaction (runs even when toolErr != nil). 542 - if e.guard != nil && e.guard.Enabled() { 543 - gr, _ := e.guard.Evaluate(ctx, guard.Meta{RunID: st.runID, Step: step, Time: time.Now().UTC()}, guard.Action{ 544 - Type: guard.ActionToolCallPost, 545 - ToolName: tc.Name, 546 - ToolParams: tc.Params, 547 - Content: observation, 548 - }) 549 - switch gr.Decision { 550 - case guard.DecisionAllowWithRedact: 551 - if strings.TrimSpace(gr.RedactedContent) != "" { 552 - observation = gr.RedactedContent 553 - } 554 - case guard.DecisionDeny: 555 - observation = "Error: blocked by guard (tool output)" 556 - if toolErr == nil { 557 - toolErr = fmt.Errorf("blocked by guard") 558 - } 641 + // guardPostRedact applies guard post-tool redaction. Runs serially after concurrent execution. 642 + func (e *Engine) guardPostRedact(ctx context.Context, st *engineLoopState, step int, tc *ToolCall, observation string, toolErr error) (string, error) { 643 + if e.guard == nil || !e.guard.Enabled() { 644 + return observation, toolErr 645 + } 646 + gr, _ := e.guard.Evaluate(ctx, guard.Meta{RunID: st.runID, Step: step, Time: time.Now().UTC()}, guard.Action{ 647 + Type: guard.ActionToolCallPost, 648 + ToolName: tc.Name, 649 + ToolParams: tc.Params, 650 + Content: observation, 651 + }) 652 + switch gr.Decision { 653 + case guard.DecisionAllowWithRedact: 654 + if strings.TrimSpace(gr.RedactedContent) != "" { 655 + observation = gr.RedactedContent 656 + } 657 + case guard.DecisionDeny: 658 + observation = "Error: blocked by guard (tool output)" 659 + if toolErr == nil { 660 + toolErr = fmt.Errorf("blocked by guard") 559 661 } 560 662 } 561 - 562 - _ = stepStart 563 - return observation, toolErr, nil, false 663 + return observation, toolErr 564 664 } 565 665 566 666 func toolCallSignature(tc ToolCall) string {
+2
agent/limits.go
··· 13 13 ParseRetries int 14 14 MaxTokenBudget int 15 15 ToolRepeatLimit int 16 + SpawnEnabled bool 16 17 } 17 18 18 19 func (l Limits) ToConfig() Config { ··· 21 22 ParseRetries: l.ParseRetries, 22 23 MaxTokenBudget: l.MaxTokenBudget, 23 24 ToolRepeatLimit: l.ToolRepeatLimit, 25 + SpawnEnabled: l.SpawnEnabled, 24 26 } 25 27 } 26 28
+123
agent/spawn_tool.go
··· 1 + package agent 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "strings" 8 + 9 + "github.com/quailyquaily/mistermorph/tools" 10 + ) 11 + 12 + const spawnToolName = "spawn" 13 + 14 + type spawnTool struct { 15 + engine *Engine 16 + } 17 + 18 + func (t *spawnTool) Name() string { return spawnToolName } 19 + 20 + func (t *spawnTool) Description() string { 21 + return "Spawn a sub-agent to handle a self-contained sub-task. " + 22 + "The sub-agent runs with its own context and a restricted set of tools you specify. " + 23 + "This call blocks until the sub-agent completes and returns its final output. " + 24 + "Use this to parallelise independent work items." 25 + } 26 + 27 + func (t *spawnTool) ParameterSchema() string { 28 + s := map[string]any{ 29 + "type": "object", 30 + "properties": map[string]any{ 31 + "task": map[string]any{ 32 + "type": "string", 33 + "description": "Task prompt for the sub-agent.", 34 + }, 35 + "tools": map[string]any{ 36 + "type": "array", 37 + "items": map[string]any{"type": "string"}, 38 + "description": "Whitelist of tool names the sub-agent can use. Cannot include 'spawn'.", 39 + }, 40 + "model": map[string]any{ 41 + "type": "string", 42 + "description": "Optional model override for the sub-agent. Defaults to the parent's model.", 43 + }, 44 + }, 45 + "required": []string{"task", "tools"}, 46 + } 47 + b, _ := json.MarshalIndent(s, "", " ") 48 + return string(b) 49 + } 50 + 51 + func (t *spawnTool) Execute(ctx context.Context, params map[string]any) (string, error) { 52 + task, _ := params["task"].(string) 53 + task = strings.TrimSpace(task) 54 + if task == "" { 55 + return "", fmt.Errorf("missing required param: task") 56 + } 57 + 58 + rawTools, _ := params["tools"].([]any) 59 + if len(rawTools) == 0 { 60 + return "", fmt.Errorf("missing required param: tools (must be a non-empty array of tool names)") 61 + } 62 + 63 + subRegistry := tools.NewRegistry() 64 + for _, raw := range rawTools { 65 + name, ok := raw.(string) 66 + if !ok { 67 + continue 68 + } 69 + name = strings.TrimSpace(name) 70 + if name == "" || strings.EqualFold(name, spawnToolName) { 71 + continue 72 + } 73 + if tool, found := t.engine.registry.Get(name); found { 74 + subRegistry.Register(tool) 75 + } 76 + } 77 + if len(subRegistry.All()) == 0 { 78 + return "", fmt.Errorf("none of the requested tools are available in the parent registry") 79 + } 80 + 81 + model, _ := params["model"].(string) 82 + model = strings.TrimSpace(model) 83 + if model == "" { 84 + model = strings.TrimSpace(t.engine.config.DefaultModel) 85 + } 86 + 87 + client := t.engine.client 88 + var cleanup func() 89 + if t.engine.subClientFactory != nil { 90 + client, cleanup = t.engine.subClientFactory("spawn") 91 + } 92 + if cleanup != nil { 93 + defer cleanup() 94 + } 95 + 96 + subOpts := []Option{WithLogger(t.engine.log)} 97 + if t.engine.guard != nil { 98 + subOpts = append(subOpts, WithGuard(t.engine.guard)) 99 + } 100 + 101 + subEngine := New(client, subRegistry, Config{ 102 + MaxSteps: t.engine.config.MaxSteps, 103 + MaxTokenBudget: t.engine.config.MaxTokenBudget, 104 + ParseRetries: t.engine.config.ParseRetries, 105 + ToolRepeatLimit: t.engine.config.ToolRepeatLimit, 106 + DefaultModel: model, 107 + ToolCallTimeout: t.engine.config.ToolCallTimeout, 108 + }, t.engine.spec, subOpts...) 109 + 110 + final, _, err := subEngine.Run(ctx, task, RunOptions{Model: model}) 111 + if err != nil { 112 + return "", fmt.Errorf("sub-agent failed: %w", err) 113 + } 114 + if final == nil { 115 + return "{}", nil 116 + } 117 + 118 + b, err := json.Marshal(final.Output) 119 + if err != nil { 120 + return fmt.Sprintf("%v", final.Output), nil 121 + } 122 + return string(b), nil 123 + }
+2
assets/config/config.example.yaml
··· 482 482 max_token_budget: 0 483 483 # - tool_repeat_limit: maximum executions per tool name within one run. 484 484 tool_repeat_limit: 3 485 + # - spawn_enabled: enable the spawn tool to start sub-agents (default false). 486 + spawn_enabled: false 485 487 # Overall run timeout. 486 488 timeout: "10m" 487 489 # Base directory for local state (memory/skills/heartbeat).
+1
cmd/mistermorph/consolecmd/runtime_support.go
··· 302 302 ParseRetries: viper.GetInt("parse_retries"), 303 303 MaxTokenBudget: viper.GetInt("max_token_budget"), 304 304 ToolRepeatLimit: viper.GetInt("tool_repeat_limit"), 305 + SpawnEnabled: viper.GetBool("spawn_enabled"), 305 306 } 306 307 } 307 308
+45
cmd/mistermorph/runcmd/run.go
··· 210 210 } 211 211 } 212 212 213 + if promptInspector != nil || requestInspector != nil { 214 + opts = append(opts, agent.WithSubClientFactory(func(prefix string) (llm.Client, func()) { 215 + var pi *llminspect.PromptInspector 216 + var ri *llminspect.RequestInspector 217 + if promptInspector != nil { 218 + var err error 219 + pi, err = llminspect.NewPromptInspector(llminspect.Options{ 220 + Task: task, 221 + Prefix: prefix, 222 + }) 223 + if err != nil { 224 + logger.Warn("spawn_prompt_inspector_error", "error", err.Error()) 225 + } 226 + } 227 + if requestInspector != nil { 228 + var err error 229 + ri, err = llminspect.NewRequestInspector(llminspect.Options{ 230 + Task: task, 231 + Prefix: prefix, 232 + }) 233 + if err != nil { 234 + logger.Warn("spawn_request_inspector_error", "error", err.Error()) 235 + } 236 + } 237 + subClient := llminspect.WrapClient(client, llminspect.ClientOptions{ 238 + PromptInspector: pi, 239 + RequestInspector: ri, 240 + APIBase: mainCfg.Endpoint, 241 + Model: mainCfg.Model, 242 + }) 243 + cleanup := func() { 244 + if pi != nil { 245 + _ = pi.Close() 246 + } 247 + if ri != nil { 248 + _ = ri.Close() 249 + } 250 + } 251 + return subClient, cleanup 252 + })) 253 + } 254 + 213 255 engine := agent.New( 214 256 client, 215 257 reg, ··· 218 260 ParseRetries: configutil.FlagOrViperInt(cmd, "parse-retries", "parse_retries"), 219 261 MaxTokenBudget: configutil.FlagOrViperInt(cmd, "max-token-budget", "max_token_budget"), 220 262 ToolRepeatLimit: configutil.FlagOrViperInt(cmd, "tool-repeat-limit", "tool_repeat_limit"), 263 + SpawnEnabled: configutil.FlagOrViperBool(cmd, "spawn-enabled", "spawn_enabled"), 264 + DefaultModel: strings.TrimSpace(mainCfg.Model), 221 265 }, 222 266 promptSpec, 223 267 opts..., ··· 272 316 cmd.Flags().Int("parse-retries", 2, "Max JSON parse retries.") 273 317 cmd.Flags().Int("max-token-budget", 0, "Max cumulative token budget (0 disables).") 274 318 cmd.Flags().Int("tool-repeat-limit", 3, "Force final when the same successful tool call repeats this many times.") 319 + cmd.Flags().Bool("spawn-enabled", false, "Enable the spawn tool to start sub-agents.") 275 320 276 321 cmd.Flags().Duration("timeout", 10*time.Minute, "Overall timeout.") 277 322
+2 -1
go.mod
··· 1 1 module github.com/quailyquaily/mistermorph 2 2 3 - go 1.25 3 + go 1.25.0 4 4 5 5 require ( 6 6 github.com/google/uuid v1.6.0 ··· 16 16 github.com/yuin/goldmark v1.7.16 17 17 golang.org/x/crypto v0.47.0 18 18 golang.org/x/net v0.49.0 19 + golang.org/x/sync v0.20.0 19 20 golang.org/x/sys v0.40.0 20 21 golang.org/x/term v0.39.0 21 22 gopkg.in/yaml.v3 v3.0.1
+2
go.sum
··· 245 245 golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= 246 246 golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= 247 247 golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 248 + golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= 249 + golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 248 250 golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 249 251 golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 250 252 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+4
internal/channelopts/options.go
··· 96 96 ParseRetries: r.GetInt("parse_retries"), 97 97 MaxTokenBudget: r.GetInt("max_token_budget"), 98 98 ToolRepeatLimit: r.GetInt("tool_repeat_limit"), 99 + SpawnEnabled: r.GetBool("spawn_enabled"), 99 100 }, 100 101 FileCacheMaxAge: r.GetDuration("file_cache.max_age"), 101 102 FileCacheMaxFiles: r.GetInt("file_cache.max_files"), ··· 301 302 ParseRetries: r.GetInt("parse_retries"), 302 303 MaxTokenBudget: r.GetInt("max_token_budget"), 303 304 ToolRepeatLimit: r.GetInt("tool_repeat_limit"), 305 + SpawnEnabled: r.GetBool("spawn_enabled"), 304 306 }, 305 307 MemoryEnabled: r.GetBool("memory.enabled"), 306 308 MemoryShortTermDays: r.GetInt("memory.short_term_days"), ··· 495 497 ParseRetries: r.GetInt("parse_retries"), 496 498 MaxTokenBudget: r.GetInt("max_token_budget"), 497 499 ToolRepeatLimit: r.GetInt("tool_repeat_limit"), 500 + SpawnEnabled: r.GetBool("spawn_enabled"), 498 501 }, 499 502 MemoryEnabled: r.GetBool("memory.enabled"), 500 503 MemoryShortTermDays: r.GetInt("memory.short_term_days"), ··· 535 538 ParseRetries: r.GetInt("parse_retries"), 536 539 MaxTokenBudget: r.GetInt("max_token_budget"), 537 540 ToolRepeatLimit: r.GetInt("tool_repeat_limit"), 541 + SpawnEnabled: r.GetBool("spawn_enabled"), 538 542 }, 539 543 MemoryEnabled: r.GetBool("memory.enabled"), 540 544 MemoryShortTermDays: r.GetInt("memory.short_term_days"),
+4 -2
internal/channelruntime/taskruntime/runtime.go
··· 193 193 return RunResult{}, err 194 194 } 195 195 196 + agentCfg := rt.AgentConfig 197 + agentCfg.DefaultModel = model 198 + 196 199 engineOpts := []agent.Option{ 197 200 agent.WithLogger(logger), 198 201 agent.WithLogOptions(rt.LogOptions), ··· 203 206 if req.PlanStepUpdate != nil { 204 207 engineOpts = append(engineOpts, agent.WithPlanStepUpdate(req.PlanStepUpdate)) 205 208 } 206 - 207 209 engine := agent.New( 208 210 rt.MainClient, 209 211 reg, 210 - rt.AgentConfig, 212 + agentCfg, 211 213 promptSpec, 212 214 engineOpts..., 213 215 )
+12 -5
internal/llminspect/inspect.go
··· 21 21 Task string 22 22 TimestampFormat string 23 23 DumpDir string 24 + Prefix string 24 25 } 25 26 26 27 type PromptInspector struct { ··· 79 80 if err := os.MkdirAll(dumpDir, 0o755); err != nil { 80 81 return nil, fmt.Errorf("create dump dir: %w", err) 81 82 } 82 - path := filepath.Join(dumpDir, buildFilename("prompt", opts.Mode, startedAt, opts.TimestampFormat)) 83 + path := filepath.Join(dumpDir, buildFilename(opts.Prefix, "prompt", opts.Mode, startedAt, opts.TimestampFormat)) 83 84 file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) 84 85 if err != nil { 85 86 return nil, fmt.Errorf("open prompt dump file: %w", err) ··· 195 196 if err := os.MkdirAll(dumpDir, 0o755); err != nil { 196 197 return nil, fmt.Errorf("create dump dir: %w", err) 197 198 } 198 - path := filepath.Join(dumpDir, buildFilename("request", opts.Mode, startedAt, opts.TimestampFormat)) 199 + path := filepath.Join(dumpDir, buildFilename(opts.Prefix, "request", opts.Mode, startedAt, opts.TimestampFormat)) 199 200 file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) 200 201 if err != nil { 201 202 return nil, fmt.Errorf("open request dump file: %w", err) ··· 354 355 return "" 355 356 } 356 357 357 - func buildFilename(kind string, mode string, t time.Time, tsFormat string) string { 358 + func buildFilename(prefix, kind string, mode string, t time.Time, tsFormat string) string { 359 + prefix = strings.TrimSpace(prefix) 358 360 mode = strings.TrimSpace(mode) 359 361 if tsFormat == "" { 360 362 tsFormat = "20060102_1504" 361 363 } 362 364 ts := t.Format(tsFormat) 365 + 366 + name := kind 367 + if prefix != "" { 368 + name = prefix + "-" + kind 369 + } 363 370 if mode == "" { 364 - return fmt.Sprintf("%s_%s.md", kind, ts) 371 + return fmt.Sprintf("%s_%s.md", name, ts) 365 372 } 366 - return fmt.Sprintf("%s_%s_%s.md", kind, mode, ts) 373 + return fmt.Sprintf("%s_%s_%s.md", name, mode, ts) 367 374 } 368 375 369 376 func chainDebugFns(fns ...func(label, payload string)) func(label, payload string) {
+65
internal/llminspect/inspect_test.go
··· 6 6 "path/filepath" 7 7 "strings" 8 8 "testing" 9 + "time" 9 10 10 11 "github.com/quailyquaily/mistermorph/llm" 11 12 ) ··· 131 132 t.Fatalf("ReadFile(%q) error = %v", entries[0].Name(), err) 132 133 } 133 134 return string(data) 135 + } 136 + 137 + func TestBuildFilename_NoPrefix(t *testing.T) { 138 + ts := time.Date(2026, 3, 21, 14, 30, 0, 0, time.UTC) 139 + got := buildFilename("", "prompt", "", ts, "") 140 + if got != "prompt_20260321_1430.md" { 141 + t.Fatalf("got %q, want prompt_20260321_1430.md", got) 142 + } 143 + } 144 + 145 + func TestBuildFilename_WithPrefix(t *testing.T) { 146 + ts := time.Date(2026, 3, 21, 14, 30, 0, 0, time.UTC) 147 + got := buildFilename("spawn", "prompt", "", ts, "") 148 + if got != "spawn-prompt_20260321_1430.md" { 149 + t.Fatalf("got %q, want spawn-prompt_20260321_1430.md", got) 150 + } 151 + } 152 + 153 + func TestBuildFilename_WithPrefixAndMode(t *testing.T) { 154 + ts := time.Date(2026, 3, 21, 14, 30, 0, 0, time.UTC) 155 + got := buildFilename("spawn", "request", "telegram", ts, "") 156 + if got != "spawn-request_telegram_20260321_1430.md" { 157 + t.Fatalf("got %q, want spawn-request_telegram_20260321_1430.md", got) 158 + } 159 + } 160 + 161 + func TestPromptInspector_PrefixedFilename(t *testing.T) { 162 + dir := t.TempDir() 163 + inspector, err := NewPromptInspector(Options{DumpDir: dir, Task: "demo", Prefix: "spawn"}) 164 + if err != nil { 165 + t.Fatalf("NewPromptInspector() error = %v", err) 166 + } 167 + defer func() { _ = inspector.Close() }() 168 + 169 + entries, err := os.ReadDir(dir) 170 + if err != nil { 171 + t.Fatalf("ReadDir error = %v", err) 172 + } 173 + if len(entries) != 1 { 174 + t.Fatalf("expected 1 file, got %d", len(entries)) 175 + } 176 + if !strings.HasPrefix(entries[0].Name(), "spawn-prompt_") { 177 + t.Fatalf("filename %q should start with spawn-prompt_", entries[0].Name()) 178 + } 179 + } 180 + 181 + func TestRequestInspector_PrefixedFilename(t *testing.T) { 182 + dir := t.TempDir() 183 + inspector, err := NewRequestInspector(Options{DumpDir: dir, Task: "demo", Prefix: "spawn"}) 184 + if err != nil { 185 + t.Fatalf("NewRequestInspector() error = %v", err) 186 + } 187 + defer func() { _ = inspector.Close() }() 188 + 189 + entries, err := os.ReadDir(dir) 190 + if err != nil { 191 + t.Fatalf("ReadDir error = %v", err) 192 + } 193 + if len(entries) != 1 { 194 + t.Fatalf("expected 1 file, got %d", len(entries)) 195 + } 196 + if !strings.HasPrefix(entries[0].Name(), "spawn-request_") { 197 + t.Fatalf("filename %q should start with spawn-request_", entries[0].Name()) 198 + } 134 199 } 135 200 136 201 func mustContainAll(t *testing.T, text string, parts ...string) {
+2
internal/toolsutil/static_register.go
··· 17 17 BuiltinPlanCreate = "plan_create" 18 18 BuiltinTodoUpdate = "todo_update" 19 19 BuiltinContactsSend = "contacts_send" 20 + BuiltinSpawn = "spawn" 20 21 ) 21 22 22 23 var builtinToolNameSet = map[string]struct{}{ ··· 28 29 BuiltinPlanCreate: {}, 29 30 BuiltinTodoUpdate: {}, 30 31 BuiltinContactsSend: {}, 32 + BuiltinSpawn: {}, 31 33 } 32 34 33 35 type StaticRegistryConfig struct {