Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 19 additions & 1 deletion crates/tinyagents-harness/src/summarization/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,18 @@ loop.

### Rendering
- `render_message_for_summary(&Message) -> String`.
- `render_message_for_summary_with(&Message, ToolRendering) -> Option<String>`
— the same, in a chosen mode; `None` when the mode omits the message.
- `ToolRendering` — `Full` (default: tool calls with ids and arguments, tool
results elided) or `Markers` (conversation only: each tool call is one
`[used <name>: <arguments>]` line, arguments cut to 120 chars with an
ellipsis, no call ids; tool results omitted).

### `ModelSummarizer`
- `with_tool_rendering(ToolRendering)` — the transcript's rendering mode;
`Full` unless set.
- `with_max_output_tokens(u32)` — sends `max_tokens` on the summary request.
Unset by default; the request sets no sampling parameters of its own.

## Files

Expand All @@ -100,7 +112,8 @@ loop.
| `mod.rs` | `estimate_tokens`, `Summarizer for ConcatSummarizer`, `SummarizationPolicy` impls; wires the submodules together. |
| `types.rs` | All public struct/enum/trait definitions. |
| `pairing.rs` | Tool-call-pairing-safe cut-point repair. |
| `render.rs` | `render_message_for_summary`. |
| `render.rs` | `render_message_for_summary`, `render_message_for_summary_with`. |
| `render_tests.rs` | Coverage for both rendering modes and marker truncation. |
| `trim.rs` | `trim_messages`/`trim_messages_with`/`trim_messages_to_token_budget_with`. |
| `compaction.rs` | `find_cut_point`, `summarize_with_split`, `OverflowClassifier`, `CompactionContext`/`CompactionDecision`. |
| `response_overflow.rs` | `detect_response_overflow`, `ResponseOverflowDetection`: overflow read from a successful response (usage above the window, zero-output `length` stop, opt-in short `length` stop). |
Expand Down Expand Up @@ -142,6 +155,11 @@ loop.
agent loop, and a model continues it with its next tool call. The request
declares no tools, so that call arrives as plain-text markup (DeepSeek V4:
`<|DSML|invoke …>`).
- **`ToolRendering::Markers` drops tool output, never the conversation.**
User, assistant and system text render exactly as under `Full`; only tool
results are omitted and tool calls shortened to one marker line each, so a
small instruction-light summarization model sees what was said and done.
`Full` stays the default, byte-identical to `render_message_for_summary`.
- **A tool call is never accepted as a summary.** A reply the tool-call
grammars parse as a call is retried once, then returned as an error, so
`FaultTolerantCachingSummarizer` falls back to its deterministic trim
Expand Down
2 changes: 1 addition & 1 deletion crates/tinyagents-harness/src/summarization/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ pub use pairing::{
advance_past_orphan_tools, find_safe_cutoff_point, is_tool_calling_assistant,
retract_orphan_tool_calls, tool_pairing_is_intact,
};
pub use render::render_message_for_summary;
pub use render::{render_message_for_summary, render_message_for_summary_with};
pub use resilient::FaultTolerantCachingSummarizer;
pub use response_overflow::{ResponseOverflowDetection, detect_response_overflow};
pub use split_turn::{SPLIT_TURN_HEADING, split_turn_start, summarize_split_turn};
Expand Down
33 changes: 30 additions & 3 deletions crates/tinyagents-harness/src/summarization/model_summarizer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ use tinyinference_llm::model::{ChatModel, ModelRequest};
use super::types::ModelSummarizer;
use super::{
CompressionProvenance, SummarizationPolicy, Summarizer, SummaryKind, SummaryRecord,
SummaryRequest, estimate_tokens, render_message_for_summary,
SummaryRequest, ToolRendering, estimate_tokens, render_message_for_summary_with,
};
use crate::error::{Result, TinyAgentsError};
use crate::token_estimation::estimate_slice_tokens;
Expand Down Expand Up @@ -52,9 +52,28 @@ impl ModelSummarizer {
model,
model_id: model_id.into(),
threshold_fraction: DEFAULT_SUMMARIZE_THRESHOLD_FRACTION,
tool_rendering: ToolRendering::Full,
max_output_tokens: None,
}
}

/// Choose how tool calls and results are rendered into the transcript the
/// model summarizes. The default, [`ToolRendering::Full`], sends them all.
#[must_use]
pub fn with_tool_rendering(mut self, rendering: ToolRendering) -> Self {
self.tool_rendering = rendering;
self
}

/// Cap the summary's length: the request carries `max_tokens` set to
/// `max_output_tokens`. Unset by default, which leaves the provider's own
/// default in force.
#[must_use]
pub fn with_max_output_tokens(mut self, max_output_tokens: u32) -> Self {
self.max_output_tokens = Some(max_output_tokens);
self
}

