fix: bound the tool-use loop to prevent runaway provider/tool cycles#69
fix: bound the tool-use loop to prevent runaway provider/tool cycles#69stealthwhizz wants to merge 2 commits into
Conversation
pi-agent-core's agent loop runs for as long as the assistant keeps emitting tool calls — it has no turn cap, and the maxTurns value gitagent configured was never consumed. A provider that re-requests the same tool call after every tool result (a broken or malicious backend) drives an unbounded loop: 100+ provider requests, duplicated tool results, and eventual timeout. Add a loop guard wired through the afterToolCall hook that terminates the run when either bound is hit: - maxTurns: total assistant turns that request tools (explicit option > manifest runtime.max_turns > default of 50) - maxRepeats: 5 consecutive identical tool calls (same name + args), the signature of a stuck loop The guard preserves the last tool's real output and appends the reason so the caller can see why the run stopped. Covered by unit tests for the normal path, repeat detection, per-batch turn counting, and output preservation. Closes open-gitagent#58
shreyas-lyzr
left a comment
There was a problem hiding this comment.
Solid fix for the unbounded loop issue (#58). The implementation is clean and the test coverage is thorough.
One minor observation: maxRepeats is clamped to Math.max(2, ...), meaning a caller passing maxRepeats: 1 gets silently bumped to 2. This is a reasonable guard but worth a comment noting that values below 2 are unsupported — not a blocker.
Everything else checks out: the afterToolCall hook is the correct termination path, the latch-once behavior ensures a mid-batch trip propagates correctly, per-batch turn counting via reference equality is the right approach, and the precedence order for maxTurns (explicit option → manifest → default) is sensible. All six test cases cover the meaningful edge cases.
There was a problem hiding this comment.
Pull request overview
Adds a safety guard to prevent unbounded tool-use loops when a provider repeatedly re-requests tools (DoS-class behavior described in #58), by enforcing a turn cap and wiring termination through the supported afterToolCall hook.
Changes:
- Adds a loop-guard unit test suite covering repeat detection, maxTurns, batching semantics, and output preservation.
- Wires
createLoopGuard({ maxTurns })intoquery()via the Agent’safterToolCallhook and resolvesmaxTurnsfrom (option → manifest → default).
Reviewed changes
Copilot reviewed 2 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| test/loop-guard.test.ts | New unit tests validating loop-guard termination behavior and output preservation. |
| src/sdk.ts | Resolves maxTurns and attaches the loop-guard afterToolCall hook to bound tool-use loops. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const DEFAULT_MAX_TURNS = 50; | ||
| const maxTurns = | ||
| options.maxTurns ?? loaded.manifest.runtime?.max_turns ?? DEFAULT_MAX_TURNS; | ||
| if (options.maxTurns !== undefined) { | ||
| modelOptions.maxTurns = options.maxTurns; | ||
| } |
| // 8. Create Agent | ||
| const loopGuard = createLoopGuard({ maxTurns }); | ||
| const agent = new Agent({ | ||
| initialState: { | ||
| systemPrompt, | ||
| model: loaded.model, | ||
| tools, | ||
| ...modelOptions, | ||
| }, | ||
| afterToolCall: loopGuard.afterToolCall, |
| tools, | ||
| ...modelOptions, | ||
| }, | ||
| afterToolCall: loopGuard.afterToolCall, |
…Turns - signatureOf: coalesce JSON.stringify(undefined) → "" so undefined args don't produce an undefined signature; fix the misleading NUL separator comment - terminating afterToolCall result now carries through result.details instead of dropping it - sdk: set modelOptions.maxTurns to the resolved cap (option → manifest → default) so agent state/telemetry reflects the limit actually enforced - test: assert details preservation on termination
shreyas-lyzr
left a comment
There was a problem hiding this comment.
Solid, targeted fix for the unbounded tool-use loop (issue #58).
What the PR does: Introduces a LoopGuard wired through pi-agent-core's afterToolCall hook — the library's supported termination path. When either bound is hit, the guard returns terminate: true on every result in the batch, which stops the loop cleanly. Two independent bounds:
maxTurns: total assistant turns that invoked tools. Defaults to 50, can be overridden bymanifest.runtime.max_turnsor an explicit query option.maxRepeats: 5 consecutive identical tool calls (same name + args), catching stuck loops well before any turn cap.
Implementation review:
loop-guard.ts: The latching pattern (trippedReason) is critical — without it, a mid-batch trip would only terminate some results in the batch, not all, so the loop wouldn't stop. The latch correctly propagates through the rest of that batch and all subsequent batches. Good.
Turn counting by assistantMessage object reference is the right mechanism here — all tool calls in one batch share the same reference, so only distinct batches increment the counter. The test that verifies this (shared assistantMessage reference = one turn) directly validates the behavior.
signatureOf() uses JSON.stringify with a fallback to String(args) — covers the undefined/non-serializable edge cases cleanly.
Output preservation: the guard prepends the real content and details from the tool result and appends only the stop-reason text. Callers that inspect tool results still get the real output.
sdk.ts: The maxTurns precedence chain (explicit option → manifest → 50) is correct and the value is reflected in modelOptions.maxTurns for telemetry/inspection. The guard itself does the actual enforcement.
Tests (loop-guard.test.ts): Covers all meaningful paths: normal use (no trip), repeat detection, counter reset on arg change, maxTurns cap, per-batch turn counting via shared reference, and output preservation on termination. Test coverage is thorough for the changed logic.
Problem
pi-agent-core's agent loop continues for as long as the assistant keeps emitting tool calls — there is no turn cap in the loop, and the
maxTurnsvalue gitagent sets on the Agent state is never consumed by the library.A provider that re-requests the same tool call after every tool result (a broken or malicious backend) drives an unbounded loop: 100+ provider requests, duplicated tool results, and eventual timeout. This is the DoS-class behavior in #58.
Root cause
runLoopin pi-agent-core loops onwhile (hasMoreToolCalls || pendingMessages.length > 0)withhasMoreToolCalls = !executedToolBatch.terminate. Nothing counts turns.grep maxTurns node_modules/@mariozechner/pi-agent-core→ no matches. gitagent'smodelOptions.maxTurnsis spread intoinitialStatebut never read.Fix
A loop guard (
src/loop-guard.ts) wired through theafterToolCallhook, which is the library's supported termination path (terminate: trueon every result in a batch stops the loop). It terminates the run when either bound is hit:query()option → agent manifestruntime.max_turns→ default50.The guard latches once tripped so a mid-batch trip still terminates the next batch, and it preserves the last tool's real output while appending the stop reason for observability.
Tests
test/loop-guard.test.ts— normal varied use (no trip), repeat detection, counter reset on arg change, maxTurns cap, per-batch turn counting (shared assistantMessage reference = one turn), and output preservation. Full suite: 33 passing.Closes #58