@zvada/agent-server 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/package.json +87 -0
- package/src/client/client.ts +589 -0
- package/src/client/index.ts +18 -0
- package/src/client/transports.ts +84 -0
- package/src/core/agents/acp/acp-agent.ts +322 -0
- package/src/core/agents/acp/adapter.ts +260 -0
- package/src/core/agents/acp/client.ts +212 -0
- package/src/core/agents/acp/known-agents.ts +29 -0
- package/src/core/agents/acp/mappings.ts +136 -0
- package/src/core/agents/base.ts +145 -0
- package/src/core/agents/claude-code/adapter.ts +451 -0
- package/src/core/agents/claude-code/claude-agent.ts +235 -0
- package/src/core/agents/claude-code/generator-session.ts +344 -0
- package/src/core/agents/claude-code/options.ts +161 -0
- package/src/core/agents/claude-code/session-manager.ts +159 -0
- package/src/core/agents/codex-app-server/adapter.ts +214 -0
- package/src/core/agents/codex-app-server/client.ts +221 -0
- package/src/core/agents/codex-app-server/codex-app-server-agent.ts +385 -0
- package/src/core/agents/codex-items.ts +122 -0
- package/src/core/agents/codex-sdk/adapter.ts +204 -0
- package/src/core/agents/codex-sdk/codex-sdk-agent.ts +236 -0
- package/src/core/agents/config-fingerprint.ts +19 -0
- package/src/core/agents/error-classifier.ts +68 -0
- package/src/core/agents/registry.ts +40 -0
- package/src/core/agents/session-store.ts +72 -0
- package/src/core/agents/tool-meta.ts +68 -0
- package/src/core/agents/types.ts +54 -0
- package/src/core/index.ts +114 -0
- package/src/core/presets.ts +78 -0
- package/src/core/provision/extract.ts +31 -0
- package/src/core/provision/index.ts +10 -0
- package/src/core/provision/npm.ts +114 -0
- package/src/core/provision/pins.ts +51 -0
- package/src/core/provision/platform.ts +73 -0
- package/src/core/provision/provisioner.ts +478 -0
- package/src/core/proxy/anthropic-proxy.ts +69 -0
- package/src/core/proxy/api-key-store.ts +34 -0
- package/src/core/proxy/index.ts +7 -0
- package/src/core/runtime/agent-runtime.ts +363 -0
- package/src/core/runtime/event-processor.ts +218 -0
- package/src/core/runtime/event-sink.ts +37 -0
- package/src/core/utils/errors.ts +41 -0
- package/src/index.ts +4 -0
- package/src/protocol/async-queue.ts +68 -0
- package/src/protocol/config.ts +100 -0
- package/src/protocol/factories.ts +125 -0
- package/src/protocol/harness.ts +50 -0
- package/src/protocol/ids.ts +53 -0
- package/src/protocol/index.ts +16 -0
- package/src/protocol/lifecycle.ts +309 -0
- package/src/protocol/models.ts +45 -0
- package/src/protocol/part-input.ts +58 -0
- package/src/protocol/parts.ts +60 -0
- package/src/protocol/thinking.ts +32 -0
- package/src/protocol/tokens.ts +39 -0
- package/src/protocol/tool-state.ts +89 -0
- package/src/protocol/wire.ts +313 -0
- package/src/server/acp/binding.ts +163 -0
- package/src/server/acp/translate.ts +160 -0
- package/src/server/agent-server.ts +357 -0
- package/src/server/bin.ts +174 -0
- package/src/server/index.ts +24 -0
- package/src/server/install.ts +51 -0
- package/src/server/session-log.ts +66 -0
- package/src/server/transports.ts +149 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentCapabilities,
|
|
3
|
+
AgentHarness,
|
|
4
|
+
LifecycleEvent,
|
|
5
|
+
PermissionOption,
|
|
6
|
+
PermissionOutcome,
|
|
7
|
+
PermissionToolCall,
|
|
8
|
+
RunRequest,
|
|
9
|
+
StopReason,
|
|
10
|
+
TokenUsage,
|
|
11
|
+
} from "../../protocol/index.ts";
|
|
12
|
+
import { DEFAULT_TOKEN_USAGE, generateUUIDv7 } from "../../protocol/index.ts";
|
|
13
|
+
import type { AgentExecuteOptions, PermissionDecision } from "../agents/base.ts";
|
|
14
|
+
import {
|
|
15
|
+
type ErrorCategory,
|
|
16
|
+
classifyError,
|
|
17
|
+
isCancellation,
|
|
18
|
+
isRecoverable,
|
|
19
|
+
} from "../agents/error-classifier.ts";
|
|
20
|
+
import type { AgentRegistry } from "../agents/registry.ts";
|
|
21
|
+
import { EventProcessor } from "./event-processor.ts";
|
|
22
|
+
import type { EventSink } from "./event-sink.ts";
|
|
23
|
+
|
|
24
|
+
/** What a completed turn reports back to the caller (in addition to the streamed events). */
|
|
25
|
+
export interface RunSummary {
|
|
26
|
+
sessionId: string;
|
|
27
|
+
turnId: string;
|
|
28
|
+
harness: AgentHarness;
|
|
29
|
+
/** Native session/thread id to persist for a future resume. */
|
|
30
|
+
nativeSessionId?: string;
|
|
31
|
+
/** When the turn requested a resume: whether the harness honored it. */
|
|
32
|
+
resumed?: boolean;
|
|
33
|
+
usage: TokenUsage;
|
|
34
|
+
/** Normalized terminal status (same value as the `turn.ended` event). */
|
|
35
|
+
stopReason: StopReason;
|
|
36
|
+
finishReason?: string;
|
|
37
|
+
cost?: number;
|
|
38
|
+
error?: { category: ErrorCategory; message: string };
|
|
39
|
+
cancelled: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The standard options offered for every brokered permission request. */
|
|
43
|
+
const PERMISSION_OPTIONS: PermissionOption[] = [
|
|
44
|
+
{ optionId: "allow", name: "Allow", kind: "allow_once" },
|
|
45
|
+
{ optionId: "reject", name: "Reject", kind: "reject_once" },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
interface PendingPermission {
|
|
49
|
+
sessionId: string;
|
|
50
|
+
/** Resolves the request and emits `permission.resolved`; await the returned
|
|
51
|
+
* promise to order that emit (the turn-end drain does, so it lands before
|
|
52
|
+
* `turn.ended`). */
|
|
53
|
+
settle: (outcome: PermissionOutcome) => void | Promise<void>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The engine. Owns no transport and no harness specifics: it routes a
|
|
58
|
+
* RunRequest to the right harness + adapter, drives the per-turn loop, brokers
|
|
59
|
+
* permission round-trips, and streams normalized LifecycleEvents to a sink.
|
|
60
|
+
*/
|
|
61
|
+
export class AgentRuntime {
|
|
62
|
+
/** Permission requests awaiting `respondPermission`, across all live turns. */
|
|
63
|
+
private readonly pendingPermissions = new Map<string, PendingPermission>();
|
|
64
|
+
private readonly activeRuns = new Set<Promise<RunSummary>>();
|
|
65
|
+
private shutdownPromise?: Promise<void>;
|
|
66
|
+
|
|
67
|
+
constructor(private readonly registry: AgentRegistry) {}
|
|
68
|
+
|
|
69
|
+
get harnesses(): AgentHarness[] {
|
|
70
|
+
return this.registry.list();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
capabilities(harness: AgentHarness): AgentCapabilities {
|
|
74
|
+
return this.registry.getAgent(harness).capabilities;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Answer a pending `permission.requested` event. Returns false when the
|
|
79
|
+
* request is unknown (already resolved, cancelled, or a sessionId mismatch).
|
|
80
|
+
*/
|
|
81
|
+
respondPermission(sessionId: string, requestId: string, outcome: PermissionOutcome): boolean {
|
|
82
|
+
const pending = this.pendingPermissions.get(requestId);
|
|
83
|
+
if (!pending || pending.sessionId !== sessionId) return false;
|
|
84
|
+
void pending.settle(outcome);
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
run(
|
|
89
|
+
request: RunRequest,
|
|
90
|
+
sink: EventSink,
|
|
91
|
+
opts: { signal?: AbortSignal } = {},
|
|
92
|
+
): Promise<RunSummary> {
|
|
93
|
+
if (this.shutdownPromise) return Promise.reject(new Error("agent runtime is shutting down"));
|
|
94
|
+
const running = this.executeRun(request, sink, opts);
|
|
95
|
+
this.activeRuns.add(running);
|
|
96
|
+
running.then(
|
|
97
|
+
() => this.activeRuns.delete(running),
|
|
98
|
+
() => this.activeRuns.delete(running),
|
|
99
|
+
);
|
|
100
|
+
return running;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private async executeRun(
|
|
104
|
+
request: RunRequest,
|
|
105
|
+
sink: EventSink,
|
|
106
|
+
opts: { signal?: AbortSignal },
|
|
107
|
+
): Promise<RunSummary> {
|
|
108
|
+
const { sessionId, turnId, input, config } = request;
|
|
109
|
+
const agent = this.registry.getAgent(config.harness);
|
|
110
|
+
const transformer = this.registry.getAdapter(config.harness)({ sessionId });
|
|
111
|
+
const processor = new EventProcessor(sessionId, turnId, {
|
|
112
|
+
harness: config.harness,
|
|
113
|
+
model: config.model,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Isolate sink failures: a misbehaving transport must never break the
|
|
117
|
+
// turn's lifecycle bracketing (turn.started ... turn.ended).
|
|
118
|
+
const emit = async (e: LifecycleEvent) => {
|
|
119
|
+
try {
|
|
120
|
+
await sink.emit(e);
|
|
121
|
+
} catch {
|
|
122
|
+
// swallow — the sink owns its own reliability
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
let nativeSessionId: string | undefined;
|
|
127
|
+
let resumed: boolean | undefined;
|
|
128
|
+
let sessionEmitted = false;
|
|
129
|
+
const flushSession = async () => {
|
|
130
|
+
if (nativeSessionId && !sessionEmitted) {
|
|
131
|
+
sessionEmitted = true;
|
|
132
|
+
await emit({
|
|
133
|
+
type: "session.created",
|
|
134
|
+
sessionId,
|
|
135
|
+
nativeSessionId,
|
|
136
|
+
harness: config.harness,
|
|
137
|
+
model: config.model,
|
|
138
|
+
...(resumed !== undefined && { resumed }),
|
|
139
|
+
timestamp: Date.now(),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// --- permission broker (one scope per turn) ----------------------------
|
|
145
|
+
const turnRequestIds = new Set<string>();
|
|
146
|
+
const requestPermission = async (
|
|
147
|
+
toolCall: PermissionToolCall,
|
|
148
|
+
permOpts?: { signal?: AbortSignal },
|
|
149
|
+
): Promise<PermissionDecision> => {
|
|
150
|
+
if (permOpts?.signal?.aborted) return { decision: "cancel" };
|
|
151
|
+
const requestId = generateUUIDv7();
|
|
152
|
+
// Register BEFORE emitting so even a sink that answers synchronously
|
|
153
|
+
// from its emit() finds the pending entry.
|
|
154
|
+
let resolveOutcome!: (o: PermissionOutcome) => void;
|
|
155
|
+
const outcomePromise = new Promise<PermissionOutcome>((resolve) => {
|
|
156
|
+
resolveOutcome = resolve;
|
|
157
|
+
});
|
|
158
|
+
const settle = (o: PermissionOutcome): Promise<void> | void => {
|
|
159
|
+
if (!this.pendingPermissions.delete(requestId)) return;
|
|
160
|
+
turnRequestIds.delete(requestId);
|
|
161
|
+
resolveOutcome(o);
|
|
162
|
+
// Returned so the in-run drain can sequence this before turn.ended;
|
|
163
|
+
// external callers (respondPermission/cancel) ignore it.
|
|
164
|
+
return emit({
|
|
165
|
+
type: "permission.resolved",
|
|
166
|
+
sessionId,
|
|
167
|
+
turnId,
|
|
168
|
+
requestId,
|
|
169
|
+
outcome: o,
|
|
170
|
+
timestamp: Date.now(),
|
|
171
|
+
});
|
|
172
|
+
};
|
|
173
|
+
this.pendingPermissions.set(requestId, { sessionId, settle });
|
|
174
|
+
turnRequestIds.add(requestId);
|
|
175
|
+
permOpts?.signal?.addEventListener("abort", () => void settle({ outcome: "cancelled" }), {
|
|
176
|
+
once: true,
|
|
177
|
+
});
|
|
178
|
+
await emit({
|
|
179
|
+
type: "permission.requested",
|
|
180
|
+
sessionId,
|
|
181
|
+
turnId,
|
|
182
|
+
requestId,
|
|
183
|
+
title: toolCall.title ?? `Allow tool: ${toolCall.toolName}`,
|
|
184
|
+
toolCall,
|
|
185
|
+
options: PERMISSION_OPTIONS,
|
|
186
|
+
timestamp: Date.now(),
|
|
187
|
+
});
|
|
188
|
+
const outcome = await outcomePromise;
|
|
189
|
+
if (outcome.outcome === "cancelled") return { decision: "cancel" };
|
|
190
|
+
const selected = PERMISSION_OPTIONS.find((o) => o.optionId === outcome.optionId);
|
|
191
|
+
return selected?.kind === "allow_once" || selected?.kind === "allow_always"
|
|
192
|
+
? { decision: "allow" }
|
|
193
|
+
: { decision: "deny", reason: "Denied by user" };
|
|
194
|
+
};
|
|
195
|
+
/** Cancellation / turn end resolves everything still pending as cancelled.
|
|
196
|
+
* Awaited so every `permission.resolved` is emitted before `turn.ended`. */
|
|
197
|
+
const drainPermissions = async () => {
|
|
198
|
+
const settles = [...turnRequestIds].map((requestId) =>
|
|
199
|
+
this.pendingPermissions.get(requestId)?.settle({ outcome: "cancelled" }),
|
|
200
|
+
);
|
|
201
|
+
await Promise.all(settles);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
await emit({ type: "turn.started", turnId, sessionId, timestamp: Date.now() });
|
|
205
|
+
|
|
206
|
+
const options: AgentExecuteOptions = {
|
|
207
|
+
sessionId,
|
|
208
|
+
turnId,
|
|
209
|
+
cwd: config.cwd,
|
|
210
|
+
model: config.model,
|
|
211
|
+
thinkingLevel: config.thinkingLevel,
|
|
212
|
+
permissionMode: config.permissionMode,
|
|
213
|
+
maxTurns: config.maxTurns,
|
|
214
|
+
systemPromptAppend: config.systemPromptAppend,
|
|
215
|
+
resumeSessionId: config.resumeSessionId,
|
|
216
|
+
resumeSessionAt: config.resumeSessionAt,
|
|
217
|
+
additionalDirectories: config.additionalDirectories,
|
|
218
|
+
mcpServers: config.mcpServers,
|
|
219
|
+
env: config.env,
|
|
220
|
+
apiKey: config.apiKey,
|
|
221
|
+
disableTools: config.disableTools,
|
|
222
|
+
signal: opts.signal,
|
|
223
|
+
onNativeSession: (id, info) => {
|
|
224
|
+
// Agents report exactly once per turn (the claude fallback defers its
|
|
225
|
+
// report until the surviving session is known); guard against a
|
|
226
|
+
// misbehaving harness re-reporting after emission. Agents always pass
|
|
227
|
+
// their honest `resumed` judgment — the runtime surfaces it only when
|
|
228
|
+
// this turn actually REQUESTED a resume.
|
|
229
|
+
if (sessionEmitted) return;
|
|
230
|
+
nativeSessionId = id;
|
|
231
|
+
if (config.resumeSessionId && info?.resumed !== undefined) resumed = info.resumed;
|
|
232
|
+
},
|
|
233
|
+
onPermissionRequest: requestPermission,
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
let cancelled = false;
|
|
237
|
+
let errored: { category: ErrorCategory; message: string } | undefined;
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
for await (const raw of agent.execute(input, options)) {
|
|
241
|
+
await flushSession();
|
|
242
|
+
if (config.includeRaw) {
|
|
243
|
+
await emit({
|
|
244
|
+
type: "raw",
|
|
245
|
+
sessionId,
|
|
246
|
+
turnId,
|
|
247
|
+
harness: config.harness,
|
|
248
|
+
data: raw,
|
|
249
|
+
timestamp: Date.now(),
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
for (const ae of transformer.process(raw)) {
|
|
253
|
+
for (const le of processor.handle(ae)) await emit(le);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
await flushSession();
|
|
257
|
+
} catch (err) {
|
|
258
|
+
cancelled = isCancellation(err);
|
|
259
|
+
if (!cancelled) {
|
|
260
|
+
const category = classifyError(err);
|
|
261
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
262
|
+
errored = { category, message };
|
|
263
|
+
await emit({
|
|
264
|
+
type: "error",
|
|
265
|
+
turnId,
|
|
266
|
+
sessionId,
|
|
267
|
+
error: message,
|
|
268
|
+
recoverable: isRecoverable(category),
|
|
269
|
+
code: category,
|
|
270
|
+
stack: err instanceof Error ? err.stack : undefined,
|
|
271
|
+
timestamp: Date.now(),
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
} finally {
|
|
275
|
+
await drainPermissions();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const result = transformer.finish();
|
|
279
|
+
cancelled = cancelled || Boolean(result.cancelled);
|
|
280
|
+
if (result.error && !errored && !cancelled) {
|
|
281
|
+
errored = { category: classifyError(new Error(result.error)), message: result.error };
|
|
282
|
+
}
|
|
283
|
+
const stopReason: StopReason = cancelled
|
|
284
|
+
? "cancelled"
|
|
285
|
+
: errored
|
|
286
|
+
? "error"
|
|
287
|
+
: (result.stopReason ?? "end_turn");
|
|
288
|
+
const terminalResult =
|
|
289
|
+
errored && !result.error ? { ...result, error: errored.message } : result;
|
|
290
|
+
for (const le of processor.finish(terminalResult, stopReason)) await emit(le);
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
sessionId,
|
|
294
|
+
turnId,
|
|
295
|
+
harness: config.harness,
|
|
296
|
+
nativeSessionId,
|
|
297
|
+
...(resumed !== undefined && { resumed }),
|
|
298
|
+
usage: result.usage ?? DEFAULT_TOKEN_USAGE,
|
|
299
|
+
stopReason,
|
|
300
|
+
finishReason: result.finishReason,
|
|
301
|
+
cost: result.cost,
|
|
302
|
+
error: errored,
|
|
303
|
+
cancelled,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async cancel(harness: AgentHarness, sessionId: string): Promise<void> {
|
|
308
|
+
// Unblock any harness parked on an approval before (and regardless of)
|
|
309
|
+
// the agent-level abort. `settle` removes the entry from the map itself.
|
|
310
|
+
for (const pending of [...this.pendingPermissions.values()]) {
|
|
311
|
+
if (pending.sessionId === sessionId) void pending.settle({ outcome: "cancelled" });
|
|
312
|
+
}
|
|
313
|
+
await this.registry.getAgent(harness).cancel(sessionId);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Dispose one idle logical session and its harness-native resources. */
|
|
317
|
+
async closeSession(harness: AgentHarness, sessionId: string): Promise<void> {
|
|
318
|
+
await Promise.all(
|
|
319
|
+
[...this.pendingPermissions.values()]
|
|
320
|
+
.filter((pending) => pending.sessionId === sessionId)
|
|
321
|
+
.map((pending) => pending.settle({ outcome: "cancelled" })),
|
|
322
|
+
);
|
|
323
|
+
const agent = this.registry.getAgent(harness);
|
|
324
|
+
if (agent.release) await agent.release(sessionId);
|
|
325
|
+
else await agent.cancel(sessionId);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Give up on stragglers after this long; the process is exiting anyway. */
|
|
329
|
+
private static readonly DEFAULT_DRAIN_TIMEOUT_MS = 10_000;
|
|
330
|
+
|
|
331
|
+
shutdown(opts: { drainTimeoutMs?: number } = {}): Promise<void> {
|
|
332
|
+
// Memoized: the first caller's timeout wins.
|
|
333
|
+
this.shutdownPromise ??= this.performShutdown(
|
|
334
|
+
opts.drainTimeoutMs ?? AgentRuntime.DEFAULT_DRAIN_TIMEOUT_MS,
|
|
335
|
+
);
|
|
336
|
+
return this.shutdownPromise;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private async performShutdown(drainTimeoutMs: number): Promise<void> {
|
|
340
|
+
await Promise.all(
|
|
341
|
+
[...this.pendingPermissions.values()].map((pending) =>
|
|
342
|
+
pending.settle({ outcome: "cancelled" }),
|
|
343
|
+
),
|
|
344
|
+
);
|
|
345
|
+
this.pendingPermissions.clear();
|
|
346
|
+
let terminationError: unknown;
|
|
347
|
+
try {
|
|
348
|
+
await this.registry.terminateAll();
|
|
349
|
+
} catch (error) {
|
|
350
|
+
terminationError = error;
|
|
351
|
+
}
|
|
352
|
+
// Drain active runs, but don't let a harness that ignores its abort
|
|
353
|
+
// signal hang the process's shutdown forever.
|
|
354
|
+
await Promise.race([
|
|
355
|
+
Promise.allSettled([...this.activeRuns]),
|
|
356
|
+
new Promise<void>((resolve) => {
|
|
357
|
+
const timer = setTimeout(resolve, drainTimeoutMs);
|
|
358
|
+
timer.unref?.();
|
|
359
|
+
}),
|
|
360
|
+
]);
|
|
361
|
+
if (terminationError !== undefined) throw terminationError;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { generateUUIDv7 } from "../../protocol/index.ts";
|
|
2
|
+
import type { Delta, LifecycleEvent, Part, StopReason } from "../../protocol/index.ts";
|
|
3
|
+
import type { AdapterEvent, TransformResult } from "../agents/types.ts";
|
|
4
|
+
|
|
5
|
+
/** Where a part lives on the wire — fixed at first emission for the whole turn. */
|
|
6
|
+
interface PartAddress {
|
|
7
|
+
messageId: string;
|
|
8
|
+
outputIndex: number;
|
|
9
|
+
partIndex: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Turns the harness-agnostic AdapterEvent stream into wire LifecycleEvents.
|
|
14
|
+
* Owns all id/index bookkeeping: assigns message ids, per-message output
|
|
15
|
+
* indices, and per-part indices, and brackets messages with started/ended.
|
|
16
|
+
*
|
|
17
|
+
* Parts are registered turn-wide: a part keeps the message/indices of its
|
|
18
|
+
* first emission even when updated after that message ended (e.g. a tool
|
|
19
|
+
* completing after the model message that issued it) — consumers upsert by
|
|
20
|
+
* `part.id`. One instance per turn.
|
|
21
|
+
*/
|
|
22
|
+
export class EventProcessor {
|
|
23
|
+
private openMessageId?: string;
|
|
24
|
+
/** `parentToolUseId` of the open message (undefined for top-level messages). */
|
|
25
|
+
private openMessageParent?: string;
|
|
26
|
+
private currentOutputIndex = -1;
|
|
27
|
+
private nextOutputIndex = 0;
|
|
28
|
+
private nextPartIndex = 0;
|
|
29
|
+
private readonly partAddressById = new Map<string, PartAddress>();
|
|
30
|
+
|
|
31
|
+
constructor(
|
|
32
|
+
private readonly sessionId: string,
|
|
33
|
+
private readonly turnId: string,
|
|
34
|
+
private readonly meta: { harness?: string; model?: string } = {},
|
|
35
|
+
) {}
|
|
36
|
+
|
|
37
|
+
*handle(ev: AdapterEvent): Generator<LifecycleEvent> {
|
|
38
|
+
switch (ev.kind) {
|
|
39
|
+
case "message-start":
|
|
40
|
+
yield* this.openMessage(ev.role);
|
|
41
|
+
return;
|
|
42
|
+
case "message-end":
|
|
43
|
+
yield* this.closeMessage();
|
|
44
|
+
return;
|
|
45
|
+
case "part-open":
|
|
46
|
+
case "part-update":
|
|
47
|
+
yield* this.emitPart(ev.part);
|
|
48
|
+
return;
|
|
49
|
+
case "text-delta": {
|
|
50
|
+
const e = this.delta(ev.partId, { type: "text-delta", text: ev.text });
|
|
51
|
+
if (e) yield e;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
case "reasoning-delta": {
|
|
55
|
+
const e = this.delta(ev.partId, { type: "reasoning-delta", text: ev.text });
|
|
56
|
+
if (e) yield e;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
case "tool-input-delta": {
|
|
60
|
+
const e = this.delta(ev.partId, {
|
|
61
|
+
type: "tool-input-delta",
|
|
62
|
+
toolCallId: ev.toolCallId,
|
|
63
|
+
toolName: ev.toolName,
|
|
64
|
+
input: ev.input,
|
|
65
|
+
});
|
|
66
|
+
if (e) yield e;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
case "usage":
|
|
70
|
+
yield {
|
|
71
|
+
type: "session.usage",
|
|
72
|
+
sessionId: this.sessionId,
|
|
73
|
+
turnId: this.turnId,
|
|
74
|
+
used: ev.used,
|
|
75
|
+
...(ev.size !== undefined && { size: ev.size }),
|
|
76
|
+
...(ev.cost !== undefined && { cost: ev.cost }),
|
|
77
|
+
timestamp: Date.now(),
|
|
78
|
+
};
|
|
79
|
+
return;
|
|
80
|
+
case "compacted":
|
|
81
|
+
yield {
|
|
82
|
+
type: "session.compacted",
|
|
83
|
+
sessionId: this.sessionId,
|
|
84
|
+
turnId: this.turnId,
|
|
85
|
+
...(ev.trigger && { trigger: ev.trigger }),
|
|
86
|
+
...(ev.preTokens !== undefined && { preTokens: ev.preTokens }),
|
|
87
|
+
...(ev.postTokens !== undefined && { postTokens: ev.postTokens }),
|
|
88
|
+
timestamp: Date.now(),
|
|
89
|
+
};
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
*finish(result: TransformResult, stopReason: StopReason): Generator<LifecycleEvent> {
|
|
95
|
+
yield* this.closeMessage();
|
|
96
|
+
yield {
|
|
97
|
+
type: "turn.ended",
|
|
98
|
+
turnId: this.turnId,
|
|
99
|
+
sessionId: this.sessionId,
|
|
100
|
+
stopReason,
|
|
101
|
+
finishReason: result.finishReason,
|
|
102
|
+
tokens: result.usage,
|
|
103
|
+
cost: result.cost,
|
|
104
|
+
error:
|
|
105
|
+
result.error && !result.cancelled
|
|
106
|
+
? { name: "AgentError", message: result.error }
|
|
107
|
+
: undefined,
|
|
108
|
+
timestamp: Date.now(),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private *openMessage(
|
|
113
|
+
role: "assistant" | "user",
|
|
114
|
+
parentToolUseId?: string,
|
|
115
|
+
): Generator<LifecycleEvent> {
|
|
116
|
+
if (this.openMessageId) yield* this.closeMessage();
|
|
117
|
+
const messageId = generateUUIDv7();
|
|
118
|
+
this.openMessageId = messageId;
|
|
119
|
+
this.openMessageParent = parentToolUseId;
|
|
120
|
+
this.currentOutputIndex = this.nextOutputIndex++;
|
|
121
|
+
this.nextPartIndex = 0;
|
|
122
|
+
yield {
|
|
123
|
+
type: "message.started",
|
|
124
|
+
turnId: this.turnId,
|
|
125
|
+
messageId,
|
|
126
|
+
outputIndex: this.currentOutputIndex,
|
|
127
|
+
role,
|
|
128
|
+
// A parented message is a sub-agent's output — nests under its tool call,
|
|
129
|
+
// not a top-level model message (see DESIGN.md D5).
|
|
130
|
+
...(parentToolUseId && { parentToolUseId }),
|
|
131
|
+
timestamp: Date.now(),
|
|
132
|
+
metadata: {
|
|
133
|
+
sessionId: this.sessionId,
|
|
134
|
+
...(this.meta.harness && { harness: this.meta.harness }),
|
|
135
|
+
...(this.meta.model && { model: this.meta.model }),
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private *closeMessage(): Generator<LifecycleEvent> {
|
|
141
|
+
if (!this.openMessageId) return;
|
|
142
|
+
yield {
|
|
143
|
+
type: "message.ended",
|
|
144
|
+
turnId: this.turnId,
|
|
145
|
+
messageId: this.openMessageId,
|
|
146
|
+
timestamp: Date.now(),
|
|
147
|
+
};
|
|
148
|
+
this.openMessageId = undefined;
|
|
149
|
+
this.openMessageParent = undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Ensure an open message whose parent matches the incoming part. A part
|
|
153
|
+
* whose `parentToolUseId` differs from the open message (main↔sub-agent, or
|
|
154
|
+
* between sibling sub-agents) starts a new message so sub-agent output is
|
|
155
|
+
* grouped under its own parented message rather than mixed into another. */
|
|
156
|
+
private *ensureMessage(parentToolUseId?: string): Generator<LifecycleEvent> {
|
|
157
|
+
if (this.openMessageId && this.openMessageParent !== parentToolUseId) {
|
|
158
|
+
yield* this.closeMessage();
|
|
159
|
+
}
|
|
160
|
+
if (!this.openMessageId) yield* this.openMessage("assistant", parentToolUseId);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Resolve (or assign) the wire address of a part. Yields a message.started
|
|
164
|
+
* first when the part is new and no matching message is open. */
|
|
165
|
+
private *addressFor(
|
|
166
|
+
partId: string,
|
|
167
|
+
parentToolUseId?: string,
|
|
168
|
+
): Generator<LifecycleEvent, PartAddress> {
|
|
169
|
+
const existing = this.partAddressById.get(partId);
|
|
170
|
+
if (existing) return existing;
|
|
171
|
+
yield* this.ensureMessage(parentToolUseId);
|
|
172
|
+
const address: PartAddress = {
|
|
173
|
+
messageId: this.openMessageId as string,
|
|
174
|
+
outputIndex: this.currentOutputIndex,
|
|
175
|
+
partIndex: this.nextPartIndex++,
|
|
176
|
+
};
|
|
177
|
+
this.partAddressById.set(partId, address);
|
|
178
|
+
return address;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private *emitPart(part: Part): Generator<LifecycleEvent> {
|
|
182
|
+
const address = yield* this.addressFor(part.id, part.parentToolUseId);
|
|
183
|
+
// Snapshot: adapters mutate their part objects in place across
|
|
184
|
+
// open/update, so we must clone at emit time or buffered consumers would
|
|
185
|
+
// all observe the final state. Stamp ownership so the part is
|
|
186
|
+
// self-describing on the wire.
|
|
187
|
+
const snapshot = structuredClone(part);
|
|
188
|
+
snapshot.sessionId = this.sessionId;
|
|
189
|
+
snapshot.messageId = address.messageId;
|
|
190
|
+
yield {
|
|
191
|
+
type: "message.part",
|
|
192
|
+
turnId: this.turnId,
|
|
193
|
+
messageId: address.messageId,
|
|
194
|
+
outputIndex: address.outputIndex,
|
|
195
|
+
partIndex: address.partIndex,
|
|
196
|
+
part: snapshot,
|
|
197
|
+
...(snapshot.parentToolUseId && { parentToolUseId: snapshot.parentToolUseId }),
|
|
198
|
+
timestamp: Date.now(),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private delta(partId: string, delta: Delta): LifecycleEvent | null {
|
|
203
|
+
// Deltas only ever follow a part-open, so an unknown part id means a
|
|
204
|
+
// misbehaving adapter — drop rather than fabricate an address.
|
|
205
|
+
const address = this.partAddressById.get(partId);
|
|
206
|
+
if (!address) return null;
|
|
207
|
+
return {
|
|
208
|
+
type: "message.part.delta",
|
|
209
|
+
turnId: this.turnId,
|
|
210
|
+
messageId: address.messageId,
|
|
211
|
+
outputIndex: address.outputIndex,
|
|
212
|
+
partIndex: address.partIndex,
|
|
213
|
+
partId,
|
|
214
|
+
delta,
|
|
215
|
+
timestamp: Date.now(),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { LifecycleEvent } from "../../protocol/index.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The single output port of the engine. A consumer implements `emit` to forward
|
|
5
|
+
* normalized events wherever they need to go (WebSocket, SSE, stdout, a buffer).
|
|
6
|
+
* The engine never assumes a transport.
|
|
7
|
+
*/
|
|
8
|
+
export interface EventSink {
|
|
9
|
+
emit(event: LifecycleEvent): void | Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Build a sink from a plain callback. */
|
|
13
|
+
export function callbackSink(fn: (event: LifecycleEvent) => void | Promise<void>): EventSink {
|
|
14
|
+
return { emit: fn };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A sink that accumulates events in memory — handy for tests and batch runs. */
|
|
18
|
+
export class CollectingSink implements EventSink {
|
|
19
|
+
readonly events: LifecycleEvent[] = [];
|
|
20
|
+
|
|
21
|
+
emit(event: LifecycleEvent): void {
|
|
22
|
+
this.events.push(event);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
byType<T extends LifecycleEvent["type"]>(type: T): Extract<LifecycleEvent, { type: T }>[] {
|
|
26
|
+
return this.events.filter((e): e is Extract<LifecycleEvent, { type: T }> => e.type === type);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Fan out to several sinks at once. */
|
|
31
|
+
export function teeSink(...sinks: EventSink[]): EventSink {
|
|
32
|
+
return {
|
|
33
|
+
async emit(event) {
|
|
34
|
+
for (const sink of sinks) await sink.emit(event);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Base class for all errors thrown by the engine. */
|
|
2
|
+
export class AgentServerError extends Error {
|
|
3
|
+
constructor(
|
|
4
|
+
message: string,
|
|
5
|
+
readonly code: string,
|
|
6
|
+
) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "AgentServerError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class HarnessNotFoundError extends AgentServerError {
|
|
13
|
+
constructor(harness: string) {
|
|
14
|
+
super(`No agent registered for harness: ${harness}`, "HARNESS_NOT_FOUND");
|
|
15
|
+
this.name = "HarnessNotFoundError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class AgentExecutionError extends AgentServerError {
|
|
20
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
|
21
|
+
super(message, "AGENT_EXECUTION_ERROR");
|
|
22
|
+
this.name = "AgentExecutionError";
|
|
23
|
+
if (options?.cause !== undefined) this.cause = options.cause;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class CliNotFoundError extends AgentServerError {
|
|
28
|
+
constructor(name: string, hint?: string) {
|
|
29
|
+
super(`Required CLI not found on PATH: ${name}${hint ? ` — ${hint}` : ""}`, "CLI_NOT_FOUND");
|
|
30
|
+
this.name = "CliNotFoundError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Managed CLI provisioning failed (download, integrity, or a bad override). */
|
|
35
|
+
export class CliProvisionError extends AgentServerError {
|
|
36
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
|
37
|
+
super(message, "CLI_PROVISION_FAILED");
|
|
38
|
+
this.name = "CliProvisionError";
|
|
39
|
+
if (options?.cause !== undefined) this.cause = options.cause;
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/index.ts
ADDED