@letta-ai/letta-agent-sdk 0.3.2 → 0.3.3
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/AGENTS.md +47 -0
- package/README.md +17 -0
- package/dist/client-entry.js +800 -732
- package/dist/client-entry.js.map +8 -5
- package/dist/cloud-sandbox.d.ts +33 -0
- package/dist/cloud-sandbox.d.ts.map +1 -0
- package/dist/cloud-session.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +799 -732
- package/dist/index.js.map +9 -6
- package/dist/remote-client-session-core.d.ts +6 -93
- package/dist/remote-client-session-core.d.ts.map +1 -1
- package/dist/remote-session-protocol.d.ts +130 -0
- package/dist/remote-session-protocol.d.ts.map +1 -0
- package/dist/remote-turn-coordinator.d.ts +49 -0
- package/dist/remote-turn-coordinator.d.ts.map +1 -0
- package/dist/types.d.ts +2 -20
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -2
- package/src/app-server-management.ts +641 -0
- package/src/app-server-session.ts +948 -0
- package/src/cli-resolver.ts +46 -0
- package/src/client-base.ts +482 -0
- package/src/client-entry.ts +31 -0
- package/src/client.ts +138 -0
- package/src/cloud-management.ts +360 -0
- package/src/cloud-sandbox.ts +117 -0
- package/src/cloud-session.ts +1313 -0
- package/src/index.ts +440 -0
- package/src/interactiveToolPolicy.ts +62 -0
- package/src/local-app-server-session.ts +39 -0
- package/src/local-app-server.ts +137 -0
- package/src/management-types.ts +133 -0
- package/src/management.ts +206 -0
- package/src/protocol.ts +249 -0
- package/src/remote-client-session-core.ts +786 -0
- package/src/remote-session-protocol.ts +660 -0
- package/src/remote-turn-coordinator.ts +505 -0
- package/src/remote.ts +177 -0
- package/src/repositories.ts +340 -0
- package/src/request-ids.ts +33 -0
- package/src/session.ts +1638 -0
- package/src/stream-events.ts +88 -0
- package/src/tool-helpers.ts +147 -0
- package/src/transport.ts +484 -0
- package/src/types.ts +1328 -0
- package/src/validation.ts +223 -0
- package/src/websocket.ts +22 -0
package/src/session.ts
ADDED
|
@@ -0,0 +1,1638 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session
|
|
3
|
+
*
|
|
4
|
+
* Represents a conversation session with a Letta agent.
|
|
5
|
+
* Implements the V2 API pattern: send() / receive()
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { SubprocessTransport } from "./transport.js";
|
|
9
|
+
import type {
|
|
10
|
+
InternalSessionOptions,
|
|
11
|
+
SDKMessage,
|
|
12
|
+
SDKInitMessage,
|
|
13
|
+
SDKResultMessage,
|
|
14
|
+
SDKErrorCode,
|
|
15
|
+
MessageWire,
|
|
16
|
+
WireMessage,
|
|
17
|
+
ControlRequest,
|
|
18
|
+
CanUseToolControlRequest,
|
|
19
|
+
CanUseToolResponse,
|
|
20
|
+
CanUseToolResponseAllow,
|
|
21
|
+
CanUseToolResponseDeny,
|
|
22
|
+
SendMessage,
|
|
23
|
+
AnyAgentTool,
|
|
24
|
+
ExecuteExternalToolRequest,
|
|
25
|
+
ListMessagesOptions,
|
|
26
|
+
ListMessagesResult,
|
|
27
|
+
ListModelsResult,
|
|
28
|
+
BootstrapStateOptions,
|
|
29
|
+
BootstrapStateResult,
|
|
30
|
+
ChangeDeviceStateOptions,
|
|
31
|
+
GetDeviceStatusOptions,
|
|
32
|
+
SessionDeviceStatus,
|
|
33
|
+
SDKStreamEventPayload,
|
|
34
|
+
RunTurnOptions,
|
|
35
|
+
RecoverPendingApprovalsOptions,
|
|
36
|
+
RecoverPendingApprovalsResult,
|
|
37
|
+
RemoveQueuedMessageResult,
|
|
38
|
+
SDKProtocolCommand,
|
|
39
|
+
SDKProtocolMessage,
|
|
40
|
+
SendCommandOptions,
|
|
41
|
+
UpdateModelOptions,
|
|
42
|
+
UpdateModelResult,
|
|
43
|
+
} from "./types.js";
|
|
44
|
+
import {
|
|
45
|
+
buildCanUseToolContext,
|
|
46
|
+
isHeadlessAutoAllowTool,
|
|
47
|
+
requiresRuntimeUserInput,
|
|
48
|
+
} from "./interactiveToolPolicy.js";
|
|
49
|
+
import { isUnrestrictedPermissionMode } from "./remote-client-session-core.js";
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
// All logging gated behind DEBUG_SDK env var
|
|
53
|
+
function sessionLog(tag: string, ...args: unknown[]) {
|
|
54
|
+
if (process.env.DEBUG_SDK) console.error(`[SDK-Session] [${tag}]`, ...args);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const MAX_BUFFERED_STREAM_MESSAGES = 100;
|
|
58
|
+
const DEFAULT_MAX_APPROVAL_RECOVERY_ATTEMPTS = 1;
|
|
59
|
+
const DEFAULT_APPROVAL_RECOVERY_TIMEOUT_MS = 5_000;
|
|
60
|
+
|
|
61
|
+
const KNOWN_SDK_ERROR_CODES = new Set<SDKErrorCode>([
|
|
62
|
+
"approval_conflict",
|
|
63
|
+
"approval_conflict_terminal",
|
|
64
|
+
"protocol_error",
|
|
65
|
+
"error",
|
|
66
|
+
"llm_api_error",
|
|
67
|
+
"max_steps",
|
|
68
|
+
"interrupted",
|
|
69
|
+
"stream_closed",
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
function isKnownSdkErrorCode(value: string): value is SDKErrorCode {
|
|
73
|
+
return KNOWN_SDK_ERROR_CODES.has(value as SDKErrorCode);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function toSdkErrorCode(value: string | undefined): SDKErrorCode | undefined {
|
|
77
|
+
if (!value || value.length === 0) return undefined;
|
|
78
|
+
return isKnownSdkErrorCode(value) ? value : undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isUnknownControlSubtypeError(
|
|
82
|
+
errorMessage: string | undefined,
|
|
83
|
+
subtype: string,
|
|
84
|
+
): boolean {
|
|
85
|
+
if (!errorMessage) return false;
|
|
86
|
+
return errorMessage
|
|
87
|
+
.toLowerCase()
|
|
88
|
+
.includes(`unknown control request subtype: ${subtype}`.toLowerCase());
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function extractApiErrorDetail(apiError: Record<string, unknown> | undefined): string | undefined {
|
|
92
|
+
const detail = apiError?.detail;
|
|
93
|
+
if (typeof detail !== "string") return undefined;
|
|
94
|
+
const trimmed = detail.trim();
|
|
95
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isApprovalConflictSignal(params: {
|
|
99
|
+
detail?: string;
|
|
100
|
+
message?: string;
|
|
101
|
+
stopReason?: string;
|
|
102
|
+
}): boolean {
|
|
103
|
+
if (params.stopReason === "requires_approval") return true;
|
|
104
|
+
// Stopgap heuristic until the server emits a first-class structured
|
|
105
|
+
// approval-conflict code in wire payloads.
|
|
106
|
+
const haystack = [params.detail, params.message]
|
|
107
|
+
.filter((value): value is string => typeof value === "string" && value.length > 0)
|
|
108
|
+
.join("\n")
|
|
109
|
+
.toLowerCase();
|
|
110
|
+
if (!haystack) return false;
|
|
111
|
+
return (
|
|
112
|
+
haystack.includes("waiting for approval on a tool call") ||
|
|
113
|
+
haystack.includes("cannot send a new message") ||
|
|
114
|
+
haystack.includes("requires_approval")
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
type BufferedStreamMessage = {
|
|
119
|
+
message: SDKMessage;
|
|
120
|
+
generation: number;
|
|
121
|
+
runId?: string;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export class Session implements AsyncDisposable {
|
|
125
|
+
private transport: SubprocessTransport;
|
|
126
|
+
private _agentId: string | null = null;
|
|
127
|
+
private _sessionId: string | null = null;
|
|
128
|
+
private _conversationId: string | null = null;
|
|
129
|
+
private initialized = false;
|
|
130
|
+
private closed = false;
|
|
131
|
+
private initializePromise: Promise<SDKInitMessage> | null = null;
|
|
132
|
+
private externalTools: Map<string, AnyAgentTool> = new Map();
|
|
133
|
+
private streamQueue: BufferedStreamMessage[] = [];
|
|
134
|
+
private streamResolvers: Array<(msg: BufferedStreamMessage | null) => void> = [];
|
|
135
|
+
private pumpPromise: Promise<void> | null = null;
|
|
136
|
+
private pumpClosed = false;
|
|
137
|
+
private droppedStreamMessages = 0;
|
|
138
|
+
// Monotonic counter incremented after each send(). Messages enqueued by the
|
|
139
|
+
// pump are tagged with the current generation; stream() filters out messages
|
|
140
|
+
// from earlier generations to prevent N-1 desync (stale events from a
|
|
141
|
+
// previous run leaking into the current run's stream).
|
|
142
|
+
private sendGeneration = 0;
|
|
143
|
+
// Run IDs that completed in the previous streamed turn. Used to drop
|
|
144
|
+
// late-arriving stale events from the old run if they arrive after send().
|
|
145
|
+
private lastCompletedRunIds = new Set<string>();
|
|
146
|
+
// Waiters for SDK-initiated control requests (e.g., listMessages).
|
|
147
|
+
// Keyed by request_id; pump resolves the matching waiter when it sees
|
|
148
|
+
// a control_response with that request_id instead of queuing it as a stream msg.
|
|
149
|
+
private controlResponseWaiters = new Map<
|
|
150
|
+
string,
|
|
151
|
+
(response: { subtype: string; response?: unknown; error?: string }) => void
|
|
152
|
+
>();
|
|
153
|
+
|
|
154
|
+
constructor(
|
|
155
|
+
private options: InternalSessionOptions = {}
|
|
156
|
+
) {
|
|
157
|
+
// Note: Validation happens in public API functions (createSession, createAgent, etc.)
|
|
158
|
+
if (options.reasoningEffort !== undefined) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
"reasoningEffort is not supported by this session. Create a Cloud, Remote, or local agent session before setting reasoning effort.",
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
this.transport = new SubprocessTransport(options);
|
|
164
|
+
|
|
165
|
+
// Store external tools in a map for quick lookup
|
|
166
|
+
if (options.tools) {
|
|
167
|
+
for (const tool of options.tools) {
|
|
168
|
+
this.externalTools.set(tool.name, tool);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Initialize the session (called automatically on first send).
|
|
175
|
+
*
|
|
176
|
+
* Single-flight: concurrent callers (including lazily-initializing entry
|
|
177
|
+
* points such as send()) share one in-flight initialization instead of
|
|
178
|
+
* each connecting the transport. A failed stdio attempt closes the session
|
|
179
|
+
* because its subprocess transport is no longer safe to reuse.
|
|
180
|
+
*/
|
|
181
|
+
async initialize(): Promise<SDKInitMessage> {
|
|
182
|
+
if (this.closed) {
|
|
183
|
+
throw new Error("Session is closed");
|
|
184
|
+
}
|
|
185
|
+
if (this.initializePromise) {
|
|
186
|
+
return this.initializePromise;
|
|
187
|
+
}
|
|
188
|
+
if (this.initialized) {
|
|
189
|
+
throw new Error("Session already initialized");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const attempt = this.performInitialize();
|
|
193
|
+
const memo = attempt
|
|
194
|
+
.catch((error: unknown) => {
|
|
195
|
+
// A failed stdio initialization can leave its one subprocess
|
|
196
|
+
// transport unusable. Closing this session cannot affect another
|
|
197
|
+
// initialize attempt because all callers share this promise.
|
|
198
|
+
this.cleanupFailedInitialize();
|
|
199
|
+
throw error;
|
|
200
|
+
})
|
|
201
|
+
.finally(() => {
|
|
202
|
+
if (this.initializePromise === memo) {
|
|
203
|
+
this.initializePromise = null;
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
this.initializePromise = memo;
|
|
207
|
+
return memo;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private async performInitialize(): Promise<SDKInitMessage> {
|
|
211
|
+
sessionLog("init", "connecting transport...");
|
|
212
|
+
await this.transport.connect();
|
|
213
|
+
sessionLog("init", "transport connected, sending initialize request");
|
|
214
|
+
|
|
215
|
+
// Send initialize control request
|
|
216
|
+
await this.transport.write({
|
|
217
|
+
type: "control_request",
|
|
218
|
+
request_id: "init_1",
|
|
219
|
+
request: { subtype: "initialize" },
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Wait for init message
|
|
223
|
+
sessionLog("init", "waiting for init message from CLI...");
|
|
224
|
+
for await (const msg of this.transport.messages()) {
|
|
225
|
+
sessionLog("init", `received wire message: type=${msg.type}`);
|
|
226
|
+
|
|
227
|
+
if (msg.type === "control_request") {
|
|
228
|
+
const handled = await this.handleControlRequest(msg as ControlRequest);
|
|
229
|
+
if (!handled) {
|
|
230
|
+
const wireMsgAny = msg as unknown as Record<string, unknown>;
|
|
231
|
+
sessionLog("init", `DROPPED unsupported control_request: subtype=${(wireMsgAny.request as Record<string, unknown>)?.subtype || "N/A"}`);
|
|
232
|
+
}
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (msg.type === "system" && "subtype" in msg && msg.subtype === "init") {
|
|
237
|
+
const initMsg = msg as WireMessage & {
|
|
238
|
+
agent_id: string;
|
|
239
|
+
session_id: string;
|
|
240
|
+
conversation_id: string;
|
|
241
|
+
model: string;
|
|
242
|
+
tools: string[];
|
|
243
|
+
memfs_enabled?: boolean;
|
|
244
|
+
skill_sources?: Array<"bundled" | "global" | "agent" | "project">;
|
|
245
|
+
system_info_reminder_enabled?: boolean;
|
|
246
|
+
reflection_trigger?: "off" | "step-count" | "compaction-event";
|
|
247
|
+
reflection_behavior?: "reminder" | "auto-launch";
|
|
248
|
+
reflection_step_count?: number;
|
|
249
|
+
};
|
|
250
|
+
this._agentId = initMsg.agent_id;
|
|
251
|
+
this._sessionId = initMsg.session_id;
|
|
252
|
+
this._conversationId = initMsg.conversation_id;
|
|
253
|
+
|
|
254
|
+
// Register external tools with CLI
|
|
255
|
+
if (this.externalTools.size > 0) {
|
|
256
|
+
await this.registerExternalTools();
|
|
257
|
+
}
|
|
258
|
+
if (this.closed) {
|
|
259
|
+
throw new Error("Session is closed");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
this.initialized = true;
|
|
263
|
+
this.startBackgroundPump();
|
|
264
|
+
|
|
265
|
+
// Include external tool names in the tools list
|
|
266
|
+
const allTools = [
|
|
267
|
+
...initMsg.tools,
|
|
268
|
+
...Array.from(this.externalTools.keys()),
|
|
269
|
+
];
|
|
270
|
+
|
|
271
|
+
sessionLog("init", `initialized: agent=${initMsg.agent_id} conversation=${initMsg.conversation_id} model=${initMsg.model} tools=${allTools.length} (${this.externalTools.size} external)`);
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
type: "init",
|
|
275
|
+
agentId: initMsg.agent_id,
|
|
276
|
+
sessionId: initMsg.session_id,
|
|
277
|
+
conversationId: initMsg.conversation_id,
|
|
278
|
+
model: initMsg.model,
|
|
279
|
+
tools: allTools,
|
|
280
|
+
memfsEnabled: initMsg.memfs_enabled,
|
|
281
|
+
skillSources: initMsg.skill_sources,
|
|
282
|
+
systemInfoReminderEnabled: initMsg.system_info_reminder_enabled,
|
|
283
|
+
dreaming:
|
|
284
|
+
initMsg.reflection_trigger &&
|
|
285
|
+
initMsg.reflection_behavior &&
|
|
286
|
+
typeof initMsg.reflection_step_count === "number"
|
|
287
|
+
? {
|
|
288
|
+
trigger: initMsg.reflection_trigger,
|
|
289
|
+
behavior: initMsg.reflection_behavior,
|
|
290
|
+
stepCount: initMsg.reflection_step_count,
|
|
291
|
+
}
|
|
292
|
+
: undefined,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const stderr = this.transport.getStderr();
|
|
298
|
+
const detail = stderr ? `\nCLI stderr:\n${stderr}` : '';
|
|
299
|
+
sessionLog("init", `ERROR: transport closed before init message received${detail}`);
|
|
300
|
+
throw new Error(`Failed to initialize session - no init message received${detail}`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private cleanupFailedInitialize(): void {
|
|
304
|
+
this.transport.close();
|
|
305
|
+
this.closed = true;
|
|
306
|
+
this.initialized = false;
|
|
307
|
+
this._agentId = null;
|
|
308
|
+
this._sessionId = null;
|
|
309
|
+
this._conversationId = null;
|
|
310
|
+
this.pumpClosed = true;
|
|
311
|
+
this.resolveAllStreamWaiters(null);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Send a message to the agent
|
|
316
|
+
*
|
|
317
|
+
* @param message - Text string or multimodal content array
|
|
318
|
+
*
|
|
319
|
+
* @example
|
|
320
|
+
* // Simple text
|
|
321
|
+
* await session.send("Hello!");
|
|
322
|
+
*
|
|
323
|
+
* @example
|
|
324
|
+
* // With image
|
|
325
|
+
* await session.send([
|
|
326
|
+
* { type: "text", text: "What's in this image?" },
|
|
327
|
+
* { type: "image", source: { type: "base64", mediaType: "image/png", data: "..." } }
|
|
328
|
+
* ]);
|
|
329
|
+
*/
|
|
330
|
+
async send(message: SendMessage): Promise<void> {
|
|
331
|
+
if (!this.initialized) {
|
|
332
|
+
sessionLog("send", "auto-initializing (not yet initialized)");
|
|
333
|
+
await this.initialize();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const preview = typeof message === "string"
|
|
337
|
+
? message.slice(0, 100)
|
|
338
|
+
: Array.isArray(message) ? `[multimodal: ${message.length} parts]` : String(message).slice(0, 100);
|
|
339
|
+
sessionLog("send", `sending message: ${preview}${typeof message === "string" && message.length > 100 ? "..." : ""}`);
|
|
340
|
+
|
|
341
|
+
// Clear stale messages from previous turn to prevent desync
|
|
342
|
+
if (this.streamQueue.length > 0) {
|
|
343
|
+
sessionLog("send", `clearing ${this.streamQueue.length} stale messages from previous turn`);
|
|
344
|
+
this.streamQueue.length = 0;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
await this.transport.write({
|
|
348
|
+
type: "user",
|
|
349
|
+
message: { role: "user", content: message },
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// Advance generation AFTER the write so any messages the pump enqueues
|
|
353
|
+
// during the await (from the previous run's lingering events) are tagged
|
|
354
|
+
// with the old generation and will be filtered by stream().
|
|
355
|
+
this.sendGeneration++;
|
|
356
|
+
sessionLog("send", `message written to transport (generation=${this.sendGeneration})`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Run a full turn (send + stream terminal result), with optional bounded
|
|
361
|
+
* SDK-owned approval-conflict recovery.
|
|
362
|
+
*/
|
|
363
|
+
async runTurn(
|
|
364
|
+
message: SendMessage,
|
|
365
|
+
options: RunTurnOptions = {},
|
|
366
|
+
): Promise<SDKResultMessage> {
|
|
367
|
+
const maxApprovalRecoveryAttempts =
|
|
368
|
+
this.resolveMaxApprovalRecoveryAttempts(options);
|
|
369
|
+
const recoveryTimeoutMs = this.resolveApprovalRecoveryTimeoutMs(options);
|
|
370
|
+
|
|
371
|
+
let recoveryAttempts = 0;
|
|
372
|
+
let result = await this.runSingleTurn(message);
|
|
373
|
+
|
|
374
|
+
while (
|
|
375
|
+
!result.success &&
|
|
376
|
+
result.approvalConflict === true &&
|
|
377
|
+
recoveryAttempts < maxApprovalRecoveryAttempts
|
|
378
|
+
) {
|
|
379
|
+
recoveryAttempts += 1;
|
|
380
|
+
const recovery = await this.recoverPendingApprovals({
|
|
381
|
+
timeoutMs: recoveryTimeoutMs,
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
if (!recovery.recovered) {
|
|
385
|
+
return this.toTerminalApprovalConflictResult(result, {
|
|
386
|
+
recoveryAttempts,
|
|
387
|
+
detail: recovery.detail,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
result = await this.runSingleTurn(message);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (!result.success && result.approvalConflict === true) {
|
|
395
|
+
return this.toTerminalApprovalConflictResult(result, { recoveryAttempts });
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (recoveryAttempts > 0) {
|
|
399
|
+
result.recoveryAttempts = recoveryAttempts;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return result;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Ask the CLI to recover pending approvals for the current
|
|
407
|
+
* agent/conversation context.
|
|
408
|
+
*/
|
|
409
|
+
async recoverPendingApprovals(
|
|
410
|
+
options: RecoverPendingApprovalsOptions = {},
|
|
411
|
+
): Promise<RecoverPendingApprovalsResult> {
|
|
412
|
+
if (!this.initialized) {
|
|
413
|
+
await this.initialize();
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const timeoutMs =
|
|
417
|
+
options.timeoutMs ??
|
|
418
|
+
this.options.approvalRecoveryTimeoutMs ??
|
|
419
|
+
DEFAULT_APPROVAL_RECOVERY_TIMEOUT_MS;
|
|
420
|
+
|
|
421
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) {
|
|
422
|
+
throw new Error(
|
|
423
|
+
"Invalid approval recovery timeout. Expected a positive integer.",
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const requestId = `recover-approvals-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
428
|
+
|
|
429
|
+
let resp: { subtype: string; response?: unknown; error?: string };
|
|
430
|
+
try {
|
|
431
|
+
resp = await this.requestControlResponse(
|
|
432
|
+
requestId,
|
|
433
|
+
{
|
|
434
|
+
subtype: "recover_pending_approvals",
|
|
435
|
+
...(this._agentId ? { agent_id: this._agentId } : {}),
|
|
436
|
+
...(this._conversationId ? { conversation_id: this._conversationId } : {}),
|
|
437
|
+
},
|
|
438
|
+
{ timeoutMs },
|
|
439
|
+
);
|
|
440
|
+
} catch (error) {
|
|
441
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
442
|
+
return {
|
|
443
|
+
recovered: false,
|
|
444
|
+
unsupported: false,
|
|
445
|
+
detail,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (resp.subtype === "error") {
|
|
450
|
+
const detail = resp.error ?? "recover_pending_approvals failed";
|
|
451
|
+
return {
|
|
452
|
+
recovered: false,
|
|
453
|
+
pendingApproval: true,
|
|
454
|
+
unsupported: isUnknownControlSubtypeError(
|
|
455
|
+
detail,
|
|
456
|
+
"recover_pending_approvals",
|
|
457
|
+
),
|
|
458
|
+
detail,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return {
|
|
463
|
+
recovered: true,
|
|
464
|
+
pendingApproval: false,
|
|
465
|
+
unsupported: false,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
private resolveMaxApprovalRecoveryAttempts(options: RunTurnOptions): number {
|
|
470
|
+
const value =
|
|
471
|
+
options.maxApprovalRecoveryAttempts ??
|
|
472
|
+
this.options.maxApprovalRecoveryAttempts ??
|
|
473
|
+
DEFAULT_MAX_APPROVAL_RECOVERY_ATTEMPTS;
|
|
474
|
+
|
|
475
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
476
|
+
throw new Error(
|
|
477
|
+
"Invalid maxApprovalRecoveryAttempts. Expected a non-negative integer.",
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return value;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
private resolveApprovalRecoveryTimeoutMs(options: RunTurnOptions): number {
|
|
485
|
+
const value =
|
|
486
|
+
options.recoveryTimeoutMs ??
|
|
487
|
+
this.options.approvalRecoveryTimeoutMs ??
|
|
488
|
+
DEFAULT_APPROVAL_RECOVERY_TIMEOUT_MS;
|
|
489
|
+
|
|
490
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
491
|
+
throw new Error(
|
|
492
|
+
"Invalid approval recovery timeout. Expected a positive integer.",
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return value;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
private toTerminalApprovalConflictResult(
|
|
500
|
+
result: SDKResultMessage,
|
|
501
|
+
options: { recoveryAttempts: number; detail?: string },
|
|
502
|
+
): SDKResultMessage {
|
|
503
|
+
return {
|
|
504
|
+
...result,
|
|
505
|
+
success: false,
|
|
506
|
+
error: "approval_conflict_terminal",
|
|
507
|
+
errorCode: "approval_conflict_terminal",
|
|
508
|
+
approvalConflict: true,
|
|
509
|
+
recoverable: false,
|
|
510
|
+
recoveryAttempts: options.recoveryAttempts,
|
|
511
|
+
errorDetail:
|
|
512
|
+
options.detail ||
|
|
513
|
+
result.errorDetail ||
|
|
514
|
+
"Approval conflict remained unresolved after bounded SDK recovery.",
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
private async runSingleTurn(message: SendMessage): Promise<SDKResultMessage> {
|
|
519
|
+
await this.send(message);
|
|
520
|
+
|
|
521
|
+
let latestApprovalConflictDetail: string | undefined;
|
|
522
|
+
let latestNonConflictErrorDetail: string | undefined;
|
|
523
|
+
|
|
524
|
+
for await (const msg of this.stream()) {
|
|
525
|
+
if (msg.type === "error") {
|
|
526
|
+
const detail = msg.errorDetail || msg.message;
|
|
527
|
+
if (msg.approvalConflict) {
|
|
528
|
+
latestApprovalConflictDetail = detail;
|
|
529
|
+
} else {
|
|
530
|
+
latestNonConflictErrorDetail = detail;
|
|
531
|
+
}
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (msg.type === "result") {
|
|
536
|
+
if (!msg.success && !msg.errorDetail) {
|
|
537
|
+
if (msg.approvalConflict && latestApprovalConflictDetail) {
|
|
538
|
+
return {
|
|
539
|
+
...msg,
|
|
540
|
+
errorDetail: latestApprovalConflictDetail,
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (!msg.approvalConflict && latestNonConflictErrorDetail) {
|
|
545
|
+
return {
|
|
546
|
+
...msg,
|
|
547
|
+
errorDetail: latestNonConflictErrorDetail,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return msg;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return {
|
|
557
|
+
type: "result",
|
|
558
|
+
success: false,
|
|
559
|
+
error: "stream_closed",
|
|
560
|
+
errorCode: "stream_closed",
|
|
561
|
+
recoverable: false,
|
|
562
|
+
errorDetail: "Stream ended before terminal result",
|
|
563
|
+
durationMs: 0,
|
|
564
|
+
conversationId: this._conversationId,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Stream messages from the agent
|
|
570
|
+
*/
|
|
571
|
+
async *stream(): AsyncGenerator<SDKMessage> {
|
|
572
|
+
const streamStart = Date.now();
|
|
573
|
+
const minGeneration = this.sendGeneration;
|
|
574
|
+
let yieldCount = 0;
|
|
575
|
+
let staleCount = 0;
|
|
576
|
+
let staleRunIdCount = 0;
|
|
577
|
+
let gotResult = false;
|
|
578
|
+
const currentStreamRunIds = new Set<string>();
|
|
579
|
+
const staleRunIds = new Set(this.lastCompletedRunIds);
|
|
580
|
+
const approvalConflictDetailsByRunId = new Map<string, string>();
|
|
581
|
+
let latestApprovalConflictDetail: string | undefined;
|
|
582
|
+
|
|
583
|
+
this.startBackgroundPump();
|
|
584
|
+
sessionLog("stream", `starting stream (agent=${this._agentId}, conversation=${this._conversationId}, generation=${minGeneration})`);
|
|
585
|
+
|
|
586
|
+
while (true) {
|
|
587
|
+
const bufferedMsg = await this.nextBufferedMessage();
|
|
588
|
+
if (!bufferedMsg) {
|
|
589
|
+
break;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Filter stale messages from previous runs. Messages enqueued before
|
|
593
|
+
// the current send() carry an older generation tag.
|
|
594
|
+
if (bufferedMsg.generation < minGeneration) {
|
|
595
|
+
staleCount++;
|
|
596
|
+
sessionLog("stream", `discarding stale message: type=${bufferedMsg.message.type} generation=${bufferedMsg.generation} (current=${minGeneration})`);
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Filter late old-run messages that arrive after send() has already
|
|
601
|
+
// advanced generation and stream queue was cleared.
|
|
602
|
+
if (bufferedMsg.runId && staleRunIds.has(bufferedMsg.runId)) {
|
|
603
|
+
staleRunIdCount++;
|
|
604
|
+
sessionLog("stream", `discarding stale message: type=${bufferedMsg.message.type} runId=${bufferedMsg.runId}`);
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (bufferedMsg.runId) {
|
|
609
|
+
currentStreamRunIds.add(bufferedMsg.runId);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const sdkMsg = bufferedMsg.message;
|
|
613
|
+
|
|
614
|
+
if (sdkMsg.type === "error" && sdkMsg.approvalConflict) {
|
|
615
|
+
const detail = sdkMsg.errorDetail || sdkMsg.message;
|
|
616
|
+
if (detail) {
|
|
617
|
+
latestApprovalConflictDetail = detail;
|
|
618
|
+
if (sdkMsg.runId) {
|
|
619
|
+
approvalConflictDetailsByRunId.set(sdkMsg.runId, detail);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
let normalizedMsg: SDKMessage = sdkMsg;
|
|
625
|
+
|
|
626
|
+
if (sdkMsg.type === "result" && !sdkMsg.success) {
|
|
627
|
+
let detail = sdkMsg.errorDetail;
|
|
628
|
+
if (!detail && Array.isArray(sdkMsg.runIds)) {
|
|
629
|
+
for (const runId of sdkMsg.runIds) {
|
|
630
|
+
const fromRun = approvalConflictDetailsByRunId.get(runId);
|
|
631
|
+
if (fromRun) {
|
|
632
|
+
detail = fromRun;
|
|
633
|
+
break;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
if (!detail) {
|
|
638
|
+
detail = latestApprovalConflictDetail;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const approvalConflict =
|
|
642
|
+
sdkMsg.approvalConflict === true
|
|
643
|
+
|| isApprovalConflictSignal({
|
|
644
|
+
detail,
|
|
645
|
+
message: sdkMsg.errorCode || sdkMsg.error,
|
|
646
|
+
stopReason: sdkMsg.stopReason,
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
if (approvalConflict) {
|
|
650
|
+
const normalizedErrorCode =
|
|
651
|
+
!sdkMsg.errorCode || sdkMsg.errorCode === "error"
|
|
652
|
+
? "approval_conflict"
|
|
653
|
+
: sdkMsg.errorCode;
|
|
654
|
+
const normalizedError =
|
|
655
|
+
!sdkMsg.error || sdkMsg.error === "error"
|
|
656
|
+
? normalizedErrorCode
|
|
657
|
+
: sdkMsg.error;
|
|
658
|
+
|
|
659
|
+
normalizedMsg = {
|
|
660
|
+
...sdkMsg,
|
|
661
|
+
approvalConflict: true,
|
|
662
|
+
recoverable: sdkMsg.recoverable ?? true,
|
|
663
|
+
...(detail ? { errorDetail: detail } : {}),
|
|
664
|
+
errorCode: normalizedErrorCode,
|
|
665
|
+
error: normalizedError,
|
|
666
|
+
};
|
|
667
|
+
} else if (!sdkMsg.errorCode && sdkMsg.error) {
|
|
668
|
+
const errorCode = toSdkErrorCode(sdkMsg.error);
|
|
669
|
+
if (errorCode) {
|
|
670
|
+
normalizedMsg = {
|
|
671
|
+
...sdkMsg,
|
|
672
|
+
errorCode,
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
yieldCount++;
|
|
679
|
+
sessionLog("stream", `yield #${yieldCount}: type=${normalizedMsg.type}${normalizedMsg.type === "result" ? ` success=${(normalizedMsg as SDKResultMessage).success} error=${(normalizedMsg as SDKResultMessage).error || "none"}` : ""}`);
|
|
680
|
+
yield normalizedMsg;
|
|
681
|
+
|
|
682
|
+
// Stop on result message
|
|
683
|
+
if (normalizedMsg.type === "result") {
|
|
684
|
+
gotResult = true;
|
|
685
|
+
this.updateCompletedRunIds((normalizedMsg as SDKResultMessage).runIds, currentStreamRunIds);
|
|
686
|
+
break;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const elapsed = Date.now() - streamStart;
|
|
691
|
+
sessionLog("stream", `stream ended: duration=${elapsed}ms yielded=${yieldCount} staleFiltered=${staleCount} staleRunIdFiltered=${staleRunIdCount} dropped=${this.droppedStreamMessages} gotResult=${gotResult}`);
|
|
692
|
+
if (!gotResult) {
|
|
693
|
+
sessionLog("stream", "WARNING: stream ended WITHOUT a result message -- transport may have closed unexpectedly");
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
private startBackgroundPump(): void {
|
|
698
|
+
if (this.pumpPromise) {
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
this.pumpClosed = false;
|
|
703
|
+
this.pumpPromise = this.runBackgroundPump()
|
|
704
|
+
.catch((err) => {
|
|
705
|
+
sessionLog("pump", `ERROR: ${err instanceof Error ? err.message : String(err)}`);
|
|
706
|
+
})
|
|
707
|
+
.finally(() => {
|
|
708
|
+
this.pumpClosed = true;
|
|
709
|
+
this.resolveAllStreamWaiters(null);
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
private async runBackgroundPump(): Promise<void> {
|
|
714
|
+
sessionLog("pump", "background pump started");
|
|
715
|
+
|
|
716
|
+
// Tool call chunks are emitted immediately (no buffering).
|
|
717
|
+
// Consumers that need complete arguments should accumulate rawArguments
|
|
718
|
+
// across chunks sharing the same toolCallId on their side.
|
|
719
|
+
//
|
|
720
|
+
// Index-to-ID mapping: The Letta API follows the OpenAI streaming format
|
|
721
|
+
// for parallel tool calls -- only the first chunk per tool call includes
|
|
722
|
+
// tool_call_id, subsequent chunks identify themselves via index only.
|
|
723
|
+
// We maintain a lightweight index->id map so transformMessage() can
|
|
724
|
+
// resolve the ID for every chunk without buffering.
|
|
725
|
+
const indexToToolCallId = new Map<number, string>();
|
|
726
|
+
|
|
727
|
+
for await (const wireMsg of this.transport.messages()) {
|
|
728
|
+
const wireMsgAny = wireMsg as unknown as Record<string, unknown>;
|
|
729
|
+
|
|
730
|
+
if (wireMsg.type === "control_request") {
|
|
731
|
+
const handled = await this.handleControlRequest(wireMsg as ControlRequest);
|
|
732
|
+
if (!handled) {
|
|
733
|
+
sessionLog("pump", `DROPPED unsupported control_request: subtype=${(wireMsgAny.request as Record<string, unknown>)?.subtype || "N/A"}`);
|
|
734
|
+
}
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Route control_response to a registered waiter (e.g., from listMessages).
|
|
739
|
+
// Unmatched control_responses are logged and dropped — they never reach the stream.
|
|
740
|
+
if (wireMsg.type === "control_response") {
|
|
741
|
+
const respMsg = wireMsg as unknown as {
|
|
742
|
+
response: { subtype: string; request_id?: string; response?: unknown; error?: string };
|
|
743
|
+
};
|
|
744
|
+
const requestId = respMsg.response?.request_id;
|
|
745
|
+
if (requestId && this.controlResponseWaiters.has(requestId)) {
|
|
746
|
+
const resolve = this.controlResponseWaiters.get(requestId)!;
|
|
747
|
+
this.controlResponseWaiters.delete(requestId);
|
|
748
|
+
resolve(respMsg.response);
|
|
749
|
+
} else {
|
|
750
|
+
sessionLog("pump", `DROPPED unmatched control_response: request_id=${requestId ?? "N/A"}`);
|
|
751
|
+
}
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// For tool_call_message chunks, resolve index-based IDs before transform.
|
|
756
|
+
// The first chunk has both tool_call_id and index; subsequent chunks have
|
|
757
|
+
// only index. Patch the wire message so transformMessage() always sees an ID.
|
|
758
|
+
const messageType = wireMsgAny.message_type as string | undefined;
|
|
759
|
+
if (wireMsg.type === "message" && (messageType === "tool_call_message" || messageType === "approval_request_message")) {
|
|
760
|
+
const toolCalls = wireMsgAny.tool_calls as Array<Record<string, unknown>> | undefined;
|
|
761
|
+
const toolCall = wireMsgAny.tool_call as Record<string, unknown> | undefined;
|
|
762
|
+
const tc = toolCalls?.[0] || toolCall;
|
|
763
|
+
if (tc) {
|
|
764
|
+
const fnObj = tc.function as Record<string, unknown> | undefined;
|
|
765
|
+
const tcId = (tc.tool_call_id as string | undefined) ?? (tc.id as string | undefined);
|
|
766
|
+
const tcIndex = tc.index as number | undefined;
|
|
767
|
+
|
|
768
|
+
if (tcId && tcIndex !== undefined) {
|
|
769
|
+
indexToToolCallId.set(tcIndex, tcId);
|
|
770
|
+
} else if (!tcId && tcIndex !== undefined) {
|
|
771
|
+
const resolvedId = indexToToolCallId.get(tcIndex);
|
|
772
|
+
if (resolvedId) {
|
|
773
|
+
// Patch the resolved ID into the wire message for transformMessage
|
|
774
|
+
if (fnObj) {
|
|
775
|
+
tc.id = resolvedId;
|
|
776
|
+
} else {
|
|
777
|
+
tc.tool_call_id = resolvedId;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const sdkMsg = this.transformMessage(wireMsg);
|
|
785
|
+
if (sdkMsg) {
|
|
786
|
+
this.enqueueStreamMessage(sdkMsg);
|
|
787
|
+
} else {
|
|
788
|
+
sessionLog("pump", `DROPPED wire message: type=${wireMsg.type} message_type=${wireMsgAny.message_type || "N/A"} subtype=${wireMsgAny.subtype || "N/A"}`);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
sessionLog("pump", "background pump ended");
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
private async handleControlRequest(controlReq: ControlRequest): Promise<boolean> {
|
|
796
|
+
// Widen to string to allow SDK-extension subtypes not in the protocol union
|
|
797
|
+
const subtype: string = controlReq.request.subtype;
|
|
798
|
+
sessionLog("pump", `control_request: subtype=${subtype} tool=${(controlReq.request as CanUseToolControlRequest).tool_name || "N/A"}`);
|
|
799
|
+
|
|
800
|
+
if (subtype === "can_use_tool") {
|
|
801
|
+
await this.handleCanUseTool(
|
|
802
|
+
controlReq.request_id,
|
|
803
|
+
controlReq.request as CanUseToolControlRequest
|
|
804
|
+
);
|
|
805
|
+
return true;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
if (subtype === "execute_external_tool") {
|
|
809
|
+
// SDK extension: not in protocol ControlRequestBody union, extract fields via Record
|
|
810
|
+
const rawReq = controlReq.request as Record<string, unknown>;
|
|
811
|
+
await this.handleExecuteExternalTool(
|
|
812
|
+
controlReq.request_id,
|
|
813
|
+
{
|
|
814
|
+
subtype: "execute_external_tool",
|
|
815
|
+
tool_call_id: rawReq.tool_call_id as string,
|
|
816
|
+
tool_name: rawReq.tool_name as string,
|
|
817
|
+
input: rawReq.input as Record<string, unknown>,
|
|
818
|
+
}
|
|
819
|
+
);
|
|
820
|
+
return true;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
return false;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
private enqueueStreamMessage(msg: SDKMessage): void {
|
|
827
|
+
const bufferedMsg: BufferedStreamMessage = {
|
|
828
|
+
message: msg,
|
|
829
|
+
generation: this.sendGeneration,
|
|
830
|
+
runId: this.getMessageRunId(msg),
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
if (this.streamResolvers.length > 0) {
|
|
834
|
+
const resolve = this.streamResolvers.shift()!;
|
|
835
|
+
resolve(bufferedMsg);
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
if (this.streamQueue.length >= MAX_BUFFERED_STREAM_MESSAGES) {
|
|
840
|
+
this.streamQueue.shift();
|
|
841
|
+
this.droppedStreamMessages++;
|
|
842
|
+
sessionLog("pump", `stream queue overflow: dropped oldest message (total_dropped=${this.droppedStreamMessages}, max=${MAX_BUFFERED_STREAM_MESSAGES})`);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
this.streamQueue.push(bufferedMsg);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
private async nextBufferedMessage(): Promise<BufferedStreamMessage | null> {
|
|
849
|
+
if (this.streamQueue.length > 0) {
|
|
850
|
+
return this.streamQueue.shift()!;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
if (this.pumpClosed) {
|
|
854
|
+
return null;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
return new Promise((resolve) => {
|
|
858
|
+
this.streamResolvers.push(resolve);
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
private resolveAllStreamWaiters(msg: BufferedStreamMessage | null): void {
|
|
863
|
+
for (const resolve of this.streamResolvers) {
|
|
864
|
+
resolve(msg);
|
|
865
|
+
}
|
|
866
|
+
this.streamResolvers = [];
|
|
867
|
+
// Also cancel any in-flight control request waiters (e.g., listMessages)
|
|
868
|
+
for (const resolve of this.controlResponseWaiters.values()) {
|
|
869
|
+
resolve({ subtype: "error", error: "session closed" });
|
|
870
|
+
}
|
|
871
|
+
this.controlResponseWaiters.clear();
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
private getMessageRunId(msg: SDKMessage): string | undefined {
|
|
875
|
+
switch (msg.type) {
|
|
876
|
+
case "assistant":
|
|
877
|
+
case "tool_call":
|
|
878
|
+
case "tool_result":
|
|
879
|
+
case "reasoning":
|
|
880
|
+
case "error":
|
|
881
|
+
case "retry":
|
|
882
|
+
return msg.runId;
|
|
883
|
+
default:
|
|
884
|
+
return undefined;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
private updateCompletedRunIds(
|
|
889
|
+
resultRunIds: string[] | undefined,
|
|
890
|
+
streamedRunIds: Set<string>,
|
|
891
|
+
): void {
|
|
892
|
+
const nextRunIds = new Set<string>();
|
|
893
|
+
|
|
894
|
+
if (Array.isArray(resultRunIds)) {
|
|
895
|
+
for (const runId of resultRunIds) {
|
|
896
|
+
if (runId) {
|
|
897
|
+
nextRunIds.add(runId);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
for (const runId of streamedRunIds) {
|
|
903
|
+
if (runId) {
|
|
904
|
+
nextRunIds.add(runId);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
this.lastCompletedRunIds = nextRunIds;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Register external tools with the CLI
|
|
913
|
+
*/
|
|
914
|
+
private async registerExternalTools(): Promise<void> {
|
|
915
|
+
const toolDefs = Array.from(this.externalTools.values()).map((tool) => ({
|
|
916
|
+
name: tool.name,
|
|
917
|
+
label: tool.label,
|
|
918
|
+
description: tool.description,
|
|
919
|
+
// Convert TypeBox schema to plain JSON Schema
|
|
920
|
+
parameters: this.schemaToJsonSchema(tool.parameters),
|
|
921
|
+
}));
|
|
922
|
+
|
|
923
|
+
sessionLog("registerTools", `registering ${toolDefs.length} external tools: ${toolDefs.map(t => t.name).join(", ")}`);
|
|
924
|
+
|
|
925
|
+
await this.transport.write({
|
|
926
|
+
type: "control_request",
|
|
927
|
+
request_id: `register_tools_${Date.now()}`,
|
|
928
|
+
request: {
|
|
929
|
+
subtype: "register_external_tools",
|
|
930
|
+
tools: toolDefs,
|
|
931
|
+
},
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Convert TypeBox schema to JSON Schema
|
|
937
|
+
*/
|
|
938
|
+
private schemaToJsonSchema(schema: unknown): Record<string, unknown> {
|
|
939
|
+
// TypeBox schemas are already JSON Schema compatible
|
|
940
|
+
// Just need to extract the schema object
|
|
941
|
+
if (schema && typeof schema === "object") {
|
|
942
|
+
// TypeBox schemas have these JSON Schema properties
|
|
943
|
+
const s = schema as Record<string, unknown>;
|
|
944
|
+
return {
|
|
945
|
+
type: s.type,
|
|
946
|
+
properties: s.properties,
|
|
947
|
+
required: s.required,
|
|
948
|
+
additionalProperties: s.additionalProperties,
|
|
949
|
+
description: s.description,
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
return { type: "object" };
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* Handle execute_external_tool control request from CLI
|
|
957
|
+
*/
|
|
958
|
+
private async handleExecuteExternalTool(
|
|
959
|
+
requestId: string,
|
|
960
|
+
req: ExecuteExternalToolRequest
|
|
961
|
+
): Promise<void> {
|
|
962
|
+
const tool = this.externalTools.get(req.tool_name);
|
|
963
|
+
|
|
964
|
+
if (!tool) {
|
|
965
|
+
// Tool not found - send error result
|
|
966
|
+
sessionLog("executeExternalTool", `ERROR: unknown tool ${req.tool_name}`);
|
|
967
|
+
await this.transport.write({
|
|
968
|
+
type: "control_response",
|
|
969
|
+
response: {
|
|
970
|
+
subtype: "external_tool_result",
|
|
971
|
+
request_id: requestId,
|
|
972
|
+
tool_call_id: req.tool_call_id,
|
|
973
|
+
content: [{ type: "text", text: `Unknown external tool: ${req.tool_name}` }],
|
|
974
|
+
is_error: true,
|
|
975
|
+
},
|
|
976
|
+
});
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
try {
|
|
981
|
+
sessionLog("executeExternalTool", `executing ${req.tool_name} (call_id=${req.tool_call_id})`);
|
|
982
|
+
// Execute the tool
|
|
983
|
+
const result = await tool.execute(req.tool_call_id, req.input);
|
|
984
|
+
|
|
985
|
+
// Send success result
|
|
986
|
+
await this.transport.write({
|
|
987
|
+
type: "control_response",
|
|
988
|
+
response: {
|
|
989
|
+
subtype: "external_tool_result",
|
|
990
|
+
request_id: requestId,
|
|
991
|
+
tool_call_id: req.tool_call_id,
|
|
992
|
+
content: result.content,
|
|
993
|
+
is_error: false,
|
|
994
|
+
},
|
|
995
|
+
});
|
|
996
|
+
sessionLog("executeExternalTool", `${req.tool_name} completed successfully`);
|
|
997
|
+
} catch (err) {
|
|
998
|
+
// Send error result
|
|
999
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
1000
|
+
sessionLog("executeExternalTool", `${req.tool_name} failed: ${errorMessage}`);
|
|
1001
|
+
await this.transport.write({
|
|
1002
|
+
type: "control_response",
|
|
1003
|
+
response: {
|
|
1004
|
+
subtype: "external_tool_result",
|
|
1005
|
+
request_id: requestId,
|
|
1006
|
+
tool_call_id: req.tool_call_id,
|
|
1007
|
+
content: [{ type: "text", text: `Tool execution error: ${errorMessage}` }],
|
|
1008
|
+
is_error: true,
|
|
1009
|
+
},
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* Handle can_use_tool control request from CLI (Claude SDK compatible format)
|
|
1016
|
+
*/
|
|
1017
|
+
private async handleCanUseTool(
|
|
1018
|
+
requestId: string,
|
|
1019
|
+
req: CanUseToolControlRequest
|
|
1020
|
+
): Promise<void> {
|
|
1021
|
+
let response: CanUseToolResponse;
|
|
1022
|
+
const toolName = req.tool_name;
|
|
1023
|
+
const hasCallback = typeof this.options.canUseTool === "function";
|
|
1024
|
+
const toolNeedsRuntimeUserInput = requiresRuntimeUserInput(toolName);
|
|
1025
|
+
const autoAllowWithoutCallback =
|
|
1026
|
+
isHeadlessAutoAllowTool(toolName);
|
|
1027
|
+
|
|
1028
|
+
sessionLog("canUseTool", `tool=${toolName} mode=${this.options.permissionMode || "standard"} requestId=${requestId}`);
|
|
1029
|
+
|
|
1030
|
+
// Tools that require runtime user input cannot be auto-allowed without a callback.
|
|
1031
|
+
if (toolNeedsRuntimeUserInput && !hasCallback) {
|
|
1032
|
+
response = {
|
|
1033
|
+
behavior: "deny",
|
|
1034
|
+
message: "No canUseTool callback registered",
|
|
1035
|
+
interrupt: false,
|
|
1036
|
+
};
|
|
1037
|
+
} else if (
|
|
1038
|
+
isUnrestrictedPermissionMode(this.options.permissionMode) &&
|
|
1039
|
+
!toolNeedsRuntimeUserInput
|
|
1040
|
+
) {
|
|
1041
|
+
// unrestricted auto-allows non-interactive tools.
|
|
1042
|
+
sessionLog("canUseTool", `AUTO-ALLOW ${toolName} (unrestricted)`);
|
|
1043
|
+
response = {
|
|
1044
|
+
behavior: "allow",
|
|
1045
|
+
updatedInput: null,
|
|
1046
|
+
updatedPermissions: [],
|
|
1047
|
+
} satisfies CanUseToolResponseAllow;
|
|
1048
|
+
} else if (hasCallback) {
|
|
1049
|
+
try {
|
|
1050
|
+
const result = await this.options.canUseTool!(
|
|
1051
|
+
toolName,
|
|
1052
|
+
req.input,
|
|
1053
|
+
buildCanUseToolContext(req as unknown as Record<string, unknown>, requestId),
|
|
1054
|
+
);
|
|
1055
|
+
if (result.behavior === "allow") {
|
|
1056
|
+
response = {
|
|
1057
|
+
behavior: "allow",
|
|
1058
|
+
updatedInput: result.updatedInput ?? null,
|
|
1059
|
+
updatedPermissions: [], // TODO: not implemented
|
|
1060
|
+
} satisfies CanUseToolResponseAllow;
|
|
1061
|
+
} else {
|
|
1062
|
+
response = {
|
|
1063
|
+
behavior: "deny",
|
|
1064
|
+
message: result.message ?? "Denied by canUseTool callback",
|
|
1065
|
+
interrupt: false, // TODO: not wired up yet
|
|
1066
|
+
} satisfies CanUseToolResponseDeny;
|
|
1067
|
+
}
|
|
1068
|
+
} catch (err) {
|
|
1069
|
+
response = {
|
|
1070
|
+
behavior: "deny",
|
|
1071
|
+
message: err instanceof Error ? err.message : "Callback error",
|
|
1072
|
+
interrupt: false,
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
} else if (autoAllowWithoutCallback) {
|
|
1076
|
+
// Default headless behavior matches Claude: EnterPlanMode can proceed
|
|
1077
|
+
// without requiring a callback in bidirectional mode.
|
|
1078
|
+
sessionLog("canUseTool", `AUTO-ALLOW ${toolName} (default behavior)`);
|
|
1079
|
+
response = {
|
|
1080
|
+
behavior: "allow",
|
|
1081
|
+
updatedInput: null,
|
|
1082
|
+
updatedPermissions: [],
|
|
1083
|
+
} satisfies CanUseToolResponseAllow;
|
|
1084
|
+
} else {
|
|
1085
|
+
// No callback registered - deny by default
|
|
1086
|
+
response = {
|
|
1087
|
+
behavior: "deny",
|
|
1088
|
+
message: "No canUseTool callback registered",
|
|
1089
|
+
interrupt: false,
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// Send control_response (Claude SDK compatible format)
|
|
1094
|
+
const responseBehavior = "behavior" in response ? response.behavior : "unknown";
|
|
1095
|
+
sessionLog("canUseTool", `responding: requestId=${requestId} behavior=${responseBehavior}`);
|
|
1096
|
+
await this.transport.write({
|
|
1097
|
+
type: "control_response",
|
|
1098
|
+
response: {
|
|
1099
|
+
subtype: "success",
|
|
1100
|
+
request_id: requestId,
|
|
1101
|
+
response,
|
|
1102
|
+
},
|
|
1103
|
+
});
|
|
1104
|
+
sessionLog("canUseTool", `response sent for ${toolName}`);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* Abort the current operation (interrupt without closing the session)
|
|
1109
|
+
*/
|
|
1110
|
+
async abort(): Promise<void> {
|
|
1111
|
+
if (!this.initialized || this.pumpClosed) return;
|
|
1112
|
+
sessionLog("abort", `aborting session (agent=${this._agentId})`);
|
|
1113
|
+
await this.transport.write({
|
|
1114
|
+
type: "control_request",
|
|
1115
|
+
request_id: `interrupt-${Date.now()}`,
|
|
1116
|
+
request: { subtype: "interrupt" },
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
private async requestControlResponse(
|
|
1121
|
+
requestId: string,
|
|
1122
|
+
request: Record<string, unknown>,
|
|
1123
|
+
options: { timeoutMs?: number } = {},
|
|
1124
|
+
): Promise<{ subtype: string; response?: unknown; error?: string }> {
|
|
1125
|
+
const responsePromise = new Promise<{
|
|
1126
|
+
subtype: string;
|
|
1127
|
+
response?: unknown;
|
|
1128
|
+
error?: string;
|
|
1129
|
+
}>((resolve) => {
|
|
1130
|
+
this.controlResponseWaiters.set(requestId, resolve);
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
try {
|
|
1134
|
+
await this.transport.write({
|
|
1135
|
+
type: "control_request",
|
|
1136
|
+
request_id: requestId,
|
|
1137
|
+
request: request as unknown as ControlRequest["request"],
|
|
1138
|
+
});
|
|
1139
|
+
} catch (error) {
|
|
1140
|
+
this.controlResponseWaiters.delete(requestId);
|
|
1141
|
+
throw error;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
1145
|
+
try {
|
|
1146
|
+
if (typeof options.timeoutMs === "number") {
|
|
1147
|
+
return await Promise.race([
|
|
1148
|
+
responsePromise,
|
|
1149
|
+
new Promise<never>((_, reject) => {
|
|
1150
|
+
timeoutHandle = setTimeout(() => {
|
|
1151
|
+
this.controlResponseWaiters.delete(requestId);
|
|
1152
|
+
reject(
|
|
1153
|
+
new Error(
|
|
1154
|
+
`Timed out waiting for control_response (${requestId})`,
|
|
1155
|
+
),
|
|
1156
|
+
);
|
|
1157
|
+
}, options.timeoutMs);
|
|
1158
|
+
}),
|
|
1159
|
+
]);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
return await responsePromise;
|
|
1163
|
+
} finally {
|
|
1164
|
+
if (timeoutHandle) {
|
|
1165
|
+
clearTimeout(timeoutHandle);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* Fetch a page of conversation messages via the CLI control protocol.
|
|
1172
|
+
*
|
|
1173
|
+
* The session must be initialized before calling this method.
|
|
1174
|
+
* Safe to call concurrently with an active stream() — the pump routes
|
|
1175
|
+
* matching control_response messages to this waiter without touching the
|
|
1176
|
+
* stream queue.
|
|
1177
|
+
*/
|
|
1178
|
+
async listMessages(options: ListMessagesOptions = {}): Promise<ListMessagesResult> {
|
|
1179
|
+
if (!this.initialized) {
|
|
1180
|
+
throw new Error("Session must be initialized before calling listMessages()");
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
const requestId = `list-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
1184
|
+
|
|
1185
|
+
const resp = await this.requestControlResponse(requestId, {
|
|
1186
|
+
subtype: "list_messages",
|
|
1187
|
+
...(options.conversationId ? { conversation_id: options.conversationId } : {}),
|
|
1188
|
+
...(options.before ? { before: options.before } : {}),
|
|
1189
|
+
...(options.after ? { after: options.after } : {}),
|
|
1190
|
+
...(options.order ? { order: options.order } : {}),
|
|
1191
|
+
...(options.limit !== undefined ? { limit: options.limit } : {}),
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
if (!resp) {
|
|
1195
|
+
throw new Error("Session closed before listMessages response arrived");
|
|
1196
|
+
}
|
|
1197
|
+
if (resp.subtype === "error") {
|
|
1198
|
+
throw new Error(resp.error ?? "listMessages failed");
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
const payload = resp.response as {
|
|
1202
|
+
messages?: unknown[];
|
|
1203
|
+
next_before?: string | null;
|
|
1204
|
+
has_more?: boolean;
|
|
1205
|
+
} | undefined;
|
|
1206
|
+
|
|
1207
|
+
return {
|
|
1208
|
+
messages: payload?.messages ?? [],
|
|
1209
|
+
nextBefore: payload?.next_before ?? null,
|
|
1210
|
+
hasMore: payload?.has_more ?? false,
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
async listModels(): Promise<ListModelsResult> {
|
|
1215
|
+
throw new Error(
|
|
1216
|
+
"listModels() is not supported by this session. Use a Cloud, Remote, or local agent session.",
|
|
1217
|
+
);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
async sendCommand(command: SDKProtocolCommand): Promise<void>;
|
|
1221
|
+
async sendCommand<TResponse extends SDKProtocolMessage = SDKProtocolMessage>(
|
|
1222
|
+
command: SDKProtocolCommand,
|
|
1223
|
+
options: SendCommandOptions,
|
|
1224
|
+
): Promise<TResponse>;
|
|
1225
|
+
async sendCommand(
|
|
1226
|
+
_command?: SDKProtocolCommand,
|
|
1227
|
+
_options?: SendCommandOptions,
|
|
1228
|
+
): Promise<void> {
|
|
1229
|
+
throw new Error(
|
|
1230
|
+
"sendCommand() is not supported by this session. Use a Cloud, Remote, or local agent session.",
|
|
1231
|
+
);
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
async updateModel(_update: string | UpdateModelOptions): Promise<UpdateModelResult> {
|
|
1235
|
+
throw new Error(
|
|
1236
|
+
"updateModel() is not supported by this session. Use a Cloud, Remote, or local agent session.",
|
|
1237
|
+
);
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
async changeDeviceState(_updates: ChangeDeviceStateOptions): Promise<void> {
|
|
1241
|
+
throw new Error(
|
|
1242
|
+
"changeDeviceState() is not supported by the legacy stdio transport. Use a Cloud, Remote, or local app-server session.",
|
|
1243
|
+
);
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
async removeQueuedMessage(_itemId: string): Promise<RemoveQueuedMessageResult> {
|
|
1247
|
+
throw new Error(
|
|
1248
|
+
"removeQueuedMessage() is not supported by the legacy stdio transport. Use a Cloud, Remote, or local app-server session.",
|
|
1249
|
+
);
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
async getDeviceStatus(_options?: GetDeviceStatusOptions): Promise<SessionDeviceStatus> {
|
|
1253
|
+
throw new Error(
|
|
1254
|
+
"getDeviceStatus() is not supported by the legacy stdio transport. Use a Cloud, Remote, or local app-server session.",
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
onDeviceStatus(_listener: (status: SessionDeviceStatus) => void): () => void {
|
|
1259
|
+
throw new Error(
|
|
1260
|
+
"onDeviceStatus() is not supported by the legacy stdio transport. Use a Cloud, Remote, or local app-server session.",
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/**
|
|
1265
|
+
* Fetch all data needed to render the initial conversation view in one round-trip.
|
|
1266
|
+
*
|
|
1267
|
+
* Returns resolved session metadata + initial history page + pending approval flag
|
|
1268
|
+
* + optional timing breakdown. This is faster than separate initialize() + listMessages()
|
|
1269
|
+
* calls because the CLI collects and returns everything in a single control response.
|
|
1270
|
+
*
|
|
1271
|
+
* The session must be initialized before calling this method.
|
|
1272
|
+
*/
|
|
1273
|
+
async bootstrapState(
|
|
1274
|
+
options: BootstrapStateOptions = {},
|
|
1275
|
+
): Promise<BootstrapStateResult> {
|
|
1276
|
+
if (!this.initialized) {
|
|
1277
|
+
throw new Error(
|
|
1278
|
+
"Session must be initialized before calling bootstrapState()",
|
|
1279
|
+
);
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
const requestId = `bootstrap-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
1283
|
+
|
|
1284
|
+
const resp = await this.requestControlResponse(requestId, {
|
|
1285
|
+
subtype: "bootstrap_session_state",
|
|
1286
|
+
...(options.limit !== undefined ? { limit: options.limit } : {}),
|
|
1287
|
+
...(options.order ? { order: options.order } : {}),
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
if (!resp) {
|
|
1291
|
+
throw new Error("Session closed before bootstrapState response arrived");
|
|
1292
|
+
}
|
|
1293
|
+
if (resp.subtype === "error") {
|
|
1294
|
+
throw new Error(
|
|
1295
|
+
(resp as { error?: string }).error ?? "bootstrapState failed",
|
|
1296
|
+
);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
const payload = resp.response as {
|
|
1300
|
+
agent_id?: string;
|
|
1301
|
+
conversation_id?: string;
|
|
1302
|
+
model?: string;
|
|
1303
|
+
tools?: string[];
|
|
1304
|
+
memfs_enabled?: boolean;
|
|
1305
|
+
messages?: unknown[];
|
|
1306
|
+
next_before?: string | null;
|
|
1307
|
+
has_more?: boolean;
|
|
1308
|
+
has_pending_approval?: boolean;
|
|
1309
|
+
timings?: {
|
|
1310
|
+
resolve_ms: number;
|
|
1311
|
+
list_messages_ms: number;
|
|
1312
|
+
total_ms: number;
|
|
1313
|
+
};
|
|
1314
|
+
} | undefined;
|
|
1315
|
+
|
|
1316
|
+
const state: BootstrapStateResult = {
|
|
1317
|
+
agentId: payload?.agent_id ?? this._agentId ?? "",
|
|
1318
|
+
conversationId: payload?.conversation_id ?? this._conversationId ?? "",
|
|
1319
|
+
model: payload?.model,
|
|
1320
|
+
memfsEnabled: payload?.memfs_enabled ?? false,
|
|
1321
|
+
messages: payload?.messages ?? [],
|
|
1322
|
+
nextBefore: payload?.next_before ?? null,
|
|
1323
|
+
hasMore: payload?.has_more ?? false,
|
|
1324
|
+
hasPendingApproval: payload?.has_pending_approval ?? false,
|
|
1325
|
+
};
|
|
1326
|
+
if (payload?.tools !== undefined) state.tools = payload.tools;
|
|
1327
|
+
if (payload?.timings !== undefined) state.timings = payload.timings;
|
|
1328
|
+
return state;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
async updateToolset(_toolsetPreference: string): Promise<void> {
|
|
1332
|
+
throw new Error("updateToolset() is not supported by this session. Use a Cloud, Remote, or local agent session.");
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
/**
|
|
1336
|
+
* Close the session
|
|
1337
|
+
*/
|
|
1338
|
+
close(): void {
|
|
1339
|
+
if (this.closed) return;
|
|
1340
|
+
this.closed = true;
|
|
1341
|
+
sessionLog("close", `closing session (agent=${this._agentId}, conversation=${this._conversationId})`);
|
|
1342
|
+
this.transport.close();
|
|
1343
|
+
this.pumpClosed = true;
|
|
1344
|
+
this.resolveAllStreamWaiters(null);
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/**
|
|
1348
|
+
* Get the agent ID (available after initialization)
|
|
1349
|
+
*/
|
|
1350
|
+
get agentId(): string | null {
|
|
1351
|
+
return this._agentId;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/**
|
|
1355
|
+
* Get the session ID (available after initialization)
|
|
1356
|
+
*/
|
|
1357
|
+
get sessionId(): string | null {
|
|
1358
|
+
return this._sessionId;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
/**
|
|
1362
|
+
* Get the conversation ID (available after initialization)
|
|
1363
|
+
*/
|
|
1364
|
+
get conversationId(): string | null {
|
|
1365
|
+
return this._conversationId;
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
/**
|
|
1369
|
+
* AsyncDisposable implementation for `await using`
|
|
1370
|
+
*/
|
|
1371
|
+
async [Symbol.asyncDispose](): Promise<void> {
|
|
1372
|
+
this.close();
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/**
|
|
1376
|
+
* Transform wire message to SDK message
|
|
1377
|
+
*/
|
|
1378
|
+
private transformMessage(wireMsg: WireMessage | MessageWire): SDKMessage | null {
|
|
1379
|
+
// Init message
|
|
1380
|
+
if (wireMsg.type === "system" && "subtype" in wireMsg && wireMsg.subtype === "init") {
|
|
1381
|
+
const msg = wireMsg as WireMessage & {
|
|
1382
|
+
agent_id: string;
|
|
1383
|
+
session_id: string;
|
|
1384
|
+
conversation_id: string;
|
|
1385
|
+
model: string;
|
|
1386
|
+
tools: string[];
|
|
1387
|
+
memfs_enabled?: boolean;
|
|
1388
|
+
skill_sources?: Array<"bundled" | "global" | "agent" | "project">;
|
|
1389
|
+
system_info_reminder_enabled?: boolean;
|
|
1390
|
+
reflection_trigger?: "off" | "step-count" | "compaction-event";
|
|
1391
|
+
reflection_behavior?: "reminder" | "auto-launch";
|
|
1392
|
+
reflection_step_count?: number;
|
|
1393
|
+
};
|
|
1394
|
+
return {
|
|
1395
|
+
type: "init",
|
|
1396
|
+
agentId: msg.agent_id,
|
|
1397
|
+
sessionId: msg.session_id,
|
|
1398
|
+
conversationId: msg.conversation_id,
|
|
1399
|
+
model: msg.model,
|
|
1400
|
+
tools: msg.tools,
|
|
1401
|
+
memfsEnabled: msg.memfs_enabled,
|
|
1402
|
+
skillSources: msg.skill_sources,
|
|
1403
|
+
systemInfoReminderEnabled: msg.system_info_reminder_enabled,
|
|
1404
|
+
dreaming:
|
|
1405
|
+
msg.reflection_trigger &&
|
|
1406
|
+
msg.reflection_behavior &&
|
|
1407
|
+
typeof msg.reflection_step_count === "number"
|
|
1408
|
+
? {
|
|
1409
|
+
trigger: msg.reflection_trigger,
|
|
1410
|
+
behavior: msg.reflection_behavior,
|
|
1411
|
+
stepCount: msg.reflection_step_count,
|
|
1412
|
+
}
|
|
1413
|
+
: undefined,
|
|
1414
|
+
};
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
// Handle message types (all have type: "message" with message_type field)
|
|
1418
|
+
if (wireMsg.type === "message" && "message_type" in wireMsg) {
|
|
1419
|
+
const msg = wireMsg as WireMessage & {
|
|
1420
|
+
message_type: string;
|
|
1421
|
+
uuid: string;
|
|
1422
|
+
run_id?: string;
|
|
1423
|
+
// assistant_message fields
|
|
1424
|
+
content?: string;
|
|
1425
|
+
// tool_call_message fields
|
|
1426
|
+
tool_call?: { name: string; arguments: string; tool_call_id: string };
|
|
1427
|
+
tool_calls?: Array<{ name: string; arguments: string; tool_call_id: string }>;
|
|
1428
|
+
// tool_return_message fields
|
|
1429
|
+
tool_call_id?: string;
|
|
1430
|
+
tool_return?: string;
|
|
1431
|
+
status?: "success" | "error";
|
|
1432
|
+
// reasoning_message fields
|
|
1433
|
+
reasoning?: string;
|
|
1434
|
+
};
|
|
1435
|
+
|
|
1436
|
+
const runId = msg.run_id || undefined;
|
|
1437
|
+
|
|
1438
|
+
// Assistant message
|
|
1439
|
+
if (msg.message_type === "assistant_message" && msg.content) {
|
|
1440
|
+
return {
|
|
1441
|
+
type: "assistant",
|
|
1442
|
+
content: msg.content,
|
|
1443
|
+
uuid: msg.uuid,
|
|
1444
|
+
runId,
|
|
1445
|
+
};
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
// Tool call message (tool_call_message = auto-executed, approval_request_message = needs approval)
|
|
1449
|
+
if (msg.message_type === "tool_call_message" || msg.message_type === "approval_request_message") {
|
|
1450
|
+
const toolCallRaw = (msg.tool_calls?.[0] || msg.tool_call) as Record<string, unknown> | undefined;
|
|
1451
|
+
if (toolCallRaw) {
|
|
1452
|
+
const fnObj = toolCallRaw.function as Record<string, unknown> | undefined;
|
|
1453
|
+
const toolCallId =
|
|
1454
|
+
(toolCallRaw.tool_call_id as string | undefined) ??
|
|
1455
|
+
(toolCallRaw.id as string | undefined);
|
|
1456
|
+
if (!toolCallId) {
|
|
1457
|
+
const detail = `Missing tool_call_id in ${msg.message_type} (uuid=${msg.uuid || "unknown"})`;
|
|
1458
|
+
sessionLog("transform", detail);
|
|
1459
|
+
return {
|
|
1460
|
+
type: "error",
|
|
1461
|
+
message: detail,
|
|
1462
|
+
errorCode: "protocol_error",
|
|
1463
|
+
stopReason: "protocol_error",
|
|
1464
|
+
runId,
|
|
1465
|
+
apiError: {
|
|
1466
|
+
error_type: "protocol_error",
|
|
1467
|
+
detail,
|
|
1468
|
+
message_type: msg.message_type,
|
|
1469
|
+
},
|
|
1470
|
+
recoverable: false,
|
|
1471
|
+
errorDetail: detail,
|
|
1472
|
+
};
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
const toolName =
|
|
1476
|
+
(toolCallRaw.name as string | undefined) ??
|
|
1477
|
+
(fnObj?.name as string | undefined) ??
|
|
1478
|
+
"?";
|
|
1479
|
+
const toolArgs =
|
|
1480
|
+
(toolCallRaw.arguments as string | undefined) ??
|
|
1481
|
+
(fnObj?.arguments as string | undefined) ??
|
|
1482
|
+
"";
|
|
1483
|
+
|
|
1484
|
+
let toolInput: Record<string, unknown> = {};
|
|
1485
|
+
try {
|
|
1486
|
+
toolInput = JSON.parse(toolArgs);
|
|
1487
|
+
} catch {
|
|
1488
|
+
toolInput = { raw: toolArgs };
|
|
1489
|
+
}
|
|
1490
|
+
return {
|
|
1491
|
+
type: "tool_call",
|
|
1492
|
+
toolCallId,
|
|
1493
|
+
toolName,
|
|
1494
|
+
toolInput,
|
|
1495
|
+
rawArguments: toolArgs || undefined,
|
|
1496
|
+
uuid: msg.uuid,
|
|
1497
|
+
runId,
|
|
1498
|
+
};
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
// Tool return message
|
|
1503
|
+
if (msg.message_type === "tool_return_message" && msg.tool_call_id) {
|
|
1504
|
+
return {
|
|
1505
|
+
type: "tool_result",
|
|
1506
|
+
toolCallId: msg.tool_call_id,
|
|
1507
|
+
content: msg.tool_return || "",
|
|
1508
|
+
isError: msg.status === "error",
|
|
1509
|
+
uuid: msg.uuid,
|
|
1510
|
+
runId,
|
|
1511
|
+
};
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
// Reasoning message
|
|
1515
|
+
if (msg.message_type === "reasoning_message" && msg.reasoning) {
|
|
1516
|
+
return {
|
|
1517
|
+
type: "reasoning",
|
|
1518
|
+
content: msg.reasoning,
|
|
1519
|
+
uuid: msg.uuid,
|
|
1520
|
+
runId,
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
// Stream event (partial message updates)
|
|
1526
|
+
if (wireMsg.type === "stream_event") {
|
|
1527
|
+
const msg = wireMsg as WireMessage & {
|
|
1528
|
+
event: unknown;
|
|
1529
|
+
uuid: string;
|
|
1530
|
+
};
|
|
1531
|
+
const eventPayload = (msg.event ?? {}) as SDKStreamEventPayload;
|
|
1532
|
+
return {
|
|
1533
|
+
type: "stream_event",
|
|
1534
|
+
event: eventPayload,
|
|
1535
|
+
uuid: msg.uuid,
|
|
1536
|
+
};
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
// Result message
|
|
1540
|
+
if (wireMsg.type === "result") {
|
|
1541
|
+
const msg = wireMsg as WireMessage & {
|
|
1542
|
+
subtype: string;
|
|
1543
|
+
result?: string;
|
|
1544
|
+
duration_ms: number;
|
|
1545
|
+
total_cost_usd?: number;
|
|
1546
|
+
conversation_id: string;
|
|
1547
|
+
stop_reason?: string;
|
|
1548
|
+
run_ids?: unknown[];
|
|
1549
|
+
};
|
|
1550
|
+
const runIds = Array.isArray(msg.run_ids)
|
|
1551
|
+
? msg.run_ids.filter((id): id is string => typeof id === "string")
|
|
1552
|
+
: undefined;
|
|
1553
|
+
const approvalConflict = isApprovalConflictSignal({
|
|
1554
|
+
stopReason: msg.stop_reason,
|
|
1555
|
+
});
|
|
1556
|
+
const legacyError = msg.subtype !== "success"
|
|
1557
|
+
? (approvalConflict && msg.subtype === "error" ? "approval_conflict" : msg.subtype)
|
|
1558
|
+
: undefined;
|
|
1559
|
+
const errorCode = toSdkErrorCode(legacyError);
|
|
1560
|
+
return {
|
|
1561
|
+
type: "result",
|
|
1562
|
+
success: msg.subtype === "success",
|
|
1563
|
+
result: msg.result,
|
|
1564
|
+
error: legacyError,
|
|
1565
|
+
errorCode,
|
|
1566
|
+
...(approvalConflict
|
|
1567
|
+
? {
|
|
1568
|
+
approvalConflict: true as const,
|
|
1569
|
+
recoverable: true as const,
|
|
1570
|
+
}
|
|
1571
|
+
: {}),
|
|
1572
|
+
stopReason: msg.stop_reason,
|
|
1573
|
+
durationMs: msg.duration_ms,
|
|
1574
|
+
totalCostUsd: msg.total_cost_usd,
|
|
1575
|
+
conversationId: msg.conversation_id,
|
|
1576
|
+
runIds,
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
// Error message — carries the actual error detail from the CLI.
|
|
1581
|
+
// The subsequent type=result only has the opaque string "error";
|
|
1582
|
+
// this message has the human-readable description and API error.
|
|
1583
|
+
if (wireMsg.type === "error") {
|
|
1584
|
+
const msg = wireMsg as WireMessage & {
|
|
1585
|
+
message: string;
|
|
1586
|
+
stop_reason: string;
|
|
1587
|
+
run_id?: string;
|
|
1588
|
+
api_error?: Record<string, unknown>;
|
|
1589
|
+
};
|
|
1590
|
+
const errorDetail = extractApiErrorDetail(msg.api_error);
|
|
1591
|
+
const approvalConflict = isApprovalConflictSignal({
|
|
1592
|
+
detail: errorDetail,
|
|
1593
|
+
message: msg.message,
|
|
1594
|
+
stopReason: msg.stop_reason,
|
|
1595
|
+
});
|
|
1596
|
+
const errorCode = approvalConflict
|
|
1597
|
+
? "approval_conflict"
|
|
1598
|
+
: toSdkErrorCode(msg.stop_reason);
|
|
1599
|
+
return {
|
|
1600
|
+
type: "error" as const,
|
|
1601
|
+
message: msg.message,
|
|
1602
|
+
...(errorCode ? { errorCode } : {}),
|
|
1603
|
+
...(approvalConflict
|
|
1604
|
+
? {
|
|
1605
|
+
approvalConflict: true as const,
|
|
1606
|
+
recoverable: true as const,
|
|
1607
|
+
}
|
|
1608
|
+
: {}),
|
|
1609
|
+
...(errorDetail ? { errorDetail } : {}),
|
|
1610
|
+
stopReason: msg.stop_reason,
|
|
1611
|
+
runId: msg.run_id,
|
|
1612
|
+
apiError: msg.api_error,
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
// Retry message — the CLI is retrying after a transient failure.
|
|
1617
|
+
if (wireMsg.type === "retry") {
|
|
1618
|
+
const msg = wireMsg as WireMessage & {
|
|
1619
|
+
reason: string;
|
|
1620
|
+
attempt: number;
|
|
1621
|
+
max_attempts: number;
|
|
1622
|
+
delay_ms: number;
|
|
1623
|
+
run_id?: string;
|
|
1624
|
+
};
|
|
1625
|
+
return {
|
|
1626
|
+
type: "retry" as const,
|
|
1627
|
+
reason: msg.reason,
|
|
1628
|
+
attempt: msg.attempt,
|
|
1629
|
+
maxAttempts: msg.max_attempts,
|
|
1630
|
+
delayMs: msg.delay_ms,
|
|
1631
|
+
runId: msg.run_id,
|
|
1632
|
+
};
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
// Skip other message types (system_message, user_message, etc.)
|
|
1636
|
+
return null;
|
|
1637
|
+
}
|
|
1638
|
+
}
|