Skip to main content

formal_ai/
agent.rs

1//! Isolated, bounded coding-agent workspace primitives.
2//!
3//! The core solver stays deterministic and permissioned: agent mode receives a
4//! fresh temp workspace, validates every path before touching the filesystem,
5//! runs only allowlisted commands without inheriting host environment
6//! variables, and records each step for projection into Links Notation.
7
8use std::error::Error;
9use std::ffi::OsString;
10use std::fmt;
11use std::fs;
12use std::io;
13use std::path::{Component, Path, PathBuf};
14use std::process::{Command, Stdio};
15use std::sync::atomic::{AtomicU64, Ordering};
16use std::thread;
17use std::time::{Duration, Instant};
18
19use crate::engine::stable_id;
20
21/// Monotonic counter making every workspace directory unique within a process, so
22/// two agent runs with the *same* prompt never share (and race on) one directory.
23static WORKSPACE_SEQ: AtomicU64 = AtomicU64::new(0);
24
25const DEFAULT_AGENT_TIME_BUDGET: Duration = Duration::from_secs(2);
26const WINDOWS_PYTHON_TIME_BUDGET_FLOOR: Duration = Duration::from_secs(15);
27
28#[derive(Debug, Clone)]
29pub struct AgentWorkspaceConfig {
30    pub base_dir: PathBuf,
31    pub time_budget: Duration,
32}
33
34impl Default for AgentWorkspaceConfig {
35    fn default() -> Self {
36        Self {
37            base_dir: std::env::temp_dir().join("formal-ai-agent-workspaces"),
38            time_budget: DEFAULT_AGENT_TIME_BUDGET,
39        }
40    }
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct AgentRun {
45    pub workspace: PathBuf,
46    pub status: AgentRunStatus,
47    pub actions: Vec<AgentAction>,
48    pub command_results: Vec<AgentCommandResult>,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum AgentRunStatus {
53    Completed,
54    Failed,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct AgentAction {
59    pub kind: AgentActionKind,
60    pub target: String,
61    pub status: AgentActionStatus,
62    pub detail: String,
63}
64
65impl AgentAction {
66    #[must_use]
67    pub const fn event_kind(&self) -> &'static str {
68        match self.kind {
69            AgentActionKind::CreateFile => "action_log:create_file",
70            AgentActionKind::ModifyFile => "action_log:modify_file",
71            AgentActionKind::DeleteFile => "action_log:delete_file",
72            AgentActionKind::RunCommand => "action_log:run_command",
73        }
74    }
75
76    #[must_use]
77    pub fn evidence_payload(&self) -> String {
78        format!("{} {} {}", self.target, self.status.slug(), self.detail)
79    }
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum AgentActionKind {
84    CreateFile,
85    ModifyFile,
86    DeleteFile,
87    RunCommand,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum AgentActionStatus {
92    Completed,
93    Failed,
94}
95
96impl AgentActionStatus {
97    #[must_use]
98    pub const fn slug(self) -> &'static str {
99        match self {
100            Self::Completed => "completed",
101            Self::Failed => "failed",
102        }
103    }
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct AgentCommandResult {
108    pub command: String,
109    pub status_code: Option<i32>,
110    pub stdout: String,
111    pub stderr: String,
112    pub timed_out: bool,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub enum PlannedAgentAction {
117    CreateFile { path: String, content: String },
118    ModifyFile { path: String, content: String },
119    DeleteFile { path: String },
120    RunCommand { command: String },
121}
122
123#[derive(Debug)]
124pub enum AgentError {
125    Io(io::Error),
126    EmptyCommand,
127    UnsupportedCommand(String),
128    PathEscapesWorkspace(String),
129}
130
131impl fmt::Display for AgentError {
132    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
133        match self {
134            Self::Io(error) => write!(formatter, "{error}"),
135            Self::EmptyCommand => write!(formatter, "command is empty"),
136            Self::UnsupportedCommand(command) => {
137                write!(formatter, "unsupported sandbox command `{command}`")
138            }
139            Self::PathEscapesWorkspace(path) => {
140                write!(formatter, "path `{path}` escapes the isolated workspace")
141            }
142        }
143    }
144}
145
146impl Error for AgentError {}
147
148impl From<io::Error> for AgentError {
149    fn from(error: io::Error) -> Self {
150        Self::Io(error)
151    }
152}
153
154pub struct AgentWorkspace {
155    root: PathBuf,
156    time_budget: Duration,
157    actions: Vec<AgentAction>,
158    command_results: Vec<AgentCommandResult>,
159    failed: bool,
160}
161
162impl AgentWorkspace {
163    pub fn for_prompt(prompt: &str, config: &AgentWorkspaceConfig) -> Result<Self, AgentError> {
164        // A stable, human-readable prefix (for debugging) plus a process id and a
165        // monotonic sequence number, so concurrent runs of the *same* prompt each
166        // get their own directory instead of racing on one shared path. The
167        // workspace path is never asserted on — only its contents are — so the
168        // suffix does not affect determinism of the agentic loop.
169        let prefix = stable_id("agent_workspace", prompt);
170        let seq = WORKSPACE_SEQ.fetch_add(1, Ordering::Relaxed);
171        let workspace_id = format!("{prefix}-{}-{seq}", std::process::id());
172        let root = config.base_dir.join(workspace_id);
173        if root.exists() {
174            fs::remove_dir_all(&root)?;
175        }
176        fs::create_dir_all(&root)?;
177        Ok(Self {
178            root,
179            time_budget: config.time_budget,
180            actions: Vec::new(),
181            command_results: Vec::new(),
182            failed: false,
183        })
184    }
185
186    #[must_use]
187    pub fn root(&self) -> &Path {
188        &self.root
189    }
190
191    /// The result of the most recently run command, if any.
192    ///
193    /// A short-lived plan uses [`Self::finish`] to collect every result at once,
194    /// but a long-lived workspace (the agentic driver reuses one across a whole
195    /// tool-call loop) needs to observe each command's output *between* steps,
196    /// before `finish` consumes the workspace.
197    #[must_use]
198    pub fn last_command_result(&self) -> Option<&AgentCommandResult> {
199        self.command_results.last()
200    }
201
202    pub fn create_file(&mut self, path: &str, content: &str) {
203        let result = self.write_file(path, content);
204        self.record_fs_action(AgentActionKind::CreateFile, path, result);
205    }
206
207    pub fn modify_file(&mut self, path: &str, content: &str) {
208        let result = self.write_file(path, content);
209        self.record_fs_action(AgentActionKind::ModifyFile, path, result);
210    }
211
212    pub fn delete_file(&mut self, path: &str) {
213        let result = self
214            .workspace_path(path)
215            .and_then(|resolved| fs::remove_file(resolved).map_err(AgentError::from));
216        self.record_fs_action(AgentActionKind::DeleteFile, path, result.map(|()| 0));
217    }
218
219    pub fn run_command(&mut self, command_line: &str) {
220        let result = self.run_command_inner(command_line);
221        match result {
222            Ok(command_result) => {
223                let status = if command_result.timed_out || command_result.status_code != Some(0) {
224                    self.failed = true;
225                    AgentActionStatus::Failed
226                } else {
227                    AgentActionStatus::Completed
228                };
229                self.actions.push(AgentAction {
230                    kind: AgentActionKind::RunCommand,
231                    target: command_line.to_owned(),
232                    status,
233                    detail: format!(
234                        "exit={:?} stdout_bytes={} stderr_bytes={}",
235                        command_result.status_code,
236                        command_result.stdout.len(),
237                        command_result.stderr.len()
238                    ),
239                });
240                self.command_results.push(command_result);
241            }
242            Err(error) => {
243                self.failed = true;
244                self.actions.push(AgentAction {
245                    kind: AgentActionKind::RunCommand,
246                    target: command_line.to_owned(),
247                    status: AgentActionStatus::Failed,
248                    detail: error.to_string(),
249                });
250            }
251        }
252    }
253
254    #[must_use]
255    pub fn finish(self) -> AgentRun {
256        AgentRun {
257            workspace: self.root,
258            status: if self.failed {
259                AgentRunStatus::Failed
260            } else {
261                AgentRunStatus::Completed
262            },
263            actions: self.actions,
264            command_results: self.command_results,
265        }
266    }
267
268    fn write_file(&self, path: &str, content: &str) -> Result<usize, AgentError> {
269        let resolved = self.workspace_path(path)?;
270        if let Some(parent) = resolved.parent() {
271            fs::create_dir_all(parent)?;
272        }
273        fs::write(resolved, content)?;
274        Ok(content.len())
275    }
276
277    fn record_fs_action(
278        &mut self,
279        kind: AgentActionKind,
280        target: &str,
281        result: Result<usize, AgentError>,
282    ) {
283        match result {
284            Ok(bytes) => self.actions.push(AgentAction {
285                kind,
286                target: target.to_owned(),
287                status: AgentActionStatus::Completed,
288                detail: format!("bytes={bytes}"),
289            }),
290            Err(error) => {
291                self.failed = true;
292                self.actions.push(AgentAction {
293                    kind,
294                    target: target.to_owned(),
295                    status: AgentActionStatus::Failed,
296                    detail: error.to_string(),
297                });
298            }
299        }
300    }
301
302    fn workspace_path(&self, path: &str) -> Result<PathBuf, AgentError> {
303        let candidate = Path::new(path.trim());
304        if candidate.as_os_str().is_empty() || candidate.is_absolute() {
305            return Err(AgentError::PathEscapesWorkspace(path.to_owned()));
306        }
307        for component in candidate.components() {
308            if !matches!(component, Component::Normal(_)) {
309                return Err(AgentError::PathEscapesWorkspace(path.to_owned()));
310            }
311        }
312        Ok(self.root.join(candidate))
313    }
314
315    fn run_command_inner(&self, command_line: &str) -> Result<AgentCommandResult, AgentError> {
316        let parts = split_command(command_line);
317        let Some((program, args)) = parts.split_first() else {
318            return Err(AgentError::EmptyCommand);
319        };
320        for argument in args {
321            if looks_like_workspace_path(argument) {
322                self.workspace_path(argument)?;
323            }
324        }
325        if let Some(result) = self.run_builtin_command(command_line, program, args)? {
326            return Ok(result);
327        }
328        let command_budget = effective_command_time_budget(program, self.time_budget);
329        let program_path = resolve_allowed_program(program)?;
330        let mut child = Command::new(program_path)
331            .args(args)
332            .current_dir(&self.root)
333            .env_clear()
334            .stdin(Stdio::null())
335            .stdout(Stdio::piped())
336            .stderr(Stdio::piped())
337            .spawn()?;
338        let started = Instant::now();
339        let timed_out = loop {
340            if child.try_wait()?.is_some() {
341                break false;
342            }
343            if started.elapsed() >= command_budget {
344                child.kill()?;
345                break true;
346            }
347            thread::sleep(Duration::from_millis(10));
348        };
349        let output = child.wait_with_output()?;
350        Ok(AgentCommandResult {
351            command: command_line.to_owned(),
352            status_code: output.status.code(),
353            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
354            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
355            timed_out,
356        })
357    }
358
359    fn run_builtin_command(
360        &self,
361        command_line: &str,
362        program: &str,
363        args: &[String],
364    ) -> Result<Option<AgentCommandResult>, AgentError> {
365        let stdout = match program {
366            "env" => String::new(),
367            "cat" if cfg!(windows) => self.read_command_files(args)?,
368            "ls" if cfg!(windows) => self.list_command_directory(args)?,
369            "printf" if cfg!(windows) => args.join(" "),
370            _ => return Ok(None),
371        };
372        Ok(Some(AgentCommandResult {
373            command: command_line.to_owned(),
374            status_code: Some(0),
375            stdout,
376            stderr: String::new(),
377            timed_out: false,
378        }))
379    }
380
381    fn read_command_files(&self, args: &[String]) -> Result<String, AgentError> {
382        let mut output = String::new();
383        for argument in args {
384            if argument.starts_with('-') {
385                continue;
386            }
387            output.push_str(&fs::read_to_string(self.workspace_path(argument)?)?);
388        }
389        Ok(output)
390    }
391
392    fn list_command_directory(&self, args: &[String]) -> Result<String, AgentError> {
393        let directory = args
394            .iter()
395            .find(|argument| !argument.starts_with('-'))
396            .map_or_else(
397                || Ok(self.root.clone()),
398                |argument| self.workspace_path(argument),
399            )?;
400        let mut entries = fs::read_dir(directory)?
401            .map(|entry| entry.map(|entry| entry.file_name().to_string_lossy().into_owned()))
402            .collect::<Result<Vec<_>, _>>()?;
403        entries.sort();
404        let mut output = entries.join("\n");
405        if !output.is_empty() {
406            output.push('\n');
407        }
408        Ok(output)
409    }
410}
411
412#[must_use]
413pub fn parse_agent_plan(prompt: &str) -> Vec<PlannedAgentAction> {
414    let lower = prompt.to_lowercase();
415    let mut indexed = Vec::new();
416    collect_create_actions(prompt, &lower, &mut indexed);
417    collect_modify_actions(prompt, &lower, &mut indexed);
418    collect_delete_actions(prompt, &lower, &mut indexed);
419    collect_command_actions(prompt, &lower, &mut indexed);
420    indexed.sort_by_key(|(index, _)| *index);
421    indexed.into_iter().map(|(_, action)| action).collect()
422}
423
424pub fn run_agent_plan(prompt: &str, config: &AgentWorkspaceConfig) -> Result<AgentRun, AgentError> {
425    let plan = parse_agent_plan(prompt);
426    let mut workspace = AgentWorkspace::for_prompt(prompt, config)?;
427    for action in plan {
428        match action {
429            PlannedAgentAction::CreateFile { path, content } => {
430                workspace.create_file(&path, &content);
431            }
432            PlannedAgentAction::ModifyFile { path, content } => {
433                workspace.modify_file(&path, &content);
434            }
435            PlannedAgentAction::DeleteFile { path } => {
436                workspace.delete_file(&path);
437            }
438            PlannedAgentAction::RunCommand { command } => {
439                workspace.run_command(&command);
440            }
441        }
442    }
443    Ok(workspace.finish())
444}
445
446fn collect_create_actions(
447    prompt: &str,
448    lower: &str,
449    indexed: &mut Vec<(usize, PlannedAgentAction)>,
450) {
451    let marker = "create file ";
452    let mut offset = 0;
453    while let Some(relative) = lower[offset..].find(marker) {
454        let index = offset + relative;
455        let path_start = index + marker.len();
456        let Some(with_relative) = lower[path_start..].find(" with ") else {
457            offset = path_start;
458            continue;
459        };
460        let path_end = path_start + with_relative;
461        let Some(content) = backticked_after(prompt, path_end) else {
462            offset = path_start;
463            continue;
464        };
465        let path = prompt[path_start..path_end].trim();
466        if !path.is_empty() {
467            indexed.push((
468                index,
469                PlannedAgentAction::CreateFile {
470                    path: path.to_owned(),
471                    content,
472                },
473            ));
474        }
475        offset = path_end;
476    }
477}
478
479fn collect_modify_actions(
480    prompt: &str,
481    lower: &str,
482    indexed: &mut Vec<(usize, PlannedAgentAction)>,
483) {
484    let marker = "modify ";
485    let mut offset = 0;
486    while let Some(relative) = lower[offset..].find(marker) {
487        let index = offset + relative;
488        let mut path_start = index + marker.len();
489        if lower[path_start..].starts_with("file ") {
490            path_start += "file ".len();
491        }
492        let Some(to_relative) = lower[path_start..].find(" to ") else {
493            offset = path_start;
494            continue;
495        };
496        let path_end = path_start + to_relative;
497        let Some(content) = backticked_after(prompt, path_end) else {
498            offset = path_start;
499            continue;
500        };
501        let path = prompt[path_start..path_end].trim();
502        if !path.is_empty() {
503            indexed.push((
504                index,
505                PlannedAgentAction::ModifyFile {
506                    path: path.to_owned(),
507                    content,
508                },
509            ));
510        }
511        offset = path_end;
512    }
513}
514
515fn collect_delete_actions(
516    prompt: &str,
517    lower: &str,
518    indexed: &mut Vec<(usize, PlannedAgentAction)>,
519) {
520    for marker in ["delete file ", "delete "] {
521        let mut offset = 0;
522        while let Some(relative) = lower[offset..].find(marker) {
523            let index = offset + relative;
524            if marker == "delete " && lower[index..].starts_with("delete file ") {
525                offset = index + "delete ".len();
526                continue;
527            }
528            let path_start = index + marker.len();
529            let tail = &prompt[path_start..];
530            let path_end = path_start + delete_path_len(tail);
531            let path = prompt[path_start..path_end].trim();
532            if !path.is_empty() {
533                indexed.push((
534                    index,
535                    PlannedAgentAction::DeleteFile {
536                        path: path.to_owned(),
537                    },
538                ));
539            }
540            offset = path_end;
541        }
542    }
543}
544
545fn collect_command_actions(
546    prompt: &str,
547    lower: &str,
548    indexed: &mut Vec<(usize, PlannedAgentAction)>,
549) {
550    for marker in ["run terminal command ", "run command ", "run "] {
551        let mut offset = 0;
552        while let Some(relative) = lower[offset..].find(marker) {
553            let index = offset + relative;
554            if marker == "run "
555                && (lower[index..].starts_with("run command ")
556                    || lower[index..].starts_with("run terminal command "))
557            {
558                offset = index + marker.len();
559                continue;
560            }
561            let Some(command) = backticked_after(prompt, index + marker.len()) else {
562                offset = index + marker.len();
563                continue;
564            };
565            if !command.trim().is_empty() {
566                indexed.push((
567                    index,
568                    PlannedAgentAction::RunCommand {
569                        command: command.trim().to_owned(),
570                    },
571                ));
572            }
573            offset = index + marker.len();
574        }
575    }
576}
577
578fn backticked_after(prompt: &str, start: usize) -> Option<String> {
579    let relative_open = prompt[start..].find('`')?;
580    let open = start + relative_open + 1;
581    let relative_close = prompt[open..].find('`')?;
582    Some(prompt[open..open + relative_close].to_owned())
583}
584
585fn delete_path_len(tail: &str) -> usize {
586    let mut end = tail.len();
587    for delimiter in [",", ";", " and ", " then "] {
588        if let Some(index) = tail.find(delimiter) {
589            end = end.min(index);
590        }
591    }
592    end
593}
594
595fn split_command(command_line: &str) -> Vec<String> {
596    command_line
597        .split_whitespace()
598        .map(str::trim)
599        .filter(|part| !part.is_empty())
600        .map(|part| part.trim_matches(['"', '\'']).to_owned())
601        .collect()
602}
603
604fn resolve_allowed_program(program: &str) -> Result<PathBuf, AgentError> {
605    let candidates: &[&str] = match program {
606        "cat" => &["/bin/cat", "/usr/bin/cat"],
607        "ls" => &["/bin/ls", "/usr/bin/ls"],
608        "printf" => &["/usr/bin/printf", "/bin/printf"],
609        "env" => &["/usr/bin/env", "/bin/env"],
610        "python3" => &["/usr/bin/python3", "/bin/python3", "/usr/local/bin/python3"],
611        other => return Err(AgentError::UnsupportedCommand(other.to_owned())),
612    };
613    candidates
614        .iter()
615        .map(PathBuf::from)
616        .find(|path| path.is_file())
617        .or_else(|| resolve_allowed_program_from_path(program))
618        .ok_or_else(|| AgentError::UnsupportedCommand(program.to_owned()))
619}
620
621fn resolve_allowed_program_from_path(program: &str) -> Option<PathBuf> {
622    let names = path_search_names(program)?;
623    resolve_program_from_path_names(names, std::env::var_os("PATH"))
624}
625
626fn resolve_program_from_path_names(names: &[&str], path: Option<OsString>) -> Option<PathBuf> {
627    let path = path?;
628    let directories: Vec<_> = std::env::split_paths(&path)
629        .filter(|directory| directory.is_absolute())
630        .collect();
631    names
632        .iter()
633        .flat_map(|name| {
634            directories
635                .iter()
636                .map(move |directory| directory.join(name))
637        })
638        .find(|candidate| candidate.is_file() && !is_blocked_execution_alias(candidate))
639}
640
641fn path_search_names(program: &str) -> Option<&'static [&'static str]> {
642    match program {
643        "python3" if cfg!(windows) => Some(&["python3.exe", "python.exe", "py.exe"]),
644        "python3" => Some(&["python3"]),
645        _ => None,
646    }
647}
648
649fn is_blocked_execution_alias(candidate: &Path) -> bool {
650    cfg!(windows)
651        && candidate
652            .to_string_lossy()
653            .to_ascii_lowercase()
654            .contains(r"\microsoft\windowsapps\")
655}
656
657fn effective_command_time_budget(program: &str, configured: Duration) -> Duration {
658    if cfg!(windows) && program == "python3" && configured < WINDOWS_PYTHON_TIME_BUDGET_FLOOR {
659        WINDOWS_PYTHON_TIME_BUDGET_FLOOR
660    } else {
661        configured
662    }
663}
664
665fn looks_like_workspace_path(argument: &str) -> bool {
666    !argument.starts_with('-')
667        && !argument.contains('=')
668        && (argument.contains('/')
669            || argument.contains('.')
670            || Path::new(argument).extension().is_some())
671}