Report context usage to the client after each model response

sigit now sends a usage_update after every model response in a turn, so an editor can show a context meter. `size` is the model's context window from the picker list. `used` is the endpoint's own count when it gives one: a streamed request now asks for it with stream_options.include_usage and reads it off the final chunk, and the JSON path reads `usage` from the response. With no count, which is always the case on-device, the history is estimated the way compaction does it. An endpoint that rejects stream_options as unknown is asked again without it, and the backend stops sending it from then on. Closes #194 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 9, 2026 at 00:20 UTC b422457bda7e61a14b9ea453425b35444c2663d1
4 files changed +352 -4
.agents/AGENTS.md
+6
@@ -144,6 +144,12 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
144 endpoint can also fail *after* the response is open, reporting it as a `data:` frame holding
145 the same envelope; that frame has no `choices`, so `consume_stream` has to check for it
146 explicitly or it parses as an empty chunk and the turn ends looking like an empty answer.
147 + `TurnResult::context_tokens` carries the endpoint's token count (prompt plus completion) up
148 + to the loop, which sends it as a `usage_update` after each model response, with the
149 + model's picker `context_window_tokens` as `size`; with no count (always on-device) the
150 + history is estimated instead. A streamed request asks for the count with
151 + `stream_options.include_usage`, and an endpoint that rejects the option is asked again
152 + without it and never asked again (`stream_usage_refused`).
153 `TurnResult::finish` carries the endpoint's `finish_reason` up to the loop, and
154 `stop_reason_for` in `main.rs` turns it into the ACP stop reason: the tool-round cap is
155 `max_turn_requests`, `length` is `max_tokens`, `content_filter` is `refusal`. ACP defines a
src/backend.rs
+242 -3
@@ -19,6 +19,7 @@
19 #![cfg_attr(not(unix), allow(dead_code))]
20
21 use std::sync::Arc;
22 +use std::sync::atomic::{AtomicBool, Ordering};
23
24 use async_trait::async_trait;
25 use onde::inference::{ChatEngine, ChatMessage, ChatRole, EngineStatus, ToolDefinition};
@@ -61,6 +62,10 @@ pub struct TurnResult {
62 /// Why the model stopped. Only the last round of a turn decides how the
63 /// turn is reported; a round that goes on to run tools is not the end.
64 pub finish: FinishReason,
65 + /// The context in use once this reply is in, as the endpoint counted it:
66 + /// prompt plus completion tokens. `None` when nothing was reported, which
67 + /// is always the case on-device.
68 + pub context_tokens: Option<u64>,
69 }
70
71 /// Why the model stopped generating, reduced to what the agent loop acts on.
@@ -914,6 +919,7 @@ async fn drain_onde_stream(
919 text,
920 tool_calls: Vec::new(),
921 finish,
922 + context_tokens: None,
923 })
924 }
925
@@ -921,6 +927,7 @@ async fn drain_onde_stream(
927 fn onde_result_to_turn(result: onde::inference::ToolAwareResult) -> TurnResult {
928 TurnResult {
929 finish: FinishReason::from_wire(Some(&result.finish_reason)),
930 + context_tokens: None,
931 text: result.text,
932 tool_calls: result
933 .tool_calls
@@ -955,6 +962,9 @@ pub struct OpenAiBackend {
962 /// Whether `model` takes audio (see `provider::model_accepts_audio`), with
963 /// the same effect on audio already in `history`.
964 accepts_audio: bool,
965 + /// Set once the endpoint has refused `stream_options`, so streamed
966 + /// requests stop asking it for token usage.
967 + stream_usage_refused: Arc<AtomicBool>,
968 }
969
970 impl OpenAiBackend {
@@ -980,6 +990,7 @@ impl OpenAiBackend {
990 model,
991 http: reqwest::Client::new(),
992 history: Mutex::new(history),
993 + stream_usage_refused: Arc::default(),
994 }
995 }
996
@@ -1059,6 +1070,7 @@ impl OpenAiBackend {
1070 text: join_reply_text(&result.text, &retry.text),
1071 tool_calls: retry.tool_calls,
1072 finish: retry.finish,
1073 + context_tokens: retry.context_tokens,
1074 })
1075 }
1076
@@ -1091,6 +1103,12 @@ impl OpenAiBackend {
1103 "messages": messages,
1104 "stream": streaming,
1105 });
1106 + // A stream reports no token usage unless asked to. It comes in a last
1107 + // chunk with no choices, which feeds the client's context meter.
1108 + let ask_for_usage = streaming && !self.stream_usage_refused.load(Ordering::Relaxed);
1109 + if ask_for_usage {
1110 + body["stream_options"] = serde_json::json!({ "include_usage": true });
1111 + }
1112 if allow_tool_calls && !tools.is_empty() {
1113 body["tools"] = serde_json::Value::Array(Self::tools_json(tools));
1114 // OpenAI specifies `auto` as the default when tools are present,
@@ -1101,7 +1119,7 @@ impl OpenAiBackend {
1119 body["tool_choice"] = serde_json::Value::String("auto".to_string());
1120 }
1121
1104 - let response = self
1122 + let mut response = self
1123 .http
1124 .post(&url)
1125 .bearer_auth(&self.api_key)
@@ -1112,8 +1130,30 @@ impl OpenAiBackend {
1130
1131 if !response.status().is_success() {
1132 let status = response.status();
1115 - let body = response.text().await.unwrap_or_default();
1116 - return Err(describe_api_error(status, &body));
1133 + let error_body = response.text().await.unwrap_or_default();
1134 + // Usage is a nicety; an endpoint that does not know the option is
1135 + // asked again without it, and never asked for it again.
1136 + if !(ask_for_usage && refuses_stream_options(status, &error_body)) {
1137 + return Err(describe_api_error(status, &error_body));
1138 + }
1139 + log::info!("{url} refused stream_options; streaming without token usage");
1140 + self.stream_usage_refused.store(true, Ordering::Relaxed);
1141 + if let Some(fields) = body.as_object_mut() {
1142 + fields.remove("stream_options");
1143 + }
1144 + response = self
1145 + .http
1146 + .post(&url)
1147 + .bearer_auth(&self.api_key)
1148 + .json(&body)
1149 + .send()
1150 + .await
1151 + .map_err(|error| format!("request to {url} failed: {error}"))?;
1152 + if !response.status().is_success() {
1153 + let status = response.status();
1154 + let error_body = response.text().await.unwrap_or_default();
1155 + return Err(describe_api_error(status, &error_body));
1156 + }
1157 }
1158
1159 if let Some(sink) = sink {
@@ -1189,6 +1229,7 @@ impl OpenAiBackend {
1229 .json()
1230 .await
1231 .map_err(|error| format!("response parse error: {error}"))?;
1232 + let context_tokens = parsed.usage.as_ref().map(Usage::context_tokens);
1233
1234 let choice = parsed
1235 .choices
@@ -1243,6 +1284,7 @@ impl OpenAiBackend {
1284 text,
1285 tool_calls: Vec::new(),
1286 finish,
1287 + context_tokens,
1288 },
1289 malformed,
1290 ));
@@ -1282,6 +1324,7 @@ impl OpenAiBackend {
1324 text: cleaned,
1325 tool_calls,
1326 finish,
1327 + context_tokens,
1328 },
1329 malformed,
1330 ));
@@ -1295,6 +1338,7 @@ impl OpenAiBackend {
1338 text: cleaned,
1339 tool_calls,
1340 finish,
1341 + context_tokens,
1342 },
1343 malformed,
1344 ));
@@ -1324,6 +1368,7 @@ impl OpenAiBackend {
1368 text: extracted.text,
1369 tool_calls,
1370 finish,
1371 + context_tokens,
1372 },
1373 malformed,
1374 ));
@@ -1343,6 +1388,7 @@ impl OpenAiBackend {
1388 text,
1389 tool_calls,
1390 finish,
1391 + context_tokens,
1392 },
1393 0,
1394 ))
@@ -1381,6 +1427,8 @@ impl OpenAiBackend {
1427 let mut done = false;
1428 // Arrives on a late chunk, usually one with an empty delta.
1429 let mut finish = FinishReason::default();
1430 + // Arrives on the last chunk, which has no choices, when asked for.
1431 + let mut context_tokens = None;
1432 // Recovers a tool call the model wrote as literal `<tool_call>` text
1433 // instead of a structured delta. Scanning here (rather than after the
1434 // stream) keeps the tag off the UI: content goes straight to `sink` as
@@ -1435,6 +1483,9 @@ impl OpenAiBackend {
1483 Err(_) => continue,
1484 };
1485
1486 + if let Some(usage) = &chunk.usage {
1487 + context_tokens = Some(usage.context_tokens());
1488 + }
1489 let Some(choice) = chunk.choices.into_iter().next() else {
1490 continue;
1491 };
@@ -1603,6 +1654,7 @@ impl OpenAiBackend {
1654 text,
1655 tool_calls,
1656 finish,
1657 + context_tokens,
1658 },
1659 malformed,
1660 ))
@@ -1781,6 +1833,8 @@ impl InferenceBackend for OpenAiBackend {
1833 history: Mutex::new(Vec::new()),
1834 accepts_images: self.accepts_images,
1835 accepts_audio: self.accepts_audio,
1836 + // It is the same endpoint, so what it refused it refuses here.
1837 + stream_usage_refused: Arc::clone(&self.stream_usage_refused),
1838 }))
1839 }
1840
@@ -2012,6 +2066,33 @@ fn is_probable_html(body: &str) -> bool {
2066 struct ChatCompletion {
2067 #[serde(default)]
2068 choices: Vec<CompletionChoice>,
2069 + #[serde(default)]
2070 + usage: Option<Usage>,
2071 +}
2072 +
2073 +/// Token usage as an OpenAI-compatible endpoint reports it.
2074 +#[derive(Debug, Deserialize)]
2075 +struct Usage {
2076 + #[serde(default)]
2077 + prompt_tokens: u64,
2078 + #[serde(default)]
2079 + completion_tokens: u64,
2080 + #[serde(default)]
2081 + total_tokens: Option<u64>,
2082 +}
2083 +
2084 +impl Usage {
2085 + /// The context in use once the reply is in.
2086 + fn context_tokens(&self) -> u64 {
2087 + self.total_tokens
2088 + .unwrap_or(self.prompt_tokens + self.completion_tokens)
2089 + }
2090 +}
2091 +
2092 +/// Whether a failed request was turned down for carrying `stream_options`,
2093 +/// which some OpenAI-compatible servers predate and reject as unknown.
2094 +fn refuses_stream_options(status: reqwest::StatusCode, body: &str) -> bool {
2095 + matches!(status.as_u16(), 400 | 422) && body.contains("stream_options")
2096 }
2097
2098 #[derive(Debug, Deserialize)]
@@ -2076,6 +2157,8 @@ struct ResponseFunction {
2157 struct StreamCompletion {
2158 #[serde(default)]
2159 choices: Vec<StreamChoice>,
2160 + #[serde(default)]
2161 + usage: Option<Usage>,
2162 }
2163
2164 #[derive(Debug, Deserialize)]
@@ -2999,6 +3082,162 @@ mod tests {
3082 assert!(shown.contains("stopped this reply"), "{shown}");
3083 }
3084
3085 + /// Serves `responses` in order, one per connection, as `(status,
3086 + /// content type, body)`, and hands back each request body.
3087 + fn spawn_scripted_stub(
3088 + responses: Vec<(u16, &'static str, String)>,
3089 + ) -> (std::net::SocketAddr, std::sync::mpsc::Receiver<String>) {
3090 + use std::io::{Read, Write};
3091 +
3092 + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3093 + let addr = listener.local_addr().unwrap();
3094 + let (sender, receiver) = std::sync::mpsc::channel();
3095 + std::thread::spawn(move || {
3096 + for (status, content_type, body) in responses {
3097 + let Ok((mut stream, _)) = listener.accept() else {
3098 + return;
3099 + };
3100 + let mut request = Vec::new();
3101 + let mut chunk = [0u8; 4096];
3102 + loop {
3103 + let n = stream.read(&mut chunk).unwrap_or(0);
3104 + if n == 0 {
3105 + break;
3106 + }
3107 + request.extend_from_slice(&chunk[..n]);
3108 + if let Some(headers_end) =
3109 + request.windows(4).position(|window| window == b"\r\n\r\n")
3110 + {
3111 + let headers = String::from_utf8_lossy(&request[..headers_end]);
3112 + let content_length = headers
3113 + .lines()
3114 + .find_map(|line| {
3115 + line.to_ascii_lowercase()
3116 + .strip_prefix("content-length:")
3117 + .map(|value| value.trim().parse::<usize>().unwrap_or(0))
3118 + })
3119 + .unwrap_or(0);
3120 + if request.len() >= headers_end + 4 + content_length {
3121 + let _ = sender.send(
3122 + String::from_utf8_lossy(&request[headers_end + 4..]).into_owned(),
3123 + );
3124 + break;
3125 + }
3126 + }
3127 + }
3128 + let response = format!(
3129 + "HTTP/1.1 {status} X\r\ncontent-type: {content_type}\r\n\
3130 + content-length: {}\r\nconnection: close\r\n\r\n{}",
3131 + body.len(),
3132 + body
3133 + );
3134 + let _ = stream.write_all(response.as_bytes());
3135 + }
3136 + });
3137 + (addr, receiver)
3138 + }
3139 +
3140 + fn sse(events: &[serde_json::Value]) -> String {
3141 + let mut body: String = events
3142 + .iter()
3143 + .map(|event| format!("data: {event}\n\n"))
3144 + .collect();
3145 + body.push_str("data: [DONE]\n\n");
3146 + body
3147 + }
3148 +
3149 + /// A stream asks for token usage and reads it off the last chunk, the
3150 + /// one with no choices, for the client's context meter (issue #194).
3151 + #[tokio::test]
3152 + async fn a_stream_reports_the_usage_it_was_asked_for() {
3153 + let (addr, requests) = spawn_scripted_stub(vec![(
3154 + 200,
3155 + "text/event-stream",
3156 + sse(&[
3157 + serde_json::json!({"choices": [{"delta": {"content": "Hi."}}]}),
3158 + serde_json::json!({
3159 + "choices": [],
3160 + "usage": {"prompt_tokens": 1200, "completion_tokens": 34, "total_tokens": 1234},
3161 + }),
3162 + ]),
3163 + )]);
3164 + let backend =
3165 + OpenAiBackend::new(format!("http://{addr}/v1"), "test-key", "test-model", None);
3166 + let (sink, _chunks) = tokio::sync::mpsc::unbounded_channel();
3167 +
3168 + let result = backend
3169 + .send_message_with_tools("hello", &[], Some(&sink))
3170 + .await
3171 + .unwrap();
3172 +
3173 + assert_eq!(result.text, "Hi.");
3174 + assert_eq!(result.context_tokens, Some(1234));
3175 + let request: serde_json::Value = serde_json::from_str(&requests.recv().unwrap()).unwrap();
3176 + assert_eq!(request["stream_options"]["include_usage"], true);
3177 + }
3178 +
3179 + #[tokio::test]
3180 + async fn a_json_reply_reports_its_usage() {
3181 + let body = serde_json::json!({
3182 + "choices": [{"message": {"role": "assistant", "content": "Hi."}}],
3183 + "usage": {"prompt_tokens": 90, "completion_tokens": 10},
3184 + })
3185 + .to_string();
3186 + let (addr, requests) = spawn_scripted_stub(vec![(200, "application/json", body)]);
3187 + let backend =
3188 + OpenAiBackend::new(format!("http://{addr}/v1"), "test-key", "test-model", None);
3189 +
3190 + let result = backend
3191 + .send_message_with_tools("hello", &[], None)
3192 + .await
3193 + .unwrap();
3194 +
3195 + // No total given: prompt plus completion.
3196 + assert_eq!(result.context_tokens, Some(100));
3197 + let request: serde_json::Value = serde_json::from_str(&requests.recv().unwrap()).unwrap();
3198 + assert!(request.get("stream_options").is_none(), "{request}");
3199 + }
3200 +
3201 + /// An endpoint that predates `stream_options` is asked again without it,
3202 + /// and not asked for usage again after that.
3203 + #[tokio::test]
3204 + async fn an_endpoint_that_refuses_stream_options_still_streams() {
3205 + let refusal = serde_json::json!({
3206 + "error": {"message": "Unrecognized request argument supplied: stream_options"},
3207 + })
3208 + .to_string();
3209 + let reply = sse(&[serde_json::json!({"choices": [{"delta": {"content": "Hi."}}]})]);
3210 + let (addr, requests) = spawn_scripted_stub(vec![
3211 + (400, "application/json", refusal),
3212 + (200, "text/event-stream", reply.clone()),
3213 + (200, "text/event-stream", reply),
3214 + ]);
3215 + let backend =
3216 + OpenAiBackend::new(format!("http://{addr}/v1"), "test-key", "test-model", None);
3217 + let (sink, _chunks) = tokio::sync::mpsc::unbounded_channel();
3218 +
3219 + let first = backend
3220 + .send_message_with_tools("hello", &[], Some(&sink))
3221 + .await
3222 + .unwrap();
3223 + assert_eq!(first.text, "Hi.");
3224 + assert_eq!(first.context_tokens, None);
3225 + let second = backend
3226 + .send_message_with_tools("again", &[], Some(&sink))
3227 + .await
3228 + .unwrap();
3229 + assert_eq!(second.text, "Hi.");
3230 +
3231 + let bodies: Vec<serde_json::Value> = requests
3232 + .try_iter()
3233 + .map(|body| serde_json::from_str(&body).unwrap())
3234 + .collect();
3235 + assert_eq!(bodies.len(), 3);
3236 + assert!(bodies[0].get("stream_options").is_some());
3237 + assert!(bodies[1].get("stream_options").is_none());
3238 + assert!(bodies[2].get("stream_options").is_none());
3239 + }
3240 +
3241 #[test]
3242 fn finish_reasons_map_from_the_wire() {
3243 for reason in [None, Some("stop"), Some("tool_calls"), Some("unheard-of")] {
src/main.rs
+45 -1
@@ -93,7 +93,7 @@ use agent_client_protocol::schema::v1::{
93 SessionNotification, SessionResumeCapabilities, SessionUpdate, SetSessionConfigOptionRequest,
94 SetSessionConfigOptionResponse, SetSessionModeRequest, SetSessionModeResponse, StopReason,
95 Terminal, TerminalOutputRequest, ToolCall, ToolCallContent, ToolCallLocation, ToolCallStatus,
96 - ToolCallUpdate, ToolCallUpdateFields, ToolKind, UnstructuredCommandInput,
96 + ToolCallUpdate, ToolCallUpdateFields, ToolKind, UnstructuredCommandInput, UsageUpdate,
97 WaitForTerminalExitRequest, WriteTextFileRequest,
98 };
99 use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo, Responder};
@@ -3405,6 +3405,7 @@ impl SiGitAgent {
3405 let max_tool_rounds = headless::max_tool_rounds_from_env();
3406
3407 let model_name = self.current_model.lock().unwrap().display_name.clone();
3408 + let context_window = self.context_window_tokens();
3409 let earlier_images = if backend.accepts_images() {
3410 0
3411 } else {
@@ -3518,6 +3519,9 @@ impl SiGitAgent {
3519 }
3520 };
3521
3522 + self.send_usage(cx, &session_id, backend.as_ref(), &result, context_window)
3523 + .await;
3524 +
3525 let mut round = 0;
3526 // Set when the repetition guard blocked a call; names the tool.
3527 let mut stopped_repeating: Option<String> = None;
@@ -3902,6 +3906,8 @@ impl SiGitAgent {
3906 return Err(agent_client_protocol::Error::new(-32603, error));
3907 }
3908 };
3909 + self.send_usage(cx, &session_id, backend.as_ref(), &result, context_window)
3910 + .await;
3911 }
3912
3913 // ── Final text response ───────────────────────────────────────────
@@ -4013,6 +4019,44 @@ impl SiGitAgent {
4019 Ok(PromptResponse::new(stop_reason))
4020 }
4021
4022 + /// The context window of the session's model, for `usage_update`. A model
4023 + /// the picker does not list (a provider-supplied name, say) falls back to
4024 + /// the compaction budget, the same as the TUI's gauge.
4025 + fn context_window_tokens(&self) -> u64 {
4026 + let current = self.current_model.lock().unwrap().clone();
4027 + models::build_model_picker_items()
4028 + .into_iter()
4029 + .find(|item| {
4030 + item.config.model_id == current.model_id
4031 + || item.display_name == current.display_name
4032 + })
4033 + .map(|item| item.context_window_tokens)
4034 + .unwrap_or(backend::DEFAULT_CONTEXT_TOKEN_BUDGET as u64)
4035 + }
4036 +
4037 + /// Report how much of the context window the session uses after one
4038 + /// model response. The endpoint's own count is used when it gave one;
4039 + /// otherwise (always on-device) the history is estimated, the way
4040 + /// compaction does it.
4041 + async fn send_usage(
4042 + &self,
4043 + cx: &ConnectionTo<Client>,
4044 + session_id: &SessionId,
4045 + backend: &dyn InferenceBackend,
4046 + result: &TurnResult,
4047 + size: u64,
4048 + ) {
4049 + let used = match result.context_tokens {
4050 + Some(tokens) => tokens,
4051 + None => backend::estimate_tokens(&backend.history_snapshot().await) as u64,
4052 + };
4053 + cx.send_notification(SessionNotification::new(
4054 + session_id.clone(),
4055 + SessionUpdate::UsageUpdate(UsageUpdate::new(used, size)),
4056 + ))
4057 + .ok();
4058 + }
4059 +
4060 /// Ask the ACP client for permission to run one tool call. The request
4061 /// names the call by `tool_call_id`, the id it was announced under, so the
4062 /// client updates that one card instead of drawing a second. Presents
tests/acp_permissions.rs
+59
@@ -1225,6 +1225,65 @@ fn chunks_of_one_model_message_share_a_message_id() {
1225 let _ = std::fs::remove_dir_all(&scratch);
1226 }
1227
1228 +/// After each model response the client hears how much of the context window
1229 +/// the session uses: the endpoint's own count when it gives one, an estimate
1230 +/// when it does not (issue #194).
1231 +#[test]
1232 +fn each_model_response_reports_context_usage() {
1233 + let endpoint = start_fake_endpoint(vec![
1234 + // No usage on this one.
1235 + sse_tool_call("call_1", "list_directory", r#"{"path":"."}"#),
1236 + sse_body(&[
1237 + json!({"choices": [{"delta": {"content": "Done."}}]}),
1238 + json!({"choices": [], "usage": {"prompt_tokens": 1500, "completion_tokens": 21}}),
1239 + ]),
1240 + ]);
1241 +
1242 + let scratch = std::env::temp_dir().join(format!("sigit_acp_usage_{}", std::process::id()));
1243 + let config_dir = scratch.join("config");
1244 + let cwd = scratch.join("cwd");
1245 + std::fs::create_dir_all(&config_dir).unwrap();
1246 + std::fs::create_dir_all(&cwd).unwrap();
1247 +
1248 + let mut agent = spawn_agent(endpoint.port, &config_dir);
1249 +
1250 + let id = agent.request(
1251 + "initialize",
1252 + json!({"protocolVersion": 1, "clientCapabilities": {}}),
1253 + );
1254 + agent.wait_for_response(id);
1255 +
1256 + let id = agent.request("session/new", json!({"cwd": cwd, "mcpServers": []}));
1257 + let session_id = agent.wait_for_response(id)["result"]["sessionId"]
1258 + .as_str()
1259 + .expect("session id")
1260 + .to_string();
1261 +
1262 + let prompt_id = agent.request(
1263 + "session/prompt",
1264 + json!({
1265 + "sessionId": session_id,
1266 + "prompt": [{"type": "text", "text": "look around"}],
1267 + }),
1268 + );
1269 + let (_, updates) = agent.wait_for_response_with_updates(prompt_id);
1270 +
1271 + let usage: Vec<&Value> = updates
1272 + .iter()
1273 + .filter(|update| update["sessionUpdate"] == "usage_update")
1274 + .collect();
1275 + assert_eq!(usage.len(), 2, "one per model response: {usage:?}");
1276 + let estimated = usage[0]["used"].as_u64().expect("used");
1277 + assert!(estimated > 0, "an estimate stands in for a missing count");
1278 + assert_eq!(usage[1]["used"], 1521, "the endpoint's own count wins");
1279 + for update in &usage {
1280 + assert!(update["size"].as_u64().unwrap_or_default() > 0, "{update}");
1281 + }
1282 +
1283 + drop(agent);
1284 + let _ = std::fs::remove_dir_all(&scratch);
1285 +}
1286 +
1287 /// A model that writes its tool call out as literal `<tool_call>` text instead
1288 /// of using the structured field must still drive the loop. Before recovery
1289 /// the tag was streamed to the client as prose and the turn ended with no tool