Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
128 commits
Select commit Hold shift + click to select a range
cbb4ed9
chore(deps): update tinytools subproject commit
senamakel Sep 19, 2026
42d8b19
chore(deps): update tinytools subproject commit
senamakel Sep 19, 2026
a767980
chore(deps): update tinytools subproject commit
senamakel Sep 19, 2026
05d09f1
chore(vendor): update tinytools subproject commit
senamakel Sep 19, 2026
41a8e6e
chore(deps): update tinytools subproject commit
senamakel Sep 19, 2026
8d805c8
chore(deps): update tinytools subproject commit
senamakel Sep 19, 2026
672801d
chore(deps): update vendored subprojects
senamakel Sep 19, 2026
150f257
chore: remove relaxed JSON and prompt tool-call handling
senamakel Sep 19, 2026
8c5b1b1
fix(repair): redirect structured-output repair to protocol crate
senamakel Sep 19, 2026
5968cc4
refactor(providers): migrate to shared prompt-tool utilities from tin…
senamakel Sep 19, 2026
92898d5
feat(providers): pass tools reference to model response builder
senamakel Sep 19, 2026
b085381
refactor(tools): delegate argument repair to the protocol crate
senamakel Sep 19, 2026
a4cff2e
fix: avoid cloning the original call when validating tool arguments
senamakel Sep 19, 2026
e49e468
feat(runtime): add tool_dialect field to RunPolicy
senamakel Sep 19, 2026
22ecbb6
chore(agent_loop): add dialect module
senamakel Sep 19, 2026
a9c6e62
feat(agent_loop): recover text-dialect tool calls from streamed content
senamakel Sep 19, 2026
a577a03
fix(agent_loop): remove unused variable for tool presence check
senamakel Sep 19, 2026
08112fa
test(harness): update claude_code tests to pass tool schemas
senamakel Sep 19, 2026
4ea97eb
test: add call-id attributes to tool results in Claude provider tests
senamakel Sep 19, 2026
84096e5
feat(tests): add e2e tests for tool dialects
senamakel Sep 19, 2026
06c2b89
test(integration): replace FakeTool with a real Tool implementation i…
senamakel Sep 19, 2026
c11393f
test(e2e_tool_dialects): tighten assertion for unknown narrated tool
senamakel Sep 19, 2026
0735373
feat(harness): handle dropped tool calls with bounded re-prompts
senamakel Sep 19, 2026
aab4266
fix(harness): reset tool-call nudge counter on resolved turns
senamakel Sep 19, 2026
aefe500
chore: reformat code and reorder imports across multiple files
senamakel Sep 19, 2026
7e55017
refactor(model_call): bundle streaming and recovery into a CallShape …
senamakel Sep 19, 2026
4da4214
fix(harness): pass full CallShape to stream handler
senamakel Sep 19, 2026
86e14ee
chore: files changed crates/tinyagents-harness/src/agent_loop/model_c…
senamakel Sep 19, 2026
c3fdad2
fix(tests): update known debt line numbers for claude_code
senamakel Sep 19, 2026
2f23790
docs(harness): document tinytools-agent integration for tool dialects…
senamakel Sep 19, 2026
693ba47
fix(repair): correct doc link for Relaxed repair strategy
senamakel Sep 19, 2026
89589bf
chore(deps): update tinytinference subproject commit
senamakel Sep 19, 2026
1c45614
chore(deps): update tinytools subproject commit
senamakel Sep 19, 2026
b7b73fa
chore(deps): update tinytools subproject commit
senamakel Sep 19, 2026
4a24f90
chore(deps): update tinytools submodule to latest commit
senamakel Sep 19, 2026
9831561
chore(deps): update tinytools subproject commit
senamakel Sep 19, 2026
d2f7a13
chore(deps): update tinytools submodule
senamakel Sep 19, 2026
4924965
chore(deps): update tinytools subproject commit
senamakel Sep 19, 2026
fb1c507
chore(deps): update tinytools subproject commit
senamakel Sep 19, 2026
2bde83d
chore(deps): update tinytools subproject commit
senamakel Sep 19, 2026
74e0ec0
chore(deps): update tinytools submodule commit
senamakel Sep 19, 2026
4848594
chore(deps): update tinytools subproject commit
senamakel Sep 19, 2026
7cee814
chore(deps): update tinytools subproject commit
senamakel Sep 19, 2026
c9ee089
chore(deps): update tinytools subproject commit
senamakel Sep 19, 2026
aec28e5
chore(deps): update tinytools subproject commit
senamakel Sep 19, 2026
e32dc90
chore(deps): update vendor submodules
senamakel Sep 19, 2026
0a4d1db
chore(deps): update tinytools dependency from 0.2.0 to 0.3.0
senamakel Sep 19, 2026
87bbcd6
Merge upstream/main (d2399476) into tool-dialect-unify
senamakel Sep 19, 2026
3befeb7
Merge upstream/main (24483d68) into tool-dialect-unify
senamakel Sep 19, 2026
87dee92
fix(harness): correct dialect field name in agent loop
senamakel Sep 19, 2026
43e821a
fix(harness): handle empty input in dialect parsing
senamakel Sep 19, 2026
fc97853
fix(harness): handle agent loop termination on empty action list
senamakel Sep 19, 2026
51747aa
chore: files changed crates/tinyagents-harness/src/agent_loop/run_loo…
senamakel Sep 19, 2026
012cdb7
fix(agent_loop): handle empty tool call arguments gracefully
senamakel Sep 19, 2026
d3d488d
chore: files changed crates/tinyagents-harness/src/agent_loop/model_c…
senamakel Sep 19, 2026
0154a8b
fix(runtime): handle empty test output gracefully
senamakel Sep 19, 2026
acc9dc0
chore: files changed crates/tinyagents-harness/src/runtime/test.rs
senamakel Sep 19, 2026
1bf48c1
fix(harness): correct test assertion for agent loop termination
senamakel Sep 19, 2026
8788d7a
fix(agent_loop): correct test assertion for agent loop termination
senamakel Sep 19, 2026
01727cf
fix(harness): correct agent loop test to verify state persistence
senamakel Sep 19, 2026
df9e8b5
fix(harness): use full registry for text recovery
senamakel Sep 19, 2026
ad4f76a
fix(harness): use scoped registry for text recovery
senamakel Sep 19, 2026
aeaaa2c
fix(integration-tests): correct tool dialect test to use proper asser…
senamakel Sep 19, 2026
ded526b
feat(tests): add e2e tests for tool dialect compatibility
senamakel Sep 19, 2026
73845ac
fix(integration-tests): correct tool dialect test expectations
senamakel Sep 19, 2026
2fd0157
feat(tests): add e2e tests for tool dialects
senamakel Sep 19, 2026
053d217
fix(agent_loop): remove redundant scrubber check in stream completion
senamakel Sep 19, 2026
8ce50c8
fix(agent-loop): include recovered calls in streamed content check
senamakel Sep 19, 2026
0a438ef
fix(tests): update e2e tool dialect test to match new error format
senamakel Sep 19, 2026
9182b10
fix(tests): add e2e tests for tool dialects across providers
senamakel Sep 19, 2026
a5f906b
fix(agent_loop): handle model call errors without panicking
senamakel Sep 19, 2026
e444a64
fix(agent_loop): handle model call errors gracefully
senamakel Sep 19, 2026
c52a412
fix(test): reformat assertion macro for readability
senamakel Sep 19, 2026
d59382a
chore: files changed crates/tinyagents-harness/src/agent_loop/run_loo…
senamakel Sep 19, 2026
8b5563f
fix(test): update test to reflect new agent loop behavior
senamakel Sep 19, 2026
ff6040c
chore: files changed crates/tinyagents-harness/src/agent_loop/run_loo…
senamakel Sep 19, 2026
6762abb
chore: files changed crates/tinyagents-harness/src/agent_loop/run_loo…
senamakel Sep 19, 2026
97e54be
fix(harness): handle tool call with no arguments
senamakel Sep 19, 2026
7c3eb5f
fix(harness): correct agent loop test to verify state transitions
senamakel Sep 19, 2026
183694f
chore: files changed crates/tinyagents-harness/src/agent_loop/tools.rs
senamakel Sep 19, 2026
db64430
fix(harness): handle tool call with no arguments
senamakel Sep 19, 2026
721aab6
chore: files changed crates/tinyagents-harness/src/agent_loop/run_loo…
senamakel Sep 19, 2026
abd16af
chore: files changed crates/tinyagents-integration-tests/tests/e2e_to…
senamakel Sep 19, 2026
0e8c45c
fix(integration-tests): correct tool dialect test for provider-specif…
senamakel Sep 19, 2026
c1b2bb5
fix(tests): correct tool dialect test to use expected assertion
senamakel Sep 19, 2026
9afc3e0
fix(agent_loop): remove stale nudge counter reset in mixed-turn path
senamakel Sep 19, 2026
897790e
fix(agent_loop): handle empty agent list gracefully
senamakel Sep 19, 2026
ce912aa
fix(agent_loop): reformat conditional and assertion for readability
senamakel Sep 19, 2026
1950391
fix(harness): handle model call errors gracefully
senamakel Sep 19, 2026
45596c7
chore: files changed crates/tinyagents-harness/src/agent_loop/run_loo…
senamakel Sep 19, 2026
74a603b
fix(agent_loop): handle empty dialect list in agent loop
senamakel Sep 19, 2026
370997e
test(e2e): add probe test for terminal-only stream recovery
senamakel Sep 19, 2026
320886c
chore: files changed crates/tinyagents-integration-tests/tests/e2e_to…
senamakel Sep 19, 2026
05e1ebe
fix(e2e-tool-dialects): correct test assertion for tool dialect response
senamakel Sep 19, 2026
4f07091
fix(harness): always offer full tool catalogue for recovery
senamakel Sep 19, 2026
92d8d0b
fix(agent_loop): handle agent loop termination on empty response
senamakel Sep 19, 2026
e423242
feat(tests): add e2e tests for tool dialect interoperability
senamakel Sep 19, 2026
1f81a2b
fix(agent_loop): mark streamed content only when delta is non-empty
senamakel Sep 19, 2026
c800fa4
fix(harness): always mark streamed content as seen
senamakel Sep 19, 2026
df0d63d
fix(agent_loop): handle missing agent in run loop
senamakel Sep 19, 2026
e6b8514
fix(harness): correct agent loop test to handle empty state
senamakel Sep 19, 2026
a694d9c
fix(agent_loop): correct tool availability check in run loop
senamakel Sep 19, 2026
340bb57
fix(harness): correct tool dialect check to use request tools
senamakel Sep 19, 2026
ad1067d
chore: files changed crates/tinyagents-harness/src/agent_loop/dialect.rs
senamakel Sep 19, 2026
b442bb2
fix(harness): handle agent loop termination on empty action list
senamakel Sep 19, 2026
fe9845c
fix(harness): handle agent loop exit on empty action list
senamakel Sep 19, 2026
46404bf
fix(agent_loop): handle empty tool call arguments in run loop
senamakel Sep 19, 2026
dd3b80b
fix(agent_loop): handle agent exit during loop execution
senamakel Sep 19, 2026
9367993
fix(tests): correct tool dialect test assertions for updated API
senamakel Sep 19, 2026
2ce4abb
fix(integration-tests): correct tool dialect test for missing argument
senamakel Sep 19, 2026
52ce873
fix(harness): always use native tool dispatcher
senamakel Sep 19, 2026
c8aafc3
fix(harness): use profile tool_calling flag in agent loop
senamakel Sep 19, 2026
e598358
fix(agent_loop): correct test assertion for agent response handling
senamakel Sep 19, 2026
a5d9b15
fix(harness): correct tool-calling capability detection
senamakel Sep 19, 2026
0f8fb92
fix(agent_loop): extend tool-calling flag for structured output
senamakel Sep 19, 2026
77b3e66
fix(harness): handle model call errors without panicking
senamakel Sep 19, 2026
b8939de
fix(dialect): handle missing dialect field in agent loop configuration
senamakel Sep 19, 2026
8660f93
fix(e2e): handle missing tool dialect in integration tests
senamakel Sep 19, 2026
b4e3a32
fix(agent_loop): skip text recovery when tool calls are present
senamakel Sep 19, 2026
52f24f3
fix: correct early return condition in recover_text_calls
senamakel Sep 19, 2026
c40c1c6
fix(harness): restore tool-call assignment for scrubbed text blocks
senamakel Sep 19, 2026
1cd02c7
fix(agent_loop): append scrubbed tool calls instead of assigning them
senamakel Sep 19, 2026
b891b29
fix(harness): handle agent loop termination on empty step
senamakel Sep 19, 2026
6e6455b
fix(harness): handle agent loop termination on empty action list
senamakel Sep 19, 2026
2d5fcc6
fix(tests): update e2e tool dialect tests for compatibility
senamakel Sep 19, 2026
0e4c505
fix(harness): correct condition for dropped tool call nudges
senamakel Sep 19, 2026
5445884
fix(harness): add missing condition for tool call nudge
senamakel Sep 19, 2026
d376660
style(harness): rustfmt run loop and agent loop test
senamakel Sep 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,12 @@ all = { level = "warn", priority = -1 }
lto = "thin"
codegen-units = 1
strip = "debuginfo"

