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