@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,451 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_TOKEN_USAGE,
|
|
3
|
+
type ReasoningPart,
|
|
4
|
+
type StopReason,
|
|
5
|
+
type StreamContext,
|
|
6
|
+
type TextPart,
|
|
7
|
+
type TokenUsage,
|
|
8
|
+
type ToolPart,
|
|
9
|
+
appendToolInput,
|
|
10
|
+
completeToolPart,
|
|
11
|
+
createPendingToolPart,
|
|
12
|
+
createReasoningPart,
|
|
13
|
+
createTextPart,
|
|
14
|
+
createToolPart,
|
|
15
|
+
setToolMeta,
|
|
16
|
+
startToolPart,
|
|
17
|
+
} from "../../../protocol/index.ts";
|
|
18
|
+
import { claudeToolMeta, toolLocationsFromInput } from "../tool-meta.ts";
|
|
19
|
+
import type { AdapterEvent, EventTransformer, TransformResult } from "../types.ts";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parses raw Claude Code SDK messages into normalized AdapterEvents.
|
|
23
|
+
*
|
|
24
|
+
* The main signal is the `stream_event` message (Anthropic raw streaming):
|
|
25
|
+
* content_block_start opens a part, content_block_delta streams text/thinking/
|
|
26
|
+
* tool-input, content_block_stop finalizes it. Tool results arrive later as
|
|
27
|
+
* `user` messages and complete the matching tool part in place. The terminal
|
|
28
|
+
* `result` message carries authoritative usage/cost. We rely on streaming for
|
|
29
|
+
* content, so the non-streaming `assistant` message is processed only as a
|
|
30
|
+
* fallback when partial messages were absent for it (e.g. some sub-agent paths)
|
|
31
|
+
* — see `streamedThisMessage`.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
// --- Minimal structural types for the Anthropic stream events we consume. ---
|
|
35
|
+
// (Kept local so the adapter doesn't hard-depend on @anthropic-ai/sdk types.)
|
|
36
|
+
interface StreamEvent {
|
|
37
|
+
type: string;
|
|
38
|
+
index?: number;
|
|
39
|
+
content_block?: { type: string; id?: string; name?: string };
|
|
40
|
+
delta?: {
|
|
41
|
+
type?: string;
|
|
42
|
+
text?: string;
|
|
43
|
+
thinking?: string;
|
|
44
|
+
partial_json?: string;
|
|
45
|
+
stop_reason?: string;
|
|
46
|
+
};
|
|
47
|
+
usage?: { output_tokens?: number };
|
|
48
|
+
}
|
|
49
|
+
interface RawUsage {
|
|
50
|
+
input_tokens?: number;
|
|
51
|
+
output_tokens?: number;
|
|
52
|
+
cache_read_input_tokens?: number;
|
|
53
|
+
cache_creation_input_tokens?: number;
|
|
54
|
+
}
|
|
55
|
+
interface ContentBlock {
|
|
56
|
+
type: string;
|
|
57
|
+
text?: string;
|
|
58
|
+
thinking?: string;
|
|
59
|
+
id?: string;
|
|
60
|
+
name?: string;
|
|
61
|
+
input?: Record<string, unknown>;
|
|
62
|
+
tool_use_id?: string;
|
|
63
|
+
content?: unknown;
|
|
64
|
+
is_error?: boolean;
|
|
65
|
+
}
|
|
66
|
+
type ClaudeMessage =
|
|
67
|
+
| {
|
|
68
|
+
type: "system";
|
|
69
|
+
subtype?: string;
|
|
70
|
+
compact_metadata?: { trigger?: string; pre_tokens?: number; post_tokens?: number };
|
|
71
|
+
}
|
|
72
|
+
| { type: "stream_event"; event: StreamEvent; parent_tool_use_id?: string | null }
|
|
73
|
+
// Synthetic, appended by ClaudeCodeAgent when the turn's abort controller
|
|
74
|
+
// fired — disambiguates interrupt from execution failure (same SDK result).
|
|
75
|
+
| { type: "turn_interrupted" }
|
|
76
|
+
| {
|
|
77
|
+
type: "assistant";
|
|
78
|
+
message?: { content?: ContentBlock[]; usage?: RawUsage };
|
|
79
|
+
parent_tool_use_id?: string | null;
|
|
80
|
+
}
|
|
81
|
+
| { type: "user"; message?: { content?: ContentBlock[] | string } }
|
|
82
|
+
| {
|
|
83
|
+
type: "result";
|
|
84
|
+
subtype?: string;
|
|
85
|
+
usage?: RawUsage;
|
|
86
|
+
modelUsage?: Record<string, { contextWindow?: number }>;
|
|
87
|
+
total_cost_usd?: number;
|
|
88
|
+
stop_reason?: string | null;
|
|
89
|
+
is_error?: boolean;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
type BlockKind = "text" | "reasoning" | "tool";
|
|
93
|
+
interface BlockEntry {
|
|
94
|
+
partId: string;
|
|
95
|
+
kind: BlockKind;
|
|
96
|
+
part: TextPart | ReasoningPart | ToolPart;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function safeParseJson(input: string): Record<string, unknown> {
|
|
100
|
+
if (!input.trim()) return {};
|
|
101
|
+
try {
|
|
102
|
+
const v = JSON.parse(input);
|
|
103
|
+
return v && typeof v === "object" ? (v as Record<string, unknown>) : {};
|
|
104
|
+
} catch {
|
|
105
|
+
return {};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Map Anthropic stop reasons / result subtypes onto the normalized enum. */
|
|
110
|
+
function mapClaudeStopReason(
|
|
111
|
+
stopReason: string | null | undefined,
|
|
112
|
+
subtype: string | undefined,
|
|
113
|
+
): StopReason | undefined {
|
|
114
|
+
if (subtype === "error_max_turns") return "max_turn_requests";
|
|
115
|
+
switch (stopReason) {
|
|
116
|
+
case "end_turn":
|
|
117
|
+
case "stop_sequence":
|
|
118
|
+
case "tool_use":
|
|
119
|
+
case "pause_turn":
|
|
120
|
+
return "end_turn";
|
|
121
|
+
case "max_tokens":
|
|
122
|
+
return "max_tokens";
|
|
123
|
+
case "refusal":
|
|
124
|
+
return "refusal";
|
|
125
|
+
default:
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Context occupancy from an Anthropic usage block: full prompt + output. */
|
|
131
|
+
function contextTokens(usage: RawUsage): number {
|
|
132
|
+
return (
|
|
133
|
+
(usage.input_tokens ?? 0) +
|
|
134
|
+
(usage.cache_read_input_tokens ?? 0) +
|
|
135
|
+
(usage.cache_creation_input_tokens ?? 0) +
|
|
136
|
+
(usage.output_tokens ?? 0)
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function stringifyToolResult(content: unknown): string {
|
|
141
|
+
if (typeof content === "string") return content;
|
|
142
|
+
if (Array.isArray(content)) {
|
|
143
|
+
return content
|
|
144
|
+
.map((b) =>
|
|
145
|
+
b && typeof b === "object" && "text" in b ? String((b as { text: unknown }).text) : "",
|
|
146
|
+
)
|
|
147
|
+
.join("");
|
|
148
|
+
}
|
|
149
|
+
if (content == null) return "";
|
|
150
|
+
return JSON.stringify(content);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export class ClaudeCodeTransformer implements EventTransformer<unknown> {
|
|
154
|
+
private readonly ctx: StreamContext;
|
|
155
|
+
/** Anthropic content blocks of the in-flight message, by stream index. */
|
|
156
|
+
private blocks = new Map<number, BlockEntry>();
|
|
157
|
+
/** Tool parts by tool_use_id, so a later tool_result can complete them. */
|
|
158
|
+
private readonly toolPartsById = new Map<string, ToolPart>();
|
|
159
|
+
private currentParent?: string;
|
|
160
|
+
/**
|
|
161
|
+
* Set by ANY stream_event in this turn (the transformer is per-turn). When
|
|
162
|
+
* set, the trailing complete `assistant` snapshot just repeats content we
|
|
163
|
+
* already streamed, so it is skipped — except sub-agent replies, which never
|
|
164
|
+
* stream and arrive only as a complete message. Mirrors the source sidecars'
|
|
165
|
+
* sticky `hasReceivedStreamEvents` flag; a per-message flag (reset on the
|
|
166
|
+
* first assistant) double-emits text when a message mixes text + tool_use.
|
|
167
|
+
*/
|
|
168
|
+
private hasStreamed = false;
|
|
169
|
+
|
|
170
|
+
private usage: TokenUsage = { ...DEFAULT_TOKEN_USAGE };
|
|
171
|
+
private stopReason?: StopReason;
|
|
172
|
+
private finishReason?: string;
|
|
173
|
+
private cost?: number;
|
|
174
|
+
private error?: string;
|
|
175
|
+
private interrupted = false;
|
|
176
|
+
private execFailure = false;
|
|
177
|
+
private sawResult = false;
|
|
178
|
+
|
|
179
|
+
constructor(ctx: { sessionId: string }) {
|
|
180
|
+
this.ctx = { sessionId: ctx.sessionId, messageId: "" };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
process(raw: unknown): AdapterEvent[] {
|
|
184
|
+
const msg = raw as ClaudeMessage;
|
|
185
|
+
switch (msg.type) {
|
|
186
|
+
case "stream_event":
|
|
187
|
+
return this.handleStream(msg.event, msg.parent_tool_use_id ?? undefined);
|
|
188
|
+
case "assistant":
|
|
189
|
+
return this.handleAssistant(msg);
|
|
190
|
+
case "user":
|
|
191
|
+
return this.handleToolResults(msg.message?.content);
|
|
192
|
+
case "result":
|
|
193
|
+
return this.captureResult(msg);
|
|
194
|
+
case "turn_interrupted":
|
|
195
|
+
// Synthetic marker from ClaudeCodeAgent: an interrupt WAS requested
|
|
196
|
+
// for this turn (arrives after the result, so decide in finish()).
|
|
197
|
+
this.interrupted = true;
|
|
198
|
+
return [];
|
|
199
|
+
case "system":
|
|
200
|
+
if (msg.subtype === "compact_boundary") {
|
|
201
|
+
const meta = msg.compact_metadata ?? {};
|
|
202
|
+
return [
|
|
203
|
+
{
|
|
204
|
+
kind: "compacted",
|
|
205
|
+
trigger: meta.trigger,
|
|
206
|
+
preTokens: meta.pre_tokens,
|
|
207
|
+
postTokens: meta.post_tokens,
|
|
208
|
+
},
|
|
209
|
+
];
|
|
210
|
+
}
|
|
211
|
+
return [];
|
|
212
|
+
default:
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
finish(): TransformResult {
|
|
218
|
+
// error_during_execution + null stop_reason is cancellation ONLY when the
|
|
219
|
+
// agent confirmed an interrupt; otherwise it is a real failure (a silent
|
|
220
|
+
// "cancelled" here is how a bad resume id used to vanish without a trace).
|
|
221
|
+
// A confirmed interrupt with NO result at all (aborted before the SDK
|
|
222
|
+
// emitted one) is also a cancellation, not a completed turn.
|
|
223
|
+
const cancelled = this.interrupted && (this.execFailure || !this.sawResult);
|
|
224
|
+
const error =
|
|
225
|
+
this.error ??
|
|
226
|
+
(this.execFailure && !this.interrupted
|
|
227
|
+
? "Claude turn failed during execution (error_during_execution — e.g. an invalid resumeSessionId fails this way)"
|
|
228
|
+
: undefined);
|
|
229
|
+
return {
|
|
230
|
+
usage: this.usage,
|
|
231
|
+
stopReason: this.stopReason,
|
|
232
|
+
finishReason: this.finishReason,
|
|
233
|
+
cost: this.cost,
|
|
234
|
+
error,
|
|
235
|
+
cancelled,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private ctxFor(): StreamContext {
|
|
240
|
+
return { ...this.ctx, ...(this.currentParent ? { parentToolUseId: this.currentParent } : {}) };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Fallback for messages delivered without streaming (notably some sub-agent
|
|
245
|
+
* paths). If the in-flight message already streamed, its parts are live —
|
|
246
|
+
* skip the duplicate. Otherwise emit its content blocks as finalized parts.
|
|
247
|
+
*/
|
|
248
|
+
private handleAssistant(msg: Extract<ClaudeMessage, { type: "assistant" }>): AdapterEvent[] {
|
|
249
|
+
// Context gauge: every top-level model message carries fresh usage (the
|
|
250
|
+
// sub-agent's context is its own and would misreport the session's).
|
|
251
|
+
const gauge: AdapterEvent[] =
|
|
252
|
+
msg.message?.usage && !msg.parent_tool_use_id
|
|
253
|
+
? [{ kind: "usage", used: contextTokens(msg.message.usage) }]
|
|
254
|
+
: [];
|
|
255
|
+
// Streaming already emitted this content live via stream_event deltas; the
|
|
256
|
+
// trailing complete snapshot would duplicate it. Skip — except sub-agent
|
|
257
|
+
// replies (parent_tool_use_id), which never stream and arrive only here.
|
|
258
|
+
if (this.hasStreamed && !msg.parent_tool_use_id) return gauge;
|
|
259
|
+
const content = msg.message?.content;
|
|
260
|
+
if (!Array.isArray(content)) return [];
|
|
261
|
+
const prevParent = this.currentParent;
|
|
262
|
+
this.currentParent = msg.parent_tool_use_id ?? undefined;
|
|
263
|
+
const events: AdapterEvent[] = [];
|
|
264
|
+
for (const block of content) {
|
|
265
|
+
if (block.type === "text" && block.text) {
|
|
266
|
+
events.push({ kind: "part-open", part: createTextPart(this.ctxFor(), block.text, false) });
|
|
267
|
+
} else if (
|
|
268
|
+
(block.type === "thinking" || block.type === "redacted_thinking") &&
|
|
269
|
+
block.thinking
|
|
270
|
+
) {
|
|
271
|
+
events.push({
|
|
272
|
+
kind: "part-open",
|
|
273
|
+
part: createReasoningPart(this.ctxFor(), block.thinking, false),
|
|
274
|
+
});
|
|
275
|
+
} else if (block.type === "tool_use" && block.id) {
|
|
276
|
+
const name = block.name ?? "tool";
|
|
277
|
+
const input = block.input ?? {};
|
|
278
|
+
const part = createToolPart(
|
|
279
|
+
this.ctxFor(),
|
|
280
|
+
block.id,
|
|
281
|
+
name,
|
|
282
|
+
input,
|
|
283
|
+
claudeToolMeta(name, input),
|
|
284
|
+
);
|
|
285
|
+
this.toolPartsById.set(block.id, part);
|
|
286
|
+
events.push({ kind: "part-open", part });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
this.currentParent = prevParent;
|
|
290
|
+
return [...gauge, ...events];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private handleStream(event: StreamEvent, parentToolUseId?: string): AdapterEvent[] {
|
|
294
|
+
// Sticky for the whole turn: any stream event means the trailing complete
|
|
295
|
+
// `assistant` snapshot is a duplicate of what we streamed.
|
|
296
|
+
this.hasStreamed = true;
|
|
297
|
+
switch (event.type) {
|
|
298
|
+
case "message_start":
|
|
299
|
+
this.blocks.clear();
|
|
300
|
+
this.currentParent = parentToolUseId;
|
|
301
|
+
// Each top-level model message becomes a wire message. Sub-agent
|
|
302
|
+
// messages don't open one — their parts nest via parentToolUseId.
|
|
303
|
+
return parentToolUseId ? [] : [{ kind: "message-start", role: "assistant" }];
|
|
304
|
+
case "content_block_start":
|
|
305
|
+
return this.openBlock(event);
|
|
306
|
+
case "content_block_delta":
|
|
307
|
+
return this.deltaBlock(event);
|
|
308
|
+
case "content_block_stop":
|
|
309
|
+
return this.closeBlock(event);
|
|
310
|
+
case "message_delta":
|
|
311
|
+
if (typeof event.delta?.stop_reason === "string")
|
|
312
|
+
this.finishReason = event.delta.stop_reason;
|
|
313
|
+
if (event.usage?.output_tokens) this.usage.output = event.usage.output_tokens;
|
|
314
|
+
return [];
|
|
315
|
+
case "message_stop":
|
|
316
|
+
return parentToolUseId || this.currentParent ? [] : [{ kind: "message-end" }];
|
|
317
|
+
default:
|
|
318
|
+
return [];
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private openBlock(event: StreamEvent): AdapterEvent[] {
|
|
323
|
+
const index = event.index ?? 0;
|
|
324
|
+
const cb = event.content_block;
|
|
325
|
+
if (!cb) return [];
|
|
326
|
+
if (cb.type === "text") {
|
|
327
|
+
const part = createTextPart(this.ctxFor(), "", true);
|
|
328
|
+
this.blocks.set(index, { partId: part.id, kind: "text", part });
|
|
329
|
+
return [{ kind: "part-open", part }];
|
|
330
|
+
}
|
|
331
|
+
if (cb.type === "thinking" || cb.type === "redacted_thinking") {
|
|
332
|
+
const part = createReasoningPart(this.ctxFor(), "", true);
|
|
333
|
+
this.blocks.set(index, { partId: part.id, kind: "reasoning", part });
|
|
334
|
+
return [{ kind: "part-open", part }];
|
|
335
|
+
}
|
|
336
|
+
if (cb.type === "tool_use") {
|
|
337
|
+
const name = cb.name ?? "tool";
|
|
338
|
+
const part = createPendingToolPart(this.ctxFor(), cb.id ?? "", name, claudeToolMeta(name));
|
|
339
|
+
this.blocks.set(index, { partId: part.id, kind: "tool", part });
|
|
340
|
+
if (cb.id) this.toolPartsById.set(cb.id, part);
|
|
341
|
+
return [{ kind: "part-open", part }];
|
|
342
|
+
}
|
|
343
|
+
return [];
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private deltaBlock(event: StreamEvent): AdapterEvent[] {
|
|
347
|
+
const entry = this.blocks.get(event.index ?? 0);
|
|
348
|
+
const d = event.delta;
|
|
349
|
+
if (!entry || !d) return [];
|
|
350
|
+
if (d.type === "text_delta" && entry.kind === "text" && d.text) {
|
|
351
|
+
(entry.part as TextPart).text += d.text;
|
|
352
|
+
return [{ kind: "text-delta", partId: entry.partId, text: d.text }];
|
|
353
|
+
}
|
|
354
|
+
if (d.type === "thinking_delta" && entry.kind === "reasoning" && d.thinking) {
|
|
355
|
+
(entry.part as ReasoningPart).text += d.thinking;
|
|
356
|
+
return [{ kind: "reasoning-delta", partId: entry.partId, text: d.thinking }];
|
|
357
|
+
}
|
|
358
|
+
if (d.type === "input_json_delta" && entry.kind === "tool" && d.partial_json) {
|
|
359
|
+
const tool = entry.part as ToolPart;
|
|
360
|
+
appendToolInput(tool, d.partial_json);
|
|
361
|
+
return [
|
|
362
|
+
{
|
|
363
|
+
kind: "tool-input-delta",
|
|
364
|
+
partId: entry.partId,
|
|
365
|
+
toolCallId: tool.toolCallId,
|
|
366
|
+
toolName: tool.toolName,
|
|
367
|
+
input: d.partial_json,
|
|
368
|
+
},
|
|
369
|
+
];
|
|
370
|
+
}
|
|
371
|
+
return [];
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private closeBlock(event: StreamEvent): AdapterEvent[] {
|
|
375
|
+
const entry = this.blocks.get(event.index ?? 0);
|
|
376
|
+
if (!entry) return [];
|
|
377
|
+
if (entry.kind === "text" || entry.kind === "reasoning") {
|
|
378
|
+
(entry.part as TextPart | ReasoningPart).state = "done";
|
|
379
|
+
return [{ kind: "part-update", part: entry.part }];
|
|
380
|
+
}
|
|
381
|
+
const tool = entry.part as ToolPart;
|
|
382
|
+
const partial = tool.state.status === "pending" ? tool.state.partialInput : "";
|
|
383
|
+
const input = safeParseJson(partial);
|
|
384
|
+
startToolPart(tool, input);
|
|
385
|
+
const locations = toolLocationsFromInput(input);
|
|
386
|
+
if (locations) setToolMeta(tool, { locations });
|
|
387
|
+
return [{ kind: "part-update", part: tool }];
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private handleToolResults(content: ContentBlock[] | string | undefined): AdapterEvent[] {
|
|
391
|
+
if (!Array.isArray(content)) return [];
|
|
392
|
+
const events: AdapterEvent[] = [];
|
|
393
|
+
for (const block of content) {
|
|
394
|
+
if (block.type !== "tool_result" || !block.tool_use_id) continue;
|
|
395
|
+
const tool = this.toolPartsById.get(block.tool_use_id);
|
|
396
|
+
if (!tool) continue;
|
|
397
|
+
completeToolPart(tool, {
|
|
398
|
+
output: stringifyToolResult(block.content),
|
|
399
|
+
isError: block.is_error === true,
|
|
400
|
+
});
|
|
401
|
+
events.push({ kind: "part-update", part: tool });
|
|
402
|
+
}
|
|
403
|
+
return events;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private captureResult(msg: Extract<ClaudeMessage, { type: "result" }>): AdapterEvent[] {
|
|
407
|
+
this.sawResult = true;
|
|
408
|
+
if (msg.usage) {
|
|
409
|
+
this.usage = {
|
|
410
|
+
input: msg.usage.input_tokens ?? 0,
|
|
411
|
+
output: msg.usage.output_tokens ?? this.usage.output,
|
|
412
|
+
cache: {
|
|
413
|
+
read: msg.usage.cache_read_input_tokens ?? 0,
|
|
414
|
+
write: msg.usage.cache_creation_input_tokens ?? 0,
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
if (typeof msg.total_cost_usd === "number") this.cost = msg.total_cost_usd;
|
|
419
|
+
this.finishReason = msg.stop_reason ?? msg.subtype ?? this.finishReason;
|
|
420
|
+
this.stopReason = mapClaudeStopReason(msg.stop_reason, msg.subtype);
|
|
421
|
+
if (msg.subtype && msg.subtype !== "success") {
|
|
422
|
+
if (msg.subtype === "error_during_execution" && msg.stop_reason === null) {
|
|
423
|
+
// Ambiguous shape: an interrupt AND an execution failure (e.g. a bad
|
|
424
|
+
// resume id) both surface this way. The agent's `turn_interrupted`
|
|
425
|
+
// marker (which arrives after this message) disambiguates in finish().
|
|
426
|
+
this.execFailure = true;
|
|
427
|
+
} else if (msg.subtype === "error_max_turns") {
|
|
428
|
+
// Hitting the turn budget is a stop condition, not a failure.
|
|
429
|
+
} else {
|
|
430
|
+
this.error = `Claude turn ended: ${msg.subtype}`;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
// Final authoritative gauge: the result knows the context-window size.
|
|
434
|
+
if (!msg.usage) return [];
|
|
435
|
+
const size = Object.values(msg.modelUsage ?? {})
|
|
436
|
+
.map((m) => m.contextWindow ?? 0)
|
|
437
|
+
.reduce((a, b) => Math.max(a, b), 0);
|
|
438
|
+
return [
|
|
439
|
+
{
|
|
440
|
+
kind: "usage",
|
|
441
|
+
used: contextTokens(msg.usage),
|
|
442
|
+
...(size > 0 && { size }),
|
|
443
|
+
...(this.cost !== undefined && { cost: this.cost }),
|
|
444
|
+
},
|
|
445
|
+
];
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export function createClaudeCodeTransformer(ctx: { sessionId: string }): EventTransformer {
|
|
450
|
+
return new ClaudeCodeTransformer(ctx);
|
|
451
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
import type { McpSetServersResult } from "@anthropic-ai/claude-agent-sdk";
|
|
3
|
+
import type { AgentCapabilities, AgentInput, McpServerConfig } from "../../../protocol/index.ts";
|
|
4
|
+
import type { AgentExecuteOptions, RawAgentEvent } from "../base.ts";
|
|
5
|
+
import { BaseAgent } from "../base.ts";
|
|
6
|
+
import type {
|
|
7
|
+
ClaudeHooksFactory,
|
|
8
|
+
ClaudeSessionExtras,
|
|
9
|
+
ClaudeToolPolicy,
|
|
10
|
+
} from "./generator-session.ts";
|
|
11
|
+
import type { ClaudeSessionConfig } from "./options.ts";
|
|
12
|
+
import { ClaudeSessionManager } from "./session-manager.ts";
|
|
13
|
+
|
|
14
|
+
type ClaudeContent = SDKUserMessage["message"]["content"];
|
|
15
|
+
|
|
16
|
+
const CAPABILITIES: AgentCapabilities = {
|
|
17
|
+
multiTurn: true,
|
|
18
|
+
sessionResume: true,
|
|
19
|
+
modelSwitch: "in-session",
|
|
20
|
+
thinkingLevels: true,
|
|
21
|
+
images: true,
|
|
22
|
+
mcpServers: true,
|
|
23
|
+
permissionRequests: true,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Convert our normalized input into Claude's message content. */
|
|
27
|
+
function toClaudeContent(input: AgentInput): ClaudeContent {
|
|
28
|
+
if (typeof input === "string") return input;
|
|
29
|
+
const blocks = input.map((part) => {
|
|
30
|
+
if (part.type === "text") return { type: "text", text: part.text };
|
|
31
|
+
const source = part.url
|
|
32
|
+
? { type: "url", url: part.url }
|
|
33
|
+
: { type: "base64", media_type: part.mediaType, data: part.data ?? "" };
|
|
34
|
+
// `file` inputs (PDFs etc.) are document blocks, not images.
|
|
35
|
+
return { type: part.type === "file" ? "document" : "image", source };
|
|
36
|
+
});
|
|
37
|
+
// Kept cast: the SDK's block sources require literal media_type unions
|
|
38
|
+
// ("image/jpeg" | ... and "application/pdf") while our PartInput carries an
|
|
39
|
+
// open string mediaType, so these blocks cannot satisfy the union as typed.
|
|
40
|
+
return blocks as unknown as ClaudeContent;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The SDK shape of a hard resume failure: the turn dies with
|
|
45
|
+
* `error_during_execution` and a null stop reason before any output. (An
|
|
46
|
+
* interrupt produces the same result message — callers must also check that no
|
|
47
|
+
* interrupt was requested.)
|
|
48
|
+
*/
|
|
49
|
+
export function claudeResumeFailed(event: unknown): boolean {
|
|
50
|
+
const msg = event as { type?: string; subtype?: string; stop_reason?: unknown };
|
|
51
|
+
return (
|
|
52
|
+
msg.type === "result" && msg.subtype === "error_during_execution" && msg.stop_reason === null
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function sessionConfigFrom(
|
|
57
|
+
options: AgentExecuteOptions,
|
|
58
|
+
cliPath: string | undefined,
|
|
59
|
+
): ClaudeSessionConfig {
|
|
60
|
+
return {
|
|
61
|
+
cwd: options.cwd,
|
|
62
|
+
additionalDirectories: options.additionalDirectories,
|
|
63
|
+
model: options.model,
|
|
64
|
+
thinkingLevel: options.thinkingLevel,
|
|
65
|
+
permissionMode: options.permissionMode,
|
|
66
|
+
maxTurns: options.maxTurns,
|
|
67
|
+
systemPromptAppend: options.systemPromptAppend,
|
|
68
|
+
resumeSessionId: options.resumeSessionId,
|
|
69
|
+
resumeSessionAt: options.resumeSessionAt,
|
|
70
|
+
mcpServers: options.mcpServers,
|
|
71
|
+
env: options.env,
|
|
72
|
+
apiKey: options.apiKey,
|
|
73
|
+
disableTools: options.disableTools === true,
|
|
74
|
+
cliPath,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface ClaudeCodeAgentOptions {
|
|
79
|
+
/**
|
|
80
|
+
* Resolve the `claude` binary to spawn (typically the provisioner's,
|
|
81
|
+
* memoized). `undefined` defers to the SDK's own platform-package
|
|
82
|
+
* resolution. Operator-level only — never sourced from the wire.
|
|
83
|
+
*/
|
|
84
|
+
resolveCliPath?: () => Promise<string | undefined>;
|
|
85
|
+
/**
|
|
86
|
+
* In-process MCP servers to attach to every session (the SDK's
|
|
87
|
+
* `createSdkMcpServer` output). Called once per session spawn. Operator
|
|
88
|
+
* embed-tier only — never wire-sourced; instances win name conflicts with
|
|
89
|
+
* wire-configured servers.
|
|
90
|
+
*/
|
|
91
|
+
sdkMcpServers?: ClaudeSessionExtras["sdkMcpServers"];
|
|
92
|
+
/**
|
|
93
|
+
* Pre-broker tool policy (SDK PermissionResult or undefined = fall through
|
|
94
|
+
* to the interactive broker). Operator embed-tier only.
|
|
95
|
+
*/
|
|
96
|
+
toolPolicy?: ClaudeToolPolicy;
|
|
97
|
+
/** SDK lifecycle hooks factory (decision-capable). Operator embed-tier only. */
|
|
98
|
+
hooks?: ClaudeHooksFactory;
|
|
99
|
+
/**
|
|
100
|
+
* Raw SDK option overrides merged over the engine's options at session
|
|
101
|
+
* spawn (embedder escape hatch for SDK surface the engine does not model:
|
|
102
|
+
* disallowedTools, forwardSubagentText, extraArgs, ...). Operator
|
|
103
|
+
* embed-tier only — never wire-sourced.
|
|
104
|
+
*/
|
|
105
|
+
sdkOptions?: ClaudeSessionExtras["sdkOptions"];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Claude Code harness. Reuses a warm `query()` per logical session for fast
|
|
110
|
+
* in-session multi-turn and model hot-swap; falls back to SDK `resume` when a
|
|
111
|
+
* session must be reconstructed (e.g. after idle eviction or across processes).
|
|
112
|
+
*/
|
|
113
|
+
export class ClaudeCodeAgent extends BaseAgent {
|
|
114
|
+
readonly harness = "claude-code" as const;
|
|
115
|
+
readonly capabilities = CAPABILITIES;
|
|
116
|
+
private readonly manager = new ClaudeSessionManager();
|
|
117
|
+
|
|
118
|
+
constructor(private readonly agentOptions: ClaudeCodeAgentOptions = {}) {
|
|
119
|
+
super();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private sessionExtras(): ClaudeSessionExtras {
|
|
123
|
+
return {
|
|
124
|
+
sdkMcpServers: this.agentOptions.sdkMcpServers,
|
|
125
|
+
toolPolicy: this.agentOptions.toolPolicy,
|
|
126
|
+
hooks: this.agentOptions.hooks,
|
|
127
|
+
sdkOptions: this.agentOptions.sdkOptions,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async *execute(
|
|
132
|
+
input: AgentInput,
|
|
133
|
+
options: AgentExecuteOptions,
|
|
134
|
+
): AsyncIterableIterator<RawAgentEvent> {
|
|
135
|
+
const controller = this.trackTurn(options.sessionId, options.signal);
|
|
136
|
+
try {
|
|
137
|
+
const cliPath = await this.agentOptions.resolveCliPath?.();
|
|
138
|
+
const content = toClaudeContent(input);
|
|
139
|
+
|
|
140
|
+
// Attempt 0 honors resumeSessionId. The SDK does not degrade a bad
|
|
141
|
+
// resume id into a fresh session — it fails the whole turn (result
|
|
142
|
+
// error_during_execution before any output) — so a classified resume
|
|
143
|
+
// failure is swallowed and the turn reruns ONCE on a fresh session,
|
|
144
|
+
// reported as resumed:false (the structured fallback signal, acp parity).
|
|
145
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
146
|
+
if (controller.signal.aborted) break;
|
|
147
|
+
const resuming = attempt === 0 && Boolean(options.resumeSessionId);
|
|
148
|
+
const session = await this.manager.getOrCreate(
|
|
149
|
+
options.sessionId,
|
|
150
|
+
{
|
|
151
|
+
...sessionConfigFrom(options, cliPath),
|
|
152
|
+
...(resuming ? {} : { resumeSessionId: undefined, resumeSessionAt: undefined }),
|
|
153
|
+
},
|
|
154
|
+
this.sessionExtras(),
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
let reported = false;
|
|
158
|
+
const report = (id: string) => {
|
|
159
|
+
if (reported) return;
|
|
160
|
+
reported = true;
|
|
161
|
+
options.onNativeSession?.(id, { resumed: resuming });
|
|
162
|
+
};
|
|
163
|
+
// Resume turns defer the report until output proves the resume held —
|
|
164
|
+
// a doomed attempt's id is never reported. Other turns report as soon
|
|
165
|
+
// as an id is known.
|
|
166
|
+
if (!resuming && session.currentSessionId) report(session.currentSessionId);
|
|
167
|
+
|
|
168
|
+
// `sendMessage` waits until the session is idle (any prior turn
|
|
169
|
+
// drained), then marks it busy — so installing the per-turn permission
|
|
170
|
+
// handler AFTER it resolves can't clobber a still-running turn's
|
|
171
|
+
// handler. The SDK processes the turn on later async tasks, so the
|
|
172
|
+
// handler is in place before canUseTool can fire.
|
|
173
|
+
const tap = await session.sendMessage(content, options.turnId);
|
|
174
|
+
session.permissionHandler = options.onPermissionRequest;
|
|
175
|
+
// An abort that landed while we were spawning/sending has no listener
|
|
176
|
+
// yet — interrupt directly, then arm the listener for later aborts.
|
|
177
|
+
if (controller.signal.aborted) void session.interruptTurn();
|
|
178
|
+
controller.signal.addEventListener("abort", () => void session.interruptTurn(), {
|
|
179
|
+
once: true,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
let sawOutput = false;
|
|
183
|
+
let resumeFailed = false;
|
|
184
|
+
for await (const event of tap.events) {
|
|
185
|
+
const msg = event as { type?: string; session_id?: string };
|
|
186
|
+
if (msg.type === "assistant" || msg.type === "stream_event") sawOutput = true;
|
|
187
|
+
if (resuming && !sawOutput && !controller.signal.aborted && claudeResumeFailed(event)) {
|
|
188
|
+
resumeFailed = true;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (msg.session_id && (!resuming || sawOutput)) report(msg.session_id);
|
|
192
|
+
yield event as RawAgentEvent;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!resumeFailed) break;
|
|
196
|
+
await this.manager.terminate(options.sessionId);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Ground truth for the adapter: an interrupted turn and a turn that
|
|
200
|
+
// failed during execution (e.g. a bad resume id) end with the SAME
|
|
201
|
+
// result shape (error_during_execution, null stop_reason). Only the
|
|
202
|
+
// agent knows whether an interrupt was actually requested.
|
|
203
|
+
if (controller.signal.aborted) yield { type: "turn_interrupted" };
|
|
204
|
+
} finally {
|
|
205
|
+
this.endTurn(options.sessionId, controller);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Hot-swap the live session's wire MCP servers without a restart (context
|
|
211
|
+
* intact). Returns false when the session isn't live. In-process servers
|
|
212
|
+
* are preserved across swaps.
|
|
213
|
+
*/
|
|
214
|
+
async setMcpServers(
|
|
215
|
+
sessionId: string,
|
|
216
|
+
servers: Record<string, McpServerConfig>,
|
|
217
|
+
): Promise<McpSetServersResult | undefined> {
|
|
218
|
+
return this.manager.setMcpServers(sessionId, servers);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
override async cancel(sessionId: string): Promise<void> {
|
|
222
|
+
await this.manager.get(sessionId)?.interruptTurn();
|
|
223
|
+
await super.cancel(sessionId);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
override async release(sessionId: string): Promise<void> {
|
|
227
|
+
await super.release(sessionId);
|
|
228
|
+
await this.manager.terminate(sessionId);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
override async terminateAll(): Promise<void> {
|
|
232
|
+
await super.terminateAll();
|
|
233
|
+
await this.manager.terminateAll();
|
|
234
|
+
}
|
|
235
|
+
}
|