vtcode_core/tools/registry/
completion_verification.rs1use std::collections::{HashSet, VecDeque};
4
5use serde_json::Value;
6
7use super::ToolRegistry;
8use crate::config::constants::tools;
9use crate::tools::{command_args, tool_intent};
10
11const OPTIONAL_EVIDENCE_LIMIT: usize = 32;
12
13#[derive(Debug)]
14struct VerifierEvidence {
15 identity: u64,
16 generation: u64,
17 command: String,
18 session_id: Option<String>,
19 exit_code: Option<i64>,
20}
21
22struct RestoredToolCall<'a> {
23 name: &'a str,
24 args: Value,
25}
26
27#[derive(Clone, Copy)]
28enum MutationEvidenceSource {
29 Live,
30 Restored,
31}
32
33#[derive(Debug, Default)]
34pub(super) struct CompletionVerification {
35 generation: u64,
36 history_initialized: bool,
37 next_identity: u64,
38 evidence: VecDeque<VerifierEvidence>,
39 required_commands: Vec<String>,
40 pending_mutation_sessions: HashSet<String>,
41}
42
43impl CompletionVerification {
44 fn mutate(&mut self) {
45 self.generation = self.generation.saturating_add(1);
46 }
47
48 fn launch(&mut self, command: String) -> u64 {
49 self.next_identity = self.next_identity.saturating_add(1);
50 self.evidence.retain(|entry| entry.command != command);
52 self.evidence.push_back(VerifierEvidence {
53 identity: self.next_identity,
54 generation: self.generation,
55 command,
56 session_id: None,
57 exit_code: None,
58 });
59 self.prune_optional_evidence();
60 self.next_identity
61 }
62
63 fn prune_optional_evidence(&mut self) {
64 let mut pinned = HashSet::new();
65 for command in &self.required_commands {
66 let command = executed_command_identity(command);
67 let mut newest = true;
68 for entry in self
69 .evidence
70 .iter()
71 .rev()
72 .filter(|entry| command_covers_required(&entry.command, &command))
73 {
74 if newest || entry.session_id.is_some() {
77 pinned.insert(entry.identity);
78 }
79 newest = false;
80 }
81 }
82 let mut to_remove = self
83 .evidence
84 .iter()
85 .filter(|entry| !pinned.contains(&entry.identity))
86 .count()
87 .saturating_sub(OPTIONAL_EVIDENCE_LIMIT);
88 self.evidence.retain(|entry| {
91 if to_remove == 0 || pinned.contains(&entry.identity) {
92 true
93 } else {
94 to_remove -= 1;
95 false
96 }
97 });
98 }
99
100 fn observe(&mut self, identity: u64, output: &Value) {
101 let Some(evidence) = self.evidence.iter_mut().find(|entry| entry.identity == identity) else {
102 return;
103 };
104 if output.get("error").is_some()
105 || output.get("success").and_then(Value::as_bool) == Some(false)
106 || ["blocked", "not_executed", "cancelled", "termination_requested"]
107 .iter()
108 .any(|key| output.get(key).and_then(Value::as_bool) == Some(true))
109 {
110 evidence.session_id = None;
111 evidence.exit_code = None;
112 self.prune_optional_evidence();
113 return;
114 }
115 if evidence
116 .session_id
117 .as_deref()
118 .is_some_and(|expected| command_args::session_id_text(output).is_some_and(|returned| returned != expected))
119 {
120 return;
121 }
122 if let Some(exit_code) = output.get("exit_code").and_then(Value::as_i64) {
123 evidence.exit_code = Some(exit_code);
124 evidence.session_id = None;
125 } else if let Some(session_id) = command_args::session_id_text(output) {
126 evidence.session_id = Some(session_id.to_owned());
127 }
128 self.prune_optional_evidence();
129 }
130
131 fn command_passed(&self, command: &str) -> bool {
132 let command = executed_command_identity(command);
133 self.evidence
134 .iter()
135 .rev()
136 .find(|entry| command_covers_required(&entry.command, &command))
137 .is_some_and(|entry| entry.generation == self.generation && entry.exit_code == Some(0))
138 }
139
140 fn requires_command(&self, command: &str) -> bool {
141 let command = executed_command_identity(command);
142 self.required_commands
143 .iter()
144 .any(|required| command_covers_required(&command, &executed_command_identity(required)))
145 }
146
147 fn pending(&self) -> bool {
148 if !self.pending_mutation_sessions.is_empty() {
149 return true;
150 }
151 if self.generation == 0 {
152 return false;
153 }
154 if !self.required_commands.is_empty() {
155 return self.required_commands.iter().any(|command| !self.command_passed(command));
156 }
157 !self
158 .evidence
159 .back()
160 .is_some_and(|entry| entry.generation == self.generation && entry.exit_code == Some(0))
161 }
162}
163
164fn executed_command_identity(command: &str) -> String {
165 let args = serde_json::json!({"action":"run", "command":command});
166 let executed = tool_intent::shell_args_as_executed(tools::UNIFIED_EXEC, &args);
167 command_args::raw_command_text(&executed)
168 .unwrap_or_else(|| command.to_owned())
169 .trim()
170 .to_owned()
171}
172
173fn command_covers_required(command: &str, required: &str) -> bool {
174 command == required
175 || (command.contains("&&") && tool_intent::activity::shell_command_covers_required_check(command, required))
176}
177
178fn is_session_cleanup(args: &Value) -> bool {
179 tool_intent::is_exec_session_cleanup_call(tools::WRITE_STDIN, args)
180 || matches!(tool_intent::command_session_action(args), Some("close" | "terminate"))
181}
182
183fn mutation_targets_are_exempt(name: &str, args: &Value, output: &Value) -> bool {
184 let exempt = |tool_name: &str, targets: &Value| {
185 tool_intent::is_plan_artifact_write(tool_name, targets) || tool_intent::is_docs_only_write(tool_name, targets)
186 };
187 if !exempt(name, args) {
188 return false;
189 }
190 let effective_paths: Vec<_> = ["path", "from", "to"]
193 .iter()
194 .filter_map(|key| output.get(*key).and_then(Value::as_str))
195 .chain(
196 output
197 .get("modified_files")
198 .and_then(Value::as_array)
199 .into_iter()
200 .flatten()
201 .filter_map(Value::as_str),
202 )
203 .collect();
204 effective_paths.is_empty() || exempt(tools::WRITE_FILE, &serde_json::json!({"paths":effective_paths}))
205}
206
207impl ToolRegistry {
208 pub fn restore_completion_verification_from_history(&self, history: &[crate::llm::provider::Message]) {
210 use crate::llm::provider::MessageRole;
211 {
212 let mut state = self.harness_context.completion_verification.lock();
213 if state.history_initialized {
214 return;
215 }
216 state.history_initialized = true;
217 if state.generation != 0 || !state.evidence.is_empty() {
220 return;
221 }
222 }
223 let mut pending = std::collections::HashMap::new();
224 for message in history {
225 if message.role == MessageRole::Assistant {
226 pending.clear();
227 for call in message.tool_calls.iter().flatten() {
228 if let Some(function) = &call.function
229 && let Ok(args) = serde_json::from_str::<Value>(&function.arguments)
230 {
231 pending.insert(call.id.as_str(), RestoredToolCall { name: function.name.as_str(), args });
232 }
233 }
234 } else if message.role == MessageRole::Tool
235 && let Some(call) = message.tool_call_id.as_deref().and_then(|id| pending.remove(id))
236 && let Ok(output) = serde_json::from_str::<Value>(message.content.as_text().as_ref())
237 {
238 if self.tracker_adopted_for_request() && call.name == tools::TASK_TRACKER {
239 self.observe_required_completion_verifiers(&output);
240 }
241 if output.get("cleared").is_some() {
242 self.observe_completion_mutation_from(
243 call.name,
244 &call.args,
245 &serde_json::json!({"success":true,"exit_code":0}),
246 MutationEvidenceSource::Restored,
247 );
248 } else {
249 self.observe_completion_mutation_from(
250 call.name,
251 &call.args,
252 &output,
253 MutationEvidenceSource::Restored,
254 );
255 }
256 }
257 }
258 }
259
260 pub fn completion_verification_pending(&self) -> bool {
262 self.harness_context.completion_verification.lock().pending()
263 }
264
265 pub(super) fn observe_required_completion_verifiers(&self, output: &Value) {
266 let Some(items) = output
267 .get("checklist")
268 .and_then(|checklist| checklist.get("items"))
269 .and_then(Value::as_array)
270 else {
271 return;
272 };
273 let mut commands = Vec::new();
274 for command in items
275 .iter()
276 .filter_map(|item| item.get("verify"))
277 .filter_map(Value::as_array)
278 .flatten()
279 .filter_map(Value::as_str)
280 {
281 let command = command.trim();
282 if !command.is_empty() && !commands.iter().any(|existing| existing == command) {
283 commands.push(command.to_owned());
284 }
285 }
286 self.set_required_completion_verifiers(commands);
287 }
288
289 pub(super) fn set_required_completion_verifiers(&self, commands: Vec<String>) {
291 let mut state = self.harness_context.completion_verification.lock();
292 state.required_commands = commands;
293 state.prune_optional_evidence();
294 }
295
296 pub fn completion_verifier_passed(&self, command: &str) -> bool {
297 self.harness_context.completion_verification.lock().command_passed(command)
298 }
299
300 pub(crate) fn missing_required_completion_verifiers(&self) -> Vec<String> {
301 let state = self.harness_context.completion_verification.lock();
302 state
303 .required_commands
304 .iter()
305 .filter(|command| !state.command_passed(command))
306 .cloned()
307 .collect()
308 }
309
310 pub fn pending_completion_verifier_session(&self, command: &str) -> Option<String> {
312 let command = executed_command_identity(command);
313 let state = self.harness_context.completion_verification.lock();
314 state
315 .evidence
316 .iter()
317 .rev()
318 .find(|entry| command_covers_required(&entry.command, &command) && entry.generation == state.generation)
319 .and_then(|entry| entry.session_id.clone())
320 }
321
322 pub fn next_required_completion_verifier(&self) -> Option<String> {
323 let state = self.harness_context.completion_verification.lock();
324 state
325 .required_commands
326 .iter()
327 .find(|command| !state.command_passed(command))
328 .cloned()
329 }
330
331 pub fn restore_completion_verification_debt(&self) {
334 let mut state = self.harness_context.completion_verification.lock();
335 if state.generation == 0 {
336 state.mutate();
337 }
338 }
339
340 pub fn unverified_completion_reason(&self) -> String {
341 let state = self.harness_context.completion_verification.lock();
342 let evidence = state
343 .evidence
344 .back()
345 .map(|entry| {
346 format!(
347 " Last command: `{}`; terminal exit: {:?}; launch generation: {}; current generation: {}.",
348 entry.command, entry.exit_code, entry.generation, state.generation
349 )
350 })
351 .unwrap_or_else(|| " No terminal verifier evidence was observed.".to_string());
352 let remaining = state.required_commands.iter().find(|command| !state.command_passed(command));
353 let required = remaining.map_or_else(String::new, |command| {
354 let preview = vtcode_commons::formatting::truncate_within(command, 1024, "...");
355 format!(
356 " Next required tracker check: `{preview}`. Run this exact command; a different passing check does not satisfy it."
357 )
358 });
359 format!(
360 "Verification is still pending: Unverified: changed code requires fresh verification before successful completion.{evidence}{required}"
361 )
362 }
363
364 pub(super) fn begin_completion_verifier(&self, args: &Value) -> Option<u64> {
365 let command = self.completion_verifier_command(args)?;
366 Some(self.harness_context.completion_verification.lock().launch(command))
367 }
368
369 pub fn is_completion_verifier_call(&self, name: &str, args: &Value) -> bool {
371 tool_intent::is_command_tool(crate::tools::names::canonical_tool_name(name))
372 && self.completion_verifier_command(args).is_some()
373 }
374
375 fn completion_verifier_command(&self, args: &Value) -> Option<String> {
376 let executed = tool_intent::shell_args_as_executed(tools::UNIFIED_EXEC, args);
377 let command = command_args::raw_command_text(&executed)?.trim().to_owned();
378 if tool_intent::readonly::static_shell_command_words(&command)
381 .or_else(|| tool_intent::readonly::static_shell_command_words_with_output_plumbing(&command))
382 .is_some_and(|segments| segments.iter().any(|words| command_args::command_uses_source_repair(words)))
383 {
384 return None;
385 }
386 if tool_intent::classify_shell_activity(tools::UNIFIED_EXEC, &executed)
387 != tool_intent::ShellActivity::Verification
388 && !self.harness_context.completion_verification.lock().requires_command(&command)
389 {
390 return None;
391 }
392 let cwd = self
393 .pty_manager()
394 .working_dir_candidate(command_args::working_dir_text(args))
395 .ok()?;
396 if cwd != *self.workspace_root() {
397 return None;
398 }
399 if args
402 .get("args")
403 .is_some_and(|value| !matches!(value, Value::Array(args) if args.is_empty()))
404 || args.get("env").is_some_and(|value| match value {
405 Value::Array(entries) => !entries.is_empty(),
406 Value::Object(env) => !env.is_empty(),
407 _ => true,
408 })
409 || ["shell", "login", "stdin"].iter().any(|key| args.get(key).is_some())
410 {
411 return None;
412 }
413 Some(command)
414 }
415
416 pub(super) fn observe_completion_verifier(
417 &self,
418 launch: Option<u64>,
419 args: &Value,
420 result: &anyhow::Result<Value>,
421 ) {
422 let mut state = self.harness_context.completion_verification.lock();
423 if let Some(session_id) = command_args::session_id_text(args)
424 && state.pending_mutation_sessions.contains(session_id)
425 && result.as_ref().is_ok_and(|output| {
426 !command_args::session_id_text(output).is_some_and(|returned| returned != session_id)
427 && (output.get("exit_code").and_then(Value::as_i64).is_some()
428 || (is_session_cleanup(args)
429 && output.get("error").is_none()
430 && output.get("success").and_then(Value::as_bool) != Some(false)))
431 })
432 {
433 state.pending_mutation_sessions.remove(session_id);
434 state.mutate();
436 }
437 let identity = launch.or_else(|| {
438 let session_id = command_args::session_id_text(args)?;
439 if tool_intent::classify_tool_intent(tools::UNIFIED_EXEC, args).mutating && !is_session_cleanup(args) {
440 return None;
441 }
442 state
443 .evidence
444 .iter()
445 .rev()
446 .find(|entry| entry.session_id.as_deref() == Some(session_id))
447 .map(|entry| entry.identity)
448 });
449 let Some(identity) = identity else {
450 return;
451 };
452 match result {
453 Ok(output) if !is_session_cleanup(args) => state.observe(identity, output),
454 _ => {
455 if let Some(entry) = state.evidence.iter_mut().find(|entry| entry.identity == identity) {
457 entry.session_id = None;
458 entry.exit_code = None;
459 }
460 state.prune_optional_evidence();
461 }
462 }
463 }
464
465 pub(super) fn observe_completion_mutation(&self, name: &str, args: &Value, output: &Value) {
466 self.observe_completion_mutation_from(name, args, output, MutationEvidenceSource::Live);
467 }
468
469 pub(super) fn observe_completion_exec_sessions_terminated(&self, session_ids: &[String]) {
479 for session_id in session_ids {
480 let args = serde_json::json!({"action":"close", "session_id":session_id});
481 let result: anyhow::Result<Value> = Ok(serde_json::json!({"success":true}));
482 self.observe_completion_verifier(None, &args, &result);
483 }
484 }
485
486 fn observe_completion_mutation_from(
487 &self,
488 name: &str,
489 args: &Value,
490 output: &Value,
491 source: MutationEvidenceSource,
492 ) {
493 let name = crate::tools::names::canonical_tool_name(name);
494 if !tool_intent::is_command_tool(name)
497 && !tool_intent::is_edited_file_conflict_guarded_call(name, args)
498 && !matches!(
499 name,
500 tools::DELETE_FILE | tools::MOVE_FILE | tools::COPY_FILE | tools::SEARCH_REPLACE | tools::UNIFIED_FILE
501 )
502 && !output
503 .get("modified_files")
504 .and_then(Value::as_array)
505 .is_some_and(|files| !files.is_empty())
506 {
507 return;
508 }
509 let exec_changed = tool_intent::is_command_tool(name) && tool_intent::activity::exec_output_has_changes(output);
510 if !exec_changed
511 && (mutation_targets_are_exempt(name, args, output)
512 || !tool_intent::classify_tool_intent(name, args).mutating
513 || output.get("error").is_some()
514 || output.get("success").and_then(Value::as_bool) == Some(false)
515 || ["blocked", "not_executed", "cancelled"]
516 .iter()
517 .any(|key| output.get(key).and_then(Value::as_bool) == Some(true)))
518 {
519 return;
520 }
521 if tool_intent::is_command_tool(name) && !exec_changed {
522 let executed = tool_intent::shell_args_as_executed(name, args);
523 if tool_intent::classify_shell_activity(name, &executed) != tool_intent::ShellActivity::Mutation
524 || tool_intent::shell_command_is_admitted_verification_attempt(&executed)
525 || self.completion_verifier_command(args).is_some()
528 || (output.get("exit_code").and_then(Value::as_i64) != Some(0)
529 && command_args::session_id_text(output).is_none())
530 {
531 return;
532 }
533 if let Some(command_text) = command_args::raw_command_text(&executed)
536 .or_else(|| command_args::shell_command_text_with_args(&executed))
537 && !tool_intent::activity::exec_mutation_should_incur_debt(&command_text, output)
538 {
539 return;
540 }
541 }
542 match crate::tools::file_ops::diff_output_has_effective_change(output) {
543 Some(false) if !exec_changed => return,
544 Some(false) | Some(true) => {}
545 None if output.get("success").and_then(Value::as_bool) != Some(true)
546 && output.get("exit_code").and_then(Value::as_i64) != Some(0)
547 && command_args::session_id_text(output).is_none()
548 && !output
549 .get("modified_files")
550 .and_then(Value::as_array)
551 .is_some_and(|files| !files.is_empty()) =>
552 {
553 return;
554 }
555 None => {}
556 }
557 let mut state = self.harness_context.completion_verification.lock();
558 state.mutate();
559 if matches!(source, MutationEvidenceSource::Live)
560 && tool_intent::is_command_tool(name)
561 && output.get("exit_code").and_then(Value::as_i64).is_none()
562 && let Some(session_id) = command_args::session_id_text(output)
563 {
564 state.pending_mutation_sessions.insert(session_id.to_owned());
565 }
566 }
567}
568
569#[cfg(test)]
570mod tests;