Skip to content

feat: multi-cycle agentic tool calling#11

Merged
chatman-media merged 1 commit into
mainfrom
claude/magical-yalow-f3731d
May 17, 2026
Merged

feat: multi-cycle agentic tool calling#11
chatman-media merged 1 commit into
mainfrom
claude/magical-yalow-f3731d

Conversation

@chatman-media

@chatman-media chatman-media commented May 17, 2026

Copy link
Copy Markdown
Owner

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

  • New src/tool-loop.tsrunToolLoop():
    • Bounded for loop; terminates when the model returns text or maxCycles is hit.
    • Executes every tool call in a cycle in parallel via Promise.allSettled — one assistant message carrying all tool_calls, one role:"tool" message per call id (OpenAI requires every id to be matched).
    • Unknown tool names and thrown execute() errors are fed back to the model as { error } results — the loop never throws on tool failure, letting the model recover.
    • On exhaustion, the caller forces a final answer without tools.
  • answerWithRaganswerFromHits() now uses runToolLoop; 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.
  • New AnswerInput.maxToolCycles (default 4 — caps at 5 LLM calls including the forced final answer).
  • New AnswerTelemetry.toolCalls[] — full per-cycle trace (name, args, result, error, cycle). toolCall retained as a @deprecated alias (first call) for backward compatibility — no breaking change.

Reviewer notes

  • feat: scoped — semver minor via semantic-release. No breaking API/telemetry change.
  • New 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.
  • Known follow-up (out of scope): messages grows 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 — clean
  • bun run check (biome) — clean
  • bun run build — succeeds

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Multi-cycle agentic tool calling is now available, supporting parallel tool execution per cycle with a configurable cycle limit.
    • Added maxToolCycles parameter to control the maximum number of agentic tool-calling iterations.
    • Enhanced telemetry now tracks all tool calls across cycles with complete execution details.
    • Tool functionality works consistently in both answerWithRag and answerWithRagStream.
  • Documentation

    • Updated tool-calling behavior documentation to reflect multi-cycle agentic capabilities.

Review Change Stack

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>
@coderabbitai

coderabbitai Bot commented May 17, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This 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 maxToolCycles is reached. The new tool loop is integrated into both synchronous (answerFromHits) and streaming (answerWithRagStream) answer paths with comprehensive telemetry tracking.

Changes

Agentic Tool Loop Feature

Layer / File(s) Summary
Tool Loop Types and Answer Contract
src/tool-loop.ts, src/answer-types.ts
Tool loop public API introduces DEFAULT_MAX_TOOL_CYCLES, ToolCallRecord, and ToolLoopResult types. AnswerInput.tools and AnswerInput.maxToolCycles enable multi-cycle agentic calling. AnswerTelemetry deprecates single toolCall and adds toolCalls array to record all executed tool calls across cycles with name, args, result, error flag, and cycle index.
Tool Loop Implementation
src/tool-loop.ts
runToolLoop implements the agentic loop: calls chat.completeWithTools, executes requested tools in parallel per cycle, feeds results back via assistant/tool messages, continues until model stops requesting tools or maxCycles is exhausted. Returns content, full tool-call trace, and exhaustion flag. buildToolTelemetry maps call records into telemetry shape.
Tool Loop Tests
test/tool-loop.test.ts
Validates single-cycle execution, multi-cycle chaining, parallel tool calls with matching messages, unknown-tool error propagation with continuation, thrown-tool error handling, and exhaustion behavior. Tests buildToolTelemetry correctness for empty and non-empty traces.
Integration into Answer Functions
src/answer.ts
answerFromHits and answerWithRagStream import and use runToolLoop. Tool execution is now a blocking agentic loop before final answer generation; telemetry always includes buildToolTelemetry(toolCallRecords). Streaming documentation clarifies that tools run as blocking prefix before token streaming.
Documentation and Roadmap Updates
src/tools.ts, README.md
JSDoc for answerWithRag describes agentic multi-cycle behavior with parallel execution and maxToolCycles termination. README roadmap across English, Russian, and Chinese marks multi-cycle tool calling as completed feature with parallel execution and cycle-count bounds.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 Hops through cycles, gathering tool calls with glee,
Each parallel dance feeds results back with spree,
Max cycles bounded, agentic and free,
From single to multi—watch the loop skip and flee! 🎯

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: multi-cycle agentic tool calling' clearly and concisely summarizes the main change: upgrading from single-cycle to multi-cycle agentic tool calling with bounded iterations.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/magical-yalow-f3731d

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

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/tools.ts (1)

25-25: ⚡ Quick win

Update 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 is toolCalls[] (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

📥 Commits

Reviewing files that changed from the base of the PR and between bb76cc4 and 6925261.

📒 Files selected for processing (6)
  • README.md
  • src/answer-types.ts
  • src/answer.ts
  • src/tool-loop.ts
  • src/tools.ts
  • test/tool-loop.test.ts

Comment thread src/answer.ts
Comment on lines +141 to +142
maxCycles: input.maxToolCycles ?? DEFAULT_MAX_TOOL_CYCLES,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

Comment thread src/tool-loop.ts
Comment on lines +81 to +94
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 });
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

@chatman-media chatman-media merged commit f3f3441 into main May 17, 2026
4 checks passed
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 1.3.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant