feat: multi-cycle agentic tool calling#11
Conversation
Replace single-cycle tool execution with a bounded agentic loop. The model can now request tools repeatedly — seeing each result and deciding what to do next — until it produces a final answer or maxToolCycles is reached. - New runToolLoop() in src/tool-loop.ts: executes every tool call in a cycle in parallel, feeds unknown-tool and execute() errors back to the model so it can recover, never throws on tool failure. - answerWithRag and answerWithRagStream both support multi-cycle tools; for streaming the loop is a blocking prefix before the final answer. - New AnswerInput.maxToolCycles (default 4) and AnswerTelemetry.toolCalls[]; toolCall retained as deprecated alias for backward compatibility. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
📝 WalkthroughWalkthroughThis PR introduces agentic multi-cycle tool calling, replacing single-cycle tool execution with iterative loops that execute tools in parallel per cycle, feed results back to the model, and continue until a final answer or ChangesAgentic Tool Loop Feature
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint skipped: no ESLint configuration detected in root package.json. To enable, add Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
src/tools.ts (1)
25-25: ⚡ Quick winUpdate the example to reference the new primary telemetry field.
The comment references
result.telemetry.toolCall(singular), which is the deprecated backward-compatibility alias. According to the PR objectives, the new primary field istoolCalls[](plural array). Update the example to guide users toward the new API.📝 Proposed fix to reference the new primary field
- * const result = await answerWithRag({ question, kb, chat, embedder, tools: [slotsTool] }); - * // result.telemetry.toolCall — name + result if a tool was invoked + * const result = await answerWithRag({ question, kb, chat, embedder, tools: [slotsTool] }); + * // result.telemetry.toolCalls[] — array of tool calls across all cycles🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/tools.ts` at line 25, The example comment references the deprecated singular alias result.telemetry.toolCall; update the example to use the new primary field result.telemetry.toolCalls (an array) instead, adjusting wording to reflect that it may contain multiple entries and showing how to access the latest or iterate over toolCalls; locate the example that mentions result.telemetry.toolCall and replace it with result.telemetry.toolCalls and corresponding plural usage.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/answer.ts`:
- Around line 141-142: Clamp user-provided maxToolCycles before passing into the
loop: replace uses of input.maxToolCycles ?? DEFAULT_MAX_TOOL_CYCLES with
Math.min(input.maxToolCycles ?? DEFAULT_MAX_TOOL_CYCLES, MAX_TOOL_CYCLES_CAP)
(or define a DOCUMENTED_MAX_TOOL_CYCLES_CAP constant) so the value never exceeds
the documented cap; apply this same clamp at the other call site that currently
passes input.maxToolCycles directly so both invocations enforce the cap.
In `@src/tool-loop.ts`:
- Around line 81-94: The tool-result JSON serialization can throw for
non-serializable payloads and must be guarded so tool failures never throw; wrap
the JSON.stringify call used when pushing messages ({ role: "tool", content:
JSON.stringify(payload), tool_call_id: tc.id }) in a safe-serialize routine: try
to JSON.stringify(payload) and on failure fall back to a stable representation
(e.g., String(payload) or a util-inspect-like safe string, include an indicator
like "[unserializable]" plus the error message), and ensure toolCalls.push still
stores the original payload/result and error flag (variables: payload, isError,
toolCalls, messages, tc, outcome) so the loop remains non-throwing.
---
Nitpick comments:
In `@src/tools.ts`:
- Line 25: The example comment references the deprecated singular alias
result.telemetry.toolCall; update the example to use the new primary field
result.telemetry.toolCalls (an array) instead, adjusting wording to reflect that
it may contain multiple entries and showing how to access the latest or iterate
over toolCalls; locate the example that mentions result.telemetry.toolCall and
replace it with result.telemetry.toolCalls and corresponding plural usage.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: f2af5af2-f2c1-4eeb-919f-ea6163d819d7
📒 Files selected for processing (6)
README.mdsrc/answer-types.tssrc/answer.tssrc/tool-loop.tssrc/tools.tstest/tool-loop.test.ts
| maxCycles: input.maxToolCycles ?? DEFAULT_MAX_TOOL_CYCLES, | ||
| }); |
There was a problem hiding this comment.
Enforce the documented maxToolCycles cap before calling the loop.
Line 141 and Line 421 pass user-provided maxToolCycles directly, so large values bypass the documented cap and can cause unexpectedly long/costly runs.
Proposed fix
- maxCycles: input.maxToolCycles ?? DEFAULT_MAX_TOOL_CYCLES,
+ maxCycles: Math.min(
+ Math.max(0, Math.trunc(input.maxToolCycles ?? DEFAULT_MAX_TOOL_CYCLES)),
+ DEFAULT_MAX_TOOL_CYCLES,
+ ),Apply the same clamp at both call sites.
Also applies to: 421-422
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/answer.ts` around lines 141 - 142, Clamp user-provided maxToolCycles
before passing into the loop: replace uses of input.maxToolCycles ??
DEFAULT_MAX_TOOL_CYCLES with Math.min(input.maxToolCycles ??
DEFAULT_MAX_TOOL_CYCLES, MAX_TOOL_CYCLES_CAP) (or define a
DOCUMENTED_MAX_TOOL_CYCLES_CAP constant) so the value never exceeds the
documented cap; apply this same clamp at the other call site that currently
passes input.maxToolCycles directly so both invocations enforce the cap.
| let payload: unknown; | ||
| let isError = false; | ||
| if (outcome.status === "fulfilled") { | ||
| payload = outcome.value; | ||
| } else { | ||
| isError = true; | ||
| const message = | ||
| outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason); | ||
| payload = { error: message }; | ||
| console.warn(`[tool-loop] tool "${tc.name}" failed: ${message}`); | ||
| } | ||
| toolCalls.push({ name: tc.name, args: tc.args, result: payload, error: isError, cycle }); | ||
| messages.push({ role: "tool", content: JSON.stringify(payload), tool_call_id: tc.id }); | ||
| } |
There was a problem hiding this comment.
Guard tool-result serialization so the loop stays non-throwing.
Line 93 can still throw if a tool returns a non-serializable value, which breaks the “tool failures don’t throw” contract and aborts the request path.
Proposed fix
let payload: unknown;
let isError = false;
if (outcome.status === "fulfilled") {
payload = outcome.value;
} else {
isError = true;
const message =
outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason);
payload = { error: message };
console.warn(`[tool-loop] tool "${tc.name}" failed: ${message}`);
}
toolCalls.push({ name: tc.name, args: tc.args, result: payload, error: isError, cycle });
- messages.push({ role: "tool", content: JSON.stringify(payload), tool_call_id: tc.id });
+ let toolContent: string;
+ try {
+ toolContent = JSON.stringify(payload ?? null);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ isError = true;
+ payload = { error: `non-serializable tool result: ${message}` };
+ toolCalls[toolCalls.length - 1] = { name: tc.name, args: tc.args, result: payload, error: true, cycle };
+ toolContent = JSON.stringify(payload);
+ }
+ messages.push({ role: "tool", content: toolContent, tool_call_id: tc.id });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/tool-loop.ts` around lines 81 - 94, The tool-result JSON serialization
can throw for non-serializable payloads and must be guarded so tool failures
never throw; wrap the JSON.stringify call used when pushing messages ({ role:
"tool", content: JSON.stringify(payload), tool_call_id: tc.id }) in a
safe-serialize routine: try to JSON.stringify(payload) and on failure fall back
to a stable representation (e.g., String(payload) or a util-inspect-like safe
string, include an indicator like "[unserializable]" plus the error message),
and ensure toolCalls.push still stores the original payload/result and error
flag (variables: payload, isError, toolCalls, messages, tc, outcome) so the loop
remains non-throwing.
|
🎉 This PR is included in version 1.3.0 🎉 The release is available on: Your semantic-release bot 📦🚀 |
Summary
Upgrades tool calling from single-cycle (shipped in v1.1.0) to a multi-cycle agentic loop. Previously only the first tool call was executed and control fell through to a plain
complete()without tools — the model never got to call another tool. Now the model can request tools repeatedly, see each result, and decide what to do next until it produces a final answer.What changed
src/tool-loop.ts—runToolLoop():forloop; terminates when the model returns text ormaxCyclesis hit.Promise.allSettled— one assistant message carrying alltool_calls, onerole:"tool"message per call id (OpenAI requires every id to be matched).execute()errors are fed back to the model as{ error }results — the loop never throws on tool failure, letting the model recover.answerWithRag—answerFromHits()now usesrunToolLoop; integrates with structured output (tool cycles run first, then the schema-honoring final pass).answerWithRagStream— gains tool support for the first time. The tool loop runs as a blocking prefix; only the final answer streams.AnswerInput.maxToolCycles(default4— caps at 5 LLM calls including the forced final answer).AnswerTelemetry.toolCalls[]— full per-cycle trace (name,args,result,error,cycle).toolCallretained as a@deprecatedalias (first call) for backward compatibility — no breaking change.Reviewer notes
feat:scoped — semver minor via semantic-release. No breaking API/telemetry change.test/tool-loop.test.ts(8 tests) — first tool-calling test coverage in the repo: single/multi-cycle, parallel calls, unknown tool, throwing tool, exhaustion,buildToolTelemetry.messagesgrows each cycle with tool-result JSON — large tool outputs could balloon context; truncation is a future task.Test plan
bun test— 156 pass (8 new)bun run typecheck— cleanbun run check(biome) — cleanbun run build— succeeds🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
maxToolCyclesparameter to control the maximum number of agentic tool-calling iterations.answerWithRagandanswerWithRagStream.Documentation