1use 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
35pub 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
57pub 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
88pub struct TaskOptions<'a> {
90 pub request: &'a str,
92 pub cwd: &'a Path,
93 pub env: &'a [(String, String)],
95 pub bounds: &'a Bounds,
96 pub deadline: Instant,
97 pub mirror: bool,
99 pub note: &'a dyn Fn(&str),
100 pub cancel: &'a dyn Fn() -> Option<i32>,
102}
103
104#[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 pub diagnostics: Option<Value>,
113 pub gap: Option<Value>,
115 pub failures: Vec<Value>,
117 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
163pub 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
193pub 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
253pub 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
298pub 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
327pub 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
365pub 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 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 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 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
603fn 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
729fn 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
738fn 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
818fn 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;