@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/README.md +17 -5
- package/dist/activity.d.ts +33 -0
- package/dist/activity.js +83 -0
- package/dist/agents.d.ts +432 -17
- package/dist/agents.js +529 -37
- package/dist/auth.d.ts +18 -3
- package/dist/auth.js +30 -2
- package/dist/client.d.ts +11 -0
- package/dist/client.js +14 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/job-automations.d.ts +343 -0
- package/dist/job-automations.js +74 -0
- package/dist/limits.d.ts +16 -0
- package/dist/limits.js +12 -0
- package/dist/projects.d.ts +11 -1
- package/dist/projects.js +30 -0
- package/dist/sessions.js +9 -1
- package/dist/types.d.ts +158 -0
- package/dist/workers.js +2 -0
- package/package.json +3 -3
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
|
-
|
|
72
|
-
...(options
|
|
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
|
-
/**
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
416
|
+
page = await this.events({ ...params, after, waitSeconds: WAIT_SECONDS });
|
|
100
417
|
}
|
|
101
|
-
catch {
|
|
102
|
-
|
|
418
|
+
catch (error) {
|
|
419
|
+
if (params.signal?.aborted)
|
|
420
|
+
return;
|
|
421
|
+
throw error;
|
|
103
422
|
}
|
|
104
|
-
if (
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
|
121
|
-
*
|
|
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
|
|
498
|
+
const tools = Object.keys(handlers);
|
|
499
|
+
let cursor;
|
|
126
500
|
while (!options.signal?.aborted) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
662
|
+
await sleep(options.pollIntervalMs ?? 400);
|
|
171
663
|
}
|
|
172
664
|
}
|
|
173
665
|
finally {
|