1use std::collections::BTreeSet;
8
9use crate::app::{AgentEvent, ToolStatus};
10use crate::tools::{WriteResult, shell::ProcessResult, shell::redact_secrets};
11use agent_client_protocol::schema::v1::ToolCallLocation;
12
13const MAX_TOOL_FIELD_CHARS: usize = 4096;
14const MAX_TOOL_LOCATIONS: usize = 6;
15const MAX_TOOL_PATH_TOKENS: usize = 12;
16
17#[derive(Clone, Debug, Eq, PartialEq)]
19pub enum SessionUpdateIntent {
20 AssistantDelta(String),
22 ReasoningDelta(String),
24 Status(String),
26 Usage {
28 input_tokens: u64,
30 output_tokens: u64,
32 },
33 ToolStarted {
35 id: String,
37 name: String,
39 arguments: String,
41 kind: ToolCallKind,
43 locations: Vec<ToolCallLocation>,
45 },
46 ToolFinished {
48 id: String,
50 status: ToolStatusIntent,
52 output: Vec<String>,
54 kind: ToolCallKind,
56 locations: Vec<ToolCallLocation>,
58 },
59 Failed(String),
61 Cancelled,
63 Finished,
65}
66
67#[derive(Clone, Copy, Debug, Eq, PartialEq)]
69pub enum ToolStatusIntent {
70 InProgress,
72 Completed,
74 Failed,
76}
77
78impl From<ToolStatus> for ToolStatusIntent {
79 fn from(status: ToolStatus) -> Self {
80 Self::from_tool_status(status)
81 }
82}
83
84impl ToolStatusIntent {
85 pub const fn from_tool_status(status: ToolStatus) -> Self {
87 match status {
88 ToolStatus::Ok => Self::Completed,
89 ToolStatus::Failed => Self::Failed,
90 ToolStatus::Cancelled => Self::Failed,
91 ToolStatus::Running => Self::InProgress,
92 }
93 }
94}
95
96#[derive(Clone, Copy, Debug, Eq, PartialEq)]
98pub enum ToolCallKind {
99 Read,
101 Edit,
103 Search,
105 Execute,
107 Fetch,
109 Think,
111 Other,
113}
114
115impl ToolCallKind {
116 pub const fn requires_permission(self) -> bool {
118 matches!(self, Self::Edit | Self::Execute)
119 }
120
121 pub const fn to_acp_kind(self) -> agent_client_protocol::schema::v1::ToolKind {
123 use agent_client_protocol::schema::v1::ToolKind;
124 match self {
125 Self::Read => ToolKind::Read,
126 Self::Edit => ToolKind::Edit,
127 Self::Search => ToolKind::Search,
128 Self::Execute => ToolKind::Execute,
129 Self::Fetch => ToolKind::Fetch,
130 Self::Think => ToolKind::Think,
131 Self::Other => ToolKind::Other,
132 }
133 }
134}
135
136pub const fn requires_permission(kind: ToolCallKind) -> bool {
138 kind.requires_permission()
139}
140
141pub fn classify_tool(name: &str) -> ToolCallKind {
143 match name {
144 "find_files" | "list_searchable_files" | "read_file_range" | "read" | "sawk" | "acp.fs.read_text_file" => {
145 ToolCallKind::Read
146 }
147 "search_text" | "web_search" => ToolCallKind::Search,
148 "create_file" | "replace_range" | "write_patch" | "acp.fs.write_text_file" => ToolCallKind::Edit,
149 "run_shell" | "acp.terminal" => ToolCallKind::Execute,
150 "read_url" | "fetch_url" | "http_request" => ToolCallKind::Fetch,
151 "plan" | "think" => ToolCallKind::Think,
152 _ => ToolCallKind::Other,
153 }
154}
155
156pub fn map_agent_event(event: &AgentEvent) -> Vec<SessionUpdateIntent> {
161 match event {
162 AgentEvent::AssistantDelta(text) => vec![SessionUpdateIntent::AssistantDelta(text.clone())],
163 AgentEvent::ReasoningDelta(text) => vec![SessionUpdateIntent::ReasoningDelta(text.clone())],
164 AgentEvent::Status(text) => vec![SessionUpdateIntent::Status(text.clone())],
165 AgentEvent::Usage { input_tokens, output_tokens } => {
166 vec![SessionUpdateIntent::Usage { input_tokens: *input_tokens, output_tokens: *output_tokens }]
167 }
168 AgentEvent::RequestAccounting(accounting) => {
169 let Some(usage) = &accounting.provider_usage else {
170 return vec![];
171 };
172 vec![SessionUpdateIntent::Usage {
173 input_tokens: usage.components.input_tokens.unwrap_or(0),
174 output_tokens: usage.components.output_tokens.unwrap_or(0),
175 }]
176 }
177 AgentEvent::ToolStarted { id, name, arguments } => {
178 let kind = classify_tool(name);
179 vec![SessionUpdateIntent::ToolStarted {
180 id: id.clone(),
181 name: name.clone(),
182 arguments: sanitize_tool_payload(arguments),
183 kind,
184 locations: infer_tool_locations_from_arguments(name, arguments),
185 }]
186 }
187 AgentEvent::ToolFinished { id, status, output, write_result, shell_result, .. } => {
188 let kind = infer_tool_kind_from_result(write_result.as_ref(), shell_result.as_deref());
189 vec![SessionUpdateIntent::ToolFinished {
190 id: id.clone(),
191 status: (*status).into(),
192 output: sanitize_tool_outputs(output),
193 kind,
194 locations: infer_tool_locations(output, write_result.as_ref(), shell_result.as_deref()),
195 }]
196 }
197 AgentEvent::Failed(message) => vec![SessionUpdateIntent::Failed(message.clone())],
198 AgentEvent::Cancelled => vec![SessionUpdateIntent::Cancelled],
199 AgentEvent::Finished => vec![SessionUpdateIntent::Finished],
200 AgentEvent::Started
201 | AgentEvent::ModelMetadataLoaded(_)
202 | AgentEvent::Retrying { .. }
203 | AgentEvent::PermissionRequest(_)
204 | AgentEvent::PermissionResolved { .. }
205 | AgentEvent::AcpSession(_) => vec![],
206 }
207}
208
209fn infer_tool_kind_from_result(
210 write_result: Option<&WriteResult>, shell_result: Option<&ProcessResult>,
211) -> ToolCallKind {
212 match write_result {
213 Some(_) => ToolCallKind::Edit,
214 None => match shell_result {
215 Some(_) => ToolCallKind::Execute,
216 None => ToolCallKind::Other,
217 },
218 }
219}
220
221fn infer_tool_locations(
222 output: &[String], write_result: Option<&WriteResult>, shell_result: Option<&ProcessResult>,
223) -> Vec<ToolCallLocation> {
224 let mut locations = BTreeSet::new();
225 if let Some(result) = write_result {
226 locations.insert((result.path.display().to_string(), None));
227 }
228 if let Some(result) = shell_result {
229 let cwd = result.cwd.to_string_lossy().to_string();
230 if !cwd.is_empty() {
231 locations.insert((cwd, None));
232 }
233 }
234
235 for line in output {
236 if locations.len() >= MAX_TOOL_LOCATIONS {
237 break;
238 }
239 collect_shell_path_candidates(line, &mut locations);
240 }
241
242 locations
243 .into_iter()
244 .filter_map(tool_call_location)
245 .take(MAX_TOOL_LOCATIONS)
246 .collect()
247}
248
249fn infer_tool_locations_from_arguments(tool_name: &str, arguments: &str) -> Vec<ToolCallLocation> {
250 let mut locations = BTreeSet::new();
251
252 if let Ok(value) = serde_json::from_str::<serde_json::Value>(arguments) {
253 collect_location_values(&value, None, &mut locations);
254 }
255 if matches!(tool_name, "run_shell") && locations.is_empty() {
256 collect_shell_path_candidates(arguments, &mut locations);
257 }
258
259 locations
260 .into_iter()
261 .filter_map(tool_call_location)
262 .take(MAX_TOOL_LOCATIONS)
263 .collect()
264}
265
266fn collect_shell_path_candidates(line: &str, locations: &mut BTreeSet<(String, Option<u32>)>) {
267 let mut accepted = 0;
268 for token in line.split_whitespace() {
269 if accepted >= MAX_TOOL_PATH_TOKENS || locations.len() >= MAX_TOOL_LOCATIONS {
270 return;
271 }
272 let token = token.trim_matches(|c| c == '"' || c == '\'');
273 if is_file_like_path(token) {
274 let _ = locations.insert((token.to_string(), None));
275 accepted += 1;
276 }
277 }
278}
279
280fn collect_location_values(
281 value: &serde_json::Value, line_hint: Option<u32>, locations: &mut BTreeSet<(String, Option<u32>)>,
282) {
283 match value {
284 serde_json::Value::Object(entries) => {
285 let mut current_line = line_hint;
286 if let Some(line) = entries.get("line").and_then(as_u32) {
287 current_line = Some(line);
288 }
289 if let Some(line) = entries.get("start_line").and_then(as_u32) {
290 current_line = Some(line);
291 }
292
293 for (key, child) in entries {
294 if is_path_key(key) {
295 collect_locations_for_key(key, child, current_line, locations);
296 } else {
297 collect_location_values(child, current_line, locations);
298 }
299 }
300 }
301 serde_json::Value::Array(values) => {
302 for child in values {
303 collect_location_values(child, line_hint, locations);
304 }
305 }
306 _ => {}
307 }
308}
309
310fn collect_locations_for_key(
311 _key: &str, value: &serde_json::Value, line_hint: Option<u32>, locations: &mut BTreeSet<(String, Option<u32>)>,
312) {
313 match value {
314 serde_json::Value::String(path) if is_path_like_value(path) => {
315 if is_file_like_path(path) {
316 locations.insert((path.to_string(), line_hint));
317 }
318 }
319 serde_json::Value::Array(values) => {
320 for child in values {
321 if let serde_json::Value::String(path) = child
322 && is_file_like_path(path)
323 {
324 locations.insert((path.to_string(), line_hint));
325 }
326 }
327 }
328 serde_json::Value::Object(_) => {
329 collect_location_values(value, line_hint, locations);
330 }
331 _ => {}
332 }
333}
334
335fn is_path_key(key: &str) -> bool {
336 matches!(
337 key,
338 "path"
339 | "paths"
340 | "file"
341 | "filename"
342 | "source"
343 | "source_path"
344 | "target"
345 | "target_path"
346 | "destination"
347 | "destination_path"
348 | "old_path"
349 | "new_path"
350 | "cwd"
351 | "directory"
352 | "dir"
353 )
354}
355
356fn is_path_like_value(value: &str) -> bool {
357 if value.is_empty() {
358 return false;
359 }
360
361 let trimmed = value.trim();
362 !trimmed.starts_with('-') && !trimmed.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
363}
364
365fn is_file_like_path(value: &str) -> bool {
366 let trimmed = value.trim();
367 if trimmed.is_empty() {
368 return false;
369 }
370
371 trimmed.contains('/') || trimmed.contains('.') || trimmed == ".." || trimmed == "."
372}
373
374fn as_u32(value: &serde_json::Value) -> Option<u32> {
375 value.as_u64().and_then(|value| u32::try_from(value).ok())
376}
377
378fn tool_call_location((path, line): (String, Option<u32>)) -> Option<ToolCallLocation> {
379 let trimmed = path.trim();
380 if trimmed.is_empty() {
381 return None;
382 }
383
384 Some(ToolCallLocation::new(trimmed).line(line))
385}
386
387pub fn sanitize_tool_payload(payload: &str) -> String {
388 sanitize_tool_text(payload)
389}
390
391fn sanitize_tool_outputs(outputs: &[String]) -> Vec<String> {
392 outputs.iter().map(|line| sanitize_tool_text(line)).collect()
393}
394
395fn sanitize_tool_text(raw: &str) -> String {
396 let value = redact_sensitive_payload(raw);
397 cap_value(&value)
398}
399
400fn redact_sensitive_payload(value: &str) -> String {
401 match serde_json::from_str::<serde_json::Value>(value) {
402 Ok(json) => redact_json_value(json).to_string(),
403 Err(_) => redact_secrets(value),
404 }
405}
406
407fn redact_json_value(value: serde_json::Value) -> serde_json::Value {
408 match value {
409 serde_json::Value::String(text) => serde_json::Value::String(redact_secrets(&text)),
410 serde_json::Value::Array(items) => serde_json::Value::Array(items.into_iter().map(redact_json_value).collect()),
411 serde_json::Value::Object(entries) => serde_json::Value::Object(
412 entries
413 .into_iter()
414 .map(|(key, value)| {
415 let redacted = if key_is_sensitive(&key) {
416 serde_json::Value::String(String::from("[REDACTED]"))
417 } else {
418 redact_json_value(value)
419 };
420 (key, redacted)
421 })
422 .collect(),
423 ),
424 _ => value,
425 }
426}
427
428fn key_is_sensitive(key: &str) -> bool {
429 matches!(
430 key.to_ascii_lowercase().as_str(),
431 "password" | "passwd" | "api_key" | "apikey" | "access_token" | "secret" | "token"
432 )
433}
434
435fn cap_value(value: &str) -> String {
436 let char_len = value.chars().count();
437 if char_len <= MAX_TOOL_FIELD_CHARS {
438 return value.to_string();
439 }
440
441 let truncated: String = value.chars().take(MAX_TOOL_FIELD_CHARS).collect();
442 format!("{truncated}...[truncated]")
443}