@deepseek-ai/dsh-headless 0.1.5-rc.2 → 0.1.6-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +36 -16
- package/README.zh.md +44 -24
- package/cordis.patch.yml +7 -7
- package/lib/index.js +228 -47
- package/lib/json-stream-Bh4BGGk9.js +280 -0
- package/lib/startup.js +63 -12
- package/lib/types/index.d.ts +13 -24
- package/lib/types/json-stream.d.ts +62 -0
- package/lib/types/runner-internals.d.ts +17 -0
- package/lib/types/startup-internals.d.ts +13 -0
- package/lib/types/startup.d.ts +12 -7
- package/package.json +20 -16
package/lib/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { n as projectJsonRun, t as boundJsonLine } from "./json-stream-Bh4BGGk9.js";
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
2
3
|
import z from "@deepseek-ai/schemastery";
|
|
3
4
|
import { brandString } from "@deepseek-ai/dsh-brand";
|
|
@@ -5,16 +6,95 @@ import { installModelSelection } from "@deepseek-ai/dsh-agent";
|
|
|
5
6
|
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
6
7
|
import { assertNever } from "@deepseek-ai/dsh-util-values";
|
|
7
8
|
import { SessionSeq } from "@deepseek-ai/dsh-session";
|
|
9
|
+
import { SessionQueryError } from "@deepseek-ai/dsh-session-query";
|
|
10
|
+
//#region lib/types/runner-internals.js
|
|
11
|
+
/**
|
|
12
|
+
* Process streams the runner reads and writes, kept out of the package entry so
|
|
13
|
+
* substituting them in tests adds no public package API. The shape matches the
|
|
14
|
+
* runner's own IO carrier structurally.
|
|
15
|
+
* @module @deepseek-ai/dsh-headless/runner-internals
|
|
16
|
+
*/
|
|
17
|
+
/** The process streams the runner reads and writes; tests substitute captures. */
|
|
18
|
+
const internals = {
|
|
19
|
+
stdout: process.stdout,
|
|
20
|
+
stderr: process.stderr,
|
|
21
|
+
readStdin: async () => {
|
|
22
|
+
const chunks = [];
|
|
23
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
24
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
//#endregion
|
|
8
28
|
//#region lib/types/index.js
|
|
9
29
|
/**
|
|
10
30
|
* @deepseek-ai/dsh-headless — one-shot direct Agent driver. The bundle patch
|
|
11
31
|
* rides over dsh-base without Host, HTTP, or browser plugins; this runner
|
|
12
|
-
* creates one Agent through the core registry
|
|
13
|
-
*
|
|
14
|
-
*
|
|
32
|
+
* creates one Agent through the core registry (or adopts the exact Session a
|
|
33
|
+
* `--session-id` names), drives the task to quiescence, streams provider
|
|
34
|
+
* reasoning to stderr, flushes its Session, prints the final assistant text to
|
|
35
|
+
* stdout, and exits. With `--json` it projects the run as newline-delimited
|
|
36
|
+
* events instead of the final text.
|
|
15
37
|
*
|
|
16
38
|
* @module @deepseek-ai/dsh-headless
|
|
17
39
|
*/
|
|
40
|
+
var __addDisposableResource = function(env, value, async) {
|
|
41
|
+
if (value !== null && value !== void 0) {
|
|
42
|
+
if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
|
|
43
|
+
var dispose, inner;
|
|
44
|
+
if (async) {
|
|
45
|
+
if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
|
|
46
|
+
dispose = value[Symbol.asyncDispose];
|
|
47
|
+
}
|
|
48
|
+
if (dispose === void 0) {
|
|
49
|
+
if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
|
|
50
|
+
dispose = value[Symbol.dispose];
|
|
51
|
+
if (async) inner = dispose;
|
|
52
|
+
}
|
|
53
|
+
if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
|
|
54
|
+
if (inner) dispose = function() {
|
|
55
|
+
try {
|
|
56
|
+
inner.call(this);
|
|
57
|
+
} catch (e) {
|
|
58
|
+
return Promise.reject(e);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
env.stack.push({
|
|
62
|
+
value,
|
|
63
|
+
dispose,
|
|
64
|
+
async
|
|
65
|
+
});
|
|
66
|
+
} else if (async) env.stack.push({ async: true });
|
|
67
|
+
return value;
|
|
68
|
+
};
|
|
69
|
+
var __disposeResources = (function(SuppressedError) {
|
|
70
|
+
return function(env) {
|
|
71
|
+
function fail(e) {
|
|
72
|
+
env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
|
|
73
|
+
env.hasError = true;
|
|
74
|
+
}
|
|
75
|
+
var r, s = 0;
|
|
76
|
+
function next() {
|
|
77
|
+
while (r = env.stack.pop()) try {
|
|
78
|
+
if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
|
|
79
|
+
if (r.dispose) {
|
|
80
|
+
var result = r.dispose.call(r.value);
|
|
81
|
+
if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) {
|
|
82
|
+
fail(e);
|
|
83
|
+
return next();
|
|
84
|
+
});
|
|
85
|
+
} else s |= 1;
|
|
86
|
+
} catch (e) {
|
|
87
|
+
fail(e);
|
|
88
|
+
}
|
|
89
|
+
if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
|
|
90
|
+
if (env.hasError) throw env.error;
|
|
91
|
+
}
|
|
92
|
+
return next();
|
|
93
|
+
};
|
|
94
|
+
})(typeof SuppressedError === "function" ? SuppressedError : function(error, suppressed, message) {
|
|
95
|
+
var e = new Error(message);
|
|
96
|
+
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
97
|
+
});
|
|
18
98
|
/** Stable Cordis plugin name. */
|
|
19
99
|
const name = "headless-runner";
|
|
20
100
|
/** Core services required before the one-shot turn can start. */
|
|
@@ -23,12 +103,11 @@ const inject = [
|
|
|
23
103
|
"agents",
|
|
24
104
|
"sessions"
|
|
25
105
|
];
|
|
26
|
-
const Config = z.object({
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
};
|
|
106
|
+
const Config = z.object({
|
|
107
|
+
task: z.string(),
|
|
108
|
+
sessionId: z.string(),
|
|
109
|
+
json: z.boolean()
|
|
110
|
+
});
|
|
32
111
|
/** Aggregate the last assistant text and turn outcome in one owned interval. */
|
|
33
112
|
function summarize(session, firstSeq) {
|
|
34
113
|
let started = false;
|
|
@@ -113,63 +192,165 @@ function streamReasoning(ctx, agent, stderr) {
|
|
|
113
192
|
close();
|
|
114
193
|
};
|
|
115
194
|
}
|
|
195
|
+
/** Iterate a live Session's durable events in order. */
|
|
196
|
+
function* liveEvents(session) {
|
|
197
|
+
const length = session.seq;
|
|
198
|
+
for (let seq = 0; seq < length; seq++) {
|
|
199
|
+
const event = session.eventAt(SessionSeq(seq));
|
|
200
|
+
if (event === void 0) throw new Error(`headless adoption cannot read seq ${String(seq)} below captured length ${String(length)}`);
|
|
201
|
+
yield event;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* The preset a Session currently runs under: its creation header advanced by
|
|
206
|
+
* the last `agent-preset/selected` event. The header is only a creation fact;
|
|
207
|
+
* the presets plugin reconstructs a session's composition from the projection.
|
|
208
|
+
*/
|
|
209
|
+
function currentPreset(header, events, sessionId) {
|
|
210
|
+
let preset = header.agentPreset;
|
|
211
|
+
for (const event of events) {
|
|
212
|
+
const candidate = event;
|
|
213
|
+
if (candidate.type !== "agent-preset/selected") continue;
|
|
214
|
+
const selected = candidate.data?.agentPreset;
|
|
215
|
+
if (typeof selected !== "string" || selected === "") throw new Error(`session "${sessionId}" records a malformed agent-preset/selected event and cannot be adopted`);
|
|
216
|
+
preset = selected;
|
|
217
|
+
}
|
|
218
|
+
return preset;
|
|
219
|
+
}
|
|
220
|
+
/** Reject a Session the one-shot runner must not adopt. */
|
|
221
|
+
function assertAdoptable(header, events, sessionId, cwd) {
|
|
222
|
+
const preset = currentPreset(header, events, sessionId);
|
|
223
|
+
if (preset !== void 0) throw new Error(`session "${sessionId}" runs under agent preset "${preset}", which the one-shot runner does not compose`);
|
|
224
|
+
if (header.origin === "subagent" || header.parentSession !== void 0) throw new Error(`session "${sessionId}" is a subagent or forked session and cannot be driven directly`);
|
|
225
|
+
if (header.cwd === void 0) throw new Error(`session "${sessionId}" recorded no working directory, so it cannot be adopted`);
|
|
226
|
+
if (header.cwd !== cwd) throw new Error(`session "${sessionId}" was recorded in "${header.cwd}", not "${cwd}"`);
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Resolve the Agent for one run: adopt the persisted Session with the requested
|
|
230
|
+
* id. The identity must already exist, and no Agent may be live under it; a
|
|
231
|
+
* first round omits the option instead, so a typo cannot pass as a brand-new
|
|
232
|
+
* conversation.
|
|
233
|
+
* @param ctx - plugin context carrying the Session query service.
|
|
234
|
+
* @param agents - the core Agent registry.
|
|
235
|
+
* @param sessionId - exact Session identity to adopt.
|
|
236
|
+
* @param agentOptions - provider/model pair for this run.
|
|
237
|
+
* @param setup - per-Agent scope setup installing the model selection.
|
|
238
|
+
* @param cwd - working directory resolved in the mounted filesystem.
|
|
239
|
+
* @returns the resumed Agent.
|
|
240
|
+
*/
|
|
241
|
+
async function resolveAgent(ctx, agents, sessionId, agentOptions, setup, cwd) {
|
|
242
|
+
if (ctx.get("sessionPersistence") === void 0) throw new Error("headless --session-id requires the sessionPersistence service; the Session would not survive this process");
|
|
243
|
+
const query = ctx.get("sessionQuery");
|
|
244
|
+
if (query === void 0) throw new Error("headless --session-id requires the sessionQuery service; dsh-base provides it");
|
|
245
|
+
const live = agents.get(sessionId);
|
|
246
|
+
if (live !== void 0) {
|
|
247
|
+
assertAdoptable(live.session.header, liveEvents(live.session), sessionId, cwd);
|
|
248
|
+
throw new Error(`session "${sessionId}" is live in this process, so the one-shot runner cannot own an exclusive run interval`);
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
const env_1 = {
|
|
252
|
+
stack: [],
|
|
253
|
+
error: void 0,
|
|
254
|
+
hasError: false
|
|
255
|
+
};
|
|
256
|
+
try {
|
|
257
|
+
const observation = __addDisposableResource(env_1, await query.observeSession(sessionId), false);
|
|
258
|
+
assertAdoptable(observation.header, observation.events, sessionId, cwd);
|
|
259
|
+
const { agent } = await agents.resume({
|
|
260
|
+
resumeSessionId: sessionId,
|
|
261
|
+
agentOptions,
|
|
262
|
+
setup
|
|
263
|
+
});
|
|
264
|
+
assertAdoptable(agent.session.header, liveEvents(agent.session), sessionId, cwd);
|
|
265
|
+
return agent;
|
|
266
|
+
} catch (e_1) {
|
|
267
|
+
env_1.error = e_1;
|
|
268
|
+
env_1.hasError = true;
|
|
269
|
+
} finally {
|
|
270
|
+
__disposeResources(env_1);
|
|
271
|
+
}
|
|
272
|
+
} catch (error) {
|
|
273
|
+
if (!(error instanceof SessionQueryError) || error.code !== "SESSION_QUERY_SESSION_NOT_FOUND") throw error;
|
|
274
|
+
throw new Error(`session "${sessionId}" does not exist; omit --session-id to start a new Session`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
116
277
|
/** Report an unexpected direct-driver failure and request a failing exit. */
|
|
117
|
-
function fail(io, error) {
|
|
118
|
-
|
|
278
|
+
function fail(io, error, json) {
|
|
279
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
280
|
+
if (json) io.stdout.write(`${boundJsonLine({
|
|
281
|
+
type: "error",
|
|
282
|
+
message
|
|
283
|
+
})}\n`);
|
|
284
|
+
io.stderr.write(`dsh: ${message}\n`);
|
|
119
285
|
io.exit(1);
|
|
120
286
|
}
|
|
121
287
|
/**
|
|
122
|
-
* Run one task through
|
|
288
|
+
* Run one task through one Agent and request process exit.
|
|
123
289
|
* @param ctx - plugin context carrying the Agent, default model, Session, and launcher IO services.
|
|
124
|
-
* @param
|
|
290
|
+
* @param config - task, optional exact Session identity, and output mode.
|
|
125
291
|
* @param io - process-facing effects.
|
|
126
292
|
*/
|
|
127
|
-
async function run(ctx,
|
|
293
|
+
async function run(ctx, config, io) {
|
|
128
294
|
await ctx.get("loader")?.await();
|
|
129
295
|
const agents = ctx.get("agents");
|
|
130
296
|
const defaultModel = ctx.get("agentDefaultModel");
|
|
131
297
|
const sessions = ctx.get("sessions");
|
|
132
298
|
if (agents === void 0 || defaultModel === void 0 || sessions === void 0) return;
|
|
299
|
+
if (config.sessionId !== void 0 && config.sessionId.trim() === "") throw new Error("headless-runner: sessionId must not be blank");
|
|
300
|
+
const task = config.task === void 0 || config.task === "-" ? await internals.readStdin() : config.task;
|
|
301
|
+
if (task.trim() === "") throw new Error("a task is required, for example: dsh --profile headless \"run the tests\"");
|
|
133
302
|
const selection = defaultModel.currentSelection();
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
303
|
+
const agentOptions = {
|
|
304
|
+
provider: selection.provider,
|
|
305
|
+
model: selection.model
|
|
306
|
+
};
|
|
307
|
+
const setup = (agentCtx) => {
|
|
308
|
+
installModelSelection(agentCtx, {
|
|
309
|
+
current: selection,
|
|
310
|
+
assembled: void 0
|
|
311
|
+
});
|
|
312
|
+
};
|
|
313
|
+
const sessionId = brandString(config.sessionId ?? `session-${randomUUID()}`);
|
|
314
|
+
const fs = ctx.get("fs");
|
|
315
|
+
const cwd = fs === void 0 ? process.cwd() : fs.processPath(await fs.resolve("."));
|
|
316
|
+
const agent = config.sessionId === void 0 ? (await agents.create({
|
|
317
|
+
sessionId,
|
|
318
|
+
meta: { cwd },
|
|
319
|
+
agentOptions,
|
|
320
|
+
setup
|
|
321
|
+
})).agent : await resolveAgent(ctx, agents, sessionId, agentOptions, setup, cwd);
|
|
148
322
|
await agent.whenIdle();
|
|
323
|
+
if (config.sessionId !== void 0) assertAdoptable(agent.session.header, liveEvents(agent.session), sessionId, cwd);
|
|
149
324
|
const firstSeq = agent.session.seq;
|
|
150
|
-
const
|
|
325
|
+
const projection = config.json === true ? projectJsonRun(ctx, agent, io.stdout, { cwd }) : void 0;
|
|
326
|
+
const stopReasoning = projection === void 0 ? streamReasoning(ctx, agent, io.stderr) : void 0;
|
|
151
327
|
try {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
328
|
+
try {
|
|
329
|
+
agent.followup(createUserMessage({
|
|
330
|
+
content: [{
|
|
331
|
+
type: "text",
|
|
332
|
+
text: task
|
|
333
|
+
}],
|
|
334
|
+
source: { kind: "user" }
|
|
335
|
+
}));
|
|
336
|
+
await agent.whenIdle();
|
|
337
|
+
} finally {
|
|
338
|
+
stopReasoning?.();
|
|
339
|
+
}
|
|
340
|
+
await sessions.flush(agent.session);
|
|
341
|
+
const outcome = summarize(agent.session, firstSeq);
|
|
342
|
+
if (projection === void 0) io.stdout.write(outcome.text + "\n");
|
|
343
|
+
else projection.finish(outcome.text);
|
|
344
|
+
if (outcome.reason?.kind === "error") io.stderr.write(`dsh: ${outcome.reason.error.code}: ${outcome.reason.error.message}\n`);
|
|
345
|
+
io.exit(outcome.reason?.kind === "completed" ? 0 : 1);
|
|
160
346
|
} finally {
|
|
161
|
-
|
|
347
|
+
projection?.dispose();
|
|
162
348
|
}
|
|
163
|
-
await sessions.flush(agent.session);
|
|
164
|
-
const outcome = summarize(agent.session, firstSeq);
|
|
165
|
-
io.stdout.write(outcome.text + "\n");
|
|
166
|
-
if (outcome.reason?.kind === "error") io.stderr.write(`dsh: ${outcome.reason.error.code}: ${outcome.reason.error.message}\n`);
|
|
167
|
-
io.exit(outcome.reason?.kind === "completed" ? 0 : 1);
|
|
168
349
|
}
|
|
169
350
|
/**
|
|
170
351
|
* Mount the one-shot direct driver.
|
|
171
352
|
* @param ctx - plugin context carrying core services and the launcher-provided exit request.
|
|
172
|
-
* @param config - validated task
|
|
353
|
+
* @param config - validated task and run options.
|
|
173
354
|
*/
|
|
174
355
|
function apply(ctx, config) {
|
|
175
356
|
const exit = ctx.get("appExit");
|
|
@@ -179,9 +360,9 @@ function apply(ctx, config) {
|
|
|
179
360
|
stderr: internals.stderr,
|
|
180
361
|
exit
|
|
181
362
|
};
|
|
182
|
-
run(ctx, config
|
|
183
|
-
fail(io, error);
|
|
363
|
+
run(ctx, config, io).catch((error) => {
|
|
364
|
+
fail(io, error, config.json === true);
|
|
184
365
|
});
|
|
185
366
|
}
|
|
186
367
|
//#endregion
|
|
187
|
-
export { Config, apply, inject,
|
|
368
|
+
export { Config, apply, inject, name };
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { lastAssistantStreamChunk } from "@deepseek-ai/dsh-llm/assistant-stream";
|
|
2
|
+
//#region lib/types/json-stream.js
|
|
3
|
+
/**
|
|
4
|
+
* `--json` run projection: a bounded, ordered event stream derived from one
|
|
5
|
+
* Agent's durable Session events. Every projected event is a commit point:
|
|
6
|
+
* text and reasoning come from committed `assistant/message` content, never
|
|
7
|
+
* from a live attempt that may still be retried or discarded, so the stream
|
|
8
|
+
* never carries content the durable log does not contain.
|
|
9
|
+
* @module @deepseek-ai/dsh-headless/json-stream
|
|
10
|
+
*/
|
|
11
|
+
/** Default per-string and per-key cap applied to every bounded projected payload. */
|
|
12
|
+
const MAX_STRING_BYTES = 8 * 1024;
|
|
13
|
+
/** Default cap on one projected event's serialized bytes, newline included; the terminal `final` is exempt. */
|
|
14
|
+
const MAX_EVENT_BYTES = 32 * 1024;
|
|
15
|
+
/** Bytes every newline-delimited writer appends after one serialized event. */
|
|
16
|
+
const LINE_TERMINATOR_BYTES = 1;
|
|
17
|
+
/** Truncate one UTF-8 string to `maxBytes`, dropping a split trailing character. */
|
|
18
|
+
function truncateUtf8(text, maxBytes) {
|
|
19
|
+
const decoded = Buffer.from(text, "utf8").subarray(0, maxBytes).toString("utf8");
|
|
20
|
+
return decoded.endsWith("�") ? decoded.slice(0, -1) : decoded;
|
|
21
|
+
}
|
|
22
|
+
/** Cap one object key, flagging the payload when it was cut. */
|
|
23
|
+
function boundKey(key, maxBytes, state) {
|
|
24
|
+
if (Buffer.byteLength(key, "utf8") <= maxBytes) return key;
|
|
25
|
+
state.truncated = true;
|
|
26
|
+
return truncateUtf8(key, maxBytes);
|
|
27
|
+
}
|
|
28
|
+
/** Maximum container depth one projected payload keeps before the tail is cut. */
|
|
29
|
+
const MAX_DEPTH = 64;
|
|
30
|
+
/** Recursively cap every string in one JSON-serializable value, keys included. */
|
|
31
|
+
function boundValue(value, maxBytes, state, depth = 0) {
|
|
32
|
+
if (typeof value === "string") {
|
|
33
|
+
if (Buffer.byteLength(value, "utf8") <= maxBytes) return value;
|
|
34
|
+
state.truncated = true;
|
|
35
|
+
return truncateUtf8(value, maxBytes);
|
|
36
|
+
}
|
|
37
|
+
if (Array.isArray(value)) {
|
|
38
|
+
if (depth >= MAX_DEPTH) {
|
|
39
|
+
state.truncated = true;
|
|
40
|
+
return "[truncated: depth]";
|
|
41
|
+
}
|
|
42
|
+
return value.map((item) => boundValue(item, maxBytes, state, depth + 1));
|
|
43
|
+
}
|
|
44
|
+
if (value !== null && typeof value === "object") {
|
|
45
|
+
if (depth >= MAX_DEPTH) {
|
|
46
|
+
state.truncated = true;
|
|
47
|
+
return "[truncated: depth]";
|
|
48
|
+
}
|
|
49
|
+
const bounded = Object.create(null);
|
|
50
|
+
for (const [key, item] of Object.entries(value)) bounded[boundKey(key, maxBytes, state)] = boundValue(item, maxBytes, state, depth + 1);
|
|
51
|
+
return bounded;
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Bound every string and key in one projected payload, adding `truncated: true`
|
|
57
|
+
* when any was cut. {@link boundJsonLine} composes this with the whole-line cap.
|
|
58
|
+
* @param event - the event payload to bound.
|
|
59
|
+
* @param maxStringBytes - per-string and per-key byte cap.
|
|
60
|
+
* @returns a copy with every over-long string and key truncated.
|
|
61
|
+
*/
|
|
62
|
+
function boundJsonEvent(event, maxStringBytes = MAX_STRING_BYTES) {
|
|
63
|
+
const state = { truncated: false };
|
|
64
|
+
const bounded = boundValue(event, maxStringBytes, state);
|
|
65
|
+
if (state.truncated) bounded.truncated = true;
|
|
66
|
+
return bounded;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Serialize one projected payload under both limits: every string and key is
|
|
70
|
+
* capped at `maxStringBytes`, and the serialized line at `maxEventBytes`. The
|
|
71
|
+
* line cap reserves the newline the writer appends, so the complete record
|
|
72
|
+
* stays within it. When the line is still too long, scalar fields survive and
|
|
73
|
+
* structured fields are dropped; when even those are too long, only `type` and
|
|
74
|
+
* `truncated` remain.
|
|
75
|
+
* @param event - the event payload to serialize.
|
|
76
|
+
* @param maxStringBytes - per-string and per-key byte cap.
|
|
77
|
+
* @param maxEventBytes - cap on the serialized line plus its trailing newline.
|
|
78
|
+
* @returns the bounded JSON line, without a trailing newline.
|
|
79
|
+
*/
|
|
80
|
+
function boundJsonLine(event, maxStringBytes = MAX_STRING_BYTES, maxEventBytes = MAX_EVENT_BYTES) {
|
|
81
|
+
const limit = maxEventBytes - LINE_TERMINATOR_BYTES;
|
|
82
|
+
const bounded = boundJsonEvent(event, maxStringBytes);
|
|
83
|
+
const line = JSON.stringify(bounded);
|
|
84
|
+
if (Buffer.byteLength(line, "utf8") <= limit) return line;
|
|
85
|
+
const scalars = Object.create(null);
|
|
86
|
+
for (const [key, value] of Object.entries(bounded)) if (value === null || typeof value !== "object") scalars[key] = value;
|
|
87
|
+
scalars.truncated = true;
|
|
88
|
+
const short = JSON.stringify(scalars);
|
|
89
|
+
if (Buffer.byteLength(short, "utf8") <= limit) return short;
|
|
90
|
+
return JSON.stringify({
|
|
91
|
+
type: bounded.type,
|
|
92
|
+
truncated: true
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Parse raw tool-call arguments as the executor does: empty input is `{}`,
|
|
97
|
+
* invalid or non-round-trippable JSON (a non-finite number) stays text.
|
|
98
|
+
*/
|
|
99
|
+
function parseArguments(raw) {
|
|
100
|
+
if (raw === "") return {};
|
|
101
|
+
const seen = { nonFinite: false };
|
|
102
|
+
try {
|
|
103
|
+
const parsed = JSON.parse(raw, (_key, value) => {
|
|
104
|
+
if (typeof value === "number" && !Number.isFinite(value)) seen.nonFinite = true;
|
|
105
|
+
return value;
|
|
106
|
+
});
|
|
107
|
+
return seen.nonFinite ? raw : parsed;
|
|
108
|
+
} catch {
|
|
109
|
+
return raw;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** Join the text blocks of a tool result's model-facing content. */
|
|
113
|
+
function resultText(blocks) {
|
|
114
|
+
return blocks.filter((block) => block.type === "text" && typeof block.text === "string").map((block) => block.text).join("");
|
|
115
|
+
}
|
|
116
|
+
/** The usage a provider reported in one attempt's stream, if any. */
|
|
117
|
+
function streamUsage(stream) {
|
|
118
|
+
return lastAssistantStreamChunk(stream, "usage")?.usage;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Accumulate one step's usage across its attempts. A retried attempt keeps its
|
|
122
|
+
* usage only in its `assistant/attempt` stream, so the committed message alone
|
|
123
|
+
* would under-report billed tokens. An attempt that reports no sample makes the
|
|
124
|
+
* step total unknowable, so the step omits usage entirely rather than publish a
|
|
125
|
+
* partial sum as if it were complete; an optional bucket is summed only when
|
|
126
|
+
* every contribution reports it.
|
|
127
|
+
* @param state - usage accumulated so far and whether it is still complete.
|
|
128
|
+
* @param next - usage reported by the next attempt.
|
|
129
|
+
* @returns the updated state.
|
|
130
|
+
*/
|
|
131
|
+
function addUsage(state, next) {
|
|
132
|
+
if (next === void 0) return {
|
|
133
|
+
usage: state.usage,
|
|
134
|
+
complete: false
|
|
135
|
+
};
|
|
136
|
+
if (state.usage === void 0) return {
|
|
137
|
+
usage: next,
|
|
138
|
+
complete: state.complete
|
|
139
|
+
};
|
|
140
|
+
const total = state.usage;
|
|
141
|
+
const sum = (a, b) => a === void 0 || b === void 0 ? void 0 : a + b;
|
|
142
|
+
const totalTokens = sum(total.totalTokens, next.totalTokens);
|
|
143
|
+
const cacheReadTokens = sum(total.cacheReadTokens, next.cacheReadTokens);
|
|
144
|
+
const cacheWriteTokens = sum(total.cacheWriteTokens, next.cacheWriteTokens);
|
|
145
|
+
const reasoningTokens = sum(total.reasoningTokens, next.reasoningTokens);
|
|
146
|
+
return {
|
|
147
|
+
usage: {
|
|
148
|
+
inputTokens: total.inputTokens + next.inputTokens,
|
|
149
|
+
outputTokens: total.outputTokens + next.outputTokens,
|
|
150
|
+
...totalTokens === void 0 ? {} : { totalTokens },
|
|
151
|
+
...cacheReadTokens === void 0 ? {} : { cacheReadTokens },
|
|
152
|
+
...cacheWriteTokens === void 0 ? {} : { cacheWriteTokens },
|
|
153
|
+
...reasoningTokens === void 0 ? {} : { reasoningTokens }
|
|
154
|
+
},
|
|
155
|
+
complete: state.complete
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Project one Agent's run as newline-delimited JSON on `sink`.
|
|
160
|
+
*
|
|
161
|
+
* The opening `session` event is written before the subscription starts, so a
|
|
162
|
+
* caller must invoke this before submitting the task. Text and reasoning are
|
|
163
|
+
* emitted only when the step's `assistant/message` commits them, and the
|
|
164
|
+
* terminal `final` event carries the same lossless answer the default mode
|
|
165
|
+
* prints (it is deliberately not bounded).
|
|
166
|
+
* @param ctx - plugin context carrying the live Session feed.
|
|
167
|
+
* @param agent - the exact Agent whose events belong to this invocation.
|
|
168
|
+
* @param sink - stdout sink receiving one JSON object per line.
|
|
169
|
+
* @param options - projection tunables.
|
|
170
|
+
* @returns the projection handle that finishes or disposes the stream.
|
|
171
|
+
*/
|
|
172
|
+
function projectJsonRun(ctx, agent, sink, options = {}) {
|
|
173
|
+
const maxStringBytes = options.maxStringBytes ?? 8192;
|
|
174
|
+
let disposed = false;
|
|
175
|
+
let stepUsage = {
|
|
176
|
+
usage: void 0,
|
|
177
|
+
complete: true
|
|
178
|
+
};
|
|
179
|
+
const write = (event) => {
|
|
180
|
+
sink.write(`${boundJsonLine(event, maxStringBytes)}\n`);
|
|
181
|
+
};
|
|
182
|
+
const onSessionEvent = (session, event) => {
|
|
183
|
+
if (session !== agent.session) return;
|
|
184
|
+
switch (event.type) {
|
|
185
|
+
case "turn/start":
|
|
186
|
+
write({
|
|
187
|
+
type: "status",
|
|
188
|
+
phase: "turn_start",
|
|
189
|
+
turn: event.data.turn
|
|
190
|
+
});
|
|
191
|
+
return;
|
|
192
|
+
case "step/start":
|
|
193
|
+
write({
|
|
194
|
+
type: "status",
|
|
195
|
+
phase: "step_start",
|
|
196
|
+
turn: event.data.turn,
|
|
197
|
+
step: event.data.step
|
|
198
|
+
});
|
|
199
|
+
return;
|
|
200
|
+
case "assistant/attempt":
|
|
201
|
+
stepUsage = addUsage(stepUsage, streamUsage(event.data.stream));
|
|
202
|
+
return;
|
|
203
|
+
case "assistant/message":
|
|
204
|
+
stepUsage = addUsage(stepUsage, event.data.usage ?? streamUsage(event.data.stream));
|
|
205
|
+
for (const block of event.data.message.content) if (block.type === "reasoning") write({
|
|
206
|
+
type: "thinking",
|
|
207
|
+
text: block.text
|
|
208
|
+
});
|
|
209
|
+
else if (block.type === "text") write({
|
|
210
|
+
type: "text",
|
|
211
|
+
text: block.text
|
|
212
|
+
});
|
|
213
|
+
return;
|
|
214
|
+
case "step/end": {
|
|
215
|
+
const { usage, complete } = stepUsage;
|
|
216
|
+
stepUsage = {
|
|
217
|
+
usage: void 0,
|
|
218
|
+
complete: true
|
|
219
|
+
};
|
|
220
|
+
write({
|
|
221
|
+
type: "status",
|
|
222
|
+
phase: "step_end",
|
|
223
|
+
turn: event.data.turn,
|
|
224
|
+
step: event.data.step,
|
|
225
|
+
...complete && usage !== void 0 ? { usage } : {}
|
|
226
|
+
});
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
case "turn/end":
|
|
230
|
+
write({
|
|
231
|
+
type: "status",
|
|
232
|
+
phase: "turn_end",
|
|
233
|
+
turn: event.data.turn,
|
|
234
|
+
reason: event.data.reason
|
|
235
|
+
});
|
|
236
|
+
return;
|
|
237
|
+
case "tool/call":
|
|
238
|
+
write({
|
|
239
|
+
type: "tool_call",
|
|
240
|
+
callId: event.data.callId,
|
|
241
|
+
tool: event.data.name,
|
|
242
|
+
input: parseArguments(event.data.arguments)
|
|
243
|
+
});
|
|
244
|
+
return;
|
|
245
|
+
case "tool/result": {
|
|
246
|
+
if (event.surfaceOp !== "append") return;
|
|
247
|
+
const block = event.data.message.content[0];
|
|
248
|
+
write({
|
|
249
|
+
type: "tool_result",
|
|
250
|
+
callId: block.toolCallId,
|
|
251
|
+
status: block.isError === true ? "error" : "completed",
|
|
252
|
+
result: resultText(block.content)
|
|
253
|
+
});
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
default: return;
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
write({
|
|
260
|
+
type: "session",
|
|
261
|
+
sessionId: agent.id,
|
|
262
|
+
cwd: options.cwd ?? process.cwd()
|
|
263
|
+
});
|
|
264
|
+
const stopSession = ctx.on("session/event", onSessionEvent);
|
|
265
|
+
return {
|
|
266
|
+
finish(text) {
|
|
267
|
+
if (disposed) return;
|
|
268
|
+
sink.write(`${JSON.stringify({
|
|
269
|
+
type: "final",
|
|
270
|
+
text
|
|
271
|
+
})}\n`);
|
|
272
|
+
},
|
|
273
|
+
dispose() {
|
|
274
|
+
disposed = true;
|
|
275
|
+
stopSession();
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
//#endregion
|
|
280
|
+
export { projectJsonRun as n, boundJsonLine as t };
|