feat: native tool calling, end to end - #17
Merged
Merged
Conversation
All three providers reported `supportsFunctionCalling: true` while
implementing nothing, and `RespAct` ran ReAct purely by text prompting.
This makes the flag honest and gives the loop a native path.
Provider layer:
- `LLMCallOptions` gains `tools` and `toolChoice`; `ILanguageModel` gains
`chatWithTools`, returning text, tool calls, and a normalised finish
reason from one turn. `BaseLM` supplies a text-only default.
- Each provider translates declarations into its own request shape and
reads the calls back: OpenAI `tools`/`tool_calls`, Anthropic
`input_schema`/`tool_use`, Gemini `functionDeclarations`/`functionCall`.
- Fixes the silent role collapse in all three converters: `tool` and
`function` turns were downgraded to `user` text, and Anthropic could
then merge a tool result into the preceding user turn.
- Anthropic also dropped `tool_use` blocks on the floor and ignored
`input_json_delta` while streaming; both are now surfaced.
Module layer:
- `RespAct` uses the native path when the model supports it and tools are
declared, keeping the text-parsing loop as the fallback. Both paths run
the same tools and emit the same events.
- Tools may declare a JSON Schema or Zod schema for their arguments and
receive a validated object; bare functions keep working unchanged.
- Parallel tool calls in one turn are executed and reported individually.
- `forceTextMode` pins a tool-capable model to the text loop.
BREAKING: `ToolCall` is reshaped for cross-provider use — it was a copy
of OpenAI's encoding that no other provider could populate faithfully.
It is now `{ id?, name, arguments, rawArguments? }` with `arguments`
always a parsed object. The dead `ChatMessage.functionCall` is removed;
`ChatMessage` gains `toolCallId`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts: # packages/anthropic/src/anthropic-lm.test.ts # packages/anthropic/src/anthropic-lm.ts # packages/core/src/modules/respact.ts # packages/core/src/types/language-model.ts # packages/gemini/src/gemini-lm.ts # packages/openai/src/openai-lm.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
RespActimplemented ReAct purely by text prompting — regex-extractingAction:/Action Input:from raw completions and appendingObservation:strings to a growing conversation string. Every tool took exactly one string argument, there were no parallel tool calls, and the loop was at the mercy of the model formatting its output correctly.Meanwhile
ToolCallandChatMessage.toolCallswere declared and publicly exported from@ts-dspy/corewith zero readers anywhere in the repo, and all three providers reportedsupportsFunctionCalling: truewhile implementing nothing. This makes the flag honest.Provider layer
LLMCallOptionsgainstoolsandtoolChoice.ILanguageModelgainschatWithTools, which returns text, tool calls, and a normalised finish reason from one turn.BaseLMsupplies a text-only default, so the capability flag — not feature detection — is what callers branch on.tools: [{type:'function', function:{…}}]→message.tool_calls; Anthropictools: [{name, description, input_schema}]→ToolUseBlock; Geminiconfig.tools: [{functionDeclarations}]→Part.functionCall→Part.functionResponse.toolandfunctionturns were downgraded tousertext —toOpenAIMessageseven carried a comment saying the coreChatMessagehad notool_call_id. Anthropic could then merge a tool result into the preceding user turn, destroying the correlation the API needs.textOf()filters totype: 'text'blocks, sotool_useblocks were silently discarded; andchatStreamonly forwardedtext_delta, ignoringinput_json_delta.Module layer
RespActtakes the native path when the model supports it and tools are declared, keeping the text-parsing loop as the fallback for local models and providers without native tool calling. Both paths run the same tools, honour the same repeat guard, and emit the sameRespActEvents.{ description, function }keep working unchanged.forceTextMode: truepins a tool-capable model to the text loop.Breaking change
ToolCallis reshaped. It was a copy of OpenAI's encoding — a requiredid, atype: 'function'literal, and a nestedfunction.argumentsJSON string — which no other provider can populate faithfully (Anthropic sendsinputalready parsed, Gemini sendsargsalready parsed and has no id at all). It is now:The dead
ChatMessage.functionCallfield is removed;ChatMessagegainstoolCallId.Verification
npm run build,lint,typecheck,format:check,test(218 passing, up from 164), andverify:packagingall green. A throwaway consumer script droveRespActwith a multi-argument tool through the native path against the builtdist/, then flippedsupportsFunctionCalling: falseon the same fake and confirmed the text fallback completed the same task identically.packages/core/src/modules/respact.test.tsstays green unmodified — the text path is unchanged behaviour.Docs: section 06 of
site/docs.htmldocuments native vs. text-mode tool calling and typed arguments (and fixes a stalefn:key that should always have beenfunction:); the README "Tools" section gains typed arguments and the two-path explanation.🤖 Generated with Claude Code