-
Notifications
You must be signed in to change notification settings - Fork 18
feat(harness): single-owner tool dialects via tinytools-agent #164
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
cbb4ed9
42d8b19
a767980
05d09f1
41a8e6e
8d805c8
672801d
150f257
8c5b1b1
5968cc4
92898d5
b085381
a4cff2e
e49e468
22ecbb6
a9c6e62
a577a03
08112fa
4ea97eb
84096e5
06c2b89
c11393f
0735373
aab4266
aefe500
7e55017
4da4214
86e14ee
c3fdad2
2f23790
693ba47
89589bf
1c45614
b7b73fa
4a24f90
9831561
d2f7a13
4924965
fb1c507
2bde83d
74e0ec0
4848594
7cee814
c9ee089
aec28e5
e32dc90
0a4d1db
87bbcd6
3befeb7
87dee92
43e821a
fc97853
51747aa
012cdb7
d3d488d
0154a8b
acc9dc0
1bf48c1
8788d7a
01727cf
df9e8b5
ad4f76a
aeaaa2c
ded526b
73845ac
2fd0157
053d217
8ce50c8
0a438ef
9182b10
a5f906b
e444a64
c52a412
d59382a
8b5563f
ff6040c
6762abb
97e54be
7c3eb5f
183694f
db64430
721aab6
abd16af
0e8c45c
c1b2bb5
9afc3e0
897790e
ce912aa
1950391
45596c7
74a603b
370997e
320886c
05e1ebe
4f07091
92d8d0b
e423242
1f81a2b
c800fa4
df0d63d
e6b8514
a694d9c
340bb57
ad1067d
b442bb2
fe9845c
46404bf
dd3b80b
9367993
2ce4abb
52ce873
c8aafc3
e598358
a5d9b15
0f8fb92
77b3e66
b8939de
8660f93
b4e3a32
52f24f3
c40c1c6
1cd02c7
b891b29
6e6455b
2d5fcc6
0e4c505
5445884
d376660
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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, | ||
|
senamakel marked this conversation as resolved.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Honor Auto's documented text fallback
[RULE] dispatcher-fallback · There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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(); | ||
|
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) | ||
|
senamakel marked this conversation as resolved.
|
||
| } | ||
| }; | ||
| request.tool_choice = ToolChoice::Auto; | ||
|
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(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 [RULE] schema-validation · |
||
| let mut inner = StreamScrubber::new().with_known_tools(known); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pass declared tool schemas to the stream scrubber The recovery path receives the full [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() | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.