@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
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BootstrapStateOptions,
|
|
3
|
+
BootstrapStateResult,
|
|
4
|
+
ChangeDeviceStateOptions,
|
|
5
|
+
CreateAgentOptions,
|
|
6
|
+
GetDeviceStatusOptions,
|
|
7
|
+
LettaCodeClientSessionOptions,
|
|
8
|
+
LettaCodeSession,
|
|
9
|
+
LettaCodeModelEntry,
|
|
10
|
+
ListModelsResult,
|
|
11
|
+
ListMessagesOptions,
|
|
12
|
+
ListMessagesResult,
|
|
13
|
+
RecoverPendingApprovalsOptions,
|
|
14
|
+
RecoverPendingApprovalsResult,
|
|
15
|
+
RemoveQueuedMessageResult,
|
|
16
|
+
RunTurnOptions,
|
|
17
|
+
SDKInitMessage,
|
|
18
|
+
SDKMessage,
|
|
19
|
+
SDKProtocolCommand,
|
|
20
|
+
SDKProtocolMessage,
|
|
21
|
+
SDKResultMessage,
|
|
22
|
+
SendCommandOptions,
|
|
23
|
+
SendMessage,
|
|
24
|
+
SessionDeviceStatus,
|
|
25
|
+
UpdateModelOptions,
|
|
26
|
+
UpdateModelResult,
|
|
27
|
+
} from "./types.js";
|
|
28
|
+
import { RemoteTurnCoordinator } from "./remote-turn-coordinator.js";
|
|
29
|
+
|
|
30
|
+
export {
|
|
31
|
+
ensureSuccess,
|
|
32
|
+
isUnrestrictedPermissionMode,
|
|
33
|
+
mapPermissionMode,
|
|
34
|
+
normalizePermissionMode,
|
|
35
|
+
normalizeSendMessage,
|
|
36
|
+
} from "./remote-session-protocol.js";
|
|
37
|
+
import {
|
|
38
|
+
ensureSuccess,
|
|
39
|
+
getContextWindow,
|
|
40
|
+
getReasoningEffort,
|
|
41
|
+
mapPermissionMode,
|
|
42
|
+
modelPayloadWithoutReasoning,
|
|
43
|
+
normalizeUpdateModelInput,
|
|
44
|
+
resolveDreamingSettings,
|
|
45
|
+
sameContextCandidates,
|
|
46
|
+
toBaseModelHandle,
|
|
47
|
+
type NormalizedUpdateModelInput,
|
|
48
|
+
type RemoteClientSessionCoreConfig,
|
|
49
|
+
type RemoteClientRuntimeController,
|
|
50
|
+
type RuntimeScope,
|
|
51
|
+
type RuntimeSessionInit,
|
|
52
|
+
type RuntimeSessionMode,
|
|
53
|
+
type UpdateModelPayload,
|
|
54
|
+
} from "./remote-session-protocol.js";
|
|
55
|
+
export type {
|
|
56
|
+
ProtocolMessage,
|
|
57
|
+
RemoteClientRuntimeController,
|
|
58
|
+
RuntimeRequestOptions,
|
|
59
|
+
RuntimeScope,
|
|
60
|
+
RuntimeSendTurnOptions,
|
|
61
|
+
RuntimeSessionInit,
|
|
62
|
+
RuntimeSessionMode,
|
|
63
|
+
RuntimeTurnResult,
|
|
64
|
+
} from "./remote-session-protocol.js";
|
|
65
|
+
|
|
66
|
+
export abstract class RemoteClientSessionCore implements LettaCodeSession {
|
|
67
|
+
protected controller: RemoteClientRuntimeController | null = null;
|
|
68
|
+
protected runtime: RuntimeScope | null = null;
|
|
69
|
+
protected initialized = false;
|
|
70
|
+
protected closed = false;
|
|
71
|
+
protected _agentId: string | null = null;
|
|
72
|
+
protected _sessionId: string | null = null;
|
|
73
|
+
protected _conversationId: string | null = null;
|
|
74
|
+
protected _model = "";
|
|
75
|
+
protected _modelSettings: Record<string, unknown> | null = null;
|
|
76
|
+
|
|
77
|
+
private readonly label: string;
|
|
78
|
+
private readonly requestTimeoutMs: number | undefined;
|
|
79
|
+
private initializePromise: Promise<SDKInitMessage> | null = null;
|
|
80
|
+
private removeMessageHandler: (() => void) | null = null;
|
|
81
|
+
private readonly turns: RemoteTurnCoordinator;
|
|
82
|
+
private toolNames: string[] | undefined;
|
|
83
|
+
private deviceStatusListeners = new Set<(status: SessionDeviceStatus) => void>();
|
|
84
|
+
private deviceStatusRefreshCancels = new Set<(error: Error) => void>();
|
|
85
|
+
|
|
86
|
+
protected constructor(
|
|
87
|
+
protected readonly mode: RuntimeSessionMode,
|
|
88
|
+
config: RemoteClientSessionCoreConfig,
|
|
89
|
+
) {
|
|
90
|
+
this.label = config.label;
|
|
91
|
+
this.requestTimeoutMs = config.requestTimeoutMs;
|
|
92
|
+
this.turns = new RemoteTurnCoordinator({
|
|
93
|
+
label: config.label,
|
|
94
|
+
requestTimeoutMs: config.requestTimeoutMs,
|
|
95
|
+
onDeviceStatus: (status) => this.emitDeviceStatus(status),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Initialize the session.
|
|
101
|
+
*
|
|
102
|
+
* Single-flight: the first caller starts initialization and every
|
|
103
|
+
* concurrent caller (including the lazily-initializing entry points such
|
|
104
|
+
* as send()/stream()/listMessages()) awaits the same promise, so a fresh
|
|
105
|
+
* session never opens more than one runtime connection. A failed attempt
|
|
106
|
+
* clears the memo so a later call can retry; it only releases the
|
|
107
|
+
* resources that failed attempt created.
|
|
108
|
+
*/
|
|
109
|
+
async initialize(): Promise<SDKInitMessage> {
|
|
110
|
+
if (this.closed) {
|
|
111
|
+
throw new Error("Session is closed");
|
|
112
|
+
}
|
|
113
|
+
if (this.initializePromise) {
|
|
114
|
+
return this.initializePromise;
|
|
115
|
+
}
|
|
116
|
+
if (this.initialized) {
|
|
117
|
+
throw new Error("Session already initialized");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const attempt = this.performInitialize();
|
|
121
|
+
const memo = attempt
|
|
122
|
+
.catch((error: unknown) => {
|
|
123
|
+
// Tear down only this attempt's partial state. Never close() the
|
|
124
|
+
// session here: a failed remote attempt is retryable.
|
|
125
|
+
this.cleanupFailedInitialize();
|
|
126
|
+
throw error;
|
|
127
|
+
})
|
|
128
|
+
.finally(() => {
|
|
129
|
+
// Keep only the in-flight promise. Once it settles, preserve the
|
|
130
|
+
// existing API: a later explicit initialize() either retries a
|
|
131
|
+
// failure or throws "already initialized" after success.
|
|
132
|
+
if (this.initializePromise === memo) {
|
|
133
|
+
this.initializePromise = null;
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
this.initializePromise = memo;
|
|
137
|
+
return memo;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private async performInitialize(): Promise<SDKInitMessage> {
|
|
141
|
+
const init = await this.initializeRuntimeController();
|
|
142
|
+
this.controller = init.controller;
|
|
143
|
+
this.runtime = init.runtime;
|
|
144
|
+
this._agentId = init.runtime.agent_id;
|
|
145
|
+
this._conversationId = init.runtime.conversation_id;
|
|
146
|
+
this._sessionId = `${init.runtime.agent_id}:${init.runtime.conversation_id}`;
|
|
147
|
+
this._modelSettings = init.modelSettings ?? null;
|
|
148
|
+
this._model =
|
|
149
|
+
typeof init.model === "string"
|
|
150
|
+
? init.model
|
|
151
|
+
: typeof this._modelSettings?.model === "string"
|
|
152
|
+
? this._modelSettings.model
|
|
153
|
+
: "";
|
|
154
|
+
this.toolNames = init.tools;
|
|
155
|
+
this.removeMessageHandler = this.controller.onMessage((message) => {
|
|
156
|
+
if (this.runtime) this.turns.handleProtocolMessage(message, this.runtime);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
await this.afterRuntimeInitialized();
|
|
160
|
+
await this.applyPostInitializeOptions();
|
|
161
|
+
if (this.closed) {
|
|
162
|
+
throw new Error("Session is closed");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// This is the lifecycle commit point. Lazy entry points must continue to
|
|
166
|
+
// await initializePromise until every post-initialize option is applied.
|
|
167
|
+
this.initialized = true;
|
|
168
|
+
|
|
169
|
+
const initMessage: SDKInitMessage = {
|
|
170
|
+
type: "init",
|
|
171
|
+
agentId: init.runtime.agent_id,
|
|
172
|
+
sessionId: this._sessionId,
|
|
173
|
+
conversationId: init.runtime.conversation_id,
|
|
174
|
+
model: this._model,
|
|
175
|
+
};
|
|
176
|
+
if (this.toolNames !== undefined) initMessage.tools = this.toolNames;
|
|
177
|
+
if (init.skillSources !== undefined) {
|
|
178
|
+
initMessage.skillSources = init.skillSources;
|
|
179
|
+
}
|
|
180
|
+
return initMessage;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Release the partial state a failed initialize attempt created without
|
|
185
|
+
* closing the session, so a later initialize() can retry.
|
|
186
|
+
*/
|
|
187
|
+
private cleanupFailedInitialize(): void {
|
|
188
|
+
this.removeMessageHandler?.();
|
|
189
|
+
this.removeMessageHandler = null;
|
|
190
|
+
this.controller?.close();
|
|
191
|
+
this.controller = null;
|
|
192
|
+
// Subclass-owned resources (spawned connections, sandboxes, handlers)
|
|
193
|
+
// are attempt-scoped too; their cleanup hooks are idempotent.
|
|
194
|
+
this.onCoreClose();
|
|
195
|
+
this.runtime = null;
|
|
196
|
+
this._agentId = null;
|
|
197
|
+
this._conversationId = null;
|
|
198
|
+
this._sessionId = null;
|
|
199
|
+
this._modelSettings = null;
|
|
200
|
+
this._model = "";
|
|
201
|
+
this.toolNames = undefined;
|
|
202
|
+
this.initialized = false;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async send(message: SendMessage): Promise<void> {
|
|
206
|
+
if (!this.initialized) {
|
|
207
|
+
await this.initialize();
|
|
208
|
+
}
|
|
209
|
+
if (!this.controller || !this.runtime) {
|
|
210
|
+
throw new Error("Session is not initialized");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
await this.beforeTurn();
|
|
214
|
+
|
|
215
|
+
const turn = this.turns.trackSentTurn(this.runtime);
|
|
216
|
+
try {
|
|
217
|
+
this.controller.sendTurnMessage(this.runtime, message, {
|
|
218
|
+
clientMessageId: turn.clientMessageId,
|
|
219
|
+
});
|
|
220
|
+
} catch (error) {
|
|
221
|
+
this.turns.removeTrackedTurn(turn);
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async runTurn(
|
|
227
|
+
message: SendMessage,
|
|
228
|
+
_options: RunTurnOptions = {},
|
|
229
|
+
): Promise<SDKResultMessage> {
|
|
230
|
+
if (this.turns.hasInFlightTurn()) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`A turn is already in flight for this ${this.label} session. Use send() and stream() to let the listener queue messages.`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
await this.send(message);
|
|
236
|
+
for await (const msg of this.stream()) {
|
|
237
|
+
if (msg.type === "result") {
|
|
238
|
+
return msg;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
type: "result",
|
|
243
|
+
success: false,
|
|
244
|
+
error: "stream_closed",
|
|
245
|
+
errorCode: "stream_closed",
|
|
246
|
+
recoverable: false,
|
|
247
|
+
errorDetail: "Stream ended before terminal result",
|
|
248
|
+
durationMs: Date.now() - this.turns.activeTurnStartedAt,
|
|
249
|
+
conversationId: this._conversationId,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async *stream(): AsyncGenerator<SDKMessage> {
|
|
254
|
+
while (true) {
|
|
255
|
+
const msg = await this.turns.nextMessage();
|
|
256
|
+
if (!msg) break;
|
|
257
|
+
yield msg;
|
|
258
|
+
if (msg.type === "result") break;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async abort(): Promise<void> {
|
|
263
|
+
if (!this.initialized) return;
|
|
264
|
+
if (!this.controller || !this.runtime) return;
|
|
265
|
+
this.turns.markAbortRequested();
|
|
266
|
+
await this.controller.abort(this.runtime);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async sendCommand(command: SDKProtocolCommand): Promise<void>;
|
|
270
|
+
async sendCommand<TResponse extends SDKProtocolMessage = SDKProtocolMessage>(
|
|
271
|
+
command: SDKProtocolCommand,
|
|
272
|
+
options: SendCommandOptions,
|
|
273
|
+
): Promise<TResponse>;
|
|
274
|
+
async sendCommand<TResponse extends SDKProtocolMessage = SDKProtocolMessage>(
|
|
275
|
+
command: SDKProtocolCommand,
|
|
276
|
+
options?: SendCommandOptions,
|
|
277
|
+
): Promise<void | TResponse> {
|
|
278
|
+
if (!this.initialized) {
|
|
279
|
+
await this.initialize();
|
|
280
|
+
}
|
|
281
|
+
if (!this.controller) {
|
|
282
|
+
throw new Error("Session is not initialized");
|
|
283
|
+
}
|
|
284
|
+
if (!command || typeof command !== "object" || Array.isArray(command)) {
|
|
285
|
+
throw new Error("Invalid command. Expected a protocol command object.");
|
|
286
|
+
}
|
|
287
|
+
if (typeof command.type !== "string" || command.type.length === 0) {
|
|
288
|
+
throw new Error("Invalid command. Expected a non-empty type.");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (!options || (!options.responseType && !options.predicate && options.timeoutMs === undefined)) {
|
|
292
|
+
this.controller.send(command);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const { type, ...body } = command;
|
|
297
|
+
const response = await this.controller.request(type, body, {
|
|
298
|
+
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
|
|
299
|
+
predicate: options.predicate
|
|
300
|
+
? (message) => options.predicate?.(message as SDKProtocolMessage) === true
|
|
301
|
+
: options.responseType
|
|
302
|
+
? (message) => message.type === options.responseType
|
|
303
|
+
: undefined,
|
|
304
|
+
});
|
|
305
|
+
return response as TResponse;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async listModels(): Promise<ListModelsResult> {
|
|
309
|
+
if (!this.initialized) {
|
|
310
|
+
await this.initialize();
|
|
311
|
+
}
|
|
312
|
+
if (!this.controller) {
|
|
313
|
+
throw new Error("Session is not initialized");
|
|
314
|
+
}
|
|
315
|
+
return this.controller.listModels();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async updateModel(update: string | UpdateModelOptions): Promise<UpdateModelResult> {
|
|
319
|
+
if (!this.initialized) {
|
|
320
|
+
await this.initialize();
|
|
321
|
+
}
|
|
322
|
+
if (!this.controller || !this.runtime) {
|
|
323
|
+
throw new Error("Session is not initialized");
|
|
324
|
+
}
|
|
325
|
+
return this.applyModelUpdate(update);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private async applyModelUpdate(
|
|
329
|
+
update: string | UpdateModelOptions,
|
|
330
|
+
): Promise<UpdateModelResult> {
|
|
331
|
+
if (!this.controller || !this.runtime) {
|
|
332
|
+
throw new Error("Session is not initialized");
|
|
333
|
+
}
|
|
334
|
+
const normalized = normalizeUpdateModelInput(update);
|
|
335
|
+
const payload = await this.resolveUpdateModelPayload(normalized);
|
|
336
|
+
const result = await this.controller.updateModel(this.runtime, payload);
|
|
337
|
+
|
|
338
|
+
if (result.modelHandle !== undefined) {
|
|
339
|
+
this._model = result.modelHandle;
|
|
340
|
+
} else if (payload.model_handle !== undefined) {
|
|
341
|
+
this._model = payload.model_handle;
|
|
342
|
+
} else if (typeof result.modelSettings?.model === "string") {
|
|
343
|
+
this._model = result.modelSettings.model;
|
|
344
|
+
}
|
|
345
|
+
if ("modelSettings" in result) {
|
|
346
|
+
this._modelSettings = result.modelSettings ?? null;
|
|
347
|
+
}
|
|
348
|
+
return result;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async recoverPendingApprovals(
|
|
352
|
+
options: RecoverPendingApprovalsOptions = {},
|
|
353
|
+
): Promise<RecoverPendingApprovalsResult> {
|
|
354
|
+
if (!this.initialized) {
|
|
355
|
+
await this.initialize();
|
|
356
|
+
}
|
|
357
|
+
if (!this.controller || !this.runtime) {
|
|
358
|
+
throw new Error("Session is not initialized");
|
|
359
|
+
}
|
|
360
|
+
return this.controller.recoverPendingApprovals(this.runtime, options);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async removeQueuedMessage(itemId: string): Promise<RemoveQueuedMessageResult> {
|
|
364
|
+
if (typeof itemId !== "string" || itemId.trim().length === 0) {
|
|
365
|
+
throw new Error("Invalid queue item id. Expected a non-empty string.");
|
|
366
|
+
}
|
|
367
|
+
if (!this.initialized) {
|
|
368
|
+
await this.initialize();
|
|
369
|
+
}
|
|
370
|
+
if (!this.controller || !this.runtime) {
|
|
371
|
+
throw new Error("Session is not initialized");
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const response = await this.controller.request(
|
|
375
|
+
"remove_queue_item",
|
|
376
|
+
{
|
|
377
|
+
runtime: this.runtime,
|
|
378
|
+
item_id: itemId,
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
predicate: (message) =>
|
|
382
|
+
message.type === "remove_queue_item_response" &&
|
|
383
|
+
message.item_id === itemId,
|
|
384
|
+
},
|
|
385
|
+
);
|
|
386
|
+
if (typeof response.success !== "boolean") {
|
|
387
|
+
throw new Error("Invalid remove_queue_item_response from runtime");
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
itemId:
|
|
392
|
+
typeof response.item_id === "string" ? response.item_id : itemId,
|
|
393
|
+
removed: response.success,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async getDeviceStatus(
|
|
398
|
+
options: GetDeviceStatusOptions = {},
|
|
399
|
+
): Promise<SessionDeviceStatus> {
|
|
400
|
+
if (!this.initialized) {
|
|
401
|
+
await this.initialize();
|
|
402
|
+
}
|
|
403
|
+
if (!this.controller || !this.runtime) {
|
|
404
|
+
throw new Error("Session is not initialized");
|
|
405
|
+
}
|
|
406
|
+
const timeoutMs = options.timeoutMs ?? this.requestTimeoutMs ?? 30_000;
|
|
407
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) {
|
|
408
|
+
throw new Error(
|
|
409
|
+
"Invalid device status timeout. Expected a positive integer.",
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
return this.refreshDeviceStatus(timeoutMs);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
onDeviceStatus(listener: (status: SessionDeviceStatus) => void): () => void {
|
|
416
|
+
if (typeof listener !== "function") {
|
|
417
|
+
throw new Error("Invalid device status listener. Expected a function.");
|
|
418
|
+
}
|
|
419
|
+
this.deviceStatusListeners.add(listener);
|
|
420
|
+
return () => {
|
|
421
|
+
this.deviceStatusListeners.delete(listener);
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
private refreshDeviceStatus(timeoutMs: number): Promise<SessionDeviceStatus> {
|
|
426
|
+
const controller = this.controller;
|
|
427
|
+
const runtime = this.runtime;
|
|
428
|
+
if (!controller || !runtime) {
|
|
429
|
+
return Promise.reject(new Error("Session is not initialized"));
|
|
430
|
+
}
|
|
431
|
+
return new Promise<SessionDeviceStatus>((resolve, reject) => {
|
|
432
|
+
let status: SessionDeviceStatus | null = null;
|
|
433
|
+
let syncAcknowledged = false;
|
|
434
|
+
let settled = false;
|
|
435
|
+
const cleanup = () => {
|
|
436
|
+
clearTimeout(timer);
|
|
437
|
+
unsubscribe();
|
|
438
|
+
this.deviceStatusRefreshCancels.delete(cancel);
|
|
439
|
+
};
|
|
440
|
+
const rejectOnce = (error: Error) => {
|
|
441
|
+
if (settled) return;
|
|
442
|
+
settled = true;
|
|
443
|
+
cleanup();
|
|
444
|
+
reject(error);
|
|
445
|
+
};
|
|
446
|
+
const resolveIfComplete = () => {
|
|
447
|
+
if (settled || !syncAcknowledged || !status) return;
|
|
448
|
+
settled = true;
|
|
449
|
+
cleanup();
|
|
450
|
+
resolve(status);
|
|
451
|
+
};
|
|
452
|
+
const cancel = (error: Error) => rejectOnce(error);
|
|
453
|
+
const timer = setTimeout(() => {
|
|
454
|
+
rejectOnce(
|
|
455
|
+
new Error(`Timed out waiting for ${this.label} device status`),
|
|
456
|
+
);
|
|
457
|
+
}, timeoutMs);
|
|
458
|
+
(timer as { unref?: () => void }).unref?.();
|
|
459
|
+
const unsubscribe = this.onDeviceStatus((nextStatus) => {
|
|
460
|
+
status = nextStatus;
|
|
461
|
+
resolveIfComplete();
|
|
462
|
+
});
|
|
463
|
+
this.deviceStatusRefreshCancels.add(cancel);
|
|
464
|
+
|
|
465
|
+
void controller.request(
|
|
466
|
+
"sync",
|
|
467
|
+
{
|
|
468
|
+
runtime,
|
|
469
|
+
recover_approvals: false,
|
|
470
|
+
force_device_status: true,
|
|
471
|
+
},
|
|
472
|
+
{
|
|
473
|
+
timeoutMs,
|
|
474
|
+
predicate: (message) => message.type === "sync_response",
|
|
475
|
+
},
|
|
476
|
+
).then(
|
|
477
|
+
(response) => {
|
|
478
|
+
if (response.success === false) {
|
|
479
|
+
rejectOnce(
|
|
480
|
+
new Error(
|
|
481
|
+
typeof response.error === "string"
|
|
482
|
+
? response.error
|
|
483
|
+
: `Failed to refresh ${this.label} device status`,
|
|
484
|
+
),
|
|
485
|
+
);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
syncAcknowledged = true;
|
|
489
|
+
resolveIfComplete();
|
|
490
|
+
},
|
|
491
|
+
(error) => {
|
|
492
|
+
rejectOnce(
|
|
493
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
494
|
+
);
|
|
495
|
+
},
|
|
496
|
+
);
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async listMessages(options: ListMessagesOptions = {}): Promise<ListMessagesResult> {
|
|
501
|
+
if (!this.initialized) {
|
|
502
|
+
await this.initialize();
|
|
503
|
+
}
|
|
504
|
+
if (!this.controller) {
|
|
505
|
+
throw new Error("Session is not initialized");
|
|
506
|
+
}
|
|
507
|
+
const conversationId = options.conversationId ?? this._conversationId;
|
|
508
|
+
if (!conversationId) {
|
|
509
|
+
throw new Error("No conversation id available for listMessages()");
|
|
510
|
+
}
|
|
511
|
+
return this.controller.listMessages(conversationId, options);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async bootstrapState(
|
|
515
|
+
options: BootstrapStateOptions = {},
|
|
516
|
+
): Promise<BootstrapStateResult> {
|
|
517
|
+
if (!this.initialized) {
|
|
518
|
+
await this.initialize();
|
|
519
|
+
}
|
|
520
|
+
const page = await this.listMessages({
|
|
521
|
+
limit: options.limit,
|
|
522
|
+
order: options.order,
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
const state: BootstrapStateResult = {
|
|
526
|
+
agentId: this._agentId ?? "",
|
|
527
|
+
conversationId: this._conversationId ?? "",
|
|
528
|
+
model: this._model,
|
|
529
|
+
messages: page.messages,
|
|
530
|
+
};
|
|
531
|
+
if (this.toolNames !== undefined) {
|
|
532
|
+
state.tools = this.toolNames;
|
|
533
|
+
}
|
|
534
|
+
if (page.nextBefore !== undefined) {
|
|
535
|
+
state.nextBefore = page.nextBefore;
|
|
536
|
+
}
|
|
537
|
+
if (page.hasMore !== undefined) {
|
|
538
|
+
state.hasMore = page.hasMore;
|
|
539
|
+
}
|
|
540
|
+
return state;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
close(): void {
|
|
544
|
+
if (this.closed) return;
|
|
545
|
+
this.closed = true;
|
|
546
|
+
this.removeMessageHandler?.();
|
|
547
|
+
this.removeMessageHandler = null;
|
|
548
|
+
this.turns.close();
|
|
549
|
+
for (const cancel of [...this.deviceStatusRefreshCancels]) {
|
|
550
|
+
cancel(new Error(`Session closed while waiting for ${this.label} device status`));
|
|
551
|
+
}
|
|
552
|
+
this.deviceStatusRefreshCancels.clear();
|
|
553
|
+
this.deviceStatusListeners.clear();
|
|
554
|
+
this.controller?.close();
|
|
555
|
+
this.controller = null;
|
|
556
|
+
this.onCoreClose();
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
get agentId(): string | null {
|
|
560
|
+
return this._agentId;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
get sessionId(): string | null {
|
|
564
|
+
return this._sessionId;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
get conversationId(): string | null {
|
|
568
|
+
return this._conversationId;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
async [Symbol.asyncDispose](): Promise<void> {
|
|
572
|
+
this.close();
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async changeDeviceState(
|
|
576
|
+
updates: ChangeDeviceStateOptions,
|
|
577
|
+
): Promise<void> {
|
|
578
|
+
if (!this.initialized) {
|
|
579
|
+
await this.initialize();
|
|
580
|
+
}
|
|
581
|
+
if (!this.controller || !this.runtime) {
|
|
582
|
+
throw new Error("Session is not initialized");
|
|
583
|
+
}
|
|
584
|
+
const payload: Record<string, unknown> = {};
|
|
585
|
+
if (updates.cwd !== undefined) payload.cwd = updates.cwd;
|
|
586
|
+
if (updates.permissionMode !== undefined) {
|
|
587
|
+
const mode = mapPermissionMode(updates.permissionMode);
|
|
588
|
+
if (mode !== undefined) payload.mode = mode;
|
|
589
|
+
}
|
|
590
|
+
if (Object.keys(payload).length === 0) {
|
|
591
|
+
throw new Error(
|
|
592
|
+
"Invalid device state update. Expected cwd or permissionMode.",
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
this.controller.send({
|
|
597
|
+
type: "change_device_state",
|
|
598
|
+
runtime: this.runtime,
|
|
599
|
+
payload,
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
async updateToolset(toolsetPreference: string): Promise<void> {
|
|
604
|
+
if (!this.initialized) {
|
|
605
|
+
await this.initialize();
|
|
606
|
+
}
|
|
607
|
+
if (!this.controller || !this.runtime) {
|
|
608
|
+
throw new Error("Session is not initialized");
|
|
609
|
+
}
|
|
610
|
+
const response = await this.controller.request(
|
|
611
|
+
"update_toolset",
|
|
612
|
+
{
|
|
613
|
+
runtime: this.runtime,
|
|
614
|
+
toolset_preference: toolsetPreference,
|
|
615
|
+
},
|
|
616
|
+
{ predicate: (message) => message.type === "update_toolset_response" },
|
|
617
|
+
);
|
|
618
|
+
ensureSuccess(response, "Failed to update toolset");
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
protected abstract initializeRuntimeController(): Promise<RuntimeSessionInit>;
|
|
622
|
+
|
|
623
|
+
protected async afterRuntimeInitialized(): Promise<void> {
|
|
624
|
+
// Optional hook for subclasses to send transport-specific startup frames.
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
protected async beforeTurn(): Promise<void> {
|
|
628
|
+
// Optional hook for subclasses to refresh transport-specific lifecycle state.
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
protected onCoreClose(): void {
|
|
632
|
+
// Optional hook for subclass-owned resources outside the controller.
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
protected currentOptions(): LettaCodeClientSessionOptions | CreateAgentOptions {
|
|
636
|
+
return this.mode.options;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
protected shouldEnableMemfs(options: LettaCodeClientSessionOptions | CreateAgentOptions): boolean {
|
|
640
|
+
void options;
|
|
641
|
+
return false;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
protected enableMemfsBody(): Record<string, unknown> {
|
|
645
|
+
if (!this.runtime) return {};
|
|
646
|
+
return { agent_id: this.runtime.agent_id };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
protected setModel(model: string): void {
|
|
650
|
+
this._model = model;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
private async resolveUpdateModelPayload(
|
|
654
|
+
input: NormalizedUpdateModelInput,
|
|
655
|
+
): Promise<UpdateModelPayload> {
|
|
656
|
+
if (input.reasoningEffort === undefined) {
|
|
657
|
+
return modelPayloadWithoutReasoning(input);
|
|
658
|
+
}
|
|
659
|
+
if (!this.controller) {
|
|
660
|
+
throw new Error("Session is not initialized");
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const catalog = await this.controller.listModels();
|
|
664
|
+
const byId = new Map(catalog.entries.map((entry) => [entry.id, entry]));
|
|
665
|
+
const aliases = catalog.byokProviderAliases;
|
|
666
|
+
|
|
667
|
+
let baseEntry: LettaCodeModelEntry | undefined;
|
|
668
|
+
let explicitHandle: string | undefined;
|
|
669
|
+
let targetHandle: string | undefined;
|
|
670
|
+
|
|
671
|
+
if (input.modelId !== undefined) {
|
|
672
|
+
baseEntry = byId.get(input.modelId);
|
|
673
|
+
explicitHandle = input.modelHandle;
|
|
674
|
+
targetHandle = baseEntry?.handle ?? toBaseModelHandle(input.modelHandle, aliases);
|
|
675
|
+
} else if (input.modelHandle !== undefined) {
|
|
676
|
+
explicitHandle = input.modelHandle;
|
|
677
|
+
targetHandle = toBaseModelHandle(input.modelHandle, aliases);
|
|
678
|
+
} else if (input.model !== undefined) {
|
|
679
|
+
baseEntry = byId.get(input.model);
|
|
680
|
+
if (baseEntry) {
|
|
681
|
+
targetHandle = baseEntry.handle;
|
|
682
|
+
} else {
|
|
683
|
+
explicitHandle = input.model;
|
|
684
|
+
targetHandle = toBaseModelHandle(input.model, aliases);
|
|
685
|
+
}
|
|
686
|
+
} else {
|
|
687
|
+
explicitHandle = this._model || undefined;
|
|
688
|
+
targetHandle = toBaseModelHandle(this._model || undefined, aliases);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
if (!targetHandle) {
|
|
692
|
+
throw new Error("reasoningEffort requires a current model or explicit model/modelId/modelHandle.");
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const candidates = catalog.entries.filter(
|
|
696
|
+
(entry) => entry.handle === targetHandle || entry.handle === explicitHandle,
|
|
697
|
+
);
|
|
698
|
+
if (candidates.length === 0) {
|
|
699
|
+
throw new Error(
|
|
700
|
+
`reasoningEffort requires a model from listModels(); no catalog entry found for ${targetHandle}.`,
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const contextWindow =
|
|
705
|
+
getContextWindow(baseEntry?.updateArgs) ?? getContextWindow(this._modelSettings);
|
|
706
|
+
const scopedCandidates = sameContextCandidates(candidates, contextWindow);
|
|
707
|
+
const matchingEntry =
|
|
708
|
+
scopedCandidates.find((entry) => getReasoningEffort(entry) === input.reasoningEffort) ??
|
|
709
|
+
candidates.find((entry) => getReasoningEffort(entry) === input.reasoningEffort);
|
|
710
|
+
|
|
711
|
+
if (!matchingEntry) {
|
|
712
|
+
throw new Error(
|
|
713
|
+
`No ${input.reasoningEffort} reasoning tier found for model ${targetHandle}.`,
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const payload: UpdateModelPayload = { model_id: matchingEntry.id };
|
|
718
|
+
if (explicitHandle !== undefined) {
|
|
719
|
+
payload.model_handle = explicitHandle;
|
|
720
|
+
}
|
|
721
|
+
return payload;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
protected async applyPostInitializeOptions(): Promise<void> {
|
|
725
|
+
if (!this.controller || !this.runtime) return;
|
|
726
|
+
|
|
727
|
+
const options = this.currentOptions();
|
|
728
|
+
|
|
729
|
+
if (this.shouldEnableMemfs(options)) {
|
|
730
|
+
const response = await this.controller.request(
|
|
731
|
+
"enable_memfs",
|
|
732
|
+
this.enableMemfsBody(),
|
|
733
|
+
{
|
|
734
|
+
predicate: (message) => message.type === "enable_memfs_response",
|
|
735
|
+
// Enabling the memory filesystem on a cloud agent is a slow
|
|
736
|
+
// multi-round-trip operation; the default request timeout is far
|
|
737
|
+
// too tight for it.
|
|
738
|
+
timeoutMs: 180_000,
|
|
739
|
+
},
|
|
740
|
+
);
|
|
741
|
+
ensureSuccess(response, "Failed to enable memfs");
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const dreamingSettings = resolveDreamingSettings(options.dreaming);
|
|
745
|
+
if (dreamingSettings) {
|
|
746
|
+
const response = await this.controller.request(
|
|
747
|
+
"set_reflection_settings",
|
|
748
|
+
{
|
|
749
|
+
runtime: this.runtime,
|
|
750
|
+
settings: dreamingSettings,
|
|
751
|
+
scope: "both",
|
|
752
|
+
},
|
|
753
|
+
{ predicate: (message) => message.type === "set_reflection_settings_response" },
|
|
754
|
+
);
|
|
755
|
+
ensureSuccess(response, "Failed to update dreaming settings");
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
if (this.mode.kind !== "session") return;
|
|
759
|
+
|
|
760
|
+
if (
|
|
761
|
+
this.mode.options.model !== undefined ||
|
|
762
|
+
this.mode.options.reasoningEffort !== undefined
|
|
763
|
+
) {
|
|
764
|
+
await this.applyModelUpdate({
|
|
765
|
+
...(this.mode.options.model !== undefined ? { model: this.mode.options.model } : {}),
|
|
766
|
+
...(this.mode.options.reasoningEffort !== undefined
|
|
767
|
+
? { reasoningEffort: this.mode.options.reasoningEffort }
|
|
768
|
+
: {}),
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Initial cwd and permission mode are part of runtime_start for
|
|
773
|
+
// websocket protocol sessions. Reserve change_device_state for explicit
|
|
774
|
+
// post-init mutations via changeDeviceState().
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
private emitDeviceStatus(status: SessionDeviceStatus): void {
|
|
778
|
+
for (const listener of [...this.deviceStatusListeners]) {
|
|
779
|
+
try {
|
|
780
|
+
listener(status);
|
|
781
|
+
} catch {
|
|
782
|
+
// Subscriber errors must not break the protocol message pump.
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|