tool出力の制限
This commit is contained in:
@@ -55,5 +55,5 @@ pub use callback::{TextBlockScope, ToolUseBlockScope};
|
||||
pub use handler::ToolUseBlockStart;
|
||||
pub use interceptor::Interceptor;
|
||||
pub use message::{ContentPart, Item, Message, Role};
|
||||
pub use tool::{ToolCall, ToolResult};
|
||||
pub use tool::{ToolCall, ToolOutputLimits, ToolResult};
|
||||
pub use worker::{RunOutput, ToolRegistryError, Worker, WorkerConfig, WorkerError, WorkerResult};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! Traits for defining tools callable by LLM.
|
||||
//! Usually auto-implemented using the `#[tool]` macro.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
@@ -32,6 +33,62 @@ pub enum ToolError {
|
||||
/// Outputs this small don't benefit from pruning.
|
||||
pub const SUMMARY_THRESHOLD: usize = 200;
|
||||
|
||||
/// Byte-size caps applied to tool execution `content` at the Worker's
|
||||
/// tool-execution boundary, before results enter conversation history.
|
||||
///
|
||||
/// Exists so a single oversized tool result (e.g. a wide `Glob` scan)
|
||||
/// cannot blow past the provider's per-minute input-token rate limit.
|
||||
/// Individual tools are not trusted to self-limit — this is the single
|
||||
/// chokepoint.
|
||||
///
|
||||
/// The unit is bytes rather than tokens because accurate pre-send token
|
||||
/// estimation is not available. The limits can be migrated to token
|
||||
/// units later without changing callers.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolOutputLimits {
|
||||
/// Cap applied to any tool not listed in `per_tool`.
|
||||
pub default_max_bytes: usize,
|
||||
/// Per-tool overrides, keyed by tool registration name.
|
||||
pub per_tool: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
impl ToolOutputLimits {
|
||||
/// Resolve the cap for a given tool name.
|
||||
pub fn limit_for(&self, tool_name: &str) -> usize {
|
||||
self.per_tool
|
||||
.get(tool_name)
|
||||
.copied()
|
||||
.unwrap_or(self.default_max_bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate `content` in-place if it exceeds `limit` bytes, replacing
|
||||
/// the dropped tail with a short human- and LLM-readable marker so the
|
||||
/// model can self-correct by narrowing its query.
|
||||
///
|
||||
/// The cut point is walked back to the nearest UTF-8 char boundary so
|
||||
/// multibyte characters are never split.
|
||||
pub(crate) fn truncate_content(content: &mut String, limit: usize) {
|
||||
let original_len = content.len();
|
||||
if original_len <= limit {
|
||||
return;
|
||||
}
|
||||
|
||||
let suffix_template = "\n\n[truncated: %BYTES% bytes dropped, refine your query]";
|
||||
// Reserve enough headroom for the suffix (upper bound on the byte length
|
||||
// of the number substitution). usize::MAX fits in 20 digits.
|
||||
let reserved = suffix_template.len() + 20 - "%BYTES%".len();
|
||||
let body_budget = limit.saturating_sub(reserved);
|
||||
|
||||
let mut cut = body_budget.min(original_len);
|
||||
while cut > 0 && !content.is_char_boundary(cut) {
|
||||
cut -= 1;
|
||||
}
|
||||
content.truncate(cut);
|
||||
let dropped = original_len - cut;
|
||||
content.push_str(&suffix_template.replace("%BYTES%", &dropped.to_string()));
|
||||
}
|
||||
|
||||
/// Tool execution result.
|
||||
///
|
||||
/// Every output has a mandatory `summary` (1-2 lines) that persists in
|
||||
@@ -253,3 +310,64 @@ impl ToolResult {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod truncate_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn noop_when_within_limit() {
|
||||
let mut s = "hello world".to_string();
|
||||
truncate_content(&mut s, 1024);
|
||||
assert_eq!(s, "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noop_at_exact_limit() {
|
||||
let mut s = "a".repeat(100);
|
||||
truncate_content(&mut s, 100);
|
||||
assert_eq!(s.len(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_oversized_ascii_with_marker() {
|
||||
let mut s = "a".repeat(1000);
|
||||
truncate_content(&mut s, 200);
|
||||
assert!(s.contains("[truncated:"));
|
||||
assert!(s.contains("refine your query"));
|
||||
assert!(s.len() <= 200, "result was {} bytes", s.len());
|
||||
let dropped: usize = s
|
||||
.split("[truncated: ")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split(' ')
|
||||
.next()
|
||||
.unwrap()
|
||||
.parse()
|
||||
.unwrap();
|
||||
let body_len = s.find("\n\n[truncated:").unwrap();
|
||||
assert_eq!(body_len + dropped, 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn respects_utf8_char_boundaries() {
|
||||
// 100 copies of "あ" (3 bytes each) = 300 bytes.
|
||||
let mut s = "あ".repeat(100);
|
||||
truncate_content(&mut s, 120);
|
||||
// Truncation must not split a multibyte character.
|
||||
assert!(s.is_char_boundary(s.find("\n\n[truncated:").unwrap_or(s.len())));
|
||||
// And the result must still be valid UTF-8 (implicitly true for String).
|
||||
assert!(s.contains("[truncated:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_per_tool_override() {
|
||||
let mut limits = ToolOutputLimits {
|
||||
default_max_bytes: 1024,
|
||||
per_tool: HashMap::new(),
|
||||
};
|
||||
limits.per_tool.insert("Read".to_string(), 4096);
|
||||
assert_eq!(limits.limit_for("Read"), 4096);
|
||||
assert_eq!(limits.limit_for("Grep"), 1024);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,10 @@ use crate::{
|
||||
state::{Locked, Mutable, WorkerState},
|
||||
timeline::event::{ErrorEvent, StatusEvent, UsageEvent},
|
||||
timeline::{TextBlockCollector, Timeline, ToolCallCollector},
|
||||
tool::{ToolCall, ToolDefinition as WorkerToolDefinition, ToolError, ToolResult},
|
||||
tool::{
|
||||
ToolCall, ToolDefinition as WorkerToolDefinition, ToolError, ToolOutputLimits, ToolResult,
|
||||
truncate_content,
|
||||
},
|
||||
tool_server::{ToolServer, ToolServerHandle},
|
||||
};
|
||||
|
||||
@@ -158,6 +161,9 @@ pub struct Worker<C: LlmClient, S: WorkerState = Mutable> {
|
||||
/// Cancel notification channel (for interrupting execution)
|
||||
cancel_tx: mpsc::Sender<()>,
|
||||
cancel_rx: mpsc::Receiver<()>,
|
||||
/// Byte-size caps applied to tool `content` before it reaches history.
|
||||
/// `None` disables truncation (tests and minimal setups).
|
||||
tool_output_limits: Option<ToolOutputLimits>,
|
||||
/// Prune configuration. `None` disables the prune projection.
|
||||
prune_config: Option<crate::prune::PruneConfig>,
|
||||
/// Callback that estimates token savings for a drop range, injected
|
||||
@@ -644,6 +650,33 @@ impl<C: LlmClient, S: WorkerState> Worker<C, S> {
|
||||
}
|
||||
};
|
||||
|
||||
// Cap `content` byte-size before it enters history. This is the
|
||||
// single chokepoint that protects the next LLM request from
|
||||
// blowing past the provider's per-minute input-token limit; no
|
||||
// individual tool is trusted to self-limit.
|
||||
if let Some(limits) = self.tool_output_limits.as_ref() {
|
||||
for tool_result in &mut results {
|
||||
let Some(content) = tool_result.content.as_mut() else {
|
||||
continue;
|
||||
};
|
||||
let Some((tool_call, _, _)) = call_info_map.get(&tool_result.tool_use_id) else {
|
||||
continue;
|
||||
};
|
||||
let limit = limits.limit_for(&tool_call.name);
|
||||
let before = content.len();
|
||||
truncate_content(content, limit);
|
||||
if content.len() != before {
|
||||
warn!(
|
||||
tool = %tool_call.name,
|
||||
before_bytes = before,
|
||||
after_bytes = content.len(),
|
||||
limit_bytes = limit,
|
||||
"Tool output exceeded byte limit and was truncated"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Apply post_tool_call interceptor
|
||||
for tool_result in &mut results {
|
||||
if let Some((tool_call, meta, tool)) = call_info_map.get(&tool_result.tool_use_id) {
|
||||
@@ -932,6 +965,7 @@ impl<C: LlmClient> Worker<C, Mutable> {
|
||||
last_run_interrupted: false,
|
||||
cancel_tx,
|
||||
cancel_rx,
|
||||
tool_output_limits: None,
|
||||
prune_config: None,
|
||||
savings_estimator: None,
|
||||
_state: PhantomData,
|
||||
@@ -963,6 +997,15 @@ impl<C: LlmClient> Worker<C, Mutable> {
|
||||
self.system_prompt = Some(prompt.into());
|
||||
}
|
||||
|
||||
/// Install byte-size caps for tool execution `content`.
|
||||
///
|
||||
/// Passing `None` (the default) disables truncation. Higher layers
|
||||
/// (e.g. Pod) translate manifest configuration into a concrete
|
||||
/// [`ToolOutputLimits`] and install it here.
|
||||
pub fn set_tool_output_limits(&mut self, limits: Option<ToolOutputLimits>) {
|
||||
self.tool_output_limits = limits;
|
||||
}
|
||||
|
||||
/// Set maximum tokens (builder pattern)
|
||||
///
|
||||
/// # Examples
|
||||
@@ -1175,6 +1218,7 @@ impl<C: LlmClient> Worker<C, Mutable> {
|
||||
|
||||
cancel_tx: self.cancel_tx,
|
||||
cancel_rx: self.cancel_rx,
|
||||
tool_output_limits: self.tool_output_limits,
|
||||
prune_config: self.prune_config,
|
||||
savings_estimator: self.savings_estimator,
|
||||
_state: PhantomData,
|
||||
@@ -1246,6 +1290,7 @@ impl<C: LlmClient> Worker<C, Locked> {
|
||||
|
||||
cancel_tx: self.cancel_tx,
|
||||
cancel_rx: self.cancel_rx,
|
||||
tool_output_limits: self.tool_output_limits,
|
||||
prune_config: self.prune_config,
|
||||
savings_estimator: self.savings_estimator,
|
||||
_state: PhantomData,
|
||||
|
||||
@@ -2,6 +2,7 @@ mod scope;
|
||||
|
||||
pub use scope::{Scope, ScopeError};
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::num::NonZeroU32;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -79,6 +80,48 @@ pub struct WorkerManifest {
|
||||
pub max_turns: Option<NonZeroU32>,
|
||||
#[serde(default)]
|
||||
pub temperature: Option<f32>,
|
||||
#[serde(default)]
|
||||
pub tool_output: Option<ToolOutputLimits>,
|
||||
}
|
||||
|
||||
/// Byte-size caps applied to tool execution `content` before it enters
|
||||
/// conversation history. Guards against a single oversized tool result
|
||||
/// blowing past the provider's per-minute input-token rate limit.
|
||||
///
|
||||
/// Field names are deliberately phrased in bytes (not tokens) because
|
||||
/// accurate pre-send token counting is not yet available; the caps can
|
||||
/// be migrated to token units later without renaming.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolOutputLimits {
|
||||
/// Cap applied to any tool not listed in `per_tool`.
|
||||
#[serde(default = "default_tool_output_max_bytes")]
|
||||
pub default_max_bytes: usize,
|
||||
/// Per-tool overrides, keyed by tool registration name (e.g. "Glob").
|
||||
#[serde(default)]
|
||||
pub per_tool: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
fn default_tool_output_max_bytes() -> usize {
|
||||
16 * 1024
|
||||
}
|
||||
|
||||
impl Default for ToolOutputLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
default_max_bytes: default_tool_output_max_bytes(),
|
||||
per_tool: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolOutputLimits {
|
||||
/// Resolve the cap for a given tool name.
|
||||
pub fn limit_for(&self, tool_name: &str) -> usize {
|
||||
self.per_tool
|
||||
.get(tool_name)
|
||||
.copied()
|
||||
.unwrap_or(self.default_max_bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Declarative scope configuration.
|
||||
@@ -367,6 +410,44 @@ permission = "write"
|
||||
assert!(PodManifest::from_toml(&toml).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_tool_output_is_none() {
|
||||
let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||
assert!(manifest.worker.tool_output.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tool_output_limits() {
|
||||
let toml = MINIMAL_REQUIRED.replace(
|
||||
"[worker]\n",
|
||||
"[worker]\n\
|
||||
[worker.tool_output]\n\
|
||||
default_max_bytes = 8192\n\n\
|
||||
[worker.tool_output.per_tool]\n\
|
||||
Read = 32768\n\
|
||||
Grep = 4096\n",
|
||||
);
|
||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
||||
let limits = manifest.worker.tool_output.unwrap();
|
||||
assert_eq!(limits.default_max_bytes, 8192);
|
||||
assert_eq!(limits.limit_for("Read"), 32768);
|
||||
assert_eq!(limits.limit_for("Grep"), 4096);
|
||||
assert_eq!(limits.limit_for("Unknown"), 8192);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_output_default_max_bytes_is_16k() {
|
||||
let toml = MINIMAL_REQUIRED.replace(
|
||||
"[worker]\n",
|
||||
"[worker]\n\
|
||||
[worker.tool_output]\n",
|
||||
);
|
||||
let manifest = PodManifest::from_toml(&toml).unwrap();
|
||||
let limits = manifest.worker.tool_output.unwrap();
|
||||
assert_eq!(limits.default_max_bytes, 16 * 1024);
|
||||
assert!(limits.per_tool.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_recursive_true() {
|
||||
let rule: ScopeRule = toml::from_str(
|
||||
|
||||
@@ -5,7 +5,7 @@ use llm_worker::Item;
|
||||
use llm_worker::llm_client::RequestConfig;
|
||||
use llm_worker::llm_client::client::LlmClient;
|
||||
use llm_worker::state::Mutable;
|
||||
use llm_worker::{Worker, WorkerError, WorkerResult};
|
||||
use llm_worker::{ToolOutputLimits, Worker, WorkerError, WorkerResult};
|
||||
use session_store::{
|
||||
EntryHash, Outcome, SessionId, SessionStartState, Store, StoreError, UsageRecord,
|
||||
};
|
||||
@@ -846,6 +846,10 @@ pub fn apply_worker_manifest<C: LlmClient>(worker: &mut Worker<C>, wm: &WorkerMa
|
||||
}
|
||||
worker.set_request_config(config);
|
||||
worker.set_max_turns(wm.max_turns.map(|n| n.get()));
|
||||
worker.set_tool_output_limits(wm.tool_output.as_ref().map(|limits| ToolOutputLimits {
|
||||
default_max_bytes: limits.default_max_bytes,
|
||||
per_tool: limits.per_tool.clone(),
|
||||
}));
|
||||
}
|
||||
|
||||
/// Result of a Pod run.
|
||||
|
||||
Reference in New Issue
Block a user