atom-agent 1.0.0 → 1.2.0
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/CHANGELOG.md +62 -2
- package/README.md +17 -16
- package/dist/App.js +1010 -77
- package/dist/adapters.js +108 -8
- package/dist/agent/gates.js +14 -1
- package/dist/agent/loop-guard.js +182 -0
- package/dist/agent/loop.js +781 -329
- package/dist/agent/normalize.js +151 -0
- package/dist/cli.js +16 -2
- package/dist/compact.js +128 -2
- package/dist/env-block.js +43 -5
- package/dist/scheduler.js +101 -21
- package/dist/sessions.js +524 -0
- package/dist/system.js +89 -12
- package/dist/telemetry-dashboard.js +19 -1
- package/dist/telemetry.js +55 -0
- package/dist/tools/dir-cache.js +214 -0
- package/dist/tools/filesystem.js +43 -3
- package/dist/tools/read-cache.js +160 -0
- package/dist/tools/registry.js +80 -0
- package/dist/tools/ripgrep.js +256 -0
- package/dist/tools/search.js +147 -80
- package/dist/tools/shared.js +39 -0
- package/dist/tools/shell.js +26 -5
- package/dist/tools/todo.js +1 -1
- package/dist/tools/web.js +6 -6
- package/dist/tools.js +3 -0
- package/dist/ui/diff-panel.js +55 -0
- package/dist/ui/diff-view.js +117 -0
- package/dist/ui/diff.js +422 -0
- package/dist/ui/highlight.js +120 -0
- package/dist/ui/live-host.js +18 -0
- package/dist/ui/live-tail.js +9 -3
- package/dist/ui/markdown.js +26 -2
- package/dist/ui/modals.js +22 -5
- package/dist/ui/palette.js +12 -2
- package/dist/ui/side-by-side.js +144 -0
- package/dist/ui/status-bar.js +20 -4
- package/dist/ui/status-host.js +22 -0
- package/dist/ui/stream-store.js +48 -0
- package/dist/ui/theme.js +6 -0
- package/dist/ui/todo-panel.js +10 -2
- package/dist/ui/tool-inspector.js +7 -1
- package/dist/ui/transcript.js +105 -39
- package/dist/zen.js +97 -20
- package/package.json +1 -1
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { truncateHead } from "../tools/shared.js";
|
|
2
|
+
// Safety net for custom executors (built-in tools already cap: read 64KB
|
|
3
|
+
// head + truncation note + overflow pointer ≈ 66KB, bash 8KB, webfetch 64KB
|
|
4
|
+
// + notes). The cap sits at 128KB so legitimate built-in outputs (overflow
|
|
5
|
+
// pointers included) pass through byte-identical; only oversized custom
|
|
6
|
+
// results truncate.
|
|
7
|
+
export const TOOL_RESULT_CAP_CHARS = 128 * 1024;
|
|
8
|
+
export function normalizeToolResult(result) {
|
|
9
|
+
let text;
|
|
10
|
+
if (typeof result === "string") {
|
|
11
|
+
text = result;
|
|
12
|
+
}
|
|
13
|
+
else if (result === null || result === undefined) {
|
|
14
|
+
return "Error: tool returned no result";
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
try {
|
|
18
|
+
const json = JSON.stringify(result);
|
|
19
|
+
text = typeof json === "string" ? json : String(result);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
try {
|
|
23
|
+
text = String(result);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return "Error: tool returned an unreadable result";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (text.length > TOOL_RESULT_CAP_CHARS) {
|
|
31
|
+
const t = truncateHead(text, TOOL_RESULT_CAP_CHARS, `\n[truncated: tool result exceeded ${TOOL_RESULT_CAP_CHARS} chars]`);
|
|
32
|
+
return t.head + t.note;
|
|
33
|
+
}
|
|
34
|
+
return text;
|
|
35
|
+
}
|
|
36
|
+
// Recursively key-sorted JSON for stable signatures. Falls back to a short
|
|
37
|
+
// type tag when unstringifiable (never throws, never aliases objects with
|
|
38
|
+
// strings: prefixes the tag).
|
|
39
|
+
function stableStringify(value) {
|
|
40
|
+
try {
|
|
41
|
+
return JSON.stringify(sortKeys(value)) ?? "undefined";
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return `unstringifiable:${typeof value}`;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function sortKeys(value) {
|
|
48
|
+
if (Array.isArray(value))
|
|
49
|
+
return value.map(sortKeys);
|
|
50
|
+
if (typeof value === "object" && value !== null) {
|
|
51
|
+
const out = {};
|
|
52
|
+
for (const k of Object.keys(value).sort()) {
|
|
53
|
+
out[k] = sortKeys(value[k]);
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
// Stable repetition/cache key: `name` + canonical args. Parsed args come
|
|
60
|
+
// from JSON.parse (insertion-ordered), so sorting closes the alias where
|
|
61
|
+
// `{"a":1,"b":2}` and `{"b":2,"a":1}` would otherwise count as different.
|
|
62
|
+
export function toolSignature(name, parsed) {
|
|
63
|
+
return `${name} ${stableStringify(parsed ?? {})}`;
|
|
64
|
+
}
|
|
65
|
+
// Defensive validation of one assistant message. Never throws: malformed
|
|
66
|
+
// tool_calls entries are dropped with a warning (the caller surfaces them
|
|
67
|
+
// via onWarning so the transcript shows what the model attempted); a fully
|
|
68
|
+
// unusable message becomes empty final text (the loop's turn-end gates then
|
|
69
|
+
// decide, exactly as if the model sent empty content).
|
|
70
|
+
export function normalizeChatResult(raw) {
|
|
71
|
+
const warnings = [];
|
|
72
|
+
if (typeof raw !== "object" || raw === null) {
|
|
73
|
+
return { result: { content: null }, warnings: ["model returned a non-object message"] };
|
|
74
|
+
}
|
|
75
|
+
const m = raw;
|
|
76
|
+
const contentRaw = m["content"];
|
|
77
|
+
const content = typeof contentRaw === "string"
|
|
78
|
+
? contentRaw
|
|
79
|
+
: contentRaw === null || contentRaw === undefined
|
|
80
|
+
? null
|
|
81
|
+
: (() => {
|
|
82
|
+
try {
|
|
83
|
+
return JSON.stringify(contentRaw);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return String(contentRaw);
|
|
87
|
+
}
|
|
88
|
+
})();
|
|
89
|
+
const callsRaw = m["calls"] ?? m["tool_calls"];
|
|
90
|
+
if (callsRaw === undefined) {
|
|
91
|
+
const result = { content };
|
|
92
|
+
if (m["usage"] !== undefined)
|
|
93
|
+
result["usage"] = m["usage"];
|
|
94
|
+
if (m["reasoning"] !== undefined)
|
|
95
|
+
result["reasoning"] = m["reasoning"];
|
|
96
|
+
// Length-truncation flag survives normalization (no calls or not — the
|
|
97
|
+
// loop decides; truncated-without-calls behaves as before).
|
|
98
|
+
if (m["truncated"] === true)
|
|
99
|
+
result.truncated = true;
|
|
100
|
+
return { result: result, warnings };
|
|
101
|
+
}
|
|
102
|
+
if (!Array.isArray(callsRaw)) {
|
|
103
|
+
warnings.push("model tool_calls was not an array — ignored");
|
|
104
|
+
return { result: { content, tool_calls: undefined }, warnings };
|
|
105
|
+
}
|
|
106
|
+
const calls = [];
|
|
107
|
+
for (let i = 0; i < callsRaw.length; i++) {
|
|
108
|
+
const entry = callsRaw[i];
|
|
109
|
+
if (typeof entry !== "object" || entry === null) {
|
|
110
|
+
warnings.push(`dropped malformed tool call at index ${i} (not an object)`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const fn = entry["function"];
|
|
114
|
+
const name = fn?.["name"];
|
|
115
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
116
|
+
const id = typeof entry["id"] === "string" ? entry["id"] : `#${i}`;
|
|
117
|
+
warnings.push(`dropped tool call ${id} with no function name`);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const id = typeof entry["id"] === "string" && entry["id"].length > 0
|
|
121
|
+
? entry["id"]
|
|
122
|
+
: `call-${i}`;
|
|
123
|
+
const argsRaw = fn?.["arguments"];
|
|
124
|
+
let args;
|
|
125
|
+
if (typeof argsRaw === "string")
|
|
126
|
+
args = argsRaw;
|
|
127
|
+
else if (argsRaw === undefined || argsRaw === null)
|
|
128
|
+
args = "{}";
|
|
129
|
+
else {
|
|
130
|
+
try {
|
|
131
|
+
args = JSON.stringify(argsRaw) ?? "{}";
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
warnings.push(`dropped tool call ${id} with unstringifiable arguments`);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const call = { id, function: { name, arguments: args } };
|
|
139
|
+
if (typeof entry["type"] === "string")
|
|
140
|
+
call.type = entry["type"];
|
|
141
|
+
calls.push(call);
|
|
142
|
+
}
|
|
143
|
+
const out = { content, tool_calls: calls.length > 0 ? calls : undefined };
|
|
144
|
+
if (m["usage"] !== undefined)
|
|
145
|
+
out["usage"] = m["usage"];
|
|
146
|
+
if (m["reasoning"] !== undefined)
|
|
147
|
+
out["reasoning"] = m["reasoning"];
|
|
148
|
+
if (m["truncated"] === true)
|
|
149
|
+
out.truncated = true;
|
|
150
|
+
return { result: out, warnings };
|
|
151
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -60,7 +60,7 @@ Env:
|
|
|
60
60
|
OPENAI_API_KEY / ANTHROPIC_API_KEY / DEEPSEEK_API_KEY / MISTRAL_API_KEY / GEMINI_API_KEY (GOOGLE_API_KEY alias) optional per provider (env wins over stored)
|
|
61
61
|
OPENCODE_ZEN_MODEL optional (default: ${DEFAULT_MODEL}; when set, wins over the saved /model)
|
|
62
62
|
OPENCODE_ZEN_ENDPOINT optional (default: ${DEFAULT_ENDPOINT})
|
|
63
|
-
Commands: /model (model picker) | /models [refresh] (local discovery refresh; Kilo catalog refresh when Kilo is active) | /provider (provider + key picker) | /effort (reasoning-effort picker) | /tools | /skills (list installed skills) | /skill:name (invoke) | /context (context usage) | /queue + /steer (follow-ups while busy) | /mode | /clear | /resume (restore last saved session) | /help | /exit | /quit — Tab cycles the permission mode normal → yolo → plan
|
|
63
|
+
Commands: /model (model picker) | /models [refresh] (local discovery refresh; Kilo catalog refresh when Kilo is active) | /provider (provider + key picker) | /effort (reasoning-effort picker) | /tools | /skills (list installed skills) | /skill:name (invoke) | /context (context usage) | /queue + /steer (follow-ups while busy) | /autoscroll (toggle follow new output) | /thinking (toggle reasoning visibility) | /mode | /clear | /new (fresh conversation, previous kept) | /rename <name> (rename current session) | /session (switch session picker) | /resume (restore last saved session) | /help | /exit | /quit — Tab cycles the permission mode normal → yolo → plan
|
|
64
64
|
Providers: kilo (default; anonymous free models, key optional)/opencode-zen/openai/anthropic/deepseek/mistral/google-gemini/openai-compatible (keys in ~/.atom/auth.json, 0600 POSIX; use /provider to paste one) + local auto-discovery: ollama (:11434), lmstudio (:1234), llamacpp (:8080) — no keys needed, overrides via ATOM_OLLAMA_URL/ATOM_LMSTUDIO_URL/ATOM_LLAMACPP_URL.
|
|
65
65
|
Note: reasoning_effort is sent only for opencode-zen supported models.`);
|
|
66
66
|
process.exit(0);
|
|
@@ -86,5 +86,19 @@ const envModel = process.env.OPENCODE_ZEN_MODEL?.trim() || undefined;
|
|
|
86
86
|
// --serve parks above (the server holds the event loop), so the TUI must
|
|
87
87
|
// never start alongside it: serve is a standalone mode like --dashboard.
|
|
88
88
|
if (!args.includes("--serve")) {
|
|
89
|
-
|
|
89
|
+
// Production frame policy (Ink 7.1.1):
|
|
90
|
+
// - incrementalRendering: only changed terminal lines rewrite per frame.
|
|
91
|
+
// Streaming paints touch the live tail + status bar, not the scrollback,
|
|
92
|
+
// so this cuts flicker and stdout bytes on every token flush.
|
|
93
|
+
// Escape hatch: ATOM_INCREMENTAL=0 restores full-frame rendering.
|
|
94
|
+
// - maxFps: 30 keeps keystroke-to-paint latency low; token paints already
|
|
95
|
+
// coalesce to ~15fps via DRAFT_THROTTLE_MS, so Ink never does extra work.
|
|
96
|
+
// - concurrent: enables React concurrent features (useTransition /
|
|
97
|
+
// useDeferredValue) for future deferral of expensive subtrees.
|
|
98
|
+
// Tests are unaffected: they render via ink-testing-library, not here.
|
|
99
|
+
render(_jsx(App, { apiKey: apiKey, endpoint: endpoint, initialModel: envModel, restorePrefs: true }), {
|
|
100
|
+
incrementalRendering: process.env.ATOM_INCREMENTAL !== "0",
|
|
101
|
+
maxFps: 30,
|
|
102
|
+
concurrent: true,
|
|
103
|
+
});
|
|
90
104
|
}
|
package/dist/compact.js
CHANGED
|
@@ -20,6 +20,7 @@ import { chatCompletionForProvider, } from "./zen.js";
|
|
|
20
20
|
// ContextManager module; compact.ts imports what its splitter needs and
|
|
21
21
|
// re-exports the stable surface so existing importers keep working untouched.
|
|
22
22
|
import { estimateTokensForChars, messageChars } from "./context-manager.js";
|
|
23
|
+
import { truncateHead } from "./tools/shared.js";
|
|
23
24
|
export { COMPACT_PCT_DEFAULT, compactPct, computeContextLoad, estimateTokensForChars, historyChars, shouldAutoCompact, } from "./context-manager.js";
|
|
24
25
|
// ---- Constants ----
|
|
25
26
|
export const COMPACT_KEEP_TOKENS = 8000;
|
|
@@ -62,10 +63,12 @@ function turnChars(history, start, end) {
|
|
|
62
63
|
export function capToolOutputsInTail(tail) {
|
|
63
64
|
return tail.map((m) => {
|
|
64
65
|
if (m.role === "tool" && typeof m.content === "string" && m.content.length > COMPACT_TOOL_OUTPUT_CAP) {
|
|
66
|
+
// Line-aware cap (issue 04): the retained head never ends mid-line;
|
|
67
|
+
// cap value and legacy note prefix are unchanged.
|
|
68
|
+
const t = truncateHead(m.content, COMPACT_TOOL_OUTPUT_CAP, "\n[truncated: tool output exceeded 2000 chars]");
|
|
65
69
|
return {
|
|
66
70
|
...m,
|
|
67
|
-
content:
|
|
68
|
-
"\n[truncated: tool output exceeded 2000 chars]",
|
|
71
|
+
content: t.head + t.note,
|
|
69
72
|
};
|
|
70
73
|
}
|
|
71
74
|
return { ...m };
|
|
@@ -233,3 +236,126 @@ export async function requestCompactSummary(req) {
|
|
|
233
236
|
}
|
|
234
237
|
}
|
|
235
238
|
}
|
|
239
|
+
function pushUniquePath(list, p) {
|
|
240
|
+
if (p.length === 0 || list.includes(p))
|
|
241
|
+
return;
|
|
242
|
+
list.push(p);
|
|
243
|
+
}
|
|
244
|
+
// Collect read/modified paths from committed tool_calls in head (insertion
|
|
245
|
+
// order, unique). Unparseable arguments are skipped — a bad payload must
|
|
246
|
+
// never break compaction. A path both read and written lands in modified
|
|
247
|
+
// only (the write implies the read).
|
|
248
|
+
export function collectTouchedFiles(head) {
|
|
249
|
+
const read = [];
|
|
250
|
+
const modified = [];
|
|
251
|
+
for (const m of head) {
|
|
252
|
+
if (m?.role !== "assistant")
|
|
253
|
+
continue;
|
|
254
|
+
const calls = m.tool_calls;
|
|
255
|
+
if (!Array.isArray(calls))
|
|
256
|
+
continue;
|
|
257
|
+
for (const c of calls) {
|
|
258
|
+
const fn = c?.function;
|
|
259
|
+
if (typeof fn?.name !== "string")
|
|
260
|
+
continue;
|
|
261
|
+
let p = "";
|
|
262
|
+
try {
|
|
263
|
+
const args = typeof fn.arguments === "string" ? JSON.parse(fn.arguments) : null;
|
|
264
|
+
const raw = args?.path;
|
|
265
|
+
if (typeof raw === "string")
|
|
266
|
+
p = raw.trim();
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
continue; // unparseable args pin nothing
|
|
270
|
+
}
|
|
271
|
+
if (p.length === 0)
|
|
272
|
+
continue;
|
|
273
|
+
if (fn.name === "write" || fn.name === "edit")
|
|
274
|
+
pushUniquePath(modified, p);
|
|
275
|
+
else if (fn.name === "read")
|
|
276
|
+
pushUniquePath(read, p);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// Modified implies read: keep modified entries out of the read list.
|
|
280
|
+
const modifiedSet = new Set(modified);
|
|
281
|
+
return { read: read.filter((p) => !modifiedSet.has(p)), modified };
|
|
282
|
+
}
|
|
283
|
+
// Canonical on-disk + on-resume format. Empty sections are omitted; both
|
|
284
|
+
// empty renders "" (the caller then appends nothing).
|
|
285
|
+
export function formatTouchedFiles(t) {
|
|
286
|
+
const lines = ["Touched files:"];
|
|
287
|
+
if (t.read.length > 0)
|
|
288
|
+
lines.push(`Read: ${t.read.join(", ")}`);
|
|
289
|
+
if (t.modified.length > 0)
|
|
290
|
+
lines.push(`Modified: ${t.modified.join(", ")}`);
|
|
291
|
+
return lines.length > 1 ? lines.join("\n") : "";
|
|
292
|
+
}
|
|
293
|
+
export function appendTouchedFiles(summaryText, touched) {
|
|
294
|
+
const block = formatTouchedFiles(touched);
|
|
295
|
+
if (!block)
|
|
296
|
+
return summaryText;
|
|
297
|
+
return `${summaryText}\n\n${block}`;
|
|
298
|
+
}
|
|
299
|
+
// ---- Size budget for the summary message ----
|
|
300
|
+
// The summary text itself is never cut — only the file lists shrink to fit,
|
|
301
|
+
// so an over-budget summary degrades gracefully instead of failing
|
|
302
|
+
// compaction. Budget = the summary output cap via the 4ch/token estimator.
|
|
303
|
+
export const COMPACT_SUMMARY_MAX_CHARS = COMPACT_SUMMARY_MAX_TOKENS * COMPACT_CHARS_PER_TOKEN;
|
|
304
|
+
// Shrink the lists until the formatted block fits maxChars. Drops the oldest
|
|
305
|
+
// entry from the longer list (ties: read first — modifications are the
|
|
306
|
+
// higher-signal list). Never throws; an empty result formats to "".
|
|
307
|
+
export function truncateTouchedFiles(touched, maxChars) {
|
|
308
|
+
const read = [...touched.read];
|
|
309
|
+
const modified = [...touched.modified];
|
|
310
|
+
while ((read.length > 0 || modified.length > 0) &&
|
|
311
|
+
formatTouchedFiles({ read, modified }).length > maxChars) {
|
|
312
|
+
if (read.length >= modified.length)
|
|
313
|
+
read.shift();
|
|
314
|
+
else
|
|
315
|
+
modified.shift();
|
|
316
|
+
}
|
|
317
|
+
return { read, modified };
|
|
318
|
+
}
|
|
319
|
+
// Append file lists to the summary within budget: shrink the lists (never
|
|
320
|
+
// the model text) until summary + block fits; when nothing fits, the summary
|
|
321
|
+
// stands alone and compaction still succeeds.
|
|
322
|
+
export function fitSummaryWithFiles(summaryText, touched, maxChars = COMPACT_SUMMARY_MAX_CHARS) {
|
|
323
|
+
const before = touched.read.length + touched.modified.length;
|
|
324
|
+
const block = formatTouchedFiles(touched);
|
|
325
|
+
if (!block)
|
|
326
|
+
return { text: summaryText, truncated: false };
|
|
327
|
+
if (summaryText.length + 2 + block.length <= maxChars) {
|
|
328
|
+
return { text: `${summaryText}\n\n${block}`, truncated: false };
|
|
329
|
+
}
|
|
330
|
+
const room = Math.max(0, maxChars - summaryText.length - 2);
|
|
331
|
+
const shrunk = truncateTouchedFiles(touched, room);
|
|
332
|
+
const shrunkBlock = formatTouchedFiles(shrunk);
|
|
333
|
+
const truncated = shrunk.read.length + shrunk.modified.length < before;
|
|
334
|
+
if (!shrunkBlock)
|
|
335
|
+
return { text: summaryText, truncated };
|
|
336
|
+
return { text: `${summaryText}\n\n${shrunkBlock}`, truncated };
|
|
337
|
+
}
|
|
338
|
+
// ---- Resume surfacing ----
|
|
339
|
+
// Pull the stored block(s) verbatim out of compacted summary messages — the
|
|
340
|
+
// same format as stored, no reformatting. lastIndexOf prefers the appended
|
|
341
|
+
// block (ours is always last; model prose comes first).
|
|
342
|
+
export function extractTouchedFilesSection(text) {
|
|
343
|
+
const idx = text.lastIndexOf("Touched files:");
|
|
344
|
+
if (idx < 0)
|
|
345
|
+
return null;
|
|
346
|
+
const section = text.slice(idx).trimEnd();
|
|
347
|
+
return section.length > 0 ? section : null;
|
|
348
|
+
}
|
|
349
|
+
export function collectStoredTouchedFiles(history) {
|
|
350
|
+
const out = [];
|
|
351
|
+
for (const m of history) {
|
|
352
|
+
if (m?.role !== "user" || typeof m.content !== "string")
|
|
353
|
+
continue;
|
|
354
|
+
if (!m.content.includes("[Compacted context"))
|
|
355
|
+
continue;
|
|
356
|
+
const section = extractTouchedFilesSection(m.content);
|
|
357
|
+
if (section)
|
|
358
|
+
out.push(section);
|
|
359
|
+
}
|
|
360
|
+
return out;
|
|
361
|
+
}
|
package/dist/env-block.js
CHANGED
|
@@ -6,14 +6,15 @@
|
|
|
6
6
|
// content via withEnvBlock), NEVER into user content. history[0] is the only
|
|
7
7
|
// slot truncateHistory never drops, so the block survives budget trimming.
|
|
8
8
|
// Caching: the App refreshes history[0] once per turn in submit() (before the
|
|
9
|
-
// budget check, so truncation accounts for it) — the loop's
|
|
10
|
-
//
|
|
9
|
+
// budget check, so truncation accounts for it) — the loop's POSTs reuse the
|
|
10
|
+
// same history[0], so git is shelled at most once per turn.
|
|
11
11
|
// Failure-silent: missing git / non-repo cwd / timeout → the block shrinks
|
|
12
12
|
// (cwd + node + time only), never throws, never blocks the turn. No new
|
|
13
13
|
// dependencies; one cheap `git status` invocation with a short timeout, and
|
|
14
14
|
// zero shell-outs when `.git` is absent.
|
|
15
15
|
import { execFileSync } from "node:child_process";
|
|
16
16
|
import { existsSync } from "node:fs";
|
|
17
|
+
import * as os from "node:os";
|
|
17
18
|
import * as path from "node:path";
|
|
18
19
|
// Cap for the block itself (~500 chars per the task). The base system prompt
|
|
19
20
|
// (SYSTEM_PROMPT + AGENTS.md overlay) is untouched by this cap.
|
|
@@ -30,8 +31,9 @@ function shortCwd(cwd) {
|
|
|
30
31
|
return `…${cwd.slice(cwd.length - (CWD_DISPLAY_CAP - 1))}`;
|
|
31
32
|
}
|
|
32
33
|
// Pure formatter (no I/O): always `cwd + node + time`, plus
|
|
33
|
-
// `branch + status` only when git reported them
|
|
34
|
-
//
|
|
34
|
+
// `branch + status` only when git reported them, plus the operating context
|
|
35
|
+
// (`os + shell + user`) when provided. Capped to ENV_BLOCK_CHAR_CAP (cwd is
|
|
36
|
+
// pre-truncated so time/node survive the cap).
|
|
35
37
|
export function buildEnvBlock(parts) {
|
|
36
38
|
const cwd = shortCwd(parts.cwd);
|
|
37
39
|
const git = parts.branch !== undefined &&
|
|
@@ -39,7 +41,10 @@ export function buildEnvBlock(parts) {
|
|
|
39
41
|
parts.branch.length > 0
|
|
40
42
|
? ` branch=${parts.branch} status=${parts.status ?? "unknown"}`
|
|
41
43
|
: "";
|
|
42
|
-
const
|
|
44
|
+
const machine = parts.os !== undefined && parts.os !== null && parts.os.length > 0
|
|
45
|
+
? ` os=${parts.os} shell=${parts.shell ?? "unknown"} user=${parts.user ?? "unknown"}`
|
|
46
|
+
: "";
|
|
47
|
+
const block = `[env cwd=${cwd}${git}${machine} node=${parts.nodeVersion} time=${parts.timestamp}]`;
|
|
43
48
|
return block.length > ENV_BLOCK_CHAR_CAP
|
|
44
49
|
? `${block.slice(0, ENV_BLOCK_CHAR_CAP - 1)}]`
|
|
45
50
|
: block;
|
|
@@ -117,12 +122,45 @@ export function getEnvBlock(cwd = process.cwd()) {
|
|
|
117
122
|
catch {
|
|
118
123
|
git = null;
|
|
119
124
|
}
|
|
125
|
+
// Operating context (best-effort, never throws): tells the model which
|
|
126
|
+
// shell runs its commands and who/where it is, so it stops probing with
|
|
127
|
+
// whoami/hostname and guessing `ls` vs `dir` (measured: 2–4 wasted bash
|
|
128
|
+
// calls per task before this existed).
|
|
129
|
+
let osName = "unknown";
|
|
130
|
+
try {
|
|
131
|
+
if (typeof process.platform === "string" && process.platform.length > 0) {
|
|
132
|
+
osName = process.platform;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// keep fallback
|
|
137
|
+
}
|
|
138
|
+
let shell = "unknown";
|
|
139
|
+
try {
|
|
140
|
+
shell = process.platform === "win32" ? "cmd.exe" : "sh";
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// keep fallback
|
|
144
|
+
}
|
|
145
|
+
let user = "unknown";
|
|
146
|
+
try {
|
|
147
|
+
const name = os.userInfo?.().username;
|
|
148
|
+
if (typeof name === "string" && name.length > 0) {
|
|
149
|
+
user = name;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// keep fallback (sandboxed runtimes may forbid userInfo)
|
|
154
|
+
}
|
|
120
155
|
return buildEnvBlock({
|
|
121
156
|
cwd: dir,
|
|
122
157
|
branch: git?.branch ?? null,
|
|
123
158
|
status: git?.status ?? null,
|
|
124
159
|
nodeVersion,
|
|
125
160
|
timestamp,
|
|
161
|
+
os: osName,
|
|
162
|
+
shell,
|
|
163
|
+
user,
|
|
126
164
|
});
|
|
127
165
|
}
|
|
128
166
|
catch {
|
package/dist/scheduler.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// Effect-aware tool scheduler: data-driven parallelism without Promise.all-
|
|
2
2
|
// ing every tool.
|
|
3
3
|
//
|
|
4
|
-
// The
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
4
|
+
// The contract: batchable calls run concurrently, conflicting calls stay
|
|
5
|
+
// serialized, results commit in original call order, cancel stops between
|
|
6
|
+
// batches, approval happens per call in order before execution. This module
|
|
7
|
+
// only PLANS batches; execution (agent/loop.ts runLoopWithChat) is untouched.
|
|
8
8
|
//
|
|
9
9
|
// How it reasons (per tool, from the TOOL_EFFECTS table — the single
|
|
10
10
|
// coupling point; the algorithm below has no per-tool branches):
|
|
@@ -16,23 +16,33 @@
|
|
|
16
16
|
// become inline-error singletons downstream, exactly as before).
|
|
17
17
|
// - interactive (ask_question) or exclusive (shared ambient state the effect
|
|
18
18
|
// model cannot see: todowrite/todo_update/todo_get) → serial singleton.
|
|
19
|
-
// -
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
19
|
+
// - process SPAWN or network WRITE (bash: unbounded footprint) → serial
|
|
20
|
+
// singleton, splitting the block globally.
|
|
21
|
+
// - filesystem writes (write/edit) batch on disjoint canonical file keys
|
|
22
|
+
// (see canonicalFileKey): same-file mutations never share a batch, so they
|
|
23
|
+
// stay strictly ordered in program order; different files run concurrently.
|
|
24
|
+
// An unresolvable target stays serial (never batch what you cannot see).
|
|
25
|
+
// - reads batch with pairwise-disjoint keys, where the key is tool + target.
|
|
26
|
+
// Same tool + same target serializes (the old overlap rule, kept verbatim:
|
|
27
|
+
// e.g. two reads of one path). A path read also conflicts with an open
|
|
28
|
+
// write to the same canonical file (read-after-write stays ordered), and a
|
|
29
|
+
// write conflicts with any open member on its key (write-after-read stays
|
|
30
|
+
// ordered). Directory-scoped scans (grep/glob) vs concurrent writes to
|
|
31
|
+
// unscanned-listed files are out of scope — same exposure as an editor
|
|
32
|
+
// saving mid-scan; only same-canonical-path pairs are ordered.
|
|
28
33
|
// - deterministic is declared per tool; its current enforcement is the
|
|
29
34
|
// same-key rule (a re-poll of the same background task, whose output can
|
|
30
35
|
// grow, never runs concurrently with itself).
|
|
31
36
|
//
|
|
32
37
|
// Pure module except the shared arg validators (same imports zen.ts already
|
|
33
|
-
// carries — no new coupling class)
|
|
34
|
-
//
|
|
35
|
-
//
|
|
38
|
+
// carries — no new coupling class) plus best-effort path canonicalization
|
|
39
|
+
// (node:fs/node:path only, so the architecture boundary is unchanged:
|
|
40
|
+
// scheduler still reasons from metadata, never from tool branches).
|
|
41
|
+
// Covered by tests/scheduler.test.ts; the end-to-end ordering/cancel
|
|
42
|
+
// behavior stays pinned by tests/parallel-calls.test.ts and
|
|
43
|
+
// tests/parallel-writes.test.ts.
|
|
44
|
+
import * as fs from "node:fs";
|
|
45
|
+
import * as path from "node:path";
|
|
36
46
|
import { toolNames, validateToolArgs } from "./tools.js";
|
|
37
47
|
const str = (v) => (typeof v === "string" ? v : "");
|
|
38
48
|
const targetOf = (key) => (args) => {
|
|
@@ -166,22 +176,65 @@ export const TOOL_EFFECTS = {
|
|
|
166
176
|
target: () => null,
|
|
167
177
|
},
|
|
168
178
|
};
|
|
179
|
+
// Canonical per-file mutation key: the identity two mutation calls compare
|
|
180
|
+
// before sharing a batch. Lexical resolve against the process cwd (the same
|
|
181
|
+
// base the executors default to), then best-effort symlink resolution, then
|
|
182
|
+
// case-folding on case-insensitive filesystems. Null when the target is
|
|
183
|
+
// missing, empty, or unresolvable — unknown footprints stay serial. Sync and
|
|
184
|
+
// best-effort by design: a handful of calls per tool block, and a miss only
|
|
185
|
+
// costs parallelism, never correctness.
|
|
186
|
+
export function canonicalFileKey(rawPath, cwd = process.cwd()) {
|
|
187
|
+
if (typeof rawPath !== "string" || rawPath.length === 0)
|
|
188
|
+
return null;
|
|
189
|
+
if (rawPath.includes("\0"))
|
|
190
|
+
return null;
|
|
191
|
+
let abs;
|
|
192
|
+
try {
|
|
193
|
+
abs = path.resolve(cwd, rawPath);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
abs = fs.realpathSync(abs);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
// Fresh write target or unreadable link: the lexical path stands.
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
abs = path.normalize(abs);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
if (process.platform === "win32" || process.platform === "darwin") {
|
|
211
|
+
abs = abs.toLowerCase();
|
|
212
|
+
}
|
|
213
|
+
return abs;
|
|
214
|
+
}
|
|
169
215
|
// Partition one assistant message's tool_calls into commit batches,
|
|
170
216
|
// preserving program order: consecutive batchable calls with pairwise
|
|
171
217
|
// disjoint keys form one batch; any serial-only call — and any call whose
|
|
172
218
|
// key already appears in the open batch — closes the batch and runs as a
|
|
173
|
-
// strict serial singleton.
|
|
174
|
-
//
|
|
175
|
-
//
|
|
219
|
+
// strict serial singleton. Filesystem writes join a batch only on a
|
|
220
|
+
// disjoint canonical file key (same-file mutations split into sequential
|
|
221
|
+
// batches, never concurrent); a path read conflicting with an open write —
|
|
222
|
+
// or a write conflicting with any open member — on the same canonical key
|
|
223
|
+
// also splits, so per-file program order always holds. A later batch never
|
|
224
|
+
// moves ahead of an earlier serial call, and batches never span the block
|
|
225
|
+
// boundary.
|
|
176
226
|
export function planBatches(calls) {
|
|
177
227
|
const batches = [];
|
|
178
228
|
let open = [];
|
|
179
229
|
const keys = new Set();
|
|
230
|
+
// Canonical file keys of the open batch ("read" and/or "write" per key).
|
|
231
|
+
const openFiles = new Map();
|
|
180
232
|
const flush = () => {
|
|
181
233
|
if (open.length > 0) {
|
|
182
234
|
batches.push(open);
|
|
183
235
|
open = [];
|
|
184
236
|
keys.clear();
|
|
237
|
+
openFiles.clear();
|
|
185
238
|
}
|
|
186
239
|
};
|
|
187
240
|
const singleton = (call, parsed) => {
|
|
@@ -212,15 +265,28 @@ export function planBatches(calls) {
|
|
|
212
265
|
singleton(call, parsed);
|
|
213
266
|
continue;
|
|
214
267
|
}
|
|
215
|
-
// Interactive, ambient-state,
|
|
268
|
+
// Interactive, ambient-state, spawning, or network-writing calls
|
|
269
|
+
// serialize (bash has no statically visible footprint: global conflict).
|
|
216
270
|
if (meta.interactive ||
|
|
217
271
|
meta.exclusive ||
|
|
218
|
-
meta.filesystem === "write" ||
|
|
219
272
|
meta.network === "write" ||
|
|
220
273
|
meta.process !== "none") {
|
|
221
274
|
singleton(call, parsed);
|
|
222
275
|
continue;
|
|
223
276
|
}
|
|
277
|
+
// Filesystem writes batch on disjoint canonical file keys: the
|
|
278
|
+
// per-file mutation queue. Same-file mutations split into sequential
|
|
279
|
+
// batches (never interleave); disjoint files run concurrently.
|
|
280
|
+
if (meta.filesystem === "write") {
|
|
281
|
+
const fileKey = canonicalFileKey(parsed["path"]);
|
|
282
|
+
if (!fileKey || openFiles.has(fileKey)) {
|
|
283
|
+
singleton(call, parsed);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
openFiles.set(fileKey, "write");
|
|
287
|
+
open.push({ call, parsed, parallelKey: `${name} ${fileKey}` });
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
224
290
|
// Reads batch on disjoint tool+target keys; empty target = unknown
|
|
225
291
|
// footprint = serial.
|
|
226
292
|
let target = null;
|
|
@@ -239,6 +305,20 @@ export function planBatches(calls) {
|
|
|
239
305
|
singleton(call, parsed);
|
|
240
306
|
continue;
|
|
241
307
|
}
|
|
308
|
+
// A path read against an open write to the same canonical file stays
|
|
309
|
+
// ordered (read-after-write): split the batch.
|
|
310
|
+
if (name === "read") {
|
|
311
|
+
const fileKey = canonicalFileKey(target);
|
|
312
|
+
if (fileKey && openFiles.get(fileKey) === "write") {
|
|
313
|
+
singleton(call, parsed);
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
keys.add(key);
|
|
317
|
+
if (fileKey && !openFiles.has(fileKey))
|
|
318
|
+
openFiles.set(fileKey, "read");
|
|
319
|
+
open.push({ call, parsed, parallelKey: key });
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
242
322
|
keys.add(key);
|
|
243
323
|
open.push({ call, parsed, parallelKey: key });
|
|
244
324
|
}
|