返回 CodeWhale
verify.rs
根目录 / crates / tui / src / tools / verify.rs
1 //! `verify` — agent-callable adversarial self-critique (#4196).
2 //!
3 //! This tool lets the agent DECIDE to spend extra test-time compute on a
4 //! self-review of its own recent work before claiming a change done. It runs
5 //! an INDEPENDENT adversarial critic pass at elevated reasoning (High/Max,
6 //! regardless of the session tier) whose job is to REFUTE the agent's claim —
7 //! surfacing correctness gaps, missed requirements, and edge cases as
8 //! structured findings the agent must then address.
9 //!
10 //! # Why elevated reasoning is the mechanism
11 //!
12 //! The critic request explicitly sets `reasoning_effort` to a high tier
13 //! ([`VerifyTool::critic_effort`], default [`ReasoningEffort::Max`]). Elevated
14 //! reasoning IS the test-time-compute lever, so the critic never inherits a low
15 //! session tier — [`build_critic_request`] threads the effort onto the outgoing
16 //! [`MessageRequest`], which the client forwards to the provider.
17 //!
18 //! # Bounded / no runaway (hard requirement)
19 //!
20 //! A verify call must not be able to trigger another verify. Two independent
21 //! guards enforce this:
22 //!
23 //! 1. **Structural (primary):** the critic is a single model call with
24 //! `tools: None` (see [`build_critic_request`]). With no tools of any kind,
25 //! the critic literally cannot invoke `verify` — recursion is impossible by
26 //! construction, not by a denylist that could be forgotten.
27 //! 2. **Re-entry guard (defense in depth):** [`VerifyTool::execute`] refuses if
28 //! it is entered while a critique is already in progress on the same task
29 //! (tracked via the [`static@VERIFY_ACTIVE`] task-local). This protects any
30 //! future path that might run the critic inside a tool loop.
31 //!
32 //! # Relationship to neighbouring tools
33 //!
34 //! - `review` critiques a specific target (file/diff/PR) as a code review.
35 //! - `run_verifiers` executes external test/build gates (pytest, cargo, …).
36 //! - `verify` (this tool) is an adversarial reasoning pass over a *claim* and
37 //! its supporting evidence — "is what I just did actually correct and
38 //! complete?" — not a linter and not a test runner.
39
40 use std::path::Path;
41
42 use async_trait::async_trait;
43 use serde::{Deserialize, Serialize};
44 use serde_json::{Value, json};
45
46 use crate::client::DeepSeekClient;
47 use crate::dependencies::ExternalTool;
48 use crate::features::Feature;
49 use crate::llm_client::LlmClient;
50 use crate::models::{ContentBlock, Message, MessageRequest, SystemPrompt, Usage};
51 use crate::tui::app::ReasoningEffort;
52 use crate::utils::truncate_with_ellipsis;
53
54 use super::spec::{
55 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
56 optional_str, required_str,
57 };
58
59 /// Total evidence budget handed to the critic. Kept well under a turn so the
60 /// critic has room to reason. Large diffs/files are truncated with a marker.
61 const DEFAULT_MAX_EVIDENCE_CHARS: usize = 120_000;
62 /// Per-file evidence cap so a single huge file can't crowd out the rest.
63 const PER_FILE_MAX_CHARS: usize = 40_000;
64 /// Response budget for the critic's structured JSON.
65 const CRITIC_MAX_TOKENS: u32 = 2_048;
66 /// Cap on the raw-text fallback summary when the critic returns non-JSON.
67 const FALLBACK_SUMMARY_MAX_CHARS: usize = 4_000;
68
69 // Task-local marker set for the duration of a critic pass. Presence means "a
70 // verify critique is already running on this task", which `VerifyTool::execute`
71 // treats as illegal re-entry. This is defense-in-depth on top of the structural
72 // `tools: None` guard in `build_critic_request`.
73 tokio::task_local! {
74 static VERIFY_ACTIVE: ();
75 }
76
77 const CRITIC_SYSTEM_PROMPT: &str = "You are an adversarial critic performing a rigorous \
78 self-review of a code change on behalf of the engineer who wrote it. Your job is to REFUTE the \
79 claim, not to praise it. Assume the change is WRONG or INCOMPLETE until the evidence proves \
80 otherwise.\n\
81 \n\
82 Hunt specifically for: correctness bugs; requirements that are only partially met or silently \
83 dropped; unhandled edge cases (empty / huge / malformed input, concurrency and re-entrancy, \
84 error and failure paths, off-by-one, integer overflow, null/None); regressions in existing \
85 behaviour; and tests that pass but assert the wrong thing (green-CI-but-wrong). Prefer a small \
86 number of concrete, evidence-backed findings over vague concerns. Cite `path:line` from the \
87 evidence whenever you can. If, after a genuine effort to break it, you cannot refute the claim, \
88 say so honestly rather than inventing problems.\n\
89 \n\
90 Return ONLY valid JSON (no prose, no markdown fences) matching this schema:\n\
91 {\n\
92 \"verdict\": \"refuted\" | \"upheld\" | \"uncertain\",\n\
93 \"summary\": \"<= 3 sentence adversarial assessment\",\n\
94 \"findings\": [\n\
95 {\n\
96 \"severity\": \"critical\" | \"high\" | \"medium\" | \"low\",\n\
97 \"issue\": \"what is wrong or unproven\",\n\
98 \"evidence\": \"where/why, path:line when possible\",\n\
99 \"suggested_fix\": \"concrete, actionable fix\"\n\
100 }\n\
101 ],\n\
102 \"unresolved_risk\": true | false\n\
103 }\n\
104 Set verdict=refuted if you found at least one critical or high finding; upheld only if you \
105 genuinely could not refute the claim; uncertain if the evidence was insufficient to decide. Set \
106 unresolved_risk=true whenever any unaddressed correctness risk remains.";
107
108 /// A single adversarial finding the agent should address.
109 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
110 pub struct CritiqueFinding {
111 /// Normalized to one of `critical` / `high` / `medium` / `low`.
112 #[serde(default)]
113 pub severity: String,
114 /// What is wrong or unproven.
115 #[serde(default)]
116 pub issue: String,
117 /// Where/why, ideally `path:line`.
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub evidence: Option<String>,
120 /// Concrete, actionable fix.
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub suggested_fix: Option<String>,
123 }
124
125 /// Structured result of a verify/critique pass.
126 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
127 pub struct CritiqueReport {
128 /// `refuted` (found a real problem), `upheld` (could not refute), or
129 /// `uncertain` (insufficient evidence / unstructured critic output).
130 #[serde(default)]
131 pub verdict: String,
132 /// Short adversarial assessment.
133 #[serde(default)]
134 pub summary: String,
135 /// Concrete findings the agent must address before claiming done.
136 #[serde(default)]
137 pub findings: Vec<CritiqueFinding>,
138 /// True whenever an unaddressed correctness risk remains. Forced `true` by
139 /// any finding at `medium` severity or above (only `low` nits are exempt),
140 /// regardless of what the critic self-reported, so "green-but-wrong"
141 /// changes are not waved through.
142 #[serde(default)]
143 pub unresolved_risk: bool,
144 }
145
146 impl CritiqueReport {
147 /// Parse the critic's raw response text into a structured report, tolerating
148 /// bare JSON, a fenced ```json block, or free-form prose (fallback).
149 #[must_use]
150 pub fn from_model_text(raw: &str) -> Self {
151 if let Some(parsed) = parse_report_json(raw) {
152 return parsed.normalize();
153 }
154 if let Some(block) = extract_json_block(raw)
155 && let Some(parsed) = parse_report_json(block)
156 {
157 return parsed.normalize();
158 }
159 Self::fallback(raw).normalize()
160 }
161
162 /// The critic returned something we could not parse as JSON. Fail safe:
163 /// treat it as unresolved risk rather than a clean bill of health.
164 fn fallback(raw: &str) -> Self {
165 let trimmed = raw.trim();
166 let summary = if trimmed.is_empty() {
167 "Critic returned no output; treat the change as unverified.".to_string()
168 } else {
169 format!(
170 "Critic returned unstructured output (treated as unresolved risk):\n{}",
171 truncate_with_ellipsis(trimmed, FALLBACK_SUMMARY_MAX_CHARS, "\n...[truncated]\n")
172 )
173 };
174 Self {
175 verdict: "uncertain".to_string(),
176 summary,
177 findings: Vec::new(),
178 unresolved_risk: true,
179 }
180 }
181
182 /// Canonicalize severities/verdict and derive a fail-safe `unresolved_risk`.
183 fn normalize(mut self) -> Self {
184 self.summary = self.summary.trim().to_string();
185 for finding in &mut self.findings {
186 finding.severity = normalize_severity(&finding.severity);
187 finding.issue = finding.issue.trim().to_string();
188 finding.evidence = normalize_optional(finding.evidence.take());
189 finding.suggested_fix = normalize_optional(finding.suggested_fix.take());
190 }
191
192 // `has_serious` (critical/high) refutes the claim outright.
193 let has_serious = self
194 .findings
195 .iter()
196 .any(|f| matches!(f.severity.as_str(), "critical" | "high"));
197 // `has_blocking` (medium-or-above, i.e. anything that is not a `low`
198 // nit) is a real unresolved correctness concern: it must force
199 // `unresolved_risk` and forbid an `upheld` verdict, even if the critic
200 // self-reported `unresolved_risk = false`. This closes the green-but-
201 // wrong gap where verdict=upheld + a MEDIUM finding + critic-set
202 // `unresolved_risk=false` reported "no unresolved risk". `low`-only
203 // findings are intentionally exempt so nits don't block a claim.
204 let has_blocking = self
205 .findings
206 .iter()
207 .any(|f| matches!(f.severity.as_str(), "critical" | "high" | "medium"));
208
209 // Verdict: honour an explicit, recognized value; otherwise infer.
210 // Precedence: any serious finding => refuted; else any blocking
211 // (medium) finding => uncertain (never upheld); else honour the critic.
212 self.verdict = match self.verdict.trim().to_ascii_lowercase().as_str() {
213 "refuted" | "rejected" | "fail" | "failed" => "refuted".to_string(),
214 "upheld" | "confirmed" | "pass" | "passed" | "ok" => {
215 if has_serious {
216 "refuted".to_string()
217 } else if has_blocking {
218 // A medium finding is a genuine open concern — the claim
219 // cannot be "upheld" while it stands.
220 "uncertain".to_string()
221 } else {
222 "upheld".to_string()
223 }
224 }
225 "" => {
226 if has_serious {
227 "refuted".to_string()
228 } else {
229 "uncertain".to_string()
230 }
231 }
232 _ => "uncertain".to_string(),
233 };
234
235 // Fail safe: any medium-or-above finding => unresolved risk, regardless
236 // of what the model set.
237 self.unresolved_risk = self.unresolved_risk || has_blocking;
238 self
239 }
240
241 /// Highest severity present, or "none".
242 #[must_use]
243 fn highest_severity(&self) -> &'static str {
244 for level in ["critical", "high", "medium", "low"] {
245 if self.findings.iter().any(|f| f.severity == level) {
246 return level;
247 }
248 }
249 "none"
250 }
251 }
252
253 /// Evidence gathered by the tool and handed to the critic.
254 struct CritiqueInput {
255 claim: String,
256 requirement: Option<String>,
257 focus: Option<String>,
258 evidence: Vec<EvidenceBlock>,
259 /// True when no diff or file contents could be gathered.
260 no_code_evidence: bool,
261 }
262
263 struct EvidenceBlock {
264 label: String,
265 body: String,
266 }
267
268 /// Outcome of a single critic invocation, plus accounting for metadata.
269 struct CritiqueRun {
270 report: CritiqueReport,
271 usage: Usage,
272 }
273
274 /// Agent-callable adversarial self-critique tool.
275 pub struct VerifyTool {
276 client: Option<DeepSeekClient>,
277 model: String,
278 /// Reasoning tier the critic runs at, independent of the session tier.
279 critic_effort: ReasoningEffort,
280 }
281
282 impl VerifyTool {
283 /// Construct with the default critic effort ([`ReasoningEffort::Max`]).
284 #[must_use]
285 pub fn new(client: Option<DeepSeekClient>, model: String) -> Self {
286 Self {
287 client,
288 model,
289 critic_effort: ReasoningEffort::Max,
290 }
291 }
292
293 /// Override the critic reasoning tier. Values below `High` are clamped up to
294 /// `High` — elevated reasoning is the whole point of this tool. This is the
295 /// seam for a future `[verify] critic_effort` config knob; production
296 /// registration currently uses the `Max` default from [`Self::new`].
297 #[allow(dead_code)]
298 #[must_use]
299 pub fn with_critic_effort(mut self, effort: ReasoningEffort) -> Self {
300 self.critic_effort = clamp_to_elevated(effort);
301 self
302 }
303 }
304
305 #[async_trait]
306 impl ToolSpec for VerifyTool {
307 fn name(&self) -> &'static str {
308 "verify"
309 }
310
311 fn description(&self) -> &'static str {
312 "Run an INDEPENDENT adversarial critic over your own recent work before you claim it is \
313 done. You state a claim (what you believe your change accomplishes) plus optional scope (the \
314 recent git diff, specific files, the original requirement); an independent critic runs at \
315 elevated reasoning and tries to REFUTE it, returning structured findings (issue, severity, \
316 suggested fix). Call this when it is worth spending extra thinking: before claiming a non-trivial \
317 change complete, after a risky or subtle edit, or when you are unsure the change fully satisfies \
318 the requirement and handles edge cases. Skip it for trivial or mechanical changes. This is not a \
319 test runner (use run_verifiers) or a code review of an arbitrary target (use review) — it is a \
320 self-check of whether what you just did is actually correct and complete."
321 }
322
323 fn input_schema(&self) -> Value {
324 json!({
325 "type": "object",
326 "properties": {
327 "claim": {
328 "type": "string",
329 "description": "What you believe your recent change accomplishes and why it is correct and complete. State it as an assertion the critic will try to REFUTE."
330 },
331 "requirement": {
332 "type": "string",
333 "description": "Optional: the original requirement / task / acceptance criteria the change must satisfy. The critic checks the change against THIS, not against your restatement of it."
334 },
335 "scope": {
336 "type": "string",
337 "enum": ["diff", "staged", "none"],
338 "default": "diff",
339 "description": "Code evidence to gather for the critic. 'diff' = uncommitted working-tree changes; 'staged' = git staged changes; 'none' = rely only on `files` and the claim text."
340 },
341 "base": {
342 "type": "string",
343 "description": "Optional git base ref for the diff (e.g. origin/main). Defaults to the plain working-tree/staged diff."
344 },
345 "files": {
346 "type": "array",
347 "items": { "type": "string" },
348 "description": "Optional explicit file paths (relative to the workspace) whose current contents to include as evidence."
349 },
350 "focus": {
351 "type": "string",
352 "description": "Optional: a specific risk to scrutinize (e.g. 'concurrency', 'the empty-input case', 'error handling on network failure')."
353 }
354 },
355 "required": ["claim"]
356 })
357 }
358
359 fn capabilities(&self) -> Vec<ToolCapability> {
360 // Read-only: it inspects the workspace (git diff, file reads) and calls
361 // the model. It never mutates the workspace.
362 vec![ToolCapability::ReadOnly, ToolCapability::Network]
363 }
364
365 fn approval_requirement(&self) -> ApprovalRequirement {
366 ApprovalRequirement::Auto
367 }
368
369 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
370 // Opt-out (defense in depth; the primary gate is registration-time in
371 // `with_agent_runtime_surface`). Honours `[features] verify_tool = false`
372 // and lets saved-transcript replays respect a disabled toggle.
373 if !context.features.enabled(Feature::Verify) {
374 return Err(ToolError::not_available(
375 "verify tool is disabled ([features] verify_tool = false)".to_string(),
376 ));
377 }
378
379 // Re-entry guard: refuse if a critique is already running on this task.
380 // Checked BEFORE anything else so it cannot be bypassed via a missing
381 // client or bad input.
382 if VERIFY_ACTIVE.try_with(|_| ()).is_ok() {
383 return Err(ToolError::not_available(
384 "verify cannot run inside its own critic pass (recursion guard)".to_string(),
385 ));
386 }
387
388 // Validate the request shape before checking client availability, so a
389 // malformed call gets a precise input error rather than a generic
390 // "no client" one.
391 let claim = required_str(&input, "claim")?.trim().to_string();
392 if claim.is_empty() {
393 return Err(ToolError::invalid_input("claim cannot be empty"));
394 }
395 let requirement = optional_str(&input, "requirement")?
396 .map(str::trim)
397 .filter(|s| !s.is_empty())
398 .map(str::to_string);
399 let focus = optional_str(&input, "focus")?
400 .map(str::trim)
401 .filter(|s| !s.is_empty())
402 .map(str::to_string);
403 let base = optional_str(&input, "base")?
404 .map(str::trim)
405 .filter(|s| !s.is_empty())
406 .map(str::to_string);
407
408 let scope = optional_str(&input, "scope")?.unwrap_or("diff").trim();
409 let staged = match scope {
410 "diff" | "" => false,
411 "staged" => true,
412 "none" => {
413 // handled below by skipping diff gathering
414 false
415 }
416 other => {
417 return Err(ToolError::invalid_input(format!(
418 "unknown scope '{other}' (expected diff | staged | none)"
419 )));
420 }
421 };
422 let gather_diff_scope = scope != "none";
423
424 let files = extract_string_array(&input, "files");
425
426 let Some(client) = self.client.clone() else {
427 return Err(ToolError::not_available(
428 "verify tool requires an active model client".to_string(),
429 ));
430 };
431
432 // --- Deterministic evidence gathering ---
433 let mut evidence: Vec<EvidenceBlock> = Vec::new();
434 if gather_diff_scope {
435 evidence.extend(
436 gather_diff_evidence(context.workspace.as_path(), staged, base.as_deref()).await?,
437 );
438 }
439 evidence.extend(gather_files(&files, context));
440
441 let no_code_evidence = evidence.is_empty();
442
443 let critique_input = CritiqueInput {
444 claim,
445 requirement,
446 focus,
447 evidence,
448 no_code_evidence,
449 };
450
451 // Run the critic under the re-entry marker so any (future) nested tool
452 // call to `verify` is refused.
453 let route = client.effective_route_envelope(&self.model, chrono::Utc::now());
454 let run = VERIFY_ACTIVE
455 .scope(
456 (),
457 run_critique(&client, &self.model, self.critic_effort, &critique_input),
458 )
459 .await?;
460
461 let mut metadata = json!({
462 "tool": "verify",
463 "verdict": run.report.verdict,
464 "finding_count": run.report.findings.len(),
465 "highest_severity": run.report.highest_severity(),
466 "unresolved_risk": run.report.unresolved_risk,
467 "critic_effort": self.critic_effort.as_setting(),
468 });
469 // Previously this reported only input/output, so a verify child's cache
470 // reads *and* cache writes were invisible to the cost audit (#4318).
471 crate::cost_status::attach_child_usage_metadata(&mut metadata, &route, &run.usage);
472
473 let result = ToolResult::json(&run.report)
474 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
475 Ok(result.with_metadata(metadata))
476 }
477 }
478
479 /// Run one adversarial critic pass. Generic over [`LlmClient`] so tests can
480 /// drive it with `MockLlmClient` without a network call.
481 async fn run_critique<C: LlmClient>(
482 client: &C,
483 model: &str,
484 effort: ReasoningEffort,
485 input: &CritiqueInput,
486 ) -> Result<CritiqueRun, ToolError> {
487 let prompt = build_critic_prompt(input, DEFAULT_MAX_EVIDENCE_CHARS);
488 let request = build_critic_request(model, effort, prompt);
489 let response = client
490 .create_message(request)
491 .await
492 .map_err(|e| ToolError::execution_failed(format!("verify critic request failed: {e}")))?;
493 let text = extract_text(&response.content);
494 Ok(CritiqueRun {
495 report: CritiqueReport::from_model_text(&text),
496 usage: response.usage,
497 })
498 }
499
500 /// Build the critic's outgoing request. **The recursion guarantee lives here:**
501 /// `tools` is always `None`, so the critic cannot invoke `verify` (or any other
502 /// tool). `reasoning_effort` is set explicitly so the critic runs elevated
503 /// regardless of the session tier.
504 fn build_critic_request(model: &str, effort: ReasoningEffort, prompt: String) -> MessageRequest {
505 MessageRequest {
506 model: model.to_string(),
507 messages: vec![Message {
508 role: "user".to_string(),
509 content: vec![ContentBlock::Text {
510 text: prompt,
511 cache_control: None,
512 }],
513 }],
514 max_tokens: CRITIC_MAX_TOKENS,
515 system: Some(SystemPrompt::Text(CRITIC_SYSTEM_PROMPT.to_string())),
516 // Hard bound: the critic gets NO tools, so it cannot recurse into verify.
517 tools: None,
518 tool_choice: None,
519 metadata: None,
520 thinking: None,
521 // Test-time compute: elevated reasoning, independent of session tier.
522 reasoning_effort: Some(clamp_to_elevated(effort).as_setting().to_string()),
523 stream: Some(false),
524 temperature: Some(0.1),
525 top_p: Some(0.9),
526 }
527 }
528
529 fn build_critic_prompt(input: &CritiqueInput, max_chars: usize) -> String {
530 let mut out = String::new();
531 out.push_str("CLAIM (to be refuted):\n");
532 out.push_str(&input.claim);
533 out.push('\n');
534
535 if let Some(req) = &input.requirement {
536 out.push_str("\nORIGINAL REQUIREMENT (verify the change against THIS):\n");
537 out.push_str(req);
538 out.push('\n');
539 }
540 if let Some(focus) = &input.focus {
541 out.push_str("\nFOCUS (scrutinize this in particular):\n");
542 out.push_str(focus);
543 out.push('\n');
544 }
545
546 // Evidence gets its own budget so the claim/requirement always survive.
547 let header_len = out.len();
548 let evidence_budget = max_chars.saturating_sub(header_len).max(1_000);
549
550 out.push_str("\n=== EVIDENCE ===\n");
551 if input.no_code_evidence {
552 out.push_str(
553 "No code diff or file contents were available. Critique the claim on its own terms, \
554 and explicitly note in your summary that you could not inspect the actual change.\n",
555 );
556 } else {
557 let mut evidence_text = String::new();
558 for block in &input.evidence {
559 evidence_text.push_str("--- ");
560 evidence_text.push_str(&block.label);
561 evidence_text.push_str(" ---\n");
562 evidence_text.push_str(&block.body);
563 if !block.body.ends_with('\n') {
564 evidence_text.push('\n');
565 }
566 evidence_text.push('\n');
567 }
568 out.push_str(&truncate_with_ellipsis(
569 &evidence_text,
570 evidence_budget,
571 "\n...[evidence truncated]...\n",
572 ));
573 }
574 out.push_str("=== END EVIDENCE ===\n\nRefute the claim. Return ONLY the JSON object.");
575 out
576 }
577
578 /// Gather git-diff evidence for the requested scope. Returns zero or more
579 /// labelled blocks; an empty result is not an error (unlike `review`) — a claim
580 /// can be about reasoning, and `files` may carry the evidence instead.
581 ///
582 /// The key correctness point (fix for the base-omits-worktree gap): when a
583 /// `base` ref is supplied for the working-tree scope, we emit BOTH the committed
584 /// changes since the merge-base (`git diff base...HEAD`) AND the uncommitted
585 /// working-tree changes (`git diff HEAD`). `git diff base...HEAD` alone captures
586 /// branch commits but drops the uncommitted edits the agent usually wants to
587 /// verify before claiming done.
588 async fn gather_diff_evidence(
589 workspace: &Path,
590 staged: bool,
591 base: Option<&str>,
592 ) -> Result<Vec<EvidenceBlock>, ToolError> {
593 let base = base.filter(|b| !b.trim().is_empty());
594 let mut blocks = Vec::new();
595
596 if staged {
597 // Staged scope: the index (optionally vs an explicit base).
598 let mut args: Vec<String> = vec!["--cached".to_string()];
599 if let Some(base) = base {
600 args.push(base.to_string());
601 }
602 if let Some(diff) = run_git_diff(workspace, &args).await? {
603 blocks.push(EvidenceBlock {
604 label: "git diff --cached (staged)".to_string(),
605 body: diff,
606 });
607 }
608 return Ok(blocks);
609 }
610
611 // Working-tree scope.
612 if let Some(base) = base {
613 // Committed changes on this branch since the merge-base with `base`.
614 if let Some(diff) = run_git_diff(workspace, &[format!("{base}...HEAD")]).await? {
615 blocks.push(EvidenceBlock {
616 label: format!("committed changes since {base} (git diff {base}...HEAD)"),
617 body: diff,
618 });
619 }
620 }
621 // Uncommitted changes (staged + unstaged) — what "before claiming done"
622 // usually means. `git diff HEAD` captures both; fall back to a plain
623 // working-tree diff on an unborn HEAD (empty repo / no commits yet).
624 let worktree = match run_git_diff(workspace, &["HEAD".to_string()]).await {
625 Ok(diff) => diff,
626 Err(_) => run_git_diff(workspace, &[]).await?,
627 };
628 if let Some(diff) = worktree {
629 blocks.push(EvidenceBlock {
630 label: "uncommitted changes (git diff HEAD, working tree)".to_string(),
631 body: diff,
632 });
633 }
634 Ok(blocks)
635 }
636
637 /// Run `git diff <args>` in `workspace`. Returns `Ok(None)` for an empty diff or
638 /// when git is unavailable, and `Err` when git runs but reports failure.
639 async fn run_git_diff(workspace: &Path, args: &[String]) -> Result<Option<String>, ToolError> {
640 let Some(mut cmd) = crate::dependencies::Git::command() else {
641 // git not installed: degrade gracefully rather than failing the tool.
642 return Ok(None);
643 };
644 cmd.arg("diff");
645 for arg in args {
646 cmd.arg(arg);
647 }
648 cmd.current_dir(workspace);
649
650 let output = tokio::task::spawn_blocking(move || cmd.output())
651 .await
652 .map_err(|e| ToolError::execution_failed(format!("git diff task panicked: {e}")))?
653 .map_err(|e| ToolError::execution_failed(format!("failed to run git diff: {e}")))?;
654 if !output.status.success() {
655 let stderr = String::from_utf8_lossy(&output.stderr);
656 return Err(ToolError::execution_failed(format!(
657 "git diff failed: {}",
658 stderr.trim()
659 )));
660 }
661 let diff = String::from_utf8_lossy(&output.stdout).to_string();
662 if diff.trim().is_empty() {
663 Ok(None)
664 } else {
665 Ok(Some(diff))
666 }
667 }
668
669 /// Read the requested files as evidence, recording read/path failures as inline
670 /// notes so the critic knows evidence was requested but unavailable.
671 fn gather_files(files: &[String], context: &ToolContext) -> Vec<EvidenceBlock> {
672 let mut blocks = Vec::new();
673 for raw in files {
674 let raw = raw.trim();
675 if raw.is_empty() {
676 continue;
677 }
678 match context.resolve_path(raw) {
679 Ok(path) => match std::fs::read_to_string(&path) {
680 Ok(content) => {
681 let display = path
682 .strip_prefix(&context.workspace)
683 .unwrap_or(&path)
684 .to_string_lossy()
685 .to_string();
686 let numbered = number_lines(&content);
687 blocks.push(EvidenceBlock {
688 label: format!("file: {display}"),
689 body: truncate_with_ellipsis(
690 &numbered,
691 PER_FILE_MAX_CHARS,
692 "\n...[file truncated]...\n",
693 ),
694 });
695 }
696 Err(e) => blocks.push(EvidenceBlock {
697 label: format!("file: {raw} (unreadable)"),
698 body: format!("<could not read file: {e}>"),
699 }),
700 },
701 Err(e) => blocks.push(EvidenceBlock {
702 label: format!("file: {raw} (rejected)"),
703 body: format!("<path rejected: {e}>"),
704 }),
705 }
706 }
707 blocks
708 }
709
710 fn number_lines(content: &str) -> String {
711 content
712 .lines()
713 .enumerate()
714 .map(|(idx, line)| format!("{:>4} | {line}", idx + 1))
715 .collect::<Vec<_>>()
716 .join("\n")
717 }
718
719 /// Reasoning tiers below `High` defeat the purpose; clamp them up.
720 fn clamp_to_elevated(effort: ReasoningEffort) -> ReasoningEffort {
721 match effort {
722 ReasoningEffort::High | ReasoningEffort::Max => effort,
723 // Off / Low / Medium / Auto → High (still elevated, provider-normalized
724 // at the client boundary).
725 _ => ReasoningEffort::High,
726 }
727 }
728
729 fn extract_string_array(input: &Value, key: &str) -> Vec<String> {
730 input
731 .get(key)
732 .and_then(Value::as_array)
733 .map(|arr| {
734 arr.iter()
735 .filter_map(|v| v.as_str())
736 .map(str::to_string)
737 .collect()
738 })
739 .unwrap_or_default()
740 }
741
742 fn extract_text(blocks: &[ContentBlock]) -> String {
743 let mut out = String::new();
744 for block in blocks {
745 if let ContentBlock::Text { text, .. } = block {
746 out.push_str(text);
747 }
748 }
749 out
750 }
751
752 fn parse_report_json(raw: &str) -> Option<CritiqueReport> {
753 serde_json::from_str::<CritiqueReport>(raw.trim()).ok()
754 }
755
756 /// Extract a JSON object from prose: prefer a fenced ```json block, else the
757 /// span from the first `{` to the last `}`.
758 fn extract_json_block(raw: &str) -> Option<&str> {
759 if let Some(start) = raw.find("```json") {
760 let after = &raw[start + "```json".len()..];
761 if let Some(end) = after.find("```") {
762 return Some(after[..end].trim());
763 }
764 }
765 let start = raw.find('{')?;
766 let end = raw.rfind('}')?;
767 if end > start {
768 Some(raw[start..=end].trim())
769 } else {
770 None
771 }
772 }
773
774 fn normalize_severity(value: &str) -> String {
775 match value.trim().to_ascii_lowercase().as_str() {
776 "critical" | "crit" | "blocker" | "severe" => "critical",
777 "high" | "major" | "important" => "high",
778 "low" | "minor" | "nit" | "trivial" => "low",
779 // Default unknown/empty to medium so a finding is never dropped, but is
780 // also not over-escalated to serious.
781 _ => "medium",
782 }
783 .to_string()
784 }
785
786 fn normalize_optional(value: Option<String>) -> Option<String> {
787 value
788 .map(|s| s.trim().to_string())
789 .filter(|s| !s.is_empty())
790 }
791
792 #[cfg(test)]
793 mod tests {
794 use super::*;
795 use crate::llm_client::mock::MockLlmClient;
796 use serde_json::json;
797 use std::path::Path;
798
799 fn ctx() -> ToolContext {
800 ToolContext::new(Path::new("."))
801 }
802
803 fn text_response(model: &str, body: &str) -> crate::models::MessageResponse {
804 crate::models::MessageResponse {
805 id: "msg_test".to_string(),
806 r#type: "message".to_string(),
807 role: "assistant".to_string(),
808 content: vec![ContentBlock::Text {
809 text: body.to_string(),
810 cache_control: None,
811 }],
812 model: model.to_string(),
813 stop_reason: Some("stop".to_string()),
814 stop_sequence: None,
815 container: None,
816 usage: Usage::default(),
817 }
818 }
819
820 fn planted_bug_input() -> CritiqueInput {
821 // A change that claims to handle all inputs but has an obvious
822 // divide-by-zero / empty-slice defect in the diff.
823 CritiqueInput {
824 claim: "average() now correctly computes the mean for any input slice".to_string(),
825 requirement: Some("Must not panic on empty input.".to_string()),
826 focus: None,
827 evidence: vec![EvidenceBlock {
828 label: "git diff (working tree)".to_string(),
829 body: "+fn average(xs: &[f64]) -> f64 {\n+ xs.iter().sum::<f64>() / xs.len() as f64\n+}\n"
830 .to_string(),
831 }],
832 no_code_evidence: false,
833 }
834 }
835
836 // === Contract tests ===
837
838 #[test]
839 fn tool_contract_name_and_schema() {
840 let tool = VerifyTool::new(None, "test-model".to_string());
841 assert_eq!(tool.name(), "verify");
842 let schema = tool.input_schema();
843 assert_eq!(schema["type"], "object");
844 assert!(schema["properties"]["claim"].is_object());
845 assert!(schema["properties"]["scope"]["enum"].is_array());
846 let required = schema["required"].as_array().expect("required array");
847 assert!(required.iter().any(|v| v == "claim"));
848 // Read-only + network; approval auto.
849 assert!(tool.capabilities().contains(&ToolCapability::ReadOnly));
850 assert!(tool.is_read_only());
851 assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
852 assert!(tool.model_visible());
853 }
854
855 // === Elevated-reasoning + no-recursion structural guard ===
856
857 #[test]
858 fn critic_request_is_elevated_and_toolless() {
859 // Even if someone constructs the tool at Low, the critic must run
860 // elevated and carry NO tools (so it cannot recurse into verify).
861 let req = build_critic_request("m", ReasoningEffort::Low, "prompt".to_string());
862 assert_eq!(
863 req.reasoning_effort.as_deref(),
864 Some("high"),
865 "Low must clamp up to elevated reasoning"
866 );
867 assert!(
868 req.tools.is_none(),
869 "critic must be given NO tools — this is the structural recursion guard"
870 );
871
872 let req_max = build_critic_request("m", ReasoningEffort::Max, "prompt".to_string());
873 assert_eq!(req_max.reasoning_effort.as_deref(), Some("max"));
874 assert!(req_max.tools.is_none());
875 }
876
877 #[test]
878 fn with_critic_effort_clamps_below_high() {
879 let tool = VerifyTool::new(None, "m".to_string()).with_critic_effort(ReasoningEffort::Low);
880 assert_eq!(tool.critic_effort, ReasoningEffort::High);
881 let tool = VerifyTool::new(None, "m".to_string()).with_critic_effort(ReasoningEffort::Max);
882 assert_eq!(tool.critic_effort, ReasoningEffort::Max);
883 }
884
885 // === Critic-finds-a-planted-bug (mocked model) ===
886
887 #[tokio::test]
888 async fn critic_surfaces_planted_bug() {
889 let mock = MockLlmClient::new(vec![]);
890 // Canonical adversarial JSON the critic would return for the defect.
891 mock.push_message_response(text_response(
892 "mock-critic",
893 r#"{
894 "verdict": "refuted",
895 "summary": "average() divides by len() with no empty-slice guard.",
896 "findings": [
897 {
898 "severity": "critical",
899 "issue": "Divide-by-zero / NaN when xs is empty; violates the no-panic requirement.",
900 "evidence": "average(): xs.len() as f64 is 0 for empty input",
901 "suggested_fix": "Return 0.0 or Option::None when xs.is_empty()."
902 }
903 ],
904 "unresolved_risk": true
905 }"#,
906 ));
907
908 let run = run_critique(
909 &mock,
910 "mock-critic",
911 ReasoningEffort::Max,
912 &planted_bug_input(),
913 )
914 .await
915 .expect("critique runs");
916
917 assert_eq!(run.report.verdict, "refuted");
918 assert!(run.report.unresolved_risk);
919 assert_eq!(run.report.findings.len(), 1);
920 assert_eq!(run.report.findings[0].severity, "critical");
921 assert!(
922 run.report.findings[0]
923 .issue
924 .to_lowercase()
925 .contains("empty"),
926 "finding should name the empty-input defect"
927 );
928 assert_eq!(run.report.highest_severity(), "critical");
929
930 // The outgoing critic request carried elevated reasoning and NO tools.
931 let sent = mock.last_request().expect("request captured");
932 assert_eq!(sent.reasoning_effort.as_deref(), Some("max"));
933 assert!(sent.tools.is_none());
934 // Evidence and requirement were threaded into the prompt.
935 let prompt = match &sent.messages[0].content[0] {
936 ContentBlock::Text { text, .. } => text.clone(),
937 _ => panic!("expected text content"),
938 };
939 assert!(prompt.contains("CLAIM"));
940 assert!(prompt.contains("Must not panic on empty input"));
941 assert!(prompt.contains("average("));
942 }
943
944 #[tokio::test]
945 async fn unstructured_critic_output_is_unresolved_risk() {
946 let mock = MockLlmClient::new(vec![]);
947 mock.push_message_response(text_response("m", "I think it looks fine, ship it."));
948 let run = run_critique(&mock, "m", ReasoningEffort::High, &planted_bug_input())
949 .await
950 .expect("runs");
951 // Fail safe: non-JSON critic output must not read as a clean pass.
952 assert_eq!(run.report.verdict, "uncertain");
953 assert!(run.report.unresolved_risk);
954 }
955
956 #[test]
957 fn upheld_with_serious_finding_is_downgraded_to_refuted() {
958 // Guards the green-but-wrong trap: a critic can't declare "upheld" while
959 // simultaneously reporting a high-severity finding.
960 let report = CritiqueReport::from_model_text(
961 r#"{"verdict":"upheld","summary":"looks ok","findings":[{"severity":"high","issue":"missing null check"}],"unresolved_risk":false}"#,
962 );
963 assert_eq!(report.verdict, "refuted");
964 assert!(report.unresolved_risk);
965 }
966
967 #[test]
968 fn upheld_with_medium_finding_flags_unresolved_risk() {
969 // The exact green-but-wrong gap: verdict=upheld + a MEDIUM finding +
970 // critic-set unresolved_risk=false must still surface as unresolved
971 // risk, and the verdict must not stay "upheld".
972 let report = CritiqueReport::from_model_text(
973 r#"{"verdict":"upheld","summary":"seems fine","findings":[{"severity":"medium","issue":"unhandled empty-input case"}],"unresolved_risk":false}"#,
974 );
975 assert!(
976 report.unresolved_risk,
977 "a medium finding must force unresolved_risk=true"
978 );
979 assert_ne!(
980 report.verdict, "upheld",
981 "cannot remain 'upheld' with an open medium finding"
982 );
983 assert_eq!(
984 report.verdict, "uncertain",
985 "medium (not serious) downgrades upheld to uncertain, not refuted"
986 );
987 }
988
989 #[test]
990 fn upheld_with_only_low_finding_stays_upheld() {
991 // Intentional carve-out: low-severity nits do not block a claim, so an
992 // otherwise-clean "upheld" with only a low finding stays upheld and
993 // does not raise unresolved_risk.
994 let report = CritiqueReport::from_model_text(
995 r#"{"verdict":"upheld","summary":"clean","findings":[{"severity":"low","issue":"nit: rename variable"}],"unresolved_risk":false}"#,
996 );
997 assert_eq!(report.verdict, "upheld");
998 assert!(!report.unresolved_risk);
999 }
1000
1001 // === Recursion / re-entry guard ===
1002
1003 #[tokio::test]
1004 async fn execute_refuses_reentry() {
1005 // Simulate being inside a critic pass; execute must refuse before it
1006 // even looks at the (absent) client or input.
1007 let tool = VerifyTool::new(None, "m".to_string());
1008 let err = VERIFY_ACTIVE
1009 .scope((), async {
1010 tool.execute(json!({ "claim": "x" }), &ctx()).await
1011 })
1012 .await
1013 .expect_err("re-entry must be refused");
1014 let msg = err.to_string().to_lowercase();
1015 assert!(
1016 msg.contains("recursion") || msg.contains("inside its own"),
1017 "expected a recursion-guard error, got: {err}"
1018 );
1019 }
1020
1021 #[tokio::test]
1022 async fn execute_without_client_is_not_available() {
1023 let tool = VerifyTool::new(None, "m".to_string());
1024 let err = tool
1025 .execute(json!({ "claim": "did the thing" }), &ctx())
1026 .await
1027 .expect_err("no client");
1028 assert!(err.to_string().to_lowercase().contains("client"));
1029 }
1030
1031 #[tokio::test]
1032 async fn execute_rejects_empty_and_unknown_scope() {
1033 let tool = VerifyTool::new(None, "m".to_string());
1034 // Empty claim is rejected before the (absent) client is consulted.
1035 let err = tool
1036 .execute(json!({ "claim": " " }), &ctx())
1037 .await
1038 .expect_err("empty claim");
1039 assert!(err.to_string().to_lowercase().contains("claim"), "{err}");
1040
1041 // Unknown scope is a precise input error, not a generic client error.
1042 let err = tool
1043 .execute(json!({ "claim": "ok", "scope": "everything" }), &ctx())
1044 .await
1045 .expect_err("unknown scope");
1046 assert!(err.to_string().to_lowercase().contains("scope"), "{err}");
1047 }
1048
1049 #[test]
1050 fn parses_fenced_json_block() {
1051 let raw = "Here is my critique:\n```json\n{\"verdict\":\"refuted\",\"summary\":\"s\",\"findings\":[],\"unresolved_risk\":true}\n```\nDone.";
1052 let report = CritiqueReport::from_model_text(raw);
1053 assert_eq!(report.verdict, "refuted");
1054 assert!(report.unresolved_risk);
1055 }
1056
1057 #[test]
1058 fn severity_normalization() {
1059 assert_eq!(normalize_severity("BLOCKER"), "critical");
1060 assert_eq!(normalize_severity("Major"), "high");
1061 assert_eq!(normalize_severity("nit"), "low");
1062 assert_eq!(normalize_severity("weird"), "medium");
1063 assert_eq!(normalize_severity(""), "medium");
1064 }
1065
1066 // === git diff evidence gathering ===
1067
1068 fn run_git(dir: &Path, args: &[&str]) {
1069 let mut cmd = crate::dependencies::Git::command().expect("git available");
1070 cmd.args(args).current_dir(dir);
1071 let out = cmd.output().expect("run git");
1072 assert!(
1073 out.status.success(),
1074 "git {args:?} failed: {}",
1075 String::from_utf8_lossy(&out.stderr)
1076 );
1077 }
1078
1079 #[tokio::test]
1080 async fn diff_scope_with_base_includes_uncommitted_worktree_changes() {
1081 // Regression for the base-omits-worktree gap: `git diff base...HEAD`
1082 // captures branch commits but drops the uncommitted edits the agent
1083 // usually wants to verify before claiming done. With a base set, the
1084 // gatherer must include BOTH the committed-since-base changes AND the
1085 // uncommitted working-tree changes.
1086 if crate::dependencies::Git::command().is_none() {
1087 return; // no git in this environment — nothing to exercise.
1088 }
1089
1090 let tmp = tempfile::tempdir().expect("tempdir");
1091 let repo = tmp.path();
1092 run_git(repo, &["init", "-q"]);
1093 run_git(repo, &["config", "user.email", "t@example.com"]);
1094 run_git(repo, &["config", "user.name", "Test"]);
1095 run_git(repo, &["config", "commit.gpgsign", "false"]);
1096
1097 // Base commit.
1098 std::fs::write(repo.join("f.txt"), "line1\n").expect("write");
1099 run_git(repo, &["add", "."]);
1100 run_git(repo, &["commit", "-q", "-m", "base"]);
1101
1102 // A committed change on top of the base.
1103 std::fs::write(repo.join("f.txt"), "line1\nCOMMITTED_MARKER\n").expect("write");
1104 run_git(repo, &["add", "."]);
1105 run_git(repo, &["commit", "-q", "-m", "second"]);
1106
1107 // An UNCOMMITTED working-tree change.
1108 std::fs::write(
1109 repo.join("f.txt"),
1110 "line1\nCOMMITTED_MARKER\nUNCOMMITTED_MARKER\n",
1111 )
1112 .expect("write");
1113
1114 let blocks = gather_diff_evidence(repo, false, Some("HEAD~1"))
1115 .await
1116 .expect("gather diff");
1117 let joined = blocks
1118 .iter()
1119 .map(|b| format!("[{}]\n{}", b.label, b.body))
1120 .collect::<Vec<_>>()
1121 .join("\n");
1122
1123 assert!(
1124 joined.contains("UNCOMMITTED_MARKER"),
1125 "uncommitted working-tree change must appear in evidence with a base set:\n{joined}"
1126 );
1127 assert!(
1128 joined.contains("COMMITTED_MARKER"),
1129 "committed-since-base change must also appear:\n{joined}"
1130 );
1131 // Two distinct labelled blocks: committed-since-base and uncommitted.
1132 assert!(
1133 blocks
1134 .iter()
1135 .any(|b| b.label.contains("committed changes since")),
1136 "expected a committed-since-base block: {:?}",
1137 blocks.iter().map(|b| &b.label).collect::<Vec<_>>()
1138 );
1139 assert!(
1140 blocks
1141 .iter()
1142 .any(|b| b.label.contains("uncommitted changes")),
1143 "expected an uncommitted working-tree block: {:?}",
1144 blocks.iter().map(|b| &b.label).collect::<Vec<_>>()
1145 );
1146 }
1147 }
1148
1148 lines RUST