@swarm-agent/sdk 0.1.52 → 0.1.53

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/dist/agents.js CHANGED
@@ -1,4 +1,83 @@
1
+ import { SwarmApiError } from './errors.js';
1
2
  import { SwarmSessionsNamespace } from './sessions.js';
3
+ const WAIT_SECONDS = 25;
4
+ // Exactly what daemons released before GET /v3/sessions/{id}/wait answer for
5
+ // that route: 404, or these unknown-route refusals. Anything else is a real error.
6
+ const OLDER_DAEMON_WAIT_REFUSALS = new Set([
7
+ 'unknown sessions v3 path',
8
+ 'route unavailable to an agent-bound token',
9
+ 'route unavailable to an operator token',
10
+ 'route unavailable on container SDK listener',
11
+ ]);
12
+ function daemonHasNoWait(error) {
13
+ if (!(error instanceof SwarmApiError))
14
+ return false;
15
+ if (error.status === 404)
16
+ return error.message !== 'session not found';
17
+ return (error.status === 400 || error.status === 403) && OLDER_DAEMON_WAIT_REFUSALS.has(error.message);
18
+ }
19
+ /** The pending records that are calls to the tools you answer, with parsed arguments. */
20
+ function clientToolCalls(sessionId, records, tools, owner) {
21
+ const names = new Set(tools);
22
+ const calls = [];
23
+ for (const record of records) {
24
+ if (!names.has(record.tool_name) || record.session_id !== sessionId)
25
+ continue;
26
+ let args;
27
+ try {
28
+ args = JSON.parse(record.tool_call_arguments || record.tool_arguments || '{}');
29
+ }
30
+ catch {
31
+ args = null;
32
+ }
33
+ if (!args || typeof args !== 'object' || Array.isArray(args))
34
+ continue;
35
+ calls.push({ kind: 'call', id: record.id, sessionId, runId: record.run_id, tool: record.tool_name, arguments: args, createdAt: record.created_at, ...owner });
36
+ }
37
+ return calls;
38
+ }
39
+ function sleep(ms, signal) {
40
+ return new Promise((resolve) => {
41
+ const finish = () => { clearTimeout(timer); signal?.removeEventListener('abort', finish); resolve(); };
42
+ const timer = setTimeout(finish, ms);
43
+ signal?.addEventListener('abort', finish, { once: true });
44
+ });
45
+ }
46
+ const LIMIT_FIELDS = [
47
+ ['messagesPerMinute', 'messages_per_minute'],
48
+ ['sessionsPerHour', 'sessions_per_hour'],
49
+ ['maxConcurrentRuns', 'max_concurrent_runs'],
50
+ ['endUserMessagesPerMinute', 'end_user_messages_per_minute'],
51
+ ['endUserSessionsPerHour', 'end_user_sessions_per_hour'],
52
+ ['endUserMaxTokensPerDay', 'end_user_max_tokens_per_day'],
53
+ ];
54
+ function limitBody(limits, keepZero) {
55
+ const body = {};
56
+ for (const [key, field] of LIMIT_FIELDS) {
57
+ const value = limits[key];
58
+ if (value === undefined || (!keepZero && !value))
59
+ continue;
60
+ if (!Number.isInteger(value) || value < 0)
61
+ throw new Error(`${key} must be a non-negative integer`);
62
+ body[field] = value;
63
+ }
64
+ return body;
65
+ }
66
+ function endUserFields(data) {
67
+ const out = {};
68
+ if (typeof data?.end_user === 'string')
69
+ out.endUser = data.end_user;
70
+ if (data?.labels && typeof data.labels === 'object' && !Array.isArray(data.labels))
71
+ out.labels = data.labels;
72
+ return out;
73
+ }
74
+ function pendingConfirmation(sessionId, record, owner) {
75
+ return {
76
+ kind: 'confirmation', id: record.id, sessionId, runId: record.run_id, tool: record.tool, summary: record.summary,
77
+ arguments: record.arguments ?? {}, argumentsHash: record.arguments_hash, createdAt: record.created_at, expiresAt: record.expires_at,
78
+ ...owner,
79
+ };
80
+ }
2
81
  /** Throw from a handler to send this message to the model; other errors send "tool failed". */
