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
15use std::path::Path;
16use std::time::{Duration, Instant};
17
18use serde_json::{json, Value};
19
20use crate::formal_ai_client::{recognize_capability_gap, CompletionClient, ToolCall};
21use crate::formal_ai_files::{evaluate_file_call, run_file_call, FileArgs};
22use crate::formal_ai_policy::{
23    self as policy, Bounds, EffectDiff, PolicyContext, EXIT_BUDGET, EXIT_PROTOCOL, EXIT_REFUSED,
24    EXIT_UNAVAILABLE, EXIT_UNSUPPORTED,
25};
26use crate::formal_ai_tool::{format_tool_result, run_tool, ToolOptions};
27
28const PREVIEW_CHARS: usize = 2000;
29
30/// Exit status of a cancellation by signal name (128 + signal number).
31pub fn signal_exit(name: &str) -> i32 {
32    match name {
33        "SIGHUP" => 129,
34        "SIGTERM" => 143,
35        _ => 130,
36    }
37}
38
39fn preview(text: &str) -> String {
40    let count = text.chars().count();
41    if count > PREVIEW_CHARS {
42        format!(
43            "{}… [{} more chars]",
44            text.chars().take(PREVIEW_CHARS).collect::<String>(),
45            count - PREVIEW_CHARS
46        )
47    } else {
48        text.to_string()
49    }
50}
51
52/// Observed working-directory changes, in words.
53pub fn describe_effects(effects: &EffectDiff) -> String {
54    let parts: Vec<String> = [
55        ("added", &effects.added),
56        ("removed", &effects.removed),
57        ("changed", &effects.changed),
58    ]
59    .iter()
60    .filter(|(_, names)| !names.is_empty())
61    .map(|(key, names)| {
62        let more = if names.len() > 10 {
63            format!(" and {} more", names.len() - 10)
64        } else {
65            String::new()
66        };
67        let shown: Vec<&str> = names.iter().take(10).map(String::as_str).collect();
68        format!("{} {}{}", key, shown.join(", "), more)
69    })
70    .collect();
71    let text = if parts.is_empty() {
72        "no file changed".to_string()
73    } else {
74        parts.join("; ")
75    };
76    if effects.complete {
77        text
78    } else {
79        format!("{} (fingerprint incomplete)", text)
80    }
81}
82
83/// What one task run needs besides the Formal AI client.
84pub struct TaskOptions<'a> {
85    /// Natural-language request, never executed.
86    pub request: &'a str,
87    pub cwd: &'a Path,
88    /// START_* variables added to the tools' environment.
89    pub env: &'a [(String, String)],
90    pub bounds: &'a Bounds,
91    pub deadline: Instant,
92    /// Mirror tool output to this process's stdout/stderr.
93    pub mirror: bool,
94    pub note: &'a dyn Fn(&str),
95    /// Exit status of a received cancellation signal, if any.
96    pub cancel: &'a dyn Fn() -> Option<i32>,
97}
98
99/// The final state of a run.
100#[derive(Debug, Clone, PartialEq)]
101pub struct Outcome {
102    pub status: String,
103    pub exit_code: i32,
104    pub message: String,
105    pub remaining: Vec<String>,
106    /// Sanitized cause of a runtime failure (issue #257).
107    pub diagnostics: Option<Value>,
108    /// The capability gap Formal AI reported (issue #258).
109    pub gap: Option<Value>,
110}
111
112impl Outcome {
113    pub fn new(
114        status: &str,
115        exit_code: i32,
116        message: impl Into<String>,
117        remaining: &[&str],
118    ) -> Self {
119        Self {
120            status: status.to_string(),
121            exit_code,
122            message: message.into(),
123            remaining: remaining.iter().map(|s| s.to_string()).collect(),
124            diagnostics: None,
125            gap: None,
126        }
127    }
128
129    pub fn to_json(&self) -> Value {
130        let mut out = json!({
131            "status": self.status,
132            "exitCode": self.exit_code,
133            "message": self.message,
134            "remainingObligations": self.remaining,
135        });
136        if let Some(diagnostics) = &self.diagnostics {
137            out["diagnostics"] = diagnostics.clone();
138        }
139        if let Some(gap) = &self.gap {
140            out["gap"] = gap.clone();
141        }
142        out
143    }
144}
145
146/// Capability gap, in words.
147pub fn describe_gap(gap: &Value) -> String {
148    let missing: Vec<&str> = gap["missingCapabilities"]
149        .as_array()
150        .map(|items| items.iter().filter_map(Value::as_str).collect())
151        .unwrap_or_default();
152    let missing = if missing.is_empty() {
153        String::new()
154    } else {
155        format!("; missing {}", missing.join(", "))
156    };
157    format!(
158        "Formal AI reported a capability gap for {} ({}{}); nothing was verified as done",
159        gap["target"].as_str().unwrap_or(""),
160        gap["reason"].as_str().unwrap_or(""),
161        missing
162    )
163}
164
165/// What a capability gap leaves to do, and how to offer the change tools.
166pub fn gap_obligations(gap: &Value, effects: &str) -> Vec<String> {
167    let mut obligations = vec![format!(
168        "the requested change to {} was not made or not verified",
169        gap["target"].as_str().unwrap_or("")
170    )];
171    let needs_change = gap["missingCapabilities"].as_array().is_some_and(|items| {
172        items
173            .iter()
174            .any(|c| matches!(c.as_str(), Some("edit" | "write" | "multi_edit")))
175    });
176    if effects != "workspace-write" && needs_change {
177        obligations.push(
178            "set START_FORMAL_AI_EFFECTS=workspace-write to offer the edit and write tools"
179                .to_string(),
180        );
181    }
182    obligations
183}
184
185fn cancelled(code: i32) -> Outcome {
186    let name = crate::exit_reason::signal_name_for_exit_code(Some(code))
187        .unwrap_or_else(|| "SIGINT".to_string());
188    Outcome::new(
189        "cancelled",
190        code,
191        format!("cancelled by {}", name),
192        &["the task was interrupted; later steps did not run"],
193    )
194}
195
196fn steps_mut(evidence: &mut Value) -> &mut Vec<Value> {
197    if !evidence["steps"].is_array() {
198        evidence["steps"] = json!([]);
199    }
200    evidence["steps"].as_array_mut().expect("steps is an array")
201}
202
203/// Run one request to completion. Fills `evidence` (a JSON object) as it
204/// goes, sets `evidence.outcome` and returns the outcome.
205pub fn run_formal_ai_task(
206    options: &TaskOptions<'_>,
207    client: &mut dyn CompletionClient,
208    evidence: &mut Value,
209) -> Outcome {
210    if !evidence.is_object() {
211        *evidence = json!({});
212    }
213    evidence["request"] = json!(options.request);
214    evidence["cwd"] = json!(options.cwd.to_string_lossy());
215    evidence["effectsMode"] = json!(options.bounds.effects);
216    evidence["bounds"] = options.bounds.to_json();
217    evidence["steps"] = json!([]);
218    evidence["effects"] = EffectDiff::default().to_json();
219    evidence["outcome"] = Value::Null;
220    let outcome = task_loop(options, client, evidence);
221    evidence["outcome"] = outcome.to_json();
222    outcome
223}
224
225fn task_loop(
226    options: &TaskOptions<'_>,
227    client: &mut dyn CompletionClient,
228    evidence: &mut Value,
229) -> Outcome {
230    let bounds = options.bounds;
231    let mut context = PolicyContext::new(options.cwd, &bounds.effects);
232    let mut total = EffectDiff::default();
233    let mut messages = vec![json!({ "role": "user", "content": options.request })];
234    let mut last_exit: Option<i32> = None;
235    let mut tool_calls: u64 = 0;
236    let is_cancelled = || (options.cancel)().is_some();
237
238    for turn in 1..=bounds.max_steps {
239        if let Some(code) = (options.cancel)() {
240            return cancelled(code);
241        }
242        if Instant::now() >= options.deadline {
243            return Outcome::new(
244                "budget-exhausted",
245                EXIT_BUDGET,
246                "time budget exhausted",
247                &["the task did not finish within START_FORMAL_AI_TIMEOUT_MS"],
248            );
249        }
250        let response = match client.complete(&messages, &is_cancelled) {
251            Ok(response) => response,
252            Err(error) => {
253                if let Some(code) = (options.cancel)() {
254                    return cancelled(code);
255                }
256                let status = match error.exit_code {
257                    EXIT_BUDGET => "budget-exhausted",
258                    EXIT_UNAVAILABLE => "unavailable",
259                    _ => "protocol-error",
260                };
261                // The cause and attempt count of a runtime failure stay in
262                // the evidence (issue #257), sanitized and bounded.
263                let mut outcome = Outcome::new(
264                    status,
265                    error.exit_code,
266                    error.message,
267                    &["Formal AI did not complete the task"],
268                );
269                outcome.diagnostics = error.details;
270                return outcome;
271            }
272        };
273        steps_mut(evidence).push(json!({
274            "kind": "response",
275            "turn": turn,
276            "finishReason": response.finish_reason,
277            "handler": response.handler,
278            "content": preview(&response.content),
279            "toolCalls": response.tool_calls.iter().map(|c| c.command.clone()).collect::<Vec<_>>(),
280        }));
281        if response.tool_calls.is_empty() {
282            if !response.content.is_empty() {
283                (options.note)(&response.content);
284            }
285            if let Some(gap) = recognize_capability_gap(&response.content, &messages) {
286                let mut outcome =
287                    Outcome::new("capability-gap", EXIT_UNSUPPORTED, describe_gap(&gap), &[]);
288                outcome.remaining = gap_obligations(&gap, &bounds.effects);
289                outcome.gap = Some(gap);
290                return outcome;
291            }
292            if response.handler.as_deref() == Some("unknown") && tool_calls == 0 {
293                return Outcome::new(
294                    "unsupported",
295                    EXIT_UNSUPPORTED,
296                    "Formal AI did not understand the request; no tool ran",
297                    &["rephrase the request or perform it manually"],
298                );
299            }
300            let Some(last) = last_exit else {
301                return Outcome::new("answered", 0, "Formal AI answered without tools", &[]);
302            };
303            // The status reports what the tools did, not what Formal AI
304            // claims: a run of reads is never presented as a completed change.
305            let status = if last == 0 {
306                format!("finished after {} tool call(s)", tool_calls)
307            } else {
308                format!("finished; the last tool call exited with {}", last)
309            };
310            let remaining: &[&str] = if last == 0 {
311                &[]
312            } else {
313                &["the last tool call failed"]
314            };
315            return Outcome::new(
316                "completed",
317                last,
318                format!("{}; {}", status, describe_effects(&total)),
319                remaining,
320            );
321        }
322        let ids: Vec<String> = response
323            .tool_calls
324            .iter()
325            .enumerate()
326            .map(|(index, call)| {
327                call.id
328                    .clone()
329                    .unwrap_or_else(|| format!("call_{}_{}", turn, index))
330            })
331            .collect();
332        messages.push(json!({
333            "role": "assistant",
334            "content": response.content,
335            "tool_calls": response.tool_calls.iter().zip(&ids).map(|(call, id)| json!({
336                "id": id,
337                "type": "function",
338                "function": {
339                    "name": call.name,
340                    "arguments": if call.tool == "bash" {
341                        json!({ "command": call.command }).to_string()
342                    } else {
343                        call.arguments.clone()
344                    },
345                },
346            })).collect::<Vec<_>>(),
347        }));
348        for (call, id) in response.tool_calls.iter().zip(&ids) {
349            tool_calls += 1;
350            if tool_calls > bounds.max_steps {
351                return Outcome::new(
352                    "budget-exhausted",
353                    EXIT_BUDGET,
354                    format!("tool call budget of {} exhausted", bounds.max_steps),
355                    &["the task did not finish within START_FORMAL_AI_MAX_STEPS"],
356                );
357            }
358            let result = run_call(options, call, &mut context, &mut total, evidence);
359            if let Some(code) = (options.cancel)() {
360                return cancelled(code);
361            }
362            let (exit_code, content) = match result {
363                Ok(done) => done,
364                Err(outcome) => return *outcome,
365            };
366            last_exit = Some(exit_code);
367            messages.push(json!({ "role": "tool", "tool_call_id": id, "content": content }));
368        }
369    }
370    Outcome::new(
371        "budget-exhausted",
372        EXIT_BUDGET,
373        format!("step budget of {} exhausted", bounds.max_steps),
374        &["the task did not finish within START_FORMAL_AI_MAX_STEPS"],
375    )
376}
377
378/// Check and run one call: Ok((exit code, tool message)) or the outcome that
379/// ends the run.
380fn run_call(
381    options: &TaskOptions<'_>,
382    call: &ToolCall,
383    context: &mut PolicyContext,
384    total: &mut EffectDiff,
385    evidence: &mut Value,
386) -> Result<(i32, String), Box<Outcome>> {
387    if let Some(args) = call.args.as_ref().filter(|_| call.tool != "bash") {
388        return run_file_tool_call(options, call, args, context, total, evidence);
389    }
390    let bounds = options.bounds;
391    let note = options.note;
392    let decision = policy::evaluate_command(&call.command, context);
393    let mut step = json!({ "kind": "tool", "command": call.command, "argv": decision.tokens });
394    if !decision.allowed {
395        let reason = decision.reason.clone().unwrap_or_default();
396        step["decision"] = json!("refused");
397        step["reason"] = json!(reason);
398        steps_mut(evidence).push(step);
399        let message = format!("refused `{}`: {}", call.command, reason);
400        note(&message);
401        return Err(Box::new(Outcome::new(
402            "refused",
403            EXIT_REFUSED,
404            message,
405            &["the refused call did not run; nothing after it ran either"],
406        )));
407    }
408    step["decision"] = json!("allowed");
409    step["effect"] = json!(decision.kind);
410    note(&format!("$ {}", call.command));
411    let before = policy::fingerprint(options.cwd, bounds.max_fingerprint_entries);
412    let remaining = options.deadline.saturating_duration_since(Instant::now());
413    let limit = Duration::from_millis(bounds.tool_timeout_ms)
414        .min(remaining)
415        .max(Duration::from_millis(1));
416    let cancel = || (options.cancel)().is_some();
417    let receipt = run_tool(&ToolOptions {
418        argv: &decision.tokens,
419        cwd: options.cwd,
420        env: options.env,
421        timeout: limit,
422        max_output_bytes: bounds.max_output_bytes,
423        mirror: options.mirror,
424        cancel: &cancel,
425    });
426    let diff = policy::diff_fingerprints(
427        &before,
428        &policy::fingerprint(options.cwd, bounds.max_fingerprint_entries),
429    );
430    step["exitCode"] = json!(receipt.exit_code);
431    step["signal"] = json!(receipt.signal);
432    step["timedOut"] = json!(receipt.timed_out);
433    step["durationMs"] = json!(receipt.duration_ms);
434    step["truncated"] = json!(receipt.truncated);
435    step["spawnError"] = json!(receipt.spawn_error);
436    step["stdout"] = json!(preview(&receipt.stdout));
437    step["stderr"] = json!(preview(&receipt.stderr));
438    step["effects"] = diff.to_json();
439    record_effects(&diff, total, evidence);
440    if receipt.truncated {
441        note(&format!(
442            "output truncated at START_FORMAL_AI_MAX_OUTPUT_BYTES={}",
443            bounds.max_output_bytes
444        ));
445    }
446    let cwd_text = options.cwd.to_string_lossy().to_string();
447    let result = if receipt.timed_out {
448        Err(Box::new(Outcome::new(
449            "budget-exhausted",
450            EXIT_BUDGET,
451            format!(
452                "`{}` exceeded the tool time limit and was stopped",
453                call.command
454            ),
455            &["the stopped call may have done part of its work"],
456        )))
457    } else if decision.kind == Some("read") {
458        policy::record_observation(&decision, context);
459        if diff.has_effects() {
460            step["unexpectedEffects"] = json!(true);
461            let names: Vec<&str> = diff
462                .added
463                .iter()
464                .chain(&diff.removed)
465                .chain(&diff.changed)
466                .take(5)
467                .map(String::as_str)
468                .collect();
469            note(&format!(
470                "warning: the working directory changed during a read-only call ({})",
471                names.join(", ")
472            ));
473        }
474        Ok((
475            receipt.exit_code,
476            format_tool_result(&receipt, &call.command, &cwd_text),
477        ))
478    } else if receipt.exit_code == 0 {
479        let verification = policy::verify_change(&decision.tokens, options.cwd);
480        step["verification"] = verification.to_json();
481        if verification.verified {
482            for target in &decision.targets {
483                context.observed.insert(target.clone());
484            }
485            Ok((0, format_tool_result(&receipt, &call.command, &cwd_text)))
486        } else {
487            Err(Box::new(Outcome::new(
488                "unverified",
489                EXIT_PROTOCOL,
490                format!("`{}` exited 0 but {}", call.command, verification.detail),
491                &["the change could not be confirmed by readback"],
492            )))
493        }
494    } else {
495        Ok((
496            receipt.exit_code,
497            format_tool_result(&receipt, &call.command, &cwd_text),
498        ))
499    };
500    steps_mut(evidence).push(step);
501    result
502}
503
504/// Add a call's working-directory diff to the run's total.
505fn record_effects(diff: &EffectDiff, total: &mut EffectDiff, evidence: &mut Value) {
506    total.added.extend(diff.added.iter().cloned());
507    total.removed.extend(diff.removed.iter().cloned());
508    total.changed.extend(diff.changed.iter().cloned());
509    total.complete = total.complete && diff.complete;
510    evidence["effects"] = total.to_json();
511}
512
513/// A structured read, edit or write call (issue #258).
514fn run_file_tool_call(
515    options: &TaskOptions<'_>,
516    call: &ToolCall,
517    args: &FileArgs,
518    context: &mut PolicyContext,
519    total: &mut EffectDiff,
520    evidence: &mut Value,
521) -> Result<(i32, String), Box<Outcome>> {
522    let bounds = options.bounds;
523    let note = options.note;
524    let decision = evaluate_file_call(&call.tool, args, context);
525    let mut step = json!({
526        "kind": "tool",
527        "tool": call.tool,
528        "command": call.command,
529        "path": args.path,
530    });
531    if !decision.allowed {
532        let reason = decision.reason.clone().unwrap_or_default();
533        step["decision"] = json!("refused");
534        step["reason"] = json!(reason);
535        steps_mut(evidence).push(step);
536        let message = format!("refused `{}`: {}", call.command, reason);
537        note(&message);
538        return Err(Box::new(Outcome::new(
539            "refused",
540            EXIT_REFUSED,
541            message,
542            &["the refused call did not run; nothing after it ran either"],
543        )));
544    }
545    step["decision"] = json!("allowed");
546    step["effect"] = json!(decision.kind);
547    step["target"] = json!(decision.target);
548    note(&call.command);
549    let started = Instant::now();
550    let before = policy::fingerprint(options.cwd, bounds.max_fingerprint_entries);
551    let receipt = run_file_call(&call.tool, args, &decision, context, bounds.max_file_bytes);
552    let diff = policy::diff_fingerprints(
553        &before,
554        &policy::fingerprint(options.cwd, bounds.max_fingerprint_entries),
555    );
556    step["exitCode"] = json!(receipt.exit_code);
557    step["durationMs"] = json!(started.elapsed().as_millis() as u64);
558    if let Value::Object(fields) = receipt.evidence() {
559        for (key, value) in fields {
560            step[key] = value;
561        }
562    }
563    step["effects"] = diff.to_json();
564    record_effects(&diff, total, evidence);
565    let mut stop = None;
566    if let Some(error) = &receipt.error {
567        note(&format!("{} failed: {}", call.tool, error));
568    } else if call.tool == "read" {
569        mirror_read(receipt.text.as_deref().unwrap_or(""), options);
570        if diff.has_effects() {
571            step["unexpectedEffects"] = json!(true);
572        }
573    } else if let Some(verification) = &receipt.verification {
574        step["verification"] = verification.to_json();
575        if verification.verified {
576            note(&verification.detail);
577        } else {
578            stop = Some(Outcome::new(
579                "unverified",
580                EXIT_PROTOCOL,
581                format!(
582                    "`{}` wrote the file but {}",
583                    call.command, verification.detail
584                ),
585                &["the change could not be confirmed by readback"],
586            ));
587        }
588    }
589    steps_mut(evidence).push(step);
590    match stop {
591        Some(outcome) => Err(Box::new(outcome)),
592        None => Ok((receipt.exit_code, receipt.result)),
593    }
594}
595
596/// Show what a read returned, like the stdout of `cat`, within the output cap.
597fn mirror_read(text: &str, options: &TaskOptions<'_>) {
598    use std::io::Write;
599    if !options.mirror || text.is_empty() {
600        return;
601    }
602    let cap = usize::try_from(options.bounds.max_output_bytes).unwrap_or(usize::MAX);
603    let bytes = text.as_bytes();
604    let shown = String::from_utf8_lossy(&bytes[..bytes.len().min(cap)]);
605    let mut stdout = std::io::stdout().lock();
606    let _ = stdout.write_all(shown.as_bytes());
607    if !shown.ends_with('\n') {
608        let _ = stdout.write_all(b"\n");
609    }
610    let _ = stdout.flush();
611    if bytes.len() > cap {
612        note_truncated(options);
613    }
614}
615
616fn note_truncated(options: &TaskOptions<'_>) {
617    (options.note)(&format!(
618        "output truncated at START_FORMAL_AI_MAX_OUTPUT_BYTES={}",
619        options.bounds.max_output_bytes
620    ));
621}
622
623#[cfg(test)]
624#[path = "formal_ai_cases.rs"]
625mod formal_ai_cases;