buildwithnexus 0.10.1 → 0.10.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -0
- package/harness/Cargo.lock +1 -1
- package/harness/Cargo.toml +2 -2
- package/harness/src/agent.rs +449 -111
- package/harness/src/checkpoint.rs +104 -0
- package/harness/src/config.rs +230 -47
- package/harness/src/hooks.rs +141 -45
- package/harness/src/lib.rs +1188 -100
- package/harness/src/onboarding.rs +68 -10
- package/harness/src/provider.rs +53 -9
- package/harness/src/report.rs +2 -2
- package/harness/src/tools.rs +415 -5
- package/harness/src/tui.rs +650 -159
- package/harness/src/workflow.rs +386 -0
- package/package.json +3 -3
- package/scripts/postinstall.js +6 -1
package/harness/src/agent.rs
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
// The orchestration
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// The orchestration: plain Rust control flow for all three modes. PLAN
|
|
2
|
+
// decomposes + gates, BUILD is a ReAct tool loop, BRAINSTORM is conversational —
|
|
3
|
+
// but ALL modes have access to the full tool surface so the model can grep,
|
|
4
|
+
// fetch, read files, and run commands regardless of which mode the user is in.
|
|
4
5
|
|
|
5
6
|
use std::io::IsTerminal;
|
|
6
7
|
use std::path::{Path, PathBuf};
|
|
7
8
|
use std::process::Command;
|
|
8
9
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
9
10
|
|
|
11
|
+
use crate::config;
|
|
10
12
|
use crate::hooks::{self, PreDecision};
|
|
11
13
|
use crate::provider::{self, complete, Msg, Provider, Reply, ToolResult};
|
|
12
14
|
use crate::report;
|
|
@@ -15,20 +17,15 @@ use crate::tui;
|
|
|
15
17
|
|
|
16
18
|
const MAX_ITERS: usize = 30;
|
|
17
19
|
const MAX_DEPTH: usize = 3;
|
|
18
|
-
|
|
19
|
-
// ── context compaction ──────────────────────────────────────────────────────
|
|
20
|
-
// Small models have small windows; long tool loops overflow them. When the
|
|
21
|
-
// running conversation passes ~70% of the model's window, distill the older
|
|
22
|
-
// middle into a summary and keep the system prompt + recent tail verbatim.
|
|
23
20
|
const KEEP_RECENT: usize = 6;
|
|
24
21
|
|
|
25
|
-
//
|
|
26
|
-
// when to compact without parsing each vendor's usage payload.
|
|
22
|
+
// ── context compaction ────────────────────────────────────────────────────────
|
|
27
23
|
fn estimate_tokens(msgs: &[Msg]) -> usize {
|
|
28
24
|
let mut chars = 0usize;
|
|
29
25
|
for m in msgs {
|
|
30
26
|
chars += match m {
|
|
31
27
|
Msg::System(s) | Msg::User(s) => s.len(),
|
|
28
|
+
Msg::UserImages { text, images } => text.len() + images.iter().map(|(_, d)| d.len() / 3).sum::<usize>(),
|
|
32
29
|
Msg::Assistant { text, calls } => {
|
|
33
30
|
text.len() + calls.iter().map(|c| c.name.len() + c.input.to_string().len()).sum::<usize>()
|
|
34
31
|
}
|
|
@@ -38,15 +35,12 @@ fn estimate_tokens(msgs: &[Msg]) -> usize {
|
|
|
38
35
|
chars / 4
|
|
39
36
|
}
|
|
40
37
|
|
|
41
|
-
// Split indices: msgs[..sys_end] are leading system messages (kept) and
|
|
42
|
-
// msgs[tail_start..] are the recent tail (kept); the middle is summarized.
|
|
43
38
|
fn compaction_split(msgs: &[Msg]) -> (usize, usize) {
|
|
44
39
|
let sys_end = msgs.iter().take_while(|m| matches!(m, Msg::System(_))).count();
|
|
45
40
|
let tail_start = msgs.len().saturating_sub(KEEP_RECENT).max(sys_end);
|
|
46
41
|
(sys_end, tail_start)
|
|
47
42
|
}
|
|
48
43
|
|
|
49
|
-
// Deterministic, no-model fallback: list the actions taken in the middle.
|
|
50
44
|
fn structural_summary(middle: &[Msg]) -> String {
|
|
51
45
|
let mut actions: Vec<String> = Vec::new();
|
|
52
46
|
for m in middle {
|
|
@@ -63,13 +57,16 @@ fn structural_summary(middle: &[Msg]) -> String {
|
|
|
63
57
|
}
|
|
64
58
|
}
|
|
65
59
|
|
|
66
|
-
// Flatten messages to text for the summarizer (bounded per message).
|
|
67
60
|
fn render_msgs(msgs: &[Msg]) -> String {
|
|
68
61
|
let mut s = String::new();
|
|
69
62
|
for m in msgs {
|
|
70
63
|
match m {
|
|
71
64
|
Msg::System(t) => { s.push_str("system: "); s.push_str(t); }
|
|
72
65
|
Msg::User(t) => { s.push_str("user: "); s.push_str(t); }
|
|
66
|
+
Msg::UserImages { text, images } => {
|
|
67
|
+
s.push_str(&format!("user [+{} image(s)]: ", images.len()));
|
|
68
|
+
s.push_str(text);
|
|
69
|
+
}
|
|
73
70
|
Msg::Assistant { text, calls } => {
|
|
74
71
|
s.push_str("assistant: ");
|
|
75
72
|
s.push_str(text);
|
|
@@ -89,8 +86,6 @@ fn render_msgs(msgs: &[Msg]) -> String {
|
|
|
89
86
|
s
|
|
90
87
|
}
|
|
91
88
|
|
|
92
|
-
// Rebuild: system prefix + a single summary note + recent tail. `summarize` is a
|
|
93
|
-
// closure so tests can inject a deterministic summary instead of a model call.
|
|
94
89
|
fn compact_with(msgs: Vec<Msg>, summarize: impl FnOnce(&[Msg]) -> String) -> Vec<Msg> {
|
|
95
90
|
let (sys_end, tail_start) = compaction_split(&msgs);
|
|
96
91
|
if tail_start <= sys_end {
|
|
@@ -107,7 +102,6 @@ fn compact_with(msgs: Vec<Msg>, summarize: impl FnOnce(&[Msg]) -> String) -> Vec
|
|
|
107
102
|
v
|
|
108
103
|
}
|
|
109
104
|
|
|
110
|
-
// Ask the model to summarize; fall back to the structural summary on error.
|
|
111
105
|
fn model_summary(p: &Provider, middle: &[Msg]) -> String {
|
|
112
106
|
let sys = "Summarize this AI coding-agent conversation into a terse brief that preserves the task, key findings, decisions, files changed, and the current state. Drop pleasantries; use compact bullet points.";
|
|
113
107
|
let q = vec![Msg::System(sys.into()), Msg::User(render_msgs(middle))];
|
|
@@ -117,9 +111,8 @@ fn model_summary(p: &Provider, middle: &[Msg]) -> String {
|
|
|
117
111
|
}
|
|
118
112
|
}
|
|
119
113
|
|
|
120
|
-
// Compact in place when the running context exceeds ~70% of the window.
|
|
121
114
|
fn maybe_compact(p: &Provider, msgs: &mut Vec<Msg>) {
|
|
122
|
-
let budget = p.context_tokens.saturating_mul(
|
|
115
|
+
let budget = p.context_tokens.saturating_mul(8) / 10;
|
|
123
116
|
if budget == 0 || estimate_tokens(msgs) <= budget {
|
|
124
117
|
return;
|
|
125
118
|
}
|
|
@@ -132,6 +125,7 @@ fn maybe_compact(p: &Provider, msgs: &mut Vec<Msg>) {
|
|
|
132
125
|
*msgs = compact_with(taken, |middle| model_summary(p, middle));
|
|
133
126
|
}
|
|
134
127
|
|
|
128
|
+
// ── permissions ───────────────────────────────────────────────────────────────
|
|
135
129
|
#[derive(Clone, Copy)]
|
|
136
130
|
pub enum Permission {
|
|
137
131
|
Ask,
|
|
@@ -140,13 +134,15 @@ pub enum Permission {
|
|
|
140
134
|
}
|
|
141
135
|
|
|
142
136
|
pub fn permission(s: &str) -> Permission {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
"
|
|
137
|
+
let normalized = s.trim().to_ascii_lowercase().replace(['_', '-'], "");
|
|
138
|
+
match normalized.as_str() {
|
|
139
|
+
"auto" | "acceptedits" | "acceptedit" | "bypasspermissions" => Permission::Auto,
|
|
140
|
+
"readonly" | "read" | "plan" | "dontask" => Permission::ReadOnly,
|
|
146
141
|
_ => Permission::Ask,
|
|
147
142
|
}
|
|
148
143
|
}
|
|
149
144
|
|
|
145
|
+
// ── roles ─────────────────────────────────────────────────────────────────────
|
|
150
146
|
pub struct Role {
|
|
151
147
|
pub system: &'static str,
|
|
152
148
|
}
|
|
@@ -154,14 +150,118 @@ pub struct Role {
|
|
|
154
150
|
pub fn role(id: &str) -> Role {
|
|
155
151
|
let system = match id {
|
|
156
152
|
"researcher" => "You are a meticulous research engineer. Investigate the codebase with the read and list tools before drawing conclusions. Cite file paths. Do not modify files unless explicitly asked.",
|
|
157
|
-
_ => "You are an autonomous senior software engineer.
|
|
153
|
+
_ => "You are an autonomous senior software engineer. \
|
|
154
|
+
Use the tools to inspect and modify the project directly. \
|
|
155
|
+
Prefer small, verifiable edits. Read before you write. \
|
|
156
|
+
When the task is complete, call the finish tool with a one-paragraph summary.\n\n\
|
|
157
|
+
IMPORTANT — tool discipline:\n\
|
|
158
|
+
• Before using run_command to install anything (npm install, pip install, cargo add, brew install, \
|
|
159
|
+
apt install, etc.), FIRST check whether an existing package or built-in tool can do the job. \
|
|
160
|
+
Look at package.json / Cargo.toml / requirements.txt / pyproject.toml to see what is already available. \
|
|
161
|
+
Use run_command with grep/find/jq/awk/sed/git/curl/which before reaching for new installs.\n\
|
|
162
|
+
• fetch_url is built-in — do NOT install curl/wget just to make an HTTP request.\n\
|
|
163
|
+
• read_file / list_dir / edit_file are built-in — do NOT install file-management packages for basic I/O.\n\
|
|
164
|
+
• If a system tool is already present (check with `which <tool>` or `command -v <tool>`) prefer it \
|
|
165
|
+
over installing an alternative.",
|
|
158
166
|
};
|
|
159
167
|
Role { system }
|
|
160
168
|
}
|
|
161
169
|
|
|
170
|
+
// Build the system prompt prefix from memory and skills/agents files.
|
|
171
|
+
fn context_prefix() -> String {
|
|
172
|
+
let mut parts: Vec<String> = Vec::new();
|
|
173
|
+
|
|
174
|
+
// Always include the tool manifest so the model knows what's built-in
|
|
175
|
+
// and doesn't try to install external tools to do things we already handle.
|
|
176
|
+
parts.push(tool_manifest());
|
|
177
|
+
|
|
178
|
+
// Probe which common system tools are actually present so the model can
|
|
179
|
+
// pick the right one without guessing or installing alternatives.
|
|
180
|
+
let env_snap = env_snapshot();
|
|
181
|
+
if !env_snap.is_empty() {
|
|
182
|
+
parts.push(format!("[Environment — tools already installed]\n{env_snap}"));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if let Some(mem) = config::load_memory() {
|
|
186
|
+
parts.push(format!("[Memory from previous sessions]\n{mem}"));
|
|
187
|
+
}
|
|
188
|
+
if let Some(agents) = config::load_agents() {
|
|
189
|
+
parts.push(format!("[Agent knowledge — Agents.md]\n{agents}"));
|
|
190
|
+
}
|
|
191
|
+
let skills = config::load_skills();
|
|
192
|
+
if !skills.is_empty() {
|
|
193
|
+
let joined = skills.iter()
|
|
194
|
+
.map(|(name, content)| format!("## Skill: {name}\n{content}"))
|
|
195
|
+
.collect::<Vec<_>>().join("\n\n");
|
|
196
|
+
parts.push(format!("[Available skills]\n{joined}"));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
format!("{}\n\n", parts.join("\n\n"))
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
fn tool_manifest() -> String {
|
|
203
|
+
"[Built-in tools — always available, no install needed]\n\
|
|
204
|
+
• read_file / list_dir — read any file or directory on the filesystem; read_file supports start_line/end_line\n\
|
|
205
|
+
• find_files / grep_files — search the codebase without shelling out; prefer these for navigation before run_command\n\
|
|
206
|
+
• write_file / edit_file — create or surgically modify files\n\
|
|
207
|
+
• run_command — run any shell command: grep, find, git, cargo, make, npm, python3, etc.\n\
|
|
208
|
+
• fetch_url — HTTP GET (no curl/wget needed)\n\
|
|
209
|
+
• web_search — DuckDuckGo web search (no API key, live results)\n\
|
|
210
|
+
• save_memory — persist a note across sessions\n\
|
|
211
|
+
• spawn_subagent — delegate a sub-task to a fresh agent with its own context\n\
|
|
212
|
+
• finish — mark the task complete with a summary"
|
|
213
|
+
.to_string()
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
fn env_snapshot() -> String {
|
|
217
|
+
// Probe a fixed list of common tools so the model knows what's available.
|
|
218
|
+
// We run everything in parallel-ish with short timeouts via sequential calls —
|
|
219
|
+
// the total overhead is ~10ms on a modern machine.
|
|
220
|
+
let probes: &[(&str, &str)] = &[
|
|
221
|
+
("node", "node --version"),
|
|
222
|
+
("npm", "npm --version"),
|
|
223
|
+
("npx", "npx --version"),
|
|
224
|
+
("python3", "python3 --version"),
|
|
225
|
+
("pip3", "pip3 --version"),
|
|
226
|
+
("cargo", "cargo --version"),
|
|
227
|
+
("rustc", "rustc --version"),
|
|
228
|
+
("git", "git --version"),
|
|
229
|
+
("docker", "docker --version"),
|
|
230
|
+
("jq", "jq --version"),
|
|
231
|
+
("rg", "rg --version"),
|
|
232
|
+
("gh", "gh --version"),
|
|
233
|
+
("bun", "bun --version"),
|
|
234
|
+
("deno", "deno --version"),
|
|
235
|
+
("go", "go version"),
|
|
236
|
+
("ruby", "ruby --version"),
|
|
237
|
+
("java", "java --version"),
|
|
238
|
+
];
|
|
239
|
+
|
|
240
|
+
use std::process::Command;
|
|
241
|
+
let mut found: Vec<String> = Vec::new();
|
|
242
|
+
for (label, cmd) in probes {
|
|
243
|
+
let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
|
|
244
|
+
let bin = parts[0];
|
|
245
|
+
let arg = parts.get(1).copied().unwrap_or("--version");
|
|
246
|
+
let ok = Command::new(bin).arg(arg)
|
|
247
|
+
.stdout(std::process::Stdio::piped())
|
|
248
|
+
.stderr(std::process::Stdio::piped())
|
|
249
|
+
.output()
|
|
250
|
+
.map(|o| {
|
|
251
|
+
let txt = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
|
252
|
+
let txt = if txt.is_empty() { String::from_utf8_lossy(&o.stderr).trim().to_string() } else { txt };
|
|
253
|
+
txt.lines().next().unwrap_or("").to_string()
|
|
254
|
+
})
|
|
255
|
+
.ok()
|
|
256
|
+
.filter(|v| !v.is_empty());
|
|
257
|
+
if let Some(ver) = ok {
|
|
258
|
+
found.push(format!("• {label}: {ver}"));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
found.join("\n")
|
|
262
|
+
}
|
|
263
|
+
|
|
162
264
|
fn confirm(label: &str) -> Option<String> {
|
|
163
|
-
// Never block waiting for input when no human can answer (JSON/automation or
|
|
164
|
-
// a non-terminal stdin) — deny by default instead of hanging.
|
|
165
265
|
if report::is_json() || !std::io::stdin().is_terminal() {
|
|
166
266
|
return Some(format!("blocked (no interactive terminal to confirm: {label})"));
|
|
167
267
|
}
|
|
@@ -174,23 +274,32 @@ fn confirm(label: &str) -> Option<String> {
|
|
|
174
274
|
}
|
|
175
275
|
}
|
|
176
276
|
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
fn gate(perm: Permission, name: &str, input: &serde_json::Value, cwd: &Path) -> Option<String> {
|
|
277
|
+
// Permission gate — returns Some(reason) when blocked.
|
|
278
|
+
// Read operations outside CWD are allowed in all modes (no CWD confinement for
|
|
279
|
+
// reads); the user specifically asked for full filesystem access.
|
|
280
|
+
pub(crate) fn gate(perm: Permission, name: &str, input: &serde_json::Value, cwd: &Path) -> Option<String> {
|
|
182
281
|
let path = tools::touched_path(name, input, cwd);
|
|
183
282
|
|
|
184
283
|
if let Some(p) = &path {
|
|
185
284
|
if tools::is_sensitive(p) {
|
|
186
285
|
return confirm(&format!("access sensitive path {}", p.display()));
|
|
187
286
|
}
|
|
287
|
+
// In WSL2, writing to a Windows drive mount (/mnt/c/, /mnt/d/, etc.)
|
|
288
|
+
// crosses the OS boundary — always confirm, even in Auto mode.
|
|
289
|
+
if tools::is_mutating(name) && tools::is_wsl_windows_mount(p) {
|
|
290
|
+
return confirm(&format!("write to Windows filesystem {} (WSL2 boundary)", p.display()));
|
|
291
|
+
}
|
|
188
292
|
}
|
|
189
293
|
if name == "run_command" {
|
|
190
294
|
if let Some(c) = input["command"].as_str() {
|
|
191
295
|
if tools::catastrophic(c) {
|
|
192
296
|
return confirm(&format!("run dangerous command `{c}`"));
|
|
193
297
|
}
|
|
298
|
+
// In WSL2, commands that reference /mnt/<drive>/ target the Windows
|
|
299
|
+
// filesystem — confirm before running, even in Auto mode.
|
|
300
|
+
if tools::is_wsl() && tools::command_touches_wsl_mount(c) {
|
|
301
|
+
return confirm(&format!("command targets Windows filesystem (WSL2): `{c}`"));
|
|
302
|
+
}
|
|
194
303
|
}
|
|
195
304
|
}
|
|
196
305
|
|
|
@@ -198,44 +307,58 @@ fn gate(perm: Permission, name: &str, input: &serde_json::Value, cwd: &Path) ->
|
|
|
198
307
|
Permission::Auto => None,
|
|
199
308
|
Permission::ReadOnly => {
|
|
200
309
|
if tools::is_mutating(name) {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
310
|
+
// run_command with clearly read-only shell tools (grep, find, etc.)
|
|
311
|
+
// should pass through even in ReadOnly mode.
|
|
312
|
+
if name == "run_command" {
|
|
313
|
+
if let Some(c) = input["command"].as_str() {
|
|
314
|
+
if tools::is_readonly_command(c) {
|
|
315
|
+
return None;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
206
318
|
}
|
|
319
|
+
return Some("read-only mode: mutation skipped".into());
|
|
207
320
|
}
|
|
208
|
-
None
|
|
321
|
+
None // reads anywhere are allowed in readonly mode
|
|
209
322
|
}
|
|
210
323
|
Permission::Ask => {
|
|
324
|
+
// run_command calls whose binary appears in allowed_commands skip the
|
|
325
|
+
// confirmation prompt — git, cargo, npm, etc. should just work.
|
|
326
|
+
if name == "run_command" {
|
|
327
|
+
if let Some(c) = input["command"].as_str() {
|
|
328
|
+
let first = c.split_whitespace().next().unwrap_or("");
|
|
329
|
+
// Accept both bare names and full paths (/usr/bin/git → git).
|
|
330
|
+
let bin = std::path::Path::new(first)
|
|
331
|
+
.file_name().and_then(|n| n.to_str()).unwrap_or(first);
|
|
332
|
+
if config::load_allowed_commands().iter().any(|a| a == bin) {
|
|
333
|
+
return None;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
211
337
|
if tools::is_mutating(name) {
|
|
212
338
|
return confirm(&tools::preview(name, input));
|
|
213
339
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
return confirm(&format!("read outside working directory: {}", p.display()));
|
|
217
|
-
}
|
|
218
|
-
}
|
|
340
|
+
// Out-of-cwd reads: just note it instead of hard-blocking.
|
|
341
|
+
// The user asked for full filesystem access.
|
|
219
342
|
None
|
|
220
343
|
}
|
|
221
344
|
}
|
|
222
345
|
}
|
|
223
346
|
|
|
224
|
-
//
|
|
225
|
-
|
|
347
|
+
// Public compact helper for the /compact REPL command.
|
|
348
|
+
pub fn compact_msgs(p: &Provider, msgs: Vec<Msg>) -> Vec<Msg> {
|
|
349
|
+
compact_with(msgs, |middle| model_summary(p, middle))
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ── BUILD mode ────────────────────────────────────────────────────────────────
|
|
226
353
|
pub fn run_build(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &Path) -> Result<(), String> {
|
|
227
354
|
let mut transcript: Vec<Msg> = Vec::new();
|
|
228
355
|
run_build_session(p, perm, role_id, task, cwd, &mut transcript, &crate::session::new_id())
|
|
229
356
|
}
|
|
230
357
|
|
|
231
|
-
// Continue a restored session: `seed` is the prior transcript, persisted under
|
|
232
|
-
// the same `sid` so resume updates the session rather than forking it.
|
|
233
358
|
pub fn run_build_resumed(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &Path, mut seed: Vec<Msg>, sid: &str) -> Result<(), String> {
|
|
234
359
|
run_build_session(p, perm, role_id, task, cwd, &mut seed, sid)
|
|
235
360
|
}
|
|
236
361
|
|
|
237
|
-
// Run a BUILD task into a caller-owned transcript, persisting the session. The
|
|
238
|
-
// REPL uses this to keep one continuous, resumable conversation across tasks.
|
|
239
362
|
pub fn run_build_session(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &Path, transcript: &mut Vec<Msg>, sid: &str) -> Result<(), String> {
|
|
240
363
|
let r = build_inner(p, perm, role_id, task, cwd, 0, transcript).map(|_| ());
|
|
241
364
|
hooks::notify("Stop", cwd);
|
|
@@ -243,9 +366,6 @@ pub fn run_build_session(p: &Provider, perm: Permission, role_id: &str, task: &s
|
|
|
243
366
|
r
|
|
244
367
|
}
|
|
245
368
|
|
|
246
|
-
// Returns the final assistant text / finish summary, so a parent can read a
|
|
247
|
-
// spawned subagent's result. `depth` bounds recursion via spawn_subagent.
|
|
248
|
-
// `msgs` is the working transcript (seeded for resume, otherwise empty).
|
|
249
369
|
fn build_inner(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &Path, depth: usize, msgs: &mut Vec<Msg>) -> Result<String, String> {
|
|
250
370
|
let task = match hooks::user_prompt_submit(task, cwd) {
|
|
251
371
|
Err(reason) => {
|
|
@@ -257,37 +377,62 @@ fn build_inner(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &
|
|
|
257
377
|
};
|
|
258
378
|
let defs = tools::defs(depth < MAX_DEPTH);
|
|
259
379
|
if msgs.is_empty() {
|
|
260
|
-
|
|
380
|
+
let prefix = context_prefix();
|
|
381
|
+
let sys = format!("{prefix}{}", role(role_id).system);
|
|
382
|
+
msgs.push(Msg::System(sys));
|
|
383
|
+
}
|
|
384
|
+
// If the caller already pushed a UserImages message (multimodal input), use its
|
|
385
|
+
// text as the task without pushing another User turn; otherwise push normally.
|
|
386
|
+
let already_pushed = matches!(msgs.last(), Some(Msg::UserImages { .. }));
|
|
387
|
+
if !already_pushed {
|
|
388
|
+
msgs.push(Msg::User(task));
|
|
261
389
|
}
|
|
262
|
-
msgs.push(Msg::User(task));
|
|
263
390
|
|
|
264
|
-
|
|
391
|
+
// Track which files have been read this session so we can enforce read-before-write.
|
|
392
|
+
let mut read_paths: std::collections::HashSet<PathBuf> = Default::default();
|
|
393
|
+
|
|
394
|
+
for step in 1..=MAX_ITERS {
|
|
265
395
|
if tui::interrupted() {
|
|
266
396
|
report::notice("interrupted");
|
|
267
397
|
return Ok(String::new());
|
|
268
398
|
}
|
|
269
399
|
maybe_compact(p, msgs);
|
|
270
|
-
// JSON mode buffers (one clean event); human mode streams tokens live,
|
|
271
|
-
// with a spinner covering the gap until the first token so a slow model
|
|
272
|
-
// never looks frozen.
|
|
273
400
|
let reply: Reply = if report::is_json() {
|
|
274
401
|
let r = complete(p, msgs.as_slice(), &defs)?;
|
|
275
402
|
report::assistant(&r.text);
|
|
276
403
|
r
|
|
277
404
|
} else {
|
|
405
|
+
// Show a step counter so the user can see multi-step reasoning progress.
|
|
406
|
+
if step > 1 {
|
|
407
|
+
tui::line(&tui::dim(&format!(" ↻ step {step}")));
|
|
408
|
+
}
|
|
278
409
|
let mut spin = Some(tui::spinner_start("thinking…"));
|
|
279
410
|
let mut streamed = false;
|
|
411
|
+
let mut thinking_buf = String::new();
|
|
412
|
+
let mut renderer = tui::StreamRenderer::new();
|
|
280
413
|
let res = provider::stream(p, msgs.as_slice(), &defs, &mut |c| {
|
|
281
414
|
if let Some(s) = spin.take() {
|
|
282
415
|
tui::spinner_stop(s);
|
|
283
416
|
}
|
|
284
|
-
|
|
417
|
+
renderer.push(c);
|
|
285
418
|
streamed = true;
|
|
419
|
+
}, &mut |t| {
|
|
420
|
+
// Extended thinking: buffer and display as dim internal monologue.
|
|
421
|
+
thinking_buf.push_str(t);
|
|
422
|
+
// Flush complete sentences/lines so output feels live.
|
|
423
|
+
while let Some(nl) = thinking_buf.find('\n') {
|
|
424
|
+
let line = thinking_buf[..nl].trim().to_string();
|
|
425
|
+
if !line.is_empty() {
|
|
426
|
+
tui::write_stream(&format!("\r {} {}\r\n", tui::dim("◌"), tui::dim(&line)));
|
|
427
|
+
}
|
|
428
|
+
thinking_buf = thinking_buf[nl + 1..].to_string();
|
|
429
|
+
}
|
|
286
430
|
});
|
|
287
431
|
if let Some(s) = spin.take() {
|
|
288
|
-
tui::spinner_stop(s);
|
|
432
|
+
tui::spinner_stop(s);
|
|
289
433
|
}
|
|
290
434
|
let r = res?;
|
|
435
|
+
renderer.flush();
|
|
291
436
|
if streamed {
|
|
292
437
|
report::assistant_end();
|
|
293
438
|
}
|
|
@@ -297,25 +442,20 @@ fn build_inner(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &
|
|
|
297
442
|
if reply.text.trim().is_empty() {
|
|
298
443
|
report::notice("model returned no output");
|
|
299
444
|
}
|
|
300
|
-
return Ok(reply.text);
|
|
445
|
+
return Ok(reply.text);
|
|
301
446
|
}
|
|
302
447
|
|
|
303
448
|
let mut results = Vec::new();
|
|
304
449
|
let mut summary: Option<String> = None;
|
|
305
450
|
for call in &reply.calls {
|
|
306
|
-
// Reject calls whose JSON arguments didn't parse, with a message the
|
|
307
|
-
// model can act on (common failure mode for small local models).
|
|
308
451
|
if let Some(raw) = call.input.get(tools::INVALID_ARGS).and_then(|v| v.as_str()) {
|
|
309
452
|
let msg = format!("tool arguments were not valid JSON: {}", raw.chars().take(200).collect::<String>());
|
|
310
453
|
report::tool_denied(&msg);
|
|
311
454
|
results.push(ToolResult { id: call.id.clone(), content: msg, is_error: true });
|
|
312
455
|
continue;
|
|
313
456
|
}
|
|
314
|
-
// Show the proposed action (with an inline diff for edits) first, so
|
|
315
|
-
// the approval prompt that follows has visible context.
|
|
316
457
|
report::tool_call(&call.name, &tools::preview(&call.name, &call.input), &call.input);
|
|
317
458
|
|
|
318
|
-
// PreToolUse hook can allow (skip the gate), deny, or defer to it.
|
|
319
459
|
let reason = match hooks::pre_tool_use(&call.name, &call.input, cwd) {
|
|
320
460
|
PreDecision::Deny(r) => Some(r),
|
|
321
461
|
PreDecision::Allow => None,
|
|
@@ -327,12 +467,54 @@ fn build_inner(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &
|
|
|
327
467
|
continue;
|
|
328
468
|
}
|
|
329
469
|
|
|
470
|
+
// Record reads so we can enforce read-before-write below.
|
|
471
|
+
if call.name == "read_file" {
|
|
472
|
+
if let Some(raw) = call.input["path"].as_str() {
|
|
473
|
+
let p = if std::path::Path::new(raw).is_absolute() {
|
|
474
|
+
PathBuf::from(raw)
|
|
475
|
+
} else {
|
|
476
|
+
cwd.join(raw)
|
|
477
|
+
};
|
|
478
|
+
read_paths.insert(p);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Require the model to read a file before overwriting or patching it.
|
|
483
|
+
if matches!(call.name.as_str(), "write_file" | "edit_file") {
|
|
484
|
+
if let Some(raw) = call.input["path"].as_str() {
|
|
485
|
+
let p = if std::path::Path::new(raw).is_absolute() {
|
|
486
|
+
PathBuf::from(raw)
|
|
487
|
+
} else {
|
|
488
|
+
cwd.join(raw)
|
|
489
|
+
};
|
|
490
|
+
if p.exists() && !read_paths.contains(&p) {
|
|
491
|
+
let msg = format!(
|
|
492
|
+
"read_file('{}') required before editing. Read the file first, then retry.",
|
|
493
|
+
p.display()
|
|
494
|
+
);
|
|
495
|
+
report::tool_denied(&msg);
|
|
496
|
+
results.push(ToolResult { id: call.id.clone(), content: msg, is_error: true });
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
330
502
|
if call.name == "spawn_subagent" {
|
|
331
503
|
let out = spawn_subagent(p, perm, &call.input, cwd, depth);
|
|
332
504
|
report::tool_result(&call.name, &out, false);
|
|
333
505
|
results.push(ToolResult { id: call.id.clone(), content: out, is_error: false });
|
|
334
506
|
continue;
|
|
335
507
|
}
|
|
508
|
+
// Memory-save tool: the model can call save_memory to persist facts.
|
|
509
|
+
if call.name == "save_memory" {
|
|
510
|
+
if let Some(note) = call.input["note"].as_str() {
|
|
511
|
+
config::append_memory(note);
|
|
512
|
+
let msg = "memory saved".to_string();
|
|
513
|
+
report::tool_result(&call.name, &msg, false);
|
|
514
|
+
results.push(ToolResult { id: call.id.clone(), content: msg, is_error: false });
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
336
518
|
|
|
337
519
|
let out = tools::run(&call.name, &call.input, cwd);
|
|
338
520
|
hooks::post_tool_use(&call.name, &call.input, &out.content, out.is_error, cwd);
|
|
@@ -346,6 +528,10 @@ fn build_inner(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &
|
|
|
346
528
|
|
|
347
529
|
msgs.push(Msg::Assistant { text: reply.text, calls: reply.calls });
|
|
348
530
|
msgs.push(Msg::Tool(results));
|
|
531
|
+
if !report::is_json() {
|
|
532
|
+
tui::context_meter(estimate_tokens(msgs), p.context_tokens);
|
|
533
|
+
tui::poll_typeahead();
|
|
534
|
+
}
|
|
349
535
|
if let Some(s) = summary {
|
|
350
536
|
return Ok(s);
|
|
351
537
|
}
|
|
@@ -355,8 +541,6 @@ fn build_inner(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &
|
|
|
355
541
|
|
|
356
542
|
static SUB_SEQ: AtomicUsize = AtomicUsize::new(0);
|
|
357
543
|
|
|
358
|
-
// Delegate a self-contained sub-task to a fresh agent (own context window).
|
|
359
|
-
// `isolate` runs it in a new git worktree so parallel edits never collide.
|
|
360
544
|
fn spawn_subagent(p: &Provider, perm: Permission, input: &serde_json::Value, cwd: &Path, depth: usize) -> String {
|
|
361
545
|
if depth + 1 >= MAX_DEPTH {
|
|
362
546
|
return "subagent depth limit reached".into();
|
|
@@ -381,7 +565,6 @@ fn spawn_subagent(p: &Provider, perm: Permission, input: &serde_json::Value, cwd
|
|
|
381
565
|
};
|
|
382
566
|
|
|
383
567
|
report::notice(&format!(" ↳ subagent: {task}"));
|
|
384
|
-
// A subagent gets its own fresh context window (not persisted as a session).
|
|
385
568
|
let mut child: Vec<Msg> = Vec::new();
|
|
386
569
|
let result = build_inner(p, perm, role, task, &run_cwd, depth + 1, &mut child)
|
|
387
570
|
.unwrap_or_else(|e| format!("subagent error: {e}"));
|
|
@@ -401,13 +584,59 @@ fn make_worktree(cwd: &Path) -> Option<PathBuf> {
|
|
|
401
584
|
out.status.success().then_some(wt)
|
|
402
585
|
}
|
|
403
586
|
|
|
404
|
-
// PLAN
|
|
587
|
+
// ── PLAN mode ─────────────────────────────────────────────────────────────────
|
|
588
|
+
// The planning phase now has tools available so the model can inspect the
|
|
589
|
+
// codebase while breaking down the task. Execution still runs through BUILD.
|
|
405
590
|
pub fn run_plan(p: &Provider, perm: Permission, task: &str, cwd: &Path) -> Result<(), String> {
|
|
406
|
-
let
|
|
407
|
-
let
|
|
408
|
-
|
|
591
|
+
let prefix = context_prefix();
|
|
592
|
+
let sys = format!("{prefix}You are a planning engineer with full access to the codebase. \
|
|
593
|
+
Use read_file and list_dir and run_command to inspect the project as needed. \
|
|
594
|
+
Break the user's task into a concise numbered list of concrete steps \
|
|
595
|
+
(one step per line, max 8 steps). Output the numbered list and nothing else.");
|
|
596
|
+
|
|
597
|
+
let defs = tools::defs(false); // planning uses tools but not subagents
|
|
598
|
+
let mut msgs = vec![Msg::System(sys), Msg::User(task.into())];
|
|
599
|
+
|
|
600
|
+
// Let the model use tools while planning (e.g. read files to understand structure).
|
|
601
|
+
let plan_text = loop {
|
|
602
|
+
maybe_compact(p, &mut msgs);
|
|
603
|
+
let reply = tui::with_spinner("planning…", || complete(p, &msgs, &defs))?;
|
|
604
|
+
|
|
605
|
+
if reply.calls.is_empty() {
|
|
606
|
+
break reply.text;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Execute tool calls during the planning phase.
|
|
610
|
+
let mut results = Vec::new();
|
|
611
|
+
for call in &reply.calls {
|
|
612
|
+
report::tool_call(&call.name, &tools::preview(&call.name, &call.input), &call.input);
|
|
613
|
+
let reason = match hooks::pre_tool_use(&call.name, &call.input, cwd) {
|
|
614
|
+
PreDecision::Deny(r) => Some(r),
|
|
615
|
+
PreDecision::Allow => None,
|
|
616
|
+
PreDecision::Continue => gate(perm, &call.name, &call.input, cwd),
|
|
617
|
+
};
|
|
618
|
+
if let Some(reason) = reason {
|
|
619
|
+
results.push(ToolResult { id: call.id.clone(), content: reason, is_error: true });
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
if call.name == "save_memory" {
|
|
623
|
+
if let Some(note) = call.input["note"].as_str() {
|
|
624
|
+
config::append_memory(note);
|
|
625
|
+
let msg = "memory saved".to_string();
|
|
626
|
+
report::tool_result(&call.name, &msg, false);
|
|
627
|
+
results.push(ToolResult { id: call.id.clone(), content: msg, is_error: false });
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
let out = tools::run(&call.name, &call.input, cwd);
|
|
632
|
+
report::tool_result(&call.name, &out.content, out.is_error);
|
|
633
|
+
results.push(ToolResult { id: call.id.clone(), content: out.content, is_error: out.is_error });
|
|
634
|
+
}
|
|
635
|
+
msgs.push(Msg::Assistant { text: reply.text, calls: reply.calls });
|
|
636
|
+
msgs.push(Msg::Tool(results));
|
|
637
|
+
};
|
|
409
638
|
|
|
410
|
-
let mut steps: Vec<String> =
|
|
639
|
+
let mut steps: Vec<String> = plan_text.lines()
|
|
411
640
|
.map(|l| l.trim_start_matches(|c: char| c.is_ascii_digit() || matches!(c, '.' | ')' | '-' | ' ')).trim())
|
|
412
641
|
.filter(|l| !l.is_empty())
|
|
413
642
|
.map(|l| l.to_string())
|
|
@@ -448,33 +677,129 @@ pub fn run_plan(p: &Provider, perm: Permission, task: &str, cwd: &Path) -> Resul
|
|
|
448
677
|
run_build(p, perm, "engineer", &full, cwd)
|
|
449
678
|
}
|
|
450
679
|
|
|
451
|
-
// BRAINSTORM
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
680
|
+
// ── BRAINSTORM mode ───────────────────────────────────────────────────────────
|
|
681
|
+
// Brainstorm is conversational but has full tool access. The model can grep,
|
|
682
|
+
// read files, fetch URLs, and run commands when the conversation calls for it.
|
|
683
|
+
// It also has a mode-transition sensor: if it detects the user wants to build
|
|
684
|
+
// or plan, it suggests switching.
|
|
685
|
+
pub fn run_brainstorm(p: &Provider, perm: Permission, cwd: &Path, first: &str) -> Result<Option<ModeHint>, String> {
|
|
686
|
+
let prefix = context_prefix();
|
|
687
|
+
let sys = format!("{prefix}You are a sharp, concise thought partner with full access to the codebase and the internet. \
|
|
688
|
+
Use tools freely to look things up, read files, grep for patterns, or run commands — \
|
|
689
|
+
whatever helps the conversation. \
|
|
690
|
+
When you think the user is ready to stop discussing and start building or planning, \
|
|
691
|
+
end your response with the exact token [SUGGEST:BUILD] or [SUGGEST:PLAN] on its own line. \
|
|
692
|
+
Otherwise just respond naturally. No fluff.");
|
|
693
|
+
|
|
694
|
+
let defs = tools::defs(false);
|
|
695
|
+
let mut msgs: Vec<Msg> = vec![Msg::System(sys)];
|
|
456
696
|
let mut question = first.to_string();
|
|
697
|
+
|
|
457
698
|
loop {
|
|
458
699
|
msgs.push(Msg::User(question.clone()));
|
|
459
700
|
maybe_compact(p, &mut msgs);
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
let
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
report::
|
|
701
|
+
|
|
702
|
+
// Keep consuming tool calls until the model gives a text response.
|
|
703
|
+
let reply_text = loop {
|
|
704
|
+
tui::line("");
|
|
705
|
+
let mut spin = Some(tui::spinner_start("thinking…"));
|
|
706
|
+
let mut streamed = false;
|
|
707
|
+
let mut thinking_buf = String::new();
|
|
708
|
+
let mut renderer = if !report::is_json() { Some(tui::StreamRenderer::new()) } else { None };
|
|
709
|
+
let res = provider::stream(p, &msgs, &defs, &mut |c| {
|
|
710
|
+
if let Some(s) = spin.take() { tui::spinner_stop(s); }
|
|
711
|
+
if let Some(r) = &mut renderer { r.push(c); } else { report::assistant_delta(c); }
|
|
712
|
+
streamed = true;
|
|
713
|
+
}, &mut |t| {
|
|
714
|
+
thinking_buf.push_str(t);
|
|
715
|
+
while let Some(nl) = thinking_buf.find('\n') {
|
|
716
|
+
let tl = thinking_buf[..nl].trim().to_string();
|
|
717
|
+
if !tl.is_empty() {
|
|
718
|
+
tui::write_stream(&format!("\r {} {}\r\n", tui::dim("◌"), tui::dim(&tl)));
|
|
719
|
+
}
|
|
720
|
+
thinking_buf = thinking_buf[nl + 1..].to_string();
|
|
721
|
+
}
|
|
722
|
+
});
|
|
723
|
+
if let Some(s) = spin.take() { tui::spinner_stop(s); }
|
|
724
|
+
let reply = res?;
|
|
725
|
+
if let Some(r) = &mut renderer { r.flush(); }
|
|
726
|
+
if streamed { report::assistant_end(); }
|
|
727
|
+
|
|
728
|
+
if reply.calls.is_empty() {
|
|
729
|
+
msgs.push(Msg::Assistant { text: reply.text.clone(), calls: vec![] });
|
|
730
|
+
if !report::is_json() {
|
|
731
|
+
tui::context_meter(estimate_tokens(&msgs), p.context_tokens);
|
|
732
|
+
}
|
|
733
|
+
break reply.text;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Execute tool calls inline.
|
|
737
|
+
let mut results = Vec::new();
|
|
738
|
+
for call in &reply.calls {
|
|
739
|
+
report::tool_call(&call.name, &tools::preview(&call.name, &call.input), &call.input);
|
|
740
|
+
let reason = match hooks::pre_tool_use(&call.name, &call.input, cwd) {
|
|
741
|
+
PreDecision::Deny(r) => Some(r),
|
|
742
|
+
PreDecision::Allow => None,
|
|
743
|
+
PreDecision::Continue => gate(perm, &call.name, &call.input, cwd),
|
|
744
|
+
};
|
|
745
|
+
if let Some(reason) = reason {
|
|
746
|
+
results.push(ToolResult { id: call.id.clone(), content: reason, is_error: true });
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
if call.name == "save_memory" {
|
|
750
|
+
if let Some(note) = call.input["note"].as_str() {
|
|
751
|
+
config::append_memory(note);
|
|
752
|
+
let msg = "memory saved".to_string();
|
|
753
|
+
report::tool_result(&call.name, &msg, false);
|
|
754
|
+
results.push(ToolResult { id: call.id.clone(), content: msg, is_error: false });
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
let out = tools::run(&call.name, &call.input, cwd);
|
|
759
|
+
hooks::post_tool_use(&call.name, &call.input, &out.content, out.is_error, cwd);
|
|
760
|
+
report::tool_result(&call.name, &out.content, out.is_error);
|
|
761
|
+
results.push(ToolResult { id: call.id.clone(), content: out.content, is_error: out.is_error });
|
|
762
|
+
}
|
|
763
|
+
msgs.push(Msg::Assistant { text: reply.text, calls: reply.calls });
|
|
764
|
+
msgs.push(Msg::Tool(results));
|
|
765
|
+
if !report::is_json() {
|
|
766
|
+
tui::context_meter(estimate_tokens(&msgs), p.context_tokens);
|
|
767
|
+
tui::poll_typeahead();
|
|
768
|
+
}
|
|
769
|
+
};
|
|
770
|
+
|
|
771
|
+
// Check for mode-transition suggestion embedded in the reply.
|
|
772
|
+
let hint = if reply_text.contains("[SUGGEST:BUILD]") {
|
|
773
|
+
Some(ModeHint::Build)
|
|
774
|
+
} else if reply_text.contains("[SUGGEST:PLAN]") {
|
|
775
|
+
Some(ModeHint::Plan)
|
|
776
|
+
} else {
|
|
777
|
+
None
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
if let Some(ref h) = hint {
|
|
781
|
+
tui::line("");
|
|
782
|
+
let suggestion = match h {
|
|
783
|
+
ModeHint::Build => "switch to BUILD mode and implement this?",
|
|
784
|
+
ModeHint::Plan => "switch to PLAN mode and break this down?",
|
|
785
|
+
ModeHint::CycleMode => "cycle to the next mode?",
|
|
786
|
+
};
|
|
787
|
+
tui::line(&tui::yellow(&format!(" ↪ AI suggests: {suggestion}")));
|
|
788
|
+
tui::line(&tui::dim(" (y to switch, anything else to keep chatting)"));
|
|
789
|
+
let ans = tui::ask(" ").unwrap_or_default();
|
|
790
|
+
if matches!(ans.trim().to_lowercase().as_str(), "y" | "yes") {
|
|
791
|
+
return Ok(Some(h.clone()));
|
|
792
|
+
}
|
|
468
793
|
}
|
|
469
|
-
tui::line("");
|
|
470
|
-
msgs.push(Msg::Assistant { text: reply.text, calls: vec![] });
|
|
471
794
|
|
|
795
|
+
tui::line("");
|
|
472
796
|
match tui::ask_task(&format!("{} ", tui::blue("you ›"))) {
|
|
473
|
-
None => return Ok(
|
|
474
|
-
Some(
|
|
797
|
+
None => return Ok(None),
|
|
798
|
+
Some(tui::InputEvent::CycleMode) => return Ok(Some(ModeHint::CycleMode)),
|
|
799
|
+
Some(tui::InputEvent::Text(f)) => {
|
|
475
800
|
let t = f.trim();
|
|
476
801
|
if t.is_empty() || t == "exit" || t == "done" {
|
|
477
|
-
return Ok(
|
|
802
|
+
return Ok(None);
|
|
478
803
|
}
|
|
479
804
|
question = t.to_string();
|
|
480
805
|
}
|
|
@@ -482,33 +807,38 @@ pub fn run_brainstorm(p: &Provider, first: &str) -> Result<(), String> {
|
|
|
482
807
|
}
|
|
483
808
|
}
|
|
484
809
|
|
|
810
|
+
#[derive(Clone)]
|
|
811
|
+
pub enum ModeHint {
|
|
812
|
+
Build,
|
|
813
|
+
Plan,
|
|
814
|
+
CycleMode,
|
|
815
|
+
}
|
|
816
|
+
|
|
485
817
|
#[cfg(test)]
|
|
486
818
|
mod tests {
|
|
487
819
|
use super::*;
|
|
488
820
|
use serde_json::json;
|
|
489
821
|
|
|
490
|
-
// ── permission ──────────────────────────────────────────────────────────
|
|
491
822
|
#[test]
|
|
492
823
|
fn permission_parsing() {
|
|
493
824
|
assert!(matches!(permission("auto"), Permission::Auto));
|
|
825
|
+
assert!(matches!(permission("acceptEdits"), Permission::Auto));
|
|
826
|
+
assert!(matches!(permission("bypass-permissions"), Permission::Auto));
|
|
494
827
|
assert!(matches!(permission("readonly"), Permission::ReadOnly));
|
|
828
|
+
assert!(matches!(permission("read-only"), Permission::ReadOnly));
|
|
829
|
+
assert!(matches!(permission("plan"), Permission::ReadOnly));
|
|
495
830
|
assert!(matches!(permission("ask"), Permission::Ask));
|
|
496
831
|
assert!(matches!(permission("anything-else"), Permission::Ask));
|
|
497
832
|
assert!(matches!(permission(""), Permission::Ask));
|
|
498
833
|
}
|
|
499
834
|
|
|
500
|
-
// ── role ────────────────────────────────────────────────────────────────
|
|
501
835
|
#[test]
|
|
502
836
|
fn role_selection() {
|
|
503
837
|
assert!(role("researcher").system.contains("research"));
|
|
504
838
|
assert!(role("engineer").system.contains("software engineer"));
|
|
505
|
-
// Unknown roles fall back to the engineer prompt.
|
|
506
839
|
assert!(role("ceo").system.contains("software engineer"));
|
|
507
840
|
}
|
|
508
841
|
|
|
509
|
-
// ── gate ────────────────────────────────────────────────────────────────
|
|
510
|
-
// These run with a non-terminal stdin, so any path that would prompt resolves
|
|
511
|
-
// to a denial (Some(reason)) rather than blocking.
|
|
512
842
|
#[test]
|
|
513
843
|
fn gate_auto_allows_ordinary_mutation() {
|
|
514
844
|
let cwd = Path::new("/proj");
|
|
@@ -519,7 +849,6 @@ mod tests {
|
|
|
519
849
|
#[test]
|
|
520
850
|
fn gate_auto_still_confirms_sensitive_path() {
|
|
521
851
|
let cwd = Path::new("/proj");
|
|
522
|
-
// Sensitive even under auto → would prompt → denied in non-terminal tests.
|
|
523
852
|
let r = gate(Permission::Auto, "read_file", &json!({"path": "/proj/.env"}), cwd);
|
|
524
853
|
assert!(r.is_some());
|
|
525
854
|
}
|
|
@@ -546,38 +875,48 @@ mod tests {
|
|
|
546
875
|
}
|
|
547
876
|
|
|
548
877
|
#[test]
|
|
549
|
-
fn
|
|
878
|
+
fn gate_readonly_allows_reads_everywhere() {
|
|
879
|
+
// Reads outside CWD are now allowed — full filesystem access.
|
|
550
880
|
let cwd = Path::new("/proj/work");
|
|
551
881
|
let r = gate(Permission::ReadOnly, "read_file", &json!({"path": "/etc/passwd"}), cwd);
|
|
552
|
-
assert!(r.
|
|
882
|
+
assert!(r.is_none());
|
|
553
883
|
}
|
|
554
884
|
|
|
555
885
|
#[test]
|
|
556
|
-
fn
|
|
557
|
-
|
|
558
|
-
let
|
|
886
|
+
fn gate_ask_allows_out_of_cwd_read() {
|
|
887
|
+
// Full filesystem read access — no longer blocked in Ask mode.
|
|
888
|
+
let cwd = Path::new("/proj");
|
|
889
|
+
let r = gate(Permission::Ask, "read_file", &json!({"path": "/home/user/docs/README.md"}), cwd);
|
|
559
890
|
assert!(r.is_none());
|
|
560
891
|
}
|
|
561
892
|
|
|
562
893
|
#[test]
|
|
563
894
|
fn gate_ask_prompts_for_mutation() {
|
|
564
895
|
let cwd = Path::new("/proj");
|
|
565
|
-
// Would prompt → denied in non-terminal test env.
|
|
566
896
|
let r = gate(Permission::Ask, "write_file", &json!({"path": "a", "content": "x"}), cwd);
|
|
567
|
-
assert!(r.is_some());
|
|
897
|
+
assert!(r.is_some()); // non-terminal → denied
|
|
568
898
|
}
|
|
569
899
|
|
|
570
900
|
#[test]
|
|
571
|
-
fn
|
|
901
|
+
fn gate_ask_allows_default_allowed_commands() {
|
|
572
902
|
let cwd = Path::new("/proj");
|
|
573
|
-
|
|
574
|
-
|
|
903
|
+
// git, cargo, npm are in the default allowed_commands list
|
|
904
|
+
for cmd in &["git status", "cargo test", "npm install", "git commit -m 'x'"] {
|
|
905
|
+
let r = gate(Permission::Ask, "run_command", &json!({"command": cmd}), cwd);
|
|
906
|
+
assert!(r.is_none(), "expected {cmd} to be auto-allowed in Ask mode");
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
#[test]
|
|
911
|
+
fn gate_ask_still_prompts_for_unknown_command() {
|
|
912
|
+
let cwd = Path::new("/proj");
|
|
913
|
+
let r = gate(Permission::Ask, "run_command", &json!({"command": "mycustombinary --flag"}), cwd);
|
|
914
|
+
assert!(r.is_some()); // not in allowed list → denied (non-terminal)
|
|
575
915
|
}
|
|
576
916
|
|
|
577
|
-
// ── compaction ──────────────────────────────────────────────────────────
|
|
578
917
|
#[test]
|
|
579
918
|
fn estimate_tokens_counts_text() {
|
|
580
|
-
assert_eq!(estimate_tokens(&[Msg::User("a".repeat(40))]), 10);
|
|
919
|
+
assert_eq!(estimate_tokens(&[Msg::User("a".repeat(40))]), 10);
|
|
581
920
|
}
|
|
582
921
|
|
|
583
922
|
#[test]
|
|
@@ -605,10 +944,9 @@ mod tests {
|
|
|
605
944
|
msgs.push(Msg::User(format!("u{i}")));
|
|
606
945
|
}
|
|
607
946
|
let out = compact_with(msgs, |_| "SUMMARY".into());
|
|
608
|
-
assert_eq!(out.len(), 1 + 1 + KEEP_RECENT);
|
|
947
|
+
assert_eq!(out.len(), 1 + 1 + KEEP_RECENT);
|
|
609
948
|
assert!(matches!(&out[0], Msg::System(s) if s == "sys"));
|
|
610
949
|
assert!(matches!(&out[1], Msg::User(s) if s.contains("SUMMARY")));
|
|
611
|
-
// The recent tail is preserved verbatim.
|
|
612
950
|
assert!(matches!(out.last(), Some(Msg::User(s)) if s == "u9"));
|
|
613
951
|
}
|
|
614
952
|
|