@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
|
@@ -3,9 +3,11 @@ import {
|
|
|
3
3
|
type ReasoningPart,
|
|
4
4
|
type StopReason,
|
|
5
5
|
type StreamContext,
|
|
6
|
+
type SubagentMetadata,
|
|
6
7
|
type TextPart,
|
|
7
8
|
type TokenUsage,
|
|
8
9
|
type ToolPart,
|
|
10
|
+
type ToolResultContent,
|
|
9
11
|
appendToolInput,
|
|
10
12
|
completeToolPart,
|
|
11
13
|
createPendingToolPart,
|
|
@@ -41,6 +43,8 @@ interface StreamEvent {
|
|
|
41
43
|
type?: string;
|
|
42
44
|
text?: string;
|
|
43
45
|
thinking?: string;
|
|
46
|
+
/** Withheld-thinking placeholder: running size of a thought whose text never streams. */
|
|
47
|
+
estimated_tokens?: number;
|
|
44
48
|
partial_json?: string;
|
|
45
49
|
stop_reason?: string;
|
|
46
50
|
};
|
|
@@ -150,6 +154,61 @@ function stringifyToolResult(content: unknown): string {
|
|
|
150
154
|
return JSON.stringify(content);
|
|
151
155
|
}
|
|
152
156
|
|
|
157
|
+
/**
|
|
158
|
+
* The display-grade view of a tool_result's content blocks (spec §3.4): the
|
|
159
|
+
* flattened string stays the model-facing record, this preserves what
|
|
160
|
+
* flattening drops — images especially, which contribute "" to the string.
|
|
161
|
+
* Block shapes we can't express are skipped, never guessed.
|
|
162
|
+
*/
|
|
163
|
+
function toolResultContent(content: unknown): ToolResultContent[] | undefined {
|
|
164
|
+
if (!Array.isArray(content)) return undefined;
|
|
165
|
+
const out: ToolResultContent[] = [];
|
|
166
|
+
for (const raw of content) {
|
|
167
|
+
if (!raw || typeof raw !== "object") continue;
|
|
168
|
+
const block = raw as {
|
|
169
|
+
type?: unknown;
|
|
170
|
+
text?: unknown;
|
|
171
|
+
source?: { data?: unknown; media_type?: unknown } | null;
|
|
172
|
+
};
|
|
173
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
174
|
+
out.push({ type: "text", text: block.text });
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (block.type === "image") {
|
|
178
|
+
const { data, media_type } = block.source ?? {};
|
|
179
|
+
// Only the base64 source form is expressible — ToolResultContent has no url variant.
|
|
180
|
+
if (typeof data === "string" && typeof media_type === "string") {
|
|
181
|
+
out.push({ type: "image", data, mimeType: media_type });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return out.length ? out : undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* SubagentMetadata from a spawning tool's input, shared by the streamed and
|
|
190
|
+
* non-streaming paths. `subagent_type` in the input identifies a spawn on any
|
|
191
|
+
* tool name; `Task` is Claude's own spawn tool and counts even before its
|
|
192
|
+
* input parses into anything useful (presence of `subagent` is what promotes
|
|
193
|
+
* the part to `kind: "task"` — see protocol §3.5).
|
|
194
|
+
*/
|
|
195
|
+
function subagentFromInput(
|
|
196
|
+
toolName: string,
|
|
197
|
+
input: Record<string, unknown>,
|
|
198
|
+
): SubagentMetadata | undefined {
|
|
199
|
+
const str = (key: string): string | undefined =>
|
|
200
|
+
typeof input[key] === "string" ? (input[key] as string) : undefined;
|
|
201
|
+
const type = str("subagent_type");
|
|
202
|
+
if (type === undefined && toolName !== "Task") return undefined;
|
|
203
|
+
const description = str("description");
|
|
204
|
+
const model = str("model");
|
|
205
|
+
return {
|
|
206
|
+
...(type !== undefined && { type }),
|
|
207
|
+
...(description !== undefined && { description }),
|
|
208
|
+
...(model !== undefined && { model }),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
153
212
|
export class ClaudeCodeTransformer implements EventTransformer<unknown> {
|
|
154
213
|
private readonly ctx: StreamContext;
|
|
155
214
|
/** Anthropic content blocks of the in-flight message, by stream index. */
|
|
@@ -237,7 +296,10 @@ export class ClaudeCodeTransformer implements EventTransformer<unknown> {
|
|
|
237
296
|
}
|
|
238
297
|
|
|
239
298
|
private ctxFor(): StreamContext {
|
|
240
|
-
|
|
299
|
+
// `parentToolCallId` is the protocol spelling; Claude's own wire field is
|
|
300
|
+
// `parent_tool_use_id`. Spelling this the provider's way silently dropped
|
|
301
|
+
// every subagent linkage (a conditional spread dodges excess-property checks).
|
|
302
|
+
return { ...this.ctx, ...(this.currentParent ? { parentToolCallId: this.currentParent } : {}) };
|
|
241
303
|
}
|
|
242
304
|
|
|
243
305
|
/**
|
|
@@ -264,24 +326,25 @@ export class ClaudeCodeTransformer implements EventTransformer<unknown> {
|
|
|
264
326
|
for (const block of content) {
|
|
265
327
|
if (block.type === "text" && block.text) {
|
|
266
328
|
events.push({ kind: "part-open", part: createTextPart(this.ctxFor(), block.text, false) });
|
|
267
|
-
} else if (
|
|
268
|
-
|
|
269
|
-
block
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
329
|
+
} else if (block.type === "thinking" || block.type === "redacted_thinking") {
|
|
330
|
+
const redacted = block.type === "redacted_thinking";
|
|
331
|
+
// A redacted block carries no plaintext (its `data` is an encrypted
|
|
332
|
+
// blob), so emit it on the flag alone — the redaction is the signal.
|
|
333
|
+
if (!block.thinking && !redacted) continue;
|
|
334
|
+
// A complete block opens and closes at once: one stamp for both ends.
|
|
335
|
+
const now = Date.now();
|
|
336
|
+
const part = createReasoningPart(this.ctxFor(), block.thinking ?? "", false);
|
|
337
|
+
part.time = { start: now, end: now };
|
|
338
|
+
if (redacted) part.providerMetadata = { redacted: true };
|
|
339
|
+
events.push({ kind: "part-open", part });
|
|
275
340
|
} else if (block.type === "tool_use" && block.id) {
|
|
276
341
|
const name = block.name ?? "tool";
|
|
277
342
|
const input = block.input ?? {};
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
claudeToolMeta(name, input),
|
|
284
|
-
);
|
|
343
|
+
const subagent = subagentFromInput(name, input);
|
|
344
|
+
const part = createToolPart(this.ctxFor(), block.id, name, input, {
|
|
345
|
+
...claudeToolMeta(name, input),
|
|
346
|
+
...(subagent && { subagent }),
|
|
347
|
+
});
|
|
285
348
|
this.toolPartsById.set(block.id, part);
|
|
286
349
|
events.push({ kind: "part-open", part });
|
|
287
350
|
}
|
|
@@ -290,17 +353,17 @@ export class ClaudeCodeTransformer implements EventTransformer<unknown> {
|
|
|
290
353
|
return [...gauge, ...events];
|
|
291
354
|
}
|
|
292
355
|
|
|
293
|
-
private handleStream(event: StreamEvent,
|
|
356
|
+
private handleStream(event: StreamEvent, parentToolCallId?: string): AdapterEvent[] {
|
|
294
357
|
// Sticky for the whole turn: any stream event means the trailing complete
|
|
295
358
|
// `assistant` snapshot is a duplicate of what we streamed.
|
|
296
359
|
this.hasStreamed = true;
|
|
297
360
|
switch (event.type) {
|
|
298
361
|
case "message_start":
|
|
299
362
|
this.blocks.clear();
|
|
300
|
-
this.currentParent =
|
|
363
|
+
this.currentParent = parentToolCallId;
|
|
301
364
|
// Each top-level model message becomes a wire message. Sub-agent
|
|
302
|
-
// messages don't open one — their parts nest via
|
|
303
|
-
return
|
|
365
|
+
// messages don't open one — their parts nest via parentToolCallId.
|
|
366
|
+
return parentToolCallId ? [] : [{ kind: "message-start", role: "assistant" }];
|
|
304
367
|
case "content_block_start":
|
|
305
368
|
return this.openBlock(event);
|
|
306
369
|
case "content_block_delta":
|
|
@@ -313,7 +376,7 @@ export class ClaudeCodeTransformer implements EventTransformer<unknown> {
|
|
|
313
376
|
if (event.usage?.output_tokens) this.usage.output = event.usage.output_tokens;
|
|
314
377
|
return [];
|
|
315
378
|
case "message_stop":
|
|
316
|
-
return
|
|
379
|
+
return parentToolCallId || this.currentParent ? [] : [{ kind: "message-end" }];
|
|
317
380
|
default:
|
|
318
381
|
return [];
|
|
319
382
|
}
|
|
@@ -330,6 +393,11 @@ export class ClaudeCodeTransformer implements EventTransformer<unknown> {
|
|
|
330
393
|
}
|
|
331
394
|
if (cb.type === "thinking" || cb.type === "redacted_thinking") {
|
|
332
395
|
const part = createReasoningPart(this.ctxFor(), "", true);
|
|
396
|
+
// Thought duration starts here; content_block_stop closes it.
|
|
397
|
+
part.time = { start: Date.now() };
|
|
398
|
+
// The plaintext never arrives for a redacted block — keep the flag so a
|
|
399
|
+
// consumer can render "[redacted]" instead of an empty thought.
|
|
400
|
+
if (cb.type === "redacted_thinking") part.providerMetadata = { redacted: true };
|
|
333
401
|
this.blocks.set(index, { partId: part.id, kind: "reasoning", part });
|
|
334
402
|
return [{ kind: "part-open", part }];
|
|
335
403
|
}
|
|
@@ -351,8 +419,18 @@ export class ClaudeCodeTransformer implements EventTransformer<unknown> {
|
|
|
351
419
|
(entry.part as TextPart).text += d.text;
|
|
352
420
|
return [{ kind: "text-delta", partId: entry.partId, text: d.text }];
|
|
353
421
|
}
|
|
354
|
-
if (d.type === "thinking_delta" && entry.kind === "reasoning"
|
|
355
|
-
|
|
422
|
+
if (d.type === "thinking_delta" && entry.kind === "reasoning") {
|
|
423
|
+
const reasoning = entry.part as ReasoningPart;
|
|
424
|
+
// Withheld thinking streams placeholder deltas: a token estimate, no
|
|
425
|
+
// text. Keep the estimate so a text-less thought still has a size.
|
|
426
|
+
if (typeof d.estimated_tokens === "number") {
|
|
427
|
+
reasoning.providerMetadata = {
|
|
428
|
+
...reasoning.providerMetadata,
|
|
429
|
+
estimatedThinkingTokens: d.estimated_tokens,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
if (!d.thinking) return [];
|
|
433
|
+
reasoning.text += d.thinking;
|
|
356
434
|
return [{ kind: "reasoning-delta", partId: entry.partId, text: d.thinking }];
|
|
357
435
|
}
|
|
358
436
|
if (d.type === "input_json_delta" && entry.kind === "tool" && d.partial_json) {
|
|
@@ -375,15 +453,25 @@ export class ClaudeCodeTransformer implements EventTransformer<unknown> {
|
|
|
375
453
|
const entry = this.blocks.get(event.index ?? 0);
|
|
376
454
|
if (!entry) return [];
|
|
377
455
|
if (entry.kind === "text" || entry.kind === "reasoning") {
|
|
378
|
-
|
|
379
|
-
|
|
456
|
+
const part = entry.part as TextPart | ReasoningPart;
|
|
457
|
+
part.state = "done";
|
|
458
|
+
if (entry.kind === "reasoning") {
|
|
459
|
+
const reasoning = part as ReasoningPart;
|
|
460
|
+
reasoning.time = { start: reasoning.time?.start ?? Date.now(), end: Date.now() };
|
|
461
|
+
}
|
|
462
|
+
return [{ kind: "part-update", part }];
|
|
380
463
|
}
|
|
381
464
|
const tool = entry.part as ToolPart;
|
|
382
465
|
const partial = tool.state.status === "pending" ? tool.state.partialInput : "";
|
|
383
466
|
const input = safeParseJson(partial);
|
|
384
467
|
startToolPart(tool, input);
|
|
468
|
+
// The streamed open carried no input, so subagent metadata can only be
|
|
469
|
+
// read now that the partial JSON has parsed.
|
|
385
470
|
const locations = toolLocationsFromInput(input);
|
|
386
|
-
|
|
471
|
+
const subagent = subagentFromInput(tool.toolName, input);
|
|
472
|
+
if (locations || subagent) {
|
|
473
|
+
setToolMeta(tool, { ...(locations && { locations }), ...(subagent && { subagent }) });
|
|
474
|
+
}
|
|
387
475
|
return [{ kind: "part-update", part: tool }];
|
|
388
476
|
}
|
|
389
477
|
|
|
@@ -394,9 +482,11 @@ export class ClaudeCodeTransformer implements EventTransformer<unknown> {
|
|
|
394
482
|
if (block.type !== "tool_result" || !block.tool_use_id) continue;
|
|
395
483
|
const tool = this.toolPartsById.get(block.tool_use_id);
|
|
396
484
|
if (!tool) continue;
|
|
485
|
+
const content = toolResultContent(block.content);
|
|
397
486
|
completeToolPart(tool, {
|
|
398
487
|
output: stringifyToolResult(block.content),
|
|
399
488
|
isError: block.is_error === true,
|
|
489
|
+
...(content && { content }),
|
|
400
490
|
});
|
|
401
491
|
events.push({ kind: "part-update", part: tool });
|
|
402
492
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
|
2
2
|
import type { McpSetServersResult } from "@anthropic-ai/claude-agent-sdk";
|
|
3
3
|
import type { AgentCapabilities, AgentInput, McpServerConfig } from "../../../protocol/index.ts";
|
|
4
|
+
import { type DiagnosticHandler, emitDiagnostic } from "../../diagnostics.ts";
|
|
4
5
|
import type { AgentExecuteOptions, CancelResult, RawAgentEvent } from "../base.ts";
|
|
5
6
|
import { BaseAgent } from "../base.ts";
|
|
6
7
|
import type {
|
|
7
8
|
ClaudeHooksFactory,
|
|
9
|
+
ClaudeSessionEndReason,
|
|
8
10
|
ClaudeSessionExtras,
|
|
9
11
|
ClaudeToolPolicy,
|
|
10
12
|
} from "./generator-session.ts";
|
|
@@ -30,13 +32,13 @@ function toClaudeContent(input: AgentInput): ClaudeContent {
|
|
|
30
32
|
if (part.type === "text") return { type: "text", text: part.text };
|
|
31
33
|
const source = part.url
|
|
32
34
|
? { type: "url", url: part.url }
|
|
33
|
-
: { type: "base64", media_type: part.
|
|
35
|
+
: { type: "base64", media_type: part.mimeType, data: part.data ?? "" };
|
|
34
36
|
// `file` inputs (PDFs etc.) are document blocks, not images.
|
|
35
37
|
return { type: part.type === "file" ? "document" : "image", source };
|
|
36
38
|
});
|
|
37
39
|
// Kept cast: the SDK's block sources require literal media_type unions
|
|
38
40
|
// ("image/jpeg" | ... and "application/pdf") while our PartInput carries an
|
|
39
|
-
// open string
|
|
41
|
+
// open string mimeType, so these blocks cannot satisfy the union as typed.
|
|
40
42
|
return blocks as unknown as ClaudeContent;
|
|
41
43
|
}
|
|
42
44
|
|
|
@@ -96,6 +98,18 @@ export interface ClaudeCodeAgentOptions {
|
|
|
96
98
|
toolPolicy?: ClaudeToolPolicy;
|
|
97
99
|
/** SDK lifecycle hooks factory (decision-capable). Operator embed-tier only. */
|
|
98
100
|
hooks?: ClaudeHooksFactory;
|
|
101
|
+
/**
|
|
102
|
+
* Called exactly once when a live session's subprocess ends, with why:
|
|
103
|
+
* `idle` (idle-timeout eviction), `replaced` (config change forced a
|
|
104
|
+
* restart), `released` (explicit release/close), `shutdown`
|
|
105
|
+
* (terminateAll). The seam for host-managed per-session resources — BYOK
|
|
106
|
+
* proxy keys, recorders — instead of re-deriving termination from side
|
|
107
|
+
* effects. NOTE: `replaced` sessions usually respawn immediately with
|
|
108
|
+
* context preserved; drop only resources bound to the dead subprocess.
|
|
109
|
+
*/
|
|
110
|
+
onSessionEnd?: (sessionId: string, reason: ClaudeSessionEndReason) => void;
|
|
111
|
+
/** Operational diagnostics (interrupt timeouts, resume fallbacks). */
|
|
112
|
+
onDiagnostic?: DiagnosticHandler;
|
|
99
113
|
/**
|
|
100
114
|
* Raw SDK option overrides merged over the engine's options at session
|
|
101
115
|
* spawn (embedder escape hatch for SDK surface the engine does not model:
|
|
@@ -113,10 +127,11 @@ export interface ClaudeCodeAgentOptions {
|
|
|
113
127
|
export class ClaudeCodeAgent extends BaseAgent {
|
|
114
128
|
readonly harness = "claude-code" as const;
|
|
115
129
|
readonly capabilities = CAPABILITIES;
|
|
116
|
-
private readonly manager
|
|
130
|
+
private readonly manager: ClaudeSessionManager;
|
|
117
131
|
|
|
118
132
|
constructor(private readonly agentOptions: ClaudeCodeAgentOptions = {}) {
|
|
119
133
|
super();
|
|
134
|
+
this.manager = new ClaudeSessionManager(agentOptions.onSessionEnd);
|
|
120
135
|
}
|
|
121
136
|
|
|
122
137
|
private sessionExtras(): ClaudeSessionExtras {
|
|
@@ -193,6 +208,12 @@ export class ClaudeCodeAgent extends BaseAgent {
|
|
|
193
208
|
}
|
|
194
209
|
|
|
195
210
|
if (!resumeFailed) break;
|
|
211
|
+
emitDiagnostic(this.agentOptions.onDiagnostic, {
|
|
212
|
+
type: "resumeFallback",
|
|
213
|
+
sessionId: options.sessionId,
|
|
214
|
+
message: `resume of ${options.resumeSessionId} failed; re-running on a fresh session`,
|
|
215
|
+
detail: { resumeSessionId: options.resumeSessionId },
|
|
216
|
+
});
|
|
196
217
|
await this.manager.terminate(options.sessionId);
|
|
197
218
|
}
|
|
198
219
|
|
|
@@ -229,6 +250,13 @@ export class ClaudeCodeAgent extends BaseAgent {
|
|
|
229
250
|
const base = await super.cancel(sessionId);
|
|
230
251
|
if (!session || !base.hadTurn) return base;
|
|
231
252
|
const interrupted = await session.interruptTurn();
|
|
253
|
+
if (!interrupted) {
|
|
254
|
+
emitDiagnostic(this.agentOptions.onDiagnostic, {
|
|
255
|
+
type: "interruptTimeout",
|
|
256
|
+
sessionId,
|
|
257
|
+
message: "cancel dispatched but the SDK interrupt was not acknowledged in time",
|
|
258
|
+
});
|
|
259
|
+
}
|
|
232
260
|
return { confirmed: interrupted, hadTurn: true };
|
|
233
261
|
}
|
|
234
262
|
|
|
@@ -49,6 +49,9 @@ export type ClaudeHooksFactory = (ctx: {
|
|
|
49
49
|
}) => CCOptions["hooks"];
|
|
50
50
|
|
|
51
51
|
/** Embed-tier extras threaded from ClaudeCodeAgentOptions into each session. */
|
|
52
|
+
/** Why a live claude session's subprocess ended (see `onSessionEnd`). */
|
|
53
|
+
export type ClaudeSessionEndReason = "idle" | "replaced" | "released" | "shutdown";
|
|
54
|
+
|
|
52
55
|
export interface ClaudeSessionExtras {
|
|
53
56
|
/** Factory for in-process MCP servers; invoked ONCE at session spawn. */
|
|
54
57
|
sdkMcpServers?: (ctx: {
|
|
@@ -115,7 +118,7 @@ export class ClaudeGeneratorSession {
|
|
|
115
118
|
constructor(
|
|
116
119
|
readonly id: string,
|
|
117
120
|
config: ClaudeSessionConfig,
|
|
118
|
-
private readonly onTerminated?: () => void,
|
|
121
|
+
private readonly onTerminated?: (reason: ClaudeSessionEndReason) => void,
|
|
119
122
|
private readonly extras?: ClaudeSessionExtras,
|
|
120
123
|
) {
|
|
121
124
|
this.config = { ...config };
|
|
@@ -250,7 +253,7 @@ export class ClaudeGeneratorSession {
|
|
|
250
253
|
}
|
|
251
254
|
}
|
|
252
255
|
|
|
253
|
-
async terminate(): Promise<void> {
|
|
256
|
+
async terminate(reason: ClaudeSessionEndReason = "released"): Promise<void> {
|
|
254
257
|
if (this.state === "terminated") return;
|
|
255
258
|
this.clearIdleTimer();
|
|
256
259
|
this.state = "terminated";
|
|
@@ -265,7 +268,12 @@ export class ClaudeGeneratorSession {
|
|
|
265
268
|
// The SDK documents close() as the forceful subprocess/MCP cleanup path.
|
|
266
269
|
query?.close();
|
|
267
270
|
} finally {
|
|
268
|
-
|
|
271
|
+
try {
|
|
272
|
+
this.onTerminated?.(reason);
|
|
273
|
+
} catch {
|
|
274
|
+
// Observer failures (incl. the host's onSessionEnd) must never make
|
|
275
|
+
// terminate() reject — the idle timer calls it unawaited.
|
|
276
|
+
}
|
|
269
277
|
}
|
|
270
278
|
}
|
|
271
279
|
|
|
@@ -314,7 +322,7 @@ export class ClaudeGeneratorSession {
|
|
|
314
322
|
private resetIdleTimer(): void {
|
|
315
323
|
this.clearIdleTimer();
|
|
316
324
|
if (this.idleTimeoutMs > 0) {
|
|
317
|
-
this.idleTimer = setTimeout(() => void this.terminate(), this.idleTimeoutMs);
|
|
325
|
+
this.idleTimer = setTimeout(() => void this.terminate("idle"), this.idleTimeoutMs);
|
|
318
326
|
this.idleTimer.unref?.();
|
|
319
327
|
}
|
|
320
328
|
}
|
|
@@ -362,7 +370,10 @@ export function createCanUseTool(deps: {
|
|
|
362
370
|
{ signal },
|
|
363
371
|
);
|
|
364
372
|
return decision.decision === "allow"
|
|
365
|
-
?
|
|
373
|
+
? // What was approved is what runs: an edited input from the broker
|
|
374
|
+
// replaces the original (C1 — silent divergence between the approved
|
|
375
|
+
// and executed input is the worst failure mode in an approval path).
|
|
376
|
+
{ behavior: "allow", updatedInput: decision.updatedInput ?? input }
|
|
366
377
|
: {
|
|
367
378
|
behavior: "deny",
|
|
368
379
|
message: (decision.decision === "deny" && decision.reason) || "Denied by user",
|
|
@@ -68,13 +68,13 @@ export interface ClaudeSessionConfig {
|
|
|
68
68
|
*/
|
|
69
69
|
function toSdkPermissionMode(mode: PermissionMode | undefined): CCOptions["permissionMode"] {
|
|
70
70
|
switch (mode) {
|
|
71
|
-
case "
|
|
71
|
+
case "bypass_permissions":
|
|
72
72
|
return "bypassPermissions";
|
|
73
73
|
case "plan":
|
|
74
74
|
return "plan";
|
|
75
|
-
case "
|
|
75
|
+
case "accept_edits":
|
|
76
76
|
return "acceptEdits";
|
|
77
|
-
case "
|
|
77
|
+
case "dont_ask":
|
|
78
78
|
// Native SDK mode: never prompt, deny unapproved. Unlike
|
|
79
79
|
// bypassPermissions it needs no dangerous flag (and the CLI keeps
|
|
80
80
|
// extended thinking enabled, which bypass turns off).
|
|
@@ -157,5 +157,15 @@ export function buildClaudeOptions(
|
|
|
157
157
|
// ClaudeSdkOptionOverrides type excludes them anyway).
|
|
158
158
|
if (overrides) Object.assign(options, overrides);
|
|
159
159
|
|
|
160
|
+
// Fable-class models withhold thinking plaintext by default: blocks stream
|
|
161
|
+
// structure + token estimates only, so every reasoning part arrives with
|
|
162
|
+
// empty text. The hidden `--thinking-display summarized` flag opts into
|
|
163
|
+
// summary text (ignored by CLIs that don't honor it). Applied after operator
|
|
164
|
+
// overrides so an unrelated operator extraArgs can't drop it, while an
|
|
165
|
+
// explicit operator `thinking-display` key still wins.
|
|
166
|
+
if (config.thinkingLevel !== "off") {
|
|
167
|
+
options.extraArgs = { "thinking-display": "summarized", ...options.extraArgs };
|
|
168
|
+
}
|
|
169
|
+
|
|
160
170
|
return options;
|
|
161
171
|
}
|
|
@@ -2,7 +2,7 @@ import type { McpSetServersResult } from "@anthropic-ai/claude-agent-sdk";
|
|
|
2
2
|
import type { McpServerConfig } from "../../../protocol/index.ts";
|
|
3
3
|
import { configFingerprint } from "../config-fingerprint.ts";
|
|
4
4
|
import { ClaudeGeneratorSession } from "./generator-session.ts";
|
|
5
|
-
import type { ClaudeSessionExtras } from "./generator-session.ts";
|
|
5
|
+
import type { ClaudeSessionEndReason, ClaudeSessionExtras } from "./generator-session.ts";
|
|
6
6
|
import type { ClaudeSessionConfig } from "./options.ts";
|
|
7
7
|
|
|
8
8
|
/** Directory-set identity: order and duplicates don't change the sandbox surface. */
|
|
@@ -66,6 +66,10 @@ export function claudeRestartConfig(
|
|
|
66
66
|
export class ClaudeSessionManager {
|
|
67
67
|
private readonly sessions = new Map<string, ClaudeGeneratorSession>();
|
|
68
68
|
|
|
69
|
+
constructor(
|
|
70
|
+
private readonly onSessionEnd?: (sessionId: string, reason: ClaudeSessionEndReason) => void,
|
|
71
|
+
) {}
|
|
72
|
+
|
|
69
73
|
async getOrCreate(
|
|
70
74
|
sessionId: string,
|
|
71
75
|
config: ClaudeSessionConfig,
|
|
@@ -80,7 +84,7 @@ export class ClaudeSessionManager {
|
|
|
80
84
|
config,
|
|
81
85
|
existing.currentSessionId,
|
|
82
86
|
);
|
|
83
|
-
await existing.terminate();
|
|
87
|
+
await existing.terminate("replaced");
|
|
84
88
|
this.sessions.delete(sessionId);
|
|
85
89
|
} else {
|
|
86
90
|
await this.hotSwapIfNeeded(existing, config);
|
|
@@ -91,8 +95,9 @@ export class ClaudeSessionManager {
|
|
|
91
95
|
const session = new ClaudeGeneratorSession(
|
|
92
96
|
sessionId,
|
|
93
97
|
startConfig,
|
|
94
|
-
() => {
|
|
98
|
+
(reason) => {
|
|
95
99
|
if (this.sessions.get(sessionId) === session) this.sessions.delete(sessionId);
|
|
100
|
+
this.onSessionEnd?.(sessionId, reason);
|
|
96
101
|
},
|
|
97
102
|
extras,
|
|
98
103
|
);
|
|
@@ -119,7 +124,7 @@ export class ClaudeSessionManager {
|
|
|
119
124
|
}
|
|
120
125
|
|
|
121
126
|
async terminateAll(): Promise<void> {
|
|
122
|
-
await Promise.all([...this.sessions.values()].map((s) => s.terminate()));
|
|
127
|
+
await Promise.all([...this.sessions.values()].map((s) => s.terminate("shutdown")));
|
|
123
128
|
this.sessions.clear();
|
|
124
129
|
}
|
|
125
130
|
|
|
@@ -64,10 +64,10 @@ type UserInput = { type: "text"; text: string; text_elements: [] } | { type: "im
|
|
|
64
64
|
|
|
65
65
|
function mapSandbox(mode: PermissionMode | undefined): string {
|
|
66
66
|
switch (mode) {
|
|
67
|
-
case "
|
|
67
|
+
case "bypass_permissions":
|
|
68
68
|
return "danger-full-access";
|
|
69
|
-
case "
|
|
70
|
-
case "
|
|
69
|
+
case "accept_edits":
|
|
70
|
+
case "dont_ask":
|
|
71
71
|
// Never-ask posture, but inside the normal sandbox — danger-full is
|
|
72
72
|
// reserved for the explicitly dangerous mode.
|
|
73
73
|
return "workspace-write";
|
|
@@ -82,6 +82,14 @@ function toUserInput(input: AgentInput): UserInput[] {
|
|
|
82
82
|
for (const part of input) {
|
|
83
83
|
if (part.type === "text") out.push({ type: "text", text: part.text, text_elements: [] });
|
|
84
84
|
else if (part.url) out.push({ type: "image", url: part.url });
|
|
85
|
+
else if (part.type === "image" && part.data) {
|
|
86
|
+
// Codex's user input carries images by URL only; a pasted image arrives
|
|
87
|
+
// as base64 `data` and used to be dropped SILENTLY while the negotiated
|
|
88
|
+
// capability said supported. A data URL delivers it on the same field —
|
|
89
|
+
// and a Codex build that rejects it fails the turn loudly instead of
|
|
90
|
+
// the prompt losing its attachment with no trace.
|
|
91
|
+
out.push({ type: "image", url: `data:${part.mimeType};base64,${part.data}` });
|
|
92
|
+
}
|
|
85
93
|
}
|
|
86
94
|
return out.length ? out : [{ type: "text", text: "", text_elements: [] }];
|
|
87
95
|
}
|
|
@@ -260,8 +268,14 @@ export class CodexAppServerAgent extends BaseAgent {
|
|
|
260
268
|
const handler = session.permissionHandler;
|
|
261
269
|
if (!handler) return decline;
|
|
262
270
|
const decision = await handler(approvalToolCall(method, params));
|
|
271
|
+
// Codex's approval reply is a bare verdict — no slot for
|
|
272
|
+
// `decision.updatedInput`, so an edited input cannot be honoured here
|
|
273
|
+
// (only claude-code's `canUseTool` can execute the edit). An edited
|
|
274
|
+
// approval therefore DECLINES: accepting would run input nobody
|
|
275
|
+
// approved.
|
|
263
276
|
switch (decision.decision) {
|
|
264
277
|
case "allow":
|
|
278
|
+
if (decision.updatedInput !== undefined) return decline;
|
|
265
279
|
return v2 ? { decision: "accept" } : { decision: "approved" };
|
|
266
280
|
case "cancel":
|
|
267
281
|
return v2 ? { decision: "cancel" } : { decision: "abort" };
|
|
@@ -77,10 +77,10 @@ export function codexThreadCompatible(
|
|
|
77
77
|
|
|
78
78
|
function mapSandbox(mode: PermissionMode | undefined): ThreadOptions["sandboxMode"] {
|
|
79
79
|
switch (mode) {
|
|
80
|
-
case "
|
|
80
|
+
case "bypass_permissions":
|
|
81
81
|
return "danger-full-access";
|
|
82
|
-
case "
|
|
83
|
-
case "
|
|
82
|
+
case "accept_edits":
|
|
83
|
+
case "dont_ask":
|
|
84
84
|
// Never-ask posture, but inside the normal sandbox — danger-full is
|
|
85
85
|
// reserved for the explicitly dangerous mode.
|
|
86
86
|
return "workspace-write";
|
package/src/core/agents/types.ts
CHANGED
|
@@ -26,7 +26,7 @@ export type AdapterEvent =
|
|
|
26
26
|
}
|
|
27
27
|
/** Context-window gauge snapshot (→ `session.usage`). */
|
|
28
28
|
| { kind: "usage"; used: number; size?: number; cost?: number }
|
|
29
|
-
/** History-compaction boundary (→ `session.
|
|
29
|
+
/** History-compaction boundary (→ the `session.compaction` entity). */
|
|
30
30
|
| { kind: "compacted"; trigger?: string; preTokens?: number; postTokens?: number };
|
|
31
31
|
|
|
32
32
|
/** Terminal result of a turn, surfaced on `turn.ended`. */
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The engine's diagnostics port: operational signals the engine used to
|
|
3
|
+
* swallow (best-effort paths that must not fail a turn) surfaced to the host
|
|
4
|
+
* instead. Products log/metric these; the engine never does its own logging.
|
|
5
|
+
* Handler errors are always swallowed — a diagnostics sink must never be able
|
|
6
|
+
* to break a turn.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Known diagnostic kinds (open set — new kinds may appear in minor versions;
|
|
11
|
+
* the `(string & {})` arm keeps the union open without losing autocomplete):
|
|
12
|
+
* - `interruptTimeout` — a cancel's SDK interrupt round-trip timed out
|
|
13
|
+
* (the turn was reported `confirmed: false`; the agent may still run).
|
|
14
|
+
* - `resumeFallback` — a requested resume failed and the turn re-ran on a
|
|
15
|
+
* fresh session (also visible as `session.created.resumed: false`).
|
|
16
|
+
* - `sinkError` — an EventSink emit threw; the event was dropped for that
|
|
17
|
+
* sink and the turn continued.
|
|
18
|
+
* - `proxyUpstreamAuth` — the BYOK proxy's upstream rejected the real key
|
|
19
|
+
* (401/403): the stored key is invalid/expired, not the placeholder.
|
|
20
|
+
*/
|
|
21
|
+
export type DiagnosticKind =
|
|
22
|
+
| "interruptTimeout"
|
|
23
|
+
| "resumeFallback"
|
|
24
|
+
| "sinkError"
|
|
25
|
+
| "proxyUpstreamAuth"
|
|
26
|
+
| (string & {});
|
|
27
|
+
|
|
28
|
+
export interface EngineDiagnostic {
|
|
29
|
+
type: DiagnosticKind;
|
|
30
|
+
sessionId?: string;
|
|
31
|
+
message: string;
|
|
32
|
+
detail?: unknown;
|
|
33
|
+
timestamp: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Declared `=> void` so any callback shape is assignable (TS's void-return
|
|
38
|
+
* exemption covers `() => diagnostics.push(d)` AND async handlers); a
|
|
39
|
+
* returned promise is still detected at runtime and its rejection swallowed.
|
|
40
|
+
*/
|
|
41
|
+
export type DiagnosticHandler = (diagnostic: EngineDiagnostic) => void;
|
|
42
|
+
|
|
43
|
+
/** Invoke a handler without letting it break the calling path — sync throws
|
|
44
|
+
* AND async rejections are swallowed (the diagnostics sink owns its own
|
|
45
|
+
* reliability). Stamps the timestamp so call sites don't repeat it. */
|
|
46
|
+
export function emitDiagnostic(
|
|
47
|
+
handler: DiagnosticHandler | undefined,
|
|
48
|
+
diagnostic: Omit<EngineDiagnostic, "timestamp">,
|
|
49
|
+
): void {
|
|
50
|
+
if (!handler) return;
|
|
51
|
+
try {
|
|
52
|
+
const result = handler({ ...diagnostic, timestamp: Date.now() }) as unknown;
|
|
53
|
+
if (result instanceof Promise) {
|
|
54
|
+
result.catch(() => {});
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
// see above
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/core/index.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// Runtime
|
|
4
4
|
export { AgentRuntime, type RunSummary, type TurnAdmission } from "./runtime/agent-runtime.ts";
|
|
5
|
+
export { type DiagnosticHandler, type EngineDiagnostic, emitDiagnostic } from "./diagnostics.ts";
|
|
5
6
|
export {
|
|
6
7
|
type EventSink,
|
|
7
8
|
callbackSink,
|
|
@@ -25,7 +26,7 @@ export {
|
|
|
25
26
|
classifyError,
|
|
26
27
|
isRecoverable,
|
|
27
28
|
isCancellation,
|
|
28
|
-
} from "
|
|
29
|
+
} from "../protocol/errors.ts";
|
|
29
30
|
|
|
30
31
|
// Adapter contract
|
|
31
32
|
export type {
|
|
@@ -58,6 +59,7 @@ export {
|
|
|
58
59
|
export type { ClaudeSdkOptionOverrides, SdkMcpServers } from "./agents/claude-code/options.ts";
|
|
59
60
|
export type {
|
|
60
61
|
ClaudeHooksFactory,
|
|
62
|
+
ClaudeSessionEndReason,
|
|
61
63
|
ClaudeSessionExtras,
|
|
62
64
|
ClaudeToolPolicy,
|
|
63
65
|
} from "./agents/claude-code/generator-session.ts";
|
package/src/core/presets.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { CodexAppServerAgent } from "./agents/codex-app-server/codex-app-server-
|
|
|
8
8
|
import { createCodexSdkTransformer } from "./agents/codex-sdk/adapter.ts";
|
|
9
9
|
import { CodexSdkAgent } from "./agents/codex-sdk/codex-sdk-agent.ts";
|
|
10
10
|
import { AgentRegistry } from "./agents/registry.ts";
|
|
11
|
+
import type { DiagnosticHandler } from "./diagnostics.ts";
|
|
11
12
|
import { CliProvisioner, type ProvisionOptions } from "./provision/provisioner.ts";
|
|
12
13
|
import { AgentRuntime } from "./runtime/agent-runtime.ts";
|
|
13
14
|
|
|
@@ -32,6 +33,14 @@ export interface CreateRegistryOptions {
|
|
|
32
33
|
* `resolveCliPath` stays provisioner-wired here.
|
|
33
34
|
*/
|
|
34
35
|
claudeCode?: Omit<ClaudeCodeAgentOptions, "resolveCliPath">;
|
|
36
|
+
/**
|
|
37
|
+
* Operational diagnostics port (see `EngineDiagnostic`): interrupt
|
|
38
|
+
* timeouts, resume fallbacks, sink failures — signals the engine must not
|
|
39
|
+
* fail a turn over, surfaced for the host to log/metric. Applied to the
|
|
40
|
+
* runtime and every harness that emits them; a harness-level
|
|
41
|
+
* `claudeCode.onDiagnostic` overrides for that harness.
|
|
42
|
+
*/
|
|
43
|
+
onDiagnostic?: DiagnosticHandler;
|
|
35
44
|
}
|
|
36
45
|
|
|
37
46
|
/** Build a registry with the standard harnesses wired to their adapters. */
|
|
@@ -50,7 +59,11 @@ export function createAgentRegistry(opts: CreateRegistryOptions = {}): AgentRegi
|
|
|
50
59
|
const registry = new AgentRegistry();
|
|
51
60
|
if (enabled.has("claude-code")) {
|
|
52
61
|
registry.register(
|
|
53
|
-
new ClaudeCodeAgent({
|
|
62
|
+
new ClaudeCodeAgent({
|
|
63
|
+
onDiagnostic: opts.onDiagnostic,
|
|
64
|
+
...opts.claudeCode,
|
|
65
|
+
resolveCliPath: claudeCli,
|
|
66
|
+
}),
|
|
54
67
|
createClaudeCodeTransformer,
|
|
55
68
|
);
|
|
56
69
|
}
|
|
@@ -74,5 +87,5 @@ export function createAgentRegistry(opts: CreateRegistryOptions = {}): AgentRegi
|
|
|
74
87
|
|
|
75
88
|
/** Convenience: a ready-to-use runtime with the standard harnesses. */
|
|
76
89
|
export function createAgentRuntime(opts: CreateRegistryOptions = {}): AgentRuntime {
|
|
77
|
-
return new AgentRuntime(createAgentRegistry(opts));
|
|
90
|
+
return new AgentRuntime(createAgentRegistry(opts), { onDiagnostic: opts.onDiagnostic });
|
|
78
91
|
}
|