Skip to main content

harness_lock_core/
agent.rs

1//! The built-in agent: a small tool-calling loop that ships inside hlock, so the meta
2//! harness works with no install (`specs/meta-harness.md`).
3//!
4//! It talks to Anthropic's Messages API, or to any OpenAI-compatible chat-completions
5//! endpoint (OpenAI, OpenRouter, Gemini's OpenAI endpoint, and local runtimes such as
6//! Ollama, LM Studio, llama.cpp and vLLM). Its tools read, search and edit files in the
7//! project and run commands. In an interactive run every command that isn't allowed is
8//! put to the user first; a headless run executes only allowlisted commands.
9
10use std::collections::BTreeMap;
11use std::io::Read;
12use std::path::{Path, PathBuf};
13use std::process::{Command, Stdio};
14use std::time::{Duration, Instant};
15
16use serde::Serialize;
17use serde_json::{json, Value};
18
19use crate::error::{Error, Result};
20
21/// Commands a headless run may execute without asking: hlock itself and read-only git.
22pub const DEFAULT_ALLOW: &[&str] = &["hlock", "git log", "git show", "git diff", "git status"];
23pub const DEFAULT_MAX_STEPS: u32 = 60;
24pub const DEFAULT_TIMEOUT_SECONDS: u64 = 300;
25/// The most a tool result sends back to the model, in bytes.
26const MAX_RESULT: usize = 24_000;
27
28/// Where each API provider lives, and the variable its key is read from.
29pub const PROVIDERS: &[(&str, &str, &str)] = &[
30    ("anthropic", "https://api.anthropic.com/v1", "ANTHROPIC_API_KEY"),
31    ("openai", "https://api.openai.com/v1", "OPENAI_API_KEY"),
32    ("google", "https://generativelanguage.googleapis.com/v1beta/openai", "GEMINI_API_KEY"),
33    ("openrouter", "https://openrouter.ai/api/v1", "OPENROUTER_API_KEY"),
34];
35
36/// How the built-in agent reaches its model and what it may do.
37#[derive(Debug, Clone, Default)]
38pub struct AgentConfig {
39    /// `anthropic` uses the Messages API; anything else, chat completions.
40    pub provider: String,
41    pub model: String,
42    pub base_url: String,
43    pub api_key: Option<String>,
44    pub max_steps: u32,
45    pub timeout_seconds: u64,
46    /// Command prefixes that run without asking (`"git log"` allows `git log -5`).
47    pub allow: Vec<String>,
48    /// Ask the user about commands that aren't allowed; otherwise refuse them.
49    pub interactive: bool,
50    pub project: PathBuf,
51}
52
53/// The default base URL for an API provider.
54pub fn provider_base_url(provider: &str) -> Option<&'static str> {
55    PROVIDERS.iter().find(|(p, _, _)| *p == provider).map(|(_, u, _)| *u)
56}
57
58/// The variable an API provider's key is read from.
59pub fn provider_key_env(provider: &str) -> Option<&'static str> {
60    PROVIDERS.iter().find(|(p, _, _)| *p == provider).map(|(_, _, k)| *k)
61}
62
63/// What the loop reports as it goes: `text` (the model's words), `tool` (a call),
64/// `result` (a one-line summary of a tool's result), `denied`, and `error`. Returning
65/// false stops the run (the user pressed Ctrl-C).
66pub type Say<'a> = dyn FnMut(&str, &str) -> bool + 'a;
67/// Asks the user about an action: returns `y` (yes), `n` (no) or `a` (always, for this run).
68pub type Ask<'a> = dyn FnMut(&str) -> String + 'a;
69
70#[derive(Debug, Clone, Serialize, Default)]
71pub struct Outcome {
72    /// `done` (the model finished), `max-steps`, `stopped`, or `error`.
73    pub status: String,
74    pub steps: u32,
75    /// The model's last words: its summary.
76    pub summary: String,
77    pub error: Option<String>,
78    pub input_tokens: u64,
79    pub output_tokens: u64,
80}
81
82// --------------------------------------------------------------------------- //
83// Conversation
84// --------------------------------------------------------------------------- //
85
86#[derive(Debug, Clone)]
87struct Call {
88    id: String,
89    name: String,
90    args: Value,
91}
92
93#[derive(Debug, Clone)]
94enum Msg {
95    User(String),
96    Assistant { text: String, calls: Vec<Call> },
97    Results(Vec<(String, String)>),
98}
99
100fn tools() -> Vec<(&'static str, &'static str, Value)> {
101    let s = |props: Value, req: &[&str]| json!({"type": "object", "properties": props, "required": req});
102    vec![
103        ("list_dir", "List a directory's entries (directories end with /).", s(json!({"path": {"type": "string", "description": "Directory, relative to the project (default: the project)"}}), &[])),
104        ("read_file", "Read a text file, optionally a range of lines.", s(json!({
105            "path": {"type": "string"},
106            "offset": {"type": "integer", "description": "First line to read, from 1"},
107            "limit": {"type": "integer", "description": "How many lines to read"}
108        }), &["path"])),
109        ("search", "Find lines containing a string in the files under a directory.", s(json!({
110            "pattern": {"type": "string", "description": "Text to look for (case-sensitive)"},
111            "path": {"type": "string", "description": "Directory or file to search (default: the project)"}
112        }), &["pattern"])),
113        ("write_file", "Create or overwrite a file inside the project.", s(json!({"path": {"type": "string"}, "content": {"type": "string"}}), &["path", "content"])),
114        ("edit_file", "Replace one exact, unique occurrence of a string in a file inside the project.", s(json!({
115            "path": {"type": "string"}, "old": {"type": "string"}, "new": {"type": "string"}
116        }), &["path", "old", "new"])),
117        ("run", "Run a shell command in the project directory and return its exit code and output.", s(json!({"command": {"type": "string"}}), &["command"])),
118    ]
119}
120
121fn system_prompt(cfg: &AgentConfig) -> String {
122    format!(
123        "You are the built-in agent of hlock (harness-lock), working in the project at {}. \
124         You act through tools: read and search files, write and edit files inside the project, and run shell commands. \
125         Work step by step, check what you did, and keep going until the job is done. \
126         {} When you're finished, reply with a short summary of what you did and what's left, and call no tools.",
127        cfg.project.display(),
128        if cfg.interactive {
129            "Commands that aren't on the allowlist are shown to the user, who approves or refuses each one."
130        } else {
131            "This run is unattended: only allowlisted commands run, and others are refused; when you need one, say which and why in your summary."
132        }
133    )
134}
135
136// --------------------------------------------------------------------------- //
137// Providers
138// --------------------------------------------------------------------------- //
139
140struct Reply {
141    text: String,
142    calls: Vec<Call>,
143    input_tokens: u64,
144    output_tokens: u64,
145}
146
147fn http() -> ureq::Agent {
148    ureq::Agent::config_builder()
149        .http_status_as_error(false)
150        .timeout_global(Some(Duration::from_secs(600)))
151        .proxy(ureq::Proxy::try_from_env())
152        .build()
153        .into()
154}
155
156fn post(cfg: &AgentConfig, path: &str, headers: &[(&str, String)], body: &Value) -> Result<Value> {
157    let url = format!("{}/{path}", cfg.base_url.trim_end_matches('/'));
158    let mut req = http().post(&url).header("content-type", "application/json");
159    for (k, v) in headers {
160        req = req.header(*k, v);
161    }
162    let mut resp = req.send(body.to_string()).map_err(|e| Error::Profile(format!("cannot reach {url}: {e}")))?;
163    let status = resp.status().as_u16();
164    let text = resp.body_mut().read_to_string().map_err(|e| Error::Profile(format!("bad response from {url}: {e}")))?;
165    if !(200..300).contains(&status) {
166        let detail: String = serde_json::from_str::<Value>(&text)
167            .ok()
168            .and_then(|v| v.pointer("/error/message").and_then(Value::as_str).map(str::to_string))
169            .unwrap_or_else(|| text.chars().take(300).collect());
170        return Err(Error::Profile(format!("{url} returned {status}: {detail}")));
171    }
172    serde_json::from_str(&text).map_err(|e| Error::Profile(format!("{url} returned invalid JSON: {e}")))
173}
174
175fn ask_anthropic(cfg: &AgentConfig, history: &[Msg]) -> Result<Reply> {
176    let mut messages = Vec::new();
177    for m in history {
178        messages.push(match m {
179            Msg::User(t) => json!({"role": "user", "content": t}),
180            Msg::Assistant { text, calls } => {
181                let mut blocks = Vec::new();
182                if !text.is_empty() {
183                    blocks.push(json!({"type": "text", "text": text}));
184                }
185                blocks.extend(calls.iter().map(|c| json!({"type": "tool_use", "id": c.id, "name": c.name, "input": c.args})));
186                json!({"role": "assistant", "content": blocks})
187            }
188            Msg::Results(rs) => json!({"role": "user", "content": rs.iter().map(|(id, out)| json!({"type": "tool_result", "tool_use_id": id, "content": out})).collect::<Vec<_>>()}),
189        });
190    }
191    let body = json!({
192        "model": cfg.model,
193        "max_tokens": 8192,
194        "system": system_prompt(cfg),
195        "tools": tools().into_iter().map(|(n, d, s)| json!({"name": n, "description": d, "input_schema": s})).collect::<Vec<_>>(),
196        "messages": messages,
197    });
198    let mut headers = vec![("anthropic-version", "2023-06-01".to_string())];
199    if let Some(k) = &cfg.api_key {
200        headers.push(("x-api-key", k.clone()));
201    }
202    let v = post(cfg, "messages", &headers, &body)?;
203    let mut text = String::new();
204    let mut calls = Vec::new();
205    for b in v["content"].as_array().into_iter().flatten() {
206        match b["type"].as_str() {
207            Some("text") => text.push_str(b["text"].as_str().unwrap_or_default()),
208            Some("tool_use") => calls.push(Call { id: b["id"].as_str().unwrap_or_default().into(), name: b["name"].as_str().unwrap_or_default().into(), args: b["input"].clone() }),
209            _ => {}
210        }
211    }
212    Ok(Reply { text, calls, input_tokens: v["usage"]["input_tokens"].as_u64().unwrap_or(0), output_tokens: v["usage"]["output_tokens"].as_u64().unwrap_or(0) })
213}
214
215fn ask_openai(cfg: &AgentConfig, history: &[Msg]) -> Result<Reply> {
216    let mut messages = vec![json!({"role": "system", "content": system_prompt(cfg)})];
217    for m in history {
218        match m {
219            Msg::User(t) => messages.push(json!({"role": "user", "content": t})),
220            Msg::Assistant { text, calls } => {
221                let mut msg = json!({"role": "assistant", "content": text});
222                if !calls.is_empty() {
223                    msg["tool_calls"] = calls.iter().map(|c| json!({"id": c.id, "type": "function", "function": {"name": c.name, "arguments": c.args.to_string()}})).collect();
224                }
225                messages.push(msg);
226            }
227            Msg::Results(rs) => messages.extend(rs.iter().map(|(id, out)| json!({"role": "tool", "tool_call_id": id, "content": out}))),
228        }
229    }
230    let body = json!({
231        "model": cfg.model,
232        "messages": messages,
233        "tools": tools().into_iter().map(|(n, d, s)| json!({"type": "function", "function": {"name": n, "description": d, "parameters": s}})).collect::<Vec<_>>(),
234    });
235    let headers: Vec<(&str, String)> = cfg.api_key.iter().map(|k| ("authorization", format!("Bearer {k}"))).collect();
236    let v = post(cfg, "chat/completions", &headers, &body)?;
237    let msg = &v["choices"][0]["message"];
238    let calls = msg["tool_calls"]
239        .as_array()
240        .into_iter()
241        .flatten()
242        .enumerate()
243        .map(|(i, c)| {
244            let raw = &c["function"]["arguments"];
245            let args = match raw {
246                Value::String(s) => serde_json::from_str(s).unwrap_or_else(|_| json!({"_unparsed": s})),
247                other => other.clone(),
248            };
249            Call { id: c["id"].as_str().map(str::to_string).unwrap_or_else(|| format!("call_{i}")), name: c["function"]["name"].as_str().unwrap_or_default().into(), args }
250        })
251        .collect();
252    let mut text = msg["content"].as_str().unwrap_or_default().to_string();
253    let mut calls: Vec<Call> = calls;
254    if calls.is_empty() {
255        if let Some(c) = text_call(&text) {
256            calls.push(c);
257            text.clear();
258        }
259    }
260    Ok(Reply {
261        text,
262        calls,
263        input_tokens: v["usage"]["prompt_tokens"].as_u64().unwrap_or(0),
264        output_tokens: v["usage"]["completion_tokens"].as_u64().unwrap_or(0),
265    })
266}
267
268/// A tool call a model wrote as text instead of a structured call, as small local models
269/// often do: `{"name": "write_file", "parameters": {...}}`, optionally in a code fence.
270fn text_call(text: &str) -> Option<Call> {
271    let t = text.trim().trim_start_matches("```json").trim_start_matches("```").trim_end_matches("```").trim();
272    let v: Value = serde_json::from_str(t).ok()?;
273    let name = v.get("name")?.as_str()?;
274    if !tools().iter().any(|(n, _, _)| *n == name) {
275        return None;
276    }
277    let args = v.get("parameters").or_else(|| v.get("arguments")).cloned().unwrap_or_else(|| json!({}));
278    let args = match args {
279        Value::String(s) => serde_json::from_str(&s).ok()?,
280        other => other,
281    };
282    Some(Call { id: "text_call".into(), name: name.into(), args })
283}
284
285// --------------------------------------------------------------------------- //
286// Tools
287// --------------------------------------------------------------------------- //
288
289fn resolve(cfg: &AgentConfig, p: &str) -> PathBuf {
290    let p = p.trim();
291    if p.is_empty() || p == "." {
292        return cfg.project.clone();
293    }
294    if let Some(rest) = p.strip_prefix("~/") {
295        if let Some(home) = std::env::var_os("HOME") {
296            return PathBuf::from(home).join(rest);
297        }
298    }
299    let path = PathBuf::from(p);
300    if path.is_absolute() {
301        path
302    } else {
303        cfg.project.join(path)
304    }
305}
306
307/// Lexically normalized, so `project/../x` isn't mistaken for a path inside the project.
308fn normalize(p: &Path) -> PathBuf {
309    let mut out = PathBuf::new();
310    for c in p.components() {
311        match c {
312            std::path::Component::ParentDir => {
313                out.pop();
314            }
315            std::path::Component::CurDir => {}
316            other => out.push(other),
317        }
318    }
319    out
320}
321
322fn inside_project(cfg: &AgentConfig, p: &Path) -> bool {
323    normalize(p).starts_with(normalize(&cfg.project))
324}
325
326fn truncate(s: String) -> String {
327    if s.len() <= MAX_RESULT {
328        return s;
329    }
330    let mut cut = MAX_RESULT;
331    while !s.is_char_boundary(cut) {
332        cut -= 1;
333    }
334    format!("{}\n… ({} more bytes not shown)", &s[..cut], s.len() - cut)
335}
336
337const SKIP_DIRS: &[&str] = &[".git", "node_modules", "target", ".venv", "__pycache__", ".hlock"];
338
339fn search(dir: &Path, pattern: &str, out: &mut Vec<String>, base: &Path) {
340    if out.len() >= 200 {
341        return;
342    }
343    if dir.is_file() {
344        if let Ok(text) = std::fs::read_to_string(dir) {
345            for (i, line) in text.lines().enumerate().filter(|(_, l)| l.contains(pattern)) {
346                let rel = dir.strip_prefix(base).unwrap_or(dir).display();
347                out.push(format!("{rel}:{}: {}", i + 1, line.trim_end().chars().take(200).collect::<String>()));
348                if out.len() >= 200 {
349                    return;
350                }
351            }
352        }
353        return;
354    }
355    let Ok(rd) = std::fs::read_dir(dir) else { return };
356    let mut entries: Vec<_> = rd.flatten().collect();
357    entries.sort_by_key(|e| e.file_name());
358    for e in entries {
359        let name = e.file_name().to_string_lossy().into_owned();
360        if SKIP_DIRS.contains(&name.as_str()) {
361            continue;
362        }
363        search(&e.path(), pattern, out, base);
364    }
365}
366
367/// Split a command into words, honoring simple quotes; `None` when it uses shell syntax
368/// (pipes, redirects, substitution, chaining), which an allowlist can't vouch for.
369pub fn command_words(cmd: &str) -> Option<Vec<String>> {
370    if cmd.chars().any(|c| matches!(c, ';' | '|' | '&' | '>' | '<' | '$' | '`' | '\n' | '(' | ')')) {
371        return None;
372    }
373    let mut words = Vec::new();
374    let mut cur = String::new();
375    let mut quote: Option<char> = None;
376    let mut any = false;
377    for c in cmd.chars() {
378        match (quote, c) {
379            (Some(q), c) if c == q => quote = None,
380            (Some(_), c) => cur.push(c),
381            (None, '\'' | '"') => {
382                quote = Some(c);
383                any = true;
384            }
385            (None, c) if c.is_whitespace() => {
386                if !cur.is_empty() || any {
387                    words.push(std::mem::take(&mut cur));
388                    any = false;
389                }
390            }
391            (None, c) => cur.push(c),
392        }
393    }
394    if quote.is_some() {
395        return None;
396    }
397    if !cur.is_empty() || any {
398        words.push(cur);
399    }
400    Some(words)
401}
402
403/// Whether `cmd` is on the allowlist: plain words (no shell syntax) starting with an
404/// allowed prefix's words. A program may be named by path (`/usr/bin/git log`).
405pub fn allowed(cmd: &str, allow: &[String]) -> bool {
406    let Some(words) = command_words(cmd) else { return false };
407    let Some(first) = words.first() else { return false };
408    let prog = Path::new(first).file_name().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default();
409    allow.iter().any(|a| {
410        let want: Vec<&str> = a.split_whitespace().collect();
411        !want.is_empty() && want.len() <= words.len() && want[0] == prog && want[1..].iter().zip(&words[1..]).all(|(w, x)| w == x)
412    })
413}
414
415/// The prefix "always" approves: the program, plus its subcommand when it has one.
416fn always_prefix(cmd: &str) -> Option<String> {
417    let words = command_words(cmd)?;
418    let prog = Path::new(words.first()?).file_name()?.to_string_lossy().into_owned();
419    Some(match words.get(1) {
420        Some(sub) if !sub.starts_with('-') && !sub.contains('/') && !sub.contains('.') => format!("{prog} {sub}"),
421        _ => prog,
422    })
423}
424
425fn run_command(cfg: &AgentConfig, cmd: &str) -> String {
426    let mut c = if cfg!(windows) {
427        let mut c = Command::new("cmd");
428        c.args(["/C", cmd]);
429        c
430    } else {
431        let mut c = Command::new("sh");
432        c.args(["-c", cmd]);
433        c
434    };
435    c.current_dir(&cfg.project).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
436    let mut child = match c.spawn() {
437        Ok(ch) => ch,
438        Err(e) => return format!("cannot run it: {e}"),
439    };
440    let (mut so, mut se) = (child.stdout.take().unwrap(), child.stderr.take().unwrap());
441    let t_out = std::thread::spawn(move || {
442        let mut b = Vec::new();
443        let _ = so.read_to_end(&mut b);
444        b
445    });
446    let t_err = std::thread::spawn(move || {
447        let mut b = Vec::new();
448        let _ = se.read_to_end(&mut b);
449        b
450    });
451    let start = Instant::now();
452    let limit = Duration::from_secs(cfg.timeout_seconds.max(1));
453    let status = loop {
454        match child.try_wait() {
455            Ok(Some(s)) => break Some(s),
456            Ok(None) if start.elapsed() > limit => {
457                let _ = child.kill();
458                let _ = child.wait();
459                break None;
460            }
461            Ok(None) => std::thread::sleep(Duration::from_millis(20)),
462            Err(_) => break None,
463        }
464    };
465    let out = String::from_utf8_lossy(&t_out.join().unwrap_or_default()).into_owned();
466    let err = String::from_utf8_lossy(&t_err.join().unwrap_or_default()).into_owned();
467    let head = match status {
468        Some(s) => format!("exit code {}", s.code().unwrap_or(-1)),
469        None => format!("timed out after {}s and was stopped", cfg.timeout_seconds),
470    };
471    truncate(format!("{head}\n{out}{}{err}", if err.is_empty() || out.ends_with('\n') || out.is_empty() { "" } else { "\n" }))
472}
473
474fn arg<'a>(args: &'a Value, key: &str) -> Option<&'a str> {
475    args.get(key).and_then(Value::as_str)
476}
477
478/// Run one tool call. Returns the result for the model and a one-line summary.
479fn call_tool(cfg: &AgentConfig, allow: &mut Vec<String>, call: &Call, say: &mut Say, ask: &mut Ask) -> (String, String) {
480    let a = &call.args;
481    let need = |k: &str| arg(a, k).ok_or_else(|| format!("missing `{k}`"));
482    let result: std::result::Result<(String, String), String> = (|| match call.name.as_str() {
483        "list_dir" => {
484            let dir = resolve(cfg, arg(a, "path").unwrap_or("."));
485            let mut names: Vec<String> = std::fs::read_dir(&dir)
486                .map_err(|e| format!("cannot list {}: {e}", dir.display()))?
487                .flatten()
488                .map(|e| format!("{}{}", e.file_name().to_string_lossy(), if e.path().is_dir() { "/" } else { "" }))
489                .collect();
490            names.sort();
491            let n = names.len();
492            names.truncate(500);
493            Ok((names.join("\n"), format!("{n} entries")))
494        }
495        "read_file" => {
496            let path = resolve(cfg, need("path")?);
497            let text = std::fs::read_to_string(&path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
498            let offset = a.get("offset").and_then(Value::as_u64).unwrap_or(1).max(1) as usize;
499            // models often send 0 for "no limit"
500            let limit = a.get("limit").and_then(Value::as_u64).filter(|n| *n > 0).unwrap_or(2000) as usize;
501            let lines: Vec<&str> = text.lines().skip(offset - 1).take(limit).collect();
502            Ok((truncate(lines.join("\n")), format!("{} lines", lines.len())))
503        }
504        "search" => {
505            let pattern = need("pattern")?;
506            let base = resolve(cfg, arg(a, "path").unwrap_or("."));
507            let mut hits = Vec::new();
508            search(&base, pattern, &mut hits, &cfg.project);
509            let n = hits.len();
510            Ok((if hits.is_empty() { "no matches".into() } else { hits.join("\n") }, format!("{n} matches")))
511        }
512        "write_file" | "edit_file" => {
513            let path = resolve(cfg, need("path")?);
514            if !inside_project(cfg, &path) {
515                return Err(format!("{} is outside the project; write files there with `run`, which the user approves", path.display()));
516            }
517            let body = if call.name == "write_file" {
518                need("content")?.to_string()
519            } else {
520                let (old, new) = (need("old")?, need("new")?);
521                let text = std::fs::read_to_string(&path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
522                match text.matches(old).count() {
523                    1 => text.replacen(old, new, 1),
524                    0 => return Err("`old` doesn't appear in the file".into()),
525                    n => return Err(format!("`old` appears {n} times; include more context so it's unique")),
526                }
527            };
528            if let Some(parent) = path.parent() {
529                std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
530            }
531            std::fs::write(&path, &body).map_err(|e| format!("cannot write {}: {e}", path.display()))?;
532            let rel = path.strip_prefix(&cfg.project).unwrap_or(&path).display().to_string();
533            Ok((format!("wrote {rel} ({} bytes)", body.len()), format!("wrote {rel}")))
534        }
535        "run" => {
536            let cmd = need("command")?.trim().to_string();
537            if !allowed(&cmd, allow) {
538                if !cfg.interactive {
539                    say("denied", &cmd);
540                    return Err(format!(
541                        "refused: `{cmd}` isn't on the allowlist ({}), and this run is unattended. Do without it, or say in your summary that the user should run it",
542                        allow.join(", ")
543                    ));
544                }
545                match ask(&cmd).trim().to_ascii_lowercase().as_str() {
546                    "y" | "yes" => {}
547                    "a" | "always" => {
548                        if let Some(p) = always_prefix(&cmd) {
549                            allow.push(p);
550                        }
551                    }
552                    _ => {
553                        say("denied", &cmd);
554                        return Err(format!("the user refused `{cmd}`; don't run it again, and ask what they'd prefer"));
555                    }
556                }
557            }
558            let out = run_command(cfg, &cmd);
559            let first = out.lines().next().unwrap_or_default().to_string();
560            Ok((out, first))
561        }
562        other => Err(format!("unknown tool {other:?}")),
563    })();
564    match result {
565        Ok((full, summary)) => (full, summary),
566        Err(e) => (format!("error: {e}"), format!("error: {e}")),
567    }
568}
569
570fn describe(call: &Call) -> String {
571    let a = &call.args;
572    match call.name.as_str() {
573        "run" => format!("$ {}", arg(a, "command").unwrap_or_default()),
574        "search" => format!("search {:?} in {}", arg(a, "pattern").unwrap_or_default(), arg(a, "path").unwrap_or(".")),
575        "list_dir" => format!("list {}", arg(a, "path").unwrap_or(".")),
576        name => format!("{} {}", name.replace('_', " "), arg(a, "path").unwrap_or_default()),
577    }
578}
579
580// --------------------------------------------------------------------------- //
581// The loop
582// --------------------------------------------------------------------------- //
583
584/// Run the agent on `prompt` until the model stops calling tools, or `max_steps`.
585pub fn run(cfg: &AgentConfig, prompt: &str, say: &mut Say, ask: &mut Ask) -> Outcome {
586    let mut history = vec![Msg::User(prompt.to_string())];
587    let mut allow = cfg.allow.clone();
588    let mut out = Outcome { status: "max-steps".into(), ..Default::default() };
589    while out.steps < cfg.max_steps.max(1) {
590        out.steps += 1;
591        let reply = if cfg.provider == "anthropic" { ask_anthropic(cfg, &history) } else { ask_openai(cfg, &history) };
592        let reply = match reply {
593            Ok(r) => r,
594            Err(e) => {
595                let msg = e.to_string();
596                say("error", &msg);
597                out.status = "error".into();
598                out.error = Some(msg);
599                return out;
600            }
601        };
602        out.input_tokens += reply.input_tokens;
603        out.output_tokens += reply.output_tokens;
604        if !reply.text.trim().is_empty() && !say("text", reply.text.trim()) {
605            out.status = "stopped".into();
606            return out;
607        }
608        if reply.calls.is_empty() {
609            out.status = "done".into();
610            out.summary = reply.text.trim().to_string();
611            return out;
612        }
613        let mut results = Vec::new();
614        for c in &reply.calls {
615            if !say("tool", &describe(c)) {
616                out.status = "stopped".into();
617                return out;
618            }
619            let (full, summary) = call_tool(cfg, &mut allow, c, say, ask);
620            say("result", &summary);
621            results.push((c.id.clone(), full));
622        }
623        history.push(Msg::Assistant { text: reply.text, calls: reply.calls });
624        history.push(Msg::Results(results));
625    }
626    say("error", &format!("stopped after {} steps", out.steps));
627    out
628}
629
630/// Build the config for a lock's built-in agent from its `[[llm]]` and `[policy]`.
631pub fn config_from_lock(lock: &crate::lockfile::Lockfile, project: &Path, interactive: bool, env: &BTreeMap<String, String>) -> Result<AgentConfig> {
632    let llm = lock.llm.iter().find(|l| l.role == "default").or_else(|| lock.llm.first()).ok_or_else(|| {
633        Error::Lockfile("the meta lock has no model; set [model] in harness.meta.toml and run `hlock meta pin`".into())
634    })?;
635    let base_url = llm
636        .endpoint
637        .clone()
638        .or_else(|| provider_base_url(&llm.provider).map(str::to_string))
639        .ok_or_else(|| Error::Lockfile(format!("no endpoint for provider {:?}; set [model] base-url", llm.provider)))?;
640    let key_env = llm.params.get("key-env").and_then(Value::as_str).map(str::to_string).or_else(|| provider_key_env(&llm.provider).map(str::to_string));
641    let api_key = key_env.as_ref().and_then(|k| env.get(k)).filter(|v| !v.is_empty()).cloned();
642    if llm.runtime.is_none() && api_key.is_none() {
643        return Err(Error::Profile(format!("{} isn't set; the built-in agent reads its {} API key from it", key_env.unwrap_or_default(), llm.provider)));
644    }
645    Ok(AgentConfig {
646        provider: llm.provider.clone(),
647        model: llm.model.clone(),
648        base_url,
649        api_key,
650        max_steps: if lock.policy.max_steps > 0 { lock.policy.max_steps } else { DEFAULT_MAX_STEPS },
651        timeout_seconds: lock.policy.tool_timeout_seconds.unwrap_or(DEFAULT_TIMEOUT_SECONDS),
652        allow: if lock.policy.tool_allowlist.is_empty() { DEFAULT_ALLOW.iter().map(|s| s.to_string()).collect() } else { lock.policy.tool_allowlist.clone() },
653        interactive,
654        project: project.to_path_buf(),
655    })
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661
662    fn allow(xs: &[&str]) -> Vec<String> {
663        xs.iter().map(|s| s.to_string()).collect()
664    }
665
666    #[test]
667    fn allowlist_matching() {
668        let a = allow(DEFAULT_ALLOW);
669        assert!(allowed("hlock meta verify install", &a));
670        assert!(allowed("git log -5 --oneline", &a));
671        assert!(allowed("/usr/bin/git show HEAD", &a));
672        assert!(!allowed("git push", &a));
673        assert!(!allowed("git log; rm -rf /", &a));
674        assert!(!allowed("hlock sync > out.txt", &a));
675        assert!(!allowed("git log $(whoami)", &a));
676        assert!(!allowed("rm -rf tasks", &a));
677        assert!(allowed("brew install pi", &allow(&["brew install"])));
678        assert!(!allowed("brew uninstall pi", &allow(&["brew install"])));
679        assert_eq!(command_words("git commit -m 'a b'").unwrap(), ["git", "commit", "-m", "a b"]);
680        assert_eq!(always_prefix("npm install -g x").as_deref(), Some("npm install"));
681        assert_eq!(always_prefix("ls -la").as_deref(), Some("ls"));
682    }
683
684    #[test]
685    fn tool_calls_written_as_text() {
686        let c = text_call("```json\n{\"name\": \"write_file\", \"parameters\": {\"path\": \"a.md\", \"content\": \"x\"}}\n```").unwrap();
687        assert_eq!((c.name.as_str(), c.args["path"].as_str()), ("write_file", Some("a.md")));
688        assert!(text_call("{\"name\": \"touch\", \"parameters\": {}}").is_none());
689        assert!(text_call("All done: wrote the notes.").is_none());
690    }
691
692    #[test]
693    fn writes_stay_in_the_project() {
694        let cfg = AgentConfig { project: PathBuf::from("/p/proj"), ..Default::default() };
695        assert!(inside_project(&cfg, &resolve(&cfg, "evals/pack.toml")));
696        assert!(!inside_project(&cfg, &resolve(&cfg, "../other/x")));
697        assert!(!inside_project(&cfg, &resolve(&cfg, "/etc/hosts")));
698    }
699}