1use async_trait::async_trait;
20use serde_json::{json, Value};
21use tracing::Instrument;
22
23use crate::tool_repair::{self, ToolInputRepair};
24use crate::tools::{
25 clip_overflow, invalid_input_failure, ToolInvocation, ToolOutcome, ToolRuntime,
26 ToolRuntimeError, ToolSpec, MAX_OUTPUT_BYTES,
27};
28
29const CATCH_ALL_CEILING: usize = 4 * MAX_OUTPUT_BYTES;
35
36#[derive(Clone)]
39pub struct BoundedToolRuntime<R> {
40 inner: R,
41 specs: Vec<ToolSpec>,
45}
46
47impl<R: ToolRuntime> BoundedToolRuntime<R> {
48 pub fn new(inner: R) -> Self {
49 let specs = inner.specs();
50 Self { inner, specs }
51 }
52
53 pub fn inner(&self) -> &R {
56 &self.inner
57 }
58
59 fn schema_for(&self, name: &str) -> Option<&Value> {
60 self.specs
61 .iter()
62 .find(|s| s.name == name)
63 .map(|s| &s.input_schema)
64 }
65}
66
67#[async_trait]
68impl<R: ToolRuntime> ToolRuntime for BoundedToolRuntime<R> {
69 fn specs(&self) -> Vec<ToolSpec> {
70 self.inner.specs()
71 }
72
73 fn repair_invocation(&self, inv: &mut ToolInvocation) -> Option<Vec<ToolInputRepair>> {
76 let schema = self.schema_for(&inv.name)?;
77 let (fixed, repairs) = tool_repair::repair_tool_input_for_spec(schema, &inv.input)?;
78 inv.input = fixed;
79 Some(repairs)
80 }
81
82 async fn invoke(&self, inv: ToolInvocation) -> Result<ToolOutcome, ToolRuntimeError> {
83 self.invoke_cancellable(inv, None).await
84 }
85
86 async fn invoke_cancellable(
87 &self,
88 mut inv: ToolInvocation,
89 cancel: Option<&tokio_util::sync::CancellationToken>,
90 ) -> Result<ToolOutcome, ToolRuntimeError> {
91 let span = tracing::info_span!("tool.invoke", tool = %inv.name, id = %inv.id);
92 async move {
93 if let Some(repairs) = self.repair_invocation(&mut inv) {
95 tracing::warn!(
96 target: "harness::tool_repair",
97 tool = %inv.name,
98 id = %inv.id,
99 repairs = ?repairs,
100 "schema-guided tool input repair applied"
101 );
102 }
103
104 if let Some(schema) = self.schema_for(&inv.name) {
107 if let Err(detail) = tool_repair::validate_against_schema(schema, &inv.input) {
108 return Ok(ToolOutcome {
109 output: Err(invalid_input_failure(
110 &inv.name,
111 detail,
112 &inv.input,
113 Some(schema),
114 )),
115 attachments: vec![],
116 });
117 }
118 }
119
120 let call_id = inv.id.clone();
122 let mut outcome = self.inner.invoke_cancellable(inv, cancel).await?;
123
124 if let Ok(value) = &outcome.output {
126 let serialized = value.to_string();
127 if serialized.len() > CATCH_ALL_CEILING {
128 tracing::warn!(
129 target: "harness::tool_bound",
130 id = %call_id,
131 bytes = serialized.len(),
132 "tool output exceeded catch-all ceiling; clipping"
133 );
134 outcome.output = Ok(json!({
135 "tool_output_clipped": true,
136 "preview": clip_overflow(&serialized),
137 }));
138 }
139 }
140 Ok(outcome)
141 }
142 .instrument(span)
143 .await
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150 use crate::tools::{ToolFailure, ToolFailureKind};
151
152 struct BigOutput;
155
156 #[async_trait]
157 impl ToolRuntime for BigOutput {
158 fn specs(&self) -> Vec<ToolSpec> {
159 vec![ToolSpec {
160 name: "big".into(),
161 description: "returns a huge blob".into(),
162 input_schema: json!({
163 "type": "object",
164 "properties": { "n": { "type": "integer" } },
165 "required": ["n"],
166 "additionalProperties": false
167 }),
168 }]
169 }
170
171 async fn invoke(&self, _inv: ToolInvocation) -> Result<ToolOutcome, ToolRuntimeError> {
172 let blob = "x".repeat(CATCH_ALL_CEILING + 1_000);
173 Ok(ToolOutcome {
174 output: Ok(json!({ "blob": blob })),
175 attachments: vec![],
176 })
177 }
178 }
179
180 fn inv(name: &str, input: Value) -> ToolInvocation {
181 ToolInvocation {
182 id: "tc_1".into(),
183 name: name.into(),
184 input,
185 raw_emitted_args: None,
186 }
187 }
188
189 #[tokio::test]
190 async fn invalid_input_returns_teaching_failure_with_example() {
191 let rt = BoundedToolRuntime::new(BigOutput);
192 let out = rt.invoke(inv("big", json!({}))).await.unwrap();
194 let ToolFailure { kind, message } = out.output.unwrap_err();
195 assert_eq!(kind, ToolFailureKind::InvalidInput);
196 assert!(message.contains("Expected shape"), "msg: {message}");
197 assert!(message.contains("\"n\""), "msg: {message}");
198 }
199
200 #[tokio::test]
201 async fn oversized_success_output_is_clipped() {
202 let rt = BoundedToolRuntime::new(BigOutput);
203 let out = rt.invoke(inv("big", json!({ "n": 1 }))).await.unwrap();
204 let value = out.output.unwrap();
205 assert_eq!(value["tool_output_clipped"], true);
206 let preview = value["preview"].as_str().unwrap();
207 assert!(preview.len() < CATCH_ALL_CEILING, "preview not clipped");
208 assert!(preview.contains("output clipped"));
209 }
210
211 #[tokio::test]
212 async fn repair_invocation_is_idempotent() {
213 let rt = BoundedToolRuntime::new(BigOutput);
214 let mut i = inv("big", json!({ "n": "5" }));
216 let first = rt.repair_invocation(&mut i);
217 assert!(first.is_some(), "expected a repair on first pass");
218 assert_eq!(i.input["n"], json!(5));
219 assert!(rt.repair_invocation(&mut i).is_none());
221 }
222}