buildwithnexus 0.10.3 → 0.10.6
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 +1 -0
- package/harness/Cargo.lock +6 -5
- package/harness/Cargo.toml +2 -8
- package/harness/src/agent.rs +2665 -248
- package/harness/src/bundled_skills/code-review.md +17 -0
- package/harness/src/bundled_skills/codebase-repair.md +32 -0
- package/harness/src/bundled_skills/data-analysis.md +16 -0
- package/harness/src/bundled_skills/decision_support.md +59 -0
- package/harness/src/bundled_skills/document-generation.md +29 -0
- package/harness/src/bundled_skills/frontend-ux.md +18 -0
- package/harness/src/bundled_skills/git-release.md +23 -0
- package/harness/src/bundled_skills/release-notes.md +17 -0
- package/harness/src/bundled_skills/research.md +17 -0
- package/harness/src/bundled_skills/rust-cli.md +26 -0
- package/harness/src/bundled_skills/security-review.md +19 -0
- package/harness/src/bundled_skills/self-knowledge.md +104 -0
- package/harness/src/bundled_skills/spec-writing.md +19 -0
- package/harness/src/bundled_skills/static-app.md +36 -0
- package/harness/src/bundled_skills/test-engineering.md +18 -0
- package/harness/src/bundled_skills/tool-use.md +52 -0
- package/harness/src/bundled_skills/verification_workflow.md +62 -0
- package/harness/src/checkpoint.rs +201 -0
- package/harness/src/config.rs +533 -88
- package/harness/src/hooks.rs +306 -52
- package/harness/src/knowledge.rs +433 -0
- package/harness/src/lib.rs +2378 -243
- package/harness/src/local.rs +13 -4
- package/harness/src/onboarding.rs +134 -22
- package/harness/src/provider.rs +346 -79
- package/harness/src/report.rs +37 -13
- package/harness/src/rules.rs +467 -0
- package/harness/src/session.rs +36 -9
- package/harness/src/tools.rs +3267 -145
- package/harness/src/trace.rs +218 -0
- package/harness/src/tui.rs +2169 -268
- package/harness/src/verifier.rs +375 -0
- package/harness/src/workflow.rs +72 -32
- package/package.json +3 -3
- package/scripts/postinstall.js +6 -1
package/harness/src/agent.rs
CHANGED
|
@@ -3,21 +3,27 @@
|
|
|
3
3
|
// but ALL modes have access to the full tool surface so the model can grep,
|
|
4
4
|
// fetch, read files, and run commands regardless of which mode the user is in.
|
|
5
5
|
|
|
6
|
+
use std::collections::{HashMap, HashSet};
|
|
6
7
|
use std::io::IsTerminal;
|
|
7
8
|
use std::path::{Path, PathBuf};
|
|
8
9
|
use std::process::Command;
|
|
9
10
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
11
|
+
use std::sync::Mutex;
|
|
10
12
|
|
|
11
13
|
use crate::config;
|
|
12
14
|
use crate::hooks::{self, PreDecision};
|
|
13
15
|
use crate::provider::{self, complete, Msg, Provider, Reply, ToolResult};
|
|
14
16
|
use crate::report;
|
|
15
17
|
use crate::tools;
|
|
18
|
+
use crate::trace;
|
|
16
19
|
use crate::tui;
|
|
17
20
|
|
|
18
21
|
const MAX_ITERS: usize = 30;
|
|
19
22
|
const MAX_DEPTH: usize = 3;
|
|
20
23
|
const KEEP_RECENT: usize = 6;
|
|
24
|
+
const MAX_IDENTICAL_TOOL_RESULTS: usize = 2;
|
|
25
|
+
const MAX_PLAN_TOOL_ROUNDS: usize = 10;
|
|
26
|
+
const MAX_CHAT_TOOL_ROUNDS: usize = 12;
|
|
21
27
|
|
|
22
28
|
// ── context compaction ────────────────────────────────────────────────────────
|
|
23
29
|
fn estimate_tokens(msgs: &[Msg]) -> usize {
|
|
@@ -25,9 +31,15 @@ fn estimate_tokens(msgs: &[Msg]) -> usize {
|
|
|
25
31
|
for m in msgs {
|
|
26
32
|
chars += match m {
|
|
27
33
|
Msg::System(s) | Msg::User(s) => s.len(),
|
|
28
|
-
Msg::UserImages { text, images } =>
|
|
34
|
+
Msg::UserImages { text, images } => {
|
|
35
|
+
text.len() + images.iter().map(|(_, d)| d.len() / 3).sum::<usize>()
|
|
36
|
+
}
|
|
29
37
|
Msg::Assistant { text, calls } => {
|
|
30
|
-
text.len()
|
|
38
|
+
text.len()
|
|
39
|
+
+ calls
|
|
40
|
+
.iter()
|
|
41
|
+
.map(|c| c.name.len() + c.input.to_string().len())
|
|
42
|
+
.sum::<usize>()
|
|
31
43
|
}
|
|
32
44
|
Msg::Tool(rs) => rs.iter().map(|r| r.content.len()).sum(),
|
|
33
45
|
};
|
|
@@ -36,7 +48,10 @@ fn estimate_tokens(msgs: &[Msg]) -> usize {
|
|
|
36
48
|
}
|
|
37
49
|
|
|
38
50
|
fn compaction_split(msgs: &[Msg]) -> (usize, usize) {
|
|
39
|
-
let sys_end = msgs
|
|
51
|
+
let sys_end = msgs
|
|
52
|
+
.iter()
|
|
53
|
+
.take_while(|m| matches!(m, Msg::System(_)))
|
|
54
|
+
.count();
|
|
40
55
|
let tail_start = msgs.len().saturating_sub(KEEP_RECENT).max(sys_end);
|
|
41
56
|
(sys_end, tail_start)
|
|
42
57
|
}
|
|
@@ -57,12 +72,30 @@ fn structural_summary(middle: &[Msg]) -> String {
|
|
|
57
72
|
}
|
|
58
73
|
}
|
|
59
74
|
|
|
75
|
+
fn assistant_tool_turn_text(text: String, calls: &[provider::ToolCall]) -> String {
|
|
76
|
+
if calls.is_empty() || text.len() <= 500 {
|
|
77
|
+
return text;
|
|
78
|
+
}
|
|
79
|
+
let actions = calls
|
|
80
|
+
.iter()
|
|
81
|
+
.map(|c| tools::preview(&c.name, &c.input))
|
|
82
|
+
.collect::<Vec<_>>()
|
|
83
|
+
.join("; ");
|
|
84
|
+
format!("Requested tool call(s): {actions}")
|
|
85
|
+
}
|
|
86
|
+
|
|
60
87
|
fn render_msgs(msgs: &[Msg]) -> String {
|
|
61
88
|
let mut s = String::new();
|
|
62
89
|
for m in msgs {
|
|
63
90
|
match m {
|
|
64
|
-
Msg::System(t) => {
|
|
65
|
-
|
|
91
|
+
Msg::System(t) => {
|
|
92
|
+
s.push_str("system: ");
|
|
93
|
+
s.push_str(t);
|
|
94
|
+
}
|
|
95
|
+
Msg::User(t) => {
|
|
96
|
+
s.push_str("user: ");
|
|
97
|
+
s.push_str(t);
|
|
98
|
+
}
|
|
66
99
|
Msg::UserImages { text, images } => {
|
|
67
100
|
s.push_str(&format!("user [+{} image(s)]: ", images.len()));
|
|
68
101
|
s.push_str(text);
|
|
@@ -88,6 +121,8 @@ fn render_msgs(msgs: &[Msg]) -> String {
|
|
|
88
121
|
|
|
89
122
|
fn compact_with(msgs: Vec<Msg>, summarize: impl FnOnce(&[Msg]) -> String) -> Vec<Msg> {
|
|
90
123
|
let (sys_end, tail_start) = compaction_split(&msgs);
|
|
124
|
+
// Even if there's nothing to summarize, truncate bloated tool results
|
|
125
|
+
let msgs = truncate_tool_results(msgs);
|
|
91
126
|
if tail_start <= sys_end {
|
|
92
127
|
return msgs;
|
|
93
128
|
}
|
|
@@ -97,11 +132,52 @@ fn compact_with(msgs: Vec<Msg>, summarize: impl FnOnce(&[Msg]) -> String) -> Vec
|
|
|
97
132
|
let tail: Vec<Msg> = it.collect();
|
|
98
133
|
let summary = summarize(&middle);
|
|
99
134
|
let mut v = system;
|
|
100
|
-
v.push(Msg::User(format!(
|
|
135
|
+
v.push(Msg::User(format!(
|
|
136
|
+
"[Summary of earlier conversation, compacted to save context]\n{summary}"
|
|
137
|
+
)));
|
|
101
138
|
v.extend(tail);
|
|
102
139
|
v
|
|
103
140
|
}
|
|
104
141
|
|
|
142
|
+
/// Truncate oversized tool result contents to keep context manageable.
|
|
143
|
+
/// Any individual tool result > 800 chars gets clipped.
|
|
144
|
+
fn truncate_tool_results(msgs: Vec<Msg>) -> Vec<Msg> {
|
|
145
|
+
const MAX_RESULT_CHARS: usize = 800;
|
|
146
|
+
msgs.into_iter()
|
|
147
|
+
.map(|m| match m {
|
|
148
|
+
Msg::Tool(results) => Msg::Tool(
|
|
149
|
+
results
|
|
150
|
+
.into_iter()
|
|
151
|
+
.map(|mut r| {
|
|
152
|
+
if r.content.len() > MAX_RESULT_CHARS {
|
|
153
|
+
let truncated: String =
|
|
154
|
+
r.content.chars().take(MAX_RESULT_CHARS).collect();
|
|
155
|
+
r.content = format!(
|
|
156
|
+
"{truncated}\n…(truncated, {} chars total)",
|
|
157
|
+
r.content.len()
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
r
|
|
161
|
+
})
|
|
162
|
+
.collect(),
|
|
163
|
+
),
|
|
164
|
+
Msg::Assistant { text, calls } => {
|
|
165
|
+
// Also trim overly verbose assistant text in tool turns
|
|
166
|
+
let trimmed = if text.len() > 1200 && !calls.is_empty() {
|
|
167
|
+
assistant_tool_turn_text(text, &calls)
|
|
168
|
+
} else {
|
|
169
|
+
text
|
|
170
|
+
};
|
|
171
|
+
Msg::Assistant {
|
|
172
|
+
text: trimmed,
|
|
173
|
+
calls,
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
other => other,
|
|
177
|
+
})
|
|
178
|
+
.collect()
|
|
179
|
+
}
|
|
180
|
+
|
|
105
181
|
fn model_summary(p: &Provider, middle: &[Msg]) -> String {
|
|
106
182
|
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.";
|
|
107
183
|
let q = vec![Msg::System(sys.into()), Msg::User(render_msgs(middle))];
|
|
@@ -125,6 +201,571 @@ fn maybe_compact(p: &Provider, msgs: &mut Vec<Msg>) {
|
|
|
125
201
|
*msgs = compact_with(taken, |middle| model_summary(p, middle));
|
|
126
202
|
}
|
|
127
203
|
|
|
204
|
+
#[derive(Default)]
|
|
205
|
+
struct ToolLoopGuard {
|
|
206
|
+
seen: HashMap<String, ToolLoopRecord>,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
struct ToolLoopRecord {
|
|
210
|
+
content: String,
|
|
211
|
+
is_error: bool,
|
|
212
|
+
count: usize,
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
impl ToolLoopGuard {
|
|
216
|
+
fn note(
|
|
217
|
+
&mut self,
|
|
218
|
+
name: &str,
|
|
219
|
+
input: &serde_json::Value,
|
|
220
|
+
content: &str,
|
|
221
|
+
is_error: bool,
|
|
222
|
+
) -> Option<String> {
|
|
223
|
+
let key = format!(
|
|
224
|
+
"{name}:{}",
|
|
225
|
+
serde_json::to_string(input).unwrap_or_default()
|
|
226
|
+
);
|
|
227
|
+
let rec = self.seen.entry(key).or_insert_with(|| ToolLoopRecord {
|
|
228
|
+
content: String::new(),
|
|
229
|
+
is_error,
|
|
230
|
+
count: 0,
|
|
231
|
+
});
|
|
232
|
+
if rec.content == content && rec.is_error == is_error {
|
|
233
|
+
rec.count += 1;
|
|
234
|
+
} else {
|
|
235
|
+
rec.content = content.to_string();
|
|
236
|
+
rec.is_error = is_error;
|
|
237
|
+
rec.count = 1;
|
|
238
|
+
}
|
|
239
|
+
if rec.count >= MAX_IDENTICAL_TOOL_RESULTS {
|
|
240
|
+
Some(repeated_tool_summary(name, input, content, is_error))
|
|
241
|
+
} else {
|
|
242
|
+
None
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
fn repeated_tool_summary(
|
|
248
|
+
name: &str,
|
|
249
|
+
input: &serde_json::Value,
|
|
250
|
+
content: &str,
|
|
251
|
+
is_error: bool,
|
|
252
|
+
) -> String {
|
|
253
|
+
let preview = tools::preview(name, input);
|
|
254
|
+
let shown = content
|
|
255
|
+
.lines()
|
|
256
|
+
.filter(|l| !l.trim().is_empty())
|
|
257
|
+
.take(20)
|
|
258
|
+
.collect::<Vec<_>>()
|
|
259
|
+
.join("\n");
|
|
260
|
+
if is_error {
|
|
261
|
+
format!(
|
|
262
|
+
"I stopped a repeated tool loop. `{preview}` returned the same error more than once:\n{shown}"
|
|
263
|
+
)
|
|
264
|
+
} else if shown.is_empty() {
|
|
265
|
+
format!("I stopped a repeated tool loop. `{preview}` returned no output more than once.")
|
|
266
|
+
} else {
|
|
267
|
+
format!(
|
|
268
|
+
"I stopped a repeated tool loop. `{preview}` returned the same result more than once, so here is the result I found:\n{shown}"
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
fn note_loop_result(
|
|
274
|
+
guard: &mut ToolLoopGuard,
|
|
275
|
+
name: &str,
|
|
276
|
+
input: &serde_json::Value,
|
|
277
|
+
content: &str,
|
|
278
|
+
is_error: bool,
|
|
279
|
+
summary: &mut Option<String>,
|
|
280
|
+
) {
|
|
281
|
+
if summary.is_some() {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if let Some(loop_msg) = guard.note(name, input, content, is_error) {
|
|
285
|
+
report::assistant(&loop_msg);
|
|
286
|
+
*summary = Some(loop_msg);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
fn tool_input_for_execution(
|
|
291
|
+
name: &str,
|
|
292
|
+
input: &serde_json::Value,
|
|
293
|
+
cwd: &Path,
|
|
294
|
+
phase: &str,
|
|
295
|
+
depth: usize,
|
|
296
|
+
task: &str,
|
|
297
|
+
) -> serde_json::Value {
|
|
298
|
+
let Some(repaired) = repair_placeholder_tool_input(name, input, cwd, task) else {
|
|
299
|
+
return input.clone();
|
|
300
|
+
};
|
|
301
|
+
report::notice(&format!(
|
|
302
|
+
" recovery: using current workspace for placeholder/root path in {name}"
|
|
303
|
+
));
|
|
304
|
+
trace::record_visible(
|
|
305
|
+
"tool_input_repaired",
|
|
306
|
+
format!("{name} path repaired"),
|
|
307
|
+
serde_json::json!({
|
|
308
|
+
"tool": name,
|
|
309
|
+
"original": input,
|
|
310
|
+
"repaired": &repaired,
|
|
311
|
+
"cwd": cwd.to_string_lossy(),
|
|
312
|
+
"phase": phase,
|
|
313
|
+
"depth": depth,
|
|
314
|
+
}),
|
|
315
|
+
);
|
|
316
|
+
repaired
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
fn repair_placeholder_tool_input(
|
|
320
|
+
name: &str,
|
|
321
|
+
input: &serde_json::Value,
|
|
322
|
+
cwd: &Path,
|
|
323
|
+
task: &str,
|
|
324
|
+
) -> Option<serde_json::Value> {
|
|
325
|
+
if matches!(name, "write" | "write_file") {
|
|
326
|
+
if let Some(repaired) = repair_write_file_input(input, task) {
|
|
327
|
+
return Some(repaired);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
let keys: &[&str] = match name {
|
|
332
|
+
"list" | "list_dir" | "list_tree" | "file_info" => &["path", "filePath"],
|
|
333
|
+
"glob" | "grep" => &["root", "path"],
|
|
334
|
+
"find_paths" | "find_files" | "grep_files" => &["root"],
|
|
335
|
+
_ => return None,
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
let mut repaired = input.clone();
|
|
339
|
+
let mut changed = false;
|
|
340
|
+
let cwd_str = cwd.to_string_lossy().to_string();
|
|
341
|
+
for key in keys {
|
|
342
|
+
if let Some(value) = repaired.get_mut(*key) {
|
|
343
|
+
let Some(path) = value.as_str() else {
|
|
344
|
+
continue;
|
|
345
|
+
};
|
|
346
|
+
if is_placeholder_path(path) || should_repair_filesystem_root(path, task) {
|
|
347
|
+
*value = serde_json::Value::String(cwd_str.clone());
|
|
348
|
+
changed = true;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
changed.then_some(repaired)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
fn repair_write_file_input(input: &serde_json::Value, task: &str) -> Option<serde_json::Value> {
|
|
356
|
+
let requested = auto_create_file_input(task)?;
|
|
357
|
+
let requested_path = requested["path"].as_str().unwrap_or("");
|
|
358
|
+
let requested_content = requested["content"].as_str().unwrap_or("");
|
|
359
|
+
let path = input
|
|
360
|
+
.get("path")
|
|
361
|
+
.or_else(|| input.get("filePath"))
|
|
362
|
+
.and_then(|v| v.as_str())
|
|
363
|
+
.unwrap_or("");
|
|
364
|
+
let content = input
|
|
365
|
+
.get("content")
|
|
366
|
+
.or_else(|| input.get("contents"))
|
|
367
|
+
.and_then(|v| v.as_str())
|
|
368
|
+
.unwrap_or("");
|
|
369
|
+
let should_repair = path.trim().is_empty()
|
|
370
|
+
|| matches!(path.trim(), "~" | "." | "/" | "./")
|
|
371
|
+
|| is_placeholder_path(path)
|
|
372
|
+
|| (path == requested_path && content != requested_content);
|
|
373
|
+
should_repair.then_some(requested)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
fn should_repair_filesystem_root(path: &str, task: &str) -> bool {
|
|
377
|
+
path.trim() == "/"
|
|
378
|
+
&& task_targets_current_workspace(task)
|
|
379
|
+
&& !task_explicitly_targets_root(task)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
fn task_targets_current_workspace(task: &str) -> bool {
|
|
383
|
+
let lower = task.to_ascii_lowercase();
|
|
384
|
+
[
|
|
385
|
+
"this project",
|
|
386
|
+
"current project",
|
|
387
|
+
"the project",
|
|
388
|
+
"this repo",
|
|
389
|
+
"current repo",
|
|
390
|
+
"repository",
|
|
391
|
+
"workspace",
|
|
392
|
+
"codebase",
|
|
393
|
+
"top-level files",
|
|
394
|
+
"top level files",
|
|
395
|
+
]
|
|
396
|
+
.iter()
|
|
397
|
+
.any(|needle| lower.contains(needle))
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
fn task_explicitly_targets_root(task: &str) -> bool {
|
|
401
|
+
let lower = task.to_ascii_lowercase();
|
|
402
|
+
[
|
|
403
|
+
"filesystem root",
|
|
404
|
+
"file system root",
|
|
405
|
+
"root filesystem",
|
|
406
|
+
"root file system",
|
|
407
|
+
"list /",
|
|
408
|
+
"inspect /",
|
|
409
|
+
"search /",
|
|
410
|
+
"from /",
|
|
411
|
+
"under /",
|
|
412
|
+
"starting at /",
|
|
413
|
+
"starting from /",
|
|
414
|
+
]
|
|
415
|
+
.iter()
|
|
416
|
+
.any(|needle| lower.contains(needle))
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
fn is_placeholder_path(path: &str) -> bool {
|
|
420
|
+
let lower = path.trim().to_ascii_lowercase();
|
|
421
|
+
if lower.is_empty() || lower == "." || lower == "./" || lower == "~" {
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
lower.contains("/path/to/")
|
|
425
|
+
|| lower.contains("path/to/your")
|
|
426
|
+
|| lower.contains("your/project")
|
|
427
|
+
|| lower.contains("your-project")
|
|
428
|
+
|| lower.contains("your_project")
|
|
429
|
+
|| lower.contains("project-directory")
|
|
430
|
+
|| lower.contains("project_directory")
|
|
431
|
+
|| matches!(
|
|
432
|
+
lower.as_str(),
|
|
433
|
+
"/path/to/project" | "path/to/project" | "<project-path>" | "{project-path}"
|
|
434
|
+
)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
#[derive(Default)]
|
|
438
|
+
struct ThinkingStream {
|
|
439
|
+
active: bool,
|
|
440
|
+
step: usize,
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
impl ThinkingStream {
|
|
444
|
+
fn push(&mut self, chunk: &str) {
|
|
445
|
+
if report::is_json() {
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
tui::poll_typeahead();
|
|
449
|
+
let mut rest = chunk.replace('\r', "");
|
|
450
|
+
if rest.is_empty() {
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
while let Some(nl) = rest.find('\n') {
|
|
454
|
+
let part = rest[..nl].trim_end();
|
|
455
|
+
if !part.is_empty() || self.active {
|
|
456
|
+
self.start();
|
|
457
|
+
tui::write_stream(&tui::dim(part));
|
|
458
|
+
tui::write_stream("\n");
|
|
459
|
+
self.active = false;
|
|
460
|
+
}
|
|
461
|
+
rest = rest[nl + 1..].to_string();
|
|
462
|
+
}
|
|
463
|
+
if !rest.is_empty() {
|
|
464
|
+
self.start();
|
|
465
|
+
tui::write_stream(&tui::dim(&rest));
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
fn finish(&mut self) {
|
|
470
|
+
if self.active {
|
|
471
|
+
tui::write_stream("\n");
|
|
472
|
+
self.active = false;
|
|
473
|
+
}
|
|
474
|
+
tui::render_queued_composer();
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
fn start(&mut self) {
|
|
478
|
+
if !self.active {
|
|
479
|
+
let spinners = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
480
|
+
let s = spinners[self.step % spinners.len()];
|
|
481
|
+
self.step += 1;
|
|
482
|
+
tui::write_stream(&format!(" {} {} ", tui::cyan(s), tui::dim("thinking ›")));
|
|
483
|
+
self.active = true;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
fn request_reply(
|
|
489
|
+
p: &Provider,
|
|
490
|
+
msgs: &[Msg],
|
|
491
|
+
defs: &[tools::ToolDef],
|
|
492
|
+
label: &str,
|
|
493
|
+
) -> Result<Reply, String> {
|
|
494
|
+
if report::is_json() {
|
|
495
|
+
let r = complete(p, msgs, defs)?;
|
|
496
|
+
report::assistant(&r.text);
|
|
497
|
+
return Ok(r);
|
|
498
|
+
}
|
|
499
|
+
tui::line(&format!(
|
|
500
|
+
" {} {} · {} [{}]",
|
|
501
|
+
tui::yellow("⚡"),
|
|
502
|
+
tui::bold(label),
|
|
503
|
+
tui::cyan(&format!("reasoning with {}", p.model)),
|
|
504
|
+
tui::dim(&format!("{} tools available", defs.len()))
|
|
505
|
+
));
|
|
506
|
+
let thinking = std::cell::RefCell::new(ThinkingStream::default());
|
|
507
|
+
let mut streamed = false;
|
|
508
|
+
let mut renderer = tui::StreamRenderer::new();
|
|
509
|
+
let res = provider::stream(
|
|
510
|
+
p,
|
|
511
|
+
msgs,
|
|
512
|
+
defs,
|
|
513
|
+
&mut |c| {
|
|
514
|
+
thinking.borrow_mut().finish();
|
|
515
|
+
renderer.push(c);
|
|
516
|
+
tui::poll_typeahead();
|
|
517
|
+
tui::render_queued_composer();
|
|
518
|
+
streamed = true;
|
|
519
|
+
},
|
|
520
|
+
&mut |t| {
|
|
521
|
+
thinking.borrow_mut().push(t);
|
|
522
|
+
},
|
|
523
|
+
);
|
|
524
|
+
thinking.borrow_mut().finish();
|
|
525
|
+
let r = res?;
|
|
526
|
+
renderer.flush();
|
|
527
|
+
if streamed {
|
|
528
|
+
report::assistant_end();
|
|
529
|
+
}
|
|
530
|
+
Ok(r)
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
static TEXT_TOOL_SEQ: AtomicUsize = AtomicUsize::new(0);
|
|
534
|
+
|
|
535
|
+
fn normalize_text_tool_calls(mut reply: Reply, defs: &[tools::ToolDef], user_text: &str) -> Reply {
|
|
536
|
+
if !reply.calls.is_empty() {
|
|
537
|
+
return reply;
|
|
538
|
+
}
|
|
539
|
+
let Some(calls) = parse_text_tool_calls(&reply.text, defs) else {
|
|
540
|
+
return reply;
|
|
541
|
+
};
|
|
542
|
+
if is_casual_turn(user_text)
|
|
543
|
+
&& calls
|
|
544
|
+
.iter()
|
|
545
|
+
.any(|call| tools::is_mutating_call(&call.name, &call.input))
|
|
546
|
+
{
|
|
547
|
+
if !report::is_json() {
|
|
548
|
+
report::notice(" recovery: ignored unrelated tool JSON for casual input");
|
|
549
|
+
}
|
|
550
|
+
trace::record_visible(
|
|
551
|
+
"tool_input_repaired",
|
|
552
|
+
"ignored casual-turn tool JSON",
|
|
553
|
+
serde_json::json!({"calls": calls.iter().map(|c| &c.name).collect::<Vec<_>>()}),
|
|
554
|
+
);
|
|
555
|
+
reply.text = "Hi. I am here and ready. Tell me what you want to build, inspect, or change."
|
|
556
|
+
.to_string();
|
|
557
|
+
reply.calls.clear();
|
|
558
|
+
if !report::is_json() {
|
|
559
|
+
report::assistant(&reply.text);
|
|
560
|
+
}
|
|
561
|
+
return reply;
|
|
562
|
+
}
|
|
563
|
+
if !report::is_json() {
|
|
564
|
+
report::notice(&format!(
|
|
565
|
+
" recovery: parsed {} tool call{} from model JSON",
|
|
566
|
+
calls.len(),
|
|
567
|
+
if calls.len() == 1 { "" } else { "s" }
|
|
568
|
+
));
|
|
569
|
+
}
|
|
570
|
+
trace::record_visible(
|
|
571
|
+
"tool_input_repaired",
|
|
572
|
+
"parsed text JSON tool call",
|
|
573
|
+
serde_json::json!({"calls": calls.iter().map(|c| &c.name).collect::<Vec<_>>()}),
|
|
574
|
+
);
|
|
575
|
+
reply.text.clear();
|
|
576
|
+
reply.calls = calls;
|
|
577
|
+
reply
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
fn is_casual_turn(text: &str) -> bool {
|
|
581
|
+
let normalized = text
|
|
582
|
+
.trim()
|
|
583
|
+
.trim_matches(|c: char| c.is_ascii_punctuation() || c.is_whitespace())
|
|
584
|
+
.to_ascii_lowercase();
|
|
585
|
+
matches!(
|
|
586
|
+
normalized.as_str(),
|
|
587
|
+
"hi" | "hello"
|
|
588
|
+
| "hey"
|
|
589
|
+
| "yo"
|
|
590
|
+
| "sup"
|
|
591
|
+
| "thanks"
|
|
592
|
+
| "thank you"
|
|
593
|
+
| "ok"
|
|
594
|
+
| "okay"
|
|
595
|
+
| "cool"
|
|
596
|
+
| "nice"
|
|
597
|
+
)
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
fn parse_text_tool_calls(text: &str, defs: &[tools::ToolDef]) -> Option<Vec<provider::ToolCall>> {
|
|
601
|
+
let candidate = extract_json_tool_candidate(text)?;
|
|
602
|
+
let value = serde_json::from_str::<serde_json::Value>(candidate).ok()?;
|
|
603
|
+
let names = defs.iter().map(|d| d.name).collect::<HashSet<_>>();
|
|
604
|
+
let mut calls = Vec::new();
|
|
605
|
+
collect_text_tool_calls(&value, &names, &mut calls);
|
|
606
|
+
(!calls.is_empty()).then_some(calls)
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
fn extract_json_tool_candidate(text: &str) -> Option<&str> {
|
|
610
|
+
let trimmed = text.trim();
|
|
611
|
+
if trimmed.starts_with('{') || trimmed.starts_with('[') {
|
|
612
|
+
return Some(trimmed);
|
|
613
|
+
}
|
|
614
|
+
let rest = trimmed.strip_prefix("```")?;
|
|
615
|
+
let fence_end = rest.find('\n')?;
|
|
616
|
+
let lang = rest[..fence_end].trim().to_ascii_lowercase();
|
|
617
|
+
if !lang.is_empty() && lang != "json" {
|
|
618
|
+
return None;
|
|
619
|
+
}
|
|
620
|
+
let body = &rest[fence_end + 1..];
|
|
621
|
+
let close = body.rfind("```")?;
|
|
622
|
+
let after = body[close + 3..].trim();
|
|
623
|
+
if !after.is_empty() {
|
|
624
|
+
return None;
|
|
625
|
+
}
|
|
626
|
+
Some(body[..close].trim())
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
fn collect_text_tool_calls(
|
|
630
|
+
value: &serde_json::Value,
|
|
631
|
+
allowed: &HashSet<&'static str>,
|
|
632
|
+
out: &mut Vec<provider::ToolCall>,
|
|
633
|
+
) {
|
|
634
|
+
if let Some(items) = value.as_array() {
|
|
635
|
+
for item in items {
|
|
636
|
+
collect_text_tool_calls(item, allowed, out);
|
|
637
|
+
}
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
let Some(obj) = value.as_object() else {
|
|
641
|
+
return;
|
|
642
|
+
};
|
|
643
|
+
if let Some(items) = obj.get("tool_calls").and_then(|v| v.as_array()) {
|
|
644
|
+
for item in items {
|
|
645
|
+
collect_text_tool_calls(item, allowed, out);
|
|
646
|
+
}
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
if let Some(items) = obj.get("calls").and_then(|v| v.as_array()) {
|
|
650
|
+
for item in items {
|
|
651
|
+
collect_text_tool_calls(item, allowed, out);
|
|
652
|
+
}
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
if let Some(function) = obj.get("function").and_then(|v| v.as_object()) {
|
|
656
|
+
let Some(name) = function.get("name").and_then(|v| v.as_str()) else {
|
|
657
|
+
return;
|
|
658
|
+
};
|
|
659
|
+
if !allowed.contains(name) {
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
let input = function
|
|
663
|
+
.get("arguments")
|
|
664
|
+
.and_then(|v| v.as_str())
|
|
665
|
+
.and_then(|s| serde_json::from_str(s).ok())
|
|
666
|
+
.unwrap_or_else(|| serde_json::json!({}));
|
|
667
|
+
out.push(text_tool_call(name, input));
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
let Some(name) = obj
|
|
671
|
+
.get("name")
|
|
672
|
+
.or_else(|| obj.get("tool_name"))
|
|
673
|
+
.and_then(|v| v.as_str())
|
|
674
|
+
else {
|
|
675
|
+
return;
|
|
676
|
+
};
|
|
677
|
+
if !allowed.contains(name) {
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
let input = obj
|
|
681
|
+
.get("arguments")
|
|
682
|
+
.or_else(|| obj.get("input"))
|
|
683
|
+
.cloned()
|
|
684
|
+
.unwrap_or_else(|| serde_json::json!({}));
|
|
685
|
+
out.push(text_tool_call(name, input));
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
fn text_tool_call(name: &str, input: serde_json::Value) -> provider::ToolCall {
|
|
689
|
+
let id = TEXT_TOOL_SEQ.fetch_add(1, Ordering::Relaxed);
|
|
690
|
+
provider::ToolCall {
|
|
691
|
+
id: format!("text-tool-{id}"),
|
|
692
|
+
name: name.to_string(),
|
|
693
|
+
input,
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
fn answer_question(input: &serde_json::Value) -> (String, bool) {
|
|
698
|
+
let question = if let Some(qs) = input["questions"].as_array() {
|
|
699
|
+
let mut acc = Vec::new();
|
|
700
|
+
for item in qs {
|
|
701
|
+
if let Some(s) = item.as_str() {
|
|
702
|
+
acc.push(s.to_string());
|
|
703
|
+
} else if let Some(s) = item["question"].as_str() {
|
|
704
|
+
acc.push(s.to_string());
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
if acc.is_empty() {
|
|
708
|
+
input["question"].as_str().unwrap_or("").to_string()
|
|
709
|
+
} else {
|
|
710
|
+
acc.join("\n")
|
|
711
|
+
}
|
|
712
|
+
} else {
|
|
713
|
+
input["question"].as_str().unwrap_or("").to_string()
|
|
714
|
+
};
|
|
715
|
+
let question = question.trim();
|
|
716
|
+
if question.is_empty() {
|
|
717
|
+
return ("question is required".to_string(), true);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
let mut options_str = String::new();
|
|
721
|
+
if let Some(opts) = input["options"].as_array() {
|
|
722
|
+
options_str.push_str("\nOptions:\n");
|
|
723
|
+
for (i, opt) in opts.iter().enumerate() {
|
|
724
|
+
if let Some(s) = opt.as_str() {
|
|
725
|
+
options_str.push_str(&format!(" {}. {}\n", i + 1, s));
|
|
726
|
+
} else {
|
|
727
|
+
let label = opt["label"].as_str().unwrap_or("");
|
|
728
|
+
let desc = opt["description"].as_str().unwrap_or("");
|
|
729
|
+
if !desc.is_empty() {
|
|
730
|
+
options_str.push_str(&format!(" {}. {} - {}\n", i + 1, label, desc));
|
|
731
|
+
} else {
|
|
732
|
+
options_str.push_str(&format!(" {}. {}\n", i + 1, label));
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
let full_prompt = format!("{}{}", question, options_str);
|
|
739
|
+
if report::is_json() || !std::io::stdin().is_terminal() {
|
|
740
|
+
return (
|
|
741
|
+
format!("blocked (no interactive terminal to answer question: {full_prompt})"),
|
|
742
|
+
true,
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
let default = input["default"].as_str().unwrap_or("").trim();
|
|
746
|
+
let prompt = if default.is_empty() {
|
|
747
|
+
format!(
|
|
748
|
+
" {} {}\n Answer: ",
|
|
749
|
+
tui::yellow("?"),
|
|
750
|
+
tui::bold(&full_prompt)
|
|
751
|
+
)
|
|
752
|
+
} else {
|
|
753
|
+
format!(
|
|
754
|
+
" {} {}\n Answer {} ",
|
|
755
|
+
tui::yellow("?"),
|
|
756
|
+
tui::bold(&full_prompt),
|
|
757
|
+
tui::dim(&format!("[{default}]"))
|
|
758
|
+
)
|
|
759
|
+
};
|
|
760
|
+
let ans = tui::ask(&prompt).unwrap_or_default();
|
|
761
|
+
let out = if ans.trim().is_empty() && !default.is_empty() {
|
|
762
|
+
default.to_string()
|
|
763
|
+
} else {
|
|
764
|
+
ans
|
|
765
|
+
};
|
|
766
|
+
(out, false)
|
|
767
|
+
}
|
|
768
|
+
|
|
128
769
|
// ── permissions ───────────────────────────────────────────────────────────────
|
|
129
770
|
#[derive(Clone, Copy)]
|
|
130
771
|
pub enum Permission {
|
|
@@ -134,9 +775,10 @@ pub enum Permission {
|
|
|
134
775
|
}
|
|
135
776
|
|
|
136
777
|
pub fn permission(s: &str) -> Permission {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
"
|
|
778
|
+
let normalized = s.trim().to_ascii_lowercase().replace(['_', '-'], "");
|
|
779
|
+
match normalized.as_str() {
|
|
780
|
+
"auto" | "acceptedits" | "acceptedit" | "bypasspermissions" => Permission::Auto,
|
|
781
|
+
"readonly" | "read" | "plan" | "dontask" => Permission::ReadOnly,
|
|
140
782
|
_ => Permission::Ask,
|
|
141
783
|
}
|
|
142
784
|
}
|
|
@@ -152,6 +794,12 @@ pub fn role(id: &str) -> Role {
|
|
|
152
794
|
_ => "You are an autonomous senior software engineer. \
|
|
153
795
|
Use the tools to inspect and modify the project directly. \
|
|
154
796
|
Prefer small, verifiable edits. Read before you write. \
|
|
797
|
+
When writing or editing files, provide the complete, fully working code. NEVER use placeholders (e.g. `// ... rest of code`). \
|
|
798
|
+
BUILD mode means execute: for any concrete build/fix/create/change request, inspect the project and make the needed edits instead of replying with a capability statement. \
|
|
799
|
+
For simple browser games, canvas demos, landing pages, prototypes, and static visual apps, build an actual runnable artifact. Prefer a single self-contained HTML file via Artifact/publish_artifact unless the user explicitly asks for a framework. NEVER search GitHub, git clone external repositories, or look for existing games online when asked to build a game, app, or prototype from scratch. You MUST write the code yourself from scratch. When asked to build or create something, YOU MUST IMMEDIATELY CALL TOOLS to create the files on disk (e.g. write_file, edit_file, run_command). DO NOT output markdown instructions, step-by-step tutorials, or code blocks in chat telling the user how to build it! YOU are the builder — execute the tool calls to build it yourself directly. \
|
|
800
|
+
For local web apps that require a dev server, use start_server, wait_for_url, inspect read_server_log if readiness fails, then open_browser when useful. \
|
|
801
|
+
If a path or file is missing, use discovery tools before asking the user. \
|
|
802
|
+
DO NOT ask the user for permission, themes, or choices unless absolutely necessary. If the user leaves something open-ended (e.g. 'pick a theme' or 'make it cool'), MAKE A REASONABLE DECISION and proceed immediately. \
|
|
155
803
|
When the task is complete, call the finish tool with a one-paragraph summary.\n\n\
|
|
156
804
|
IMPORTANT — tool discipline:\n\
|
|
157
805
|
• Before using run_command to install anything (npm install, pip install, cargo add, brew install, \
|
|
@@ -167,32 +815,130 @@ over installing an alternative.",
|
|
|
167
815
|
}
|
|
168
816
|
|
|
169
817
|
// Build the system prompt prefix from memory and skills/agents files.
|
|
170
|
-
|
|
818
|
+
// When context_tokens is small (≤16K), skip expensive sections to leave
|
|
819
|
+
// room for tool definitions + actual conversation.
|
|
820
|
+
fn context_prefix(cwd: &Path, context_tokens: usize) -> String {
|
|
821
|
+
let compact = context_tokens <= 16_384;
|
|
171
822
|
let mut parts: Vec<String> = Vec::new();
|
|
172
823
|
|
|
824
|
+
let home_str = std::env::var("HOME").unwrap_or_default();
|
|
825
|
+
let mut path_info = format!(
|
|
826
|
+
"[Environment Paths]\n\
|
|
827
|
+
Workspace (CWD): {}\n\
|
|
828
|
+
User Home (~): {}\n",
|
|
829
|
+
cwd.display(),
|
|
830
|
+
home_str
|
|
831
|
+
);
|
|
832
|
+
if cwd.to_string_lossy().contains("/tmp/") || cwd.to_string_lossy().contains("/private/tmp/") {
|
|
833
|
+
path_info.push_str(
|
|
834
|
+
"Note: The current workspace is running in a temporary directory/mount. \
|
|
835
|
+
To find personal folders or files belonging to the user, you must search starting from the User Home (~).\n"
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
parts.push(path_info);
|
|
839
|
+
|
|
173
840
|
// Always include the tool manifest so the model knows what's built-in
|
|
174
841
|
// and doesn't try to install external tools to do things we already handle.
|
|
175
842
|
parts.push(tool_manifest());
|
|
843
|
+
if !compact {
|
|
844
|
+
parts.push(visible_reasoning_policy());
|
|
845
|
+
parts.push(discovery_policy());
|
|
846
|
+
parts.push(skill_manifest());
|
|
847
|
+
}
|
|
176
848
|
|
|
177
849
|
// Probe which common system tools are actually present so the model can
|
|
178
850
|
// pick the right one without guessing or installing alternatives.
|
|
179
|
-
|
|
180
|
-
if !
|
|
181
|
-
|
|
851
|
+
// Skip for small contexts — the tool defs already dominate.
|
|
852
|
+
if !compact {
|
|
853
|
+
let env_snap = env_snapshot();
|
|
854
|
+
if !env_snap.is_empty() {
|
|
855
|
+
parts.push(format!(
|
|
856
|
+
"[Environment — tools already installed]\n{env_snap}"
|
|
857
|
+
));
|
|
858
|
+
}
|
|
182
859
|
}
|
|
183
860
|
|
|
184
861
|
if let Some(mem) = config::load_memory() {
|
|
185
|
-
|
|
862
|
+
// Memory is user-important; always include but truncate for small ctx
|
|
863
|
+
let mem_text = if compact && mem.len() > 300 {
|
|
864
|
+
format!("{}…", &mem[..300])
|
|
865
|
+
} else {
|
|
866
|
+
mem
|
|
867
|
+
};
|
|
868
|
+
parts.push(format!("[Memory from previous sessions]\n{mem_text}"));
|
|
186
869
|
}
|
|
187
870
|
if let Some(agents) = config::load_agents() {
|
|
188
|
-
|
|
871
|
+
trace::record_visible(
|
|
872
|
+
"agents",
|
|
873
|
+
"loaded Agents.md",
|
|
874
|
+
serde_json::json!({"bytes": agents.len(), "preview": trace::preview(&agents, 600)}),
|
|
875
|
+
);
|
|
876
|
+
// For small contexts, truncate Agents.md to avoid blowing the budget
|
|
877
|
+
let agents_text = if compact && agents.len() > 500 {
|
|
878
|
+
format!("{}…", &agents[..500])
|
|
879
|
+
} else {
|
|
880
|
+
agents
|
|
881
|
+
};
|
|
882
|
+
parts.push(format!("[Agent knowledge — Agents.md]\n{agents_text}"));
|
|
189
883
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
884
|
+
|
|
885
|
+
// Skip rules, knowledge, hooks, and skill descriptions for small contexts
|
|
886
|
+
if !compact {
|
|
887
|
+
// Inject active rules for operational judgment
|
|
888
|
+
let mut engine = crate::rules::RuleEngine::load_defaults();
|
|
889
|
+
let rules_dir = config::home().join("rules");
|
|
890
|
+
if let Ok(rd) = std::fs::read_dir(&rules_dir) {
|
|
891
|
+
for e in rd.flatten() {
|
|
892
|
+
if let Ok(loaded) =
|
|
893
|
+
crate::rules::RuleEngine::load_from_file(&e.path().to_string_lossy())
|
|
894
|
+
{
|
|
895
|
+
for r in loaded.rules {
|
|
896
|
+
engine.add_rule(r);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
let mut rules_summary = String::from("The following engineering constraints and business logic rules MUST be strictly enforced for operational judgment:\n");
|
|
902
|
+
for r in &engine.rules {
|
|
903
|
+
if r.enabled {
|
|
904
|
+
rules_summary.push_str(&format!(
|
|
905
|
+
"• [{}] {} — {}\n",
|
|
906
|
+
r.severity, r.id, r.description
|
|
907
|
+
));
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
parts.push(format!(
|
|
911
|
+
"[Operational Judgment — Engineering Constraints & Rules]\n{}",
|
|
912
|
+
rules_summary.trim()
|
|
913
|
+
));
|
|
914
|
+
|
|
915
|
+
// Inject structured knowledge base if present
|
|
916
|
+
let kb = crate::knowledge::KnowledgeBase::new(".");
|
|
917
|
+
if !kb.entities.is_empty() {
|
|
918
|
+
parts.push(format!(
|
|
919
|
+
"[Structured Knowledge Base — Known Project Entities]\n{}",
|
|
920
|
+
kb.generate_context_summary(&[])
|
|
921
|
+
));
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
let active_hooks = hooks::list_active();
|
|
925
|
+
if !active_hooks.is_empty() {
|
|
926
|
+
parts.push(format!("[Active Hooks]\n{}", active_hooks.join("\n")));
|
|
927
|
+
}
|
|
928
|
+
let skill_descs = config::load_skill_descriptions();
|
|
929
|
+
if !skill_descs.is_empty() {
|
|
930
|
+
let joined = skill_descs
|
|
931
|
+
.iter()
|
|
932
|
+
.map(|(name, desc)| format!("• /{name} — {desc}"))
|
|
933
|
+
.collect::<Vec<_>>()
|
|
934
|
+
.join("\n");
|
|
935
|
+
parts.push(format!(
|
|
936
|
+
"[Available skills — descriptions only]\n{joined}\n\n\
|
|
937
|
+
To use a skill, call load_skill with the skill name to load its full instructions. \
|
|
938
|
+
Only load skills that are directly relevant to the current task. \
|
|
939
|
+
Do not load skills for simple greetings or casual replies."
|
|
940
|
+
));
|
|
941
|
+
}
|
|
196
942
|
}
|
|
197
943
|
|
|
198
944
|
format!("{}\n\n", parts.join("\n\n"))
|
|
@@ -200,15 +946,44 @@ fn context_prefix() -> String {
|
|
|
200
946
|
|
|
201
947
|
fn tool_manifest() -> String {
|
|
202
948
|
"[Built-in tools — always available, no install needed]\n\
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
•
|
|
208
|
-
•
|
|
209
|
-
•
|
|
210
|
-
•
|
|
211
|
-
|
|
949
|
+
Aliases are supported: bash/read/write/edit/patch/glob/grep/list/task/todowrite/todoread/webfetch/websearch/skill/question. \
|
|
950
|
+
Use built-ins before installing anything. Use list_tree/find_paths/grep_files before guessing paths; use find_paths kind=`dir` for folders. \
|
|
951
|
+
Use start_server/list_servers/wait_for_url/read_server_log/stop_server for long-running local dev servers, and open_browser for local URLs or generated HTML.\n\n\
|
|
952
|
+
[CRITICAL TOOL DISCIPLINE]\n\
|
|
953
|
+
• For generated/edited code, HTML, or file contents, call write_file/edit_file/Artifact; never paste code as plain markdown.\n\
|
|
954
|
+
• For canvas games, browser games, standalone demos, landing pages, and small web apps: publish a complete runnable static HTML artifact with embedded CSS/JS unless a framework is explicitly requested. Include controls, restart/error states, responsive sizing, and touch/mobile support when useful; then open_browser if possible.\n\
|
|
955
|
+
• When tasked to build, create, or write code/applications/games, build them locally from scratch. NEVER search the web or attempt to fetch non-existent repositories or URLs from GitHub or the internet unless the user explicitly provides a URL or asks to download from external sources.\n\
|
|
956
|
+
• No placeholders. No asking for theme/layout/permission when a reasonable default works. Build immediately."
|
|
957
|
+
.to_string()
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
fn visible_reasoning_policy() -> String {
|
|
961
|
+
"[Visible reasoning]\n\
|
|
962
|
+
Emit short operational `<think>...</think>` notes before tool calls, recovery decisions, and final answers. Think briefly, then act."
|
|
963
|
+
.to_string()
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
fn discovery_policy() -> String {
|
|
967
|
+
"[Filesystem discovery policy]\n\
|
|
968
|
+
Do not invent placeholder paths. If a path is vague or a read/list fails, search with list_tree/find_paths/find_files/grep_files before asking. \
|
|
969
|
+
For folders use find_paths kind=`dir`. For user files/projects, search likely roots such as ~, ~/Documents, ~/Desktop, ~/Downloads, ~/Projects, ~/repos, then bounded parent directories. \
|
|
970
|
+
Only ask for a path after bounded discovery fails or the search would be too broad/sensitive."
|
|
971
|
+
.to_string()
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
fn skill_manifest() -> String {
|
|
975
|
+
let names = config::bundled_skills()
|
|
976
|
+
.into_iter()
|
|
977
|
+
.map(|(name, _)| format!("/{name}"))
|
|
978
|
+
.collect::<Vec<_>>()
|
|
979
|
+
.join(", ");
|
|
980
|
+
format!(
|
|
981
|
+
"[Skill invocation policy]\n\
|
|
982
|
+
Skills are first-class operating instructions. Before substantial work, choose and follow the most relevant skill from the loaded skills. \
|
|
983
|
+
If the user names a skill or uses a skill slash command, treat that skill as active context. \
|
|
984
|
+
Bundled skills are callable by users as slash commands: {names}. \
|
|
985
|
+
Use /trace to inspect evidence of skill loading, tool calls, hooks, and subagents."
|
|
986
|
+
)
|
|
212
987
|
}
|
|
213
988
|
|
|
214
989
|
fn env_snapshot() -> String {
|
|
@@ -216,23 +991,23 @@ fn env_snapshot() -> String {
|
|
|
216
991
|
// We run everything in parallel-ish with short timeouts via sequential calls —
|
|
217
992
|
// the total overhead is ~10ms on a modern machine.
|
|
218
993
|
let probes: &[(&str, &str)] = &[
|
|
219
|
-
("node",
|
|
220
|
-
("npm",
|
|
221
|
-
("npx",
|
|
994
|
+
("node", "node --version"),
|
|
995
|
+
("npm", "npm --version"),
|
|
996
|
+
("npx", "npx --version"),
|
|
222
997
|
("python3", "python3 --version"),
|
|
223
|
-
("pip3",
|
|
224
|
-
("cargo",
|
|
225
|
-
("rustc",
|
|
226
|
-
("git",
|
|
227
|
-
("docker",
|
|
228
|
-
("jq",
|
|
229
|
-
("rg",
|
|
230
|
-
("gh",
|
|
231
|
-
("bun",
|
|
232
|
-
("deno",
|
|
233
|
-
("go",
|
|
234
|
-
("ruby",
|
|
235
|
-
("java",
|
|
998
|
+
("pip3", "pip3 --version"),
|
|
999
|
+
("cargo", "cargo --version"),
|
|
1000
|
+
("rustc", "rustc --version"),
|
|
1001
|
+
("git", "git --version"),
|
|
1002
|
+
("docker", "docker --version"),
|
|
1003
|
+
("jq", "jq --version"),
|
|
1004
|
+
("rg", "rg --version"),
|
|
1005
|
+
("gh", "gh --version"),
|
|
1006
|
+
("bun", "bun --version"),
|
|
1007
|
+
("deno", "deno --version"),
|
|
1008
|
+
("go", "go version"),
|
|
1009
|
+
("ruby", "ruby --version"),
|
|
1010
|
+
("java", "java --version"),
|
|
236
1011
|
];
|
|
237
1012
|
|
|
238
1013
|
use std::process::Command;
|
|
@@ -241,13 +1016,18 @@ fn env_snapshot() -> String {
|
|
|
241
1016
|
let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
|
|
242
1017
|
let bin = parts[0];
|
|
243
1018
|
let arg = parts.get(1).copied().unwrap_or("--version");
|
|
244
|
-
let ok = Command::new(bin)
|
|
1019
|
+
let ok = Command::new(bin)
|
|
1020
|
+
.arg(arg)
|
|
245
1021
|
.stdout(std::process::Stdio::piped())
|
|
246
1022
|
.stderr(std::process::Stdio::piped())
|
|
247
1023
|
.output()
|
|
248
1024
|
.map(|o| {
|
|
249
1025
|
let txt = String::from_utf8_lossy(&o.stdout).trim().to_string();
|
|
250
|
-
let txt = if txt.is_empty() {
|
|
1026
|
+
let txt = if txt.is_empty() {
|
|
1027
|
+
String::from_utf8_lossy(&o.stderr).trim().to_string()
|
|
1028
|
+
} else {
|
|
1029
|
+
txt
|
|
1030
|
+
};
|
|
251
1031
|
txt.lines().next().unwrap_or("").to_string()
|
|
252
1032
|
})
|
|
253
1033
|
.ok()
|
|
@@ -259,14 +1039,78 @@ fn env_snapshot() -> String {
|
|
|
259
1039
|
found.join("\n")
|
|
260
1040
|
}
|
|
261
1041
|
|
|
1042
|
+
static SESSION_ALLOWED_TOOLS: Mutex<Option<HashSet<String>>> = Mutex::new(None);
|
|
1043
|
+
|
|
1044
|
+
fn is_session_allowed_tool(key: &str) -> bool {
|
|
1045
|
+
if key.is_empty() {
|
|
1046
|
+
return false;
|
|
1047
|
+
}
|
|
1048
|
+
if let Ok(guard) = SESSION_ALLOWED_TOOLS.lock() {
|
|
1049
|
+
if let Some(set) = guard.as_ref() {
|
|
1050
|
+
return set.contains(key);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
false
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
fn add_session_allowed_tool(key: &str) {
|
|
1057
|
+
if key.is_empty() {
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
if let Ok(mut guard) = SESSION_ALLOWED_TOOLS.lock() {
|
|
1061
|
+
let set = guard.get_or_insert_with(HashSet::new);
|
|
1062
|
+
set.insert(key.to_string());
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
#[allow(dead_code)]
|
|
262
1067
|
fn confirm(label: &str) -> Option<String> {
|
|
1068
|
+
confirm_tool(label, "")
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
fn confirm_tool(label: &str, tool_key: &str) -> Option<String> {
|
|
263
1072
|
if report::is_json() || !std::io::stdin().is_terminal() {
|
|
264
|
-
return Some(format!(
|
|
1073
|
+
return Some(format!(
|
|
1074
|
+
"blocked (no interactive terminal to confirm: {label})"
|
|
1075
|
+
));
|
|
265
1076
|
}
|
|
266
|
-
let q = format!(
|
|
1077
|
+
let q = format!(
|
|
1078
|
+
" {} {}? {} ",
|
|
1079
|
+
tui::yellow("➤"),
|
|
1080
|
+
tui::bold(label),
|
|
1081
|
+
tui::dim("[y: yes | n: no | s: allow session | a: allow always | d <reason>: deny with feedback]")
|
|
1082
|
+
);
|
|
267
1083
|
let ans = tui::ask(&q).unwrap_or_default();
|
|
268
|
-
|
|
1084
|
+
let trimmed = ans.trim();
|
|
1085
|
+
let lower = trimmed.to_lowercase();
|
|
1086
|
+
if matches!(lower.as_str(), "y" | "yes") {
|
|
1087
|
+
None
|
|
1088
|
+
} else if matches!(lower.as_str(), "s" | "session") {
|
|
1089
|
+
add_session_allowed_tool(tool_key);
|
|
269
1090
|
None
|
|
1091
|
+
} else if matches!(lower.as_str(), "a" | "always") {
|
|
1092
|
+
add_session_allowed_tool(tool_key);
|
|
1093
|
+
if !tool_key.is_empty() {
|
|
1094
|
+
if let Some(mut s) = crate::config::load_settings() {
|
|
1095
|
+
if !s.allowed_commands.contains(&tool_key.to_string()) {
|
|
1096
|
+
s.allowed_commands.push(tool_key.to_string());
|
|
1097
|
+
crate::config::save_settings(&s);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
None
|
|
1102
|
+
} else if lower.starts_with("d ")
|
|
1103
|
+
|| lower.starts_with("deny ")
|
|
1104
|
+
|| lower.starts_with("n ")
|
|
1105
|
+
|| lower.starts_with("no ")
|
|
1106
|
+
{
|
|
1107
|
+
let idx = trimmed.find(' ').unwrap_or(0);
|
|
1108
|
+
let feedback = trimmed[idx..].trim();
|
|
1109
|
+
if feedback.is_empty() {
|
|
1110
|
+
Some("denied by user".into())
|
|
1111
|
+
} else {
|
|
1112
|
+
Some(format!("denied by user with feedback: {feedback}"))
|
|
1113
|
+
}
|
|
270
1114
|
} else {
|
|
271
1115
|
Some("denied by user".into())
|
|
272
1116
|
}
|
|
@@ -275,43 +1119,64 @@ fn confirm(label: &str) -> Option<String> {
|
|
|
275
1119
|
// Permission gate — returns Some(reason) when blocked.
|
|
276
1120
|
// Read operations outside CWD are allowed in all modes (no CWD confinement for
|
|
277
1121
|
// reads); the user specifically asked for full filesystem access.
|
|
278
|
-
pub(crate) fn gate(
|
|
1122
|
+
pub(crate) fn gate(
|
|
1123
|
+
perm: Permission,
|
|
1124
|
+
name: &str,
|
|
1125
|
+
input: &serde_json::Value,
|
|
1126
|
+
cwd: &Path,
|
|
1127
|
+
) -> Option<String> {
|
|
1128
|
+
let tool_key = if let Some(c) = tools::command_arg_for(name, input) {
|
|
1129
|
+
let first = c.split_whitespace().next().unwrap_or("");
|
|
1130
|
+
std::path::Path::new(first)
|
|
1131
|
+
.file_name()
|
|
1132
|
+
.and_then(|n| n.to_str())
|
|
1133
|
+
.unwrap_or(first)
|
|
1134
|
+
.to_string()
|
|
1135
|
+
} else {
|
|
1136
|
+
name.to_string()
|
|
1137
|
+
};
|
|
1138
|
+
|
|
279
1139
|
let path = tools::touched_path(name, input, cwd);
|
|
280
1140
|
|
|
281
1141
|
if let Some(p) = &path {
|
|
282
1142
|
if tools::is_sensitive(p) {
|
|
283
|
-
return
|
|
1143
|
+
return confirm_tool(&format!("access sensitive path {}", p.display()), &tool_key);
|
|
284
1144
|
}
|
|
285
1145
|
// In WSL2, writing to a Windows drive mount (/mnt/c/, /mnt/d/, etc.)
|
|
286
1146
|
// crosses the OS boundary — always confirm, even in Auto mode.
|
|
287
|
-
if tools::
|
|
288
|
-
return
|
|
1147
|
+
if tools::is_mutating_call(name, input) && tools::is_wsl_windows_mount(p) {
|
|
1148
|
+
return confirm_tool(
|
|
1149
|
+
&format!(
|
|
1150
|
+
"write to Windows filesystem {} (WSL2 boundary)",
|
|
1151
|
+
p.display()
|
|
1152
|
+
),
|
|
1153
|
+
&tool_key,
|
|
1154
|
+
);
|
|
289
1155
|
}
|
|
290
1156
|
}
|
|
291
|
-
if name
|
|
292
|
-
if
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
1157
|
+
if let Some(c) = tools::command_arg_for(name, input) {
|
|
1158
|
+
if tools::catastrophic(c) {
|
|
1159
|
+
return confirm_tool(&format!("run dangerous command `{c}`"), &tool_key);
|
|
1160
|
+
}
|
|
1161
|
+
// In WSL2, commands that reference /mnt/<drive>/ target the Windows
|
|
1162
|
+
// filesystem — confirm before running, even in Auto mode.
|
|
1163
|
+
if tools::is_wsl() && tools::command_touches_wsl_mount(c) {
|
|
1164
|
+
return confirm_tool(
|
|
1165
|
+
&format!("command targets Windows filesystem (WSL2): `{c}`"),
|
|
1166
|
+
&tool_key,
|
|
1167
|
+
);
|
|
301
1168
|
}
|
|
302
1169
|
}
|
|
303
1170
|
|
|
304
1171
|
match perm {
|
|
305
1172
|
Permission::Auto => None,
|
|
306
1173
|
Permission::ReadOnly => {
|
|
307
|
-
if tools::
|
|
1174
|
+
if tools::is_mutating_call(name, input) {
|
|
308
1175
|
// run_command with clearly read-only shell tools (grep, find, etc.)
|
|
309
1176
|
// should pass through even in ReadOnly mode.
|
|
310
|
-
if name
|
|
311
|
-
if
|
|
312
|
-
|
|
313
|
-
return None;
|
|
314
|
-
}
|
|
1177
|
+
if let Some(c) = tools::command_arg_for(name, input) {
|
|
1178
|
+
if tools::is_readonly_command(c) {
|
|
1179
|
+
return None;
|
|
315
1180
|
}
|
|
316
1181
|
}
|
|
317
1182
|
return Some("read-only mode: mutation skipped".into());
|
|
@@ -321,19 +1186,15 @@ pub(crate) fn gate(perm: Permission, name: &str, input: &serde_json::Value, cwd:
|
|
|
321
1186
|
Permission::Ask => {
|
|
322
1187
|
// run_command calls whose binary appears in allowed_commands skip the
|
|
323
1188
|
// confirmation prompt — git, cargo, npm, etc. should just work.
|
|
324
|
-
if
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
if config::load_allowed_commands().iter().any(|a| a == bin) {
|
|
331
|
-
return None;
|
|
332
|
-
}
|
|
333
|
-
}
|
|
1189
|
+
if config::load_allowed_commands()
|
|
1190
|
+
.iter()
|
|
1191
|
+
.any(|a| a == &tool_key)
|
|
1192
|
+
|| is_session_allowed_tool(&tool_key)
|
|
1193
|
+
{
|
|
1194
|
+
return None;
|
|
334
1195
|
}
|
|
335
|
-
if tools::
|
|
336
|
-
return
|
|
1196
|
+
if tools::is_mutating_call(name, input) {
|
|
1197
|
+
return confirm_tool(&tools::preview(name, input), &tool_key);
|
|
337
1198
|
}
|
|
338
1199
|
// Out-of-cwd reads: just note it instead of hard-blocking.
|
|
339
1200
|
// The user asked for full filesystem access.
|
|
@@ -348,23 +1209,63 @@ pub fn compact_msgs(p: &Provider, msgs: Vec<Msg>) -> Vec<Msg> {
|
|
|
348
1209
|
}
|
|
349
1210
|
|
|
350
1211
|
// ── BUILD mode ────────────────────────────────────────────────────────────────
|
|
351
|
-
pub fn run_build(
|
|
1212
|
+
pub fn run_build(
|
|
1213
|
+
p: &Provider,
|
|
1214
|
+
perm: Permission,
|
|
1215
|
+
role_id: &str,
|
|
1216
|
+
task: &str,
|
|
1217
|
+
cwd: &Path,
|
|
1218
|
+
) -> Result<(), String> {
|
|
352
1219
|
let mut transcript: Vec<Msg> = Vec::new();
|
|
353
|
-
run_build_session(
|
|
1220
|
+
run_build_session(
|
|
1221
|
+
p,
|
|
1222
|
+
perm,
|
|
1223
|
+
role_id,
|
|
1224
|
+
task,
|
|
1225
|
+
cwd,
|
|
1226
|
+
&mut transcript,
|
|
1227
|
+
&crate::session::new_id(),
|
|
1228
|
+
)
|
|
354
1229
|
}
|
|
355
1230
|
|
|
356
|
-
pub fn run_build_resumed(
|
|
1231
|
+
pub fn run_build_resumed(
|
|
1232
|
+
p: &Provider,
|
|
1233
|
+
perm: Permission,
|
|
1234
|
+
role_id: &str,
|
|
1235
|
+
task: &str,
|
|
1236
|
+
cwd: &Path,
|
|
1237
|
+
mut seed: Vec<Msg>,
|
|
1238
|
+
sid: &str,
|
|
1239
|
+
) -> Result<(), String> {
|
|
357
1240
|
run_build_session(p, perm, role_id, task, cwd, &mut seed, sid)
|
|
358
1241
|
}
|
|
359
1242
|
|
|
360
|
-
pub fn run_build_session(
|
|
1243
|
+
pub fn run_build_session(
|
|
1244
|
+
p: &Provider,
|
|
1245
|
+
perm: Permission,
|
|
1246
|
+
role_id: &str,
|
|
1247
|
+
task: &str,
|
|
1248
|
+
cwd: &Path,
|
|
1249
|
+
transcript: &mut Vec<Msg>,
|
|
1250
|
+
sid: &str,
|
|
1251
|
+
) -> Result<(), String> {
|
|
1252
|
+
hooks::notify("SessionStart", cwd);
|
|
361
1253
|
let r = build_inner(p, perm, role_id, task, cwd, 0, transcript).map(|_| ());
|
|
1254
|
+
hooks::notify("SessionEnd", cwd);
|
|
362
1255
|
hooks::notify("Stop", cwd);
|
|
363
1256
|
crate::session::save(sid, cwd, &p.model, transcript);
|
|
364
1257
|
r
|
|
365
1258
|
}
|
|
366
1259
|
|
|
367
|
-
fn build_inner(
|
|
1260
|
+
fn build_inner(
|
|
1261
|
+
p: &Provider,
|
|
1262
|
+
perm: Permission,
|
|
1263
|
+
role_id: &str,
|
|
1264
|
+
task: &str,
|
|
1265
|
+
cwd: &Path,
|
|
1266
|
+
depth: usize,
|
|
1267
|
+
msgs: &mut Vec<Msg>,
|
|
1268
|
+
) -> Result<String, String> {
|
|
368
1269
|
let task = match hooks::user_prompt_submit(task, cwd) {
|
|
369
1270
|
Err(reason) => {
|
|
370
1271
|
report::error(&format!("blocked by hook: {reason}"));
|
|
@@ -373,9 +1274,14 @@ fn build_inner(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &
|
|
|
373
1274
|
Ok(ctx) if !ctx.is_empty() => format!("{task}\n\n[hook context]\n{ctx}"),
|
|
374
1275
|
Ok(_) => task.to_string(),
|
|
375
1276
|
};
|
|
376
|
-
let
|
|
1277
|
+
let task_for_recovery = recovery_task_text(&task);
|
|
1278
|
+
let defs = if matches!(perm, Permission::ReadOnly) {
|
|
1279
|
+
tools::defs_readonly()
|
|
1280
|
+
} else {
|
|
1281
|
+
tools::defs_for_context(depth < MAX_DEPTH, p.context_tokens)
|
|
1282
|
+
};
|
|
377
1283
|
if msgs.is_empty() {
|
|
378
|
-
let prefix = context_prefix();
|
|
1284
|
+
let prefix = context_prefix(cwd, p.context_tokens);
|
|
379
1285
|
let sys = format!("{prefix}{}", role(role_id).system);
|
|
380
1286
|
msgs.push(Msg::System(sys));
|
|
381
1287
|
}
|
|
@@ -383,11 +1289,14 @@ fn build_inner(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &
|
|
|
383
1289
|
// text as the task without pushing another User turn; otherwise push normally.
|
|
384
1290
|
let already_pushed = matches!(msgs.last(), Some(Msg::UserImages { .. }));
|
|
385
1291
|
if !already_pushed {
|
|
386
|
-
msgs.push(Msg::User(task));
|
|
1292
|
+
msgs.push(Msg::User(task.clone()));
|
|
387
1293
|
}
|
|
388
1294
|
|
|
389
1295
|
// Track which files have been read this session so we can enforce read-before-write.
|
|
390
1296
|
let mut read_paths: std::collections::HashSet<PathBuf> = Default::default();
|
|
1297
|
+
let mut loop_guard = ToolLoopGuard::default();
|
|
1298
|
+
let mut artifact_error_count = 0usize;
|
|
1299
|
+
let mut static_artifact_recovery_count = 0usize;
|
|
391
1300
|
|
|
392
1301
|
for step in 1..=MAX_ITERS {
|
|
393
1302
|
if tui::interrupted() {
|
|
@@ -395,51 +1304,144 @@ fn build_inner(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &
|
|
|
395
1304
|
return Ok(String::new());
|
|
396
1305
|
}
|
|
397
1306
|
maybe_compact(p, msgs);
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
1307
|
+
if step > 1 && !report::is_json() {
|
|
1308
|
+
tui::line(&tui::dim(&format!(" ↻ step {step}")));
|
|
1309
|
+
}
|
|
1310
|
+
let start_instant = std::time::Instant::now();
|
|
1311
|
+
let reply = match request_reply(p, msgs.as_slice(), &defs, "thinking") {
|
|
1312
|
+
Ok(r) => r,
|
|
1313
|
+
Err(e) => {
|
|
1314
|
+
hooks::notify("OnError", cwd);
|
|
1315
|
+
return Err(e);
|
|
1316
|
+
}
|
|
1317
|
+
};
|
|
1318
|
+
let reply = normalize_text_tool_calls(reply, &defs, &task_for_recovery);
|
|
1319
|
+
let elapsed = start_instant.elapsed().as_secs_f64();
|
|
1320
|
+
let gen_toks = (reply.text.len() / 4) + (reply.calls.len() * 40);
|
|
1321
|
+
if !report::is_json() {
|
|
1322
|
+
tui::inference_telemetry(gen_toks.max(10), elapsed);
|
|
1323
|
+
}
|
|
1324
|
+
hooks::notify("PostResponse", cwd);
|
|
1325
|
+
if reply.calls.is_empty() {
|
|
1326
|
+
if let Some(input) = auto_create_file_input(&task_for_recovery) {
|
|
1327
|
+
if let Some(reason) = gate(perm, "write_file", &input, cwd) {
|
|
1328
|
+
report::tool_denied(&reason);
|
|
1329
|
+
return Err(reason);
|
|
1330
|
+
}
|
|
1331
|
+
report::tool_call("write_file", &tools::preview("write_file", &input), &input);
|
|
1332
|
+
trace_tool_call("write_file", &input, "build", depth);
|
|
1333
|
+
let out = tools::run("write_file", &input, cwd);
|
|
1334
|
+
hooks::post_tool_use("write_file", &input, &out.content, out.is_error, cwd);
|
|
1335
|
+
report::tool_result("write_file", &out.content, out.is_error);
|
|
1336
|
+
trace_tool_result("write_file", &out.content, out.is_error, "build", depth);
|
|
1337
|
+
if out.is_error {
|
|
1338
|
+
return Err(out.content);
|
|
414
1339
|
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
1340
|
+
return Ok(format!(
|
|
1341
|
+
"{}\n\nCompleted the explicit file creation task after the model returned prose without tool calls.",
|
|
1342
|
+
out.content
|
|
1343
|
+
));
|
|
1344
|
+
}
|
|
1345
|
+
if let Some(input) = auto_static_artifact_input(&task_for_recovery, &reply.text) {
|
|
1346
|
+
report::tool_call("Artifact", &tools::preview("Artifact", &input), &input);
|
|
1347
|
+
trace_tool_call("Artifact", &input, "build", depth);
|
|
1348
|
+
let out = tools::run("Artifact", &input, cwd);
|
|
1349
|
+
report::tool_result("Artifact", &out.content, out.is_error);
|
|
1350
|
+
trace_tool_result("Artifact", &out.content, out.is_error, "build", depth);
|
|
1351
|
+
if out.is_error {
|
|
1352
|
+
if canvas_game_requested(&task_for_recovery) {
|
|
1353
|
+
let fallback = fallback_canvas_game_input(&task_for_recovery);
|
|
1354
|
+
report::tool_call(
|
|
1355
|
+
"Artifact",
|
|
1356
|
+
&tools::preview("Artifact", &fallback),
|
|
1357
|
+
&fallback,
|
|
1358
|
+
);
|
|
1359
|
+
trace_tool_call("Artifact", &fallback, "build", depth);
|
|
1360
|
+
let fallback_out = tools::run("Artifact", &fallback, cwd);
|
|
1361
|
+
report::tool_result(
|
|
1362
|
+
"Artifact",
|
|
1363
|
+
&fallback_out.content,
|
|
1364
|
+
fallback_out.is_error,
|
|
1365
|
+
);
|
|
1366
|
+
trace_tool_result(
|
|
1367
|
+
"Artifact",
|
|
1368
|
+
&fallback_out.content,
|
|
1369
|
+
fallback_out.is_error,
|
|
1370
|
+
"build",
|
|
1371
|
+
depth,
|
|
1372
|
+
);
|
|
1373
|
+
if !fallback_out.is_error {
|
|
1374
|
+
return Ok(format!(
|
|
1375
|
+
"{}\n\nThe model produced incomplete canvas HTML, so buildwithnexus published a complete self-contained fallback canvas game.",
|
|
1376
|
+
fallback_out.content
|
|
1377
|
+
));
|
|
1378
|
+
}
|
|
1379
|
+
return Err(fallback_out.content);
|
|
425
1380
|
}
|
|
426
|
-
|
|
1381
|
+
msgs.push(Msg::Assistant {
|
|
1382
|
+
text: reply.text.clone(),
|
|
1383
|
+
calls: vec![],
|
|
1384
|
+
});
|
|
1385
|
+
msgs.push(Msg::User(format!(
|
|
1386
|
+
"The HTML artifact you wrote in plain text was rejected: {}. \
|
|
1387
|
+
Rewrite it as one complete self-contained HTML artifact and call Artifact/publish_artifact. \
|
|
1388
|
+
Do not answer with markdown code.",
|
|
1389
|
+
out.content
|
|
1390
|
+
)));
|
|
1391
|
+
continue;
|
|
427
1392
|
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
1393
|
+
msgs.push(Msg::Assistant {
|
|
1394
|
+
text: reply.text.clone(),
|
|
1395
|
+
calls: vec![],
|
|
1396
|
+
});
|
|
1397
|
+
return Ok(out.content);
|
|
431
1398
|
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
1399
|
+
if static_artifact_requested(&task_for_recovery) {
|
|
1400
|
+
msgs.push(Msg::Assistant {
|
|
1401
|
+
text: reply.text.clone(),
|
|
1402
|
+
calls: vec![],
|
|
1403
|
+
});
|
|
1404
|
+
if static_artifact_recovery_count == 0 {
|
|
1405
|
+
static_artifact_recovery_count += 1;
|
|
1406
|
+
msgs.push(Msg::User(static_artifact_recovery_prompt(
|
|
1407
|
+
&task_for_recovery,
|
|
1408
|
+
)));
|
|
1409
|
+
continue;
|
|
1410
|
+
}
|
|
1411
|
+
if canvas_game_requested(&task_for_recovery) {
|
|
1412
|
+
let fallback = fallback_canvas_game_input(&task_for_recovery);
|
|
1413
|
+
report::tool_call(
|
|
1414
|
+
"Artifact",
|
|
1415
|
+
&tools::preview("Artifact", &fallback),
|
|
1416
|
+
&fallback,
|
|
1417
|
+
);
|
|
1418
|
+
trace_tool_call("Artifact", &fallback, "build", depth);
|
|
1419
|
+
let fallback_out = tools::run("Artifact", &fallback, cwd);
|
|
1420
|
+
report::tool_result("Artifact", &fallback_out.content, fallback_out.is_error);
|
|
1421
|
+
trace_tool_result(
|
|
1422
|
+
"Artifact",
|
|
1423
|
+
&fallback_out.content,
|
|
1424
|
+
fallback_out.is_error,
|
|
1425
|
+
"build",
|
|
1426
|
+
depth,
|
|
1427
|
+
);
|
|
1428
|
+
if !fallback_out.is_error {
|
|
1429
|
+
return Ok(format!(
|
|
1430
|
+
"{}\n\nThe model did not produce a runnable canvas artifact after being asked to proceed with reasonable defaults, so buildwithnexus published a complete self-contained fallback canvas game.",
|
|
1431
|
+
fallback_out.content
|
|
1432
|
+
));
|
|
1433
|
+
}
|
|
1434
|
+
return Err(fallback_out.content);
|
|
1435
|
+
}
|
|
1436
|
+
return Ok(reply.text);
|
|
436
1437
|
}
|
|
437
|
-
r
|
|
438
|
-
};
|
|
439
|
-
if reply.calls.is_empty() {
|
|
440
1438
|
if reply.text.trim().is_empty() {
|
|
441
1439
|
report::notice("model returned no output");
|
|
442
1440
|
}
|
|
1441
|
+
msgs.push(Msg::Assistant {
|
|
1442
|
+
text: reply.text.clone(),
|
|
1443
|
+
calls: vec![],
|
|
1444
|
+
});
|
|
443
1445
|
return Ok(reply.text);
|
|
444
1446
|
}
|
|
445
1447
|
|
|
@@ -447,103 +1449,343 @@ fn build_inner(p: &Provider, perm: Permission, role_id: &str, task: &str, cwd: &
|
|
|
447
1449
|
let mut summary: Option<String> = None;
|
|
448
1450
|
for call in &reply.calls {
|
|
449
1451
|
if let Some(raw) = call.input.get(tools::INVALID_ARGS).and_then(|v| v.as_str()) {
|
|
450
|
-
let msg = format!(
|
|
1452
|
+
let msg = format!(
|
|
1453
|
+
"tool arguments were not valid JSON: {}",
|
|
1454
|
+
raw.chars().take(200).collect::<String>()
|
|
1455
|
+
);
|
|
451
1456
|
report::tool_denied(&msg);
|
|
452
|
-
|
|
1457
|
+
note_loop_result(
|
|
1458
|
+
&mut loop_guard,
|
|
1459
|
+
&call.name,
|
|
1460
|
+
&call.input,
|
|
1461
|
+
&msg,
|
|
1462
|
+
true,
|
|
1463
|
+
&mut summary,
|
|
1464
|
+
);
|
|
1465
|
+
results.push(ToolResult {
|
|
1466
|
+
id: call.id.clone(),
|
|
1467
|
+
content: msg,
|
|
1468
|
+
is_error: true,
|
|
1469
|
+
});
|
|
1470
|
+
continue;
|
|
1471
|
+
}
|
|
1472
|
+
let call_input = tool_input_for_execution(
|
|
1473
|
+
&call.name,
|
|
1474
|
+
&call.input,
|
|
1475
|
+
cwd,
|
|
1476
|
+
"build",
|
|
1477
|
+
depth,
|
|
1478
|
+
&task_for_recovery,
|
|
1479
|
+
);
|
|
1480
|
+
if matches!(call.name.as_str(), "bash" | "run_command") {
|
|
1481
|
+
if let Some(fallback) = auto_create_file_input(&task_for_recovery) {
|
|
1482
|
+
report::notice(
|
|
1483
|
+
" recovery: using write_file for explicit file creation instead of shell",
|
|
1484
|
+
);
|
|
1485
|
+
if let Some(reason) = gate(perm, "write_file", &fallback, cwd) {
|
|
1486
|
+
report::tool_denied(&reason);
|
|
1487
|
+
results.push(ToolResult {
|
|
1488
|
+
id: call.id.clone(),
|
|
1489
|
+
content: reason,
|
|
1490
|
+
is_error: true,
|
|
1491
|
+
});
|
|
1492
|
+
continue;
|
|
1493
|
+
}
|
|
1494
|
+
report::tool_call(
|
|
1495
|
+
"write_file",
|
|
1496
|
+
&tools::preview("write_file", &fallback),
|
|
1497
|
+
&fallback,
|
|
1498
|
+
);
|
|
1499
|
+
trace_tool_call("write_file", &fallback, "build", depth);
|
|
1500
|
+
let fallback_out = tools::run("write_file", &fallback, cwd);
|
|
1501
|
+
hooks::post_tool_use(
|
|
1502
|
+
"write_file",
|
|
1503
|
+
&fallback,
|
|
1504
|
+
&fallback_out.content,
|
|
1505
|
+
fallback_out.is_error,
|
|
1506
|
+
cwd,
|
|
1507
|
+
);
|
|
1508
|
+
report::tool_result("write_file", &fallback_out.content, fallback_out.is_error);
|
|
1509
|
+
trace_tool_result(
|
|
1510
|
+
"write_file",
|
|
1511
|
+
&fallback_out.content,
|
|
1512
|
+
fallback_out.is_error,
|
|
1513
|
+
"build",
|
|
1514
|
+
depth,
|
|
1515
|
+
);
|
|
1516
|
+
results.push(ToolResult {
|
|
1517
|
+
id: call.id.clone(),
|
|
1518
|
+
content: fallback_out.content.clone(),
|
|
1519
|
+
is_error: fallback_out.is_error,
|
|
1520
|
+
});
|
|
1521
|
+
if !fallback_out.is_error {
|
|
1522
|
+
summary = Some(format!(
|
|
1523
|
+
"{}\n\nCompleted the explicit file creation task with write_file.",
|
|
1524
|
+
fallback_out.content
|
|
1525
|
+
));
|
|
1526
|
+
}
|
|
1527
|
+
continue;
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
report::tool_call(
|
|
1531
|
+
&call.name,
|
|
1532
|
+
&tools::preview(&call.name, &call_input),
|
|
1533
|
+
&call_input,
|
|
1534
|
+
);
|
|
1535
|
+
trace_tool_call(&call.name, &call_input, "build", depth);
|
|
1536
|
+
|
|
1537
|
+
if call.name == "question" || call.name == "AskUserQuestion" {
|
|
1538
|
+
let (answer, is_error) = answer_question(&call_input);
|
|
1539
|
+
report::tool_result(&call.name, &answer, is_error);
|
|
1540
|
+
trace_tool_result(&call.name, &answer, is_error, "build", depth);
|
|
1541
|
+
note_loop_result(
|
|
1542
|
+
&mut loop_guard,
|
|
1543
|
+
&call.name,
|
|
1544
|
+
&call_input,
|
|
1545
|
+
&answer,
|
|
1546
|
+
is_error,
|
|
1547
|
+
&mut summary,
|
|
1548
|
+
);
|
|
1549
|
+
results.push(ToolResult {
|
|
1550
|
+
id: call.id.clone(),
|
|
1551
|
+
content: answer,
|
|
1552
|
+
is_error,
|
|
1553
|
+
});
|
|
453
1554
|
continue;
|
|
454
1555
|
}
|
|
455
|
-
report::tool_call(&call.name, &tools::preview(&call.name, &call.input), &call.input);
|
|
456
1556
|
|
|
457
|
-
let reason = match hooks::pre_tool_use(&call.name, &
|
|
1557
|
+
let reason = match hooks::pre_tool_use(&call.name, &call_input, cwd) {
|
|
458
1558
|
PreDecision::Deny(r) => Some(r),
|
|
459
1559
|
PreDecision::Allow => None,
|
|
460
|
-
PreDecision::Continue => gate(perm, &call.name, &
|
|
1560
|
+
PreDecision::Continue => gate(perm, &call.name, &call_input, cwd),
|
|
461
1561
|
};
|
|
462
1562
|
if let Some(reason) = reason {
|
|
463
1563
|
report::tool_denied(&reason);
|
|
464
|
-
|
|
1564
|
+
trace::record_visible(
|
|
1565
|
+
"tool_denied",
|
|
1566
|
+
format!("{} denied", call.name),
|
|
1567
|
+
serde_json::json!({"tool": call.name, "reason": reason, "input": &call_input, "phase": "build", "depth": depth}),
|
|
1568
|
+
);
|
|
1569
|
+
note_loop_result(
|
|
1570
|
+
&mut loop_guard,
|
|
1571
|
+
&call.name,
|
|
1572
|
+
&call_input,
|
|
1573
|
+
&reason,
|
|
1574
|
+
true,
|
|
1575
|
+
&mut summary,
|
|
1576
|
+
);
|
|
1577
|
+
results.push(ToolResult {
|
|
1578
|
+
id: call.id.clone(),
|
|
1579
|
+
content: reason,
|
|
1580
|
+
is_error: true,
|
|
1581
|
+
});
|
|
465
1582
|
continue;
|
|
466
1583
|
}
|
|
467
1584
|
|
|
468
1585
|
// Record reads so we can enforce read-before-write below.
|
|
469
|
-
if call.name
|
|
470
|
-
|
|
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
|
-
}
|
|
1586
|
+
if let Some(p) = tools::read_tracking_path(&call.name, &call_input, cwd) {
|
|
1587
|
+
read_paths.insert(p);
|
|
478
1588
|
}
|
|
479
1589
|
|
|
480
1590
|
// Require the model to read a file before overwriting or patching it.
|
|
481
|
-
if
|
|
482
|
-
if
|
|
483
|
-
let
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
1591
|
+
if let Some(p) = tools::edit_tracking_path(&call.name, &call_input, cwd) {
|
|
1592
|
+
if p.exists() && !read_paths.contains(&p) {
|
|
1593
|
+
let msg = format!(
|
|
1594
|
+
"read('{}') required before editing. Read the file first, then retry.",
|
|
1595
|
+
p.display()
|
|
1596
|
+
);
|
|
1597
|
+
report::tool_denied(&msg);
|
|
1598
|
+
note_loop_result(
|
|
1599
|
+
&mut loop_guard,
|
|
1600
|
+
&call.name,
|
|
1601
|
+
&call_input,
|
|
1602
|
+
&msg,
|
|
1603
|
+
true,
|
|
1604
|
+
&mut summary,
|
|
1605
|
+
);
|
|
1606
|
+
results.push(ToolResult {
|
|
1607
|
+
id: call.id.clone(),
|
|
1608
|
+
content: msg,
|
|
1609
|
+
is_error: true,
|
|
1610
|
+
});
|
|
1611
|
+
continue;
|
|
497
1612
|
}
|
|
498
1613
|
}
|
|
499
1614
|
|
|
500
|
-
if call.name
|
|
501
|
-
let out = spawn_subagent(p, perm, &
|
|
1615
|
+
if matches!(call.name.as_str(), "task" | "spawn_subagent") {
|
|
1616
|
+
let out = spawn_subagent(p, perm, &call_input, cwd, depth);
|
|
502
1617
|
report::tool_result(&call.name, &out, false);
|
|
503
|
-
|
|
1618
|
+
trace_tool_result(&call.name, &out, false, "build", depth);
|
|
1619
|
+
results.push(ToolResult {
|
|
1620
|
+
id: call.id.clone(),
|
|
1621
|
+
content: out,
|
|
1622
|
+
is_error: false,
|
|
1623
|
+
});
|
|
504
1624
|
continue;
|
|
505
1625
|
}
|
|
506
1626
|
// Memory-save tool: the model can call save_memory to persist facts.
|
|
507
1627
|
if call.name == "save_memory" {
|
|
508
|
-
if let Some(note) =
|
|
1628
|
+
if let Some(note) = call_input["note"].as_str() {
|
|
509
1629
|
config::append_memory(note);
|
|
510
1630
|
let msg = "memory saved".to_string();
|
|
511
1631
|
report::tool_result(&call.name, &msg, false);
|
|
512
|
-
|
|
1632
|
+
trace_tool_result(&call.name, &msg, false, "build", depth);
|
|
1633
|
+
results.push(ToolResult {
|
|
1634
|
+
id: call.id.clone(),
|
|
1635
|
+
content: msg,
|
|
1636
|
+
is_error: false,
|
|
1637
|
+
});
|
|
513
1638
|
continue;
|
|
514
1639
|
}
|
|
515
1640
|
}
|
|
516
1641
|
|
|
517
|
-
let out = tools::run(&call.name, &
|
|
518
|
-
hooks::post_tool_use(&call.name, &
|
|
1642
|
+
let out = tools::run(&call.name, &call_input, cwd);
|
|
1643
|
+
hooks::post_tool_use(&call.name, &call_input, &out.content, out.is_error, cwd);
|
|
1644
|
+
if out.is_error {
|
|
1645
|
+
hooks::notify("OnError", cwd);
|
|
1646
|
+
}
|
|
519
1647
|
report::tool_result(&call.name, &out.content, out.is_error);
|
|
1648
|
+
trace_tool_result(&call.name, &out.content, out.is_error, "build", depth);
|
|
1649
|
+
if matches!(call.name.as_str(), "bash" | "run_command")
|
|
1650
|
+
&& out.is_error
|
|
1651
|
+
&& out.content.contains("No such file or directory")
|
|
1652
|
+
{
|
|
1653
|
+
if let Some(fallback) = auto_create_file_input(&task_for_recovery) {
|
|
1654
|
+
report::tool_call(
|
|
1655
|
+
"write_file",
|
|
1656
|
+
&tools::preview("write_file", &fallback),
|
|
1657
|
+
&fallback,
|
|
1658
|
+
);
|
|
1659
|
+
trace_tool_call("write_file", &fallback, "build", depth);
|
|
1660
|
+
let fallback_out = tools::run("write_file", &fallback, cwd);
|
|
1661
|
+
report::tool_result("write_file", &fallback_out.content, fallback_out.is_error);
|
|
1662
|
+
trace_tool_result(
|
|
1663
|
+
"write_file",
|
|
1664
|
+
&fallback_out.content,
|
|
1665
|
+
fallback_out.is_error,
|
|
1666
|
+
"build",
|
|
1667
|
+
depth,
|
|
1668
|
+
);
|
|
1669
|
+
if !fallback_out.is_error {
|
|
1670
|
+
summary = Some(format!(
|
|
1671
|
+
"{}\n\nRecovered from a failed shell redirect by writing the requested file directly.",
|
|
1672
|
+
fallback_out.content
|
|
1673
|
+
));
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
if matches!(call.name.as_str(), "Artifact" | "publish_artifact")
|
|
1678
|
+
&& out.is_error
|
|
1679
|
+
&& canvas_game_requested(&task_for_recovery)
|
|
1680
|
+
{
|
|
1681
|
+
artifact_error_count += 1;
|
|
1682
|
+
if artifact_error_count >= 1 {
|
|
1683
|
+
let fallback = fallback_canvas_game_input(&task_for_recovery);
|
|
1684
|
+
report::tool_call(
|
|
1685
|
+
"Artifact",
|
|
1686
|
+
&tools::preview("Artifact", &fallback),
|
|
1687
|
+
&fallback,
|
|
1688
|
+
);
|
|
1689
|
+
trace_tool_call("Artifact", &fallback, "build", depth);
|
|
1690
|
+
let fallback_out = tools::run("Artifact", &fallback, cwd);
|
|
1691
|
+
report::tool_result("Artifact", &fallback_out.content, fallback_out.is_error);
|
|
1692
|
+
trace_tool_result(
|
|
1693
|
+
"Artifact",
|
|
1694
|
+
&fallback_out.content,
|
|
1695
|
+
fallback_out.is_error,
|
|
1696
|
+
"build",
|
|
1697
|
+
depth,
|
|
1698
|
+
);
|
|
1699
|
+
if !fallback_out.is_error {
|
|
1700
|
+
summary = Some(format!(
|
|
1701
|
+
"{}\n\nThe model repeatedly produced incomplete canvas HTML, so buildwithnexus published a complete self-contained fallback canvas game.",
|
|
1702
|
+
fallback_out.content
|
|
1703
|
+
));
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
520
1707
|
if out.finished {
|
|
521
1708
|
report::finish(&out.content);
|
|
522
1709
|
summary = Some(out.content.clone());
|
|
1710
|
+
} else {
|
|
1711
|
+
note_loop_result(
|
|
1712
|
+
&mut loop_guard,
|
|
1713
|
+
&call.name,
|
|
1714
|
+
&call_input,
|
|
1715
|
+
&out.content,
|
|
1716
|
+
out.is_error,
|
|
1717
|
+
&mut summary,
|
|
1718
|
+
);
|
|
523
1719
|
}
|
|
524
|
-
results.push(ToolResult {
|
|
1720
|
+
results.push(ToolResult {
|
|
1721
|
+
id: call.id.clone(),
|
|
1722
|
+
content: out.content,
|
|
1723
|
+
is_error: out.is_error,
|
|
1724
|
+
});
|
|
525
1725
|
}
|
|
526
1726
|
|
|
527
|
-
msgs.push(Msg::Assistant {
|
|
1727
|
+
msgs.push(Msg::Assistant {
|
|
1728
|
+
text: assistant_tool_turn_text(reply.text, &reply.calls),
|
|
1729
|
+
calls: reply.calls,
|
|
1730
|
+
});
|
|
528
1731
|
msgs.push(Msg::Tool(results));
|
|
529
1732
|
if !report::is_json() {
|
|
530
1733
|
tui::context_meter(estimate_tokens(msgs), p.context_tokens);
|
|
531
1734
|
tui::poll_typeahead();
|
|
532
1735
|
}
|
|
533
1736
|
if let Some(s) = summary {
|
|
1737
|
+
if depth == 0 && !report::is_json() {
|
|
1738
|
+
let verifier = crate::verifier::Verifier::new(&cwd.to_string_lossy());
|
|
1739
|
+
let ctx = crate::verifier::VerificationContext {
|
|
1740
|
+
task_description: task.to_string(),
|
|
1741
|
+
task_type: None,
|
|
1742
|
+
changed_files: vec![],
|
|
1743
|
+
tool_calls: vec![],
|
|
1744
|
+
evidence_gathered: vec![],
|
|
1745
|
+
tests_added: vec![],
|
|
1746
|
+
dependencies_changed: vec![],
|
|
1747
|
+
git_diff: None,
|
|
1748
|
+
};
|
|
1749
|
+
let rep = verifier.verify(&ctx);
|
|
1750
|
+
if matches!(
|
|
1751
|
+
rep.status,
|
|
1752
|
+
crate::verifier::VerificationStatus::PassedWithWarnings
|
|
1753
|
+
| crate::verifier::VerificationStatus::Blocked
|
|
1754
|
+
| crate::verifier::VerificationStatus::Failed
|
|
1755
|
+
) {
|
|
1756
|
+
report::notice(&format!(" [Verification: {}]", rep.status));
|
|
1757
|
+
if !rep.rule_violations.is_empty() {
|
|
1758
|
+
report::notice(&crate::rules::RuleEngine::format_violations(
|
|
1759
|
+
&rep.rule_violations,
|
|
1760
|
+
));
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
534
1764
|
return Ok(s);
|
|
535
1765
|
}
|
|
536
1766
|
}
|
|
537
|
-
Err(format!(
|
|
1767
|
+
Err(format!(
|
|
1768
|
+
"reached the {MAX_ITERS}-step limit without finishing"
|
|
1769
|
+
))
|
|
538
1770
|
}
|
|
539
1771
|
|
|
540
1772
|
static SUB_SEQ: AtomicUsize = AtomicUsize::new(0);
|
|
541
1773
|
|
|
542
|
-
fn spawn_subagent(
|
|
1774
|
+
fn spawn_subagent(
|
|
1775
|
+
p: &Provider,
|
|
1776
|
+
perm: Permission,
|
|
1777
|
+
input: &serde_json::Value,
|
|
1778
|
+
cwd: &Path,
|
|
1779
|
+
depth: usize,
|
|
1780
|
+
) -> String {
|
|
543
1781
|
if depth + 1 >= MAX_DEPTH {
|
|
544
1782
|
return "subagent depth limit reached".into();
|
|
545
1783
|
}
|
|
546
|
-
let task = input["task"]
|
|
1784
|
+
let task = input["task"]
|
|
1785
|
+
.as_str()
|
|
1786
|
+
.or_else(|| input["description"].as_str())
|
|
1787
|
+
.unwrap_or("")
|
|
1788
|
+
.trim();
|
|
547
1789
|
if task.is_empty() {
|
|
548
1790
|
return "spawn_subagent requires a task".into();
|
|
549
1791
|
}
|
|
@@ -556,16 +1798,42 @@ fn spawn_subagent(p: &Provider, perm: Permission, input: &serde_json::Value, cwd
|
|
|
556
1798
|
let n = format!("[isolated worktree: {}]\n", wt.display());
|
|
557
1799
|
(wt, n)
|
|
558
1800
|
}
|
|
559
|
-
None => (
|
|
1801
|
+
None => (
|
|
1802
|
+
cwd.to_path_buf(),
|
|
1803
|
+
"[worktree unavailable — ran in place]\n".into(),
|
|
1804
|
+
),
|
|
560
1805
|
}
|
|
561
1806
|
} else {
|
|
562
1807
|
(cwd.to_path_buf(), String::new())
|
|
563
1808
|
};
|
|
564
1809
|
|
|
565
1810
|
report::notice(&format!(" ↳ subagent: {task}"));
|
|
1811
|
+
trace::record_visible(
|
|
1812
|
+
"subagent_spawn",
|
|
1813
|
+
format!("{role}: {}", trace::preview(task, 80)),
|
|
1814
|
+
serde_json::json!({
|
|
1815
|
+
"task": task,
|
|
1816
|
+
"role": role,
|
|
1817
|
+
"isolate": isolate,
|
|
1818
|
+
"cwd": run_cwd.to_string_lossy(),
|
|
1819
|
+
"parent_depth": depth,
|
|
1820
|
+
}),
|
|
1821
|
+
);
|
|
566
1822
|
let mut child: Vec<Msg> = Vec::new();
|
|
567
1823
|
let result = build_inner(p, perm, role, task, &run_cwd, depth + 1, &mut child)
|
|
568
1824
|
.unwrap_or_else(|e| format!("subagent error: {e}"));
|
|
1825
|
+
trace::record_visible(
|
|
1826
|
+
"subagent_done",
|
|
1827
|
+
format!("{role}: {}", trace::preview(task, 80)),
|
|
1828
|
+
serde_json::json!({
|
|
1829
|
+
"task": task,
|
|
1830
|
+
"role": role,
|
|
1831
|
+
"isolate": isolate,
|
|
1832
|
+
"cwd": run_cwd.to_string_lossy(),
|
|
1833
|
+
"result": result,
|
|
1834
|
+
"depth": depth + 1,
|
|
1835
|
+
}),
|
|
1836
|
+
);
|
|
569
1837
|
format!("{note}{result}")
|
|
570
1838
|
}
|
|
571
1839
|
|
|
@@ -582,63 +1850,620 @@ fn make_worktree(cwd: &Path) -> Option<PathBuf> {
|
|
|
582
1850
|
out.status.success().then_some(wt)
|
|
583
1851
|
}
|
|
584
1852
|
|
|
1853
|
+
fn parse_plan_steps(plan_text: &str) -> Vec<String> {
|
|
1854
|
+
let mut listed = Vec::new();
|
|
1855
|
+
for line in plan_text.lines() {
|
|
1856
|
+
let trimmed = line.trim();
|
|
1857
|
+
if trimmed.is_empty() || trimmed.starts_with("```") {
|
|
1858
|
+
continue;
|
|
1859
|
+
}
|
|
1860
|
+
if let Some(step) = strip_plan_marker(trimmed) {
|
|
1861
|
+
let step = strip_markdown_emphasis(step);
|
|
1862
|
+
if !step.is_empty() {
|
|
1863
|
+
listed.push(step);
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
if !listed.is_empty() {
|
|
1868
|
+
return listed.into_iter().take(8).collect();
|
|
1869
|
+
}
|
|
1870
|
+
Vec::new()
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
fn plan_steps_are_actionable(steps: &[String]) -> bool {
|
|
1874
|
+
!steps.is_empty() && steps.iter().all(|step| plan_step_is_actionable(step))
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
fn is_exit_plan_tool(name: &str) -> bool {
|
|
1878
|
+
matches!(name, "exit_plan" | "ExitPlanMode")
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
fn fallback_exit_plan_input(task: &str) -> serde_json::Value {
|
|
1882
|
+
let lower = task.to_ascii_lowercase();
|
|
1883
|
+
let read_only_request = lower.contains("do not modify")
|
|
1884
|
+
|| lower.contains("don't modify")
|
|
1885
|
+
|| lower.contains("readonly")
|
|
1886
|
+
|| lower.contains("read-only")
|
|
1887
|
+
|| (lower.contains("list") && !lower.contains("change") && !lower.contains("fix"));
|
|
1888
|
+
let steps = if let Some(input) = auto_create_file_input(task) {
|
|
1889
|
+
let path = input["path"].as_str().unwrap_or("the requested file");
|
|
1890
|
+
vec![
|
|
1891
|
+
format!("Create {path} with the requested content."),
|
|
1892
|
+
format!("Verify {path} exists and contains exactly the requested content."),
|
|
1893
|
+
"Summarize the completed file creation.".to_string(),
|
|
1894
|
+
]
|
|
1895
|
+
} else if read_only_request {
|
|
1896
|
+
vec![
|
|
1897
|
+
"Inspect the current workspace with read-only tools.".to_string(),
|
|
1898
|
+
"List the requested files, folders, or findings.".to_string(),
|
|
1899
|
+
"Summarize the result without modifying files.".to_string(),
|
|
1900
|
+
]
|
|
1901
|
+
} else {
|
|
1902
|
+
vec![
|
|
1903
|
+
format!("Implement the user's requested task: {task}."),
|
|
1904
|
+
"Inspect only the files or directories needed for that task.".to_string(),
|
|
1905
|
+
"Make the required edits or generated files with the appropriate tools.".to_string(),
|
|
1906
|
+
"Verify the result with focused checks or tests.".to_string(),
|
|
1907
|
+
"Summarize the completed work and any remaining risks.".to_string(),
|
|
1908
|
+
]
|
|
1909
|
+
};
|
|
1910
|
+
serde_json::json!({ "steps": steps })
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
fn plan_step_is_actionable(step: &str) -> bool {
|
|
1914
|
+
let lower_step = step.to_ascii_lowercase();
|
|
1915
|
+
if lower_step.contains("exit plan")
|
|
1916
|
+
|| lower_step.contains("exit_plan")
|
|
1917
|
+
|| lower_step.contains("exitplanmode")
|
|
1918
|
+
|| lower_step.contains("plan mode")
|
|
1919
|
+
{
|
|
1920
|
+
return false;
|
|
1921
|
+
}
|
|
1922
|
+
let first = step
|
|
1923
|
+
.trim()
|
|
1924
|
+
.trim_start_matches(['`', '\'', '"', '['])
|
|
1925
|
+
.split_whitespace()
|
|
1926
|
+
.next()
|
|
1927
|
+
.unwrap_or("")
|
|
1928
|
+
.trim_matches(|c: char| !c.is_ascii_alphabetic())
|
|
1929
|
+
.to_ascii_lowercase();
|
|
1930
|
+
matches!(
|
|
1931
|
+
first.as_str(),
|
|
1932
|
+
"add"
|
|
1933
|
+
| "analyze"
|
|
1934
|
+
| "audit"
|
|
1935
|
+
| "build"
|
|
1936
|
+
| "check"
|
|
1937
|
+
| "compare"
|
|
1938
|
+
| "configure"
|
|
1939
|
+
| "create"
|
|
1940
|
+
| "document"
|
|
1941
|
+
| "edit"
|
|
1942
|
+
| "find"
|
|
1943
|
+
| "fix"
|
|
1944
|
+
| "generate"
|
|
1945
|
+
| "identify"
|
|
1946
|
+
| "implement"
|
|
1947
|
+
| "inspect"
|
|
1948
|
+
| "install"
|
|
1949
|
+
| "list"
|
|
1950
|
+
| "load"
|
|
1951
|
+
| "open"
|
|
1952
|
+
| "publish"
|
|
1953
|
+
| "read"
|
|
1954
|
+
| "refactor"
|
|
1955
|
+
| "remove"
|
|
1956
|
+
| "review"
|
|
1957
|
+
| "run"
|
|
1958
|
+
| "search"
|
|
1959
|
+
| "start"
|
|
1960
|
+
| "stop"
|
|
1961
|
+
| "summarize"
|
|
1962
|
+
| "test"
|
|
1963
|
+
| "update"
|
|
1964
|
+
| "use"
|
|
1965
|
+
| "verify"
|
|
1966
|
+
| "write"
|
|
1967
|
+
)
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
fn strip_plan_marker(line: &str) -> Option<&str> {
|
|
1971
|
+
if let Some(rest) = line.strip_prefix("- ").or_else(|| line.strip_prefix("* ")) {
|
|
1972
|
+
return Some(rest);
|
|
1973
|
+
}
|
|
1974
|
+
let mut saw_digit = false;
|
|
1975
|
+
for (idx, ch) in line.char_indices() {
|
|
1976
|
+
if ch.is_ascii_digit() {
|
|
1977
|
+
saw_digit = true;
|
|
1978
|
+
continue;
|
|
1979
|
+
}
|
|
1980
|
+
if saw_digit && matches!(ch, '.' | ')') {
|
|
1981
|
+
return Some(line[idx + ch.len_utf8()..].trim_start());
|
|
1982
|
+
}
|
|
1983
|
+
break;
|
|
1984
|
+
}
|
|
1985
|
+
None
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
fn strip_markdown_emphasis(line: &str) -> String {
|
|
1989
|
+
let mut cleaned = line.trim().to_string();
|
|
1990
|
+
if let Some(rest) = cleaned.strip_prefix("**") {
|
|
1991
|
+
cleaned = rest.to_string();
|
|
1992
|
+
if let Some(idx) = cleaned.rfind("**") {
|
|
1993
|
+
cleaned.replace_range(idx..idx + 2, "");
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
strip_step_label(cleaned.trim()).to_string()
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
fn strip_step_label(line: &str) -> &str {
|
|
2000
|
+
let lower = line.to_ascii_lowercase();
|
|
2001
|
+
if !lower.starts_with("step ") {
|
|
2002
|
+
return line;
|
|
2003
|
+
}
|
|
2004
|
+
let rest = &line[5..];
|
|
2005
|
+
let mut saw_digit = false;
|
|
2006
|
+
for (idx, ch) in rest.char_indices() {
|
|
2007
|
+
if ch.is_ascii_digit() {
|
|
2008
|
+
saw_digit = true;
|
|
2009
|
+
continue;
|
|
2010
|
+
}
|
|
2011
|
+
if saw_digit && ch == ':' {
|
|
2012
|
+
return rest[idx + 1..].trim_start();
|
|
2013
|
+
}
|
|
2014
|
+
break;
|
|
2015
|
+
}
|
|
2016
|
+
line
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
fn auto_static_artifact_input(task: &str, text: &str) -> Option<serde_json::Value> {
|
|
2020
|
+
if !static_artifact_requested(task) {
|
|
2021
|
+
return None;
|
|
2022
|
+
}
|
|
2023
|
+
let html = extract_html_from_text(text)?;
|
|
2024
|
+
let title = extract_html_title(&html).unwrap_or_else(|| "static-artifact".to_string());
|
|
2025
|
+
Some(serde_json::json!({
|
|
2026
|
+
"title": title,
|
|
2027
|
+
"contents": html,
|
|
2028
|
+
"type": "html",
|
|
2029
|
+
}))
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
fn auto_create_file_input(task: &str) -> Option<serde_json::Value> {
|
|
2033
|
+
let task = recovery_task_text(task);
|
|
2034
|
+
let lower = task.to_ascii_lowercase();
|
|
2035
|
+
let create_pos = lower.find("create ")?;
|
|
2036
|
+
let after_create = &task[create_pos + "create ".len()..];
|
|
2037
|
+
let after_lower = &lower[create_pos + "create ".len()..];
|
|
2038
|
+
let containing_pos = after_lower.find(" containing ")?;
|
|
2039
|
+
let mut raw_path = after_create[..containing_pos]
|
|
2040
|
+
.trim()
|
|
2041
|
+
.trim_matches(['`', '\'', '"']);
|
|
2042
|
+
for article in ["a ", "an ", "the "] {
|
|
2043
|
+
if raw_path.to_ascii_lowercase().starts_with(article) {
|
|
2044
|
+
raw_path = raw_path[article.len()..].trim_start();
|
|
2045
|
+
break;
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
let content = after_create[containing_pos + " containing ".len()..]
|
|
2049
|
+
.trim()
|
|
2050
|
+
.trim_matches(['`', '\'', '"']);
|
|
2051
|
+
if raw_path.is_empty()
|
|
2052
|
+
|| content.is_empty()
|
|
2053
|
+
|| raw_path.contains('\n')
|
|
2054
|
+
|| raw_path.starts_with('-')
|
|
2055
|
+
{
|
|
2056
|
+
return None;
|
|
2057
|
+
}
|
|
2058
|
+
Some(serde_json::json!({
|
|
2059
|
+
"path": raw_path,
|
|
2060
|
+
"content": content,
|
|
2061
|
+
}))
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
fn recovery_task_text(task: &str) -> String {
|
|
2065
|
+
task.split("\n\nFollow this approved plan:")
|
|
2066
|
+
.next()
|
|
2067
|
+
.unwrap_or(task)
|
|
2068
|
+
.split("\n\n[hook context]")
|
|
2069
|
+
.next()
|
|
2070
|
+
.unwrap_or(task)
|
|
2071
|
+
.trim()
|
|
2072
|
+
.to_string()
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
fn static_artifact_requested(task: &str) -> bool {
|
|
2076
|
+
let lower = task.to_lowercase();
|
|
2077
|
+
[
|
|
2078
|
+
"canvas game",
|
|
2079
|
+
"browser game",
|
|
2080
|
+
"static html",
|
|
2081
|
+
"static artifact",
|
|
2082
|
+
"single html",
|
|
2083
|
+
"standalone html",
|
|
2084
|
+
"landing page",
|
|
2085
|
+
"prototype",
|
|
2086
|
+
"demo",
|
|
2087
|
+
"website",
|
|
2088
|
+
"web app",
|
|
2089
|
+
]
|
|
2090
|
+
.iter()
|
|
2091
|
+
.any(|needle| lower.contains(needle))
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
fn static_artifact_recovery_prompt(task: &str) -> String {
|
|
2095
|
+
format!(
|
|
2096
|
+
"This is a BUILD task, not a clarification request. Proceed with reasonable defaults for: {task}\n\n\
|
|
2097
|
+
If this is a canvas/browser game or static web artifact, build the runnable artifact now. \
|
|
2098
|
+
Call Artifact/publish_artifact with one complete self-contained HTML file. \
|
|
2099
|
+
Include all CSS and JavaScript inline. Do not ask the user for assets or a concept unless the task is impossible without them."
|
|
2100
|
+
)
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
fn extract_html_from_text(text: &str) -> Option<String> {
|
|
2104
|
+
if let Some(fenced) = extract_fenced_html(text) {
|
|
2105
|
+
return Some(fenced);
|
|
2106
|
+
}
|
|
2107
|
+
let lower = text.to_lowercase();
|
|
2108
|
+
let start = lower
|
|
2109
|
+
.find("<!doctype html")
|
|
2110
|
+
.or_else(|| lower.find("<html"))?;
|
|
2111
|
+
let html = text[start..].trim();
|
|
2112
|
+
if html.contains("</html>") {
|
|
2113
|
+
Some(html.to_string())
|
|
2114
|
+
} else {
|
|
2115
|
+
None
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
fn extract_fenced_html(text: &str) -> Option<String> {
|
|
2120
|
+
let mut in_html = false;
|
|
2121
|
+
let mut out = Vec::new();
|
|
2122
|
+
for line in text.lines() {
|
|
2123
|
+
let trimmed = line.trim_start();
|
|
2124
|
+
if trimmed.starts_with("```") {
|
|
2125
|
+
if in_html {
|
|
2126
|
+
return Some(out.join("\n").trim().to_string());
|
|
2127
|
+
}
|
|
2128
|
+
let tag = trimmed.trim_start_matches("```").trim().to_lowercase();
|
|
2129
|
+
if tag == "html" || tag.is_empty() {
|
|
2130
|
+
in_html = true;
|
|
2131
|
+
out.clear();
|
|
2132
|
+
}
|
|
2133
|
+
continue;
|
|
2134
|
+
}
|
|
2135
|
+
if in_html {
|
|
2136
|
+
out.push(line);
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
None
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
fn extract_html_title(html: &str) -> Option<String> {
|
|
2143
|
+
let lower = html.to_lowercase();
|
|
2144
|
+
let start = lower.find("<title")?;
|
|
2145
|
+
let after_tag = &html[start..];
|
|
2146
|
+
let gt = after_tag.find('>')?;
|
|
2147
|
+
let rest = &after_tag[gt + 1..];
|
|
2148
|
+
let end = rest.to_lowercase().find("</title")?;
|
|
2149
|
+
let title = rest[..end].trim();
|
|
2150
|
+
(!title.is_empty()).then(|| title.to_string())
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
fn canvas_game_requested(task: &str) -> bool {
|
|
2154
|
+
let lower = task.to_lowercase();
|
|
2155
|
+
lower.contains("canvas game") || lower.contains("browser game") || lower.contains("game")
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
fn fallback_canvas_game_input(task: &str) -> serde_json::Value {
|
|
2159
|
+
let title = if task.to_lowercase().contains("orbit") {
|
|
2160
|
+
"Orbit Dodge"
|
|
2161
|
+
} else {
|
|
2162
|
+
"Canvas Dodge"
|
|
2163
|
+
};
|
|
2164
|
+
let html = format!(
|
|
2165
|
+
r#"<!doctype html>
|
|
2166
|
+
<html lang="en">
|
|
2167
|
+
<head>
|
|
2168
|
+
<meta charset="utf-8">
|
|
2169
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
2170
|
+
<title>{title}</title>
|
|
2171
|
+
<style>
|
|
2172
|
+
html,body{{margin:0;height:100%;overflow:hidden;background:#111827;color:#e5e7eb;font-family:system-ui,-apple-system,Segoe UI,sans-serif}}
|
|
2173
|
+
#wrap{{position:fixed;inset:0;display:grid;place-items:center;background:radial-gradient(circle at 50% 35%,#1f2937,#030712 70%)}}
|
|
2174
|
+
canvas{{width:min(94vw,860px);height:min(72vh,560px);background:#050816;border:1px solid #334155;box-shadow:0 24px 80px #0008}}
|
|
2175
|
+
#hud{{position:fixed;left:18px;right:18px;top:14px;display:flex;justify-content:space-between;gap:16px;font-weight:700;text-shadow:0 2px 8px #000}}
|
|
2176
|
+
#help{{position:fixed;left:18px;right:18px;bottom:14px;text-align:center;color:#9ca3af;font-size:14px}}
|
|
2177
|
+
#pad{{position:fixed;right:18px;bottom:56px;display:grid;grid-template-columns:48px 48px 48px;gap:8px}}
|
|
2178
|
+
button{{background:#1f2937cc;color:#e5e7eb;border:1px solid #475569;border-radius:8px;min-height:44px;font:inherit}}
|
|
2179
|
+
@media (pointer:fine){{#pad{{display:none}}}}
|
|
2180
|
+
</style>
|
|
2181
|
+
</head>
|
|
2182
|
+
<body>
|
|
2183
|
+
<div id="wrap"><canvas id="game" width="860" height="560"></canvas></div>
|
|
2184
|
+
<div id="hud"><span id="score">Score 0</span><span id="state">Move to start</span></div>
|
|
2185
|
+
<div id="help">WASD/Arrows move · Space/P pause · R restart · Dodge the orbiting sparks</div>
|
|
2186
|
+
<div id="pad"><span></span><button data-k="ArrowUp">↑</button><span></span><button data-k="ArrowLeft">←</button><button data-k=" ">Ⅱ</button><button data-k="ArrowRight">→</button><span></span><button data-k="ArrowDown">↓</button><span></span></div>
|
|
2187
|
+
<script>
|
|
2188
|
+
const c=document.getElementById('game'),x=c.getContext('2d'),scoreEl=document.getElementById('score'),stateEl=document.getElementById('state');
|
|
2189
|
+
const keys=new Set();let player,orbs,score,best=0,over=false,paused=false,last=0;
|
|
2190
|
+
function reset(){{player={{x:c.width/2,y:c.height/2,r:11,vx:0,vy:0}};orbs=[];score=0;over=false;paused=false;last=0;stateEl.textContent='Survive';for(let i=0;i<7;i++)orbs.push({{a:i*.9,d:70+i*34,s:.7+i*.08,r:8+i%3}});}}
|
|
2191
|
+
function key(k,on){{if(on)keys.add(k);else keys.delete(k);if(k==='r'||k==='R')reset();if(k===' '||k==='p'||k==='P')paused=!paused;}}
|
|
2192
|
+
addEventListener('keydown',e=>{{key(e.key,true);if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(e.key))e.preventDefault();}});
|
|
2193
|
+
addEventListener('keyup',e=>key(e.key,false));
|
|
2194
|
+
document.querySelectorAll('button').forEach(b=>{{b.onpointerdown=e=>{{b.setPointerCapture(e.pointerId);key(b.dataset.k,true)}};b.onpointerup=e=>key(b.dataset.k,false);}});
|
|
2195
|
+
function step(t){{requestAnimationFrame(step);let dt=Math.min(.033,(t-last)/1000||0);last=t;if(paused){{draw();stateEl.textContent='Paused';return}}if(over){{draw();stateEl.textContent='Game over · R to restart';return}}
|
|
2196
|
+
let ax=(keys.has('ArrowRight')||keys.has('d')||keys.has('D'))-(keys.has('ArrowLeft')||keys.has('a')||keys.has('A'));
|
|
2197
|
+
let ay=(keys.has('ArrowDown')||keys.has('s')||keys.has('S'))-(keys.has('ArrowUp')||keys.has('w')||keys.has('W'));
|
|
2198
|
+
player.vx=(player.vx+ax*900*dt)*.86;player.vy=(player.vy+ay*900*dt)*.86;player.x=Math.max(player.r,Math.min(c.width-player.r,player.x+player.vx*dt));player.y=Math.max(player.r,Math.min(c.height-player.r,player.y+player.vy*dt));
|
|
2199
|
+
score+=dt*10;best=Math.max(best,score);scoreEl.textContent=`Score ${{score|0}} · Best ${{best|0}}`;
|
|
2200
|
+
for(const o of orbs){{o.a+=o.s*dt;let ox=c.width/2+Math.cos(o.a)*o.d,oy=c.height/2+Math.sin(o.a*1.35)*o.d;if(Math.hypot(player.x-ox,player.y-oy)<player.r+o.r)over=true;}}
|
|
2201
|
+
draw();}}
|
|
2202
|
+
function draw(){{x.clearRect(0,0,c.width,c.height);x.fillStyle='#111827';x.fillRect(0,0,c.width,c.height);x.strokeStyle='#1f9cf0';x.globalAlpha=.18;for(let r=70;r<330;r+=34){{x.beginPath();x.ellipse(c.width/2,c.height/2,r,r*.72,0,0,7);x.stroke();}}x.globalAlpha=1;for(const o of orbs){{let ox=c.width/2+Math.cos(o.a)*o.d,oy=c.height/2+Math.sin(o.a*1.35)*o.d;x.fillStyle='#f97316';x.beginPath();x.arc(ox,oy,o.r,0,7);x.fill();}}x.fillStyle=over?'#ef4444':'#22c55e';x.beginPath();x.arc(player.x,player.y,player.r,0,7);x.fill();}}
|
|
2203
|
+
reset();requestAnimationFrame(step);
|
|
2204
|
+
</script>
|
|
2205
|
+
</body>
|
|
2206
|
+
</html>"#
|
|
2207
|
+
);
|
|
2208
|
+
serde_json::json!({
|
|
2209
|
+
"title": title,
|
|
2210
|
+
"contents": html,
|
|
2211
|
+
"type": "html",
|
|
2212
|
+
})
|
|
2213
|
+
}
|
|
2214
|
+
|
|
585
2215
|
// ── PLAN mode ─────────────────────────────────────────────────────────────────
|
|
586
2216
|
// The planning phase now has tools available so the model can inspect the
|
|
587
2217
|
// codebase while breaking down the task. Execution still runs through BUILD.
|
|
588
2218
|
pub fn run_plan(p: &Provider, perm: Permission, task: &str, cwd: &Path) -> Result<(), String> {
|
|
589
|
-
let prefix = context_prefix();
|
|
590
|
-
let sys = format!(
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
2219
|
+
let prefix = context_prefix(cwd, p.context_tokens);
|
|
2220
|
+
let sys = format!(
|
|
2221
|
+
"{prefix}You are a planning engineer with full access to the codebase. \
|
|
2222
|
+
Use read_file/list_dir/list_tree/find_paths/grep_files/fetch_url and read-only bash/run_command calls to inspect the project as needed. \
|
|
2223
|
+
Do not write files, edit files, apply patches, spawn subagents, or run mutating shell commands while planning. \
|
|
2224
|
+
When ready, call exit_plan or ExitPlanMode with a concise numbered implementation plan. \
|
|
2225
|
+
The plan must be concrete, actionable, and at most 8 steps. \
|
|
2226
|
+
Do not include code fences, shell snippets, intro text, or outro text."
|
|
2227
|
+
);
|
|
2228
|
+
|
|
2229
|
+
let defs = tools::defs_readonly(); // planning inspects context but never writes
|
|
596
2230
|
let mut msgs = vec![Msg::System(sys), Msg::User(task.into())];
|
|
2231
|
+
let mut loop_guard = ToolLoopGuard::default();
|
|
2232
|
+
let mut tool_rounds = 0usize;
|
|
2233
|
+
let mut plan_format_recovery_count = 0usize;
|
|
2234
|
+
let mut plan_loop_recovery_count = 0usize;
|
|
597
2235
|
|
|
598
2236
|
// Let the model use tools while planning (e.g. read files to understand structure).
|
|
599
|
-
let plan_text = loop {
|
|
2237
|
+
let plan_text = 'planning: loop {
|
|
2238
|
+
tool_rounds += 1;
|
|
2239
|
+
if tool_rounds > MAX_PLAN_TOOL_ROUNDS {
|
|
2240
|
+
return Err(format!(
|
|
2241
|
+
"planning stopped after {MAX_PLAN_TOOL_ROUNDS} tool rounds without producing a plan"
|
|
2242
|
+
));
|
|
2243
|
+
}
|
|
600
2244
|
maybe_compact(p, &mut msgs);
|
|
601
|
-
let
|
|
2245
|
+
let start_instant = std::time::Instant::now();
|
|
2246
|
+
let reply = request_reply(p, &msgs, &defs, "planning")?;
|
|
2247
|
+
let reply = normalize_text_tool_calls(reply, &defs, task);
|
|
2248
|
+
let elapsed = start_instant.elapsed().as_secs_f64();
|
|
2249
|
+
let gen_toks = (reply.text.len() / 4) + (reply.calls.len() * 40);
|
|
2250
|
+
if !report::is_json() {
|
|
2251
|
+
tui::inference_telemetry(gen_toks.max(10), elapsed);
|
|
2252
|
+
}
|
|
602
2253
|
|
|
603
2254
|
if reply.calls.is_empty() {
|
|
2255
|
+
let candidate_steps = parse_plan_steps(&reply.text);
|
|
2256
|
+
if !plan_steps_are_actionable(&candidate_steps) && plan_format_recovery_count < 2 {
|
|
2257
|
+
plan_format_recovery_count += 1;
|
|
2258
|
+
msgs.push(Msg::Assistant {
|
|
2259
|
+
text: reply.text.clone(),
|
|
2260
|
+
calls: vec![],
|
|
2261
|
+
});
|
|
2262
|
+
msgs.push(Msg::User(format!(
|
|
2263
|
+
"That was not a valid plan. Use the actual current workspace, not placeholder paths: {}\n\
|
|
2264
|
+
If you need context, call read-only tools now. Otherwise output only a concise numbered list of concrete steps, max 8, with no prose.",
|
|
2265
|
+
cwd.display()
|
|
2266
|
+
)));
|
|
2267
|
+
continue;
|
|
2268
|
+
}
|
|
2269
|
+
if !plan_steps_are_actionable(&candidate_steps) {
|
|
2270
|
+
let fallback = fallback_exit_plan_input(task);
|
|
2271
|
+
report::tool_call(
|
|
2272
|
+
"exit_plan",
|
|
2273
|
+
&tools::preview("exit_plan", &fallback),
|
|
2274
|
+
&fallback,
|
|
2275
|
+
);
|
|
2276
|
+
trace_tool_call("exit_plan", &fallback, "plan", 0);
|
|
2277
|
+
let out = tools::run("exit_plan", &fallback, cwd);
|
|
2278
|
+
report::tool_result("exit_plan", &out.content, out.is_error);
|
|
2279
|
+
trace_tool_result("exit_plan", &out.content, out.is_error, "plan", 0);
|
|
2280
|
+
if !out.is_error && plan_steps_are_actionable(&parse_plan_steps(&out.content)) {
|
|
2281
|
+
break out.content;
|
|
2282
|
+
}
|
|
2283
|
+
return Err(out.content);
|
|
2284
|
+
}
|
|
604
2285
|
break reply.text;
|
|
605
2286
|
}
|
|
606
2287
|
|
|
607
2288
|
// Execute tool calls during the planning phase.
|
|
608
2289
|
let mut results = Vec::new();
|
|
2290
|
+
let mut loop_summary: Option<String> = None;
|
|
609
2291
|
for call in &reply.calls {
|
|
610
|
-
|
|
611
|
-
|
|
2292
|
+
if let Some(raw) = call.input.get(tools::INVALID_ARGS).and_then(|v| v.as_str()) {
|
|
2293
|
+
let msg = format!(
|
|
2294
|
+
"tool arguments were not valid JSON: {}",
|
|
2295
|
+
raw.chars().take(200).collect::<String>()
|
|
2296
|
+
);
|
|
2297
|
+
report::tool_denied(&msg);
|
|
2298
|
+
note_loop_result(
|
|
2299
|
+
&mut loop_guard,
|
|
2300
|
+
&call.name,
|
|
2301
|
+
&call.input,
|
|
2302
|
+
&msg,
|
|
2303
|
+
true,
|
|
2304
|
+
&mut loop_summary,
|
|
2305
|
+
);
|
|
2306
|
+
results.push(ToolResult {
|
|
2307
|
+
id: call.id.clone(),
|
|
2308
|
+
content: msg,
|
|
2309
|
+
is_error: true,
|
|
2310
|
+
});
|
|
2311
|
+
continue;
|
|
2312
|
+
}
|
|
2313
|
+
let call_input =
|
|
2314
|
+
tool_input_for_execution(&call.name, &call.input, cwd, "plan", 0, task);
|
|
2315
|
+
report::tool_call(
|
|
2316
|
+
&call.name,
|
|
2317
|
+
&tools::preview(&call.name, &call_input),
|
|
2318
|
+
&call_input,
|
|
2319
|
+
);
|
|
2320
|
+
trace_tool_call(&call.name, &call_input, "plan", 0);
|
|
2321
|
+
if is_exit_plan_tool(&call.name) {
|
|
2322
|
+
let out = tools::run(&call.name, &call_input, cwd);
|
|
2323
|
+
let steps = parse_plan_steps(&out.content);
|
|
2324
|
+
let invalid_plan = out.is_error || !plan_steps_are_actionable(&steps);
|
|
2325
|
+
let content = if invalid_plan && !out.is_error {
|
|
2326
|
+
format!(
|
|
2327
|
+
"exit_plan rejected: provide an actionable numbered or bulleted plan for the user's task, not prose, a file list, or a description of exit_plan itself.\nCurrent user task: {task}\nCall exit_plan again with concrete task steps."
|
|
2328
|
+
)
|
|
2329
|
+
} else {
|
|
2330
|
+
out.content.clone()
|
|
2331
|
+
};
|
|
2332
|
+
report::tool_result(&call.name, &content, invalid_plan);
|
|
2333
|
+
trace_tool_result(&call.name, &content, invalid_plan, "plan", 0);
|
|
2334
|
+
if invalid_plan {
|
|
2335
|
+
results.push(ToolResult {
|
|
2336
|
+
id: call.id.clone(),
|
|
2337
|
+
content,
|
|
2338
|
+
is_error: true,
|
|
2339
|
+
});
|
|
2340
|
+
let fallback = fallback_exit_plan_input(task);
|
|
2341
|
+
report::tool_call(
|
|
2342
|
+
"exit_plan",
|
|
2343
|
+
&tools::preview("exit_plan", &fallback),
|
|
2344
|
+
&fallback,
|
|
2345
|
+
);
|
|
2346
|
+
trace_tool_call("exit_plan", &fallback, "plan", 0);
|
|
2347
|
+
let fallback_out = tools::run("exit_plan", &fallback, cwd);
|
|
2348
|
+
report::tool_result("exit_plan", &fallback_out.content, fallback_out.is_error);
|
|
2349
|
+
trace_tool_result(
|
|
2350
|
+
"exit_plan",
|
|
2351
|
+
&fallback_out.content,
|
|
2352
|
+
fallback_out.is_error,
|
|
2353
|
+
"plan",
|
|
2354
|
+
0,
|
|
2355
|
+
);
|
|
2356
|
+
if !fallback_out.is_error
|
|
2357
|
+
&& plan_steps_are_actionable(&parse_plan_steps(&fallback_out.content))
|
|
2358
|
+
{
|
|
2359
|
+
break 'planning fallback_out.content;
|
|
2360
|
+
}
|
|
2361
|
+
return Err(fallback_out.content);
|
|
2362
|
+
}
|
|
2363
|
+
break 'planning out.content;
|
|
2364
|
+
}
|
|
2365
|
+
if call.name == "question" || call.name == "AskUserQuestion" {
|
|
2366
|
+
let (answer, is_error) = answer_question(&call_input);
|
|
2367
|
+
report::tool_result(&call.name, &answer, is_error);
|
|
2368
|
+
trace_tool_result(&call.name, &answer, is_error, "plan", 0);
|
|
2369
|
+
note_loop_result(
|
|
2370
|
+
&mut loop_guard,
|
|
2371
|
+
&call.name,
|
|
2372
|
+
&call_input,
|
|
2373
|
+
&answer,
|
|
2374
|
+
is_error,
|
|
2375
|
+
&mut loop_summary,
|
|
2376
|
+
);
|
|
2377
|
+
results.push(ToolResult {
|
|
2378
|
+
id: call.id.clone(),
|
|
2379
|
+
content: answer,
|
|
2380
|
+
is_error,
|
|
2381
|
+
});
|
|
2382
|
+
continue;
|
|
2383
|
+
}
|
|
2384
|
+
let reason = match hooks::pre_tool_use(&call.name, &call_input, cwd) {
|
|
612
2385
|
PreDecision::Deny(r) => Some(r),
|
|
613
2386
|
PreDecision::Allow => None,
|
|
614
|
-
PreDecision::Continue => gate(
|
|
2387
|
+
PreDecision::Continue => gate(Permission::ReadOnly, &call.name, &call_input, cwd),
|
|
615
2388
|
};
|
|
616
2389
|
if let Some(reason) = reason {
|
|
617
|
-
|
|
2390
|
+
trace::record_visible(
|
|
2391
|
+
"tool_denied",
|
|
2392
|
+
format!("{} denied", call.name),
|
|
2393
|
+
serde_json::json!({"tool": call.name, "reason": reason, "input": &call_input, "phase": "plan", "depth": 0}),
|
|
2394
|
+
);
|
|
2395
|
+
note_loop_result(
|
|
2396
|
+
&mut loop_guard,
|
|
2397
|
+
&call.name,
|
|
2398
|
+
&call_input,
|
|
2399
|
+
&reason,
|
|
2400
|
+
true,
|
|
2401
|
+
&mut loop_summary,
|
|
2402
|
+
);
|
|
2403
|
+
results.push(ToolResult {
|
|
2404
|
+
id: call.id.clone(),
|
|
2405
|
+
content: reason,
|
|
2406
|
+
is_error: true,
|
|
2407
|
+
});
|
|
618
2408
|
continue;
|
|
619
2409
|
}
|
|
620
2410
|
if call.name == "save_memory" {
|
|
621
|
-
if let Some(note) =
|
|
2411
|
+
if let Some(note) = call_input["note"].as_str() {
|
|
622
2412
|
config::append_memory(note);
|
|
623
2413
|
let msg = "memory saved".to_string();
|
|
624
2414
|
report::tool_result(&call.name, &msg, false);
|
|
625
|
-
|
|
2415
|
+
trace_tool_result(&call.name, &msg, false, "plan", 0);
|
|
2416
|
+
results.push(ToolResult {
|
|
2417
|
+
id: call.id.clone(),
|
|
2418
|
+
content: msg,
|
|
2419
|
+
is_error: false,
|
|
2420
|
+
});
|
|
626
2421
|
continue;
|
|
627
2422
|
}
|
|
628
2423
|
}
|
|
629
|
-
let out = tools::run(&call.name, &
|
|
2424
|
+
let out = tools::run(&call.name, &call_input, cwd);
|
|
630
2425
|
report::tool_result(&call.name, &out.content, out.is_error);
|
|
631
|
-
|
|
2426
|
+
trace_tool_result(&call.name, &out.content, out.is_error, "plan", 0);
|
|
2427
|
+
note_loop_result(
|
|
2428
|
+
&mut loop_guard,
|
|
2429
|
+
&call.name,
|
|
2430
|
+
&call_input,
|
|
2431
|
+
&out.content,
|
|
2432
|
+
out.is_error,
|
|
2433
|
+
&mut loop_summary,
|
|
2434
|
+
);
|
|
2435
|
+
results.push(ToolResult {
|
|
2436
|
+
id: call.id.clone(),
|
|
2437
|
+
content: out.content,
|
|
2438
|
+
is_error: out.is_error,
|
|
2439
|
+
});
|
|
632
2440
|
}
|
|
633
|
-
msgs.push(Msg::Assistant {
|
|
2441
|
+
msgs.push(Msg::Assistant {
|
|
2442
|
+
text: assistant_tool_turn_text(reply.text, &reply.calls),
|
|
2443
|
+
calls: reply.calls,
|
|
2444
|
+
});
|
|
634
2445
|
msgs.push(Msg::Tool(results));
|
|
2446
|
+
if let Some(loop_msg) = loop_summary {
|
|
2447
|
+
if plan_loop_recovery_count < 2 {
|
|
2448
|
+
plan_loop_recovery_count += 1;
|
|
2449
|
+
msgs.push(Msg::User(format!(
|
|
2450
|
+
"You are repeating the same inspection instead of exiting plan mode.\n\
|
|
2451
|
+
Use the gathered result below and call exit_plan/ExitPlanMode now with a concise actionable plan. \
|
|
2452
|
+
Do not call the same tool again.\n\n{loop_msg}"
|
|
2453
|
+
)));
|
|
2454
|
+
continue;
|
|
2455
|
+
}
|
|
2456
|
+
return Err(loop_msg);
|
|
2457
|
+
}
|
|
635
2458
|
};
|
|
636
2459
|
|
|
637
|
-
let mut steps
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
2460
|
+
let mut steps = parse_plan_steps(&plan_text);
|
|
2461
|
+
if !plan_steps_are_actionable(&steps) {
|
|
2462
|
+
return Err(
|
|
2463
|
+
"planning did not produce an actionable numbered or bulleted plan after recovery attempts"
|
|
2464
|
+
.into(),
|
|
2465
|
+
);
|
|
2466
|
+
}
|
|
642
2467
|
|
|
643
2468
|
loop {
|
|
644
2469
|
tui::line("");
|
|
@@ -647,8 +2472,13 @@ pub fn run_plan(p: &Provider, perm: Permission, task: &str, cwd: &Path) -> Resul
|
|
|
647
2472
|
tui::line(&format!(" {}. {}", i + 1, s));
|
|
648
2473
|
}
|
|
649
2474
|
tui::line("");
|
|
650
|
-
let ans = tui::ask(&format!(
|
|
651
|
-
|
|
2475
|
+
let ans = tui::ask(&format!(
|
|
2476
|
+
" {} execute, {} edit <n>, {} cancel: ",
|
|
2477
|
+
tui::bold("[Enter]"),
|
|
2478
|
+
tui::bold("e"),
|
|
2479
|
+
tui::bold("c")
|
|
2480
|
+
))
|
|
2481
|
+
.unwrap_or_default();
|
|
652
2482
|
let a = ans.trim();
|
|
653
2483
|
if a.is_empty() || a == "y" {
|
|
654
2484
|
break;
|
|
@@ -670,18 +2500,41 @@ pub fn run_plan(p: &Provider, perm: Permission, task: &str, cwd: &Path) -> Resul
|
|
|
670
2500
|
}
|
|
671
2501
|
}
|
|
672
2502
|
|
|
673
|
-
let plan = steps
|
|
674
|
-
|
|
2503
|
+
let plan = steps
|
|
2504
|
+
.iter()
|
|
2505
|
+
.enumerate()
|
|
2506
|
+
.map(|(i, s)| format!("{}. {}", i + 1, s))
|
|
2507
|
+
.collect::<Vec<_>>()
|
|
2508
|
+
.join("\n");
|
|
2509
|
+
let full = approved_plan_build_task(task, &plan);
|
|
675
2510
|
run_build(p, perm, "engineer", &full, cwd)
|
|
676
2511
|
}
|
|
677
2512
|
|
|
2513
|
+
fn approved_plan_build_task(task: &str, plan: &str) -> String {
|
|
2514
|
+
format!(
|
|
2515
|
+
"{task}\n\nFollow this approved plan:\n{plan}\n\n\
|
|
2516
|
+
BUILD EXECUTION DIRECTIVE:\n\
|
|
2517
|
+
- The user already approved this plan. Do not re-plan, ask to proceed, or answer with a plan.\n\
|
|
2518
|
+
- Execute the approved steps now using tools.\n\
|
|
2519
|
+
- If the task requires files/artifacts, create or edit them on disk instead of pasting code in chat.\n\
|
|
2520
|
+
- If the task requires a server or browser check, use start_server, wait_for_url, read_server_log, and open_browser where appropriate.\n\
|
|
2521
|
+
- Run focused verification when possible.\n\
|
|
2522
|
+
- Finish with the finish tool once the approved plan has been executed."
|
|
2523
|
+
)
|
|
2524
|
+
}
|
|
2525
|
+
|
|
678
2526
|
// ── BRAINSTORM mode ───────────────────────────────────────────────────────────
|
|
679
2527
|
// Brainstorm is conversational but has full tool access. The model can grep,
|
|
680
2528
|
// read files, fetch URLs, and run commands when the conversation calls for it.
|
|
681
2529
|
// It also has a mode-transition sensor: if it detects the user wants to build
|
|
682
2530
|
// or plan, it suggests switching.
|
|
683
|
-
pub fn run_brainstorm(
|
|
684
|
-
|
|
2531
|
+
pub fn run_brainstorm(
|
|
2532
|
+
p: &Provider,
|
|
2533
|
+
perm: Permission,
|
|
2534
|
+
cwd: &Path,
|
|
2535
|
+
first: &str,
|
|
2536
|
+
) -> Result<Option<ModeHint>, String> {
|
|
2537
|
+
let prefix = context_prefix(cwd, p.context_tokens);
|
|
685
2538
|
let sys = format!("{prefix}You are a sharp, concise thought partner with full access to the codebase and the internet. \
|
|
686
2539
|
Use tools freely to look things up, read files, grep for patterns, or run commands — \
|
|
687
2540
|
whatever helps the conversation. \
|
|
@@ -689,42 +2542,39 @@ pub fn run_brainstorm(p: &Provider, perm: Permission, cwd: &Path, first: &str) -
|
|
|
689
2542
|
end your response with the exact token [SUGGEST:BUILD] or [SUGGEST:PLAN] on its own line. \
|
|
690
2543
|
Otherwise just respond naturally. No fluff.");
|
|
691
2544
|
|
|
692
|
-
let defs = tools::
|
|
2545
|
+
let defs = tools::defs_for_context(false, p.context_tokens);
|
|
693
2546
|
let mut msgs: Vec<Msg> = vec![Msg::System(sys)];
|
|
694
2547
|
let mut question = first.to_string();
|
|
2548
|
+
let mut loop_guard = ToolLoopGuard::default();
|
|
695
2549
|
|
|
696
2550
|
loop {
|
|
697
2551
|
msgs.push(Msg::User(question.clone()));
|
|
698
2552
|
maybe_compact(p, &mut msgs);
|
|
699
2553
|
|
|
700
2554
|
// Keep consuming tool calls until the model gives a text response.
|
|
2555
|
+
let mut tool_rounds = 0usize;
|
|
701
2556
|
let reply_text = loop {
|
|
2557
|
+
tool_rounds += 1;
|
|
2558
|
+
if tool_rounds > MAX_CHAT_TOOL_ROUNDS {
|
|
2559
|
+
break format!(
|
|
2560
|
+
"I stopped after {MAX_CHAT_TOOL_ROUNDS} tool rounds without a final response."
|
|
2561
|
+
);
|
|
2562
|
+
}
|
|
702
2563
|
tui::line("");
|
|
703
|
-
let
|
|
704
|
-
let
|
|
705
|
-
let
|
|
706
|
-
let
|
|
707
|
-
let
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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(); }
|
|
2564
|
+
let start_instant = std::time::Instant::now();
|
|
2565
|
+
let reply = request_reply(p, &msgs, &defs, "thinking")?;
|
|
2566
|
+
let reply = normalize_text_tool_calls(reply, &defs, &question);
|
|
2567
|
+
let elapsed = start_instant.elapsed().as_secs_f64();
|
|
2568
|
+
let gen_toks = (reply.text.len() / 4) + (reply.calls.len() * 40);
|
|
2569
|
+
if !report::is_json() {
|
|
2570
|
+
tui::inference_telemetry(gen_toks.max(10), elapsed);
|
|
2571
|
+
}
|
|
725
2572
|
|
|
726
2573
|
if reply.calls.is_empty() {
|
|
727
|
-
msgs.push(Msg::Assistant {
|
|
2574
|
+
msgs.push(Msg::Assistant {
|
|
2575
|
+
text: reply.text.clone(),
|
|
2576
|
+
calls: vec![],
|
|
2577
|
+
});
|
|
728
2578
|
if !report::is_json() {
|
|
729
2579
|
tui::context_meter(estimate_tokens(&msgs), p.context_tokens);
|
|
730
2580
|
}
|
|
@@ -733,37 +2583,132 @@ pub fn run_brainstorm(p: &Provider, perm: Permission, cwd: &Path, first: &str) -
|
|
|
733
2583
|
|
|
734
2584
|
// Execute tool calls inline.
|
|
735
2585
|
let mut results = Vec::new();
|
|
2586
|
+
let mut loop_summary: Option<String> = None;
|
|
736
2587
|
for call in &reply.calls {
|
|
737
|
-
|
|
738
|
-
|
|
2588
|
+
if let Some(raw) = call.input.get(tools::INVALID_ARGS).and_then(|v| v.as_str()) {
|
|
2589
|
+
let msg = format!(
|
|
2590
|
+
"tool arguments were not valid JSON: {}",
|
|
2591
|
+
raw.chars().take(200).collect::<String>()
|
|
2592
|
+
);
|
|
2593
|
+
report::tool_denied(&msg);
|
|
2594
|
+
note_loop_result(
|
|
2595
|
+
&mut loop_guard,
|
|
2596
|
+
&call.name,
|
|
2597
|
+
&call.input,
|
|
2598
|
+
&msg,
|
|
2599
|
+
true,
|
|
2600
|
+
&mut loop_summary,
|
|
2601
|
+
);
|
|
2602
|
+
results.push(ToolResult {
|
|
2603
|
+
id: call.id.clone(),
|
|
2604
|
+
content: msg,
|
|
2605
|
+
is_error: true,
|
|
2606
|
+
});
|
|
2607
|
+
continue;
|
|
2608
|
+
}
|
|
2609
|
+
let call_input = tool_input_for_execution(
|
|
2610
|
+
&call.name,
|
|
2611
|
+
&call.input,
|
|
2612
|
+
cwd,
|
|
2613
|
+
"brainstorm",
|
|
2614
|
+
0,
|
|
2615
|
+
&question,
|
|
2616
|
+
);
|
|
2617
|
+
report::tool_call(
|
|
2618
|
+
&call.name,
|
|
2619
|
+
&tools::preview(&call.name, &call_input),
|
|
2620
|
+
&call_input,
|
|
2621
|
+
);
|
|
2622
|
+
trace_tool_call(&call.name, &call_input, "brainstorm", 0);
|
|
2623
|
+
if call.name == "question" || call.name == "AskUserQuestion" {
|
|
2624
|
+
let (answer, is_error) = answer_question(&call_input);
|
|
2625
|
+
report::tool_result(&call.name, &answer, is_error);
|
|
2626
|
+
trace_tool_result(&call.name, &answer, is_error, "brainstorm", 0);
|
|
2627
|
+
note_loop_result(
|
|
2628
|
+
&mut loop_guard,
|
|
2629
|
+
&call.name,
|
|
2630
|
+
&call_input,
|
|
2631
|
+
&answer,
|
|
2632
|
+
is_error,
|
|
2633
|
+
&mut loop_summary,
|
|
2634
|
+
);
|
|
2635
|
+
results.push(ToolResult {
|
|
2636
|
+
id: call.id.clone(),
|
|
2637
|
+
content: answer,
|
|
2638
|
+
is_error,
|
|
2639
|
+
});
|
|
2640
|
+
continue;
|
|
2641
|
+
}
|
|
2642
|
+
let reason = match hooks::pre_tool_use(&call.name, &call_input, cwd) {
|
|
739
2643
|
PreDecision::Deny(r) => Some(r),
|
|
740
2644
|
PreDecision::Allow => None,
|
|
741
|
-
PreDecision::Continue => gate(perm, &call.name, &
|
|
2645
|
+
PreDecision::Continue => gate(perm, &call.name, &call_input, cwd),
|
|
742
2646
|
};
|
|
743
2647
|
if let Some(reason) = reason {
|
|
744
|
-
|
|
2648
|
+
trace::record_visible(
|
|
2649
|
+
"tool_denied",
|
|
2650
|
+
format!("{} denied", call.name),
|
|
2651
|
+
serde_json::json!({"tool": call.name, "reason": reason, "input": &call_input, "phase": "brainstorm", "depth": 0}),
|
|
2652
|
+
);
|
|
2653
|
+
note_loop_result(
|
|
2654
|
+
&mut loop_guard,
|
|
2655
|
+
&call.name,
|
|
2656
|
+
&call_input,
|
|
2657
|
+
&reason,
|
|
2658
|
+
true,
|
|
2659
|
+
&mut loop_summary,
|
|
2660
|
+
);
|
|
2661
|
+
results.push(ToolResult {
|
|
2662
|
+
id: call.id.clone(),
|
|
2663
|
+
content: reason,
|
|
2664
|
+
is_error: true,
|
|
2665
|
+
});
|
|
745
2666
|
continue;
|
|
746
2667
|
}
|
|
747
2668
|
if call.name == "save_memory" {
|
|
748
|
-
if let Some(note) =
|
|
2669
|
+
if let Some(note) = call_input["note"].as_str() {
|
|
749
2670
|
config::append_memory(note);
|
|
750
2671
|
let msg = "memory saved".to_string();
|
|
751
2672
|
report::tool_result(&call.name, &msg, false);
|
|
752
|
-
|
|
2673
|
+
trace_tool_result(&call.name, &msg, false, "brainstorm", 0);
|
|
2674
|
+
results.push(ToolResult {
|
|
2675
|
+
id: call.id.clone(),
|
|
2676
|
+
content: msg,
|
|
2677
|
+
is_error: false,
|
|
2678
|
+
});
|
|
753
2679
|
continue;
|
|
754
2680
|
}
|
|
755
2681
|
}
|
|
756
|
-
let out = tools::run(&call.name, &
|
|
757
|
-
hooks::post_tool_use(&call.name, &
|
|
2682
|
+
let out = tools::run(&call.name, &call_input, cwd);
|
|
2683
|
+
hooks::post_tool_use(&call.name, &call_input, &out.content, out.is_error, cwd);
|
|
758
2684
|
report::tool_result(&call.name, &out.content, out.is_error);
|
|
759
|
-
|
|
2685
|
+
trace_tool_result(&call.name, &out.content, out.is_error, "brainstorm", 0);
|
|
2686
|
+
note_loop_result(
|
|
2687
|
+
&mut loop_guard,
|
|
2688
|
+
&call.name,
|
|
2689
|
+
&call_input,
|
|
2690
|
+
&out.content,
|
|
2691
|
+
out.is_error,
|
|
2692
|
+
&mut loop_summary,
|
|
2693
|
+
);
|
|
2694
|
+
results.push(ToolResult {
|
|
2695
|
+
id: call.id.clone(),
|
|
2696
|
+
content: out.content,
|
|
2697
|
+
is_error: out.is_error,
|
|
2698
|
+
});
|
|
760
2699
|
}
|
|
761
|
-
msgs.push(Msg::Assistant {
|
|
2700
|
+
msgs.push(Msg::Assistant {
|
|
2701
|
+
text: assistant_tool_turn_text(reply.text, &reply.calls),
|
|
2702
|
+
calls: reply.calls,
|
|
2703
|
+
});
|
|
762
2704
|
msgs.push(Msg::Tool(results));
|
|
763
2705
|
if !report::is_json() {
|
|
764
2706
|
tui::context_meter(estimate_tokens(&msgs), p.context_tokens);
|
|
765
2707
|
tui::poll_typeahead();
|
|
766
2708
|
}
|
|
2709
|
+
if let Some(loop_msg) = loop_summary {
|
|
2710
|
+
break loop_msg;
|
|
2711
|
+
}
|
|
767
2712
|
};
|
|
768
2713
|
|
|
769
2714
|
// Check for mode-transition suggestion embedded in the reply.
|
|
@@ -778,8 +2723,8 @@ pub fn run_brainstorm(p: &Provider, perm: Permission, cwd: &Path, first: &str) -
|
|
|
778
2723
|
if let Some(ref h) = hint {
|
|
779
2724
|
tui::line("");
|
|
780
2725
|
let suggestion = match h {
|
|
781
|
-
ModeHint::Build
|
|
782
|
-
ModeHint::Plan
|
|
2726
|
+
ModeHint::Build => "switch to BUILD mode and implement this?",
|
|
2727
|
+
ModeHint::Plan => "switch to PLAN mode and break this down?",
|
|
783
2728
|
ModeHint::CycleMode => "cycle to the next mode?",
|
|
784
2729
|
};
|
|
785
2730
|
tui::line(&tui::yellow(&format!(" ↪ AI suggests: {suggestion}")));
|
|
@@ -805,6 +2750,166 @@ pub fn run_brainstorm(p: &Provider, perm: Permission, cwd: &Path, first: &str) -
|
|
|
805
2750
|
}
|
|
806
2751
|
}
|
|
807
2752
|
|
|
2753
|
+
pub fn run_chat_turn(
|
|
2754
|
+
p: &Provider,
|
|
2755
|
+
perm: Permission,
|
|
2756
|
+
cwd: &Path,
|
|
2757
|
+
question: &str,
|
|
2758
|
+
) -> Result<(), String> {
|
|
2759
|
+
let prefix = context_prefix(cwd, p.context_tokens);
|
|
2760
|
+
let sys = format!(
|
|
2761
|
+
"{prefix}You are buildwithnexus in a coding terminal. Answer the user's current message naturally and concisely. \
|
|
2762
|
+
If the user asks a normal conversational question or greeting, answer in plain text and do not call tools. \
|
|
2763
|
+
If answering well requires inspecting the workspace or environment, use tools, then summarize the result. \
|
|
2764
|
+
Do not emit JSON unless a tool call is actually required by the tool protocol."
|
|
2765
|
+
);
|
|
2766
|
+
|
|
2767
|
+
let defs = tools::defs_for_context(false, p.context_tokens);
|
|
2768
|
+
let mut msgs: Vec<Msg> = vec![Msg::System(sys), Msg::User(question.to_string())];
|
|
2769
|
+
let mut loop_guard = ToolLoopGuard::default();
|
|
2770
|
+
|
|
2771
|
+
for tool_round in 1..=MAX_CHAT_TOOL_ROUNDS {
|
|
2772
|
+
maybe_compact(p, &mut msgs);
|
|
2773
|
+
let start_instant = std::time::Instant::now();
|
|
2774
|
+
let reply = request_reply(p, &msgs, &defs, "thinking")?;
|
|
2775
|
+
let reply = normalize_text_tool_calls(reply, &defs, question);
|
|
2776
|
+
let elapsed = start_instant.elapsed().as_secs_f64();
|
|
2777
|
+
let gen_toks = (reply.text.len() / 4) + (reply.calls.len() * 40);
|
|
2778
|
+
if !report::is_json() {
|
|
2779
|
+
tui::inference_telemetry(gen_toks.max(10), elapsed);
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
if reply.calls.is_empty() {
|
|
2783
|
+
if !reply.text.trim().is_empty() && !report::is_json() {
|
|
2784
|
+
tui::context_meter(estimate_tokens(&msgs), p.context_tokens);
|
|
2785
|
+
}
|
|
2786
|
+
return Ok(());
|
|
2787
|
+
}
|
|
2788
|
+
|
|
2789
|
+
let mut results = Vec::new();
|
|
2790
|
+
let mut loop_summary: Option<String> = None;
|
|
2791
|
+
for call in &reply.calls {
|
|
2792
|
+
if let Some(raw) = call.input.get(tools::INVALID_ARGS).and_then(|v| v.as_str()) {
|
|
2793
|
+
let msg = format!(
|
|
2794
|
+
"tool arguments were not valid JSON: {}",
|
|
2795
|
+
raw.chars().take(200).collect::<String>()
|
|
2796
|
+
);
|
|
2797
|
+
report::tool_denied(&msg);
|
|
2798
|
+
note_loop_result(
|
|
2799
|
+
&mut loop_guard,
|
|
2800
|
+
&call.name,
|
|
2801
|
+
&call.input,
|
|
2802
|
+
&msg,
|
|
2803
|
+
true,
|
|
2804
|
+
&mut loop_summary,
|
|
2805
|
+
);
|
|
2806
|
+
results.push(ToolResult {
|
|
2807
|
+
id: call.id.clone(),
|
|
2808
|
+
content: msg,
|
|
2809
|
+
is_error: true,
|
|
2810
|
+
});
|
|
2811
|
+
continue;
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2814
|
+
let call_input = tool_input_for_execution(
|
|
2815
|
+
&call.name,
|
|
2816
|
+
&call.input,
|
|
2817
|
+
cwd,
|
|
2818
|
+
"chat",
|
|
2819
|
+
tool_round,
|
|
2820
|
+
question,
|
|
2821
|
+
);
|
|
2822
|
+
report::tool_call(
|
|
2823
|
+
&call.name,
|
|
2824
|
+
&tools::preview(&call.name, &call_input),
|
|
2825
|
+
&call_input,
|
|
2826
|
+
);
|
|
2827
|
+
trace_tool_call(&call.name, &call_input, "chat", tool_round);
|
|
2828
|
+
|
|
2829
|
+
let reason = match hooks::pre_tool_use(&call.name, &call_input, cwd) {
|
|
2830
|
+
PreDecision::Deny(r) => Some(r),
|
|
2831
|
+
PreDecision::Allow => None,
|
|
2832
|
+
PreDecision::Continue => gate(perm, &call.name, &call_input, cwd),
|
|
2833
|
+
};
|
|
2834
|
+
if let Some(reason) = reason {
|
|
2835
|
+
report::tool_denied(&reason);
|
|
2836
|
+
trace::record_visible(
|
|
2837
|
+
"tool_denied",
|
|
2838
|
+
format!("{} denied", call.name),
|
|
2839
|
+
serde_json::json!({"tool": call.name, "reason": reason, "input": &call_input, "phase": "chat", "depth": tool_round}),
|
|
2840
|
+
);
|
|
2841
|
+
note_loop_result(
|
|
2842
|
+
&mut loop_guard,
|
|
2843
|
+
&call.name,
|
|
2844
|
+
&call_input,
|
|
2845
|
+
&reason,
|
|
2846
|
+
true,
|
|
2847
|
+
&mut loop_summary,
|
|
2848
|
+
);
|
|
2849
|
+
results.push(ToolResult {
|
|
2850
|
+
id: call.id.clone(),
|
|
2851
|
+
content: reason,
|
|
2852
|
+
is_error: true,
|
|
2853
|
+
});
|
|
2854
|
+
continue;
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
let out = tools::run(&call.name, &call_input, cwd);
|
|
2858
|
+
hooks::post_tool_use(&call.name, &call_input, &out.content, out.is_error, cwd);
|
|
2859
|
+
report::tool_result(&call.name, &out.content, out.is_error);
|
|
2860
|
+
trace_tool_result(&call.name, &out.content, out.is_error, "chat", tool_round);
|
|
2861
|
+
note_loop_result(
|
|
2862
|
+
&mut loop_guard,
|
|
2863
|
+
&call.name,
|
|
2864
|
+
&call_input,
|
|
2865
|
+
&out.content,
|
|
2866
|
+
out.is_error,
|
|
2867
|
+
&mut loop_summary,
|
|
2868
|
+
);
|
|
2869
|
+
results.push(ToolResult {
|
|
2870
|
+
id: call.id.clone(),
|
|
2871
|
+
content: out.content,
|
|
2872
|
+
is_error: out.is_error,
|
|
2873
|
+
});
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
msgs.push(Msg::Assistant {
|
|
2877
|
+
text: assistant_tool_turn_text(reply.text, &reply.calls),
|
|
2878
|
+
calls: reply.calls,
|
|
2879
|
+
});
|
|
2880
|
+
msgs.push(Msg::Tool(results));
|
|
2881
|
+
if !report::is_json() {
|
|
2882
|
+
tui::context_meter(estimate_tokens(&msgs), p.context_tokens);
|
|
2883
|
+
tui::poll_typeahead();
|
|
2884
|
+
}
|
|
2885
|
+
if let Some(loop_msg) = loop_summary {
|
|
2886
|
+
report::assistant(&loop_msg);
|
|
2887
|
+
return Ok(());
|
|
2888
|
+
}
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
report::assistant(&format!(
|
|
2892
|
+
"I stopped after {MAX_CHAT_TOOL_ROUNDS} tool rounds without a final response."
|
|
2893
|
+
));
|
|
2894
|
+
Ok(())
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2897
|
+
fn trace_tool_call(name: &str, input: &serde_json::Value, phase: &str, depth: usize) {
|
|
2898
|
+
trace::record_visible(
|
|
2899
|
+
"tool_call",
|
|
2900
|
+
tools::preview(name, input),
|
|
2901
|
+
serde_json::json!({"tool": name, "input": input, "phase": phase, "depth": depth}),
|
|
2902
|
+
);
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
fn trace_tool_result(name: &str, content: &str, is_error: bool, phase: &str, depth: usize) {
|
|
2906
|
+
trace::record_visible(
|
|
2907
|
+
"tool_result",
|
|
2908
|
+
format!("{name} {}", if is_error { "error" } else { "ok" }),
|
|
2909
|
+
serde_json::json!({"tool": name, "is_error": is_error, "content": content, "phase": phase, "depth": depth}),
|
|
2910
|
+
);
|
|
2911
|
+
}
|
|
2912
|
+
|
|
808
2913
|
#[derive(Clone)]
|
|
809
2914
|
pub enum ModeHint {
|
|
810
2915
|
Build,
|
|
@@ -820,7 +2925,11 @@ mod tests {
|
|
|
820
2925
|
#[test]
|
|
821
2926
|
fn permission_parsing() {
|
|
822
2927
|
assert!(matches!(permission("auto"), Permission::Auto));
|
|
2928
|
+
assert!(matches!(permission("acceptEdits"), Permission::Auto));
|
|
2929
|
+
assert!(matches!(permission("bypass-permissions"), Permission::Auto));
|
|
823
2930
|
assert!(matches!(permission("readonly"), Permission::ReadOnly));
|
|
2931
|
+
assert!(matches!(permission("read-only"), Permission::ReadOnly));
|
|
2932
|
+
assert!(matches!(permission("plan"), Permission::ReadOnly));
|
|
824
2933
|
assert!(matches!(permission("ask"), Permission::Ask));
|
|
825
2934
|
assert!(matches!(permission("anything-else"), Permission::Ask));
|
|
826
2935
|
assert!(matches!(permission(""), Permission::Ask));
|
|
@@ -833,38 +2942,309 @@ mod tests {
|
|
|
833
2942
|
assert!(role("ceo").system.contains("software engineer"));
|
|
834
2943
|
}
|
|
835
2944
|
|
|
2945
|
+
#[test]
|
|
2946
|
+
fn text_json_tool_call_is_parsed_from_fenced_block() {
|
|
2947
|
+
let defs = tools::defs_for_context(true, 128_000);
|
|
2948
|
+
let reply = Reply {
|
|
2949
|
+
text: "```json\n{\"name\":\"start_server\",\"arguments\":{\"command\":\"npm start\",\"cwd\":\"/tmp/app\",\"port\":3000}}\n```".to_string(),
|
|
2950
|
+
calls: vec![],
|
|
2951
|
+
};
|
|
2952
|
+
let normalized = normalize_text_tool_calls(reply, &defs, "build the app");
|
|
2953
|
+
assert_eq!(normalized.text, "");
|
|
2954
|
+
assert_eq!(normalized.calls.len(), 1);
|
|
2955
|
+
assert_eq!(normalized.calls[0].name, "start_server");
|
|
2956
|
+
assert_eq!(normalized.calls[0].input["command"], "npm start");
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2959
|
+
#[test]
|
|
2960
|
+
fn casual_text_json_mutating_tool_call_is_ignored() {
|
|
2961
|
+
let defs = tools::defs_for_context(true, 128_000);
|
|
2962
|
+
let reply = Reply {
|
|
2963
|
+
text: "```json\n{\"name\":\"start_server\",\"arguments\":{\"command\":\"npm start\",\"port\":3000}}\n```".to_string(),
|
|
2964
|
+
calls: vec![],
|
|
2965
|
+
};
|
|
2966
|
+
let normalized = normalize_text_tool_calls(reply, &defs, "hello");
|
|
2967
|
+
assert!(normalized.calls.is_empty());
|
|
2968
|
+
assert!(normalized.text.contains("ready"));
|
|
2969
|
+
assert!(!normalized.text.contains("start_server"));
|
|
2970
|
+
}
|
|
2971
|
+
|
|
2972
|
+
#[test]
|
|
2973
|
+
fn parse_plan_steps_keeps_only_list_items() {
|
|
2974
|
+
let steps = parse_plan_steps(
|
|
2975
|
+
"Intro prose.\n\
|
|
2976
|
+
1. **Inspect the content of `hello.txt`**.\n\
|
|
2977
|
+
```bash\n\
|
|
2978
|
+
write_file hello.txt nope\n\
|
|
2979
|
+
```\n\
|
|
2980
|
+
2. Step 2: Summarize the file.\n\
|
|
2981
|
+
Outro.",
|
|
2982
|
+
);
|
|
2983
|
+
assert_eq!(
|
|
2984
|
+
steps,
|
|
2985
|
+
vec![
|
|
2986
|
+
"Inspect the content of `hello.txt`.".to_string(),
|
|
2987
|
+
"Summarize the file.".to_string(),
|
|
2988
|
+
]
|
|
2989
|
+
);
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
#[test]
|
|
2993
|
+
fn parse_plan_steps_rejects_prose_only_plan() {
|
|
2994
|
+
let steps = parse_plan_steps(
|
|
2995
|
+
"The project directory does not exist. I will attempt to list files if you provide a path.",
|
|
2996
|
+
);
|
|
2997
|
+
assert!(steps.is_empty());
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
#[test]
|
|
3001
|
+
fn plan_steps_must_be_actions_not_file_lists() {
|
|
3002
|
+
let files = vec![
|
|
3003
|
+
"`.env.example`".to_string(),
|
|
3004
|
+
"`README.md`".to_string(),
|
|
3005
|
+
"`harness/`".to_string(),
|
|
3006
|
+
];
|
|
3007
|
+
assert!(!plan_steps_are_actionable(&files));
|
|
3008
|
+
let actions = vec![
|
|
3009
|
+
"Inspect the current workspace root.".to_string(),
|
|
3010
|
+
"List the top-level files.".to_string(),
|
|
3011
|
+
"Summarize the result.".to_string(),
|
|
3012
|
+
];
|
|
3013
|
+
assert!(plan_steps_are_actionable(&actions));
|
|
3014
|
+
let meta = vec!["Write a clean exit plan with concrete steps.".to_string()];
|
|
3015
|
+
assert!(!plan_steps_are_actionable(&meta));
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
#[test]
|
|
3019
|
+
fn exit_plan_tool_aliases_are_recognized() {
|
|
3020
|
+
assert!(is_exit_plan_tool("exit_plan"));
|
|
3021
|
+
assert!(is_exit_plan_tool("ExitPlanMode"));
|
|
3022
|
+
assert!(!is_exit_plan_tool("finish"));
|
|
3023
|
+
}
|
|
3024
|
+
|
|
3025
|
+
#[test]
|
|
3026
|
+
fn fallback_exit_plan_uses_readonly_steps_for_listing_tasks() {
|
|
3027
|
+
let input = fallback_exit_plan_input("inspect this project and list files; do not modify");
|
|
3028
|
+
let steps = input["steps"].as_array().unwrap();
|
|
3029
|
+
assert!(steps[0].as_str().unwrap().contains("read-only"));
|
|
3030
|
+
let plan = tools::run("exit_plan", &input, Path::new("/tmp"));
|
|
3031
|
+
assert!(!plan.is_error, "{}", plan.content);
|
|
3032
|
+
assert!(plan_steps_are_actionable(&parse_plan_steps(&plan.content)));
|
|
3033
|
+
}
|
|
3034
|
+
|
|
3035
|
+
#[test]
|
|
3036
|
+
fn fallback_exit_plan_uses_task_specific_steps_for_file_creation() {
|
|
3037
|
+
let input = fallback_exit_plan_input(
|
|
3038
|
+
"create scratch/exitplan-smoke.txt containing exit plan smoke",
|
|
3039
|
+
);
|
|
3040
|
+
let plan = tools::run("exit_plan", &input, Path::new("/tmp"));
|
|
3041
|
+
assert!(!plan.is_error, "{}", plan.content);
|
|
3042
|
+
assert!(plan.content.contains("Create scratch/exitplan-smoke.txt"));
|
|
3043
|
+
assert!(!plan.content.contains("Implement the requested change"));
|
|
3044
|
+
}
|
|
3045
|
+
|
|
3046
|
+
#[test]
|
|
3047
|
+
fn approved_plan_handoff_is_execution_directive() {
|
|
3048
|
+
let full = approved_plan_build_task(
|
|
3049
|
+
"build me a canvas game",
|
|
3050
|
+
"1. Create a single HTML artifact.\n2. Verify it opens.",
|
|
3051
|
+
);
|
|
3052
|
+
assert!(full.contains("Follow this approved plan:"));
|
|
3053
|
+
assert!(full.contains("BUILD EXECUTION DIRECTIVE"));
|
|
3054
|
+
assert!(full.contains("Do not re-plan"));
|
|
3055
|
+
assert!(full.contains("Execute the approved steps now using tools"));
|
|
3056
|
+
assert!(full.contains("finish tool"));
|
|
3057
|
+
}
|
|
3058
|
+
|
|
3059
|
+
#[test]
|
|
3060
|
+
fn recovery_task_text_strips_approved_plan_directive() {
|
|
3061
|
+
let full = approved_plan_build_task(
|
|
3062
|
+
"create scratch/exitplan-smoke.txt containing exit plan smoke",
|
|
3063
|
+
"1. Write the file.",
|
|
3064
|
+
);
|
|
3065
|
+
assert_eq!(
|
|
3066
|
+
recovery_task_text(&full),
|
|
3067
|
+
"create scratch/exitplan-smoke.txt containing exit plan smoke"
|
|
3068
|
+
);
|
|
3069
|
+
}
|
|
3070
|
+
|
|
3071
|
+
#[test]
|
|
3072
|
+
fn auto_create_file_input_parses_simple_create_request() {
|
|
3073
|
+
let input =
|
|
3074
|
+
auto_create_file_input("create scratch/exitplan-smoke.txt containing exit plan smoke")
|
|
3075
|
+
.unwrap();
|
|
3076
|
+
assert_eq!(input["path"], "scratch/exitplan-smoke.txt");
|
|
3077
|
+
assert_eq!(input["content"], "exit plan smoke");
|
|
3078
|
+
let with_article = auto_create_file_input(
|
|
3079
|
+
"Create a scratch/exitplan-smoke.txt containing exit plan smoke",
|
|
3080
|
+
)
|
|
3081
|
+
.unwrap();
|
|
3082
|
+
assert_eq!(with_article["path"], "scratch/exitplan-smoke.txt");
|
|
3083
|
+
let with_plan_context = auto_create_file_input(
|
|
3084
|
+
"create scratch/exitplan-smoke.txt containing exit plan smoke\n\nFollow this approved plan:\n1. Write the file.",
|
|
3085
|
+
)
|
|
3086
|
+
.unwrap();
|
|
3087
|
+
assert_eq!(with_plan_context["content"], "exit plan smoke");
|
|
3088
|
+
assert!(auto_create_file_input("create a thing").is_none());
|
|
3089
|
+
}
|
|
3090
|
+
|
|
3091
|
+
#[test]
|
|
3092
|
+
fn write_file_placeholder_path_is_repaired_from_create_task() {
|
|
3093
|
+
let repaired = repair_placeholder_tool_input(
|
|
3094
|
+
"write_file",
|
|
3095
|
+
&json!({"path": "~", "content": "exit plan smoke\n- Create scratch/exitplan-smoke.txt"}),
|
|
3096
|
+
Path::new("/tmp/example-project"),
|
|
3097
|
+
"create scratch/exitplan-smoke.txt containing exit plan smoke",
|
|
3098
|
+
)
|
|
3099
|
+
.unwrap();
|
|
3100
|
+
assert_eq!(repaired["path"], "scratch/exitplan-smoke.txt");
|
|
3101
|
+
assert_eq!(repaired["content"], "exit plan smoke");
|
|
3102
|
+
}
|
|
3103
|
+
|
|
3104
|
+
#[test]
|
|
3105
|
+
fn write_file_plan_contaminated_content_is_repaired_from_create_task() {
|
|
3106
|
+
let repaired = repair_placeholder_tool_input(
|
|
3107
|
+
"write",
|
|
3108
|
+
&json!({"path": "scratch/exitplan-smoke.txt", "content": "exit plan smoke\n\n1. Create scratch/exitplan-smoke.txt"}),
|
|
3109
|
+
Path::new("/tmp/example-project"),
|
|
3110
|
+
"create scratch/exitplan-smoke.txt containing exit plan smoke",
|
|
3111
|
+
)
|
|
3112
|
+
.unwrap();
|
|
3113
|
+
assert_eq!(repaired["path"], "scratch/exitplan-smoke.txt");
|
|
3114
|
+
assert_eq!(repaired["content"], "exit plan smoke");
|
|
3115
|
+
}
|
|
3116
|
+
|
|
3117
|
+
#[test]
|
|
3118
|
+
fn placeholder_project_path_is_repaired_for_discovery_tools() {
|
|
3119
|
+
let cwd = Path::new("/tmp/example-project");
|
|
3120
|
+
let repaired = repair_placeholder_tool_input(
|
|
3121
|
+
"list",
|
|
3122
|
+
&json!({"path": "/path/to/your/project"}),
|
|
3123
|
+
cwd,
|
|
3124
|
+
"inspect this project",
|
|
3125
|
+
)
|
|
3126
|
+
.unwrap();
|
|
3127
|
+
assert_eq!(repaired["path"], "/tmp/example-project");
|
|
3128
|
+
assert!(repair_placeholder_tool_input(
|
|
3129
|
+
"read_file",
|
|
3130
|
+
&json!({"path": "your-project-file.txt"}),
|
|
3131
|
+
cwd,
|
|
3132
|
+
"inspect this project"
|
|
3133
|
+
)
|
|
3134
|
+
.is_none());
|
|
3135
|
+
}
|
|
3136
|
+
|
|
3137
|
+
#[test]
|
|
3138
|
+
fn filesystem_root_is_repaired_for_current_workspace_discovery() {
|
|
3139
|
+
let cwd = Path::new("/tmp/example-project");
|
|
3140
|
+
let repaired = repair_placeholder_tool_input(
|
|
3141
|
+
"list",
|
|
3142
|
+
&json!({"path": "/"}),
|
|
3143
|
+
cwd,
|
|
3144
|
+
"inspect this project and list the top-level files",
|
|
3145
|
+
)
|
|
3146
|
+
.unwrap();
|
|
3147
|
+
assert_eq!(repaired["path"], "/tmp/example-project");
|
|
3148
|
+
}
|
|
3149
|
+
|
|
3150
|
+
#[test]
|
|
3151
|
+
fn filesystem_root_is_not_repaired_when_user_asks_for_root() {
|
|
3152
|
+
let cwd = Path::new("/tmp/example-project");
|
|
3153
|
+
assert!(repair_placeholder_tool_input(
|
|
3154
|
+
"list",
|
|
3155
|
+
&json!({"path": "/"}),
|
|
3156
|
+
cwd,
|
|
3157
|
+
"inspect the filesystem root / and list its top-level files",
|
|
3158
|
+
)
|
|
3159
|
+
.is_none());
|
|
3160
|
+
}
|
|
3161
|
+
|
|
3162
|
+
#[test]
|
|
3163
|
+
fn auto_static_artifact_extracts_html_response() {
|
|
3164
|
+
let input = auto_static_artifact_input(
|
|
3165
|
+
"build me a canvas game",
|
|
3166
|
+
"HTML:\n```html\n<!doctype html><html><head><title>Orbit</title></head><body><canvas></canvas><script>requestAnimationFrame(()=>{})</script></body></html>\n```",
|
|
3167
|
+
)
|
|
3168
|
+
.unwrap();
|
|
3169
|
+
assert_eq!(input["title"], "Orbit");
|
|
3170
|
+
assert!(input["contents"].as_str().unwrap().contains("<canvas>"));
|
|
3171
|
+
}
|
|
3172
|
+
|
|
3173
|
+
#[test]
|
|
3174
|
+
fn auto_static_artifact_ignores_non_static_tasks() {
|
|
3175
|
+
assert!(auto_static_artifact_input(
|
|
3176
|
+
"explain this code",
|
|
3177
|
+
"<!doctype html><html><body></body></html>"
|
|
3178
|
+
)
|
|
3179
|
+
.is_none());
|
|
3180
|
+
}
|
|
3181
|
+
|
|
3182
|
+
#[test]
|
|
3183
|
+
fn static_artifact_recovery_prompt_tells_model_to_build() {
|
|
3184
|
+
let prompt = static_artifact_recovery_prompt("build me a canvas game");
|
|
3185
|
+
assert!(prompt.contains("BUILD task"));
|
|
3186
|
+
assert!(prompt.contains("reasonable defaults"));
|
|
3187
|
+
assert!(prompt.contains("Artifact/publish_artifact"));
|
|
3188
|
+
assert!(prompt.contains("self-contained HTML"));
|
|
3189
|
+
}
|
|
3190
|
+
|
|
836
3191
|
#[test]
|
|
837
3192
|
fn gate_auto_allows_ordinary_mutation() {
|
|
838
3193
|
let cwd = Path::new("/proj");
|
|
839
|
-
let r = gate(
|
|
3194
|
+
let r = gate(
|
|
3195
|
+
Permission::Auto,
|
|
3196
|
+
"write_file",
|
|
3197
|
+
&json!({"path": "a.txt", "content": "x"}),
|
|
3198
|
+
cwd,
|
|
3199
|
+
);
|
|
840
3200
|
assert!(r.is_none());
|
|
841
3201
|
}
|
|
842
3202
|
|
|
843
3203
|
#[test]
|
|
844
3204
|
fn gate_auto_still_confirms_sensitive_path() {
|
|
845
3205
|
let cwd = Path::new("/proj");
|
|
846
|
-
let r = gate(
|
|
3206
|
+
let r = gate(
|
|
3207
|
+
Permission::Auto,
|
|
3208
|
+
"read_file",
|
|
3209
|
+
&json!({"path": "/proj/.env"}),
|
|
3210
|
+
cwd,
|
|
3211
|
+
);
|
|
847
3212
|
assert!(r.is_some());
|
|
848
3213
|
}
|
|
849
3214
|
|
|
850
3215
|
#[test]
|
|
851
3216
|
fn gate_auto_confirms_catastrophic_command() {
|
|
852
3217
|
let cwd = Path::new("/proj");
|
|
853
|
-
let r = gate(
|
|
3218
|
+
let r = gate(
|
|
3219
|
+
Permission::Auto,
|
|
3220
|
+
"run_command",
|
|
3221
|
+
&json!({"command": "rm -rf /"}),
|
|
3222
|
+
cwd,
|
|
3223
|
+
);
|
|
854
3224
|
assert!(r.is_some());
|
|
855
3225
|
}
|
|
856
3226
|
|
|
857
3227
|
#[test]
|
|
858
3228
|
fn gate_auto_allows_safe_command() {
|
|
859
3229
|
let cwd = Path::new("/proj");
|
|
860
|
-
let r = gate(
|
|
3230
|
+
let r = gate(
|
|
3231
|
+
Permission::Auto,
|
|
3232
|
+
"run_command",
|
|
3233
|
+
&json!({"command": "ls"}),
|
|
3234
|
+
cwd,
|
|
3235
|
+
);
|
|
861
3236
|
assert!(r.is_none());
|
|
862
3237
|
}
|
|
863
3238
|
|
|
864
3239
|
#[test]
|
|
865
3240
|
fn gate_readonly_blocks_mutation() {
|
|
866
3241
|
let cwd = Path::new("/proj");
|
|
867
|
-
let r = gate(
|
|
3242
|
+
let r = gate(
|
|
3243
|
+
Permission::ReadOnly,
|
|
3244
|
+
"write_file",
|
|
3245
|
+
&json!({"path": "a", "content": "x"}),
|
|
3246
|
+
cwd,
|
|
3247
|
+
);
|
|
868
3248
|
assert!(r.unwrap().contains("read-only"));
|
|
869
3249
|
}
|
|
870
3250
|
|
|
@@ -872,7 +3252,12 @@ mod tests {
|
|
|
872
3252
|
fn gate_readonly_allows_reads_everywhere() {
|
|
873
3253
|
// Reads outside CWD are now allowed — full filesystem access.
|
|
874
3254
|
let cwd = Path::new("/proj/work");
|
|
875
|
-
let r = gate(
|
|
3255
|
+
let r = gate(
|
|
3256
|
+
Permission::ReadOnly,
|
|
3257
|
+
"read_file",
|
|
3258
|
+
&json!({"path": "/etc/passwd"}),
|
|
3259
|
+
cwd,
|
|
3260
|
+
);
|
|
876
3261
|
assert!(r.is_none());
|
|
877
3262
|
}
|
|
878
3263
|
|
|
@@ -880,14 +3265,24 @@ mod tests {
|
|
|
880
3265
|
fn gate_ask_allows_out_of_cwd_read() {
|
|
881
3266
|
// Full filesystem read access — no longer blocked in Ask mode.
|
|
882
3267
|
let cwd = Path::new("/proj");
|
|
883
|
-
let r = gate(
|
|
3268
|
+
let r = gate(
|
|
3269
|
+
Permission::Ask,
|
|
3270
|
+
"read_file",
|
|
3271
|
+
&json!({"path": "/home/user/docs/README.md"}),
|
|
3272
|
+
cwd,
|
|
3273
|
+
);
|
|
884
3274
|
assert!(r.is_none());
|
|
885
3275
|
}
|
|
886
3276
|
|
|
887
3277
|
#[test]
|
|
888
3278
|
fn gate_ask_prompts_for_mutation() {
|
|
889
3279
|
let cwd = Path::new("/proj");
|
|
890
|
-
let r = gate(
|
|
3280
|
+
let r = gate(
|
|
3281
|
+
Permission::Ask,
|
|
3282
|
+
"write_file",
|
|
3283
|
+
&json!({"path": "a", "content": "x"}),
|
|
3284
|
+
cwd,
|
|
3285
|
+
);
|
|
891
3286
|
assert!(r.is_some()); // non-terminal → denied
|
|
892
3287
|
}
|
|
893
3288
|
|
|
@@ -895,8 +3290,18 @@ mod tests {
|
|
|
895
3290
|
fn gate_ask_allows_default_allowed_commands() {
|
|
896
3291
|
let cwd = Path::new("/proj");
|
|
897
3292
|
// git, cargo, npm are in the default allowed_commands list
|
|
898
|
-
for cmd in &[
|
|
899
|
-
|
|
3293
|
+
for cmd in &[
|
|
3294
|
+
"git status",
|
|
3295
|
+
"cargo test",
|
|
3296
|
+
"npm install",
|
|
3297
|
+
"git commit -m 'x'",
|
|
3298
|
+
] {
|
|
3299
|
+
let r = gate(
|
|
3300
|
+
Permission::Ask,
|
|
3301
|
+
"run_command",
|
|
3302
|
+
&json!({"command": cmd}),
|
|
3303
|
+
cwd,
|
|
3304
|
+
);
|
|
900
3305
|
assert!(r.is_none(), "expected {cmd} to be auto-allowed in Ask mode");
|
|
901
3306
|
}
|
|
902
3307
|
}
|
|
@@ -904,7 +3309,12 @@ mod tests {
|
|
|
904
3309
|
#[test]
|
|
905
3310
|
fn gate_ask_still_prompts_for_unknown_command() {
|
|
906
3311
|
let cwd = Path::new("/proj");
|
|
907
|
-
let r = gate(
|
|
3312
|
+
let r = gate(
|
|
3313
|
+
Permission::Ask,
|
|
3314
|
+
"run_command",
|
|
3315
|
+
&json!({"command": "mycustombinary --flag"}),
|
|
3316
|
+
cwd,
|
|
3317
|
+
);
|
|
908
3318
|
assert!(r.is_some()); // not in allowed list → denied (non-terminal)
|
|
909
3319
|
}
|
|
910
3320
|
|
|
@@ -956,4 +3366,11 @@ mod tests {
|
|
|
956
3366
|
}];
|
|
957
3367
|
assert!(structural_summary(&msgs).contains("run: ls"));
|
|
958
3368
|
}
|
|
3369
|
+
|
|
3370
|
+
#[test]
|
|
3371
|
+
fn test_session_allowlist() {
|
|
3372
|
+
assert!(!super::is_session_allowed_tool("custom_test_tool"));
|
|
3373
|
+
super::add_session_allowed_tool("custom_test_tool");
|
|
3374
|
+
assert!(super::is_session_allowed_tool("custom_test_tool"));
|
|
3375
|
+
}
|
|
959
3376
|
}
|