Tool interop flow
This page explains how tool interop works across core, providers, provider extensions, and tool plugins.
It focuses on three related but distinct things:
- tool schema interop
- tool call interop
- tool result message flow
The short version is:
- schemas are adapted at request injection time
- tool calls are interpreted when responses are converted back into core
- tool results are not adapter-based today; they flow through normal core-message <-> provider-native message conversion
If you are writing a provider or provider extension, this page explains where conversions belong and which runtime data you should use.
Core concepts
Schema
A tool schema is the provider-facing declaration of a tool.
Examples:
- OpenAI chat-completions function schema
- OpenAI Responses function schema
- OpenAI Responses custom/freeform schema
Tool call
A tool call is the provider response object that says "invoke this tool with this payload".
Examples:
- OpenAI chat
tool_calls[*] - OpenAI Responses
function_call - OpenAI Responses
custom_tool_call
Tool result
A tool result is the output produced by the tool after core executes it.
Today, tool results are represented as normal core role=="tool" messages.
There is currently no separate tool-result interop adapter registry.
That means result conversion is handled by normal provider or provider-extension
message conversion code, not by ToolInteropRegistry.
For ordinary text tools, format_tool_result(...) returns a string and that
string becomes the core tool message content. For provider-specific
multimodal output, format_tool_result(...) may return an explicit
provider-native envelope:
{
"type": "provider_native_tool_result",
"format": "openai.chat_completions",
"content": [
{"type": "text", "text": "<image path=\"/tmp/image.png\">"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}},
{"type": "text", "text": "</image>"},
],
}
Only explicit envelopes are preserved as structured content. Ordinary dict/list formatter outputs are compatibility-stringified by the default tool adapter.
Runtime ownership
Tool interop is owned by core for the active request.
Core builds the effective registry from:
- explicit config contributions
- provider contributions
- provider-extension contributions
- tool contributions
- built-in defaults
Core also computes the accepted target schema formats for the active provider
stack from accepted_tool_schema_formats(...).
Core passes both pieces of runtime data through request context:
context["tool_interop"]["registry"]
context["tool_interop"]["schema_target_formats"]
Provider and provider-extension code should prefer those values instead of
using DEFAULT_TOOL_INTEROP_REGISTRY.
DEFAULT_TOOL_INTEROP_REGISTRY is fallback-only and should be treated as a
last resort for non-request-bound utility paths.
End-to-end flow
1. Tool plugins emit source schemas
Tool plugins expose schemas via get_tool_schemas(...).
Those schemas may already be in the provider's native format, or they may be in some other supported source format.
Examples:
- a chat-function schema for OpenAI-compatible providers
- a Responses custom schema for a freeform tool
- a tool-specific custom schema format contributed by the tool plugin
At this stage, schemas are not globally rewritten by core.
Core keeps the original source schemas and records tool descriptors from them.
2. Core builds the effective tool interop registry
Before request execution, core composes the active registry and determines the accepted target schema formats for the current provider and enabled extensions.
This is the source of truth for request-time adaptation.
3. Core passes interop runtime data into request context
During initialize_request(...) and finalize(...), providers, provider
extensions, and features receive:
context = {
"core": ...,
"config": ...,
"tool_interop": {
"registry": ...,
"schema_target_formats": [...],
},
...
}
Use this runtime context when adapting schemas or sanitizing calls.
Where schema conversion happens
Schema conversion belongs in provider or provider-extension request injection.
In practice, this means initialize_request(...).
That code should:
- read the source schemas from state/config
- fetch the active registry from
context["tool_interop"]["registry"] - fetch target formats from
context["tool_interop"]["schema_target_formats"] - call
registry.convert_schemas(...) - inject the converted schemas into the request payload
Example pattern:
from agent_core.tool_interop import (
ToolInteropTarget,
get_registry_from_context,
get_schema_target_formats_from_context,
)
def initialize_request(self, native_messages, state, *, context=None):
request = state.get("request") or {}
payload = dict(request.get("payload") or {})
tools = state.get("_tools") or []
if tools and "tools" not in payload:
registry = get_registry_from_context(context)
target_formats = get_schema_target_formats_from_context(context)
payload["tools"] = registry.convert_schemas(
[tool for tool in tools if isinstance(tool, dict)],
target=ToolInteropTarget("openai.chat_completions"),
target_formats=target_formats,
)
payload["tool_choice"] = payload.get("tool_choice", "auto")
return native_messages, {**state, "request": {**request, "payload": payload}}
Why conversion happens here
This is the point where the active provider stack is known.
Only the provider and enabled extensions know:
- which request wire format is being built
- which schema formats are accepted natively
- which fallback behavior should be used when extensions are absent
Core deliberately does not pre-convert every schema into one canonical wire
format before initialize_request(...).
End-to-end example: tool schema flow
This example shows a freeform apply_patch-style tool whose source schema is a
Responses custom schema, while the active provider stack expects OpenAI chat
function schemas.
Source schema emitted by the tool plugin
This is what get_tool_schemas(...) returns:
{
"type": "custom",
"name": "apply_patch",
"description": "Apply a textual patch to files in the workspace.",
"format": {
"type": "grammar",
"syntax": "lark",
"definition": "start: /.+/"
}
}
What core keeps before request injection
Core stores this original schema in the tool descriptor. It does not rewrite it yet.
Conceptually:
ToolDescriptor(
plugin_name="apply_patch",
tool_name="apply_patch",
schema={
"type": "custom",
"name": "apply_patch",
"description": "Apply a textual patch to files in the workspace.",
"format": {
"type": "grammar",
"syntax": "lark",
"definition": "start: /.+/",
},
},
)
Runtime context passed into request injection
When the request is prepared, core passes the active registry and accepted target formats through context:
context = {
"tool_interop": {
"registry": registry,
"schema_target_formats": ["openai.chat_completions.function"],
},
...
}
Where conversion happens
Conversion happens in provider or provider-extension initialize_request(...).
Example:
payload["tools"] = registry.convert_schemas(
[source_schema],
target=ToolInteropTarget("openai.chat_completions"),
target_formats=["openai.chat_completions.function"],
)
Converted schema sent to the provider
The converted request payload may look like:
{
"model": "gpt-4.1",
"tools": [
{
"type": "function",
"function": {
"name": "apply_patch",
"description": "Apply a textual patch to files in the workspace.",
"parameters": {
"type": "object",
"properties": {
"input": {
"type": "string",
"description": "Patch text."
}
},
"required": ["input"]
}
}
}
],
"tool_choice": "auto"
}
What is stored after request injection
Core does not replace the tool plugin's source schema everywhere with the converted one.
The important distinction is:
- source schema remains the tool's authored schema
- converted schema is what gets injected into the provider request
Why this matters
This lets one tool schema source participate in multiple provider stacks:
- chat-function targets
- Responses function targets
- Responses custom/freeform targets
without forcing the tool plugin itself to guess the active wire format.
Where tool call conversion happens
Tool call conversion happens when provider-native responses are translated back into core messages or tool-loop inputs.
Typical places:
- provider
from_native_messages(...) - provider-extension
from_native_messages(...) - provider helpers that build assistant metadata tool-call entries
The active registry inspects or converts provider-native tool call objects so core can work with:
- tool name
- call id
- payload
- payload kind
- payload format
- payload metadata
Examples:
- Responses
function_call-> chat-style function tool call - Responses
custom_tool_call-> preserved raw-input tool call object
Accessors vs adapters
Accessors inspect a schema or call and tell core what it means.
Adapters translate one format into another.
Use:
registry.inspect_schema(...)andregistry.inspect_call(...)when you need semantic information from an existing objectregistry.convert_schemas(...)andregistry.convert_tool_calls(...)when you need a different wire format
End-to-end example: tool call flow
This example shows a Responses function call emitted by the provider and then converted into the core-visible tool call metadata shape.
Provider-native tool call received from the model
The provider may receive something like:
{
"type": "function_call",
"call_id": "call_123",
"name": "read_file",
"arguments": "{\"path\":\"README.md\"}"
}
Where the call is interpreted
This happens in provider or provider-extension native-to-core response
conversion, typically inside from_native_messages(...) or a helper called by
it.
There are two common paths:
- inspect the provider-native object and build a core tool-call object
- convert the provider-native object into another supported wire format first
Example conversion call:
chat_tool_calls = registry.convert_tool_calls(
[native_item],
target=ToolInteropTarget("openai.chat_completions"),
)
Converted core-visible tool call
After conversion, the assistant metadata may contain:
{
"id": "call_123",
"type": "function",
"function": {
"name": "read_file",
"arguments": "{\"path\":\"README.md\"}"
}
}
What core extracts from the call
When the tool loop inspects it, the registry exposes normalized semantics:
inspected = registry.inspect_call(tool_call)
assert inspected.call_id == "call_123"
assert inspected.tool_name == "read_file"
assert inspected.payload == {"path": "README.md"}
assert inspected.payload_kind == "object"
What is stored in session/core messages
The tool call is stored in assistant metadata on the core message:
{
"role": "assistant",
"content": "",
"metadata": {
"tool_calls": [
{
"id": "call_123",
"type": "function",
"function": {
"name": "read_file",
"arguments": "{\"path\":\"README.md\"}"
}
}
]
}
}
Streaming + sanitization example
If streaming delivered corrupted partial arguments:
{
"id": "call_123",
"type": "function",
"function": {
"name": "read_file",
"arguments": "{\"path\":"
}
}
then provider-extension finalize(...) sanitizes before the call is retained in
native history:
sanitized = registry.sanitize_tool_call(corrupted_call)
Result:
{
"id": "call_123",
"type": "function",
"function": {
"name": "read_file",
"arguments": "{}"
}
}
What is sent on the next request
When native history is replayed into the next provider request, the sanitized version is what gets sent, not the corrupted fragment.
That is why sanitization must happen before history retention.
Where sanitization happens
Sanitization is a tool-call concern, not a schema concern.
Provider extensions that accumulate streamed tool calls should sanitize them in
finalize(...) before those calls are retained in native history.
That should also use the request-owned registry from context:
from agent_core.tool_interop import get_registry_from_context
def finalize(self, final_messages, native_messages, state, *, context=None):
partial = state.get("partial")
if isinstance(partial, dict):
tool_calls = partial.get("tool_calls")
if isinstance(tool_calls, list):
registry = get_registry_from_context(context)
partial["tool_calls"] = [
registry.sanitize_tool_call(call)
for call in tool_calls
if isinstance(call, dict)
]
return final_messages, native_messages, state
This prevents corrupted JSON argument fragments from being stored and replayed into later API requests.
Where core tool execution uses interop
Once tool calls are in core, the tool loop and core execution path use the registry to interpret them in a payload-first way.
Core uses inspected call semantics to decide:
- which tool should handle the call
- what payload should be passed to the tool
- whether the payload is structured data or raw freeform input
This is the layer that lets older dict-argument tools and newer raw-input tools coexist.
End-to-end example: tool result flow
This example shows a read_file tool returning text content after execution.
Core executes the tool
After the inspected call is routed, the tool receives normalized payload:
payload = {"path": "README.md"}
result = tool.execute_tool("read_file", payload, state, context=context)
Assume the tool returns:
{
"path": "README.md",
"content": "# Project title"
}
Tool wrapper formats the result
format_tool_result(...) returns the canonical structured tool-result payload.
For example:
{
"role": "tool",
"content": "{\"path\":\"README.md\",\"content\":\"# Project title\"}",
"toolResult": {
"type": "tool_result",
"text": "{\"path\":\"README.md\",\"content\":\"# Project title\"}"
},
"metadata": {
"tool_name": "read_file",
"tool_call_id": "call_123"
}
}
This is the core-side result representation that gets appended to the session.
Where result conversion happens
Tool results now use the same interop layer as schemas and calls:
ToolResultAccessorinspects stored/publictoolResultpayloadsToolResultAdapterconvertstoolResultpayloads between formats- provider or provider-extension
to_native_messages(...)still decides where the converted payload lands in the native message/item shape
For an OpenAI-compatible provider, the sent native message may become:
{
"role": "tool",
"content": "{\"path\":\"README.md\",\"content\":\"# Project title\"}",
"tool_call_id": "call_123",
"_metadata": {
"tool_name": "read_file"
}
}
For a Responses-style provider, the sent native item may instead become:
{
"type": "function_call_output",
"call_id": "call_123",
"output": "{\"path\":\"README.md\",\"content\":\"# Project title\"}"
}
What is stored
The core session stores the core tool message:
{
"role": "tool",
"content": "{\"path\":\"README.md\",\"content\":\"# Project title\"}",
"toolResult": {
"type": "tool_result",
"text": "{\"path\":\"README.md\",\"content\":\"# Project title\"}"
},
"metadata": {
"tool_call_id": "call_123",
"tool_name": "read_file"
}
}
The provider-native history stores the provider-native result message or item.
What is sent later
If the next request reuses native history, the provider-native result message is what gets replayed to the provider.
If native history must be rebuilt from core messages, provider or
provider-extension to_native_messages(...) converts the stored toolResult
into the provider-native result shape and preserves compact attachment refs
until request-time expansion.
Why results now use interop adapters
Results are still ordinary message flow after tool execution, but the
structured toolResult payload is now explicitly inspectable and convertible.
So the conversion points are:
- schema:
initialize_request(...) - tool call: native response -> core metadata/tool loop input
- tool result: core/public
toolResult-> provider-native result payload
General rule for tool results
The flow is:
- core executes the tool
- the tool returns a result value
- the tool wrapper formats that result with
format_tool_result(...) - core stores:
- string
contentfallback - structured top-level
toolResult - user-facing
metadata.display - provider or provider-extension
to_native_messages(...)convertstoolResultinto provider-native output form - provider
initialize_request(...)expands compact attachment refs into the final API payload when needed
That means:
- schema adapters are for request-time tool declarations
- call adapters/accessors are for provider response tool calls
- tool result adapters cover structured result conversion
- provider/provider-extension message mapping decides how converted tool-result payloads are embedded into native history
Why results are different
Results are not provider-generated tool-call objects. They are regular message
traffic produced after tool execution, so adapters convert the structured
toolResult payload rather than the outer message.
So if you need provider-specific result handling, implement it in:
- tool/provider/provider-extension
get_tool_interop_contribution(...)when you need new result accessors or adapters - provider
to_native_messages(...) - provider-extension
to_native_messages(...) - provider
initialize_request(...)for request-time attachment expansion - provider
from_native_messages(...)if you also need rebuild symmetry
Practical responsibilities by plugin type
Tool plugin
Responsible for:
- emitting source schemas
- contributing extra schema/call accessors or adapters when needed
- contributing result accessors/adapters when tool results use a non-default structured envelope
- executing calls based on normalized payload semantics
- formatting tool results
Not responsible for:
- deciding the final provider wire-format target
Provider extension
Responsible for:
- injecting converted schemas into provider request payloads
- accumulating and sanitizing streamed tool calls
- converting provider-native tool calls into core metadata
- converting core tool-result messages into provider-native message shapes when needed
Provider
Responsible for:
- provider-level fallback injection when no extension handles tools
- native message conversion for provider-specific result/message formats
- provider-specific helper conversions around request/response payloads
Common mistakes
Mistake: convert schemas in tool plugins based on guessed provider shape
Avoid hardcoding one provider target inside a tool plugin unless the tool is explicitly provider-specific.
Prefer emitting source schemas and letting request-time injection adapt them.
Mistake: use DEFAULT_TOOL_INTEROP_REGISTRY in request-bound code
That bypasses request-specific contributions and can break custom/freeform tools.
Prefer get_registry_from_context(context).
Mistake: store the registry in config or state
The active registry is runtime-only and may contain non-serializable objects.
Use request context instead.
Mistake: expect tool results to use schema/call adapters
They do not today.
Use normal provider/provider-extension message conversion hooks for results.
Summary
The clean mental model is:
- tool plugins emit source schemas
- core builds the request-owned interop registry and accepted target formats
- providers/extensions adapt schemas during
initialize_request(...) - providers/extensions inspect or convert tool calls during native-to-core response handling
- providers/extensions sanitize streamed calls during
finalize(...) - core executes tools using normalized payload semantics
- tool results return through normal core-message/provider-native message conversion
- provider-native result envelopes are an explicit content payload carried by
core and interpreted by providers that support their
format
See also: