atom-agent 1.3.0 → 1.5.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 -0
- package/README.md +220 -224
- package/dist/App.js +922 -341
- package/dist/adapters.js +127 -14
- package/dist/agent/goal-evaluator.js +3 -0
- package/dist/agent/loop.js +211 -430
- package/dist/agent/tool-pipeline.js +398 -0
- package/dist/agent/turn-events.js +12 -0
- package/dist/cli.js +57 -8
- package/dist/compact.js +72 -8
- package/dist/config.js +19 -0
- package/dist/context-manager.js +6 -2
- package/dist/extensions.js +6 -0
- package/dist/file-diffs.js +108 -0
- package/dist/kilo.js +1 -1
- package/dist/local-discovery.js +2 -2
- package/dist/media.js +276 -0
- package/dist/overflow.js +140 -0
- package/dist/policy.js +8 -0
- package/dist/scheduler.js +38 -9
- package/dist/session-revert.js +125 -0
- package/dist/sessions.js +101 -0
- package/dist/snapshots.js +69 -0
- package/dist/system.js +2 -89
- package/dist/telemetry.js +26 -1
- package/dist/todos.js +241 -0
- package/dist/tools/filesystem.js +102 -22
- package/dist/tools/registry.js +184 -45
- package/dist/tools/ripgrep.js +7 -6
- package/dist/tools/search.js +172 -17
- package/dist/tools/shared.js +6 -0
- package/dist/tools.js +7 -39
- package/dist/ui/diff-panel.js +5 -5
- package/dist/ui/diff-view.js +16 -7
- package/dist/ui/diff.js +73 -51
- package/dist/ui/errors.js +20 -6
- package/dist/ui/input.js +24 -20
- package/dist/ui/live-tail.js +36 -1
- package/dist/ui/markdown.js +9 -4
- package/dist/ui/modals.js +6 -4
- package/dist/ui/paint-scheduler.js +120 -0
- package/dist/ui/palette.js +4 -2
- package/dist/ui/pickers.js +4 -1
- package/dist/ui/side-by-side.js +88 -27
- package/dist/ui/status-bar.js +63 -8
- package/dist/ui/stream-store.js +7 -0
- package/dist/ui/theme.js +23 -1
- package/dist/ui/todo-panel.js +5 -2
- package/dist/ui/tool-inspector.js +33 -4
- package/dist/ui/transcript.js +9 -6
- package/dist/web/events.js +93 -0
- package/dist/web/runtime.js +790 -0
- package/dist/web/server.js +570 -0
- package/dist/web/ui/app.js +1925 -0
- package/dist/web/ui/index.html +135 -0
- package/dist/web/ui/styles.css +515 -0
- package/dist/zen.js +115 -4
- package/documentation/cli.md +5 -5
- package/documentation/configuration.md +11 -6
- package/documentation/development.md +4 -3
- package/documentation/extensions.md +1 -1
- package/documentation/goals.md +1 -1
- package/documentation/index.md +4 -4
- package/documentation/providers.md +2 -3
- package/documentation/skills.md +3 -3
- package/documentation/tools.md +8 -3
- package/documentation/troubleshooting.md +1 -1
- package/examples/extensions/01-audit-gate.js +2 -2
- package/examples/extensions/02-notes-tool.js +2 -2
- package/examples/extensions/03-custom-command.js +2 -2
- package/package.json +3 -2
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
// One per-call pipeline for the SERIAL tool path: every serial tool call runs
|
|
2
|
+
// through hook → validate → approve → execute → post-hook → commit in this
|
|
3
|
+
// order, speaking one decision vocabulary and emitting one receipt.
|
|
4
|
+
//
|
|
5
|
+
// Ordering (the serial driver in ./loop.js calls runSerialToolPipeline, then
|
|
6
|
+
// its shared commit funnel, then reports telemetry from the receipt):
|
|
7
|
+
// 1. unknown-name gate (registry toolNames() — the intercepted tools are
|
|
8
|
+
// members, so no exemption exists).
|
|
9
|
+
// 2. before-hooks (fail CLOSED to blocked; rewrites flow downstream).
|
|
10
|
+
// 3. validation of the effective (post-rewrite) args (fail → inline error).
|
|
11
|
+
// 4. registry-intercepted stage (validated, never needs approval, resolved
|
|
12
|
+
// without an executor via runInterceptedTool — dispatched by roster
|
|
13
|
+
// lookup, never by name).
|
|
14
|
+
// 5. approval via the existing hook as-is (ticket 04 owns the policy).
|
|
15
|
+
// 6. execution with timeout + normalization (throw/cancel propagates —
|
|
16
|
+
// never a receipt, never retried).
|
|
17
|
+
// 7. post-hooks + result hook inside the commit funnel (fail OPEN to the
|
|
18
|
+
// original result; veto skips the commit).
|
|
19
|
+
//
|
|
20
|
+
// Decision vocabulary — each shape documented once, here:
|
|
21
|
+
// - BeforeOutcome { args, blocked }: pre-hook verdict. `blocked !== null`
|
|
22
|
+
// commits without executing (first block wins, approval skipped).
|
|
23
|
+
// - string | null from validateToolArgs: non-null detail = invalid args,
|
|
24
|
+
// committed as an `Error: invalid call: …` result, never executes.
|
|
25
|
+
// - ApprovalDecision | null ("once" | "always" | "no" | null): "no" (or a
|
|
26
|
+
// throwing approver) denies without executing; null = no approval applies.
|
|
27
|
+
// - string execution result, or a THROWN error (cancel → LoopCancelledError,
|
|
28
|
+
// aborts the turn; anything else aborts the turn too — never a result).
|
|
29
|
+
// - AfterOutcome { content, isError }: post-hook patch verdict.
|
|
30
|
+
// - ToolResultHookDecision | string | null | void: `content` replaces the
|
|
31
|
+
// result, `isError` overrides the error flag, `veto: true` skips the
|
|
32
|
+
// commit; string replaces content; anything else (or a throw) passes the
|
|
33
|
+
// original through.
|
|
34
|
+
// - PipelineDecisionKind: the serial path's terminal label per call.
|
|
35
|
+
// - ToolCallReceipt: the single receipt telemetry, history, and the
|
|
36
|
+
// transcript all observe (see the truth choice below).
|
|
37
|
+
//
|
|
38
|
+
// Receipt truth choice: the receipt carries the EFFECTIVE post-before-hook
|
|
39
|
+
// args (what validated, approved, and ran — pre-hook args never executed,
|
|
40
|
+
// so measuring them would bill work that never happened) and the FINAL
|
|
41
|
+
// post-after-hook result (what history/activity/the model see — measuring
|
|
42
|
+
// the pre-patch result would split "what ran" from "what was recorded").
|
|
43
|
+
// What the user sees is what got measured.
|
|
44
|
+
//
|
|
45
|
+
// Parallel batches keep their own pre-pass + ordered commit with pre-commit
|
|
46
|
+
// telemetry (ticket 05): the pre-pass plans each member via planToolCall
|
|
47
|
+
// above (serially, in call order) and members execute through
|
|
48
|
+
// runPlannedToolCall — the same plan/run pair the serial path uses. The
|
|
49
|
+
// batch planner, the serial approval pre-pass, and the ordered commit stay
|
|
50
|
+
// in ./loop.js untouched, so this module never changes parallel behavior.
|
|
51
|
+
//
|
|
52
|
+
// Dependency direction: agent/tool-pipeline -> {config, goal, tools,
|
|
53
|
+
// tools/intercept, agent/normalize, agent/types} and NOT agent/loop or zen
|
|
54
|
+
// (loop.js imports this module; the graph stays acyclic — see
|
|
55
|
+
// tests/architecture.test.ts).
|
|
56
|
+
import { invalidCall, isInterceptedTool, needsApproval, runInterceptedTool, toolNames, validateToolArgs, } from "../tools.js";
|
|
57
|
+
import { afterToolInterceptors, applyAfterInterceptors, applyBeforeInterceptors, beforeToolInterceptors, blockedToolResult, } from "../tools/intercept.js";
|
|
58
|
+
import { normalizeToolResult } from "./normalize.js";
|
|
59
|
+
// Whole-turn cancellation: thrown when the user cancels (Ctrl+C) mid-loop.
|
|
60
|
+
// The App catches it, rolls the partial turn back (same splice contract as
|
|
61
|
+
// POST failure), renders one dim `(cancelled)` line, and returns to a clean
|
|
62
|
+
// input state. Never retried, never a tool result.
|
|
63
|
+
export class LoopCancelledError extends Error {
|
|
64
|
+
constructor() {
|
|
65
|
+
super("(cancelled)");
|
|
66
|
+
this.name = "LoopCancelledError";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export function isCancelError(e) {
|
|
70
|
+
if (e instanceof LoopCancelledError)
|
|
71
|
+
return true;
|
|
72
|
+
if (e instanceof Error && e.name === "LoopCancelledError")
|
|
73
|
+
return true;
|
|
74
|
+
// fetch abort surfaces as DOMException AbortError (or Error with that name
|
|
75
|
+
// in mocks). Treat any AbortError as a cancellation, never a retry.
|
|
76
|
+
if (e instanceof Error && e.name === "AbortError")
|
|
77
|
+
return true;
|
|
78
|
+
if (typeof DOMException !== "undefined" && e instanceof DOMException && e.name === "AbortError") {
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
export function throwIfCancelled(signal) {
|
|
84
|
+
if (signal?.aborted)
|
|
85
|
+
throw new LoopCancelledError();
|
|
86
|
+
}
|
|
87
|
+
// Per-tool outer timeout (ms): undefined → default 60s (enabled); explicit
|
|
88
|
+
// <=0/NaN → disabled (direct await, zero overhead). Clamped 1s–120s when
|
|
89
|
+
// enabled so a stuck executor can never hang the turn past the bash ceiling.
|
|
90
|
+
export const DEFAULT_TOOL_TIMEOUT_MS = 60_000;
|
|
91
|
+
export function resolveToolTimeoutMs(raw) {
|
|
92
|
+
if (raw === undefined)
|
|
93
|
+
return DEFAULT_TOOL_TIMEOUT_MS;
|
|
94
|
+
if (typeof raw !== "number" || !Number.isFinite(raw))
|
|
95
|
+
return DEFAULT_TOOL_TIMEOUT_MS;
|
|
96
|
+
if (raw <= 0)
|
|
97
|
+
return null;
|
|
98
|
+
return Math.min(Math.max(Math.floor(raw), 1000), 120_000);
|
|
99
|
+
}
|
|
100
|
+
// Race one execution against the outer timeout. Timeout resolves to an
|
|
101
|
+
// `Error:` result (the model adapts); the underlying promise is left to
|
|
102
|
+
// settle — executors own their own cleanup.
|
|
103
|
+
//
|
|
104
|
+
// Cancellation is DELIBERATELY not raced here: the pinned contract is that
|
|
105
|
+
// an in-flight tool runs to completion and its result IS recorded, with the
|
|
106
|
+
// cancel stopping the turn before the next batch/POST (see the
|
|
107
|
+
// throwIfCancelled checks between batches and before each POST). Racing
|
|
108
|
+
// abort against the execution would drop the in-flight result and break
|
|
109
|
+
// assistant/tool pairing guarantees the tests pin. A hung tool + cancel
|
|
110
|
+
// therefore waits for the timeout (≤60s), commits the timeout error, then
|
|
111
|
+
// the next boundary check throws LoopCancelledError.
|
|
112
|
+
export async function executeWithTimeout(execute, name, parsed, timeoutMs, signal) {
|
|
113
|
+
// No new executions after a cancel: refuse to start when already aborted.
|
|
114
|
+
if (signal?.aborted)
|
|
115
|
+
throw new LoopCancelledError();
|
|
116
|
+
if (timeoutMs === null) {
|
|
117
|
+
return execute(name, parsed);
|
|
118
|
+
}
|
|
119
|
+
let timer = null;
|
|
120
|
+
try {
|
|
121
|
+
const execP = execute(name, parsed);
|
|
122
|
+
const timeoutP = new Promise((_resolve, reject) => {
|
|
123
|
+
timer = setTimeout(() => {
|
|
124
|
+
const err = new Error(`timeout after ${timeoutMs}ms`);
|
|
125
|
+
err.code = "ToolTimeout";
|
|
126
|
+
reject(err);
|
|
127
|
+
}, timeoutMs);
|
|
128
|
+
});
|
|
129
|
+
try {
|
|
130
|
+
return await Promise.race([execP, timeoutP]);
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
if (isCancelError(e))
|
|
134
|
+
throw e;
|
|
135
|
+
if (e?.code === "ToolTimeout" || e?.message?.startsWith("timeout after ")) {
|
|
136
|
+
return `Error: ${name} timed out after ${timeoutMs}ms — retry with a narrower scope or smaller input.`;
|
|
137
|
+
}
|
|
138
|
+
throw e;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
if (timer)
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// Permission-gate resolution shared by the serial path and the parallel
|
|
147
|
+
// pre-pass: returns the hook's decision, or null when no approval applies
|
|
148
|
+
// (no hook, or a tool that never needs it). A "no" (or a throwing hook)
|
|
149
|
+
// denies without executing; cancellation always propagates.
|
|
150
|
+
export async function resolveApproval(name, parsed, opts) {
|
|
151
|
+
if (!opts?.approve || !needsApproval(name))
|
|
152
|
+
return null;
|
|
153
|
+
try {
|
|
154
|
+
const decision = await opts.approve(name, parsed);
|
|
155
|
+
// Abort that lands as a resolved denial still cancels the whole turn.
|
|
156
|
+
throwIfCancelled(opts?.signal);
|
|
157
|
+
return decision;
|
|
158
|
+
}
|
|
159
|
+
catch (e) {
|
|
160
|
+
// Whole-turn cancellation must propagate (Ctrl+C cancels the turn,
|
|
161
|
+
// not just deny one call). Anything else is a denial.
|
|
162
|
+
if (isCancelError(e) || opts?.signal?.aborted)
|
|
163
|
+
throw new LoopCancelledError();
|
|
164
|
+
return "no";
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// Shared JSON-arguments parse for the serial driver: malformed arguments
|
|
168
|
+
// never reach hooks or executors — the caller routes the null case through
|
|
169
|
+
// the commit funnel as an invalid-call error. Returns the parsed record, or
|
|
170
|
+
// null when the raw arguments are not a JSON object.
|
|
171
|
+
export function parseToolArguments(raw) {
|
|
172
|
+
try {
|
|
173
|
+
const text = typeof raw === "string" ? raw : "{}";
|
|
174
|
+
const value = JSON.parse(text);
|
|
175
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
176
|
+
return value;
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// Model-visible result for arguments that are not a JSON object (an
|
|
185
|
+
// `Error: invalid call: …` result, committed normally — the turn continues).
|
|
186
|
+
export function invalidJsonArgsResult(name) {
|
|
187
|
+
return (`Error: invalid call: invalid JSON arguments for tool "${name}" ` +
|
|
188
|
+
`(arguments must be valid JSON). Fix the arguments and retry.`);
|
|
189
|
+
}
|
|
190
|
+
// Extension tool-call interception: global pre/post hooks registered via
|
|
191
|
+
// ExtensionAPI.onBeforeToolCall/onAfterToolCall. Both helpers snapshot the
|
|
192
|
+
// live handler list and never throw — a throwing before handler fails
|
|
193
|
+
// closed (blocked outcome), a throwing after handler fails open (original
|
|
194
|
+
// content). Cancellation checks stay where they are; hooks are in-process
|
|
195
|
+
// policy, not executions, so they run even when the signal is armed and the
|
|
196
|
+
// existing boundaries still stop the turn.
|
|
197
|
+
export async function runBeforeIntercept(name, parsed) {
|
|
198
|
+
try {
|
|
199
|
+
return await applyBeforeInterceptors(beforeToolInterceptors(), name, parsed);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return { args: parsed, blocked: blockedToolResult(name, "(unknown)", "interception failed") };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
export async function runAfterIntercept(name, parsed, result, isError) {
|
|
206
|
+
try {
|
|
207
|
+
return await applyAfterInterceptors(afterToolInterceptors(), { name, args: parsed, result, isError });
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return { content: result, isError };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// After-tool-call result hook (issue 06): the single rewrite seam between
|
|
214
|
+
// execution and commit. Absent hook (or null/undefined/void/non-object
|
|
215
|
+
// return) → the original result, byte-identical. A string return replaces
|
|
216
|
+
// content (error flag kept); an object may replace content, override
|
|
217
|
+
// isError, or veto the commit. Hook failures degrade to the original — the
|
|
218
|
+
// turn never breaks. The committed receipt carries the rewrite, so
|
|
219
|
+
// telemetry, history, and activity all observe it.
|
|
220
|
+
export async function applyToolResultHook(hook, name, parsed, result, isError) {
|
|
221
|
+
if (!hook)
|
|
222
|
+
return { content: result, isError, veto: false };
|
|
223
|
+
try {
|
|
224
|
+
const decision = await hook({ name, args: parsed, result, isError });
|
|
225
|
+
if (decision === null || decision === undefined)
|
|
226
|
+
return { content: result, isError, veto: false };
|
|
227
|
+
if (typeof decision === "string")
|
|
228
|
+
return { content: decision, isError, veto: false };
|
|
229
|
+
if (typeof decision === "object") {
|
|
230
|
+
if (decision.veto === true)
|
|
231
|
+
return { content: result, isError, veto: true };
|
|
232
|
+
const content = typeof decision.content === "string" ? decision.content : result;
|
|
233
|
+
const nextIsError = typeof decision.isError === "boolean" ? decision.isError : isError;
|
|
234
|
+
return { content, isError: nextIsError, veto: false };
|
|
235
|
+
}
|
|
236
|
+
return { content: result, isError, veto: false };
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return { content: result, isError, veto: false };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// The commit-funnel patch sequence (post-hooks, then the result hook),
|
|
243
|
+
// shared by the serial and parallel commits: every committed result passes
|
|
244
|
+
// through both, in commit order, so tool_call_id re-pairing and ordering
|
|
245
|
+
// are untouched. Never throws (both stages fail open — see above).
|
|
246
|
+
export async function applyCommitPatches(name, parsed, result, isError, hook) {
|
|
247
|
+
const after = await runAfterIntercept(name, parsed, result, isError);
|
|
248
|
+
return applyToolResultHook(hook, name, parsed, after.content, after.isError);
|
|
249
|
+
}
|
|
250
|
+
// Model-visible result for an unknown tool name (an `Error: unknown tool:
|
|
251
|
+
// … Available: …` result, committed normally — the turn continues).
|
|
252
|
+
export function unknownToolResult(name) {
|
|
253
|
+
return `Error: unknown tool "${name}". Available: ${toolNames().join(", ")}`;
|
|
254
|
+
}
|
|
255
|
+
export async function planToolCall(name, parsed, opts) {
|
|
256
|
+
// Unknown-name gate: the registry's toolNames() is the single source — it
|
|
257
|
+
// already includes the intercepted tools, so no exemption is needed.
|
|
258
|
+
if (!toolNames().includes(name)) {
|
|
259
|
+
return {
|
|
260
|
+
plan: { args: parsed, blocked: null, invalid: null, unknown: unknownToolResult(name) },
|
|
261
|
+
preDecision: null,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
const pre = await runBeforeIntercept(name, parsed);
|
|
265
|
+
if (pre.blocked !== null) {
|
|
266
|
+
return {
|
|
267
|
+
plan: { args: pre.args, blocked: pre.blocked, invalid: null, unknown: null },
|
|
268
|
+
preDecision: null,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
const invalid = validateToolArgs(name, pre.args);
|
|
272
|
+
if (invalid) {
|
|
273
|
+
return {
|
|
274
|
+
plan: { args: pre.args, blocked: null, invalid, unknown: null },
|
|
275
|
+
preDecision: null,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
const preDecision = await resolveApproval(name, pre.args, opts);
|
|
279
|
+
return {
|
|
280
|
+
plan: { args: pre.args, blocked: null, invalid: null, unknown: null },
|
|
281
|
+
preDecision,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
// The ONE per-call runner: a planned call either yields its inline result
|
|
285
|
+
// (unknown/blocked/invalid — never executes; denial resolves inside the
|
|
286
|
+
// shared stages via the pre-decision) or executes through the shared
|
|
287
|
+
// validate → intercepted → approve-or-preDecision → execute stages. Serial
|
|
288
|
+
// calls plan+run inline; parallel members plan serially in the pre-pass,
|
|
289
|
+
// then run here concurrently.
|
|
290
|
+
export async function runPlannedToolCall(call, plan, preDecision, opts, execute, onUpdateGoal) {
|
|
291
|
+
if (plan.unknown !== null) {
|
|
292
|
+
return { result: plan.unknown, args: plan.args, decision: "unknown-tool" };
|
|
293
|
+
}
|
|
294
|
+
if (plan.blocked !== null) {
|
|
295
|
+
return { result: plan.blocked, args: plan.args, decision: "blocked" };
|
|
296
|
+
}
|
|
297
|
+
if (plan.invalid !== null) {
|
|
298
|
+
return { result: invalidCall(plan.invalid), args: plan.args, decision: "invalid-args" };
|
|
299
|
+
}
|
|
300
|
+
return runStagesWithDecision(call, plan.args, opts, execute, preDecision, onUpdateGoal);
|
|
301
|
+
}
|
|
302
|
+
// Inner execution after pre-interception: validate (rewritten) args, then
|
|
303
|
+
// the registry-intercepted stage, then permission, then the executor. Kept
|
|
304
|
+
// for import compatibility; new code prefers planToolCall +
|
|
305
|
+
// runPlannedToolCall.
|
|
306
|
+
export async function runOneToolWithArgs(call, parsed, opts, execute, preDecision, onUpdateGoal) {
|
|
307
|
+
const outcome = await runStagesWithDecision(call, parsed, opts, execute, preDecision, onUpdateGoal);
|
|
308
|
+
return { result: outcome.result, args: outcome.args };
|
|
309
|
+
}
|
|
310
|
+
async function runStagesWithDecision(call, parsed, opts, execute, preDecision, onUpdateGoal) {
|
|
311
|
+
const name = call?.function?.name ?? "(unknown)";
|
|
312
|
+
// Argument validation BEFORE approval/execution: model mistake, never runs.
|
|
313
|
+
// Pre-hook rewrites arrive here already applied, so they re-validate on
|
|
314
|
+
// exactly what would execute — invalid rewrites never reach an executor.
|
|
315
|
+
const detail = validateToolArgs(name, parsed);
|
|
316
|
+
if (detail) {
|
|
317
|
+
return { result: invalidCall(detail), args: parsed, decision: "invalid-args" };
|
|
318
|
+
}
|
|
319
|
+
// Intercepted tools (ask_question/update_goal): validated above, never need
|
|
320
|
+
// approval, resolved without an executor. Dispatched by registry roster
|
|
321
|
+
// lookup — never by name — so visibility and executability stay one list.
|
|
322
|
+
if (isInterceptedTool(name)) {
|
|
323
|
+
throwIfCancelled(opts?.signal);
|
|
324
|
+
// If the signal aborts during the modal, the runner rethrows raw and the
|
|
325
|
+
// mapping below raises LoopCancelledError (no result). If it resolves
|
|
326
|
+
// just as the signal aborts, return the result — the loop records it,
|
|
327
|
+
// then stops before the next POST (no new POSTs, pairing stays valid
|
|
328
|
+
// until rollback).
|
|
329
|
+
try {
|
|
330
|
+
const resolved = await runInterceptedTool(name, parsed, {
|
|
331
|
+
askUser: opts?.askUser,
|
|
332
|
+
signal: opts?.signal,
|
|
333
|
+
onUpdateGoal,
|
|
334
|
+
});
|
|
335
|
+
// Non-null by roster contract (isInterceptedTool just matched); the
|
|
336
|
+
// fallthrough keeps this total if the roster ever drifts — the call
|
|
337
|
+
// then flows into approval/execution like any other known tool.
|
|
338
|
+
if (resolved !== null) {
|
|
339
|
+
return { result: resolved.result, args: parsed, decision: resolved.decision };
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
catch (e) {
|
|
343
|
+
if (isCancelError(e) || opts?.signal?.aborted)
|
|
344
|
+
throw new LoopCancelledError();
|
|
345
|
+
throw e;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const decision = preDecision ?? (await resolveApproval(name, parsed, opts));
|
|
349
|
+
if (decision === "no") {
|
|
350
|
+
return { result: `Error: denied by user: ${name}`, args: parsed, decision: "denied" };
|
|
351
|
+
}
|
|
352
|
+
// "once"/"always" run this call (the caller caches the always-allowed set
|
|
353
|
+
// session-wide so later calls skip the prompt).
|
|
354
|
+
// No new executions after a cancel: stop after the current tool finishes.
|
|
355
|
+
// The current tool (if already running) is awaited to completion and its
|
|
356
|
+
// result IS recorded — the loop then stops before the next tool/POST, so
|
|
357
|
+
// assistant/tool pairing stays valid until the caller rolls back.
|
|
358
|
+
throwIfCancelled(opts?.signal);
|
|
359
|
+
const timeoutMs = resolveToolTimeoutMs(opts?.toolTimeoutMs);
|
|
360
|
+
const doNormalize = opts?.normalizeResults !== false;
|
|
361
|
+
try {
|
|
362
|
+
const raw = await executeWithTimeout(execute, name, parsed, timeoutMs, opts?.signal);
|
|
363
|
+
if (doNormalize)
|
|
364
|
+
return { result: normalizeToolResult(raw), args: parsed, decision: "executed" };
|
|
365
|
+
return { result: typeof raw === "string" ? raw : normalizeToolResult(raw), args: parsed, decision: "executed" };
|
|
366
|
+
}
|
|
367
|
+
catch (e) {
|
|
368
|
+
if (isCancelError(e) || opts?.signal?.aborted)
|
|
369
|
+
throw new LoopCancelledError();
|
|
370
|
+
throw e;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
// The ONE serial per-call pipeline: unknown-name gate → before-hooks →
|
|
374
|
+
// validate → approve → execute (stages 1–6 above), returning the execution
|
|
375
|
+
// result with the effective args and the terminal decision. Post-hooks and
|
|
376
|
+
// the commit run in the shared commit funnel (via applyCommitPatches), so
|
|
377
|
+
// the serial and parallel commits patch identically; the serial driver
|
|
378
|
+
// reports telemetry from the funnel's receipt, unifying all three observers.
|
|
379
|
+
// Hook-vs-approval ordering: pre-hooks run BEFORE validation and approval.
|
|
380
|
+
// The hook sees the call pre-approval and may rewrite args before the
|
|
381
|
+
// approval prompt shows them; a block short-circuits approval entirely (no
|
|
382
|
+
// prompt for a call that never runs). Rewrites always re-validate before
|
|
383
|
+
// execution, so a hook can never smuggle unvalidated args into an executor.
|
|
384
|
+
// Unknown names never reach hooks (model mistake — nothing would run).
|
|
385
|
+
// Registry-intercepted tools (ask_question/update_goal) pass the gate like
|
|
386
|
+
// any known tool: the stages below validate them and resolve them without
|
|
387
|
+
// an executor (never needs approval). ask_question never needs approval;
|
|
388
|
+
// without an askUser hook it resolves to "Error: ask_question has no UI
|
|
389
|
+
// hook". update_goal never needs approval either; without the per-turn
|
|
390
|
+
// recorder it resolves to the outside-turn error. Model mistakes (unknown
|
|
391
|
+
// name, invalid args) return repair-oriented results WITHOUT executing;
|
|
392
|
+
// cancellations propagate as LoopCancelledError (never a result, never
|
|
393
|
+
// retried).
|
|
394
|
+
export async function runSerialToolPipeline(call, parsed, opts, execute, onUpdateGoal) {
|
|
395
|
+
const name = call?.function?.name ?? "(unknown)";
|
|
396
|
+
const { plan, preDecision } = await planToolCall(name, parsed, opts);
|
|
397
|
+
return runPlannedToolCall(call, plan, preDecision, opts, execute, onUpdateGoal);
|
|
398
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Guarded emission: runs emit against the sink, swallowing observer errors so
|
|
2
|
+
// a throwing sink can never break the turn. No-op when the sink is absent.
|
|
3
|
+
export function emitTurnEvent(sink, emit) {
|
|
4
|
+
if (!sink)
|
|
5
|
+
return;
|
|
6
|
+
try {
|
|
7
|
+
emit(sink);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
// observer errors never break the loop
|
|
11
|
+
}
|
|
12
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -22,7 +22,39 @@ if (args.includes("--dashboard")) {
|
|
|
22
22
|
console.error("Dashboard failed to write — telemetry store unavailable.");
|
|
23
23
|
process.exit(1);
|
|
24
24
|
}
|
|
25
|
-
if (args.includes("--
|
|
25
|
+
if (args.includes("--web")) {
|
|
26
|
+
// ATOM WebUI: local agentic frontend over the same runtime as the TUI
|
|
27
|
+
// (loopback-only, Ctrl+C stops). The TUI below never starts alongside it.
|
|
28
|
+
const flagValue = (name) => {
|
|
29
|
+
const eq = args.find((a) => a.startsWith(`${name}=`));
|
|
30
|
+
if (eq !== undefined)
|
|
31
|
+
return eq.slice(name.length + 1);
|
|
32
|
+
const i = args.indexOf(name);
|
|
33
|
+
if (i !== -1 && i + 1 < args.length) {
|
|
34
|
+
const next = args[i + 1];
|
|
35
|
+
if (!next.startsWith("-"))
|
|
36
|
+
return next;
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
};
|
|
40
|
+
(async () => {
|
|
41
|
+
const { resolveWebPort, startWebServer } = await import("./web/server.js");
|
|
42
|
+
const server = await startWebServer({ port: resolveWebPort(process.env, flagValue("--port")) });
|
|
43
|
+
console.log(`ATOM WebUI at ${server.url} (loopback-only — Ctrl+C to stop).`);
|
|
44
|
+
console.log(`JSON API: ${server.url}api/health · ${server.url}api/providers · ${server.url}api/sessions`);
|
|
45
|
+
const stop = () => {
|
|
46
|
+
server.close().then(() => process.exit(0), () => process.exit(0));
|
|
47
|
+
};
|
|
48
|
+
process.on("SIGINT", stop);
|
|
49
|
+
process.on("SIGTERM", stop);
|
|
50
|
+
await new Promise(() => { });
|
|
51
|
+
})().catch((e) => {
|
|
52
|
+
const detail = e instanceof Error ? e.message : String(e);
|
|
53
|
+
console.error(`ATOM WebUI failed to start (${detail}). Is the port already in use? Try --port <n>.`);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
else if (args.includes("--serve")) {
|
|
26
58
|
// Local observability webUI: serve the live dashboard + read-only JSON API
|
|
27
59
|
// on loopback (Ctrl+C stops). The static --dashboard file is untouched.
|
|
28
60
|
const flagValue = (name) => {
|
|
@@ -58,7 +90,8 @@ else if (args.includes("--help") || args.includes("-h")) {
|
|
|
58
90
|
console.log(`Atom chatbot (Ink TUI)
|
|
59
91
|
Usage: npm start
|
|
60
92
|
Flags: --dashboard (write ~/.atom/telemetry/dashboard.html and exit)
|
|
61
|
-
|
|
93
|
+
--serve [--port <n>] (serve the live dashboard webUI on loopback and keep running)
|
|
94
|
+
--web [--port <n>] (serve the agentic WebUI on loopback and keep running)
|
|
62
95
|
--no-extensions (--lockdown alias: boot with zero third-party extensions; builtins unchanged)
|
|
63
96
|
--enable-extension <glob> (repeatable; only matching extensions load)
|
|
64
97
|
--disable-extension <glob> (repeatable; wins over --enable-extension)
|
|
@@ -68,7 +101,7 @@ Env:
|
|
|
68
101
|
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)
|
|
69
102
|
OPENCODE_ZEN_MODEL optional (default: ${DEFAULT_MODEL}; when set, wins over the saved /model)
|
|
70
103
|
OPENCODE_ZEN_ENDPOINT optional (default: ${DEFAULT_ENDPOINT})
|
|
71
|
-
Commands: /model (model picker
|
|
104
|
+
Commands: /model [filter|refresh] (model picker; refresh re-probes local servers, Kilo catalog refresh when Kilo is active) | /provider (provider + key picker) | /effort (reasoning-effort picker) | /goal <objective> (pin one session objective; bare shows it, pause/resume/clear manage it) | /compact [focus] (summarize older turns) | /tools | /skill (skill picker) | /skill:name (invoke) | /context (context usage) | /queue + /steer (follow-ups while busy) | /autoscroll (toggle follow new output) | /thinking (toggle reasoning visibility) | /mode | /trust | /allow | /deny | /rules | /clear | /new (fresh conversation, previous kept) | /rename <name> (rename current session) | /session (switch session picker) | /resume (restore last saved session) | /telemetry | /dashboard | /rewind | /help | /exit | /quit — Tab cycles the permission mode normal → yolo → plan → normal (extension slash commands appear in the / menu and palette, not in this static list)
|
|
72
105
|
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.
|
|
73
106
|
Effort (Auto/Low/Medium/High/Max) applies on every provider: reasoning_effort for OpenAI-chat kinds, thinking budgets for Anthropic, thinking levels for Gemini. Auto omits the knob.`);
|
|
74
107
|
process.exit(0);
|
|
@@ -93,16 +126,32 @@ const envModel = process.env.OPENCODE_ZEN_MODEL?.trim() || undefined;
|
|
|
93
126
|
// /provider pointer; nothing is POSTed.
|
|
94
127
|
// --serve parks above (the server holds the event loop), so the TUI must
|
|
95
128
|
// never start alongside it: serve is a standalone mode like --dashboard.
|
|
96
|
-
|
|
97
|
-
|
|
129
|
+
// Same for --web (the agentic WebUI server owns the process instead).
|
|
130
|
+
if (!args.includes("--serve") && !args.includes("--web")) {
|
|
131
|
+
// Production frame policy (Ink 7.1.1), measured with
|
|
132
|
+
// scripts/bench-render.mjs (real render root, fake TTY, 100x30):
|
|
98
133
|
// - incrementalRendering: only changed terminal lines rewrite per frame.
|
|
99
|
-
// Streaming paints touch the live tail + status bar, not the scrollback
|
|
100
|
-
//
|
|
134
|
+
// Streaming paints touch the live tail + status bar, not the scrollback.
|
|
135
|
+
// Measured ~3x fewer stdout bytes than full-frame on sustained
|
|
136
|
+
// streaming (paced 300-token run: ~66KB vs ~195KB) with identical
|
|
137
|
+
// frames — strictly less flicker, zero behavior change. Compatible
|
|
138
|
+
// with the whole UI (no full-screen chrome depends on reprints).
|
|
101
139
|
// Escape hatch: ATOM_INCREMENTAL=0 restores full-frame rendering.
|
|
102
140
|
// - maxFps: 30 keeps keystroke-to-paint latency low; token paints already
|
|
103
|
-
// coalesce to ~15fps via
|
|
141
|
+
// coalesce to ~15fps via the paint scheduler, so Ink never does extra
|
|
142
|
+
// work. Measured 15fps: fewer total bytes but the same clears-per-frame
|
|
143
|
+
// (clears are driven by frame HEIGHT on win32, not rate) — halving the
|
|
144
|
+
// cap would trade responsiveness for no structural gain. FPS limiting
|
|
145
|
+
// is deliberately NOT used as a flicker fix.
|
|
104
146
|
// - concurrent: enables React concurrent features (useTransition /
|
|
105
147
|
// useDeferredValue) for future deferral of expensive subtrees.
|
|
148
|
+
// Measured neutral vs sync mode (frames/bytes within noise), kept for
|
|
149
|
+
// the deferral option, not for current gains.
|
|
150
|
+
// Not configurable here (and intentionally so): full-screen clears come
|
|
151
|
+
// from frame geometry (win32 clears whenever the frame fills the
|
|
152
|
+
// viewport), so they are fixed by keeping the live frame short
|
|
153
|
+
// (windowed thinking, capped approval preview, coalesced paints) —
|
|
154
|
+
// no render flag can substitute for that.
|
|
106
155
|
// Tests are unaffected: they render via ink-testing-library, not here.
|
|
107
156
|
render(_jsx(App, { apiKey: apiKey, endpoint: endpoint, initialModel: envModel, restorePrefs: true, extensionsLockdown: extFlags.lockdown, enableExtensions: extFlags.enable, disableExtensions: extFlags.disable }), {
|
|
108
157
|
incrementalRendering: process.env.ATOM_INCREMENTAL !== "0",
|
package/dist/compact.js
CHANGED
|
@@ -20,12 +20,23 @@ 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 { usableLimitFor } from "./overflow.js";
|
|
23
24
|
import { truncateHead } from "./tools/shared.js";
|
|
24
25
|
export { COMPACT_PCT_DEFAULT, compactPct, computeContextLoad, estimateTokensForChars, historyChars, shouldAutoCompact, } from "./context-manager.js";
|
|
25
26
|
// ---- Constants ----
|
|
26
27
|
export const COMPACT_KEEP_TOKENS = 20000;
|
|
27
28
|
export const COMPACT_SUMMARY_MAX_TOKENS = 4096;
|
|
28
29
|
export const COMPACT_TOOL_OUTPUT_CAP = 2000;
|
|
30
|
+
// Budgeted tail band (issue 04, opencode reference): the retained tail is
|
|
31
|
+
// 25% of the model's usable limit (verified window minus reserve), clamped
|
|
32
|
+
// to [MIN, MAX]. Models with no verified window never fabricate one — they
|
|
33
|
+
// fall back to COMPACT_KEEP_TOKENS (the pre-existing fixed tail).
|
|
34
|
+
export const COMPACT_TAIL_MIN_TOKENS = 2000;
|
|
35
|
+
export const COMPACT_TAIL_MAX_TOKENS = 15000;
|
|
36
|
+
// Cleared marker for pruned old tool outputs. Same `[truncated: ...]`
|
|
37
|
+
// family as capToolOutputsInTail — never a second convention. Short by
|
|
38
|
+
// design: the summary carries the story, not this placeholder.
|
|
39
|
+
export const COMPACT_PRUNED_TOOL_OUTPUT = "[truncated: old tool output cleared]";
|
|
29
40
|
// opencode's 4ch/token heuristic (V2 preflight estimate): chars/4 floors to
|
|
30
41
|
// estimated tokens. Used for load fallback + tail split only, never for the
|
|
31
42
|
// `token: n/a` honesty rule or the NK cumulative spend.
|
|
@@ -74,18 +85,53 @@ export function capToolOutputsInTail(tail) {
|
|
|
74
85
|
return { ...m };
|
|
75
86
|
});
|
|
76
87
|
}
|
|
88
|
+
// Tail budget for a model (issue 04): 25% of the usable limit from
|
|
89
|
+
// overflow.ts (read-only — trigger semantics untouched), clamped to the
|
|
90
|
+
// [MIN, MAX] band. Unknown/blank models fall back to COMPACT_KEEP_TOKENS.
|
|
91
|
+
export function tailKeepTokensForModel(model) {
|
|
92
|
+
if (typeof model !== "string" || model.length === 0)
|
|
93
|
+
return COMPACT_KEEP_TOKENS;
|
|
94
|
+
const usable = usableLimitFor(model);
|
|
95
|
+
if (usable === undefined)
|
|
96
|
+
return COMPACT_KEEP_TOKENS;
|
|
97
|
+
const quarter = Math.floor(usable * 0.25);
|
|
98
|
+
return Math.min(COMPACT_TAIL_MAX_TOKENS, Math.max(COMPACT_TAIL_MIN_TOKENS, quarter));
|
|
99
|
+
}
|
|
100
|
+
// Prune pass for old tool outputs (issue 04): bulky tool results OUTSIDE the
|
|
101
|
+
// protected newest window (i.e. in the head being summarized) collapse to the
|
|
102
|
+
// short cleared marker so the summary POST stays small even when the session
|
|
103
|
+
// holds huge dumps. Small outputs pass through verbatim so the summary keeps
|
|
104
|
+
// fidelity; the retained tail is NEVER passed here — its outputs stay
|
|
105
|
+
// intact, and the newest turn is never pruned. Idempotent (the marker itself
|
|
106
|
+
// is far below the cap).
|
|
107
|
+
export function pruneOldToolOutputs(head) {
|
|
108
|
+
return head.map((m) => {
|
|
109
|
+
if (m?.role === "tool" &&
|
|
110
|
+
typeof m.content === "string" &&
|
|
111
|
+
m.content.length > COMPACT_TOOL_OUTPUT_CAP) {
|
|
112
|
+
return { ...m, content: COMPACT_PRUNED_TOOL_OUTPUT };
|
|
113
|
+
}
|
|
114
|
+
return { ...m };
|
|
115
|
+
});
|
|
116
|
+
}
|
|
77
117
|
// Split history (after system) into head + retained newest tail of whole
|
|
78
|
-
// user-turns up to
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
|
|
118
|
+
// user-turns up to a token budget estimated at chars/4. The budget is
|
|
119
|
+
// keepTokens by default (COMPACT_KEEP_TOKENS fallback); pass a model id to
|
|
120
|
+
// scale it with that model's usable limit via tailKeepTokensForModel (the
|
|
121
|
+
// model wins when given — unknown models fall back to the same fixed tail).
|
|
122
|
+
// Tool outputs in the tail are capped at 2000 chars each. Never drops
|
|
123
|
+
// history[0]; always keeps at least the newest turn; when everything fits
|
|
124
|
+
// but there is more than one turn, keeps only the newest turn in the tail
|
|
125
|
+
// so manual /compact still has an older turn to summarize.
|
|
126
|
+
export function splitHistoryForCompaction(history, keepTokens = COMPACT_KEEP_TOKENS, model) {
|
|
84
127
|
if (history.length <= 1)
|
|
85
128
|
return { head: [], tail: [], olderTurnCount: 0 };
|
|
86
129
|
const starts = turnStarts(history);
|
|
87
130
|
if (starts.length === 0)
|
|
88
131
|
return { head: [], tail: [], olderTurnCount: 0 };
|
|
132
|
+
const budget = typeof model === "string" && model.length > 0
|
|
133
|
+
? tailKeepTokensForModel(model)
|
|
134
|
+
: keepTokens;
|
|
89
135
|
let totalChars = 0;
|
|
90
136
|
let tailStart = starts[starts.length - 1];
|
|
91
137
|
for (let s = starts.length - 1; s >= 0; s--) {
|
|
@@ -93,7 +139,7 @@ export function splitHistoryForCompaction(history, keepTokens = COMPACT_KEEP_TOK
|
|
|
93
139
|
const end = turnEnd(history, start, starts);
|
|
94
140
|
totalChars += turnChars(history, start, end);
|
|
95
141
|
const est = estimateTokensForChars(totalChars);
|
|
96
|
-
if (est <=
|
|
142
|
+
if (est <= budget) {
|
|
97
143
|
tailStart = start;
|
|
98
144
|
}
|
|
99
145
|
else {
|
|
@@ -122,6 +168,11 @@ export function splitHistoryForCompaction(history, keepTokens = COMPACT_KEEP_TOK
|
|
|
122
168
|
// and next steps inside its prose (the canonical `Goal:` block is appended
|
|
123
169
|
// separately after the POST). Absent/blank reads exactly as before, so
|
|
124
170
|
// non-goal compaction output stays byte-identical.
|
|
171
|
+
// Chaining (ticket 03): on a second or later compaction the head opens with
|
|
172
|
+
// a prior "[Compacted context ...]" summary message. The summarizer merges
|
|
173
|
+
// it forward — Objective, key decisions, and Relevant Files survive every
|
|
174
|
+
// link in the chain — and when the prior summary conflicts with newer
|
|
175
|
+
// conversation, the newer conversation wins.
|
|
125
176
|
export function buildCompactionInstruction(focusText, goalObjective) {
|
|
126
177
|
const focus = typeof focusText === "string" && focusText.trim().length > 0
|
|
127
178
|
? `\nFocus for this summary: ${focusText.trim()}\n`
|
|
@@ -142,6 +193,12 @@ export function buildCompactionInstruction(focusText, goalObjective) {
|
|
|
142
193
|
`### Blocked\n` +
|
|
143
194
|
`## Next Move\n` +
|
|
144
195
|
`## Relevant Files\n` +
|
|
196
|
+
`Chaining: the history may open with a prior compaction summary ("[Compacted context ...]"). ` +
|
|
197
|
+
`Merge it forward — carry its Objective, key decisions, and Relevant Files into this summary ` +
|
|
198
|
+
`so a chain of compactions never loses the original goal. ` +
|
|
199
|
+
`When the prior summary conflicts with newer conversation, the newer conversation wins; ` +
|
|
200
|
+
`discard the stale fact and keep only what the newer turns confirm or what is carried forward explicitly. ` +
|
|
201
|
+
`Preserve file paths and identifiers verbatim.\n` +
|
|
145
202
|
`Rules: no tools are available for this request — answer with the summary text only, no tool calls, no preamble beyond the headings.`);
|
|
146
203
|
}
|
|
147
204
|
export function buildSummaryMessages(systemContent, head, focusText, goalObjective) {
|
|
@@ -203,12 +260,19 @@ export function truncateHeadForRetry(head) {
|
|
|
203
260
|
// throw immediately with history untouched (caller must not swap).
|
|
204
261
|
export async function requestCompactSummary(req) {
|
|
205
262
|
const attempt = async (head) => {
|
|
206
|
-
|
|
263
|
+
// Issue 04: bulky tool outputs outside the protected tail collapse to
|
|
264
|
+
// the cleared marker before the POST, so a session full of huge dumps
|
|
265
|
+
// still summarizes in one cheap request. The tail never flows through
|
|
266
|
+
// here — only the head — so newest-turn outputs stay intact.
|
|
267
|
+
const messages = buildSummaryMessages(req.systemContent, pruneOldToolOutputs(head), req.focusText, req.goalObjective);
|
|
207
268
|
const res = await chatCompletionForProvider(req.provider, req.apiKey, req.model, messages, {
|
|
208
269
|
baseURL: req.baseURL,
|
|
209
270
|
endpointOverride: req.endpointOverride,
|
|
210
271
|
disableTools: true,
|
|
211
272
|
maxOutputTokens: COMPACT_SUMMARY_MAX_TOKENS,
|
|
273
|
+
// The summarizer needs prose, not pixels: media descriptors stay
|
|
274
|
+
// as markers so the summary POST stays text-only and cheap.
|
|
275
|
+
stripMedia: true,
|
|
212
276
|
...(req.signal ? { signal: req.signal } : {}),
|
|
213
277
|
});
|
|
214
278
|
// Totals keep accumulating: forward real summary usage when present.
|