-
Notifications
You must be signed in to change notification settings - Fork 18
fix(cache): stable layout under text dialects; RunPolicy::host_renders_tool_catalogue #190
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
0fde24f
700d81a
04d6f6a
6066d70
457bf21
eea0b7d
6ea0b3d
c1690ab
5dd2ba1
d6fdc96
70e3c00
1cda58d
c9e77a3
daaf397
c2237ab
c62e279
0ee035b
800c25a
aef6059
2339609
3fc0b0e
8215a71
da2ed36
58f79fa
c5cd302
5643f8d
ef3354f
4fcc4b6
fbb3e7f
6447da4
bc65a53
e186981
0a8567c
e8e29e0
bc65be3
ff4ce2e
4f4d6d2
ffafc73
6a2c8a1
e8acf7e
3522bbb
b67063f
f5841c3
4550bb0
51886de
e9a998f
7da8248
ff6a8a7
b3bf0fb
6d15748
c4fd430
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,8 +12,10 @@ | |
|
|
||
| use std::sync::Arc; | ||
|
|
||
| use tinyinference_llm::message::ContentBlock; | ||
| use tinyinference_llm::model::{ModelRequest, ModelResponse, ToolChoice}; | ||
| use tinyinference_llm::message::{ContentBlock, Message}; | ||
| use tinyinference_llm::model::{ | ||
| ModelRequest, ModelResponse, PromptSegment, SegmentRole, ToolChoice, | ||
| }; | ||
| use tinyinference_llm::tool::{ToolCall, ToolSchema}; | ||
| use tinytools_agent::dialect::{CodeDialect, CodeStyle, PFormatDialect}; | ||
| use tinytools_agent::types::{ParseOptions, ParsedToolCall}; | ||
|
|
@@ -101,7 +103,24 @@ impl RunDialect { | |
| /// 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) { | ||
| /// | ||
| /// With `host_renders_catalogue` the schemas still leave the wire (the | ||
| /// registry built from them before this call is what parses the answer), | ||
| /// and nothing from the run's ordinary catalogue is appended: the host's | ||
| /// own prompt already carries the protocol block and the catalogue for | ||
| /// this dialect. `synthesized` is the exception — tool schemas minted | ||
| /// *this turn*, after the host's static prompt was already composed (the | ||
| /// structured-output fallback tool `StructuredStrategy::ToolCall` / | ||
| /// `ToolCallUnion` push onto `request.tools`). The host cannot have | ||
| /// rendered a schema it did not know about yet, so their catalogue | ||
| /// entries are appended here even in the host-rendered case, or the | ||
| /// model never learns the shape it is being forced to call. | ||
| pub(super) fn apply_to_request( | ||
| &self, | ||
| request: &mut ModelRequest, | ||
| host_renders_catalogue: bool, | ||
| synthesized: &[ToolSchema], | ||
| ) { | ||
| if !self.is_text() || request.tools.is_empty() || request.tool_choice == ToolChoice::None { | ||
| return; | ||
| } | ||
|
|
@@ -110,6 +129,34 @@ impl RunDialect { | |
| 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); | ||
| let had_leading_system = matches!(messages.first(), Some(Message::System(_))); | ||
| if host_renders_catalogue { | ||
|
senamakel marked this conversation as resolved.
|
||
| let mut block = String::new(); | ||
| if !synthesized.is_empty() { | ||
| block.push_str(&self.render_catalogue(synthesized)); | ||
|
Comment on lines
+135
to
+136
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.
When AGENTS.md reference: AGENTS.md:L66-L70 Useful? React with 👍 / 👎. |
||
| } | ||
| // Only a forced choice still has to be said, since the host's | ||
| // prompt was composed before the choice was known. | ||
| match &request.tool_choice { | ||
| ToolChoice::Required => block.push_str("You must emit at least one tool call.\n"), | ||
| ToolChoice::Tool(name) => { | ||
| block.push_str(&format!("You must call the `{name}` tool.\n")); | ||
| } | ||
| ToolChoice::Auto | ToolChoice::None => {} | ||
| } | ||
| request.messages = if block.is_empty() { | ||
| // Nothing to say: no synthesized tool to advertise and no | ||
| // forced choice, so this rewrite leaves `messages` — and in | ||
| // particular whether a leading system message exists — | ||
| // exactly as it already was. | ||
| messages | ||
| } else { | ||
| prompt_tools::append_system_block(&messages, &block) | ||
| }; | ||
| request.tool_choice = ToolChoice::Auto; | ||
| sync_stripped_tools_cache_segment(request, had_leading_system); | ||
| return; | ||
|
senamakel marked this conversation as resolved.
|
||
| } | ||
| request.messages = match self { | ||
| Self::Xml | Self::Native => { | ||
| prompt_tools::with_tool_instructions(&messages, &tools, &request.tool_choice) | ||
|
|
@@ -158,7 +205,118 @@ impl RunDialect { | |
| } | ||
| }; | ||
| request.tool_choice = ToolChoice::Auto; | ||
| sync_stripped_tools_cache_segment(request, had_leading_system); | ||
| } | ||
|
|
||
| /// Renders `tools` into this dialect's catalogue shape alone (no | ||
| /// protocol instructions): the `Self::Xml` full-schema form, the | ||
| /// `Self::PFormat` positional-signature form, or the `Self::Code` | ||
| /// function-signature form. Used to advertise a schema the host's own | ||
| /// static catalogue could not have carried — see | ||
| /// [`Self::apply_to_request`]'s `synthesized` parameter. | ||
| fn render_catalogue(&self, tools: &[ToolSchema]) -> String { | ||
| 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(); | ||
| match self { | ||
| Self::Xml | Self::Native => tinytools_agent::render::render_json_catalogue(&specs), | ||
| Self::PFormat(_) => tinytools_agent::render::render_pformat_catalogue(&specs), | ||
| Self::Code(style, _) => tinytools_agent::render::render_code_catalogue(&specs, *style), | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Keeps a harness-declared `cache_segments` layout in sync with a text | ||
| /// dialect's rewrite, using the one thing only this call site still knows | ||
| /// for certain: whether `pre_rewrite_messages` already had a leading system | ||
| /// message *before* the protocol block gets folded in below. | ||
| /// | ||
| /// `request.cache_segments` may declare a trailing canonical tools segment | ||
| /// (`{id: "tools", role: Tools, cacheable: true}`) that is about to | ||
| /// disappear once `request.tools` is cleared. When a leading system message | ||
| /// already existed, dropping that trailing segment is all that is needed — | ||
| /// the declared head still names the same messages it always did, and later | ||
| /// fingerprinting (`refresh_prompt_cache_fingerprint`) can verify that by | ||
| /// simple equality. But when none existed yet, | ||
| /// `tinyinference_llm::prompt_tools::append_system_block` (used by both the | ||
| /// host-rendered and ordinary rewrite paths below) synthesizes exactly one | ||
| /// new leading system message for the protocol block — a segment no | ||
| /// declaration could have named in advance. That case is resolved *here*, | ||
| /// with certain knowledge of the pre-rewrite shape, rather than left for | ||
| /// `refresh_prompt_cache_fingerprint` to guess from the rewritten request | ||
| /// alone: reconstructing it after the fact from the post-rewrite shape alone | ||
| /// cannot tell an actually-synthesized segment apart from a custom | ||
| /// declaration that deliberately left an already-present system message out | ||
| /// of the cache key, and conflating the two would silently widen what a | ||
| /// middleware asked to keep out of the stable prefix. | ||
| /// | ||
| /// A declaration that is not exactly `[.., tools_segment]` — anything with a | ||
| /// head that does not otherwise account for the messages, or no declaration | ||
| /// at all — is left untouched, so `refresh_prompt_cache_fingerprint` keeps | ||
| /// taking the conservative whole-request digest for it. | ||
| fn sync_stripped_tools_cache_segment(request: &mut ModelRequest, had_leading_system: bool) { | ||
| let canonical_tools_segment = PromptSegment { | ||
| id: "tools".to_string(), | ||
| role: SegmentRole::Tools, | ||
| cacheable: true, | ||
| }; | ||
| let Some((last, head)) = request.cache_segments.split_last() else { | ||
| return; | ||
| }; | ||
| if *last != canonical_tools_segment { | ||
| return; | ||
| } | ||
| // The real, post-rewrite leading-system-message count — the exact same | ||
| // thing `refresh_prompt_cache_fingerprint` will independently derive | ||
| // from `request.messages` a moment later. Deriving the comparison | ||
| // against this, rather than against `head`'s own declared shape, is | ||
| // what lets every case below be a plain equality check instead of a | ||
| // guess: whatever the rewrite actually did to the messages is the one | ||
| // fact this function can trust. | ||
| let final_system_end = request | ||
| .messages | ||
| .iter() | ||
| .take_while(|message| matches!(message, Message::System(_))) | ||
| .count(); | ||
| let canonical_head: Vec<PromptSegment> = (0..final_system_end) | ||
| .map(|index| PromptSegment { | ||
| id: crate::prompt::system_segment_id(index), | ||
| role: SegmentRole::System, | ||
| cacheable: true, | ||
| }) | ||
| .collect(); | ||
| if head == canonical_head { | ||
| // The declared head already names exactly the messages that are | ||
| // really there (including the trivial `final_system_end == 0` | ||
| // case, where both sides are empty because this rewrite turned out | ||
| // to touch nothing — e.g. a host-rendered request with no | ||
| // synthesized tool and an `Auto` choice leaves `messages` | ||
| // untouched); only the now-gone trailing tools segment is stale. | ||
| request.cache_segments = canonical_head; | ||
| } else if head.is_empty() && !had_leading_system && final_system_end == 1 { | ||
|
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. Exercise the no-system dialect rewrite path The new cache-segment promotion branch is specifically dependent on a request with no leading system message and a dialect rewrite that creates exactly one. No regression test in this change exercises that path, leaving the cache layout behavior vulnerable to regressions in the most conditional part of the rewrite. [RULE] missing-regression-test · |
||
| // No declared head and no existing leading system message before | ||
| // this call: the rewrite is the sole source of the new leading | ||
| // segment (`prompt_tools::append_system_block` inserts exactly one | ||
| // when none exists), so this is unambiguously the harness's own | ||
| // synthesis rather than something a declaration could have named in | ||
| // advance. | ||
| request.cache_segments = canonical_head; | ||
| } | ||
| // Every other combination is left completely untouched, including the | ||
| // trailing tools segment: `head.is_empty() && had_leading_system` is a | ||
| // declaration that deliberately named nothing ahead of the tools | ||
| // segment even though a system message already existed (dropping to | ||
| // `head` would leave an *empty* `cache_segments`, which | ||
| // `refresh_prompt_cache_fingerprint` reads as "nothing declared yet" | ||
| // and promotes just the same); a non-empty `head` that does not match | ||
| // `canonical_head` is a custom declaration (a middleware-owned id, or a | ||
| // stale count) that must not be silently rewritten out from under it, | ||
| // partially or otherwise. | ||
| } | ||
|
|
||
| /// Builds the positional layout registry the P-Format and code dialects | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Update callers for the expanded dialect API
This changes
apply_to_requestfrom accepting onlyrequestto requiring two additional arguments. The pull request changes no callers, including the existing agent-loop path that reaches this module, so the workspace will fail to compile until every call supplieshost_renders_catalogueandsynthesized(or the API preserves a compatible wrapper).[RULE] api-call-signature ·