@letta-ai/letta-agent-sdk 0.8.73 → 0.8.74
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/README.md +69 -0
- package/dist/app-server-session.d.ts +28 -7
- package/dist/app-server-session.d.ts.map +1 -1
- package/dist/client-base.d.ts +17 -0
- package/dist/client-base.d.ts.map +1 -1
- package/dist/client-entry.d.ts +1 -0
- package/dist/client-entry.d.ts.map +1 -1
- package/dist/client-entry.js +480 -77
- package/dist/client-entry.js.map +15 -12
- package/dist/client.d.ts +9 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/cloud-session.d.ts.map +1 -1
- package/dist/cloud-status-transport.d.ts.map +1 -1
- package/dist/external-tools.d.ts +11 -0
- package/dist/external-tools.d.ts.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +508 -102
- package/dist/index.js.map +16 -13
- package/dist/remote-client-session-core.d.ts.map +1 -1
- package/dist/remote-session-protocol.d.ts +3 -1
- package/dist/remote-session-protocol.d.ts.map +1 -1
- package/dist/remote-turn-coordinator.d.ts +14 -0
- package/dist/remote-turn-coordinator.d.ts.map +1 -1
- package/dist/runtime-tool-policy.d.ts +57 -0
- package/dist/runtime-tool-policy.d.ts.map +1 -0
- package/dist/structured-output-session.d.ts.map +1 -1
- package/dist/tool-registration.d.ts +67 -0
- package/dist/tool-registration.d.ts.map +1 -0
- package/dist/types.d.ts +30 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/app-server-session.ts +104 -60
- package/src/client-base.ts +57 -14
- package/src/client-entry.ts +1 -0
- package/src/client.ts +6 -2
- package/src/cloud-session.ts +12 -3
- package/src/cloud-status-transport.ts +4 -0
- package/src/external-tools.ts +69 -0
- package/src/index.ts +3 -0
- package/src/remote-client-session-core.ts +4 -4
- package/src/remote-session-protocol.ts +3 -0
- package/src/remote-turn-coordinator.ts +80 -15
- package/src/runtime-tool-policy.ts +114 -0
- package/src/structured-output-session.ts +2 -1
- package/src/tool-registration.ts +311 -0
- package/src/types.ts +39 -1
|
@@ -3,9 +3,11 @@ import type {
|
|
|
3
3
|
SDKLoopStatusMessage,
|
|
4
4
|
SDKMessage,
|
|
5
5
|
OutputFormat,
|
|
6
|
+
SDKQueueItem,
|
|
6
7
|
SDKQueueUpdateMessage,
|
|
7
8
|
SDKResultMessage,
|
|
8
9
|
SDKStreamEventPayload,
|
|
10
|
+
SDKTurnOrigin,
|
|
9
11
|
SessionDeviceStatus,
|
|
10
12
|
} from "./types.js";
|
|
11
13
|
import {
|
|
@@ -46,6 +48,9 @@ type RemoteTurnCoordinatorConfig = {
|
|
|
46
48
|
|
|
47
49
|
const MAX_RECENTLY_SETTLED_RUN_IDS = 256;
|
|
48
50
|
const TRAILING_USAGE_GRACE_MS = 100;
|
|
51
|
+
const TURN_ORIGINS = new Set<string>([
|
|
52
|
+
"task_notification", "cron", "subagent", "system", "channel", "user",
|
|
53
|
+
]);
|
|
49
54
|
|
|
50
55
|
/**
|
|
51
56
|
* Owns the portable session's turn correlation and stream queue.
|
|
@@ -64,6 +69,9 @@ export class RemoteTurnCoordinator {
|
|
|
64
69
|
private activeTurn: TurnTracker | null = null;
|
|
65
70
|
private pendingTurns: TurnTracker[] = [];
|
|
66
71
|
private settledRunIds = new Set<string>();
|
|
72
|
+
/** Queue sources by client message id, to tag turns the session did not send. */
|
|
73
|
+
private queueOrigins = new Map<string, SDKTurnOrigin>();
|
|
74
|
+
private nextServerOrigin: SDKTurnOrigin = "unknown";
|
|
67
75
|
private nextTurnId = 0;
|
|
68
76
|
private messageCounter = 0;
|
|
69
77
|
private clientMessageCounter = 0;
|
|
@@ -81,8 +89,10 @@ export class RemoteTurnCoordinator {
|
|
|
81
89
|
return this._activeTurnStartedAt;
|
|
82
90
|
}
|
|
83
91
|
|
|
92
|
+
/** Whether a turn this session sent is active or queued. */
|
|
84
93
|
hasInFlightTurn(): boolean {
|
|
85
|
-
return this.activeTurn !== null
|
|
94
|
+
return (this.activeTurn !== null && this.activeTurn.origin === undefined) ||
|
|
95
|
+
this.pendingTurns.length > 0;
|
|
86
96
|
}
|
|
87
97
|
|
|
88
98
|
/**
|
|
@@ -96,13 +106,29 @@ export class RemoteTurnCoordinator {
|
|
|
96
106
|
outputFormat?: OutputFormat,
|
|
97
107
|
): TurnTracker {
|
|
98
108
|
const otid = normalizeCallerOtid(callerOtid);
|
|
99
|
-
const turn
|
|
100
|
-
id: ++this.nextTurnId,
|
|
101
|
-
runtime,
|
|
109
|
+
const turn = this.newTracker(runtime, {
|
|
102
110
|
...(otid !== undefined ? { otid } : {}),
|
|
103
111
|
...(outputFormat !== undefined ? { outputFormat } : {}),
|
|
104
112
|
clientMessageId:
|
|
105
113
|
otid ?? `sdk-message-${Date.now()}-${++this.clientMessageCounter}`,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
if (this.activeTurn) {
|
|
117
|
+
this.pendingTurns.push(turn);
|
|
118
|
+
} else {
|
|
119
|
+
this.activateTurn(turn);
|
|
120
|
+
}
|
|
121
|
+
return turn;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private newTracker(
|
|
125
|
+
runtime: RuntimeScope,
|
|
126
|
+
identity: Pick<TurnTracker, "clientMessageId" | "otid" | "outputFormat" | "origin">,
|
|
127
|
+
): TurnTracker {
|
|
128
|
+
return {
|
|
129
|
+
id: ++this.nextTurnId,
|
|
130
|
+
runtime,
|
|
131
|
+
...identity,
|
|
106
132
|
queuedAt: Date.now(),
|
|
107
133
|
startedAt: 0,
|
|
108
134
|
assistantText: "",
|
|
@@ -119,15 +145,44 @@ export class RemoteTurnCoordinator {
|
|
|
119
145
|
abortRequested: false,
|
|
120
146
|
timeout: null,
|
|
121
147
|
};
|
|
148
|
+
}
|
|
122
149
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
150
|
+
/**
|
|
151
|
+
* A run started while the session had no turn of its own in flight, such as
|
|
152
|
+
* a task notification or cron prompt. Track it like a sent turn so stream()
|
|
153
|
+
* yields its events and a result tagged with its origin.
|
|
154
|
+
*/
|
|
155
|
+
private startServerTurn(runtime: RuntimeScope): TurnTracker {
|
|
156
|
+
const turn = this.newTracker(runtime, { clientMessageId: "", origin: this.nextServerOrigin });
|
|
157
|
+
this.nextServerOrigin = "unknown";
|
|
158
|
+
this.activateTurn(turn);
|
|
128
159
|
return turn;
|
|
129
160
|
}
|
|
130
161
|
|
|
162
|
+
private observeQueueOrigins(message: ProtocolMessage, queue: SDKQueueItem[]): void {
|
|
163
|
+
for (const item of queue) {
|
|
164
|
+
if (!item.clientMessageId) continue;
|
|
165
|
+
this.queueOrigins.set(item.clientMessageId,
|
|
166
|
+
TURN_ORIGINS.has(item.source) ? (item.source as SDKTurnOrigin) : "unknown");
|
|
167
|
+
if (this.queueOrigins.size > MAX_RECENTLY_SETTLED_RUN_IDS) {
|
|
168
|
+
this.queueOrigins.delete(this.queueOrigins.keys().next().value!);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
for (const removed of Array.isArray(message.removed) ? message.removed : []) {
|
|
172
|
+
const { client_message_id: id, disposition, source } =
|
|
173
|
+
removed as { client_message_id?: unknown; disposition?: unknown; source?: unknown };
|
|
174
|
+
if (disposition !== "dequeued" || typeof id !== "string") continue;
|
|
175
|
+
const own = [this.activeTurn, ...this.pendingTurns]
|
|
176
|
+
.some((turn) => turn?.clientMessageId === id);
|
|
177
|
+
if (own) continue;
|
|
178
|
+
// Listeners that send `source` on removals cover items added and dequeued
|
|
179
|
+
// in the same update; older ones only expose it on the queued item.
|
|
180
|
+
this.nextServerOrigin = typeof source === "string" && TURN_ORIGINS.has(source)
|
|
181
|
+
? (source as SDKTurnOrigin)
|
|
182
|
+
: this.queueOrigins.get(id) ?? "unknown";
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
131
186
|
removeTrackedTurn(turn: TurnTracker): void {
|
|
132
187
|
if (turn.timeout) clearTimeout(turn.timeout);
|
|
133
188
|
if (turn.pendingTerminalTimeout) clearTimeout(turn.pendingTerminalTimeout);
|
|
@@ -139,6 +194,13 @@ export class RemoteTurnCoordinator {
|
|
|
139
194
|
if (index !== -1) this.pendingTurns.splice(index, 1);
|
|
140
195
|
}
|
|
141
196
|
|
|
197
|
+
/** A dropped transport cannot finish a turn the runtime started; end it here. */
|
|
198
|
+
failServerTurn(detail: string): void {
|
|
199
|
+
const active = this.activeTurn;
|
|
200
|
+
if (active?.origin === undefined) return;
|
|
201
|
+
this.failTurn(active, detail, { errorCode: "stream_closed", recoverable: true });
|
|
202
|
+
}
|
|
203
|
+
|
|
142
204
|
markAbortRequested(): void {
|
|
143
205
|
if (this.activeTurn) this.activeTurn.abortRequested = true;
|
|
144
206
|
}
|
|
@@ -158,6 +220,7 @@ export class RemoteTurnCoordinator {
|
|
|
158
220
|
type: "queue_update",
|
|
159
221
|
queue: queueItems(message),
|
|
160
222
|
};
|
|
223
|
+
this.observeQueueOrigins(message, sdkMessage.queue);
|
|
161
224
|
this.enqueue(sdkMessage);
|
|
162
225
|
return;
|
|
163
226
|
}
|
|
@@ -219,12 +282,6 @@ export class RemoteTurnCoordinator {
|
|
|
219
282
|
const delta = streamDeltaRecord(message);
|
|
220
283
|
if (!delta) return;
|
|
221
284
|
const messageType = streamDeltaMessageType(delta);
|
|
222
|
-
// Terminal metadata can trail the result it belongs to. It must never
|
|
223
|
-
// activate a queued turn or become evidence for that next turn.
|
|
224
|
-
const active =
|
|
225
|
-
messageType === "usage_statistics" || messageType === "stop_reason"
|
|
226
|
-
? this.activeTurn
|
|
227
|
-
: this.activateNextTurnFromProtocol();
|
|
228
285
|
const runId = streamDeltaRunId(delta);
|
|
229
286
|
const isTurnEvidence =
|
|
230
287
|
messageType === "assistant_message" ||
|
|
@@ -235,6 +292,13 @@ export class RemoteTurnCoordinator {
|
|
|
235
292
|
messageType === "error_message" ||
|
|
236
293
|
messageType === "loop_error" ||
|
|
237
294
|
(runId !== undefined && (messageType === "usage_statistics" || messageType === "stop_reason"));
|
|
295
|
+
// Terminal metadata can trail the result it belongs to. It must never
|
|
296
|
+
// activate a queued turn or become evidence for that next turn.
|
|
297
|
+
const active =
|
|
298
|
+
messageType === "usage_statistics" || messageType === "stop_reason"
|
|
299
|
+
? this.activeTurn
|
|
300
|
+
: (this.activateNextTurnFromProtocol() ??
|
|
301
|
+
(isTurnEvidence && runId !== undefined ? this.startServerTurn(runtime) : null));
|
|
238
302
|
// Persisted user-message echoes and lifecycle status are not evidence that
|
|
239
303
|
// the new model run started. In particular they may arrive after an abort.
|
|
240
304
|
if (active && isTurnEvidence) {
|
|
@@ -806,6 +870,7 @@ export class RemoteTurnCoordinator {
|
|
|
806
870
|
Date.now() - (tracker?.startedAt || this._activeTurnStartedAt),
|
|
807
871
|
conversationId: turn.runtime.conversation_id,
|
|
808
872
|
runIds: turn.runIds.length > 0 ? turn.runIds : undefined,
|
|
873
|
+
...(tracker?.origin !== undefined ? { origin: tracker.origin } : {}),
|
|
809
874
|
};
|
|
810
875
|
}
|
|
811
876
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { AppServerClient } from "@letta-ai/letta-code/app-server-client";
|
|
2
|
+
import type { AnyAgentTool, ClientToolsetConfig } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A connection's tool selection for every turn on a conversation, including
|
|
6
|
+
* turns the runtime starts itself (task notifications, cron prompts, wakes).
|
|
7
|
+
* Mirrors Letta Code's `RuntimeToolPolicy` wire shape on `runtime_start` and
|
|
8
|
+
* `runtime_external_tools_update`; listeners without the capability ignore it.
|
|
9
|
+
*/
|
|
10
|
+
export type RuntimeToolPolicy = {
|
|
11
|
+
toolset?: { base?: ClientToolsetConfig["base"]; include?: string[] };
|
|
12
|
+
allowlist?: string[];
|
|
13
|
+
exclude_interactive?: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** `app_server_info` capability advertising `tool_policy` support. */
|
|
17
|
+
const RUNTIME_TOOL_POLICY_CAPABILITY = "runtime_tool_policy";
|
|
18
|
+
/** Bounded so a listener that cannot answer never delays session startup long. */
|
|
19
|
+
const CAPABILITY_PROBE_TIMEOUT_MS = 5_000;
|
|
20
|
+
|
|
21
|
+
export function clientToolsetPayload(
|
|
22
|
+
toolset: ClientToolsetConfig,
|
|
23
|
+
): NonNullable<RuntimeToolPolicy["toolset"]> {
|
|
24
|
+
return {
|
|
25
|
+
...(toolset.base !== undefined ? { base: toolset.base } : {}),
|
|
26
|
+
...(toolset.include !== undefined
|
|
27
|
+
? { include: [...new Set(toolset.include)] }
|
|
28
|
+
: {}),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build the policy from the same options a session accepts. SDK sessions are
|
|
34
|
+
* headless, so interactive user-input tools are always excluded.
|
|
35
|
+
*/
|
|
36
|
+
export function runtimeToolPolicy(
|
|
37
|
+
allowedTools: readonly string[] | undefined,
|
|
38
|
+
toolset: ClientToolsetConfig | undefined,
|
|
39
|
+
): RuntimeToolPolicy {
|
|
40
|
+
return {
|
|
41
|
+
...(toolset !== undefined ? { toolset: clientToolsetPayload(toolset) } : {}),
|
|
42
|
+
...(allowedTools !== undefined ? { allowlist: [...new Set(allowedTools)] } : {}),
|
|
43
|
+
exclude_interactive: true,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether the connected listener applies `tool_policy` to every turn. Older
|
|
49
|
+
* listeners, and transports that cannot answer `app_server_info`, report false
|
|
50
|
+
* so callers keep sending the equivalent per-message fields.
|
|
51
|
+
*/
|
|
52
|
+
export async function supportsRuntimeToolPolicy(
|
|
53
|
+
client: Pick<AppServerClient, "info">,
|
|
54
|
+
): Promise<boolean> {
|
|
55
|
+
try {
|
|
56
|
+
const info = await client.info({ timeoutMs: CAPABILITY_PROBE_TIMEOUT_MS });
|
|
57
|
+
const capabilities = info.capabilities as Record<string, unknown> | undefined;
|
|
58
|
+
return capabilities?.[RUNTIME_TOOL_POLICY_CAPABILITY] === true;
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The part of a registration a session on one of its conversations inherits. */
|
|
65
|
+
export type InheritedTools = {
|
|
66
|
+
tools: AnyAgentTool[];
|
|
67
|
+
allowedTools?: string[];
|
|
68
|
+
toolset?: ClientToolsetConfig;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** A session's own tool selection: its `tools` (plus MCP tools) and options. */
|
|
72
|
+
export type SessionToolSelection = {
|
|
73
|
+
tools: AnyAgentTool[];
|
|
74
|
+
allowedTools?: string[];
|
|
75
|
+
toolset?: ClientToolsetConfig;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Where a session reads the registration covering its conversation. `current()`
|
|
80
|
+
* returns null once no registration covers it any more.
|
|
81
|
+
*/
|
|
82
|
+
export interface InheritedToolSource {
|
|
83
|
+
current(): InheritedTools | null;
|
|
84
|
+
onChange(listener: () => void): () => void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Combine a registration with a session's own selection. The session's tools
|
|
89
|
+
* are added on top (its own wins on a name clash). Its `allowedTools` can only
|
|
90
|
+
* narrow the registration's: entries outside the registration's allowlist are
|
|
91
|
+
* dropped unless they name one of the session's own tools. Without its own
|
|
92
|
+
* `allowedTools`, the session gets the registration's plus its own tools.
|
|
93
|
+
*/
|
|
94
|
+
export function mergeInheritedTools(
|
|
95
|
+
inherited: InheritedTools | null,
|
|
96
|
+
own: SessionToolSelection,
|
|
97
|
+
): SessionToolSelection {
|
|
98
|
+
if (!inherited) return own;
|
|
99
|
+
const tools = new Map(inherited.tools.map((tool) => [tool.name, tool]));
|
|
100
|
+
for (const tool of own.tools) tools.set(tool.name, tool);
|
|
101
|
+
const ownNames = new Set(own.tools.map((tool) => tool.name));
|
|
102
|
+
const registered = inherited.allowedTools;
|
|
103
|
+
const allowedTools = registered === undefined
|
|
104
|
+
? own.allowedTools
|
|
105
|
+
: own.allowedTools === undefined
|
|
106
|
+
? [...new Set([...registered, ...ownNames])]
|
|
107
|
+
: own.allowedTools.filter((name) => registered.includes(name) || ownNames.has(name));
|
|
108
|
+
const toolset = own.toolset ?? inherited.toolset;
|
|
109
|
+
return {
|
|
110
|
+
tools: [...tools.values()],
|
|
111
|
+
...(allowedTools !== undefined ? { allowedTools } : {}),
|
|
112
|
+
...(toolset !== undefined ? { toolset } : {}),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -64,7 +64,8 @@ export async function* streamStructuredTurns(config: {
|
|
|
64
64
|
// External SDK tools have no terminal rule; stop after their successful return.
|
|
65
65
|
void config.abort().catch(() => undefined);
|
|
66
66
|
}
|
|
67
|
-
|
|
67
|
+
// Results of turns the session did not send never settle its own turn.
|
|
68
|
+
if (msg.type !== "result" || msg.origin !== undefined) {
|
|
68
69
|
yield msg;
|
|
69
70
|
continue;
|
|
70
71
|
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createAppServerClient,
|
|
3
|
+
type AppServerClient,
|
|
4
|
+
type AppServerSocketConstructor,
|
|
5
|
+
} from "@letta-ai/letta-code/app-server-client";
|
|
6
|
+
import type { ConversationRetrieveResponseMessage } from "@letta-ai/letta-code/app-server-protocol";
|
|
7
|
+
import type { AppServerSessionOptions } from "./app-server-session.js";
|
|
8
|
+
import { createExternalToolCallHandler, externalToolGroups } from "./external-tools.js";
|
|
9
|
+
import { applyUniqueRequestIds } from "./request-ids.js";
|
|
10
|
+
import {
|
|
11
|
+
runtimeToolPolicy,
|
|
12
|
+
supportsRuntimeToolPolicy,
|
|
13
|
+
type InheritedToolSource,
|
|
14
|
+
} from "./runtime-tool-policy.js";
|
|
15
|
+
import type { RuntimeScope } from "./remote-session-protocol.js";
|
|
16
|
+
import type { AnyAgentTool, ClientToolsetConfig } from "./types.js";
|
|
17
|
+
|
|
18
|
+
/** Options for `client.registerTools()`. */
|
|
19
|
+
export interface RegisterToolsOptions {
|
|
20
|
+
/**
|
|
21
|
+
* Conversations that can call these tools. Pass a `conv-xxx` ID for a
|
|
22
|
+
* specific conversation or an `agent-xxx` ID for the agent's default
|
|
23
|
+
* conversation, as with `resumeSession()`.
|
|
24
|
+
*/
|
|
25
|
+
conversations: string[];
|
|
26
|
+
/** Client tools, executed in this process like a session's `tools`. */
|
|
27
|
+
tools: AnyAgentTool[];
|
|
28
|
+
/**
|
|
29
|
+
* Tools every turn in these conversations may use, including turns the
|
|
30
|
+
* runtime starts itself (task notifications, cron prompts). Same meaning as
|
|
31
|
+
* the session option.
|
|
32
|
+
*/
|
|
33
|
+
allowedTools?: string[];
|
|
34
|
+
/** Base toolset and bundled client tools, as on a session. */
|
|
35
|
+
toolset?: ClientToolsetConfig;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A live registration returned by `client.registerTools()`. */
|
|
39
|
+
export interface ToolRegistration extends AsyncDisposable {
|
|
40
|
+
readonly conversations: readonly string[];
|
|
41
|
+
/** Replace any of the registered options. Omitted fields are kept. */
|
|
42
|
+
update(options: Partial<RegisterToolsOptions>): Promise<void>;
|
|
43
|
+
/** Remove these tools from their conversations. */
|
|
44
|
+
close(): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type Entry = {
|
|
48
|
+
options: RegisterToolsOptions;
|
|
49
|
+
runtimes: RuntimeScope[];
|
|
50
|
+
call: ReturnType<typeof createExternalToolCallHandler>;
|
|
51
|
+
};
|
|
52
|
+
type Connection = { client: AppServerClient; owned: { close(): void } | null; toolPolicy: boolean };
|
|
53
|
+
|
|
54
|
+
const RECONNECT_DELAYS_MS = [250, 1_000, 5_000, 15_000];
|
|
55
|
+
const runtimeKey = (runtime: { agent_id: string | null; conversation_id: string }) =>
|
|
56
|
+
`${runtime.agent_id}:${runtime.conversation_id}`;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Owns one long-lived app-server connection that registers client tools for
|
|
60
|
+
* many conversations. Tool calls are dispatched by the calling conversation,
|
|
61
|
+
* and every registration is published again after a reconnect.
|
|
62
|
+
*/
|
|
63
|
+
export class ToolRegistry {
|
|
64
|
+
private connection: Promise<Connection> | null = null;
|
|
65
|
+
private readonly entries = new Set<Entry>();
|
|
66
|
+
private readonly listeners = new Set<() => void>();
|
|
67
|
+
private reconnectAttempt = 0;
|
|
68
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
69
|
+
private closed = false;
|
|
70
|
+
|
|
71
|
+
constructor(private readonly options: AppServerSessionOptions) {}
|
|
72
|
+
|
|
73
|
+
async register(options: RegisterToolsOptions): Promise<ToolRegistration> {
|
|
74
|
+
if (this.closed) throw new Error("LettaAgentClient is closed");
|
|
75
|
+
const entry: Entry = { options, runtimes: [], call: createExternalToolCallHandler(new Map()) };
|
|
76
|
+
await this.apply(entry, options);
|
|
77
|
+
const registry = this;
|
|
78
|
+
return {
|
|
79
|
+
get conversations() {
|
|
80
|
+
return [...entry.options.conversations];
|
|
81
|
+
},
|
|
82
|
+
update: async (next) => {
|
|
83
|
+
if (!registry.entries.has(entry)) throw new Error("This tool registration is closed.");
|
|
84
|
+
await registry.apply(entry, { ...entry.options, ...next });
|
|
85
|
+
},
|
|
86
|
+
close: () => registry.remove(entry),
|
|
87
|
+
[Symbol.asyncDispose]: () => registry.remove(entry),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
close(): void {
|
|
92
|
+
this.closed = true;
|
|
93
|
+
this.entries.clear();
|
|
94
|
+
this.disconnect();
|
|
95
|
+
this.notify();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The registration source for a session on `id`, or null when no
|
|
100
|
+
* registration covers it. Throws when the session's options conflict with
|
|
101
|
+
* the registration instead of narrowing it.
|
|
102
|
+
*/
|
|
103
|
+
inheritFor(id: string, own: { tools?: AnyAgentTool[]; toolset?: ClientToolsetConfig }): InheritedToolSource | null {
|
|
104
|
+
const covering = this.covering(id);
|
|
105
|
+
if (!covering) return null;
|
|
106
|
+
const registered = new Set(covering.options.tools.map((tool) => tool.name));
|
|
107
|
+
const clash = own.tools?.find((tool) => registered.has(tool.name));
|
|
108
|
+
if (clash) {
|
|
109
|
+
throw new Error(`Tool ${clash.name} is already registered for ${id} with registerTools(); don't pass it to the session again.`);
|
|
110
|
+
}
|
|
111
|
+
if (
|
|
112
|
+
own.toolset !== undefined && covering.options.toolset !== undefined &&
|
|
113
|
+
JSON.stringify(own.toolset) !== JSON.stringify(covering.options.toolset)
|
|
114
|
+
) {
|
|
115
|
+
throw new Error(`${id} already has a toolset from registerTools(); narrow it with the session's allowedTools instead.`);
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
current: () => this.covering(id)?.options ?? null,
|
|
119
|
+
onChange: (listener) => {
|
|
120
|
+
this.listeners.add(listener);
|
|
121
|
+
return () => this.listeners.delete(listener);
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private covering(id: string): Entry | undefined {
|
|
127
|
+
return [...this.entries].find((entry) =>
|
|
128
|
+
entry.options.conversations.includes(id) ||
|
|
129
|
+
entry.runtimes.some((runtime) =>
|
|
130
|
+
runtime.conversation_id === id ||
|
|
131
|
+
(runtime.conversation_id === "default" && runtime.agent_id === id)
|
|
132
|
+
)
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private notify(): void {
|
|
137
|
+
for (const listener of [...this.listeners]) listener();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private async apply(entry: Entry, options: RegisterToolsOptions): Promise<void> {
|
|
141
|
+
if (!Array.isArray(options.conversations) || options.conversations.length === 0) {
|
|
142
|
+
throw new Error("registerTools() requires at least one conversation.");
|
|
143
|
+
}
|
|
144
|
+
if (!Array.isArray(options.tools)) throw new Error("registerTools() requires a tools array.");
|
|
145
|
+
const connection = await this.connect();
|
|
146
|
+
if (!connection.toolPolicy && (options.allowedTools || options.toolset)) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
"registerTools() allowedTools and toolset require a Letta Code app server that supports runtime tool policies. Upgrade @letta-ai/letta-code.",
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
const runtimes = await Promise.all(
|
|
152
|
+
options.conversations.map((id) => resolveRuntime(connection.client, id)),
|
|
153
|
+
);
|
|
154
|
+
const taken = new Set(
|
|
155
|
+
[...this.entries].filter((other) => other !== entry)
|
|
156
|
+
.flatMap((other) => other.runtimes.map(runtimeKey)),
|
|
157
|
+
);
|
|
158
|
+
const overlap = options.conversations.find((_, index) => taken.has(runtimeKey(runtimes[index]!)));
|
|
159
|
+
if (overlap) throw new Error(`Tools are already registered for ${overlap}.`);
|
|
160
|
+
|
|
161
|
+
const keys = new Set(runtimes.map(runtimeKey));
|
|
162
|
+
const dropped = entry.runtimes.filter((runtime) => !keys.has(runtimeKey(runtime)));
|
|
163
|
+
// Claim the conversations before publishing so concurrent calls cannot
|
|
164
|
+
// register the same conversation twice; restore the old state on failure.
|
|
165
|
+
const previous = { ...entry };
|
|
166
|
+
const isNew = !this.entries.has(entry);
|
|
167
|
+
entry.options = options;
|
|
168
|
+
entry.runtimes = runtimes;
|
|
169
|
+
entry.call = createExternalToolCallHandler(new Map(options.tools.map((tool) => [tool.name, tool])));
|
|
170
|
+
this.entries.add(entry);
|
|
171
|
+
try {
|
|
172
|
+
await publish(connection.client, [
|
|
173
|
+
group(runtimes, options, connection.toolPolicy),
|
|
174
|
+
...(dropped.length > 0 ? [{ runtimes: dropped, external_tools: [] }] : []),
|
|
175
|
+
]);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
Object.assign(entry, previous);
|
|
178
|
+
if (isNew) this.entries.delete(entry);
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
this.notify();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private async remove(entry: Entry): Promise<void> {
|
|
185
|
+
if (!this.entries.delete(entry)) return;
|
|
186
|
+
this.notify();
|
|
187
|
+
if (this.entries.size === 0) {
|
|
188
|
+
// Closing the connection removes everything it registered.
|
|
189
|
+
this.disconnect();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const connection = await this.connect();
|
|
193
|
+
await publish(connection.client, [{ runtimes: entry.runtimes, external_tools: [] }]);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private connect(): Promise<Connection> {
|
|
197
|
+
this.connection ??= this.open().catch((error: unknown) => {
|
|
198
|
+
this.connection = null;
|
|
199
|
+
throw error;
|
|
200
|
+
});
|
|
201
|
+
return this.connection;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private async open(): Promise<Connection> {
|
|
205
|
+
const owned = this.options.url ? null : await this.options.connect?.();
|
|
206
|
+
const url = this.options.url ?? owned?.url;
|
|
207
|
+
if (!url) throw new Error("registerTools() requires an app-server url.");
|
|
208
|
+
const client = applyUniqueRequestIds(createAppServerClient({
|
|
209
|
+
url,
|
|
210
|
+
...(this.options.authToken !== undefined ? { authToken: this.options.authToken } : {}),
|
|
211
|
+
...(this.options.WebSocket
|
|
212
|
+
? { WebSocket: this.options.WebSocket as AppServerSocketConstructor }
|
|
213
|
+
: {}),
|
|
214
|
+
...(this.options.requestTimeoutMs !== undefined
|
|
215
|
+
? { requestTimeoutMs: this.options.requestTimeoutMs }
|
|
216
|
+
: {}),
|
|
217
|
+
}));
|
|
218
|
+
client.onExternalToolCall(async (request) => {
|
|
219
|
+
const runtime = request.runtime;
|
|
220
|
+
const entry = runtime &&
|
|
221
|
+
[...this.entries].find((candidate) => candidate.runtimes.some((r) => runtimeKey(r) === runtimeKey(runtime)));
|
|
222
|
+
if (!entry) throw new Error(`No tools registered for conversation ${runtime?.conversation_id}`);
|
|
223
|
+
return entry.call(request);
|
|
224
|
+
});
|
|
225
|
+
try {
|
|
226
|
+
await client.connect();
|
|
227
|
+
} catch (error) {
|
|
228
|
+
client.close();
|
|
229
|
+
owned?.close();
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
client.onDisconnect(() => this.onDisconnect(client));
|
|
233
|
+
return { client, owned: owned ?? null, toolPolicy: await supportsRuntimeToolPolicy(client) };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private disconnect(): void {
|
|
237
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
238
|
+
this.reconnectTimer = null;
|
|
239
|
+
const connection = this.connection;
|
|
240
|
+
this.connection = null;
|
|
241
|
+
void connection?.then(({ client, owned }) => {
|
|
242
|
+
client.close();
|
|
243
|
+
owned?.close();
|
|
244
|
+
}, () => undefined);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private onDisconnect(client: AppServerClient): void {
|
|
248
|
+
void this.connection?.then((current) => {
|
|
249
|
+
if (current.client !== client) return;
|
|
250
|
+
current.owned?.close();
|
|
251
|
+
this.connection = null;
|
|
252
|
+
this.scheduleReconnect();
|
|
253
|
+
}, () => undefined);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private scheduleReconnect(): void {
|
|
257
|
+
if (this.closed || this.entries.size === 0 || this.reconnectTimer) return;
|
|
258
|
+
const delay = RECONNECT_DELAYS_MS[Math.min(this.reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)];
|
|
259
|
+
this.reconnectAttempt += 1;
|
|
260
|
+
this.reconnectTimer = setTimeout(() => {
|
|
261
|
+
this.reconnectTimer = null;
|
|
262
|
+
void this.republish().catch(() => this.scheduleReconnect());
|
|
263
|
+
}, delay);
|
|
264
|
+
(this.reconnectTimer as { unref?: () => void }).unref?.();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private async republish(): Promise<void> {
|
|
268
|
+
const connection = await this.connect();
|
|
269
|
+
await publish(
|
|
270
|
+
connection.client,
|
|
271
|
+
[...this.entries].map((entry) => group(entry.runtimes, entry.options, connection.toolPolicy)),
|
|
272
|
+
);
|
|
273
|
+
this.reconnectAttempt = 0;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Older listeners may reject unknown group keys, so tool_policy is gated. */
|
|
278
|
+
function group(runtimes: RuntimeScope[], options: RegisterToolsOptions, toolPolicy: boolean) {
|
|
279
|
+
return {
|
|
280
|
+
runtimes,
|
|
281
|
+
external_tools: externalToolGroups(options.tools) ?? [],
|
|
282
|
+
...(toolPolicy ? { tool_policy: runtimeToolPolicy(options.allowedTools, options.toolset) } : {}),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function publish(
|
|
287
|
+
client: AppServerClient,
|
|
288
|
+
updates: Array<{ runtimes: RuntimeScope[]; external_tools: unknown[]; tool_policy?: unknown }>,
|
|
289
|
+
): Promise<void> {
|
|
290
|
+
const response = await client.runtimeExternalToolsUpdate(
|
|
291
|
+
{ updates } as unknown as Parameters<AppServerClient["runtimeExternalToolsUpdate"]>[0],
|
|
292
|
+
);
|
|
293
|
+
if (!response.success) throw new Error(response.error ?? "Failed to register tools");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function resolveRuntime(client: AppServerClient, id: string): Promise<RuntimeScope> {
|
|
297
|
+
if (!id.startsWith("conv-") && !id.startsWith("local-conv-")) {
|
|
298
|
+
return { agent_id: id, conversation_id: "default" };
|
|
299
|
+
}
|
|
300
|
+
const response = await client.request<ConversationRetrieveResponseMessage>(
|
|
301
|
+
{ type: "conversation_retrieve", request_id: client.nextRequestId("conversation_retrieve"), conversation_id: id },
|
|
302
|
+
{
|
|
303
|
+
predicate: (message): message is ConversationRetrieveResponseMessage =>
|
|
304
|
+
message.type === "conversation_retrieve_response",
|
|
305
|
+
},
|
|
306
|
+
);
|
|
307
|
+
if (!response.success || !response.conversation) {
|
|
308
|
+
throw new Error(response.error ?? `Failed to retrieve conversation ${id}`);
|
|
309
|
+
}
|
|
310
|
+
return { agent_id: response.conversation.agent_id ?? null, conversation_id: id };
|
|
311
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -257,6 +257,20 @@ export interface AgentToolResult<T> {
|
|
|
257
257
|
*/
|
|
258
258
|
export type AgentToolUpdateCallback<T> = (update: Partial<AgentToolResult<T>>) => void;
|
|
259
259
|
|
|
260
|
+
/**
|
|
261
|
+
* Which call a tool is serving. Passed as the last argument to
|
|
262
|
+
* {@link AgentTool.execute}, so one tool instance can serve many
|
|
263
|
+
* conversations and keep per-conversation state keyed on `conversationId`.
|
|
264
|
+
*/
|
|
265
|
+
export interface AgentToolCallContext {
|
|
266
|
+
/** Same as the first `execute` argument. */
|
|
267
|
+
toolCallId: string;
|
|
268
|
+
/** Agent that made the call, or `null` when the runtime has no agent id. */
|
|
269
|
+
agentId: string | null;
|
|
270
|
+
/** Conversation that made the call. */
|
|
271
|
+
conversationId: string;
|
|
272
|
+
}
|
|
273
|
+
|
|
260
274
|
/**
|
|
261
275
|
* Agent tool definition (matches pi-agent-core)
|
|
262
276
|
*/
|
|
@@ -273,12 +287,16 @@ export interface AgentTool<TParams, TResult> {
|
|
|
273
287
|
/** JSON Schema for parameters (TypeBox or plain object) */
|
|
274
288
|
parameters: TParams;
|
|
275
289
|
|
|
276
|
-
/**
|
|
290
|
+
/**
|
|
291
|
+
* Execution function. `context` says which agent and conversation made the
|
|
292
|
+
* call; tools that don't need it can ignore it.
|
|
293
|
+
*/
|
|
277
294
|
execute: (
|
|
278
295
|
toolCallId: string,
|
|
279
296
|
args: unknown,
|
|
280
297
|
signal?: AbortSignal,
|
|
281
298
|
onUpdate?: AgentToolUpdateCallback<TResult>,
|
|
299
|
+
context?: AgentToolCallContext,
|
|
282
300
|
) => Promise<AgentToolResult<TResult>>;
|
|
283
301
|
}
|
|
284
302
|
|
|
@@ -1087,8 +1105,28 @@ export interface SDKResultMessage {
|
|
|
1087
1105
|
conversationId: string | null;
|
|
1088
1106
|
/** Run IDs associated with this turn (if provided by the CLI). */
|
|
1089
1107
|
runIds?: string[];
|
|
1108
|
+
/**
|
|
1109
|
+
* Set only on a turn this session did not send, such as a background task
|
|
1110
|
+
* notification or a cron prompt. `stream()` yields these turns as they run
|
|
1111
|
+
* and keeps going until the result of the session's own turn.
|
|
1112
|
+
*/
|
|
1113
|
+
origin?: SDKTurnOrigin;
|
|
1090
1114
|
}
|
|
1091
1115
|
|
|
1116
|
+
/**
|
|
1117
|
+
* Who started a turn the session did not send: a queue source reported by
|
|
1118
|
+
* the runtime, `"user"` for another client's message, or `"unknown"` when the
|
|
1119
|
+
* runtime started the turn without reporting a source.
|
|
1120
|
+
*/
|
|
1121
|
+
export type SDKTurnOrigin =
|
|
1122
|
+
| "task_notification"
|
|
1123
|
+
| "cron"
|
|
1124
|
+
| "subagent"
|
|
1125
|
+
| "system"
|
|
1126
|
+
| "channel"
|
|
1127
|
+
| "user"
|
|
1128
|
+
| "unknown";
|
|
1129
|
+
|
|
1092
1130
|
export interface SDKStreamEventDeltaPayload {
|
|
1093
1131
|
type: string;
|
|
1094
1132
|
index?: number;
|