@zvada/agent-server 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +317 -0
- package/README.md +20 -4
- package/docs/consuming.md +269 -0
- package/docs/deploy.md +80 -0
- package/docs/harnesses.md +64 -0
- package/docs/rfds/0001-deterministic-echo-ids.md +44 -0
- package/package.json +23 -3
- package/src/client/client.ts +143 -50
- package/src/core/agents/acp/acp-agent.ts +9 -0
- package/src/core/agents/acp/mappings.ts +3 -3
- package/src/core/agents/base.ts +9 -1
- package/src/core/agents/claude-code/adapter.ts +116 -26
- package/src/core/agents/claude-code/claude-agent.ts +31 -3
- package/src/core/agents/claude-code/generator-session.ts +16 -5
- package/src/core/agents/claude-code/options.ts +13 -3
- package/src/core/agents/claude-code/session-manager.ts +9 -4
- package/src/core/agents/codex-app-server/codex-app-server-agent.ts +17 -3
- package/src/core/agents/codex-sdk/codex-sdk-agent.ts +3 -3
- package/src/core/agents/types.ts +1 -1
- package/src/core/diagnostics.ts +59 -0
- package/src/core/index.ts +3 -1
- package/src/core/presets.ts +15 -2
- package/src/core/provision/pins.ts +5 -1
- package/src/core/proxy/anthropic-proxy.ts +12 -0
- package/src/core/runtime/agent-runtime.ts +66 -18
- package/src/core/runtime/event-processor.ts +51 -26
- package/src/protocol/config.ts +8 -6
- package/src/{core/agents/error-classifier.ts → protocol/errors.ts} +33 -7
- package/src/protocol/factories.ts +106 -10
- package/src/protocol/guards.ts +53 -0
- package/src/protocol/index.ts +10 -0
- package/src/protocol/lifecycle.ts +289 -112
- package/src/protocol/meta.ts +14 -0
- package/src/protocol/part-input.ts +56 -7
- package/src/protocol/parts.ts +125 -10
- package/src/protocol/reduce.ts +749 -0
- package/src/protocol/selectors.ts +162 -0
- package/src/protocol/seq-cursor.ts +87 -0
- package/src/protocol/stop-reasons.ts +45 -0
- package/src/protocol/time.ts +23 -0
- package/src/protocol/tokens.ts +23 -0
- package/src/protocol/tool-state.ts +85 -25
- package/src/protocol/verify.ts +440 -0
- package/src/protocol/vocabulary.ts +18 -0
- package/src/protocol/wire.ts +81 -13
- package/src/server/acp/binding.ts +23 -2
- package/src/server/acp/translate.ts +51 -14
- package/src/server/agent-server.ts +85 -6
- package/AGENTS.md +0 -21
|
@@ -27,7 +27,11 @@ export type CliTool = (typeof CLI_TOOLS)[number];
|
|
|
27
27
|
* set `provision.pins.codex` to their SDK's vendored version.
|
|
28
28
|
*/
|
|
29
29
|
export const DEFAULT_PINS: Record<CliTool, string> = {
|
|
30
|
-
|
|
30
|
+
// Held at 0.3.220: the paired CLI (2.1.220) honors `--thinking-display
|
|
31
|
+
// summarized`, so reasoning parts carry text. 2.1.233 ignores the flag and
|
|
32
|
+
// streams token-estimate placeholders instead — bump only after verifying
|
|
33
|
+
// thinking summaries still flow (see options.ts extraArgs).
|
|
34
|
+
claude: "0.3.220",
|
|
31
35
|
// 0.146.1 re-verified 2026-08-06: generate-ts snapshot carries our full
|
|
32
36
|
// surface unchanged (thread/turn/item methods, tokenUsage w/ window,
|
|
33
37
|
// approvals) and a live turn ran clean incl. the gpt-5.6-sol default.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type DiagnosticHandler, emitDiagnostic } from "../diagnostics.ts";
|
|
1
2
|
import { type ApiKeyStore, apiKeyStore } from "./api-key-store.ts";
|
|
2
3
|
|
|
3
4
|
/** Placeholder key handed to the agent subprocess; swapped for the real one here. */
|
|
@@ -16,6 +17,8 @@ export interface AnthropicProxyOptions {
|
|
|
16
17
|
pathPrefix?: string;
|
|
17
18
|
/** Fetch implementation (test seam / platform override). Default `globalThis.fetch`. */
|
|
18
19
|
fetch?: FetchLike;
|
|
20
|
+
/** Notified on upstream 401/403 — the STORED key is bad, not the placeholder. */
|
|
21
|
+
onDiagnostic?: DiagnosticHandler;
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
/**
|
|
@@ -98,6 +101,15 @@ export function createAnthropicProxy(
|
|
|
98
101
|
);
|
|
99
102
|
}
|
|
100
103
|
|
|
104
|
+
if (upstream.status === 401 || upstream.status === 403) {
|
|
105
|
+
emitDiagnostic(options.onDiagnostic, {
|
|
106
|
+
type: "proxyUpstreamAuth",
|
|
107
|
+
sessionId,
|
|
108
|
+
message: `upstream rejected the stored key for session ${sessionId} (${upstream.status})`,
|
|
109
|
+
detail: { status: upstream.status },
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
101
113
|
const contentType = upstream.headers.get("content-type") ?? "";
|
|
102
114
|
// Media type only — parameters (charset) stripped, case-insensitive, so
|
|
103
115
|
// `text/event-streamish` or a parameter VALUE never selects SSE handling.
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ErrorCategory,
|
|
3
|
+
classifyError,
|
|
4
|
+
isCancellation,
|
|
5
|
+
isRecoverable,
|
|
6
|
+
} from "../../protocol/errors.ts";
|
|
1
7
|
import type {
|
|
2
8
|
AgentCapabilities,
|
|
3
9
|
AgentHarness,
|
|
@@ -11,13 +17,8 @@ import type {
|
|
|
11
17
|
} from "../../protocol/index.ts";
|
|
12
18
|
import { DEFAULT_TOKEN_USAGE, generateUUIDv7 } from "../../protocol/index.ts";
|
|
13
19
|
import type { AgentExecuteOptions, CancelResult, PermissionDecision } from "../agents/base.ts";
|
|
14
|
-
import {
|
|
15
|
-
type ErrorCategory,
|
|
16
|
-
classifyError,
|
|
17
|
-
isCancellation,
|
|
18
|
-
isRecoverable,
|
|
19
|
-
} from "../agents/error-classifier.ts";
|
|
20
20
|
import type { AgentRegistry } from "../agents/registry.ts";
|
|
21
|
+
import { type DiagnosticHandler, emitDiagnostic } from "../diagnostics.ts";
|
|
21
22
|
import { TurnConflictError } from "../utils/errors.ts";
|
|
22
23
|
import { EventProcessor } from "./event-processor.ts";
|
|
23
24
|
import type { EventSink } from "./event-sink.ts";
|
|
@@ -69,10 +70,14 @@ const turnKeyOf = (sessionId: string, turnId: string) => `${sessionId}:${turnId}
|
|
|
69
70
|
const inputKeyOf = (request: RunRequest) =>
|
|
70
71
|
canonicalJson({ input: request.input, config: request.config });
|
|
71
72
|
|
|
72
|
-
/** The standard options offered for every brokered permission request.
|
|
73
|
+
/** The standard options offered for every brokered permission request (§6.4:
|
|
74
|
+
* the broker must actually offer the `*_always` kinds the schema advertises —
|
|
75
|
+
* a "don't ask again" answer becomes a standing per-session rule). */
|
|
73
76
|
const PERMISSION_OPTIONS: PermissionOption[] = [
|
|
74
77
|
{ optionId: "allow", name: "Allow", kind: "allow_once" },
|
|
78
|
+
{ optionId: "allow_always", name: "Always allow", kind: "allow_always" },
|
|
75
79
|
{ optionId: "reject", name: "Reject", kind: "reject_once" },
|
|
80
|
+
{ optionId: "reject_always", name: "Always reject", kind: "reject_always" },
|
|
76
81
|
];
|
|
77
82
|
|
|
78
83
|
interface PendingPermission {
|
|
@@ -91,6 +96,8 @@ interface PendingPermission {
|
|
|
91
96
|
export class AgentRuntime {
|
|
92
97
|
/** Permission requests awaiting `respondPermission`, across all live turns. */
|
|
93
98
|
private readonly pendingPermissions = new Map<string, PendingPermission>();
|
|
99
|
+
/** Standing per-session `*_always` answers: sessionId -> toolName -> verdict. */
|
|
100
|
+
private readonly alwaysDecisions = new Map<string, Map<string, "allow" | "deny">>();
|
|
94
101
|
private readonly activeRuns = new Set<Promise<RunSummary>>();
|
|
95
102
|
/** In-flight turns by turn key — the convergence target for retried runs. */
|
|
96
103
|
private readonly inflightTurns = new Map<
|
|
@@ -106,7 +113,10 @@ export class AgentRuntime {
|
|
|
106
113
|
private static readonly COMPLETED_TURN_MEMORY = 16;
|
|
107
114
|
private shutdownPromise?: Promise<void>;
|
|
108
115
|
|
|
109
|
-
constructor(
|
|
116
|
+
constructor(
|
|
117
|
+
private readonly registry: AgentRegistry,
|
|
118
|
+
private readonly options: { onDiagnostic?: DiagnosticHandler } = {},
|
|
119
|
+
) {}
|
|
110
120
|
|
|
111
121
|
get harnesses(): AgentHarness[] {
|
|
112
122
|
return this.registry.list();
|
|
@@ -229,18 +239,22 @@ export class AgentRuntime {
|
|
|
229
239
|
const { sessionId, turnId, input, config } = request;
|
|
230
240
|
const agent = this.registry.getAgent(config.harness);
|
|
231
241
|
const transformer = this.registry.getAdapter(config.harness)({ sessionId });
|
|
232
|
-
const processor = new EventProcessor(sessionId, turnId, {
|
|
233
|
-
harness: config.harness,
|
|
234
|
-
model: config.model,
|
|
235
|
-
});
|
|
242
|
+
const processor = new EventProcessor(sessionId, turnId, { model: config.model });
|
|
236
243
|
|
|
237
244
|
// Isolate sink failures: a misbehaving transport must never break the
|
|
238
245
|
// turn's lifecycle bracketing (turn.started ... turn.ended).
|
|
239
246
|
const emit = async (e: LifecycleEvent) => {
|
|
240
247
|
try {
|
|
241
248
|
await sink.emit(e);
|
|
242
|
-
} catch {
|
|
243
|
-
//
|
|
249
|
+
} catch (err) {
|
|
250
|
+
// The turn must not fail on a misbehaving sink — but the host gets
|
|
251
|
+
// to know its sink dropped an event.
|
|
252
|
+
emitDiagnostic(this.options.onDiagnostic, {
|
|
253
|
+
type: "sinkError",
|
|
254
|
+
sessionId,
|
|
255
|
+
message: `sink emit failed for ${e.type}: ${err instanceof Error ? err.message : String(err)}`,
|
|
256
|
+
detail: err,
|
|
257
|
+
});
|
|
244
258
|
}
|
|
245
259
|
};
|
|
246
260
|
|
|
@@ -274,6 +288,16 @@ export class AgentRuntime {
|
|
|
274
288
|
permOpts?: { signal?: AbortSignal },
|
|
275
289
|
): Promise<PermissionDecision> => {
|
|
276
290
|
if (permOpts?.signal?.aborted) return { decision: "cancel" };
|
|
291
|
+
// A standing `*_always` rule from an earlier prompt in this session
|
|
292
|
+
// answers without a round-trip (and without fake prompt events). The
|
|
293
|
+
// rule carries the verdict only — an `updatedInput` edit belongs to the
|
|
294
|
+
// call it was made for, never to every later call to the same tool.
|
|
295
|
+
const standing = this.alwaysDecisions.get(sessionId)?.get(toolCall.toolName);
|
|
296
|
+
if (standing) {
|
|
297
|
+
return standing === "allow"
|
|
298
|
+
? { decision: "allow" }
|
|
299
|
+
: { decision: "deny", reason: "Denied by a standing rule" };
|
|
300
|
+
}
|
|
277
301
|
const requestId = generateUUIDv7();
|
|
278
302
|
// Register BEFORE emitting so even a sink that answers synchronously
|
|
279
303
|
// from its emit() finds the pending entry.
|
|
@@ -317,8 +341,26 @@ export class AgentRuntime {
|
|
|
317
341
|
const outcome = await outcomePromise;
|
|
318
342
|
if (outcome.outcome === "cancelled") return { decision: "cancel" };
|
|
319
343
|
const selected = PERMISSION_OPTIONS.find((o) => o.optionId === outcome.optionId);
|
|
320
|
-
|
|
321
|
-
|
|
344
|
+
const allowed = selected?.kind === "allow_once" || selected?.kind === "allow_always";
|
|
345
|
+
// An `*_always` answer becomes a standing per-session rule: later calls
|
|
346
|
+
// to the same tool skip the round-trip entirely (the user already gave
|
|
347
|
+
// a durable answer — re-asking would be noise, re-emitting events would
|
|
348
|
+
// fake a prompt that never happened). Cleared on session close.
|
|
349
|
+
if (selected?.kind === "allow_always" || selected?.kind === "reject_always") {
|
|
350
|
+
let rules = this.alwaysDecisions.get(sessionId);
|
|
351
|
+
if (!rules) {
|
|
352
|
+
rules = new Map();
|
|
353
|
+
this.alwaysDecisions.set(sessionId, rules);
|
|
354
|
+
}
|
|
355
|
+
rules.set(toolCall.toolName, allowed ? "allow" : "deny");
|
|
356
|
+
}
|
|
357
|
+
return allowed
|
|
358
|
+
? {
|
|
359
|
+
decision: "allow",
|
|
360
|
+
// What was approved is what runs (C1): an edited input replaces
|
|
361
|
+
// the original all the way into the harness.
|
|
362
|
+
...(outcome.updatedInput !== undefined && { updatedInput: outcome.updatedInput }),
|
|
363
|
+
}
|
|
322
364
|
: { decision: "deny", reason: "Denied by user" };
|
|
323
365
|
};
|
|
324
366
|
/** Cancellation / turn end resolves everything still pending as cancelled.
|
|
@@ -334,6 +376,11 @@ export class AgentRuntime {
|
|
|
334
376
|
|
|
335
377
|
await emit({ type: "turn.started", turnId, sessionId, timestamp: Date.now() });
|
|
336
378
|
|
|
379
|
+
// The user echo (spec §7.2): the submitted input goes back onto the stream
|
|
380
|
+
// as a complete user message (outputIndex 0) before any harness output, so
|
|
381
|
+
// the stream is a complete transcript and multi-client attach works.
|
|
382
|
+
for (const le of processor.echoUserMessage(input)) await emit(le);
|
|
383
|
+
|
|
337
384
|
const options: AgentExecuteOptions = {
|
|
338
385
|
sessionId,
|
|
339
386
|
turnId,
|
|
@@ -395,9 +442,9 @@ export class AgentRuntime {
|
|
|
395
442
|
type: "error",
|
|
396
443
|
turnId,
|
|
397
444
|
sessionId,
|
|
398
|
-
|
|
445
|
+
category,
|
|
446
|
+
message,
|
|
399
447
|
recoverable: isRecoverable(category),
|
|
400
|
-
code: category,
|
|
401
448
|
stack: err instanceof Error ? err.stack : undefined,
|
|
402
449
|
timestamp: Date.now(),
|
|
403
450
|
});
|
|
@@ -453,6 +500,7 @@ export class AgentRuntime {
|
|
|
453
500
|
/** Dispose one idle logical session and its harness-native resources. */
|
|
454
501
|
async closeSession(harness: AgentHarness, sessionId: string): Promise<void> {
|
|
455
502
|
this.completedTurns.delete(sessionId);
|
|
503
|
+
this.alwaysDecisions.delete(sessionId);
|
|
456
504
|
await Promise.all(
|
|
457
505
|
[...this.pendingPermissions.values()]
|
|
458
506
|
.filter((pending) => pending.sessionId === sessionId)
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
classifyError,
|
|
3
|
+
createUserEchoParts,
|
|
4
|
+
echoMessageId,
|
|
5
|
+
generateUUIDv7,
|
|
6
|
+
} from "../../protocol/index.ts";
|
|
7
|
+
import type { AgentInput, Delta, LifecycleEvent, Part, StopReason } from "../../protocol/index.ts";
|
|
3
8
|
import type { AdapterEvent, TransformResult } from "../agents/types.ts";
|
|
4
9
|
|
|
5
10
|
/** Where a part lives on the wire — fixed at first emission for the whole turn. */
|
|
@@ -21,7 +26,7 @@ interface PartAddress {
|
|
|
21
26
|
*/
|
|
22
27
|
export class EventProcessor {
|
|
23
28
|
private openMessageId?: string;
|
|
24
|
-
/** `
|
|
29
|
+
/** `parentToolCallId` of the open message (undefined for top-level messages). */
|
|
25
30
|
private openMessageParent?: string;
|
|
26
31
|
private currentOutputIndex = -1;
|
|
27
32
|
private nextOutputIndex = 0;
|
|
@@ -31,9 +36,24 @@ export class EventProcessor {
|
|
|
31
36
|
constructor(
|
|
32
37
|
private readonly sessionId: string,
|
|
33
38
|
private readonly turnId: string,
|
|
34
|
-
private readonly meta: {
|
|
39
|
+
private readonly meta: { model?: string } = {},
|
|
35
40
|
) {}
|
|
36
41
|
|
|
42
|
+
/**
|
|
43
|
+
* The user echo: emits the submitted input back onto the stream as a
|
|
44
|
+
* complete user message at outputIndex 0, before any harness output — the
|
|
45
|
+
* stream is a complete transcript. Its messageId is DERIVED from the turn
|
|
46
|
+
* id (`echoMessageId`), so a consumer that minted the turnId can predict the
|
|
47
|
+
* whole echo instead of reconciling a look-alike against it.
|
|
48
|
+
*/
|
|
49
|
+
*echoUserMessage(input: AgentInput): Generator<LifecycleEvent> {
|
|
50
|
+
yield* this.openMessage("user", undefined, echoMessageId(this.turnId));
|
|
51
|
+
for (const part of createUserEchoParts(input, this.turnId)) {
|
|
52
|
+
yield* this.emitPart(part);
|
|
53
|
+
}
|
|
54
|
+
yield* this.closeMessage();
|
|
55
|
+
}
|
|
56
|
+
|
|
37
57
|
*handle(ev: AdapterEvent): Generator<LifecycleEvent> {
|
|
38
58
|
switch (ev.kind) {
|
|
39
59
|
case "message-start":
|
|
@@ -47,18 +67,18 @@ export class EventProcessor {
|
|
|
47
67
|
yield* this.emitPart(ev.part);
|
|
48
68
|
return;
|
|
49
69
|
case "text-delta": {
|
|
50
|
-
const e = this.delta(ev.partId, { type: "text
|
|
70
|
+
const e = this.delta(ev.partId, { type: "text", text: ev.text });
|
|
51
71
|
if (e) yield e;
|
|
52
72
|
return;
|
|
53
73
|
}
|
|
54
74
|
case "reasoning-delta": {
|
|
55
|
-
const e = this.delta(ev.partId, { type: "reasoning
|
|
75
|
+
const e = this.delta(ev.partId, { type: "reasoning", text: ev.text });
|
|
56
76
|
if (e) yield e;
|
|
57
77
|
return;
|
|
58
78
|
}
|
|
59
79
|
case "tool-input-delta": {
|
|
60
80
|
const e = this.delta(ev.partId, {
|
|
61
|
-
type: "
|
|
81
|
+
type: "tool_input",
|
|
62
82
|
toolCallId: ev.toolCallId,
|
|
63
83
|
toolName: ev.toolName,
|
|
64
84
|
input: ev.input,
|
|
@@ -78,10 +98,14 @@ export class EventProcessor {
|
|
|
78
98
|
};
|
|
79
99
|
return;
|
|
80
100
|
case "compacted":
|
|
101
|
+
// Harnesses report compaction after the fact — a single completed
|
|
102
|
+
// upsert of the positional entity (its first appearance anchors it).
|
|
81
103
|
yield {
|
|
82
|
-
type: "session.
|
|
104
|
+
type: "session.compaction",
|
|
83
105
|
sessionId: this.sessionId,
|
|
84
106
|
turnId: this.turnId,
|
|
107
|
+
compactionId: generateUUIDv7(),
|
|
108
|
+
status: "completed",
|
|
85
109
|
...(ev.trigger && { trigger: ev.trigger }),
|
|
86
110
|
...(ev.preTokens !== undefined && { preTokens: ev.preTokens }),
|
|
87
111
|
...(ev.postTokens !== undefined && { postTokens: ev.postTokens }),
|
|
@@ -103,7 +127,7 @@ export class EventProcessor {
|
|
|
103
127
|
cost: result.cost,
|
|
104
128
|
error:
|
|
105
129
|
result.error && !result.cancelled
|
|
106
|
-
? {
|
|
130
|
+
? { category: classifyError(result.error), message: result.error }
|
|
107
131
|
: undefined,
|
|
108
132
|
timestamp: Date.now(),
|
|
109
133
|
};
|
|
@@ -111,29 +135,28 @@ export class EventProcessor {
|
|
|
111
135
|
|
|
112
136
|
private *openMessage(
|
|
113
137
|
role: "assistant" | "user",
|
|
114
|
-
|
|
138
|
+
parentToolCallId?: string,
|
|
139
|
+
/** Derived id for the user echo; harness messages get a fresh UUIDv7. */
|
|
140
|
+
id?: string,
|
|
115
141
|
): Generator<LifecycleEvent> {
|
|
116
142
|
if (this.openMessageId) yield* this.closeMessage();
|
|
117
|
-
const messageId = generateUUIDv7();
|
|
143
|
+
const messageId = id ?? generateUUIDv7();
|
|
118
144
|
this.openMessageId = messageId;
|
|
119
|
-
this.openMessageParent =
|
|
145
|
+
this.openMessageParent = parentToolCallId;
|
|
120
146
|
this.currentOutputIndex = this.nextOutputIndex++;
|
|
121
147
|
this.nextPartIndex = 0;
|
|
122
148
|
yield {
|
|
123
149
|
type: "message.started",
|
|
150
|
+
sessionId: this.sessionId,
|
|
124
151
|
turnId: this.turnId,
|
|
125
152
|
messageId,
|
|
126
153
|
outputIndex: this.currentOutputIndex,
|
|
127
154
|
role,
|
|
128
155
|
// A parented message is a sub-agent's output — nests under its tool call,
|
|
129
156
|
// not a top-level model message (see DESIGN.md D5).
|
|
130
|
-
...(
|
|
157
|
+
...(parentToolCallId && { parentToolCallId }),
|
|
158
|
+
...(this.meta.model && { model: this.meta.model }),
|
|
131
159
|
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
160
|
};
|
|
138
161
|
}
|
|
139
162
|
|
|
@@ -141,6 +164,7 @@ export class EventProcessor {
|
|
|
141
164
|
if (!this.openMessageId) return;
|
|
142
165
|
yield {
|
|
143
166
|
type: "message.ended",
|
|
167
|
+
sessionId: this.sessionId,
|
|
144
168
|
turnId: this.turnId,
|
|
145
169
|
messageId: this.openMessageId,
|
|
146
170
|
timestamp: Date.now(),
|
|
@@ -150,25 +174,25 @@ export class EventProcessor {
|
|
|
150
174
|
}
|
|
151
175
|
|
|
152
176
|
/** Ensure an open message whose parent matches the incoming part. A part
|
|
153
|
-
* whose `
|
|
177
|
+
* whose `parentToolCallId` differs from the open message (main↔sub-agent, or
|
|
154
178
|
* between sibling sub-agents) starts a new message so sub-agent output is
|
|
155
179
|
* grouped under its own parented message rather than mixed into another. */
|
|
156
|
-
private *ensureMessage(
|
|
157
|
-
if (this.openMessageId && this.openMessageParent !==
|
|
180
|
+
private *ensureMessage(parentToolCallId?: string): Generator<LifecycleEvent> {
|
|
181
|
+
if (this.openMessageId && this.openMessageParent !== parentToolCallId) {
|
|
158
182
|
yield* this.closeMessage();
|
|
159
183
|
}
|
|
160
|
-
if (!this.openMessageId) yield* this.openMessage("assistant",
|
|
184
|
+
if (!this.openMessageId) yield* this.openMessage("assistant", parentToolCallId);
|
|
161
185
|
}
|
|
162
186
|
|
|
163
187
|
/** Resolve (or assign) the wire address of a part. Yields a message.started
|
|
164
188
|
* first when the part is new and no matching message is open. */
|
|
165
189
|
private *addressFor(
|
|
166
190
|
partId: string,
|
|
167
|
-
|
|
191
|
+
parentToolCallId?: string,
|
|
168
192
|
): Generator<LifecycleEvent, PartAddress> {
|
|
169
193
|
const existing = this.partAddressById.get(partId);
|
|
170
194
|
if (existing) return existing;
|
|
171
|
-
yield* this.ensureMessage(
|
|
195
|
+
yield* this.ensureMessage(parentToolCallId);
|
|
172
196
|
const address: PartAddress = {
|
|
173
197
|
messageId: this.openMessageId as string,
|
|
174
198
|
outputIndex: this.currentOutputIndex,
|
|
@@ -179,7 +203,7 @@ export class EventProcessor {
|
|
|
179
203
|
}
|
|
180
204
|
|
|
181
205
|
private *emitPart(part: Part): Generator<LifecycleEvent> {
|
|
182
|
-
const address = yield* this.addressFor(part.id, part.
|
|
206
|
+
const address = yield* this.addressFor(part.id, part.parentToolCallId);
|
|
183
207
|
// Snapshot: adapters mutate their part objects in place across
|
|
184
208
|
// open/update, so we must clone at emit time or buffered consumers would
|
|
185
209
|
// all observe the final state. Stamp ownership so the part is
|
|
@@ -189,12 +213,12 @@ export class EventProcessor {
|
|
|
189
213
|
snapshot.messageId = address.messageId;
|
|
190
214
|
yield {
|
|
191
215
|
type: "message.part",
|
|
216
|
+
sessionId: this.sessionId,
|
|
192
217
|
turnId: this.turnId,
|
|
193
218
|
messageId: address.messageId,
|
|
194
219
|
outputIndex: address.outputIndex,
|
|
195
220
|
partIndex: address.partIndex,
|
|
196
221
|
part: snapshot,
|
|
197
|
-
...(snapshot.parentToolUseId && { parentToolUseId: snapshot.parentToolUseId }),
|
|
198
222
|
timestamp: Date.now(),
|
|
199
223
|
};
|
|
200
224
|
}
|
|
@@ -206,6 +230,7 @@ export class EventProcessor {
|
|
|
206
230
|
if (!address) return null;
|
|
207
231
|
return {
|
|
208
232
|
type: "message.part.delta",
|
|
233
|
+
sessionId: this.sessionId,
|
|
209
234
|
turnId: this.turnId,
|
|
210
235
|
messageId: address.messageId,
|
|
211
236
|
outputIndex: address.outputIndex,
|
package/src/protocol/config.ts
CHANGED
|
@@ -4,17 +4,19 @@ import { AgentInputSchema } from "./part-input.ts";
|
|
|
4
4
|
import { ThinkingLevelSchema } from "./thinking.ts";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Permission posture for a turn. `
|
|
7
|
+
* Permission posture for a turn. `bypass_permissions` auto-approves every
|
|
8
8
|
* tool (the right default inside a sandbox); `plan` is read-only planning;
|
|
9
|
-
* `
|
|
10
|
-
* their normal (non-dangerous) level.
|
|
9
|
+
* `dont_ask` never prompts — unapproved tools are denied, sandboxes stay at
|
|
10
|
+
* their normal (non-dangerous) level. Values follow the casing law (snake);
|
|
11
|
+
* adapters map to provider vocabulary at the edge (Claude SDK: acceptEdits,
|
|
12
|
+
* bypassPermissions).
|
|
11
13
|
*/
|
|
12
14
|
export const PermissionModeSchema = z.enum([
|
|
13
15
|
"default",
|
|
14
16
|
"plan",
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
17
|
+
"accept_edits",
|
|
18
|
+
"dont_ask",
|
|
19
|
+
"bypass_permissions",
|
|
18
20
|
]);
|
|
19
21
|
export type PermissionMode = z.infer<typeof PermissionModeSchema>;
|
|
20
22
|
|
|
@@ -1,20 +1,42 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { openVocabulary } from "./vocabulary.ts";
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
+
* The one error vocabulary (Law: one shape per concept). Used by
|
|
6
|
+
* `turn.ended.error`, the standalone `error` event, and `RunSummary.error`.
|
|
7
|
+
*
|
|
8
|
+
* OPEN (Law 3): products may extend with their own categories (deus adds
|
|
9
|
+
* "db_write"); unknown non-`_` values are reserved for this protocol.
|
|
10
|
+
* `usage_limit` (plan/quota exhausted) and `rate_limit` (429/backoff) are
|
|
11
|
+
* deliberately distinct — they need different user guidance; collapsing them
|
|
12
|
+
* is a display choice, never a wire merge.
|
|
5
13
|
*/
|
|
6
14
|
export const ERROR_CATEGORIES = [
|
|
7
15
|
"auth",
|
|
8
16
|
"rate_limit",
|
|
17
|
+
"usage_limit",
|
|
9
18
|
"context_limit",
|
|
10
19
|
"network",
|
|
11
20
|
"abort",
|
|
21
|
+
"invalid_request",
|
|
12
22
|
"process_exit",
|
|
13
|
-
"
|
|
14
|
-
"unknown",
|
|
23
|
+
"internal",
|
|
15
24
|
] as const;
|
|
16
|
-
export type ErrorCategory = (typeof ERROR_CATEGORIES)[number];
|
|
25
|
+
export type ErrorCategory = (typeof ERROR_CATEGORIES)[number] | (string & {});
|
|
26
|
+
export const ErrorCategorySchema = openVocabulary<ErrorCategory>();
|
|
27
|
+
|
|
28
|
+
export const ErrorInfoSchema = z.object({
|
|
29
|
+
category: ErrorCategorySchema,
|
|
30
|
+
message: z.string(),
|
|
31
|
+
});
|
|
32
|
+
export type ErrorInfo = z.infer<typeof ErrorInfoSchema>;
|
|
17
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Maps a raw harness error into a stable category, so callers can react
|
|
36
|
+
* (retry on `network`/`rate_limit`, surface `auth` prominently, treat `abort`
|
|
37
|
+
* as success). Rule order matters. Distilled from deus-machine's lifecycle
|
|
38
|
+
* classifier; lives in /protocol because categories are vocabulary.
|
|
39
|
+
*/
|
|
18
40
|
const RULES: ReadonlyArray<[ErrorCategory, readonly string[]]> = [
|
|
19
41
|
["abort", ["aborted", "aborterror", "cancelled", "canceled", "interrupted", "sigint"]],
|
|
20
42
|
[
|
|
@@ -46,6 +68,10 @@ const RULES: ReadonlyArray<[ErrorCategory, readonly string[]]> = [
|
|
|
46
68
|
"socket hang up",
|
|
47
69
|
],
|
|
48
70
|
],
|
|
71
|
+
[
|
|
72
|
+
"invalid_request",
|
|
73
|
+
["invalid request", "invalid_request_error", "status 400", "http 400", "bad request"],
|
|
74
|
+
],
|
|
49
75
|
["process_exit", ["exited with code", "process exited", "spawn", "enoent", "killed"]],
|
|
50
76
|
];
|
|
51
77
|
|
|
@@ -54,7 +80,7 @@ export function classifyError(error: unknown): ErrorCategory {
|
|
|
54
80
|
for (const [category, needles] of RULES) {
|
|
55
81
|
if (needles.some((n) => message.includes(n))) return category;
|
|
56
82
|
}
|
|
57
|
-
return "
|
|
83
|
+
return "internal";
|
|
58
84
|
}
|
|
59
85
|
|
|
60
86
|
/** Whether a turn can plausibly be retried after this error. */
|