3
82
  export class ClientToolError extends Error {
4
83
  constructor(message) {
@@ -12,15 +91,31 @@ function toolName(name) {
12
91
  throw new Error('tool and agent names are lowercase letters, digits and underscores');
13
92
  return name;
14
93
  }
94
+ function requestId(prefix) {
95
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
96
+ return `${prefix}-${crypto.randomUUID()}`;
97
+ return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 10)}`;
98
+ }
99
+ function operatorRequestId() {
100
+ return requestId('sdk-operator');
101
+ }
15
102
  /**
16
103
  * Sealed agents and client tools (`/v2/agents`, `/v2/custom-tools`). Defining
17
104
  * them needs the owner or an `admin` token: keep this in a trusted backend.
18
105
  */
19
106
  export class SwarmAgentsNamespace {
20
107
  transport;
108
+ olderDaemonNoted = false;
21
109
  constructor(transport) {
22
110
  this.transport = transport;
23
111
  }
112
+ /** Records, once, that this daemon predates waiting and the SDK polls instead. */
113
+ noteOlderDaemon() {
114
+ if (this.olderDaemonNoted)
115
+ return;
116
+ this.olderDaemonNoted = true;
117
+ console.debug('swarm sdk: this daemon has no GET /v3/sessions/{id}/wait (it predates it); serve and converse poll instead');
118
+ }
24
119
  async defineClientTool(definition) {
25
120
  const name = toolName(definition.name);
26
121
  const res = await this.transport.request(`/v2/custom-tools/${name}`, {
@@ -31,6 +126,8 @@ export class SwarmAgentsNamespace {
31
126
  input_schema: definition.input_schema,
32
127
  effect: definition.effect ?? 'read',
33
128
  ...(definition.timeout_ms !== undefined ? { timeout_ms: definition.timeout_ms } : {}),
129
+ ...(definition.confirm !== undefined ? { confirm: definition.confirm } : {}),
130
+ ...(definition.confirm_timeout_ms !== undefined ? { confirm_timeout_ms: definition.confirm_timeout_ms } : {}),
34
131
  },
35
132
  });
36
133
  return res.data.custom_tool;
@@ -61,6 +158,8 @@ export class SwarmAgentsNamespace {
61
158
  * Mints the token a gateway holds to serve one sealed agent. It can only
62
159
  * create and use that agent's sessions and answer its client tool calls;
63
160
  * it stops working if the agent is given any built-in tool. Owner only.
161
+ * Like every key it works while used and stops after 90 days unused;
162
+ * expiresInSeconds adds an optional fixed end date (at most 365 days).
64
163
  */
65
164
  async createGatewayToken(agentName, options = {}) {
66
165
  const agent = toolName(agentName);
@@ -68,14 +167,112 @@ export class SwarmAgentsNamespace {
68
167
  method: 'POST',
69
168
  body: {
70
169
  name: options.name ?? `${agent} gateway`, agent_name: agent,
71
- ...(options.expiresInSeconds ? { expires_in_seconds: options.expiresInSeconds } : {}),
72
- ...(options.messagesPerMinute ? { messages_per_minute: options.messagesPerMinute } : {}),
73
- ...(options.sessionsPerHour ? { sessions_per_hour: options.sessionsPerHour } : {}),
170
+ expires_in_seconds: options.expiresInSeconds,
171
+ ...limitBody(options, false),
74
172
  },
75
173
  });
76
174
  return { token: res.data.token, record: res.data.record };
77
175
  }
176
+ /**
177
+ * Changes a gateway token's limits in place (owner only); fields you omit
178
+ * keep their value, 0 restores the default. Applies from its next request.
179
+ */
180
+ async updateGatewayTokenLimits(tokenId, limits) {
181
+ if (typeof tokenId !== 'string' || !tokenId)
182
+ throw new Error('tokenId is required');
183
+ const body = limitBody(limits, true);
184
+ if (Object.keys(body).length === 0)
185
+ throw new Error('give at least one limit to change');
186
+ const res = await this.transport.request(`/v3/auth/tokens/${encodeURIComponent(tokenId)}/limits`, { method: 'POST', body });
187
+ return res.data.record;
188
+ }
189
+ /**
190
+ * Creates a conversation with a sealed agent for one end user (gateway
191
+ * token). `endUser` is stored with the session once and cannot change; the
192
+ * model never sees it. Pass the same `clientRequestId` to retry safely.
193
+ */
194
+ async startConversation(agentName, options = {}) {
195
+ const agent = toolName(agentName);
196
+ const session = await new SwarmSessionsNamespace(this.transport).create({
197
+ agent_name: agent,
198
+ ...(options.title !== undefined ? { title: options.title } : {}),
199
+ ...(options.clientRequestId !== undefined ? { client_request_id: options.clientRequestId } : {}),
200
+ ...(options.workspacePath !== undefined ? { workspace_path: options.workspacePath } : {}),
201
+ ...(options.workspaceId !== undefined ? { workspace_id: options.workspaceId } : {}),
202
+ ...(options.endUser !== undefined ? { end_user: options.endUser } : {}),
203
+ ...(options.labels !== undefined ? { labels: options.labels } : {}),
204
+ });
205
+ if (options.endUser !== undefined && session.end_user !== options.endUser) {
206
+ throw new Error('the daemon did not confirm the conversation\'s end user');
207
+ }
208
+ return { sessionId: session.id, ...(session.end_user !== undefined ? { endUser: session.end_user } : {}), ...(session.labels ? { labels: session.labels } : {}) };
209
+ }
210
+ /**
211
+ * One UTC day's model usage of the agent's conversations, by end user (a
212
+ * gateway token reads its own agent; the owner passes `agentName`).
213
+ */
214
+ async endUserUsage(options = {}) {
215
+ const query = new URLSearchParams();
216
+ if (options.agentName)
217
+ query.set('agent_name', toolName(options.agentName));
218
+ if (options.endUser !== undefined)
219
+ query.set('end_user', options.endUser);
220
+ if (options.date)
221
+ query.set('date', options.date);
222
+ if (options.limit !== undefined)
223
+ query.set('limit', String(options.limit));
224
+ const suffix = query.toString() ? `?${query.toString()}` : '';
225
+ const res = await this.transport.request(`/v3/sessions:end-user-usage${suffix}`);
226
+ return {
227
+ date: res.data.date,
228
+ truncated: Boolean(res.data.truncated),
229
+ endUsers: (res.data.end_users ?? []).map((row) => ({ endUser: row.end_user, recorded: row.recorded, usage: row.usage })),
230
+ };
231
+ }
78
232
  /** Today's spend against the daily cap. A gateway token may read it. */
233
+ /**
234
+ * Mints the human-takeover token for one sealed agent: it can read that
235
+ * agent's conversations, take one over (the agent stops replying), reply as a
236
+ * person and hand it back. It cannot post as the visitor, start runs or answer
237
+ * tool calls. Owner only. Like every key it works while used and stops after
238
+ * 90 days unused; expiresInSeconds adds an optional fixed end date.
239
+ */
240
+ async createOperatorToken(agentName, options = {}) {
241
+ const agent = toolName(agentName);
242
+ const res = await this.transport.request('/v3/auth/tokens', {
243
+ method: 'POST',
244
+ body: { name: options.name ?? `${agent} operator`, agent_name: agent, operator: true, expires_in_seconds: options.expiresInSeconds },
245
+ });
246
+ return { token: res.data.token, record: res.data.record };
247
+ }
248
+ /** Who holds a conversation: `{ state: 'human', by, since }`, `{ state: 'agent', ended_at }`, or undefined. */
249
+ async takeoverState(sessionId) {
250
+ const res = await this.transport.request(`/v3/sessions/${encodeURIComponent(sessionId)}/takeover`);
251
+ return res.data.takeover ?? undefined;
252
+ }
253
+ /** A person takes the conversation: the agent's run in flight stops and visitor messages start no runs. */
254
+ async takeOver(sessionId, options = {}) {
255
+ return this.setTakeover(sessionId, 'start', options.clientRequestId);
256
+ }
257
+ /** Hands the conversation back to the agent; the next visitor message runs it again. */
258
+ async handBack(sessionId, options = {}) {
259
+ return this.setTakeover(sessionId, 'end', options.clientRequestId);
260
+ }
261
+ /** Replies as a person during a takeover (an assistant message with source "operator"). */
262
+ async replyAsOperator(sessionId, content, options = {}) {
263
+ const res = await this.transport.request(`/v3/sessions/${encodeURIComponent(sessionId)}/operator-messages`, {
264
+ method: 'POST',
265
+ body: { content, client_request_id: options.clientRequestId ?? operatorRequestId() },
266
+ });
267
+ return res.data.message;
268
+ }
269
+ async setTakeover(sessionId, action, clientRequestId) {
270
+ const res = await this.transport.request(`/v3/sessions/${encodeURIComponent(sessionId)}/takeover`, {
271
+ method: 'POST',
272
+ body: { action, client_request_id: clientRequestId ?? operatorRequestId() },
273
+ });
274
+ return res.data.takeover;
275
+ }
79
276
  async spendToday() {
80
277
  const res = await this.transport.request('/v3/sessions:usage-limits');
81
278
  return res.data.limits;
@@ -86,26 +283,196 @@ export class SwarmAgentsNamespace {
86
283
  async removeClientTool(name) {
87
284
  await this.transport.request(`/v2/custom-tools/${toolName(name)}`, { method: 'DELETE' });
88
285
  }
89
- /** Client tool calls waiting in a session, limited to the tool names you answer. */
286
+ /**
287
+ * Waits on one conversation (`GET /v3/sessions/{id}/wait`). Without `after`
288
+ * it answers at once with the current state and cursor. With `after` it
289
+ * returns as soon as there is a new assistant message, a pending tool call,
290
+ * a confirmation waiting for the person, the end of a run or of the
291
+ * session, or after `waitSeconds` (default 25, max 30). A gateway token may
292
+ * wait only on its agent's sessions.
293
+ */
294
+ async wait(sessionId, options = {}) {
295
+ const q = new URLSearchParams();
296
+ let seconds = 0;
297
+ if (typeof options.after === 'number') {
298
+ q.set('after', String(options.after));
299
+ seconds = Math.max(0, Math.min(30, Math.floor(options.waitSeconds ?? WAIT_SECONDS)));
300
+ q.set('wait_seconds', String(seconds));
301
+ }
302
+ const path = `/v3/sessions/${encodeURIComponent(sessionId)}/wait${q.toString() ? `?${q.toString()}` : ''}`;
303
+ const res = await this.transport.request(path, { signal: options.signal, timeoutMs: (seconds + 15) * 1000 });
304
+ const d = res.data;
305
+ const owner = endUserFields(d);
306
+ return {
307
+ sessionId: d.session_id ?? sessionId, cursor: d.cursor, reasons: d.reasons ?? [], timedOut: !!d.timed_out,
308
+ more: !!d.more, staleCursor: !!d.stale_cursor, ended: !!d.ended, run: d.run ?? undefined, pendingCalls: d.pending_calls ?? [],
309
+ pendingConfirmations: (d.pending_confirmations ?? []).filter((record) => record.status === 'pending').map((record) => pendingConfirmation(sessionId, record, owner)),
310
+ ...owner,
311
+ };
312
+ }
313
+ /**
314
+ * What waits on your application in a session, limited to the tool names you
315
+ * answer: client tool calls to run (`kind: 'call'`) and confirmed calls
316
+ * waiting for the person's approval (`kind: 'confirmation'`). Both carry the
317
+ * session's `endUser` and `labels`.
318
+ */
90
319
  async pendingCalls(sessionId, tools) {
320
+ const names = [...tools];
321
+ let state;
322
+ try {
323
+ state = await this.wait(sessionId);
324
+ }
325
+ catch (error) {
326
+ if (!daemonHasNoWait(error))
327
+ throw error;
328
+ this.noteOlderDaemon();
329
+ return this.polledItems(sessionId, names);
330
+ }
331
+ return this.waitItems(sessionId, state, names);
332
+ }
333
+ /** The calls and confirmations a wait reported, for the tools you answer. */
334
+ waitItems(sessionId, state, tools) {
335
+ const owner = { ...(state.endUser !== undefined ? { endUser: state.endUser } : {}), ...(state.labels ? { labels: state.labels } : {}) };
91
336
  const names = new Set(tools);
337
+ return [...clientToolCalls(sessionId, state.pendingCalls, tools, owner), ...state.pendingConfirmations.filter((confirmation) => names.has(confirmation.tool))];
338
+ }
339
+ /** pendingCalls for daemons that predate waiting (see noteOlderDaemon). */
340
+ async polledItems(sessionId, tools) {
92
341
  const res = await this.transport.request(`/v3/sessions/${encodeURIComponent(sessionId)}/permissions?status=pending&limit=100`);
93
- const calls = [];
94
- for (const record of res.data.permissions ?? []) {
95
- if (!names.has(record.tool_name) || record.session_id !== sessionId)
96
- continue;
97
- let args;
342
+ // The end user comes with the delivery, from the stored session.
343
+ const items = clientToolCalls(sessionId, res.data.permissions ?? [], tools, endUserFields(res.data));
344
+ const names = new Set(tools);
345
+ for (const confirmation of await this.confirmations(sessionId)) {
346
+ if (names.has(confirmation.tool))
347
+ items.push(confirmation);
348
+ }
349
+ return items;
350
+ }
351
+ /**
352
+ * Lists the agent's conversations, most recently updated first
353
+ * (`GET /v3/sessions:agent`). A gateway or operator token lists its own
354
+ * agent; the owner passes `agent`. Page with `nextCursor`.
355
+ */
356
+ async listSessions(params = {}) {
357
+ const q = new URLSearchParams();
358
+ if (params.agent)
359
+ q.set('agent', toolName(params.agent));
360
+ if (params.state)
361
+ q.set('state', params.state);
362
+ if (typeof params.updatedAfter === 'number')
363
+ q.set('updated_after', String(params.updatedAfter));
364
+ if (typeof params.updatedBefore === 'number')
365
+ q.set('updated_before', String(params.updatedBefore));
366
+ if (params.cursor)
367
+ q.set('cursor', params.cursor);
368
+ if (typeof params.limit === 'number')
369
+ q.set('limit', String(params.limit));
370
+ const res = await this.transport.request(`/v3/sessions:agent${q.toString() ? `?${q.toString()}` : ''}`);
371
+ return { sessions: res.data.sessions ?? [], ...(res.data.next_cursor ? { nextCursor: res.data.next_cursor } : {}) };
372
+ }
373
+ /**
374
+ * Ends a conversation: it accepts no more messages, its run in flight and
375
+ * pending tool calls stop. Ending an ended conversation changes nothing.
376
+ */
377
+ async endSession(sessionId, options = {}) {
378
+ const res = await this.transport.request(`/v3/sessions/${encodeURIComponent(sessionId)}/end`, { method: 'POST', body: { client_request_id: options.clientRequestId ?? requestId('sdk-end'), ...(options.reason ? { reason: options.reason } : {}) } });
379
+ return { ended: res.data.ended, alreadyEnded: !!res.data.already_ended, ...(res.data.stopped_run_id ? { stoppedRunId: res.data.stopped_run_id } : {}) };
380
+ }
381
+ /**
382
+ * Hard deletes a conversation and its messages. The daemon refuses (409)
383
+ * while a run or tool call is in flight: end the session first.
384
+ */
385
+ async deleteSession(sessionId) {
386
+ await this.transport.request(`/v3/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' });
387
+ }
388
+ /** Reads the agent's event feed after a cursor (`GET /v3/sessions:agent-events`), oldest first. */
389
+ async events(params = {}) {
390
+ const q = new URLSearchParams();
391
+ if (params.agent)
392
+ q.set('agent', toolName(params.agent));
393
+ if (typeof params.after === 'number')
394
+ q.set('after', String(params.after));
395
+ if (typeof params.limit === 'number')
396
+ q.set('limit', String(params.limit));
397
+ if (params.kinds?.length)
398
+ q.set('kind', params.kinds.join(','));
399
+ const seconds = Math.max(0, Math.min(30, Math.floor(params.waitSeconds ?? 0)));
400
+ if (seconds > 0)
401
+ q.set('wait', String(seconds));
402
+ const res = await this.transport.request(`/v3/sessions:agent-events${q.toString() ? `?${q.toString()}` : ''}`, { signal: params.signal, timeoutMs: (seconds + 15) * 1000 });
403
+ const { events, next_after, latest_seq, oldest_seq, gap } = res.data;
404
+ return { events: events ?? [], next_after, latest_seq, oldest_seq, gap: !!gap };
405
+ }
406
+ /**
407
+ * Follows the agent's event feed until `signal` aborts, waiting on the
408
+ * daemon between entries (no polling). Yields a gap record when entries
409
+ * after the cursor were already dropped by the feed's retention.
410
+ */
411
+ async *watchEvents(params = {}) {
412
+ let after = params.after ?? 0;
413
+ while (!params.signal?.aborted) {
414
+ let page;
98
415
  try {
99
- args = JSON.parse(record.tool_call_arguments || record.tool_arguments || '{}');
416
+ page = await this.events({ ...params, after, waitSeconds: WAIT_SECONDS });
100
417
  }
101
- catch {
102
- args = null;
418
+ catch (error) {
419
+ if (params.signal?.aborted)
420
+ return;
421
+ throw error;
103
422
  }
104
- if (!args || typeof args !== 'object' || Array.isArray(args))
105
- continue;
106
- calls.push({ id: record.id, sessionId, runId: record.run_id, tool: record.tool_name, arguments: args, createdAt: record.created_at });
423
+ if (page.gap)
424
+ yield { kind: 'gap', from: after, to: page.next_after };
425
+ for (const event of page.events)
426
+ yield event;
427
+ after = page.next_after;
107
428
  }
108
- return calls;
429
+ }
430
+ /** Confirmed tool calls in a session waiting for the person's approval (at most one). */
431
+ async confirmations(sessionId) {
432
+ const res = await this.transport.request(`/v3/sessions/${encodeURIComponent(sessionId)}/confirmations`);
433
+ const owner = endUserFields(res.data);
434
+ return (res.data.confirmations ?? []).filter((record) => record.status === 'pending').map((record) => pendingConfirmation(sessionId, record, owner));
435
+ }
436
+ /**
437
+ * Records the person's decision on a confirmation and returns its new state.
438
+ * Approving needs the hash of the arguments the person was shown; the call
439
+ * then waits for your handler like any other. `confirm` and `reject` do this
440
+ * and also wait for the agent's reply.
441
+ */
442
+ async decideConfirmation(sessionId, confirmationId, decision) {
443
+ if (typeof confirmationId !== 'string' || !confirmationId)
444
+ throw new Error('confirmationId is required');
445
+ if (decision.decision === 'approve' && !decision.argumentsHash)
446
+ throw new Error('approving needs the argumentsHash the person was shown');
447
+ const res = await this.transport.request(`/v3/sessions/${encodeURIComponent(sessionId)}/confirmations/${encodeURIComponent(confirmationId)}`, {
448
+ method: 'POST',
449
+ body: {
450
+ decision: decision.decision,
451
+ ...(decision.argumentsHash ? { arguments_hash: decision.argumentsHash } : {}),
452
+ ...(decision.note ? { note: decision.note } : {}),
453
+ },
454
+ });
455
+ return res.data.confirmation;
456
+ }
457
+ /**
458
+ * The person approved: runs the confirmed call through `handlers` (with
459
+ * exactly the confirmation's arguments) and returns the agent's reply. With
460
+ * `endUser`, it refuses a session that belongs to anyone else before
461
+ * recording the approval.
462
+ */
463
+ async confirm(sessionId, confirmation, handlers, options = {}) {
464
+ return this.followTurn(sessionId, handlers, options, async () => {
465
+ await this.decideConfirmation(sessionId, confirmation.id, { decision: 'approve', argumentsHash: confirmation.argumentsHash });
466
+ });
467
+ }
468
+ /**
469
+ * The person declined: the model is told "the person declined: <note>".
470
+ * Returns the agent's reply. `endUser` works as on `confirm`.
471
+ */
472
+ async reject(sessionId, confirmation, handlers, options = {}) {
473
+ return this.followTurn(sessionId, handlers, options, async () => {
474
+ await this.decideConfirmation(sessionId, confirmation.id, { decision: 'reject', note: options.note });
475
+ });
109
476
  }
110
477
  /** Returns `result` (any JSON value) to the model as the tool's output. */
111
478
  async answer(sessionId, callId, result) {
@@ -117,57 +484,182 @@ export class SwarmAgentsNamespace {
117
484
  }
118
485
  /**
119
486
  * Answers one session's client tool calls with `handlers` until `signal`
120
- * aborts. Each call is answered at most once; a handler error fails the
121
- * call with "tool failed" unless it is a ClientToolError.
487
+ * aborts or the conversation has ended, waiting on the daemon between steps
488
+ * (`intervalMs` applies only to daemons that predate waiting). Each call is
489
+ * answered at most once; a handler error fails the call with "tool failed"
490
+ * unless it is a ClientToolError. With `endUser`, a call whose session
491
+ * belongs to anyone else is failed unanswered, and such a confirmation is
492
+ * not passed to `onConfirm`. Confirmations are never answered here:
493
+ * `onConfirm` is called once for each (show the approval card), and
494
+ * `confirm` or `reject` records the person's decision.
122
495
  */
123
496
  async serve(sessionId, handlers, options = {}) {
124
497
  const handled = new Set();
125
- const interval = Math.max(50, options.intervalMs ?? 250);
498
+ const tools = Object.keys(handlers);
499
+ let cursor;
126
500
  while (!options.signal?.aborted) {
127
- for (const call of await this.pendingCalls(sessionId, Object.keys(handlers))) {
128
- if (handled.has(call.id))
129
- continue;
130
- handled.add(call.id);
131
- try {
132
- await this.answer(sessionId, call.id, await handlers[call.tool](call.arguments, call));
133
- }
134
- catch (error) {
135
- await this.fail(sessionId, call.id, error instanceof ClientToolError ? error.message : 'tool failed').catch(() => undefined);
501
+ let state;
502
+ try {
503
+ state = await this.wait(sessionId, cursor === undefined ? { signal: options.signal } : { after: cursor, signal: options.signal });
504
+ }
505
+ catch (error) {
506
+ if (options.signal?.aborted)
507
+ return;
508
+ if (cursor === undefined && daemonHasNoWait(error)) {
509
+ this.noteOlderDaemon();
510
+ return this.servePolling(sessionId, handlers, handled, options);
136
511
  }
512
+ throw error;
513
+ }
514
+ cursor = state.cursor;
515
+ await this.handleItems(sessionId, this.waitItems(sessionId, state, tools), handlers, handled, options);
516
+ // An ended conversation starts no more runs, so no more calls come.
517
+ if (state.ended && !state.run?.active)
518
+ return;
519
+ }
520
+ }
521
+ /** Answers each new call once and shows each new confirmation once. */
522
+ async handleItems(sessionId, items, handlers, handled, options) {
523
+ for (const item of items) {
524
+ if (handled.has(item.id))
525
+ continue;
526
+ handled.add(item.id);
527
+ if (item.kind === 'confirmation') {
528
+ if (options.endUser === undefined || item.endUser === options.endUser)
529
+ await options.onConfirm?.(item);
530
+ continue;
531
+ }
532
+ if (options.endUser !== undefined && item.endUser !== options.endUser) {
533
+ await this.fail(sessionId, item.id, 'tool failed').catch(() => undefined);
534
+ continue;
137
535
  }
138
- await new Promise((resolve) => {
139
- const timer = setTimeout(resolve, interval);
140
- options.signal?.addEventListener('abort', () => { clearTimeout(timer); resolve(); }, { once: true });
141
- });
536
+ try {
537
+ await this.answer(sessionId, item.id, await handlers[item.tool](item.arguments, item));
538
+ }
539
+ catch (error) {
540
+ await this.fail(sessionId, item.id, error instanceof ClientToolError ? error.message : 'tool failed').catch(() => undefined);
541
+ }
542
+ }
543
+ }
544
+ /** serve for daemons that predate waiting (see noteOlderDaemon). */
545
+ async servePolling(sessionId, handlers, handled, options) {
546
+ const interval = Math.max(50, options.intervalMs ?? 250);
547
+ const tools = Object.keys(handlers);
548
+ while (!options.signal?.aborted) {
549
+ await this.handleItems(sessionId, await this.polledItems(sessionId, tools), handlers, handled, options);
550
+ await sleep(interval, options.signal);
142
551
  }
143
552
  }
144
553
  /**
145
554
  * Sends one user message to a sealed agent's session, answers its client
146
555
  * tool calls with `handlers` while it runs, and returns once the run has
147
556
  * finished. This is the whole loop a gateway needs per visitor message.
557
+ * It waits on the daemon between steps; `pollIntervalMs` applies only to
558
+ * daemons that predate waiting. With `endUser`, it refuses a session that
559
+ * belongs to anyone else before sending, and answers only that end user's
560
+ * calls. When the agent asks the person to approve a confirmed tool call,
561
+ * the result carries `confirmation`: pass it to `confirm` or `reject`.
148
562
  */
149
563
  async converse(sessionId, message, handlers, options = {}) {
150
564
  const sessions = new SwarmSessionsNamespace(this.transport);
151
- const before = new Set(((await sessions.get(sessionId)).messages ?? []).map((m) => m.id));
565
+ return this.followTurn(sessionId, handlers, options, async () => {
566
+ await sessions.sendMessage(sessionId, { content: message });
567
+ });
568
+ }
569
+ /**
570
+ * Starts a turn with `start`, answers client tool calls while it runs, and
571
+ * returns once the agent has replied or has asked for a confirmation. With
572
+ * `endUser`, it refuses a session that belongs to anyone else before
573
+ * `start` runs.
574
+ */
575
+ async followTurn(sessionId, handlers, options, start) {
576
+ const sessions = new SwarmSessionsNamespace(this.transport);
577
+ const initial = await sessions.get(sessionId);
578
+ if (options.endUser !== undefined && initial.end_user !== options.endUser) {
579
+ throw new Error('This conversation belongs to another end user');
580
+ }
581
+ let begin;
582
+ try {
583
+ begin = await this.wait(sessionId);
584
+ }
585
+ catch (error) {
586
+ if (!daemonHasNoWait(error))
587
+ throw error;
588
+ this.noteOlderDaemon();
589
+ return this.followTurnPolling(sessionId, initial, handlers, options, start);
590
+ }
591
+ if (begin.ended)
592
+ throw new Error('The conversation has ended; start a new session');
593
+ const before = new Set((initial.messages ?? []).map((m) => m.id));
594
+ const earlier = (initial.raw ?? {}).pending_confirmation?.id;
595
+ const serving = { onConfirm: options.onConfirm, ...(options.endUser !== undefined ? { endUser: options.endUser } : {}) };
596
+ const tools = Object.keys(handlers);
597
+ const deadline = Date.now() + (options.timeoutMs ?? 120_000);
598
+ const handled = new Set();
599
+ await start();
600
+ let cursor = begin.cursor;
601
+ for (;;) {
602
+ const remaining = deadline - Date.now();
603
+ if (remaining <= 0)
604
+ throw new Error('The agent did not finish in time; the run may still complete');
605
+ const state = await this.wait(sessionId, { after: cursor, waitSeconds: Math.min(WAIT_SECONDS, Math.ceil(remaining / 1000)) });
606
+ cursor = state.cursor;
607
+ const items = this.waitItems(sessionId, state, tools);
608
+ await this.handleItems(sessionId, items, handlers, handled, serving);
609
+ const calls = items.filter((item) => item.kind === 'call');
610
+ if (calls.length > 0 || state.run?.active || state.more)
611
+ continue;
612
+ const asked = items.find((item) => item.kind === 'confirmation' && item.id !== earlier);
613
+ if (state.reasons.length === 0 && !state.ended && !asked)
614
+ continue;
615
+ const detail = await sessions.get(sessionId);
616
+ const added = (detail.messages ?? []).filter((m) => !before.has(m.id));
617
+ const failure = added.find((m) => m.role === 'system' && m.metadata?.message_kind === 'run_failure');
618
+ if (failure)
619
+ throw new Error(`The agent's run failed: ${String(failure.content ?? '').slice(0, 300)}`);
620
+ const reply = added.filter((m) => m.role === 'assistant').map((m) => String(m.content ?? '')).join('\n\n');
621
+ if (asked)
622
+ return { reply, messages: added, confirmation: asked };
623
+ if (added.some((m) => m.role === 'assistant'))
624
+ return { reply, messages: added };
625
+ if (state.ended)
626
+ throw new Error('The conversation was ended before the agent replied');
627
+ }
628
+ }
629
+ /** followTurn for daemons that predate waiting (see noteOlderDaemon). */
630
+ async followTurnPolling(sessionId, initial, handlers, options, start) {
631
+ const sessions = new SwarmSessionsNamespace(this.transport);
632
+ const before = new Set((initial.messages ?? []).map((m) => m.id));
633
+ const earlier = (initial.raw ?? {}).pending_confirmation?.id;
152
634
  const controller = new AbortController();
153
- const serving = this.serve(sessionId, handlers, { signal: controller.signal, intervalMs: options.pollIntervalMs ?? 200 });
635
+ const serving = this.servePolling(sessionId, handlers, new Set(), {
636
+ signal: controller.signal,
637
+ intervalMs: options.pollIntervalMs ?? 200,
638
+ onConfirm: options.onConfirm,
639
+ ...(options.endUser !== undefined ? { endUser: options.endUser } : {}),
640
+ });
154
641
  const deadline = Date.now() + (options.timeoutMs ?? 120_000);
155
642
  try {
156
- await sessions.sendMessage(sessionId, { content: message });
643
+ await start();
157
644
  for (;;) {
158
645
  const detail = await sessions.get(sessionId);
159
646
  const raw = (detail.raw ?? {});
160
647
  const added = (detail.messages ?? []).filter((m) => !before.has(m.id));
161
648
  const pending = Array.isArray(raw.pending_permissions) ? raw.pending_permissions.length : 0;
162
649
  const failure = added.find((m) => m.role === 'system' && m.metadata?.message_kind === 'run_failure');
650
+ const reply = added.filter((m) => m.role === 'assistant').map((m) => String(m.content ?? '')).join('\n\n');
163
651
  if (!raw.active_run_intent && failure)
164
652
  throw new Error(`The agent's run failed: ${String(failure.content ?? '').slice(0, 300)}`);
653
+ const asked = raw.pending_confirmation;
654
+ if (!raw.active_run_intent && asked?.id && asked.id !== earlier) {
655
+ return { reply, messages: added, confirmation: pendingConfirmation(sessionId, asked, endUserFields(raw)) };
656
+ }
165
657
  if (!raw.active_run_intent && pending === 0 && added.some((m) => m.role === 'assistant')) {
166
- return { reply: added.filter((m) => m.role === 'assistant').map((m) => String(m.content ?? '')).join('\n\n'), messages: added };
658
+ return { reply, messages: added };
167
659
  }
168
660
  if (Date.now() > deadline)
169
661
  throw new Error('The agent did not finish in time; the run may still complete');
170
- await new Promise((resolve) => setTimeout(resolve, options.pollIntervalMs ?? 400));
662
+ await sleep(options.pollIntervalMs ?? 400);
171
663
  }
172
664
  }
173
665
  finally {