@letta-ai/letta-agent-sdk 0.8.72 → 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.
Files changed (47) hide show
  1. package/README.md +69 -0
  2. package/dist/app-server-session.d.ts +28 -7
  3. package/dist/app-server-session.d.ts.map +1 -1
  4. package/dist/client-base.d.ts +17 -0
  5. package/dist/client-base.d.ts.map +1 -1
  6. package/dist/client-entry.d.ts +1 -0
  7. package/dist/client-entry.d.ts.map +1 -1
  8. package/dist/client-entry.js +480 -77
  9. package/dist/client-entry.js.map +15 -12
  10. package/dist/client.d.ts +9 -0
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/cloud-session.d.ts.map +1 -1
  13. package/dist/cloud-status-transport.d.ts.map +1 -1
  14. package/dist/external-tools.d.ts +11 -0
  15. package/dist/external-tools.d.ts.map +1 -0
  16. package/dist/index.d.ts +2 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +509 -103
  19. package/dist/index.js.map +17 -14
  20. package/dist/remote-client-session-core.d.ts.map +1 -1
  21. package/dist/remote-session-protocol.d.ts +3 -1
  22. package/dist/remote-session-protocol.d.ts.map +1 -1
  23. package/dist/remote-turn-coordinator.d.ts +14 -0
  24. package/dist/remote-turn-coordinator.d.ts.map +1 -1
  25. package/dist/runtime-tool-policy.d.ts +57 -0
  26. package/dist/runtime-tool-policy.d.ts.map +1 -0
  27. package/dist/structured-output-session.d.ts.map +1 -1
  28. package/dist/tool-registration.d.ts +67 -0
  29. package/dist/tool-registration.d.ts.map +1 -0
  30. package/dist/types.d.ts +30 -2
  31. package/dist/types.d.ts.map +1 -1
  32. package/package.json +3 -3
  33. package/src/app-server-session.ts +104 -60
  34. package/src/client-base.ts +57 -14
  35. package/src/client-entry.ts +1 -0
  36. package/src/client.ts +6 -2
  37. package/src/cloud-session.ts +12 -3
  38. package/src/cloud-status-transport.ts +4 -0
  39. package/src/external-tools.ts +69 -0
  40. package/src/index.ts +3 -0
  41. package/src/remote-client-session-core.ts +4 -4
  42. package/src/remote-session-protocol.ts +3 -0
  43. package/src/remote-turn-coordinator.ts +80 -15
  44. package/src/runtime-tool-policy.ts +114 -0
  45. package/src/structured-output-session.ts +2 -1
  46. package/src/tool-registration.ts +311 -0
  47. 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 || this.pendingTurns.length > 0;
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: TurnTracker = {
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
- if (this.activeTurn) {
124
- this.pendingTurns.push(turn);
125
- } else {
126
- this.activateTurn(turn);
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
- if (msg.type !== "result") {
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
- /** Execution function */
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;