# `tinyinference-llm` names `tinytools-agent` as a git dependency (it is not on
# crates.io). Inside this workspace that crate is the vendored submodule, so the
# git source is redirected onto the path — one copy of the protocol crate in the
# graph, and the harness's `path` dependency and the provider's dependency are
# the same type.
[patch."https://github.com/tinyhumansai/tinytools"]
tinytools-agent = { path = "vendor/tinytools/crates/tinytools-agent" }
tinytools = { path = "vendor/tinytools/crates/tinytools" }
2 changes: 1 addition & 1 deletion crates/tinyagents-graph/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ tinyagents-harness = { path = "../tinyagents-harness", version = "2.1.2", defaul
tinyagents-language = { path = "../tinyagents-language", version = "2.1.2" }
tinyagents-tracing = { path = "../tinyagents-tracing", version = "2.1.2", default-features = false }
tinyinference-llm = { path = "../../vendor/tinyinference/crates/tinyinference-llm", version = "0.3.0" }
tinytools = { path = "../../vendor/tinytools/crates/tinytools", version = "0.2.0" }
tinytools = { path = "../../vendor/tinytools/crates/tinytools", version = "0.3.0" }
tokio = { version = "1", default-features = false, features = ["sync", "time", "macros", "rt", "rt-multi-thread", "fs"] }

[features]
Expand Down
4 changes: 2 additions & 2 deletions crates/tinyagents-harness/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ sha2 = "0.11"
thiserror = "2"
tinyagents-tracing = { path = "../tinyagents-tracing", version = "2.1.2", default-features = false }
tinyagents-definition = { path = "../tinyagents-definition", version = "2.1.2" }
tinytools-agent = { path = "../../vendor/tinytools/crates/tinytools-agent", version = "0.2.0", default-features = false }
tinytools-agent = { path = "../../vendor/tinytools/crates/tinytools-agent", version = "0.3.0", default-features = false }
tinyinference-llm = { path = "../../vendor/tinyinference/crates/tinyinference-llm", version = "0.3.0" }
tinyinference-embeddings = { path = "../../vendor/tinyinference/crates/tinyinference-embeddings", version = "0.3.0" }
tinytools = { path = "../../vendor/tinytools/crates/tinytools", version = "0.2.0" }
tinytools = { path = "../../vendor/tinytools/crates/tinytools", version = "0.3.0" }
tokio = { version = "1", default-features = false, features = ["sync", "time", "macros", "rt", "rt-multi-thread", "fs", "io-util", "process"] }
tempfile = "3"
wait-timeout = "0.2"
Expand Down
1 change: 0 additions & 1 deletion crates/tinyagents-harness/src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ the module map below.
| `observability` | Durable observability — journals, status stores, sinks — making the live event stream persistent. See [`observability/README.md`](observability/README.md). |
| `prompt` | Prompt assembly — templates and `PromptBuilder` turning runtime values into the final request. |
| `providers` | Model adapters whose behavior depends on TinyAgents-specific prompt dialects (e.g. Claude Code/Agent SDK). |
| `relaxed_json` (crate-private) | Best-effort repair of malformed JSON small local models emit for tool-call arguments. |
| `retriever` | Provider-neutral retrieval contracts (`Retriever`) for injecting ranked context into a prompt. See [`retriever/README.md`](retriever/README.md). |
| `retry` | Retry/backoff, model fallback, and rate-limiting policies applied uniformly to every model call. See [`retry/README.md`](retry/README.md). |
| `run_queue` | A generic multi-lane FIFO queue (steer/followup/collect) for messages arriving during an active run. See [`run_queue/README.md`](run_queue/README.md). |
Expand Down
316 changes: 316 additions & 0 deletions crates/tinyagents-harness/src/agent_loop/dialect.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,316 @@
//! Host-side selection and application of the tool dialect for one run.
//!
//! The protocol itself — how a call is rendered, parsed, repaired, and
//! scrubbed from a stream — is owned by `tinytools-agent`. What is decided
//! *here* is the host's part: which dialect a run speaks
//! ([`RunPolicy::tool_dialect`]), the rewrite of a request onto a text
//! protocol when one is forced, the minting of call ids for calls recovered
//! from text, and the fallback that reads a native model's narrated call out
//! of its visible text.
//!
//! [`RunPolicy::tool_dialect`]: crate::runtime::RunPolicy::tool_dialect

use std::sync::Arc;

use tinyinference_llm::message::ContentBlock;
use tinyinference_llm::model::{ModelRequest, ModelResponse, ToolChoice};
use tinyinference_llm::tool::{ToolCall, ToolSchema};
use tinytools_agent::dialect::PFormatDialect;
use tinytools_agent::types::{ParseOptions, ParsedToolCall};
use tinytools_agent::{PFormatRegistry, StreamScrubber};

use crate::config::ToolDispatcher;
use crate::ids::CallId;

/// The dialect a run speaks, resolved once from policy.
#[derive(Debug, Clone)]
pub(super) enum RunDialect {
/// Schemas on the wire; the provider adapter owns any text fallback.
Native,
/// JSON-in-tag, rendered into the system prompt by the host.
Xml,
/// Positional P-Format, rendered into the system prompt by the host.
PFormat(Arc<PFormatRegistry>),
}

impl RunDialect {
/// Resolves the policy against the tools this run offers.
pub(super) fn resolve(dispatcher: ToolDispatcher, tools: &[ToolSchema]) -> Self {
match dispatcher {
ToolDispatcher::Auto | ToolDispatcher::Native => Self::Native,
Comment thread
senamakel marked this conversation as resolved.
Comment thread
senamakel marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

priority high security confident

Honor Auto's documented text fallback

ToolDispatcher::Auto is documented to use native calls only when supported and otherwise fall back to XML, but this branch resolves it unconditionally to Native. Runs using Auto with a model that lacks native tool-call support therefore do not render any text dialect or recover narrated calls, causing tool-capable turns to fail or be treated as plain responses. Resolve Auto using the provider capability, or retain the documented XML fallback when native support is unavailable.

[RULE] dispatcher-fallback ·

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

priority high security likely

Gate native capability after adding synthetic tools

The native dialect is selected without accounting for per-turn synthetic tools, such as the structured-output fallback tool. If those tools are added after model resolution, a model lacking native tool support can still receive them on the wire and enter the native path. Recompute or enforce the required native capability after the final tool set is assembled, before dispatch.

[RULE] capability-gating ·

ToolDispatcher::Xml => Self::Xml,
ToolDispatcher::Pformat => Self::PFormat(Arc::new(tinytools_agent::build_registry(
tools
.iter()
.map(|schema| (schema.name.clone(), schema.parameters.clone())),
))),
}
}

/// Whether the host renders the protocol and parses the answer itself.
pub(super) fn is_text(&self) -> bool {
!matches!(self, Self::Native)
}

/// The P-Format registry for one call, extended with any tool in `tools`
/// beyond the run-level set the registry was built from.
///
/// The run-level registry is built once from the schemas offered at the
/// start of the run (see [`Self::resolve`]); a synthetic per-turn tool —
/// the structured-output fallback schema pushed onto `request.tools`
/// after that — is advertised in the P-Format catalogue (rendered fresh
/// from the final tool list on every call) but would otherwise have no
/// positional layout to decode a call against. Extending here, rather
/// than rebuilding from scratch every call, keeps the common case (no
/// new tool this turn) a cheap `Arc::clone`.
pub(super) fn registry_for(&self, tools: &[ToolSchema]) -> Option<Arc<PFormatRegistry>> {
match self {
Self::PFormat(registry) => {
let extra: Vec<&ToolSchema> = tools
.iter()
.filter(|schema| !registry.contains_key(&schema.name))
.collect();
Comment thread
senamakel marked this conversation as resolved.
if extra.is_empty() {
return Some(Arc::clone(registry));
}
let mut merged = (**registry).clone();
merged.extend(tinytools_agent::build_registry(
extra
.into_iter()
.map(|schema| (schema.name.clone(), schema.parameters.clone())),
));
Some(Arc::new(merged))
}
_ => None,
}
}

/// Rewrites `request` onto this dialect's text protocol: the transcript
/// is folded into forms a prompt-guided model can read, the protocol block
/// and catalogue go into the system prompt, and no schema goes on the
/// wire. A no-op for [`Self::Native`] or when no tools are offered.
pub(super) fn apply_to_request(&self, request: &mut ModelRequest) {
if !self.is_text() || request.tools.is_empty() || request.tool_choice == ToolChoice::None {
return;
}
use tinyinference_llm::prompt_tools;

let tools = std::mem::take(&mut request.tools);
let messages = prompt_tools::coalesce_tool_results(&request.messages);
let messages = prompt_tools::ensure_resolvable_user_turn(&messages);
request.messages = match self {
Self::Xml | Self::Native => {
prompt_tools::with_tool_instructions(&messages, &tools, &request.tool_choice)
}
Self::PFormat(_) => {
let specs: Vec<tinytools_agent::tinytools::ToolSpec> = tools
.iter()
.map(|schema| tinytools_agent::tinytools::ToolSpec {
name: schema.name.clone(),
description: schema.description.clone(),
parameters: schema.parameters.clone(),
})
.collect();
let mut block = PFormatDialect::instructions();
block.push_str(&tinytools_agent::render::render_pformat_catalogue(&specs));
// The XML branch renders `tool_choice` into its instructions
// via `prompt_tools::tool_instructions`; P-Format has no
// schema on the wire either (the wire choice is reset to
// `Auto` below), so a forced choice has to be said in plain
// English here too or `Required`/`Tool(name)` silently loses
// its meaning — in particular the sole synthetic
// structured-output tool would no longer be forced, and a
// plain-text response would make extraction fail.
match &request.tool_choice {
ToolChoice::Required => {
block.push_str("\nYou must emit at least one tool call.\n");
}
ToolChoice::Tool(name) => {
block.push_str(&format!("\nYou must call the `{name}` tool.\n"));
}
ToolChoice::Auto | ToolChoice::None => {}
}
prompt_tools::append_system_block(&messages, &block)
Comment thread
senamakel marked this conversation as resolved.
}
};
request.tool_choice = ToolChoice::Auto;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

/// What a model call needs in order to recover text-dialect calls: the
/// tools that were offered (which a text-dialect request no longer carries
/// on the wire) and the P-Format registry, when there is one.
#[derive(Debug, Clone, Default)]
pub(super) struct TextRecovery {
/// The tools offered this turn, before any dialect rewrite.
pub(super) offered: Arc<Vec<ToolSchema>>,
/// The P-Format layouts, for [`RunDialect::PFormat`].
pub(super) registry: Option<Arc<PFormatRegistry>>,
}

impl TextRecovery {
/// A scrubber for one streamed model call, or `None` when no tools were
/// offered and there is nothing to recover.
pub(super) fn scrubber(&self, model_call_id: &CallId) -> Option<DeltaScrubber> {
(!self.offered.is_empty()).then(|| {
DeltaScrubber::new(model_call_id.clone(), &self.offered, self.registry.clone())
})
}
}

/// How one model call is made: streamed or unary, and what it needs to
/// recover text-dialect calls from the answer.
#[derive(Debug, Clone, Default)]
pub(super) struct CallShape {
/// Whether the provider's streaming path is used.
pub(super) streaming: bool,
/// Offered tools and P-Format registry for text recovery.
pub(super) recovery: TextRecovery,
}

/// Converts a recovered call into the harness's [`ToolCall`], minting an id
/// scoped to the model call it came from.
///
/// `{model_call_id}-tool-{n}` is unique per run by construction — model call
/// ids already are — and visibly distinct from any provider's, so a
/// recovered call can never be confused with a native one in a transcript.
fn to_tool_call(call: ParsedToolCall, model_call_id: &CallId, slot: usize) -> ToolCall {
// `call.id` is intentionally never used, even when a grammar or a future
// change to `tinytools-agent` happens to populate one: this function's
// whole contract (see its doc comment) is that a text-recovered call's id
// is always host-minted and unique per run, so it can never collide with
// another recovered call or be confused with a native provider one. A
// parser-supplied id would be model-controlled input; trusting it here
// would let two calls collide on an id the model chose, or let a
// narrated call impersonate a specific native one.
let id = format!("{model_call_id}-tool-{slot}");
ToolCall::new(id, call.name, call.arguments)
}

/// Reads text-dialect calls out of a response that carries no structured
/// ones, through every grammar `tinytools-agent` knows, with the offered
/// tools enabling name repair. Non-text content blocks (reasoning) survive.
pub(super) fn recover_text_calls(
response: &mut ModelResponse,
model_call_id: &CallId,
offered: &[ToolSchema],
registry: Option<&PFormatRegistry>,
) {
if offered.is_empty() {
return;
}
let known: Vec<String> = offered.iter().map(|tool| tool.name.clone()).collect();
let mut options = ParseOptions::new().with_known_tools(&known);
if let Some(registry) = registry {
options = options.with_registry(registry);
}
let text = response.text();
let outcome = tinytools_agent::parse_text(&text, &options);
if outcome.calls.is_empty() {
return;
}
for diagnostic in &outcome.diagnostics {
tinyagents_tracing::debug!(?diagnostic, "[agent_loop] text-dialect recovery");
}
// Appended, not assigned: a provider can legitimately return one native
// structured call *and* narrate a second one as text in the same
// response (this is deliberately parsed even when `tool_calls` was
// already non-empty — see above), and overwriting the collection here
// used to silently drop whichever set ran second.
let recovered = outcome
.calls
.into_iter()
.enumerate()
.map(|(index, call)| to_tool_call(call, model_call_id, index + 1));
response.message.tool_calls.extend(recovered);
response.message.content =
replace_text_blocks(std::mem::take(&mut response.message.content), outcome.text);
}

/// Keeps every non-text block in place and substitutes one cleaned text at
/// the first text block's position; an empty `cleaned` emits no text block.
fn replace_text_blocks(content: Vec<ContentBlock>, cleaned: String) -> Vec<ContentBlock> {
let mut out = Vec::with_capacity(content.len());
let mut inserted = false;
for block in content {
match block {
ContentBlock::Text(_) => {
if !inserted {
if !cleaned.is_empty() {
out.push(ContentBlock::Text(cleaned.clone()));
}
inserted = true;
}
}
other => out.push(other),
}
}
if !inserted && !cleaned.is_empty() {
out.push(ContentBlock::Text(cleaned));
}
out
}

/// Scrubs tool-call markup from streamed visible text and collects the
/// calls it completes, minting harness ids for them.
///
/// Consumers of [`AgentEvent::ModelDelta`](crate::events::AgentEvent::ModelDelta)
/// never see a partial `<tool_call>`; the calls surface on the terminal
/// response instead, exactly once.
pub(super) struct DeltaScrubber {
inner: StreamScrubber,
model_call_id: CallId,
calls: Vec<ToolCall>,
}

impl DeltaScrubber {
/// A scrubber for one model call, knowing the tools it offered.
pub(super) fn new(
model_call_id: CallId,
offered: &[ToolSchema],
registry: Option<Arc<PFormatRegistry>>,
) -> Self {
let known = offered.iter().map(|tool| tool.name.clone()).collect();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

priority medium critique likely

Pass the declared tool schemas to the stream scrubber

The scrubber is given only the names of the offered tools, so streamed calls are parsed and recovered without the declared parameter schemas. A streamed model can therefore supply arguments that do not conform to the tool contract, while the equivalent terminal recovery has access to offered and can be validated downstream. Pass the schemas through the scrubber's schema-aware API, or validate each call against the matching offered schema before collecting it.

[RULE] schema-validation ·

let mut inner = StreamScrubber::new().with_known_tools(known);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

priority high security likely

Pass declared tool schemas to the stream scrubber

The recovery path receives the full ToolSchema values, but reduces them to names before constructing StreamScrubber. Consequently, streamed text-dialect calls are scrubbed and recovered without the declared parameter schemas that govern the offered tools, so the streaming path can accept or normalize calls without the same schema context used by the canonical dispatch path. Pass the offered schemas through the scrubber API, or validate every recovered streamed call against the corresponding declared schema before exposing it for dispatch.

[RULE] schema-preservation ·

if let Some(registry) = registry {
inner = inner.with_registry(registry);
}
Self {
inner,
model_call_id,
calls: Vec::new(),
}
}

/// Feeds one text delta; returns the text safe to forward.
pub(super) fn feed(&mut self, text: &str) -> String {
let step = self.inner.feed(text);
self.collect(step.calls);
step.text
}

/// Drains the remainder at end of stream.
pub(super) fn flush(&mut self) -> String {
let step = self.inner.flush();
self.collect(step.calls);
step.text
}

fn collect(&mut self, calls: Vec<ParsedToolCall>) {
for call in calls {
let slot = self.calls.len() + 1;
self.calls
.push(to_tool_call(call, &self.model_call_id, slot));
}
}

/// The calls completed during the stream, in order.
pub(super) fn into_calls(self) -> Vec<ToolCall> {
self.calls
}

/// Whether any call was completed during the stream.
pub(super) fn has_calls(&self) -> bool {
!self.calls.is_empty()
}
}
1 change: 1 addition & 0 deletions crates/tinyagents-harness/src/agent_loop/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ use tinyinference_llm::model::{
};
use tinyinference_llm::tool::{ToolCall, ToolSchema};

mod dialect;
mod entry;
mod model_call;
mod run_loop;
Expand Down
Loading
Loading