1use 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
30pub 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
52pub 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
83pub struct TaskOptions<'a> {
85 pub request: &'a str,
87 pub cwd: &'a Path,
88 pub env: &'a [(String, String)],
90 pub bounds: &'a Bounds,
91 pub deadline: Instant,
92 pub mirror: bool,
94 pub note: &'a dyn Fn(&str),
95 pub cancel: &'a dyn Fn() -> Option<i32>,
97}
98
99#[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 pub diagnostics: Option<Value>,
108 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
146pub 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
165pub 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
203pub 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 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 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
378fn 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
504fn 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
513fn 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
596fn 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;