Skip to main content

start_command/
formal_ai.rs

1//! The task loop of the experimental `--use-formal-ai` mode (issue #255).
2//! Mirrors js/src/lib/formal-ai.js.
3//!
4//! The natural-language request goes to Formal AI verbatim. Formal AI decides
5//! what to observe and which tool calls to make; start checks every call
6//! against the effect policy, runs approved calls, reports each receipt back
7//! and keeps evidence of what actually happened. The run ends when Formal AI
8//! gives its final answer, says it did not understand, or a bound, refusal,
9//! protocol error or cancellation stops it, and the exit status says which.
10//!
11//! A final answer that is a known capability gap (issue #258) is reported as
12//! incomplete with a nonzero exit, and structured read, edit and write calls
13//! run under the same workspace policy as bash commands.
14//!
15//! A failed change stays unresolved until the same typed tool changes the same
16//! target and its readback is verified (issue #261): a later read, a copy over
17//! the file or Formal AI's final answer does not resolve it, and the run then
18//! ends `incomplete` with the original failure and its exit status.
19
20use std::path::Path;
21use std::time::{Duration, Instant};
22
23use serde_json::{json, Value};
24
25use crate::formal_ai_client::{recognize_capability_gap, CompletionClient, ToolCall};
26use crate::formal_ai_files::{evaluate_file_call, run_file_call, FileArgs};
27use crate::formal_ai_policy::{
28    self as policy, Bounds, EffectDiff, PolicyContext, EXIT_BUDGET, EXIT_PROTOCOL, EXIT_REFUSED,
29    EXIT_UNAVAILABLE, EXIT_UNSUPPORTED,
30};
31use crate::formal_ai_tool::{format_tool_result, run_tool, ToolOptions};
32
33const PREVIEW_CHARS: usize = 2000;
34
35/// Exit status of a cancellation by signal name (128 + signal number).
36pub fn signal_exit(name: &str) -> i32 {
37    match name {
38        "SIGHUP" => 129,
39        "SIGTERM" => 143,
40        _ => 130,
41    }
42}
43
44fn preview(text: &str) -> String {
45    let count = text.chars().count();
46    if count > PREVIEW_CHARS {
47        format!(
48            "{}… [{} more chars]",
49            text.chars().take(PREVIEW_CHARS).collect::<String>(),
50            count - PREVIEW_CHARS
51        )
52    } else {
53        text.to_string()
54    }
55}
56
57/// Observed working-directory changes, in words.
58pub fn describe_effects(effects: &EffectDiff) -> String {
59    let parts: Vec<String> = [
60        ("added", &effects.added),
61        ("removed", &effects.removed),
62        ("changed", &effects.changed),
63    ]
64    .iter()
65    .filter(|(_, names)| !names.is_empty())
66    .map(|(key, names)| {
67        let more = if names.len() > 10 {
68            format!(" and {} more", names.len() - 10)
69        } else {
70            String::new()
71        };
72        let shown: Vec<&str> = names.iter().take(10).map(String::as_str).collect();
73        format!("{} {}{}", key, shown.join(", "), more)
74    })
75    .collect();
76    let text = if parts.is_empty() {
77        "no file changed".to_string()
78    } else {
79        parts.join("; ")
80    };
81    if effects.complete {
82        text
83    } else {
84        format!("{} (fingerprint incomplete)", text)
85    }
86}
87
88/// What one task run needs besides the Formal AI client.
89pub struct TaskOptions<'a> {
90    /// Natural-language request, never executed.
91    pub request: &'a str,
92    pub cwd: &'a Path,
93    /// START_* variables added to the tools' environment.
94    pub env: &'a [(String, String)],
95    pub bounds: &'a Bounds,
96    pub deadline: Instant,
97    /// Mirror tool output to this process's stdout/stderr.
98    pub mirror: bool,
99    pub note: &'a dyn Fn(&str),
100    /// Exit status of a received cancellation signal, if any.
101    pub cancel: &'a dyn Fn() -> Option<i32>,
102}
103
104/// The final state of a run.
105#[derive(Debug, Clone, PartialEq)]
106pub struct Outcome {
107    pub status: String,
108    pub exit_code: i32,
109    pub message: String,
110    pub remaining: Vec<String>,
111    /// Sanitized cause of a runtime failure (issue #257).
112    pub diagnostics: Option<Value>,
113    /// The capability gap Formal AI reported (issue #258).
114    pub gap: Option<Value>,
115    /// Failed calls no later verified call resolved (issue #261).
116    pub failures: Vec<Value>,
117    /// A gap record Formal AI answered with that start does not know (#261).
118    pub gap_record: Option<Value>,
119}
120
121impl Outcome {
122    pub fn new(
123        status: &str,
124        exit_code: i32,
125        message: impl Into<String>,
126        remaining: &[&str],
127    ) -> Self {
128        Self {
129            status: status.to_string(),
130            exit_code,
131            message: message.into(),
132            remaining: remaining.iter().map(|s| s.to_string()).collect(),
133            diagnostics: None,
134            gap: None,
135            failures: Vec::new(),
136            gap_record: None,
137        }
138    }
139
140    pub fn to_json(&self) -> Value {
141        let mut out = json!({
142            "status": self.status,
143            "exitCode": self.exit_code,
144            "message": self.message,
145            "remainingObligations": self.remaining,
146        });
147        if let Some(diagnostics) = &self.diagnostics {
148            out["diagnostics"] = diagnostics.clone();
149        }
150        if !self.failures.is_empty() {
151            out["failures"] = json!(self.failures);
152        }
153        if let Some(gap) = &self.gap {
154            out["gap"] = gap.clone();
155        }
156        if let Some(record) = &self.gap_record {
157            out["gapRecord"] = record.clone();
158        }
159        out
160    }
161}
162
163/// Capability gap, in words. When tools changed the working directory, the
164/// message keeps what start observed: Formal AI's gap still means the task is
165/// not confirmed, whatever start verified on its own (#261).
166pub fn describe_gap(gap: &Value, effects: &EffectDiff) -> String {
167    let missing: Vec<&str> = gap["missingCapabilities"]
168        .as_array()
169        .map(|items| items.iter().filter_map(Value::as_str).collect())
170        .unwrap_or_default();
171    let missing = if missing.is_empty() {
172        String::new()
173    } else {
174        format!("; missing {}", missing.join(", "))
175    };
176    let head = format!(
177        "Formal AI reported a capability gap for {} ({}{})",
178        gap["target"].as_str().unwrap_or(""),
179        gap["reason"].as_str().unwrap_or(""),
180        missing
181    );
182    if effects.has_effects() {
183        format!(
184            "{}; Formal AI did not confirm the task as done; start observed: {}",
185            head,
186            describe_effects(effects)
187        )
188    } else {
189        format!("{}; nothing was verified as done", head)
190    }
191}
192
193/// Keep the failed calls of a run until a later call meets their obligation
194/// (issue #261). A failed edit or write is resolved only by a verified edit or
195/// write of the same target (a write also resolves a failed read of the file
196/// it created); a failed read by a read of the same target; a failed write
197/// command only by the same command succeeding. A copy or move over the target
198/// never counts as the edit.
199pub fn track_failures(unresolved: &mut Vec<Value>, step: &Value) {
200    if step["kind"] != "tool" || step["decision"] != "allowed" {
201        return;
202    }
203    let tool = step["tool"].as_str();
204    let typed = tool.is_some();
205    let changed = typed && tool != Some("read");
206    let exit_code = step["exitCode"].as_i64();
207    if exit_code != Some(0) && (typed || step["effect"] == "write") {
208        let error = match (step["error"].as_str(), step["signal"].as_str()) {
209            (Some(error), _) if !error.is_empty() => error.to_string(),
210            (_, Some(signal)) => format!("stopped by {}", signal),
211            _ => format!(
212                "exited with {}",
213                exit_code.map_or("null".to_string(), |code| code.to_string())
214            ),
215        };
216        let mut failure = json!({ "command": step["command"] });
217        if typed {
218            failure["tool"] = step["tool"].clone();
219            failure["target"] = step["target"].clone();
220        }
221        failure["exitCode"] = json!(exit_code.unwrap_or(1));
222        failure["error"] = json!(error);
223        unresolved.push(failure);
224        return;
225    }
226    if exit_code != Some(0) {
227        return;
228    }
229    let verified = changed && step["verification"]["verified"] == true;
230    unresolved.retain(|failure| {
231        let resolved = if typed {
232            failure["target"] == step["target"]
233                && if failure["tool"] == "read" {
234                    tool == Some("read") || verified
235                } else {
236                    verified && (failure["tool"].as_str() == tool || tool == Some("edit"))
237                }
238        } else {
239            failure.get("tool").is_none() && failure["command"] == step["command"]
240        };
241        !resolved
242    });
243}
244
245fn failure_obligation(failure: &Value) -> String {
246    format!(
247        "`{}` failed: {}",
248        failure["command"].as_str().unwrap_or(""),
249        failure["error"].as_str().unwrap_or("")
250    )
251}
252
253/// A final answer that is exactly one Links Notation record whose record_type
254/// names a gap, as Formal AI's task obligations answer (`obligation_gap`, e.g.
255/// reason `no_artifact_in_clause`): an id line, then `  key "value"` lines.
256/// Returned in the JSON gap shape (schema = record_type, never complete), so
257/// a gap Formal AI states in its own notation is never taken as success, even
258/// when start verified every change it made (#261).
259pub fn lino_gap_record(text: &str) -> Option<Value> {
260    let mut lines = text.split('\n');
261    let id = lines.next()?;
262    let lines: Vec<&str> = lines.collect();
263    let word = |c: char| c.is_ascii_alphanumeric() || c == '_';
264    if id.is_empty() || !id.chars().all(word) || lines.is_empty() {
265        return None;
266    }
267    let pattern = regex::Regex::new(r#"^ {2}([a-z_]+) (?:"([^"]*)"|'([^']*)'|(\S+))$"#)
268        .expect("valid pattern");
269    let mut fields = serde_json::Map::new();
270    for line in lines {
271        let found = pattern.captures(line)?;
272        let key = found[1].to_string();
273        if fields.contains_key(&key) {
274            return None;
275        }
276        let value = found
277            .get(2)
278            .or_else(|| found.get(3))
279            .or_else(|| found.get(4))
280            .map_or("", |m| m.as_str());
281        fields.insert(key, json!(value));
282    }
283    let schema = fields.get("record_type")?.as_str()?.to_string();
284    let mut out = json!({ "schema": schema });
285    for key in ["reason", "clause"] {
286        if let Some(value) = fields
287            .get(key)
288            .and_then(Value::as_str)
289            .filter(|v| !v.is_empty())
290        {
291            out[key] = json!(value);
292        }
293    }
294    out["complete"] = json!(false);
295    Some(out)
296}
297
298/// A final answer that is a JSON (or Links Notation) record of a gap start
299/// does not know: it is never taken as success, though its schema grants no
300/// recognized gap. A record echoed from a tool message is not Formal AI's.
301pub fn unrecognized_gap_record(content: &str, messages: &[Value]) -> Option<Value> {
302    let text = content.trim();
303    let value = serde_json::from_str::<Value>(text)
304        .ok()
305        .or_else(|| lino_gap_record(text))?;
306    let schema = value["schema"].as_str()?;
307    if !value.is_object()
308        || !schema.to_lowercase().contains("gap")
309        || !(value["verified"] == false
310            || value["complete"] == false
311            || value["completed"] == false)
312    {
313        return None;
314    }
315    if crate::formal_ai_files::echoed_by_tool(text, messages) {
316        return None;
317    }
318    let mut out = json!({ "schema": schema });
319    for key in ["target", "reason", "clause"] {
320        if let Some(field) = value[key].as_str() {
321            out[key] = json!(field);
322        }
323    }
324    Some(out)
325}
326
327/// What a capability gap leaves to do, and how to offer the change tools.
328pub fn gap_obligations(gap: &Value, effects: &str) -> Vec<String> {
329    let mut obligations = vec![format!(
330        "the requested change to {} was not made or not verified",
331        gap["target"].as_str().unwrap_or("")
332    )];
333    let needs_change = gap["missingCapabilities"].as_array().is_some_and(|items| {
334        items
335            .iter()
336            .any(|c| matches!(c.as_str(), Some("edit" | "write" | "multi_edit")))
337    });
338    if effects != "workspace-write" && needs_change {
339        obligations.push(
340            "set START_FORMAL_AI_EFFECTS=workspace-write to offer the edit and write tools"
341                .to_string(),
342        );
343    }
344    obligations
345}
346
347fn cancelled(code: i32) -> Outcome {
348    let name = crate::exit_reason::signal_name_for_exit_code(Some(code))
349        .unwrap_or_else(|| "SIGINT".to_string());
350    Outcome::new(
351        "cancelled",
352        code,
353        format!("cancelled by {}", name),
354        &["the task was interrupted; later steps did not run"],
355    )
356}
357
358fn steps_mut(evidence: &mut Value) -> &mut Vec<Value> {
359    if !evidence["steps"].is_array() {
360        evidence["steps"] = json!([]);
361    }
362    evidence["steps"].as_array_mut().expect("steps is an array")
363}
364
365/// Run one request to completion. Fills `evidence` (a JSON object) as it
366/// goes, sets `evidence.outcome` and returns the outcome.
367pub fn run_formal_ai_task(
368    options: &TaskOptions<'_>,
369    client: &mut dyn CompletionClient,
370    evidence: &mut Value,
371) -> Outcome {
372    if !evidence.is_object() {
373        *evidence = json!({});
374    }
375    evidence["request"] = json!(options.request);
376    evidence["cwd"] = json!(options.cwd.to_string_lossy());
377    evidence["effectsMode"] = json!(options.bounds.effects);
378    evidence["bounds"] = options.bounds.to_json();
379    evidence["steps"] = json!([]);
380    evidence["effects"] = EffectDiff::default().to_json();
381    evidence["outcome"] = Value::Null;
382    // Failed calls whose obligation no later verified call met (#261).
383    let mut unresolved = Vec::new();
384    let mut outcome = task_loop(options, client, evidence, &mut unresolved);
385    for obligation in unresolved.iter().map(failure_obligation) {
386        if !outcome.remaining.contains(&obligation) {
387            outcome.remaining.push(obligation);
388        }
389    }
390    outcome.failures = unresolved;
391    evidence["outcome"] = outcome.to_json();
392    outcome
393}
394
395fn task_loop(
396    options: &TaskOptions<'_>,
397    client: &mut dyn CompletionClient,
398    evidence: &mut Value,
399    unresolved: &mut Vec<Value>,
400) -> Outcome {
401    let bounds = options.bounds;
402    let mut context = PolicyContext::new(options.cwd, &bounds.effects);
403    let mut total = EffectDiff::default();
404    let mut messages = vec![json!({ "role": "user", "content": options.request })];
405    let mut last_exit: Option<i32> = None;
406    let mut tool_calls: u64 = 0;
407    let is_cancelled = || (options.cancel)().is_some();
408
409    for turn in 1..=bounds.max_steps {
410        if let Some(code) = (options.cancel)() {
411            return cancelled(code);
412        }
413        if Instant::now() >= options.deadline {
414            return Outcome::new(
415                "budget-exhausted",
416                EXIT_BUDGET,
417                "time budget exhausted",
418                &["the task did not finish within START_FORMAL_AI_TIMEOUT_MS"],
419            );
420        }
421        let response = match client.complete(&messages, &is_cancelled) {
422            Ok(response) => response,
423            Err(error) => {
424                if let Some(code) = (options.cancel)() {
425                    return cancelled(code);
426                }
427                let status = match error.exit_code {
428                    EXIT_BUDGET => "budget-exhausted",
429                    EXIT_UNAVAILABLE => "unavailable",
430                    _ => "protocol-error",
431                };
432                // The cause and attempt count of a runtime failure stay in
433                // the evidence (issue #257), sanitized and bounded.
434                let mut outcome = Outcome::new(
435                    status,
436                    error.exit_code,
437                    error.message,
438                    &["Formal AI did not complete the task"],
439                );
440                outcome.diagnostics = error.details;
441                return outcome;
442            }
443        };
444        steps_mut(evidence).push(json!({
445            "kind": "response",
446            "turn": turn,
447            "finishReason": response.finish_reason,
448            "handler": response.handler,
449            "content": preview(&response.content),
450            "toolCalls": response.tool_calls.iter().map(|c| c.command.clone()).collect::<Vec<_>>(),
451        }));
452        if response.tool_calls.is_empty() {
453            if !response.content.is_empty() {
454                (options.note)(&response.content);
455            }
456            if let Some(gap) = recognize_capability_gap(&response.content, &messages) {
457                let mut outcome = Outcome::new(
458                    "capability-gap",
459                    EXIT_UNSUPPORTED,
460                    describe_gap(&gap, &total),
461                    &[],
462                );
463                outcome.remaining = gap_obligations(&gap, &bounds.effects);
464                outcome.gap = Some(gap);
465                return outcome;
466            }
467            if let Some(first) = unresolved.first() {
468                return Outcome::new(
469                    "incomplete",
470                    first["exitCode"]
471                        .as_i64()
472                        .filter(|code| *code != 0)
473                        .map_or(1, |code| code as i32),
474                    format!(
475                        "`{}` failed ({}) and no later call resolved it; {}",
476                        first["command"].as_str().unwrap_or(""),
477                        first["error"].as_str().unwrap_or(""),
478                        describe_effects(&total)
479                    ),
480                    &[],
481                );
482            }
483            if let Some(record) = unrecognized_gap_record(&response.content, &messages) {
484                let text = |key: &str| {
485                    record[key]
486                        .as_str()
487                        .filter(|value| !value.is_empty())
488                        .map(str::to_string)
489                };
490                let mut message =
491                    format!("Formal AI reported {}", text("schema").unwrap_or_default());
492                if let Some(reason) = text("reason") {
493                    message.push_str(&format!(" ({})", reason));
494                }
495                if let Some(target) = text("target") {
496                    message.push_str(&format!(" for {}", target));
497                }
498                if let Some(clause) = text("clause") {
499                    message.push_str(&format!(" for the clause {}", json!(clause)));
500                }
501                message.push_str(&format!(
502                    "; nothing was verified as done by Formal AI; {}",
503                    describe_effects(&total)
504                ));
505                let mut outcome = Outcome::new(
506                    "incomplete",
507                    EXIT_UNSUPPORTED,
508                    message,
509                    &["Formal AI did not confirm the requested change"],
510                );
511                outcome.gap_record = Some(record);
512                return outcome;
513            }
514            if response.handler.as_deref() == Some("unknown") && tool_calls == 0 {
515                return Outcome::new(
516                    "unsupported",
517                    EXIT_UNSUPPORTED,
518                    "Formal AI did not understand the request; no tool ran",
519                    &["rephrase the request or perform it manually"],
520                );
521            }
522            let Some(last) = last_exit else {
523                return Outcome::new("answered", 0, "Formal AI answered without tools", &[]);
524            };
525            // The status reports what the tools did, not what Formal AI
526            // claims: a run of reads is never presented as a completed change.
527            let status = if last == 0 {
528                format!("finished after {} tool call(s)", tool_calls)
529            } else {
530                format!("finished; the last tool call exited with {}", last)
531            };
532            let remaining: &[&str] = if last == 0 {
533                &[]
534            } else {
535                &["the last tool call failed"]
536            };
537            return Outcome::new(
538                "completed",
539                last,
540                format!("{}; {}", status, describe_effects(&total)),
541                remaining,
542            );
543        }
544        let ids: Vec<String> = response
545            .tool_calls
546            .iter()
547            .enumerate()
548            .map(|(index, call)| {
549                call.id
550                    .clone()
551                    .unwrap_or_else(|| format!("call_{}_{}", turn, index))
552            })
553            .collect();
554        messages.push(json!({
555            "role": "assistant",
556            "content": response.content,
557            "tool_calls": response.tool_calls.iter().zip(&ids).map(|(call, id)| json!({
558                "id": id,
559                "type": "function",
560                "function": {
561                    "name": call.name,
562                    "arguments": if call.tool == "bash" {
563                        json!({ "command": call.command }).to_string()
564                    } else {
565                        call.arguments.clone()
566                    },
567                },
568            })).collect::<Vec<_>>(),
569        }));
570        for (call, id) in response.tool_calls.iter().zip(&ids) {
571            tool_calls += 1;
572            if tool_calls > bounds.max_steps {
573                return Outcome::new(
574                    "budget-exhausted",
575                    EXIT_BUDGET,
576                    format!("tool call budget of {} exhausted", bounds.max_steps),
577                    &["the task did not finish within START_FORMAL_AI_MAX_STEPS"],
578                );
579            }
580            let result = run_call(options, call, &mut context, &mut total, evidence);
581            if let Some(step) = evidence["steps"].as_array().and_then(|steps| steps.last()) {
582                track_failures(unresolved, step);
583            }
584            if let Some(code) = (options.cancel)() {
585                return cancelled(code);
586            }
587            let (exit_code, content) = match result {
588                Ok(done) => done,
589                Err(outcome) => return *outcome,
590            };
591            last_exit = Some(exit_code);
592            messages.push(json!({ "role": "tool", "tool_call_id": id, "content": content }));
593        }
594    }
595    Outcome::new(
596        "budget-exhausted",
597        EXIT_BUDGET,
598        format!("step budget of {} exhausted", bounds.max_steps),
599        &["the task did not finish within START_FORMAL_AI_MAX_STEPS"],
600    )
601}
602
603/// Check and run one call: Ok((exit code, tool message)) or the outcome that
604/// ends the run.
605fn run_call(
606    options: &TaskOptions<'_>,
607    call: &ToolCall,
608    context: &mut PolicyContext,
609    total: &mut EffectDiff,
610    evidence: &mut Value,
611) -> Result<(i32, String), Box<Outcome>> {
612    if let Some(args) = call.args.as_ref().filter(|_| call.tool != "bash") {
613        return run_file_tool_call(options, call, args, context, total, evidence);
614    }
615    let bounds = options.bounds;
616    let note = options.note;
617    let decision = policy::evaluate_command(&call.command, context);
618    let mut step = json!({ "kind": "tool", "command": call.command, "argv": decision.tokens });
619    if !decision.allowed {
620        let reason = decision.reason.clone().unwrap_or_default();
621        step["decision"] = json!("refused");
622        step["reason"] = json!(reason);
623        steps_mut(evidence).push(step);
624        let message = format!("refused `{}`: {}", call.command, reason);
625        note(&message);
626        return Err(Box::new(Outcome::new(
627            "refused",
628            EXIT_REFUSED,
629            message,
630            &["the refused call did not run; nothing after it ran either"],
631        )));
632    }
633    step["decision"] = json!("allowed");
634    step["effect"] = json!(decision.kind);
635    note(&format!("$ {}", call.command));
636    let before = policy::fingerprint(options.cwd, bounds.max_fingerprint_entries);
637    let remaining = options.deadline.saturating_duration_since(Instant::now());
638    let limit = Duration::from_millis(bounds.tool_timeout_ms)
639        .min(remaining)
640        .max(Duration::from_millis(1));
641    let cancel = || (options.cancel)().is_some();
642    let receipt = run_tool(&ToolOptions {
643        argv: &decision.tokens,
644        cwd: options.cwd,
645        env: options.env,
646        timeout: limit,
647        max_output_bytes: bounds.max_output_bytes,
648        mirror: options.mirror,
649        cancel: &cancel,
650    });
651    let diff = policy::diff_fingerprints(
652        &before,
653        &policy::fingerprint(options.cwd, bounds.max_fingerprint_entries),
654    );
655    step["exitCode"] = json!(receipt.exit_code);
656    step["signal"] = json!(receipt.signal);
657    step["timedOut"] = json!(receipt.timed_out);
658    step["durationMs"] = json!(receipt.duration_ms);
659    step["truncated"] = json!(receipt.truncated);
660    step["spawnError"] = json!(receipt.spawn_error);
661    step["stdout"] = json!(preview(&receipt.stdout));
662    step["stderr"] = json!(preview(&receipt.stderr));
663    step["effects"] = diff.to_json();
664    record_effects(&diff, total, evidence);
665    if receipt.truncated {
666        note(&format!(
667            "output truncated at START_FORMAL_AI_MAX_OUTPUT_BYTES={}",
668            bounds.max_output_bytes
669        ));
670    }
671    let cwd_text = options.cwd.to_string_lossy().to_string();
672    let result = if receipt.timed_out {
673        Err(Box::new(Outcome::new(
674            "budget-exhausted",
675            EXIT_BUDGET,
676            format!(
677                "`{}` exceeded the tool time limit and was stopped",
678                call.command
679            ),
680            &["the stopped call may have done part of its work"],
681        )))
682    } else if decision.kind == Some("read") {
683        policy::record_observation(&decision, context);
684        if diff.has_effects() {
685            step["unexpectedEffects"] = json!(true);
686            let names: Vec<&str> = diff
687                .added
688                .iter()
689                .chain(&diff.removed)
690                .chain(&diff.changed)
691                .take(5)
692                .map(String::as_str)
693                .collect();
694            note(&format!(
695                "warning: the working directory changed during a read-only call ({})",
696                names.join(", ")
697            ));
698        }
699        Ok((
700            receipt.exit_code,
701            format_tool_result(&receipt, &call.command, &cwd_text),
702        ))
703    } else if receipt.exit_code == 0 {
704        let verification = policy::verify_change(&decision.tokens, options.cwd);
705        step["verification"] = verification.to_json();
706        if verification.verified {
707            for target in &decision.targets {
708                context.observed.insert(target.clone());
709            }
710            Ok((0, format_tool_result(&receipt, &call.command, &cwd_text)))
711        } else {
712            Err(Box::new(Outcome::new(
713                "unverified",
714                EXIT_PROTOCOL,
715                format!("`{}` exited 0 but {}", call.command, verification.detail),
716                &["the change could not be confirmed by readback"],
717            )))
718        }
719    } else {
720        Ok((
721            receipt.exit_code,
722            format_tool_result(&receipt, &call.command, &cwd_text),
723        ))
724    };
725    steps_mut(evidence).push(step);
726    result
727}
728
729/// Add a call's working-directory diff to the run's total.
730fn record_effects(diff: &EffectDiff, total: &mut EffectDiff, evidence: &mut Value) {
731    total.added.extend(diff.added.iter().cloned());
732    total.removed.extend(diff.removed.iter().cloned());
733    total.changed.extend(diff.changed.iter().cloned());
734    total.complete = total.complete && diff.complete;
735    evidence["effects"] = total.to_json();
736}
737
738/// A structured read, edit or write call (issue #258).
739fn run_file_tool_call(
740    options: &TaskOptions<'_>,
741    call: &ToolCall,
742    args: &FileArgs,
743    context: &mut PolicyContext,
744    total: &mut EffectDiff,
745    evidence: &mut Value,
746) -> Result<(i32, String), Box<Outcome>> {
747    let bounds = options.bounds;
748    let note = options.note;
749    let decision = evaluate_file_call(&call.tool, args, context);
750    let mut step = json!({
751        "kind": "tool",
752        "tool": call.tool,
753        "command": call.command,
754        "path": args.path,
755    });
756    if !decision.allowed {
757        let reason = decision.reason.clone().unwrap_or_default();
758        step["decision"] = json!("refused");
759        step["reason"] = json!(reason);
760        steps_mut(evidence).push(step);
761        let message = format!("refused `{}`: {}", call.command, reason);
762        note(&message);
763        return Err(Box::new(Outcome::new(
764            "refused",
765            EXIT_REFUSED,
766            message,
767            &["the refused call did not run; nothing after it ran either"],
768        )));
769    }
770    step["decision"] = json!("allowed");
771    step["effect"] = json!(decision.kind);
772    step["target"] = json!(decision.target);
773    note(&call.command);
774    let started = Instant::now();
775    let before = policy::fingerprint(options.cwd, bounds.max_fingerprint_entries);
776    let receipt = run_file_call(&call.tool, args, &decision, context, bounds.max_file_bytes);
777    let diff = policy::diff_fingerprints(
778        &before,
779        &policy::fingerprint(options.cwd, bounds.max_fingerprint_entries),
780    );
781    step["exitCode"] = json!(receipt.exit_code);
782    step["durationMs"] = json!(started.elapsed().as_millis() as u64);
783    if let Value::Object(fields) = receipt.evidence() {
784        for (key, value) in fields {
785            step[key] = value;
786        }
787    }
788    step["effects"] = diff.to_json();
789    record_effects(&diff, total, evidence);
790    let mut stop = None;
791    if let Some(error) = &receipt.error {
792        note(&format!("{} failed: {}", call.tool, error));
793    } else if call.tool == "read" {
794        mirror_read(receipt.text.as_deref().unwrap_or(""), options);
795        if diff.has_effects() {
796            step["unexpectedEffects"] = json!(true);
797        }
798    } else if let Some(verification) = &receipt.verification {
799        step["verification"] = verification.to_json();
800        if verification.verified {
801            note(&verification.detail);
802        } else {
803            stop = Some(Outcome::new(
804                "unverified",
805                EXIT_PROTOCOL,
806                format!("`{}` ran but {}", call.command, verification.detail),
807                &["the change could not be confirmed by readback"],
808            ));
809        }
810    }
811    steps_mut(evidence).push(step);
812    match stop {
813        Some(outcome) => Err(Box::new(outcome)),
814        None => Ok((receipt.exit_code, receipt.result)),
815    }
816}
817
818/// Show what a read returned, like the stdout of `cat`, within the output cap.
819fn mirror_read(text: &str, options: &TaskOptions<'_>) {
820    use std::io::Write;
821    if !options.mirror || text.is_empty() {
822        return;
823    }
824    let cap = usize::try_from(options.bounds.max_output_bytes).unwrap_or(usize::MAX);
825    let bytes = text.as_bytes();
826    let shown = String::from_utf8_lossy(&bytes[..bytes.len().min(cap)]);
827    let mut stdout = std::io::stdout().lock();
828    let _ = stdout.write_all(shown.as_bytes());
829    if !shown.ends_with('\n') {
830        let _ = stdout.write_all(b"\n");
831    }
832    let _ = stdout.flush();
833    if bytes.len() > cap {
834        note_truncated(options);
835    }
836}
837
838fn note_truncated(options: &TaskOptions<'_>) {
839    (options.note)(&format!(
840        "output truncated at START_FORMAL_AI_MAX_OUTPUT_BYTES={}",
841        options.bounds.max_output_bytes
842    ));
843}
844
845#[cfg(test)]
846#[path = "formal_ai_cases.rs"]
847mod formal_ai_cases;