Unified Agent + reusable Go agent core.
0
fork

Configure Feed

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

fix: allow duplicate tool calls

Lyric f0c819a9 69611a07

+18 -65
+3 -3
agent/engine_concurrent_test.go
··· 450 450 {ID: "c1", Name: "search", Arguments: map[string]any{"q": "same"}}, 451 451 }}, 452 452 llm.Result{ToolCalls: []llm.ToolCall{ 453 - {ID: "c2", Name: "search", Arguments: map[string]any{"q": "same"}}, 453 + {ID: "c2", Name: "search", Arguments: map[string]any{"q": "again"}}, 454 454 }}, 455 455 finalResponse("done"), 456 456 ) 457 457 458 458 var startCount int 459 - e := New(client, reg, Config{MaxSteps: 5, ToolRepeatLimit: 10}, DefaultPromptSpec(), 459 + e := New(client, reg, Config{MaxSteps: 5, ToolRepeatLimit: 1}, DefaultPromptSpec(), 460 460 WithOnToolStart(func(_ *Context, toolName string) { 461 461 startCount++ 462 462 }), ··· 467 467 t.Fatalf("unexpected error: %v", err) 468 468 } 469 469 if startCount != 1 { 470 - t.Fatalf("onToolStart called %d times, want 1 (second call is duplicate)", startCount) 470 + t.Fatalf("onToolStart called %d times, want 1 (second call is repeat-limited)", startCount) 471 471 } 472 472 } 473 473
+5 -20
agent/engine_hooks_test.go
··· 300 300 } 301 301 } 302 302 303 - func TestToolCallDedup_BlocksNonConsecutiveDuplicateToolCalls(t *testing.T) { 303 + func TestToolCallRepeat_AllowsNonConsecutiveDuplicateToolCalls(t *testing.T) { 304 304 reg := baseRegistry() 305 305 searchCount := 0 306 306 otherCount := 0 ··· 337 337 if f == nil || f.Output != "forced" { 338 338 t.Fatalf("unexpected final output: %#v", f) 339 339 } 340 - if searchCount != 1 { 341 - t.Fatalf("search execute count = %d, want 1", searchCount) 340 + if searchCount != 2 { 341 + t.Fatalf("search execute count = %d, want 2", searchCount) 342 342 } 343 343 if otherCount != 1 { 344 344 t.Fatalf("other execute count = %d, want 1", otherCount) ··· 347 347 if len(calls) != 4 { 348 348 t.Fatalf("chat calls = %d, want 4", len(calls)) 349 349 } 350 - if !requestContains(calls, 3, "ERR_DUPLICATE_TOOL_CALL") { 351 - t.Fatal("expected ERR_DUPLICATE_TOOL_CALL in follow-up model request") 352 - } 353 350 } 354 351 355 352 func TestToolTracking_RebuildOnlyCountsSuccessfulSteps(t *testing.T) { ··· 369 366 }, 370 367 } 371 368 372 - counts, seen := rebuildToolTrackingFromSteps(steps) 369 + counts := rebuildToolTrackingFromSteps(steps) 373 370 if counts["search"] != 1 { 374 371 t.Fatalf("search count = %d, want 1", counts["search"]) 375 372 } ··· 379 376 if len(counts) != 2 { 380 377 t.Fatalf("counts size = %d, want 2", len(counts)) 381 378 } 382 - 383 - okSig := toolCallSignature(ToolCall{Name: "search", Params: map[string]any{"q": "ok"}}) 384 - failSig := toolCallSignature(ToolCall{Name: "search", Params: map[string]any{"q": "fail"}}) 385 - if !seen[okSig] { 386 - t.Fatal("expected successful signature to be tracked") 387 - } 388 - if seen[failSig] { 389 - t.Fatal("failed signature must not be tracked") 390 - } 391 379 } 392 380 393 - func TestToolTracking_FailedCallDoesNotConsumeLimitOrDedup(t *testing.T) { 381 + func TestToolTracking_FailedCallDoesNotConsumeLimit(t *testing.T) { 394 382 reg := baseRegistry() 395 383 st := &scriptedTool{ 396 384 name: "search", ··· 430 418 } 431 419 if requestContains(calls, 2, "ERR_TOOL_REPEAT_LIMIT") { 432 420 t.Fatal("failed first call should not trigger repeat-limit block on second call") 433 - } 434 - if requestContains(calls, 2, "ERR_DUPLICATE_TOOL_CALL") { 435 - t.Fatal("failed first call should not trigger dedupe block on second call") 436 421 } 437 422 } 438 423
+10 -42
agent/engine_loop.go
··· 39 39 40 40 // Run-local tool tracking caches. They are rebuilt from successful historical 41 41 // steps when a run starts/resumes, and never persisted in resume state. 42 - toolRunCounts map[string]int 43 - seenToolCallSignatures map[string]bool 42 + toolRunCounts map[string]int 44 43 } 45 44 46 45 func newRunID() string { return fmt.Sprintf("%x", rand.Uint64()) } ··· 50 49 return nil, nil, fmt.Errorf("nil engine state") 51 50 } 52 51 if st.toolRunCounts == nil { 53 - st.toolRunCounts, st.seenToolCallSignatures = rebuildToolTrackingFromSteps(st.agentCtx.Steps) 54 - } 55 - if st.seenToolCallSignatures == nil { 56 - st.seenToolCallSignatures = make(map[string]bool) 52 + st.toolRunCounts = rebuildToolTrackingFromSteps(st.agentCtx.Steps) 57 53 } 58 54 log := st.log 59 55 if log == nil { ··· 292 288 assistantTextAdded = true 293 289 } 294 290 295 - // --- Phase 1: serial pre-check (dedup, repeat limit, guard) --- 291 + // --- Phase 1: serial pre-check (repeat limit, guard) --- 296 292 type toolExecItem struct { 297 293 tc ToolCall 298 - sig string 299 294 toolNameKey string 300 295 skip bool 301 296 observation string ··· 311 306 312 307 for i := range toolCalls { 313 308 tc := toolCalls[i] 314 - sig := toolCallSignature(tc) 315 309 toolNameKey := normalizedToolName(tc.Name) 316 - items[i] = toolExecItem{tc: tc, sig: sig, toolNameKey: toolNameKey, stepStart: time.Now()} 310 + items[i] = toolExecItem{tc: tc, toolNameKey: toolNameKey, stepStart: time.Now()} 317 311 318 312 debugMode := log.Enabled(ctx, slog.LevelDebug) 319 313 fields := []any{"step", step, "tool", tc.Name, "args", toolArgsSummary(tc.Name, tc.Params, e.logOpts, debugMode)} ··· 342 336 } 343 337 344 338 switch { 345 - case sig != "" && st.seenToolCallSignatures[sig]: 346 - items[i].observation = duplicateToolCallObservation(tc.Name) 347 - items[i].err = fmt.Errorf("duplicate tool call blocked") 348 - items[i].skip = true 349 339 case e.config.ToolRepeatLimit > 0 && toolNameKey != "" && st.toolRunCounts[toolNameKey] >= e.config.ToolRepeatLimit: 350 340 items[i].observation = toolRepeatLimitObservation(tc.Name, e.config.ToolRepeatLimit) 351 341 items[i].err = fmt.Errorf("tool repeat limit reached") ··· 362 352 items[i].err = fmt.Errorf("blocked by guard") 363 353 items[i].skip = true 364 354 } else if !paused { 365 - // Reserve signature/count so later items in this batch 366 - // are correctly deduped and repeat-limited. 367 - if sig != "" { 368 - st.seenToolCallSignatures[sig] = true 369 - } 355 + // Reserve the count so later items in this batch are repeat-limited correctly. 370 356 if toolNameKey != "" { 371 357 st.toolRunCounts[toolNameKey] = st.toolRunCounts[toolNameKey] + 1 372 358 } ··· 440 426 if item.toolNameKey != "" && st.toolRunCounts[item.toolNameKey] > 0 { 441 427 st.toolRunCounts[item.toolNameKey] = st.toolRunCounts[item.toolNameKey] - 1 442 428 } 443 - if item.sig != "" { 444 - delete(st.seenToolCallSignatures, item.sig) 445 - } 446 429 } 447 430 448 431 st.agentCtx.RecordStep(Step{ ··· 751 734 return strings.TrimSpace(err.Error()) 752 735 } 753 736 754 - func duplicateToolCallObservation(toolName string) string { 755 - payload := map[string]any{ 756 - "error_code": "ERR_DUPLICATE_TOOL_CALL", 757 - "message": "Duplicate tool call with the same parameters is blocked in this run.", 758 - "tool": strings.TrimSpace(toolName), 759 - } 760 - b, _ := json.Marshal(payload) 761 - return string(b) 762 - } 763 - 764 737 func toolRepeatLimitObservation(toolName string, limit int) string { 765 738 payload := map[string]any{ 766 739 "error_code": "ERR_TOOL_REPEAT_LIMIT", ··· 772 745 return string(b) 773 746 } 774 747 775 - // rebuildToolTrackingFromSteps reconstructs dedupe/repeat tracking from the 776 - // persisted step history. Only successful executions are counted; blocked or 777 - // failed steps (Error != nil) are intentionally ignored. 778 - func rebuildToolTrackingFromSteps(steps []Step) (map[string]int, map[string]bool) { 748 + // rebuildToolTrackingFromSteps reconstructs repeat tracking from the persisted 749 + // step history. Only successful executions are counted; blocked or failed 750 + // steps (Error != nil) are intentionally ignored. 751 + func rebuildToolTrackingFromSteps(steps []Step) map[string]int { 779 752 counts := make(map[string]int) 780 - seen := make(map[string]bool) 781 753 for _, s := range steps { 782 754 if s.Error != nil { 783 755 continue ··· 786 758 if name != "" { 787 759 counts[name] = counts[name] + 1 788 760 } 789 - sig := toolCallSignature(ToolCall{Name: s.Action, Params: s.ActionInput}) 790 - if sig != "" { 791 - seen[sig] = true 792 - } 793 761 } 794 - return counts, seen 762 + return counts 795 763 } 796 764 797 765 func parsePlanCreateObservation(observation string) *Plan {