atom-agent 1.1.0 → 1.3.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 +106 -0
- package/README.md +18 -8
- package/atom.example.json +11 -0
- package/dist/App.js +1637 -255
- package/dist/adapters.js +112 -21
- package/dist/agent/gates.js +14 -1
- package/dist/agent/goal-evaluator.js +69 -0
- package/dist/agent/loop-guard.js +11 -13
- package/dist/agent/loop.js +716 -132
- package/dist/agent/normalize.js +9 -2
- package/dist/cli.js +25 -3
- package/dist/compact.js +169 -17
- package/dist/config.js +43 -7
- package/dist/context-manager.js +16 -198
- package/dist/context-windows.js +4 -2
- package/dist/env-block.js +46 -8
- package/dist/extension-commands.js +196 -0
- package/dist/extension-ui.js +153 -0
- package/dist/extensions.js +1571 -0
- package/dist/goal.js +583 -0
- package/dist/project-trust.js +96 -0
- package/dist/providers.js +6 -6
- package/dist/scheduler.js +159 -41
- package/dist/session.js +23 -5
- package/dist/sessions.js +543 -0
- package/dist/system.js +89 -13
- package/dist/telemetry-dashboard.js +28 -0
- package/dist/telemetry.js +39 -0
- package/dist/tools/compaction-hooks.js +165 -0
- package/dist/tools/custom.js +189 -0
- package/dist/tools/dir-cache.js +7 -0
- package/dist/tools/filesystem.js +3 -2
- package/dist/tools/intercept.js +145 -0
- package/dist/tools/overrides.js +105 -0
- package/dist/tools/provider-hooks.js +224 -0
- package/dist/tools/registry.js +247 -17
- package/dist/tools/ripgrep.js +256 -0
- package/dist/tools/search.js +119 -58
- package/dist/tools/shared.js +39 -0
- package/dist/tools/shell.js +7 -5
- package/dist/tools/web.js +6 -6
- package/dist/tools.js +45 -0
- package/dist/ui/diff-view.js +7 -2
- 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/palette.js +3 -1
- package/dist/ui/side-by-side.js +2 -2
- package/dist/ui/status-bar.js +80 -5
- package/dist/ui/status-host.js +22 -0
- package/dist/ui/stream-store.js +48 -0
- package/dist/ui/tool-inspector.js +7 -1
- package/dist/ui/transcript.js +92 -38
- package/dist/zen.js +370 -87
- package/documentation/architecture.md +114 -0
- package/documentation/cli.md +82 -0
- package/documentation/compaction.md +50 -0
- package/documentation/configuration.md +111 -0
- package/documentation/development.md +62 -0
- package/documentation/extensions.md +160 -0
- package/documentation/getting-started.md +63 -0
- package/documentation/goals.md +41 -0
- package/documentation/index.md +41 -0
- package/documentation/observability.md +70 -0
- package/documentation/permissions.md +66 -0
- package/documentation/providers.md +78 -0
- package/documentation/sessions.md +92 -0
- package/documentation/skills.md +57 -0
- package/documentation/tools.md +94 -0
- package/documentation/troubleshooting.md +54 -0
- package/examples/extensions/01-audit-gate.js +24 -0
- package/examples/extensions/02-notes-tool.js +32 -0
- package/examples/extensions/03-custom-command.js +32 -0
- package/package.json +6 -2
|
@@ -243,6 +243,26 @@ function iterationTimeline(s) {
|
|
|
243
243
|
});
|
|
244
244
|
return `<div class="timeline">${rows.join("")}</div>`;
|
|
245
245
|
}
|
|
246
|
+
// Goal fragment for one turn (ticket 09): rendered only when the trace
|
|
247
|
+
// carries a goal snapshot — absent or malformed reads as no-goal and renders
|
|
248
|
+
// nothing (never a fake claim). Counters show only when reported.
|
|
249
|
+
function goalFragment(t) {
|
|
250
|
+
const g = t.goal;
|
|
251
|
+
if (!g || typeof g.objective !== "string" || g.objective.length === 0)
|
|
252
|
+
return "";
|
|
253
|
+
const state = g.active === true ? "active" : "paused";
|
|
254
|
+
const bits = [];
|
|
255
|
+
if (typeof g.turns === "number")
|
|
256
|
+
bits.push(`${fmtCount(g.turns)} turn(s)`);
|
|
257
|
+
if (typeof g.requests === "number")
|
|
258
|
+
bits.push(`${fmtCount(g.requests)} request(s)`);
|
|
259
|
+
if (typeof g.tokens === "number")
|
|
260
|
+
bits.push(`${fmtCount(g.tokens)} tokens`);
|
|
261
|
+
if (typeof g.workMs === "number")
|
|
262
|
+
bits.push(fmtMs(g.workMs));
|
|
263
|
+
const counters = bits.length > 0 ? ` <span class="mute">(${bits.join(" · ")})</span>` : "";
|
|
264
|
+
return `<p><strong>Goal:</strong> <span class="mono">${escapeHtml(g.objective)}</span> <span class="pill">${state}</span>${counters}</p>`;
|
|
265
|
+
}
|
|
246
266
|
function turnBlock(t) {
|
|
247
267
|
const outcomeCls = t.outcome === "completed" ? "ok" : t.outcome === "failed" ? "err" : "warn";
|
|
248
268
|
const total = turnTokensTotal(t);
|
|
@@ -272,6 +292,7 @@ function turnBlock(t) {
|
|
|
272
292
|
<summary><span class="mono">${escapeHtml(t.id)}</span> · ${fmtTime(t.startedAt)} · ${escapeHtml(t.provider)} · ${escapeHtml(t.model)} · ${head}</summary>
|
|
273
293
|
<div class="turnbody">
|
|
274
294
|
<p><strong>Input:</strong> <span class="mono">${escapeHtml(t.inputPreview)}</span> <span class="mute">(${fmtCount(t.inputChars)} chars)</span></p>
|
|
295
|
+
${goalFragment(t)}
|
|
275
296
|
${t.error ? `<p><strong>Error:</strong> <span class="err">${escapeHtml(t.error)}</span></p>` : ""}
|
|
276
297
|
${t.replyPreview ? `<p><strong>Reply:</strong> <span class="mono">${escapeHtml(t.replyPreview)}</span></p>` : ""}
|
|
277
298
|
<h5>Timeline</h5>
|
|
@@ -361,6 +382,12 @@ export function buildDashboardHtml(sessions, opts = {}) {
|
|
|
361
382
|
const successRate = agg.toolSuccessRate !== null
|
|
362
383
|
? `${(agg.toolSuccessRate * 100).toFixed(1)}%`
|
|
363
384
|
: `<span class="na" title="No tool calls recorded — a rate over zero calls would be fake.">n/a</span>`;
|
|
385
|
+
// Goal overview (ticket 09): present only when at least one stored turn
|
|
386
|
+
// carried a goal snapshot — zero goal turns omit the card entirely (same
|
|
387
|
+
// conditional-render precedent as the corrupt-file note above).
|
|
388
|
+
const goalCard = agg.goalTurns > 0
|
|
389
|
+
? `<div class="card"><div class="k">Goal turns</div><div class="v">${fmtCount(agg.goalTurns)}</div></div>`
|
|
390
|
+
: "";
|
|
364
391
|
const refreshSeconds = typeof opts.refreshSeconds === "number" && Number.isFinite(opts.refreshSeconds)
|
|
365
392
|
? Math.floor(opts.refreshSeconds)
|
|
366
393
|
: 0;
|
|
@@ -504,6 +531,7 @@ ${corruptNote}
|
|
|
504
531
|
<div class="card"><div class="k">Completion tokens (reported)</div><div class="v">${fmtTokens(agg.usage.completion_tokens, agg.usageReported, "No model call reported completion_tokens.")}</div></div>
|
|
505
532
|
<div class="card"><div class="k">Cache read / write (reported)</div><div class="v">${fmtTokens(agg.usage.cacheReadTokens, agg.usageReported, "No provider reported cache-read counters.")} / ${fmtTokens(agg.usage.cacheWriteTokens, agg.usageReported, "No provider reported cache-write counters.")}</div></div>
|
|
506
533
|
<div class="card"><div class="k">Retries</div><div class="v">${fmtCount(agg.retries)}</div></div>
|
|
534
|
+
${goalCard}
|
|
507
535
|
<div class="card"><div class="k">Read-cache hits (local)</div><div class="v">${fmtCount(agg.cacheHits)}</div></div>
|
|
508
536
|
<div class="card"><div class="k">Loop-guard hits</div><div class="v">${fmtCount(agg.repetitionHits)}</div></div>
|
|
509
537
|
<div class="card"><div class="k">Avg model latency</div><div class="v">${fmtMs(agg.avgModelLatencyMs)}</div></div>
|
package/dist/telemetry.js
CHANGED
|
@@ -111,6 +111,38 @@ export function cleanUsage(value) {
|
|
|
111
111
|
? out
|
|
112
112
|
: undefined;
|
|
113
113
|
}
|
|
114
|
+
// Keep only a well-formed goal snapshot (tolerant reader/writer pair with
|
|
115
|
+
// the dashboard: absent or malformed reads as no-goal, never a throw).
|
|
116
|
+
// Returns undefined when no goal was live. The objective is capped so a
|
|
117
|
+
// pasted paragraph cannot bloat the trace; counters copy finite values only.
|
|
118
|
+
export function cleanGoalSnapshot(value) {
|
|
119
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
120
|
+
return undefined;
|
|
121
|
+
const o = value;
|
|
122
|
+
const objective = o["objective"];
|
|
123
|
+
if (typeof objective !== "string" || objective.length === 0)
|
|
124
|
+
return undefined;
|
|
125
|
+
const snap = {
|
|
126
|
+
objective: objective.length > TELEMETRY_INPUT_PREVIEW_CHARS
|
|
127
|
+
? objective.slice(0, TELEMETRY_INPUT_PREVIEW_CHARS)
|
|
128
|
+
: objective,
|
|
129
|
+
active: o["active"] === true,
|
|
130
|
+
};
|
|
131
|
+
const counter = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.floor(v) : undefined;
|
|
132
|
+
const turns = counter(o["turns"]);
|
|
133
|
+
if (turns !== undefined)
|
|
134
|
+
snap.turns = turns;
|
|
135
|
+
const requests = counter(o["requests"]);
|
|
136
|
+
if (requests !== undefined)
|
|
137
|
+
snap.requests = requests;
|
|
138
|
+
const tokens = counter(o["tokens"]);
|
|
139
|
+
if (tokens !== undefined)
|
|
140
|
+
snap.tokens = tokens;
|
|
141
|
+
const workMs = counter(o["workMs"]);
|
|
142
|
+
if (workMs !== undefined)
|
|
143
|
+
snap.workMs = workMs;
|
|
144
|
+
return snap;
|
|
145
|
+
}
|
|
114
146
|
export function addUsageInto(target, extra) {
|
|
115
147
|
if (!extra)
|
|
116
148
|
return false;
|
|
@@ -438,6 +470,7 @@ export function summarizeTelemetry(sessions) {
|
|
|
438
470
|
retries: 0,
|
|
439
471
|
cacheHits: 0,
|
|
440
472
|
repetitionHits: 0,
|
|
473
|
+
goalTurns: 0,
|
|
441
474
|
outcomes: emptyOutcomes(),
|
|
442
475
|
byTool: [],
|
|
443
476
|
avgModelLatencyMs: null,
|
|
@@ -462,6 +495,9 @@ export function summarizeTelemetry(sessions) {
|
|
|
462
495
|
agg.turns += 1;
|
|
463
496
|
if (t.outcome in agg.outcomes)
|
|
464
497
|
agg.outcomes[t.outcome] += 1;
|
|
498
|
+
if (t.goal && typeof t.goal.objective === "string" && t.goal.objective.length > 0) {
|
|
499
|
+
agg.goalTurns += 1;
|
|
500
|
+
}
|
|
465
501
|
if (t.usageReported) {
|
|
466
502
|
if (addUsageInto(agg.usage, t.usage))
|
|
467
503
|
agg.usageReported = true;
|
|
@@ -657,6 +693,9 @@ export class TelemetryRecorder {
|
|
|
657
693
|
usageReported: false,
|
|
658
694
|
retryCount: 0,
|
|
659
695
|
};
|
|
696
|
+
const goalSnap = cleanGoalSnapshot(meta.goal);
|
|
697
|
+
if (goalSnap)
|
|
698
|
+
turn.goal = goalSnap;
|
|
660
699
|
this.session.turns.push(turn);
|
|
661
700
|
this.openTurns.set(id, turn);
|
|
662
701
|
this.pendingRetries = [];
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Before-compaction hooks (ticket 09): extension interception over automatic
|
|
2
|
+
// and manual compaction (cancel or custom summary).
|
|
3
|
+
//
|
|
4
|
+
// Dependency-free like intercept.ts and provider-hooks.ts (type-only imports)
|
|
5
|
+
// so the extension host, the compaction caller (App.tsx), and the tests can
|
|
6
|
+
// all share it with no cycle: extensions register here, doCompact applies
|
|
7
|
+
// here, nobody imports the other.
|
|
8
|
+
//
|
|
9
|
+
// Semantics (fail-open vs fail-closed):
|
|
10
|
+
// - Cancel (fail CLOSED on explicit veto, fail OPEN on throws): handlers run
|
|
11
|
+
// in registration order BEFORE any snapshot/persist/mutate step with the
|
|
12
|
+
// reason and the pending head/tail split. Only an explicit cancel vetoes —
|
|
13
|
+
// true (default reason naming the extension), a non-empty string (that
|
|
14
|
+
// reason), or { cancel: true | "reason" }. Everything else
|
|
15
|
+
// (void/null/false/{cancel:false}/foreign shapes) allows. The first cancel
|
|
16
|
+
// wins: later handlers never run. A throwing handler is recorded and fails
|
|
17
|
+
// OPEN (degrades to the builtin summary with a visible error) — a buggy
|
|
18
|
+
// extension must never hold compaction hostage or half-compact a session
|
|
19
|
+
// (same rationale as the before_switch fail-open gate).
|
|
20
|
+
// - Custom summary (fail OPEN): a handler may return { summary: "text" } to
|
|
21
|
+
// replace the builtin summarizer output. The text must be a non-empty
|
|
22
|
+
// string after trimming — anything else is ignored. The first valid
|
|
23
|
+
// summary wins: later handlers never run. The winning text enters the SAME
|
|
24
|
+
// post-processing as builtin output (touched-files append/fit, boundary
|
|
25
|
+
// marker, atomic swap, save, snapshot clearing) at the single injection
|
|
26
|
+
// point in doCompact — never a parallel pipeline. A bare string return is
|
|
27
|
+
// a cancel reason (the before_switch convention), never a summary, so the
|
|
28
|
+
// two decisions can never be confused.
|
|
29
|
+
// - Read-only split: handlers observe deep copies (per-handler fresh clones
|
|
30
|
+
// of a pristine snapshot), never the live split arrays — an in-place
|
|
31
|
+
// mutation by a handler must not corrupt planning (the provider-hooks
|
|
32
|
+
// deep-copy precedent). The apply entry point never mutates its inputs.
|
|
33
|
+
//
|
|
34
|
+
// Coverage: doCompact in App.tsx is the single compaction funnel (manual
|
|
35
|
+
// /compact via runCompactCommand, pending drains, and auto via
|
|
36
|
+
// maybeAutoCompact all route through it), so one gate covers every reason —
|
|
37
|
+
// auto, manual, and the overflow domain the type reserves for future callers.
|
|
38
|
+
function isRecord(value) {
|
|
39
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
40
|
+
}
|
|
41
|
+
function errorText(e) {
|
|
42
|
+
return e instanceof Error ? e.message : String(e ?? "unknown error");
|
|
43
|
+
}
|
|
44
|
+
function cloneMessages(messages) {
|
|
45
|
+
try {
|
|
46
|
+
return structuredClone(messages);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(JSON.stringify(messages));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return messages.map((m) => ({ ...m }));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Cancel interpretation (single rule, mirrors the before_switch gate): only
|
|
58
|
+
// an explicit veto cancels — true (default reason), a string (that reason,
|
|
59
|
+
// empty falls back to the default), or { cancel: true | "reason" }.
|
|
60
|
+
// Everything else (void/null/false/{cancel:false}/foreign shapes) allows.
|
|
61
|
+
function cancelReasonOf(decision, owner) {
|
|
62
|
+
if (decision === true)
|
|
63
|
+
return `extension "${owner}" cancelled compaction`;
|
|
64
|
+
if (typeof decision === "string") {
|
|
65
|
+
return decision.length > 0 ? decision : `extension "${owner}" cancelled compaction`;
|
|
66
|
+
}
|
|
67
|
+
if (isRecord(decision)) {
|
|
68
|
+
const cancel = decision.cancel;
|
|
69
|
+
if (cancel === true)
|
|
70
|
+
return `extension "${owner}" cancelled compaction`;
|
|
71
|
+
if (typeof cancel === "string") {
|
|
72
|
+
return cancel.length > 0 ? cancel : `extension "${owner}" cancelled compaction`;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
// Custom-summary interpretation (single rule): only { summary } with a
|
|
78
|
+
// non-empty-after-trim string supplies — void/null/false/bare strings (a
|
|
79
|
+
// bare string is a cancel reason above, never a summary)/foreign shapes pass
|
|
80
|
+
// through to the builtin summarizer.
|
|
81
|
+
function summaryOf(decision) {
|
|
82
|
+
if (!isRecord(decision))
|
|
83
|
+
return null;
|
|
84
|
+
const summary = decision.summary;
|
|
85
|
+
if (typeof summary !== "string" || summary.trim().length === 0)
|
|
86
|
+
return null;
|
|
87
|
+
return summary;
|
|
88
|
+
}
|
|
89
|
+
const beforeCompactHandlers = [];
|
|
90
|
+
/** Register a before-compaction hook. Returns an unregister function. */
|
|
91
|
+
export function registerBeforeCompact(handler, owner = "(unknown)") {
|
|
92
|
+
if (typeof handler !== "function") {
|
|
93
|
+
throw new Error("before-compact handler must be a function");
|
|
94
|
+
}
|
|
95
|
+
const record = { owner, handler };
|
|
96
|
+
beforeCompactHandlers.push(record);
|
|
97
|
+
let live = true;
|
|
98
|
+
return () => {
|
|
99
|
+
if (!live)
|
|
100
|
+
return;
|
|
101
|
+
live = false;
|
|
102
|
+
const idx = beforeCompactHandlers.indexOf(record);
|
|
103
|
+
if (idx >= 0)
|
|
104
|
+
beforeCompactHandlers.splice(idx, 1);
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/** Snapshot of live before-compaction handlers in registration order (deterministic composition). */
|
|
108
|
+
export function beforeCompactInterceptors() {
|
|
109
|
+
return [...beforeCompactHandlers];
|
|
110
|
+
}
|
|
111
|
+
/** Test seam: drop every compaction hook. */
|
|
112
|
+
export function clearCompactionHooks() {
|
|
113
|
+
beforeCompactHandlers.length = 0;
|
|
114
|
+
}
|
|
115
|
+
// Apply before-compaction handlers sequentially in registration order. Never
|
|
116
|
+
// throws and never mutates its inputs: each handler receives fresh deep
|
|
117
|
+
// copies of a pristine snapshot, a throwing handler is recorded fail-open
|
|
118
|
+
// (its veto/summary dropped, the chain continues), and the first explicit
|
|
119
|
+
// cancel — or the first valid custom summary — wins with later handlers
|
|
120
|
+
// never running (the before_switch first-wins precedent).
|
|
121
|
+
export async function applyBeforeCompact(handlers, info) {
|
|
122
|
+
const out = {
|
|
123
|
+
cancelled: false,
|
|
124
|
+
cancelReason: null,
|
|
125
|
+
cancelOwner: null,
|
|
126
|
+
summary: null,
|
|
127
|
+
summaryOwner: null,
|
|
128
|
+
errors: [],
|
|
129
|
+
};
|
|
130
|
+
// Pristine snapshot first (never the caller's live arrays): per-handler
|
|
131
|
+
// clones below mean a mutating hook corrupts neither planning nor the
|
|
132
|
+
// next handler's view.
|
|
133
|
+
const pristineHead = cloneMessages(info.head);
|
|
134
|
+
const pristineTail = cloneMessages(info.tail);
|
|
135
|
+
for (const record of handlers) {
|
|
136
|
+
let decision;
|
|
137
|
+
try {
|
|
138
|
+
decision = await record.handler({
|
|
139
|
+
reason: info.reason,
|
|
140
|
+
focusText: info.focusText,
|
|
141
|
+
head: cloneMessages(pristineHead),
|
|
142
|
+
tail: cloneMessages(pristineTail),
|
|
143
|
+
olderTurnCount: info.olderTurnCount,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
catch (e) {
|
|
147
|
+
out.errors.push(`${record.owner}: ${errorText(e)}`);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const reason = cancelReasonOf(decision, record.owner);
|
|
151
|
+
if (reason !== null) {
|
|
152
|
+
out.cancelled = true;
|
|
153
|
+
out.cancelReason = reason;
|
|
154
|
+
out.cancelOwner = record.owner;
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
const summary = summaryOf(decision);
|
|
158
|
+
if (summary !== null) {
|
|
159
|
+
out.summary = summary;
|
|
160
|
+
out.summaryOwner = record.owner;
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Extension-registered model-callable tools (ticket 02): the runtime store
|
|
2
|
+
// behind ExtensionAPI.registerTool. This module stays dependency-free apart
|
|
3
|
+
// from the sibling overrides store (which itself imports nothing), so the
|
|
4
|
+
// registry and the scheduler can still consult it without a runtime cycle.
|
|
5
|
+
import { isToolExecutionMode } from "./overrides.js";
|
|
6
|
+
const NAME_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
7
|
+
const store = new Map();
|
|
8
|
+
function isRecord(value) {
|
|
9
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
10
|
+
}
|
|
11
|
+
function shapeSummary(parameters) {
|
|
12
|
+
// Compact `{field: type}` hint for error details; falls back to raw JSON.
|
|
13
|
+
try {
|
|
14
|
+
const props = parameters["properties"];
|
|
15
|
+
if (isRecord(props)) {
|
|
16
|
+
const parts = [];
|
|
17
|
+
for (const [k, v] of Object.entries(props)) {
|
|
18
|
+
const t = isRecord(v) && typeof v["type"] === "string" ? v["type"] : "any";
|
|
19
|
+
parts.push(`"${k}": ${t}`);
|
|
20
|
+
}
|
|
21
|
+
const required = Array.isArray(parameters["required"])
|
|
22
|
+
? ` (required: ${parameters["required"].map((r) => JSON.stringify(r)).join(", ")})`
|
|
23
|
+
: "";
|
|
24
|
+
return `{${parts.join(", ")}}${required}`;
|
|
25
|
+
}
|
|
26
|
+
const raw = JSON.stringify(parameters);
|
|
27
|
+
return raw.length > 200 ? `${raw.slice(0, 200)}…` : raw;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return "{}";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Validate a registration shape. Throws Error on any problem (bad name,
|
|
35
|
+
* empty description, non-object schema, non-function execute). Duplicate
|
|
36
|
+
* and builtin-collision checks live in the registry wrapper
|
|
37
|
+
* (registerExtensionTool), which knows the builtin names.
|
|
38
|
+
*/
|
|
39
|
+
export function validateExtensionToolDef(def) {
|
|
40
|
+
if (!isRecord(def))
|
|
41
|
+
throw new Error("extension tool definition must be an object");
|
|
42
|
+
if (typeof def.name !== "string" || !NAME_RE.test(def.name)) {
|
|
43
|
+
throw new Error(`extension tool has an invalid name ${JSON.stringify(def.name)} (want 1-64 chars of A-Za-z0-9_-)`);
|
|
44
|
+
}
|
|
45
|
+
if (typeof def.description !== "string" || def.description.trim().length === 0) {
|
|
46
|
+
throw new Error(`extension tool "${def.name}" needs a non-empty description`);
|
|
47
|
+
}
|
|
48
|
+
if (!isRecord(def.parameters) || def.parameters["type"] !== "object") {
|
|
49
|
+
throw new Error(`extension tool "${def.name}" needs a parameters schema object with type "object"`);
|
|
50
|
+
}
|
|
51
|
+
if (typeof def.execute !== "function") {
|
|
52
|
+
throw new Error(`extension tool "${def.name}" needs an execute function`);
|
|
53
|
+
}
|
|
54
|
+
if (def.requireApproval !== undefined && typeof def.requireApproval !== "boolean") {
|
|
55
|
+
throw new Error(`extension tool "${def.name}" field "requireApproval" must be a boolean`);
|
|
56
|
+
}
|
|
57
|
+
if (def.oneLiner !== undefined && typeof def.oneLiner !== "string") {
|
|
58
|
+
throw new Error(`extension tool "${def.name}" field "oneLiner" must be a string`);
|
|
59
|
+
}
|
|
60
|
+
if (def.executionMode !== undefined && !isToolExecutionMode(def.executionMode)) {
|
|
61
|
+
throw new Error(`extension tool "${def.name}" field "executionMode" must be "sequential" or "parallel"`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function typeLabel(v) {
|
|
65
|
+
if (v === null)
|
|
66
|
+
return "null";
|
|
67
|
+
if (Array.isArray(v))
|
|
68
|
+
return "array";
|
|
69
|
+
return typeof v;
|
|
70
|
+
}
|
|
71
|
+
function schemaTypeMatches(schema, value) {
|
|
72
|
+
const t = schema["type"];
|
|
73
|
+
if (typeof t !== "string")
|
|
74
|
+
return true; // untyped property: anything goes
|
|
75
|
+
switch (t) {
|
|
76
|
+
case "string":
|
|
77
|
+
return typeof value === "string";
|
|
78
|
+
case "number":
|
|
79
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
80
|
+
case "integer":
|
|
81
|
+
return typeof value === "number" && Number.isInteger(value);
|
|
82
|
+
case "boolean":
|
|
83
|
+
return typeof value === "boolean";
|
|
84
|
+
case "array":
|
|
85
|
+
return Array.isArray(value);
|
|
86
|
+
case "object":
|
|
87
|
+
return isRecord(value);
|
|
88
|
+
case "null":
|
|
89
|
+
return value === null;
|
|
90
|
+
default:
|
|
91
|
+
return true; // unknown type keyword: do not reject
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Validate parsed args against the tool's parameters schema. Returns a
|
|
95
|
+
// detail string (without prefix) when malformed, or null when valid — the
|
|
96
|
+
// caller frames it with invalidCall so failures surface as inline
|
|
97
|
+
// model-visible errors and the tool never runs.
|
|
98
|
+
export function validateCustomToolArgs(name, args) {
|
|
99
|
+
const rec = store.get(name);
|
|
100
|
+
if (!rec)
|
|
101
|
+
return null;
|
|
102
|
+
if (!isRecord(args)) {
|
|
103
|
+
return `arguments for tool "${name}" must be an object. Expected ${shapeSummary(rec.parameters)}`;
|
|
104
|
+
}
|
|
105
|
+
const exp = shapeSummary(rec.parameters);
|
|
106
|
+
const schema = rec.parameters;
|
|
107
|
+
const required = schema["required"];
|
|
108
|
+
if (Array.isArray(required)) {
|
|
109
|
+
for (const key of required) {
|
|
110
|
+
if (typeof key !== "string")
|
|
111
|
+
continue;
|
|
112
|
+
if (args[key] === undefined) {
|
|
113
|
+
return `missing required field "${key}" for tool "${name}". Expected ${exp}`;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const props = schema["properties"];
|
|
118
|
+
if (isRecord(props)) {
|
|
119
|
+
for (const [key, propSchema] of Object.entries(props)) {
|
|
120
|
+
const value = args[key];
|
|
121
|
+
if (value === undefined)
|
|
122
|
+
continue;
|
|
123
|
+
if (!isRecord(propSchema))
|
|
124
|
+
continue;
|
|
125
|
+
if (!schemaTypeMatches(propSchema, value)) {
|
|
126
|
+
const want = typeof propSchema["type"] === "string" ? propSchema["type"] : "matching value";
|
|
127
|
+
return `field "${key}" for tool "${name}" must be a ${want} (got ${typeLabel(value)}). Expected ${exp}`;
|
|
128
|
+
}
|
|
129
|
+
const en = propSchema["enum"];
|
|
130
|
+
if (Array.isArray(en) && !en.includes(value)) {
|
|
131
|
+
return `field "${key}" for tool "${name}" must be one of ${JSON.stringify(en)} (got ${JSON.stringify(value)}). Expected ${exp}`;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (schema["additionalProperties"] === false && isRecord(props)) {
|
|
136
|
+
for (const key of Object.keys(args)) {
|
|
137
|
+
if (!(key in props)) {
|
|
138
|
+
return `unknown field "${key}" for tool "${name}". Expected ${exp}`;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
/** Register a validated custom tool. Throws on duplicate names. */
|
|
145
|
+
export function registerCustomTool(def) {
|
|
146
|
+
validateExtensionToolDef(def);
|
|
147
|
+
if (store.has(def.name)) {
|
|
148
|
+
throw new Error(`extension tool "${def.name}" is already registered`);
|
|
149
|
+
}
|
|
150
|
+
const rec = {
|
|
151
|
+
name: def.name,
|
|
152
|
+
description: def.description,
|
|
153
|
+
parameters: def.parameters,
|
|
154
|
+
execute: def.execute,
|
|
155
|
+
requireApproval: def.requireApproval ?? true,
|
|
156
|
+
executionMode: def.executionMode,
|
|
157
|
+
oneLiner: typeof def.oneLiner === "string" && def.oneLiner.length > 0
|
|
158
|
+
? def.oneLiner
|
|
159
|
+
: def.description.split("\n")[0].slice(0, 120),
|
|
160
|
+
};
|
|
161
|
+
store.set(def.name, rec);
|
|
162
|
+
let live = true;
|
|
163
|
+
return () => {
|
|
164
|
+
if (!live)
|
|
165
|
+
return;
|
|
166
|
+
live = false;
|
|
167
|
+
if (store.get(def.name) === rec)
|
|
168
|
+
store.delete(def.name);
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
export function unregisterCustomTool(name) {
|
|
172
|
+
return store.delete(name);
|
|
173
|
+
}
|
|
174
|
+
export function getCustomTool(name) {
|
|
175
|
+
return store.get(name);
|
|
176
|
+
}
|
|
177
|
+
export function isCustomTool(name) {
|
|
178
|
+
return store.has(name);
|
|
179
|
+
}
|
|
180
|
+
export function customToolNames() {
|
|
181
|
+
return [...store.keys()];
|
|
182
|
+
}
|
|
183
|
+
export function listCustomTools() {
|
|
184
|
+
return [...store.values()];
|
|
185
|
+
}
|
|
186
|
+
/** Test seam: drop every custom tool (callers restore one-liners separately). */
|
|
187
|
+
export function clearCustomTools() {
|
|
188
|
+
store.clear();
|
|
189
|
+
}
|
package/dist/tools/dir-cache.js
CHANGED
|
@@ -76,6 +76,13 @@ function toCwdRel(absDir, cwd, dirRel) {
|
|
|
76
76
|
return dirRel;
|
|
77
77
|
return `${prefix}/${dirRel}`;
|
|
78
78
|
}
|
|
79
|
+
// Public for the ripgrep adapter: rg emits dir-relative paths, but the
|
|
80
|
+
// enumerated contract (and therefore outputs) is cwd-relative — which may
|
|
81
|
+
// climb out of the tree (`../../..`) when the search dir sits outside cwd.
|
|
82
|
+
// Exported so both paths share the one mapping (never duplicated logic).
|
|
83
|
+
export function rgRelToCwdRel(absDir, cwd, dirRel) {
|
|
84
|
+
return toCwdRel(absDir, cwd, dirRel);
|
|
85
|
+
}
|
|
79
86
|
function filterSkipped(relPaths) {
|
|
80
87
|
return relPaths.filter((rel) => {
|
|
81
88
|
if (!rel)
|
package/dist/tools/filesystem.js
CHANGED
|
@@ -7,7 +7,7 @@ import { contentHash, fingerprintKey, readFingerprints } from "./fingerprints.js
|
|
|
7
7
|
import { appendOverflow } from "./overflow.js";
|
|
8
8
|
import { getCachedRead, invalidatePath, normalizeReadWindow, setCachedRead } from "./read-cache.js";
|
|
9
9
|
import { invalidateListingsForFile } from "./dir-cache.js";
|
|
10
|
-
import { err, invalidCall, READ_CHAR_CAP, resolveSandbox } from "./shared.js";
|
|
10
|
+
import { err, invalidCall, READ_CHAR_CAP, resolveSandbox, truncateHead } from "./shared.js";
|
|
11
11
|
// offset/limit are 1-based line numbers. Output capped at ~64KB.
|
|
12
12
|
export async function readTool(args, cwd = process.cwd()) {
|
|
13
13
|
try {
|
|
@@ -58,7 +58,8 @@ export async function readTool(args, cwd = process.cwd()) {
|
|
|
58
58
|
let out = window.map((line, i) => `${offset + i}: ${line}`).join("\n");
|
|
59
59
|
if (out.length > READ_CHAR_CAP) {
|
|
60
60
|
const full = out;
|
|
61
|
-
|
|
61
|
+
const t = truncateHead(full, READ_CHAR_CAP, "\n[truncated: output exceeded 64KB]");
|
|
62
|
+
out = appendOverflow(t.head, t.note, "file output", full);
|
|
62
63
|
}
|
|
63
64
|
try {
|
|
64
65
|
const statInfo = { mtimeMs: st.mtimeMs ?? 0, size: st.size ?? 0 };
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Tool-call interception store (ticket 03): extension pre/post hooks over
|
|
2
|
+
// every loop-executed tool call (builtins and custom tools alike).
|
|
3
|
+
//
|
|
4
|
+
// Dependency-free like custom.ts (no imports) so the extension host, the
|
|
5
|
+
// registry barrel, and the agentic loop can all share it with no cycle:
|
|
6
|
+
// extensions register here, the loop applies here, nobody imports the other.
|
|
7
|
+
//
|
|
8
|
+
// Semantics:
|
|
9
|
+
// - Before handlers run in registration order and see each call pre-
|
|
10
|
+
// validation and pre-approval. Each may return { args } to rewrite the
|
|
11
|
+
// arguments (later handlers see the rewrite) or { block: reason } / a
|
|
12
|
+
// reason string / { block: true } to veto the execution. The first block
|
|
13
|
+
// wins: later handlers never run for a blocked call, approval is skipped
|
|
14
|
+
// entirely, and the reason commits as a normal model-visible result.
|
|
15
|
+
// - A throwing (or rejecting) before handler fails CLOSED: the call is
|
|
16
|
+
// blocked with a handler-failed reason and the turn continues. Unknown
|
|
17
|
+
// behavior never executes blindly.
|
|
18
|
+
// - After handlers run in registration order inside the commit funnel, so
|
|
19
|
+
// they observe every committed result (executions, blocks, denials,
|
|
20
|
+
// validation errors). Each may return a string or { content } to patch
|
|
21
|
+
// what the model sees. A throwing after handler fails OPEN to the
|
|
22
|
+
// original result — a patch must never break the turn or the
|
|
23
|
+
// tool_call_id re-pairing/commit order around it.
|
|
24
|
+
function isRecord(value) {
|
|
25
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
26
|
+
}
|
|
27
|
+
function errorText(e) {
|
|
28
|
+
return e instanceof Error ? e.message : String(e ?? "unknown error");
|
|
29
|
+
}
|
|
30
|
+
const beforeHandlers = [];
|
|
31
|
+
const afterHandlers = [];
|
|
32
|
+
/** Register a pre-execution interceptor. Returns an unregister function. */
|
|
33
|
+
export function registerBeforeToolCall(handler, owner = "(unknown)") {
|
|
34
|
+
if (typeof handler !== "function") {
|
|
35
|
+
throw new Error("before-tool-call handler must be a function");
|
|
36
|
+
}
|
|
37
|
+
const record = { owner, handler };
|
|
38
|
+
beforeHandlers.push(record);
|
|
39
|
+
let live = true;
|
|
40
|
+
return () => {
|
|
41
|
+
if (!live)
|
|
42
|
+
return;
|
|
43
|
+
live = false;
|
|
44
|
+
const idx = beforeHandlers.indexOf(record);
|
|
45
|
+
if (idx >= 0)
|
|
46
|
+
beforeHandlers.splice(idx, 1);
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** Register a post-execution result patcher. Returns an unregister function. */
|
|
50
|
+
export function registerAfterToolCall(handler, owner = "(unknown)") {
|
|
51
|
+
if (typeof handler !== "function") {
|
|
52
|
+
throw new Error("after-tool-call handler must be a function");
|
|
53
|
+
}
|
|
54
|
+
const record = { owner, handler };
|
|
55
|
+
afterHandlers.push(record);
|
|
56
|
+
let live = true;
|
|
57
|
+
return () => {
|
|
58
|
+
if (!live)
|
|
59
|
+
return;
|
|
60
|
+
live = false;
|
|
61
|
+
const idx = afterHandlers.indexOf(record);
|
|
62
|
+
if (idx >= 0)
|
|
63
|
+
afterHandlers.splice(idx, 1);
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** Snapshot of live before handlers in registration order (deterministic composition). */
|
|
67
|
+
export function beforeToolInterceptors() {
|
|
68
|
+
return [...beforeHandlers];
|
|
69
|
+
}
|
|
70
|
+
/** Snapshot of live after handlers in registration order. */
|
|
71
|
+
export function afterToolInterceptors() {
|
|
72
|
+
return [...afterHandlers];
|
|
73
|
+
}
|
|
74
|
+
/** Test seam: drop every interceptor. */
|
|
75
|
+
export function clearToolInterceptors() {
|
|
76
|
+
beforeHandlers.length = 0;
|
|
77
|
+
afterHandlers.length = 0;
|
|
78
|
+
}
|
|
79
|
+
/** Model-visible result for a blocked call (an `Error:` result, committed normally — the turn continues). */
|
|
80
|
+
export function blockedToolResult(name, owner, reason) {
|
|
81
|
+
const trimmed = reason.trim();
|
|
82
|
+
const by = owner.length > 0 ? `extension "${owner}"` : "extension";
|
|
83
|
+
return trimmed.length > 0
|
|
84
|
+
? `Error: blocked by ${by}: ${trimmed}`
|
|
85
|
+
: `Error: blocked by ${by}: tool "${name}" was blocked`;
|
|
86
|
+
}
|
|
87
|
+
// Apply before handlers sequentially in registration order. Never throws:
|
|
88
|
+
// a throwing handler fails closed to a blocked outcome carrying the cause.
|
|
89
|
+
export async function applyBeforeInterceptors(handlers, name, args) {
|
|
90
|
+
let current = args;
|
|
91
|
+
for (const record of handlers) {
|
|
92
|
+
let decision;
|
|
93
|
+
try {
|
|
94
|
+
decision = await record.handler({ name, args: current });
|
|
95
|
+
}
|
|
96
|
+
catch (e) {
|
|
97
|
+
return {
|
|
98
|
+
args: current,
|
|
99
|
+
blocked: blockedToolResult(name, record.owner, `handler failed: ${errorText(e)}`),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
if (typeof decision === "string") {
|
|
103
|
+
// Convenience form: a returned string is a block reason.
|
|
104
|
+
return { args: current, blocked: blockedToolResult(name, record.owner, decision) };
|
|
105
|
+
}
|
|
106
|
+
if (!isRecord(decision))
|
|
107
|
+
continue;
|
|
108
|
+
const next = decision;
|
|
109
|
+
if (isRecord(next["args"]))
|
|
110
|
+
current = next["args"];
|
|
111
|
+
const block = next["block"];
|
|
112
|
+
if (block === true) {
|
|
113
|
+
return { args: current, blocked: blockedToolResult(name, record.owner, "") };
|
|
114
|
+
}
|
|
115
|
+
if (typeof block === "string" && block.trim().length > 0) {
|
|
116
|
+
return { args: current, blocked: blockedToolResult(name, record.owner, block) };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { args: current, blocked: null };
|
|
120
|
+
}
|
|
121
|
+
// Apply after handlers sequentially; each sees the previous patch. Never
|
|
122
|
+
// throws: a throwing handler fails open to the content so far.
|
|
123
|
+
export async function applyAfterInterceptors(handlers, input) {
|
|
124
|
+
let content = input.result;
|
|
125
|
+
const isError = input.isError;
|
|
126
|
+
for (const record of handlers) {
|
|
127
|
+
let decision;
|
|
128
|
+
try {
|
|
129
|
+
decision = await record.handler({ name: input.name, args: input.args, result: content, isError });
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (typeof decision === "string") {
|
|
135
|
+
content = decision;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (!isRecord(decision))
|
|
139
|
+
continue;
|
|
140
|
+
const patch = decision["content"];
|
|
141
|
+
if (typeof patch === "string")
|
|
142
|
+
content = patch;
|
|
143
|
+
}
|
|
144
|
+
return { content, isError };
|
|
145
|
+
}
|