/// Override the threshold fraction recorded in summary provenance. Use the
/// same value passed to [`summarization_policy_with`].
#[must_use]
Expand Down Expand Up @@ -99,7 +118,7 @@ impl ModelSummarizer {

let transcript = messages
.iter()
.map(render_message_for_summary)
.filter_map(|message| render_message_for_summary_with(message, self.tool_rendering))
.collect::<Vec<_>>()
.join("\n");
let (system_prompt, request_text) = match kind {
Expand All @@ -120,10 +139,11 @@ impl ModelSummarizer {
"[tinyagents::summarize] dispatching context-window summary"
);

let request = ModelRequest::new(vec![
let mut request = ModelRequest::new(vec![
Message::system(system_prompt),
Message::user(request_text),
]);
request.max_tokens = self.max_output_tokens;
let (summary, usage) = self.summarize_once(request).await.map_err(|failure| {
let (error, usage) = *failure;
match usage {
Expand Down Expand Up @@ -370,6 +390,13 @@ Produce exactly these sections (write \"None\" when a section is empty):\n\
## Goal\n\
What the user is ultimately trying to accomplish.\n\
\n\
## About the User\n\
Every fact the user shared about themselves (name, places, people, preferences, routines), \
stated as they gave it.\n\
\n\
## Commitments\n\
What the assistant promised or scheduled (reminders, follow-ups, deadlines), with when.\n\
\n\
## Completed Actions\n\
Numbered list of what has already been done, with key results/outputs.\n\
\n\
Expand Down
110 changes: 109 additions & 1 deletion crates/tinyagents-harness/src/summarization/model_summarizer_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use tinyinference_llm::usage::Usage;
use super::{
DEFAULT_SUMMARIZE_KEEP_LAST, DEFAULT_SUMMARIZE_THRESHOLD_FRACTION,
FaultTolerantCachingSummarizer, ModelSummarizer, SummarizationPolicy, Summarizer,
SummaryRecord, SummaryRequest, summarization_policy, summarization_policy_with,
SummaryRecord, SummaryRequest, ToolRendering, summarization_policy, summarization_policy_with,
};
use crate::error::{Result, TinyAgentsError};
use crate::testkit::ScriptedModel;
Expand Down Expand Up @@ -360,6 +360,23 @@ async fn pending_work_is_handed_on_as_in_progress_not_stale() {
assert!(prompt.contains("already answered"), "{prompt}");
}

/// A compaction must not cost what the user told the assistant about
/// themselves: the checkpoint keeps those facts, and the commitments the
/// assistant made, in sections of their own.
#[tokio::test]
async fn the_checkpoint_keeps_facts_about_the_user_and_commitments() {
let model = Arc::new(ScriptedModel::replies(vec!["## Goal\nx"]));
let summarizer = ModelSummarizer::new(model.clone(), "m");
summarizer
.summarize(&[Message::user("I live in Pune"), Message::assistant("noted")])
.await
.unwrap();

let prompt = model.requests()[0].messages[0].text();
assert!(prompt.contains("## About the User"), "{prompt}");
assert!(prompt.contains("## Commitments"), "{prompt}");
}

#[tokio::test]
async fn a_turn_prefix_request_uses_its_own_prompt() {
let model = Arc::new(ScriptedModel::replies(vec![
Expand Down Expand Up @@ -398,3 +415,94 @@ async fn a_turn_prefix_request_uses_its_own_prompt() {
.contains("beginning of a turn")
);
}

/// A short exchange around one tool call: the user's ask, a call with a long
/// argument, its result carrying a sentinel, and the assistant's reply.
fn cron_exchange() -> Vec<Message> {
vec![
Message::user("remind me to water the plants"),
Message::Assistant(AssistantMessage {
id: None,
content: Vec::new(),
tool_calls: vec![ToolCall::new(
"cron-1",
"cron",
json!({"schedule": "0 9 * * *", "note": format!("water the plants {}", "z".repeat(300))}),
)],
usage: None,
origin: None,
}),
Message::tool("cron-1", "SENTINEL-TOOL-RESULT job 42 created"),
Message::assistant("Done, I will remind you at 9 every morning."),
]
}

#[tokio::test]
async fn the_default_rendering_is_unchanged() {
let expected = "user: remind me to water the plants\n\
assistant: <reasoning>I should inspect the matching records.</reasoning> \
<tool_call id=\"lookup-1\" name=\"lookup\">{\"query\":\"open issues\"}</tool_call>\n\
tool: <tool_result id=\"lookup-1\"><json>{\"matches\":2}</json></tool_result>\n\
assistant: There are 2.";
let mut messages = vec![Message::user("remind me to water the plants")];
messages.extend(tool_call_messages(json!({"query": "open issues"})));
messages.push(Message::assistant("There are 2."));

for rendering in [None, Some(ToolRendering::Full)] {
let model = Arc::new(ScriptedModel::replies(vec!["## Goal\nx"]));
let mut summarizer = ModelSummarizer::new(model.clone(), "m");
if let Some(rendering) = rendering {
summarizer = summarizer.with_tool_rendering(rendering);
}
summarizer.summarize(&messages).await.unwrap();
let request = &model.requests()[0];
let sent = request.messages[1].text();
assert!(
sent.contains(&format!("<transcript>\n{expected}\n</transcript>")),
"{sent}"
);
assert_eq!(request.max_tokens, None);
assert_eq!(request.temperature, None);
}
}

#[tokio::test]
async fn markers_send_the_conversation_without_tool_output() {
let model = Arc::new(ScriptedModel::replies(vec!["## Goal\nx"]));
let summarizer =
ModelSummarizer::new(model.clone(), "m").with_tool_rendering(ToolRendering::Markers);
summarizer.summarize(&cron_exchange()).await.unwrap();

let sent = model.requests()[0].messages[1].text();
assert!(
sent.contains("user: remind me to water the plants"),
"{sent}"
);
assert!(
sent.contains("assistant: Done, I will remind you at 9 every morning."),
"{sent}"
);
assert!(sent.contains("[used cron: "), "{sent}");
assert!(!sent.contains("SENTINEL-TOOL-RESULT"), "{sent}");
assert!(!sent.contains("<tool_call"), "{sent}");
assert!(!sent.contains("<tool_result"), "{sent}");
assert!(!sent.contains("cron-1"), "call ids are dropped: {sent}");
let marker = sent
.lines()
.find(|line| line.starts_with("[used cron: "))
.expect("one marker line");
let shown = &marker["[used cron: ".len()..marker.len() - 1];
assert!(shown.chars().count() <= 120, "{marker}");
assert!(!sent.contains(&"z".repeat(120)), "{sent}");
}

#[tokio::test]
async fn max_output_tokens_rides_the_request() {
let model = Arc::new(ScriptedModel::replies(vec!["## Goal\nx"]));
let summarizer = ModelSummarizer::new(model.clone(), "m").with_max_output_tokens(512);
summarizer.summarize(&[Message::user("x")]).await.unwrap();

let request = &model.requests()[0];
assert_eq!(request.max_tokens, Some(512));
assert_eq!(request.temperature, None);
}
60 changes: 60 additions & 0 deletions crates/tinyagents-harness/src/summarization/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@

use tinyinference_llm::message::{ContentBlock, Message};

use super::ToolRendering;

/// Maximum characters rendered for a single tool-call argument blob or tool
/// result body before it is elided.
///
Expand All @@ -34,6 +36,10 @@ use tinyinference_llm::message::{ContentBlock, Message};
/// to.
const MAX_RENDERED_PAYLOAD_CHARS: usize = 2_000;

/// Most characters of a tool call's arguments shown in a
/// [`ToolRendering::Markers`] line, the ellipsis included.
const MARKER_ARGUMENT_CHARS: usize = 120;

/// The role label used when rendering a message for summarization.
fn role_label(message: &Message) -> &'static str {
match message {
Expand Down Expand Up @@ -147,3 +153,57 @@ pub fn render_message_for_summary(message: &Message) -> String {

format!("{}: {}", role_label(message), parts.join(" "))
}

/// Renders a single message for a summary transcript in the given
/// [`ToolRendering`] mode, or `None` when the mode leaves the message out.
///
/// [`ToolRendering::Full`] is exactly [`render_message_for_summary`].
/// [`ToolRendering::Markers`] renders system, user, assistant and custom
/// content the same way, but:
///
/// - each assistant tool call becomes its own line,
/// `[used <name>: <arguments>]`, without its call id and with the arguments'
/// JSON cut to 120 characters (an ellipsis marks the cut);
/// - an assistant turn that only called tools is just those lines;
/// - a tool result ([`Message::Tool`]) is omitted: `None`;
/// - so is an assistant turn with neither prose nor tool calls.
pub fn render_message_for_summary_with(
message: &Message,
rendering: ToolRendering,
) -> Option<String> {
match (rendering, message) {
(ToolRendering::Full, _) => Some(render_message_for_summary(message)),
(ToolRendering::Markers, Message::Tool(_)) => None,
(ToolRendering::Markers, Message::Assistant(assistant)) => {
let parts = render_content(&assistant.content);
let prose = (!parts.is_empty())
.then(|| format!("{}: {}", role_label(message), parts.join(" ")));
let markers = assistant.tool_calls.iter().map(|call| {
format!(
"[used {}: {}]",
call.name,
shorten(&call.arguments.to_string(), MARKER_ARGUMENT_CHARS)
)
});
let lines = prose.into_iter().chain(markers).collect::<Vec<_>>();
(!lines.is_empty()).then(|| lines.join("\n"))
}
(ToolRendering::Markers, _) => Some(render_message_for_summary(message)),
}
}

/// Cuts `text` to at most `max_chars` characters, the last of them an
/// ellipsis when anything was dropped. Counts characters, so a cut never
/// splits one.
fn shorten(text: &str, max_chars: usize) -> String {
if text.chars().count() <= max_chars {
return text.to_string();
}
let mut kept: String = text.chars().take(max_chars.saturating_sub(1)).collect();
kept.push('…');
kept
}

#[cfg(test)]
#[path = "render_tests.rs"]
mod tests;
Loading
Loading