Skip to main content

heartbit_core/codegen/
builder.rs

1//! [`CodeAgentBuilder`] — a domain-specific harness for code generation, mirroring
2//! [`BrowserAgentBuilder`](crate::browser::BrowserAgentBuilder). It curates the
3//! code builtins rooted at a workspace, adds the [`VerifyCommandTool`], installs a
4//! loop-invariant system prompt, and (optionally) gates completion on a
5//! [`GoalCondition`] whose independent judge keys on the deterministic
6//! `VERIFY_RESULT:` sentinel. Everything else (the ReAct loop, the goal-driven
7//! repair continuation, workspace jailing, guardrails) is reused, not rebuilt.
8
9use std::path::PathBuf;
10use std::sync::Arc;
11
12use crate::agent::events::OnEvent;
13use crate::agent::goal::GoalCondition;
14use crate::agent::guardrail::Guardrail;
15use crate::agent::{AgentRunner, AgentRunnerBuilder};
16use crate::error::Error;
17use crate::llm::{BoxedProvider, LlmProvider};
18use crate::tool::Tool;
19use crate::tool::builtins::{BuiltinToolsConfig, builtin_tools};
20
21use super::verify::VerifyCommandTool;
22
23/// Default ReAct turn budget for a code agent. Generous because
24/// edit→verify→read-failures→re-edit→re-verify burns turns quickly.
25const DEFAULT_CODE_MAX_TURNS: usize = 40;
26
27/// Code builtins this harness curates (the editing + searching surface), plus the
28/// `verify` tool which is appended separately. `bash` is included only when
29/// `dangerous_tools` is set.
30const CODE_BUILTIN_NAMES: &[&str] = &[
31    "read",
32    "write",
33    "edit",
34    "patch",
35    "grep",
36    "glob",
37    "list",
38    "bash",
39    "todoread",
40    "todowrite",
41    "skill",
42];
43
44/// The loop-invariant system prompt for the code harness (the code analogue of
45/// `BROWSER_SYSTEM_PROMPT`).
46pub const CODE_SYSTEM_PROMPT: &str = "You are a senior software engineer working inside a fixed workspace. Your job is to \
47implement the requested change and PROVE it works by running the project's verification \
48command. Follow this loop:\n\
49\n\
501. UNDERSTAND: before editing, read the relevant files (read/grep/glob/list). Never edit \
51code you have not looked at. Build a concrete picture of the current state.\n\
522. PLAN: keep an explicit, ordered list of subgoals (use the todo tools). Work one step \
53at a time.\n\
543. EDIT: make the smallest change that achieves the goal. Prefer `edit`/`patch` over \
55rewriting whole files. Read a file before you write to it.\n\
564. VERIFY: after a coherent change, call the `verify` tool. It runs the project's \
57configured build/test command and reports `VERIFY_RESULT: PASS` or `VERIFY_RESULT: FAIL` \
58with the exit code. This — not your own judgement — is the source of truth for whether \
59the code works.\n\
605. REPAIR: if `verify` reports FAIL, read the captured output, identify the specific \
61cause, fix it, and run `verify` again. Do not repeat the same failing change; if a \
62command keeps failing identically, change your approach.\n\
636. FINISH: you are done ONLY immediately after `verify` reports `VERIFY_RESULT: PASS`. \
64Then summarise what you changed. Never claim success without a passing `verify`.\n\
65\n\
66EFFICIENCY: keep edits and tool calls minimal; do not re-read unchanged files.\n\
67SAFETY: stay within the workspace. The `verify` command is fixed by the harness — you \
68cannot choose what \"passing\" means, only make the code pass it.";
69
70/// The build goal's objective — keyed on the deterministic verify sentinel.
71const CODE_GOAL_OBJECTIVE: &str = "The requested code change is complete and proven to work. The objective is met ONLY \
72     when the most recent `verify` tool result in the transcript shows `VERIFY_RESULT: PASS` \
73     (exit code 0) — i.e. the project's configured verification command actually passed. A \
74     claim of completion WITHOUT a `VERIFY_RESULT: PASS` line from the verify tool, or \
75     superseded by a later `VERIFY_RESULT: FAIL`, does NOT satisfy the objective.";
76
77/// Curated code builtins rooted at `workspace`, plus the `verify` tool bound to
78/// `verify_commands`. When `dangerous` is false, `bash` is omitted.
79pub fn code_tools(
80    workspace: impl Into<PathBuf>,
81    dangerous: bool,
82    verify_commands: Vec<String>,
83) -> Vec<Arc<dyn Tool>> {
84    let ws: PathBuf = workspace.into();
85    let raw = builtin_tools(BuiltinToolsConfig {
86        workspace: Some(ws.clone()),
87        dangerous_tools: dangerous,
88        ..Default::default()
89    });
90    let mut tools: Vec<Arc<dyn Tool>> = raw
91        .into_iter()
92        .filter(|t| CODE_BUILTIN_NAMES.contains(&t.definition().name.as_str()))
93        .collect();
94    tools.push(Arc::new(VerifyCommandTool::new(ws, verify_commands)));
95    tools
96}
97
98/// A [`GoalCondition`] whose independent judge gates completion on the
99/// deterministic `VERIFY_RESULT: PASS` evidence produced by the `verify` tool.
100pub fn code_goal(judge: Arc<BoxedProvider>) -> GoalCondition {
101    GoalCondition::new(CODE_GOAL_OBJECTIVE, judge).with_max_continuations(4)
102}
103
104/// Fluent builder assembling a code-generation [`AgentRunner`] (mirror of
105/// [`BrowserAgentBuilder`](crate::browser::BrowserAgentBuilder)).
106pub struct CodeAgentBuilder<P: LlmProvider> {
107    provider: Arc<P>,
108    workspace: PathBuf,
109    verify_commands: Vec<String>,
110    system_prompt: Option<String>,
111    name: Option<String>,
112    max_turns: Option<usize>,
113    max_identical_tool_calls: Option<u32>,
114    on_event: Option<Arc<OnEvent>>,
115    extra_guardrails: Vec<Arc<dyn Guardrail>>,
116    tool_allow: Vec<String>,
117    goal: Option<GoalCondition>,
118    dangerous_tools: bool,
119}
120
121impl<P: LlmProvider> CodeAgentBuilder<P> {
122    /// Start a code harness rooted at `workspace` (file tools are jailed there).
123    pub fn new(provider: Arc<P>, workspace: impl Into<PathBuf>) -> Self {
124        Self {
125            provider,
126            workspace: workspace.into(),
127            verify_commands: Vec::new(),
128            system_prompt: None,
129            name: None,
130            max_turns: None,
131            max_identical_tool_calls: None,
132            on_event: None,
133            extra_guardrails: Vec::new(),
134            tool_allow: Vec::new(),
135            goal: None,
136            dangerous_tools: true,
137        }
138    }
139
140    /// Append one verification command (run in order, fail-fast).
141    pub fn verify_command(mut self, cmd: impl Into<String>) -> Self {
142        self.verify_commands.push(cmd.into());
143        self
144    }
145
146    /// Set the verification command list (replaces any existing).
147    pub fn verify_commands(mut self, cmds: impl IntoIterator<Item = impl Into<String>>) -> Self {
148        self.verify_commands = cmds.into_iter().map(Into::into).collect();
149        self
150    }
151
152    /// Override the loop-invariant system prompt.
153    pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
154        self.system_prompt = Some(prompt.into());
155        self
156    }
157
158    /// Set the agent's name (for events / observability).
159    pub fn name(mut self, name: impl Into<String>) -> Self {
160        self.name = Some(name.into());
161        self
162    }
163
164    /// Override the ReAct turn budget (default 40).
165    pub fn max_turns(mut self, max_turns: usize) -> Self {
166        self.max_turns = Some(max_turns);
167        self
168    }
169
170    /// Doom-loop cure: abort after `n` identical tool calls in a row.
171    pub fn max_identical_tool_calls(mut self, n: u32) -> Self {
172        self.max_identical_tool_calls = Some(n);
173        self
174    }
175
176    /// Subscribe to agent events.
177    pub fn on_event(mut self, callback: Arc<OnEvent>) -> Self {
178        self.on_event = Some(callback);
179        self
180    }
181
182    /// Add an extra guardrail (first-Deny-wins, composed with any others).
183    pub fn guardrail(mut self, guard: Arc<dyn Guardrail>) -> Self {
184        self.extra_guardrails.push(guard);
185        self
186    }
187
188    /// Restrict the curated builtins to this allowlist (the `verify` tool is
189    /// always kept). Empty = keep all curated tools.
190    pub fn tools_allow(mut self, names: impl IntoIterator<Item = impl Into<String>>) -> Self {
191        self.tool_allow = names.into_iter().map(Into::into).collect();
192        self
193    }
194
195    /// Gate completion on a goal (typically [`code_goal`]).
196    pub fn goal(mut self, goal: GoalCondition) -> Self {
197        self.goal = Some(goal);
198        self
199    }
200
201    /// Include/exclude the `bash` tool (default: included).
202    pub fn dangerous_tools(mut self, enabled: bool) -> Self {
203        self.dangerous_tools = enabled;
204        self
205    }
206
207    /// Assemble the [`AgentRunner`].
208    pub fn build(self) -> Result<AgentRunner<P>, Error> {
209        let mut tools = code_tools(
210            self.workspace.clone(),
211            self.dangerous_tools,
212            self.verify_commands.clone(),
213        );
214        if !self.tool_allow.is_empty() {
215            tools.retain(|t| {
216                let n = t.definition().name;
217                // `verify` is the harness's reason to exist — never filter it out.
218                n == "verify" || self.tool_allow.iter().any(|a| a == &n)
219            });
220        }
221
222        let prompt = self
223            .system_prompt
224            .unwrap_or_else(|| CODE_SYSTEM_PROMPT.to_string());
225
226        let mut b: AgentRunnerBuilder<P> = AgentRunner::builder(self.provider)
227            .tools(tools)
228            .system_prompt(prompt)
229            .workspace(self.workspace)
230            .max_turns(self.max_turns.unwrap_or(DEFAULT_CODE_MAX_TURNS));
231        if let Some(name) = self.name {
232            b = b.name(name);
233        }
234        if let Some(n) = self.max_identical_tool_calls {
235            b = b.max_identical_tool_calls(n);
236        }
237        if let Some(ev) = self.on_event {
238            b = b.on_event(ev);
239        }
240        if !self.extra_guardrails.is_empty() {
241            b = b.guardrails(self.extra_guardrails);
242        }
243        if let Some(goal) = self.goal {
244            b = b.goal(goal);
245        }
246        b.build()
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use crate::agent::test_helpers::MockProvider;
254
255    #[test]
256    fn system_prompt_encodes_loop_invariants() {
257        for needle in [
258            "UNDERSTAND",
259            "PLAN",
260            "EDIT",
261            "VERIFY",
262            "REPAIR",
263            "FINISH",
264            "verify",
265            "VERIFY_RESULT",
266        ] {
267            assert!(
268                CODE_SYSTEM_PROMPT.contains(needle),
269                "system prompt missing loop invariant `{needle}`"
270            );
271        }
272    }
273
274    #[test]
275    fn goal_objective_keys_on_verify_sentinel() {
276        let judge = Arc::new(BoxedProvider::new(MockProvider::new(vec![])));
277        let goal = code_goal(judge);
278        assert!(
279            goal.objective().contains("VERIFY_RESULT: PASS"),
280            "goal must gate on the deterministic verify sentinel, got: {}",
281            goal.objective()
282        );
283    }
284
285    #[test]
286    fn code_tools_include_verify_and_editing_surface() {
287        let dir = tempfile::tempdir().unwrap();
288        let tools = code_tools(dir.path(), true, vec!["true".into()]);
289        let names: Vec<String> = tools.iter().map(|t| t.definition().name).collect();
290        for expected in [
291            "verify", "read", "write", "edit", "patch", "grep", "glob", "list", "bash",
292        ] {
293            assert!(
294                names.iter().any(|n| n == expected),
295                "code_tools missing `{expected}`; got {names:?}"
296            );
297        }
298    }
299
300    #[test]
301    fn code_tools_omit_bash_when_not_dangerous() {
302        let dir = tempfile::tempdir().unwrap();
303        let tools = code_tools(dir.path(), false, vec!["true".into()]);
304        let names: Vec<String> = tools.iter().map(|t| t.definition().name).collect();
305        assert!(
306            !names.iter().any(|n| n == "bash"),
307            "bash must be gated: {names:?}"
308        );
309        // verify and the editing surface remain.
310        assert!(names.iter().any(|n| n == "verify"));
311        assert!(names.iter().any(|n| n == "edit"));
312    }
313
314    #[test]
315    fn build_wires_a_runner() {
316        let dir = tempfile::tempdir().unwrap();
317        let provider = Arc::new(MockProvider::new(vec![]));
318        let runner = CodeAgentBuilder::new(provider, dir.path())
319            .name("coder")
320            .verify_command("cargo test")
321            .build();
322        assert!(
323            runner.is_ok(),
324            "build should wire a runner: {:?}",
325            runner.err()
326        );
327    }
328
329    #[test]
330    fn build_keeps_verify_even_under_tools_allow() {
331        let dir = tempfile::tempdir().unwrap();
332        let provider = Arc::new(MockProvider::new(vec![]));
333        // Restrict to just `read`; `verify` must survive regardless.
334        let tools = code_tools(dir.path(), true, vec!["true".into()]);
335        let _ = CodeAgentBuilder::new(provider, dir.path())
336            .verify_command("true")
337            .tools_allow(["read"])
338            .build()
339            .expect("build ok");
340        // The curated set itself still contains verify (build keeps it).
341        assert!(tools.iter().any(|t| t.definition().name == "verify"));
342    }
343}