@ziggs-ai/agent-sdk 0.32.0 → 0.33.1
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/CHANGELOG.md +17 -0
- package/dist/Agent.d.ts +1 -0
- package/dist/Agent.js +85 -19
- package/dist/mcp/createMcpAgent.js +11 -26
- package/dist/platform/ConnectionPool.d.ts +2 -0
- package/dist/platform/ConnectionPool.js +26 -1
- package/dist/platform/inboxLoop.d.ts +2 -1
- package/dist/platform/inboxLoop.js +31 -13
- package/dist/platform/tools/tier1/protocolRunner.js +16 -60
- package/dist/platform/tools/tier2/connectionTools.js +2 -1
- package/dist/runtime/AgentMachine.d.ts +10 -0
- package/dist/runtime/AgentMachine.js +31 -38
- package/dist/runtime/validateWorkflow.js +19 -11
- package/dist/shared/backendUrl.d.ts +11 -0
- package/dist/shared/backendUrl.js +17 -0
- package/dist/tools/capabilityAdapter.js +2 -3
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,23 @@
|
|
|
12
12
|
|
|
13
13
|
## Unreleased
|
|
14
14
|
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Hosted Claude agents can discover and invoke the access explanation tool named by their recovery instructions.
|
|
18
|
+
- Shutdown stops new inbox work, acknowledges the current pass, and then releases its lease. Bounded draining includes inbox shutdown, while idle long-polls are cancelled promptly.
|
|
19
|
+
|
|
20
|
+
- `agreement_subcontract` sends the terms the model stated. With no end date
|
|
21
|
+
and no task count the server writes a standing sub-agreement, as it does
|
|
22
|
+
over MCP; the SDK used to turn that into one count-bound task, and to reuse
|
|
23
|
+
an open sub-agreement for the same worker instead of creating one.
|
|
24
|
+
- The backend address is read one way across the SDK: explicit config, then
|
|
25
|
+
`ZIGGS_BACKEND_URL`, then `BACKEND_URL`, then what `configureApiClient` set.
|
|
26
|
+
`createMcpAgent`'s per-user connect request no longer falls back to
|
|
27
|
+
`http://localhost:3000` when only `configureApiClient` is set.
|
|
28
|
+
- A tool (mechanical) state whose tool throws, with no transition for the
|
|
29
|
+
error, now returns the agent to its initial state, as a thinking state
|
|
30
|
+
already did. `validateWorkflow` warns a tool state with no error transition.
|
|
31
|
+
|
|
15
32
|
### Added
|
|
16
33
|
|
|
17
34
|
- The Claude brain asks the backend's wake gate before spending a turn on chat
|
package/dist/Agent.d.ts
CHANGED
|
@@ -256,6 +256,7 @@ export declare class Agent {
|
|
|
256
256
|
private stateTimers;
|
|
257
257
|
/** The per-agent inbox long-poll loop — this agent's sole wake source. */
|
|
258
258
|
private inboxLoop;
|
|
259
|
+
private inboxStopping;
|
|
259
260
|
private inboxLoopEnabled;
|
|
260
261
|
private started;
|
|
261
262
|
private pollRunner;
|
package/dist/Agent.js
CHANGED
|
@@ -12,7 +12,7 @@ import { applyBrainToAgentOptions, isClaudeCognition, } from './brain/index.js';
|
|
|
12
12
|
import { CLAUDE_SDK_TOOLS } from './brain/claudeSdkTools.js';
|
|
13
13
|
import { resolveClaudeModel } from './brain/claudeCognition.js';
|
|
14
14
|
import { CHAT_TOOLS } from './platform/tools/tier2/chatTools.js';
|
|
15
|
-
import { contextReadTool, openTool } from './platform/tools/tier2/contextTools.js';
|
|
15
|
+
import { contextReadTool, openTool, accessExplainTool } from './platform/tools/tier2/contextTools.js';
|
|
16
16
|
import { hostedModelTools } from './brain/hostedToolSurface.js';
|
|
17
17
|
import { EventQueue } from './platform/EventQueue.js';
|
|
18
18
|
import { StateTimers } from './platform/StateTimers.js';
|
|
@@ -99,12 +99,13 @@ function createToolManager(options) {
|
|
|
99
99
|
// model reaches it through tool_write; an agent that can be handed a task
|
|
100
100
|
// with a check-back rule but cannot arm a wake would have to busy-wait, and
|
|
101
101
|
// there is nothing to busy-wait with.
|
|
102
|
-
// open / context_read are the hosted twins of the MCP everyday set.
|
|
102
|
+
// open / context_read are the hosted twins of the MCP everyday set.
|
|
103
|
+
// access_explain must also be available for protocol and artifact recovery calls. Skip
|
|
103
104
|
// them when the agent already brought the same tools, so the shadow warning
|
|
104
105
|
// does not fire on a duplicate registration.
|
|
105
106
|
const ownNames = new Set(ownTools.map((t) => toolName(t)));
|
|
106
107
|
const everydayContext = isClaudeCognition(options.cognition)
|
|
107
|
-
? [contextReadTool, openTool].filter((t) => !ownNames.has(toolName(t)))
|
|
108
|
+
? [contextReadTool, openTool, accessExplainTool].filter((t) => !ownNames.has(toolName(t)))
|
|
108
109
|
: [];
|
|
109
110
|
const platformTools = [...taskTools, ...CHAT_TOOLS, ...browseTools, ...claudeSdkTools, ...everydayContext, ...WAKE_TOOLS];
|
|
110
111
|
warnOnShadowedPlatformTools(platformTools, ownTools, options.agentId);
|
|
@@ -218,6 +219,7 @@ export class Agent {
|
|
|
218
219
|
stateTimers;
|
|
219
220
|
/** The per-agent inbox long-poll loop — this agent's sole wake source. */
|
|
220
221
|
inboxLoop = null;
|
|
222
|
+
inboxStopping = null;
|
|
221
223
|
inboxLoopEnabled;
|
|
222
224
|
started = false;
|
|
223
225
|
pollRunner;
|
|
@@ -463,8 +465,10 @@ export class Agent {
|
|
|
463
465
|
task = await this.taskService.getTask(taskId);
|
|
464
466
|
}
|
|
465
467
|
catch (error) {
|
|
468
|
+
// Unreadable is not a hold. The wake filter still drops a frame whose
|
|
469
|
+
// own task cannot be read; a tool that names one gets an error instead.
|
|
466
470
|
if ([403, 404].includes(error.status ?? 0))
|
|
467
|
-
return { blocked: true };
|
|
471
|
+
return { blocked: true, unread: true };
|
|
468
472
|
throw error;
|
|
469
473
|
}
|
|
470
474
|
// A coordinator sees its children's task events too; those do not pause it.
|
|
@@ -1118,7 +1122,7 @@ export class Agent {
|
|
|
1118
1122
|
release();
|
|
1119
1123
|
taskRuns.clear();
|
|
1120
1124
|
};
|
|
1121
|
-
const checkpoint = async (
|
|
1125
|
+
const checkpoint = async (namedTaskId) => {
|
|
1122
1126
|
if (tickAbort.signal.aborted) {
|
|
1123
1127
|
if (tickAbort.signal.reason?.code === 'TASK_CONTROL')
|
|
1124
1128
|
heldTask = tickAbort.signal.reason;
|
|
@@ -1126,27 +1130,59 @@ export class Agent {
|
|
|
1126
1130
|
}
|
|
1127
1131
|
if (heldTask)
|
|
1128
1132
|
throw new Error('Task execution held');
|
|
1129
|
-
const
|
|
1133
|
+
const wakeId = marks.scope.taskId ?? wakeTaskId;
|
|
1134
|
+
const taskId = namedTaskId ?? wakeId;
|
|
1130
1135
|
if (!taskId)
|
|
1131
1136
|
return;
|
|
1132
|
-
|
|
1137
|
+
// Only this wake's own task shares the tick abort. A tool that names
|
|
1138
|
+
// some other task must not be able to cancel the rest of the wake.
|
|
1139
|
+
const ownsWake = namedTaskId == null || namedTaskId === wakeId;
|
|
1140
|
+
if (ownsWake)
|
|
1141
|
+
registerTaskRun(taskId);
|
|
1133
1142
|
const execution = await readControl(taskId);
|
|
1134
1143
|
if (heldTask)
|
|
1135
1144
|
throw new Error('Task execution held');
|
|
1136
|
-
if (execution?.blocked)
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1145
|
+
if (!execution?.blocked)
|
|
1146
|
+
return;
|
|
1147
|
+
if (!ownsWake || execution.unread) {
|
|
1148
|
+
const error = execution.unread
|
|
1149
|
+
? `Task ${taskId} cannot be read. This call did not run.`
|
|
1150
|
+
: execution.closed
|
|
1151
|
+
? `Task ${taskId} is cancelled. This call did not run.`
|
|
1152
|
+
: `Task ${taskId} is paused. This call did not run.`;
|
|
1153
|
+
return { error };
|
|
1154
|
+
}
|
|
1155
|
+
heldTask = { taskId, control: execution.control };
|
|
1156
|
+
taskRuns.get(taskId).run.control = execution.control;
|
|
1157
|
+
tickAbort.abort(Object.assign(new Error('Task execution held'), { code: 'TASK_CONTROL', taskId, control: execution.control }));
|
|
1158
|
+
throw new Error('Task execution held');
|
|
1159
|
+
};
|
|
1160
|
+
const taskIdNamedBy = (effect) => {
|
|
1161
|
+
if (effect.kind !== 'tool-call' || !effect.args || typeof effect.args !== 'object' || Array.isArray(effect.args))
|
|
1162
|
+
return undefined;
|
|
1163
|
+
const args = effect.args;
|
|
1164
|
+
if (typeof args.taskId === 'string')
|
|
1165
|
+
return args.taskId;
|
|
1166
|
+
const nested = args.args;
|
|
1167
|
+
if ((effect.name === 'tool_read' || effect.name === 'tool_write')
|
|
1168
|
+
&& nested && typeof nested === 'object' && !Array.isArray(nested)) {
|
|
1169
|
+
const inner = nested.taskId;
|
|
1170
|
+
if (typeof inner === 'string')
|
|
1171
|
+
return inner;
|
|
1141
1172
|
}
|
|
1173
|
+
return undefined;
|
|
1142
1174
|
};
|
|
1143
1175
|
const eff = async (effect) => {
|
|
1144
1176
|
// Record-only telemetry can finish after interruption. Every action and
|
|
1145
1177
|
// model call checks the current task before starting another operation.
|
|
1146
|
-
if (effect.kind
|
|
1147
|
-
const
|
|
1148
|
-
|
|
1149
|
-
|
|
1178
|
+
if (effect.kind === 'tool-call') {
|
|
1179
|
+
const refusal = await checkpoint(taskIdNamedBy(effect));
|
|
1180
|
+
// The handler is generic over every effect. This branch is only a tool call.
|
|
1181
|
+
if (refusal)
|
|
1182
|
+
return { ok: false, error: refusal.error };
|
|
1183
|
+
}
|
|
1184
|
+
else if (effect.kind !== 'record-event') {
|
|
1185
|
+
await checkpoint();
|
|
1150
1186
|
}
|
|
1151
1187
|
const agreementId = marks.scope.agreementId;
|
|
1152
1188
|
const sameRoom = 'sessionId' in effect && effect.sessionId === session.sessionId;
|
|
@@ -1410,6 +1446,16 @@ export class Agent {
|
|
|
1410
1446
|
return this.options.agentId || null;
|
|
1411
1447
|
}
|
|
1412
1448
|
async connectAsync(_timeout) {
|
|
1449
|
+
if (this.started)
|
|
1450
|
+
return this;
|
|
1451
|
+
// Reconnecting must not race the previous loop's final lease release.
|
|
1452
|
+
if (this.inboxStopping) {
|
|
1453
|
+
await this.inboxStopping;
|
|
1454
|
+
this.inboxStopping = null;
|
|
1455
|
+
}
|
|
1456
|
+
// Another reconnect may have resumed from the same retirement promise.
|
|
1457
|
+
// Everything below starts synchronously, so rechecking prevents a second
|
|
1458
|
+
// inbox loop from replacing the handle of the one that just started.
|
|
1413
1459
|
if (this.started)
|
|
1414
1460
|
return this;
|
|
1415
1461
|
// Start the per-agent inbox long-poll loop — the agent's sole wake source.
|
|
@@ -1514,16 +1560,36 @@ export class Agent {
|
|
|
1514
1560
|
this.started = false;
|
|
1515
1561
|
this.pollRunner?.stop();
|
|
1516
1562
|
this.stateTimers.clearAll();
|
|
1517
|
-
|
|
1563
|
+
if (this.inboxLoop)
|
|
1564
|
+
this.inboxStopping = this.inboxLoop.stop();
|
|
1518
1565
|
this.inboxLoop = null;
|
|
1519
|
-
|
|
1566
|
+
// Draining is separately bounded by the caller. Waiting here could hang
|
|
1567
|
+
// shutdown on a slow wake before that timeout even starts.
|
|
1568
|
+
return Promise.resolve();
|
|
1520
1569
|
}
|
|
1521
1570
|
/**
|
|
1522
1571
|
* Waits for in-flight ticks to finish. Call before process.exit() on SIGTERM.
|
|
1523
1572
|
* A timeout is returned, not thrown — the caller names it (lab verdict, fleet
|
|
1524
1573
|
* shutdown log) instead of EventQueue printing a stray warning.
|
|
1525
1574
|
*/
|
|
1526
|
-
drain(timeoutMs = 30_000) {
|
|
1575
|
+
async drain(timeoutMs = 30_000) {
|
|
1576
|
+
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
1577
|
+
if (this.inboxStopping) {
|
|
1578
|
+
let timer;
|
|
1579
|
+
const stopped = await Promise.race([
|
|
1580
|
+
this.inboxStopping.then(() => true),
|
|
1581
|
+
new Promise((resolve) => { timer = setTimeout(() => resolve(false), Math.max(0, timeoutMs)); }),
|
|
1582
|
+
]).finally(() => clearTimeout(timer));
|
|
1583
|
+
if (!stopped) {
|
|
1584
|
+
const queue = await this.eventQueue.waitForIdle(0);
|
|
1585
|
+
return { ...queue, idle: false, timedOut: true, timeoutMs,
|
|
1586
|
+
busyLanes: [...queue.busyLanes, { laneKey: 'inbox-shutdown', queued: 0, processing: true }] };
|
|
1587
|
+
}
|
|
1588
|
+
this.inboxStopping = null;
|
|
1589
|
+
}
|
|
1590
|
+
const result = await this.eventQueue.waitForIdle(Math.max(0, deadline - Date.now()));
|
|
1591
|
+
return { ...result, timeoutMs };
|
|
1592
|
+
}
|
|
1527
1593
|
registerTool(tool) { return this.toolManager.register(tool); }
|
|
1528
1594
|
isConnected() { return this.started && (!this.inboxLoopEnabled || (this.inboxLoop?.running ?? false)); }
|
|
1529
1595
|
/**
|
|
@@ -2,7 +2,8 @@ import { resolveWebAppOrigin, connectionsSettingsAppUrl, laneHeader } from '@zig
|
|
|
2
2
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
3
3
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
4
4
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
5
|
-
import { ConnectionsClient
|
|
5
|
+
import { ConnectionsClient } from '@ziggs-ai/api-client';
|
|
6
|
+
import { resolveBackendUrl } from '../shared/backendUrl.js';
|
|
6
7
|
import { createAgent } from '../Agent.js';
|
|
7
8
|
import { laneFromToolContext } from '../tools/toolCreds.js';
|
|
8
9
|
import { thinkingDefaults } from '../runtime/defaults.js';
|
|
@@ -58,15 +59,7 @@ export function resolveZiggsGatewayTarget(config, defaults = {}) {
|
|
|
58
59
|
if (!agentId) {
|
|
59
60
|
throw new Error('ziggs MCP transport requires an agentId to impersonate (X-Agent-Id)');
|
|
60
61
|
}
|
|
61
|
-
|
|
62
|
-
// the resolution every other SDK call uses — not `localhost:3000`. A deployment
|
|
63
|
-
// that sets none of BACKEND_URL / ZIGGS_BACKEND_URL would otherwise have every
|
|
64
|
-
// gateway call silently point at localhost inside its own container: the
|
|
65
|
-
// requests never reach the backend, and the agent reports it as unreachable.
|
|
66
|
-
const baseUrl = (config.baseUrl ||
|
|
67
|
-
process.env.ZIGGS_BACKEND_URL ||
|
|
68
|
-
process.env.BACKEND_URL ||
|
|
69
|
-
getBackendUrl()).replace(/\/$/, '');
|
|
62
|
+
const baseUrl = resolveBackendUrl(config.baseUrl);
|
|
70
63
|
return {
|
|
71
64
|
url: new URL(`${baseUrl}/connections/${encodeURIComponent(config.connectionId)}/mcp`),
|
|
72
65
|
headers: {
|
|
@@ -92,15 +85,7 @@ export function resolvePerUserGatewayTarget(config, agentId, userId, onBehalfOfA
|
|
|
92
85
|
if (!operatorKey) {
|
|
93
86
|
throw new Error('ziggs-per-user MCP transport requires an operatorKey (or ZIGGS_OPERATOR_KEY)');
|
|
94
87
|
}
|
|
95
|
-
|
|
96
|
-
// the resolution every other SDK call uses — not `localhost:3000`. A deployment
|
|
97
|
-
// that sets none of BACKEND_URL / ZIGGS_BACKEND_URL would otherwise have every
|
|
98
|
-
// gateway call silently point at localhost inside its own container: the
|
|
99
|
-
// requests never reach the backend, and the agent reports it as unreachable.
|
|
100
|
-
const baseUrl = (config.baseUrl ||
|
|
101
|
-
process.env.ZIGGS_BACKEND_URL ||
|
|
102
|
-
process.env.BACKEND_URL ||
|
|
103
|
-
getBackendUrl()).replace(/\/$/, '');
|
|
88
|
+
const baseUrl = resolveBackendUrl(config.baseUrl);
|
|
104
89
|
return {
|
|
105
90
|
url: new URL(`${baseUrl}/connections/mcp/relay`),
|
|
106
91
|
headers: {
|
|
@@ -384,10 +369,7 @@ export async function createMcpAgent(options) {
|
|
|
384
369
|
// request targeting THEM (X-On-Behalf-Of-User) so the consent prompt lands
|
|
385
370
|
// in their inbox. The backend dedups pending requests, so retrying every
|
|
386
371
|
// turn is safe.
|
|
387
|
-
const backendBase = (transportConfig.baseUrl
|
|
388
|
-
process.env.BACKEND_URL ||
|
|
389
|
-
process.env.ZIGGS_BACKEND_URL ||
|
|
390
|
-
'http://localhost:3000').replace(/\/$/, '');
|
|
372
|
+
const backendBase = resolveBackendUrl(transportConfig.baseUrl);
|
|
391
373
|
// App URL for the user-facing connect link: api.ziggsai.com → ziggsai.com.
|
|
392
374
|
const appUrl = resolveWebAppOrigin(undefined, backendBase);
|
|
393
375
|
const serverLabel = transportConfig.serverUrl
|
|
@@ -395,14 +377,17 @@ export async function createMcpAgent(options) {
|
|
|
395
377
|
: 'the external service';
|
|
396
378
|
// Open a connection request for this user; returns its id so the caller can
|
|
397
379
|
// deep-link the user straight to it on the Connections page.
|
|
398
|
-
|
|
380
|
+
// The server holds the invoking user to the people this wake may ask, so
|
|
381
|
+
// the request carries the same lane as the call that missed: in the hire's
|
|
382
|
+
// room that user is its customer.
|
|
383
|
+
const triggerConnectionRequest = async (userId, laneId) => {
|
|
399
384
|
if (!transportConfig.serverUrl)
|
|
400
385
|
return null;
|
|
401
386
|
const operatorKey = transportConfig.operatorKey || process.env.ZIGGS_OPERATOR_KEY;
|
|
402
387
|
if (!operatorKey)
|
|
403
388
|
return null;
|
|
404
389
|
try {
|
|
405
|
-
const body = await new ConnectionsClient(operatorKey, agentId, backendBase).requestMcpConnection({
|
|
390
|
+
const body = await new ConnectionsClient(operatorKey, agentId, backendBase, laneId).requestMcpConnection({
|
|
406
391
|
serverUrl: transportConfig.serverUrl,
|
|
407
392
|
tools: tools.map(t => t.name),
|
|
408
393
|
onBehalfOfUserId: userId,
|
|
@@ -460,7 +445,7 @@ export async function createMcpAgent(options) {
|
|
|
460
445
|
if (lastErr instanceof ZiggsGatewayError &&
|
|
461
446
|
lastErr.code === 'NO_USER_CONNECTION' &&
|
|
462
447
|
transportConfig.serverUrl) {
|
|
463
|
-
const requestId = await triggerConnectionRequest(userId).catch(() => null);
|
|
448
|
+
const requestId = await triggerConnectionRequest(userId, laneId).catch(() => null);
|
|
464
449
|
const link = connectionsSettingsAppUrl(appUrl, requestId);
|
|
465
450
|
return {
|
|
466
451
|
content: [
|
|
@@ -17,6 +17,8 @@ export declare class ConnectionPool {
|
|
|
17
17
|
private readonly manager;
|
|
18
18
|
private readonly configs;
|
|
19
19
|
private readonly hosts;
|
|
20
|
+
private readonly retiringHosts;
|
|
21
|
+
private shutdownGeneration;
|
|
20
22
|
private readonly responseListeners;
|
|
21
23
|
/**
|
|
22
24
|
* Register N agent configs. Nothing connects until `connectAll()`. A config
|
|
@@ -23,6 +23,12 @@ export class ConnectionPool {
|
|
|
23
23
|
// open/close callbacks and does not expose them, and re-reading one through
|
|
24
24
|
// `startAgent` after a stop would reconnect the agent we are shutting down.
|
|
25
25
|
hosts = new Map();
|
|
26
|
+
// A stopped host can still be acknowledging its last delivery. Its
|
|
27
|
+
// replacement shares this process's lease identity, so it must wait.
|
|
28
|
+
retiringHosts = new Map();
|
|
29
|
+
// Invalidate replacements already waiting on a retiring host when shutdown
|
|
30
|
+
// starts. They are not in the manager's active set yet, so stopAll misses them.
|
|
31
|
+
shutdownGeneration = 0;
|
|
26
32
|
// Listeners enqueued by requestFrom — drained on outbound send-message effects.
|
|
27
33
|
responseListeners = new Map();
|
|
28
34
|
/**
|
|
@@ -45,6 +51,17 @@ export class ConnectionPool {
|
|
|
45
51
|
}
|
|
46
52
|
this.configs.set(agentId, config);
|
|
47
53
|
this.manager.register(agentId, async () => {
|
|
54
|
+
const generation = this.shutdownGeneration;
|
|
55
|
+
const retiring = this.retiringHosts.get(agentId);
|
|
56
|
+
if (retiring) {
|
|
57
|
+
const result = await retiring.drain();
|
|
58
|
+
if (generation !== this.shutdownGeneration) {
|
|
59
|
+
throw new Error(`Pool shut down while agent "${agentId}" was waiting for its previous host`);
|
|
60
|
+
}
|
|
61
|
+
if (!result.idle)
|
|
62
|
+
throw new Error(`Agent "${agentId}" is still finishing shutdown; retry after its current delivery completes`);
|
|
63
|
+
this.retiringHosts.delete(agentId);
|
|
64
|
+
}
|
|
48
65
|
const cfg = this.configs.get(agentId);
|
|
49
66
|
const originalTap = cfg.effectTap;
|
|
50
67
|
const pool = this;
|
|
@@ -64,6 +81,7 @@ export class ConnectionPool {
|
|
|
64
81
|
runtimeLog.debug('ConnectionPool', `connected "${agentId}"`);
|
|
65
82
|
return host;
|
|
66
83
|
}, async (host) => {
|
|
84
|
+
this.retiringHosts.set(agentId, host);
|
|
67
85
|
await host.disconnect();
|
|
68
86
|
this.hosts.delete(agentId);
|
|
69
87
|
runtimeLog.debug('ConnectionPool', `disconnected "${agentId}"`);
|
|
@@ -116,11 +134,18 @@ export class ConnectionPool {
|
|
|
116
134
|
* holding up the rest — the caller is on a shutdown clock of its own.
|
|
117
135
|
*/
|
|
118
136
|
async shutdown(timeoutMs = 30_000) {
|
|
119
|
-
|
|
137
|
+
this.shutdownGeneration++;
|
|
138
|
+
const hosts = [...new Set([...this.hosts.values(), ...this.retiringHosts.values()])];
|
|
120
139
|
await this.manager.stopAll();
|
|
121
140
|
await Promise.all(hosts.map(async (host) => {
|
|
122
141
|
try {
|
|
123
142
|
const result = await host.drain(timeoutMs);
|
|
143
|
+
if (result?.idle) {
|
|
144
|
+
for (const [id, retiring] of this.retiringHosts) {
|
|
145
|
+
if (retiring === host)
|
|
146
|
+
this.retiringHosts.delete(id);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
124
149
|
if (result?.timedOut) {
|
|
125
150
|
runtimeLog.warn('ConnectionPool', `drain timed out after ${timeoutMs}ms: ${result.queuedEvents} event(s) queued, ${result.busyLanes.length} busy lane(s)`);
|
|
126
151
|
}
|
|
@@ -66,8 +66,9 @@ export declare function chatsInWakeOrder(chats: InboxChatNews[]): InboxChatNews[
|
|
|
66
66
|
* Reused each iteration of {@link runInboxLoop}; also usable as a single catch-up
|
|
67
67
|
* read (`waitSeconds: 0`) at bootstrap.
|
|
68
68
|
*/
|
|
69
|
-
export declare function pollInboxOnce(deps: InboxLoopDeps, waitSeconds?: number,
|
|
69
|
+
export declare function pollInboxOnce(deps: InboxLoopDeps, waitSeconds?: number, opts?: {
|
|
70
70
|
forceAck?: boolean;
|
|
71
|
+
signal?: AbortSignal;
|
|
71
72
|
}): Promise<InboxPassResult>;
|
|
72
73
|
export interface InboxLoopHandle {
|
|
73
74
|
/** Stop the loop after the current pass; resolves when it has fully exited. */
|
|
@@ -130,11 +130,17 @@ async function deliverMessagesForChat(deps, news, refs, reader, attempt, handled
|
|
|
130
130
|
* Reused each iteration of {@link runInboxLoop}; also usable as a single catch-up
|
|
131
131
|
* read (`waitSeconds: 0`) at bootstrap.
|
|
132
132
|
*/
|
|
133
|
-
export async function pollInboxOnce(deps, waitSeconds = 0,
|
|
133
|
+
export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
|
|
134
134
|
const label = deps.label ?? 'inboxLoop';
|
|
135
135
|
const inbox = inboxClient(deps);
|
|
136
136
|
const reader = new ContextReadClient(deps.creds.operatorKey, deps.creds.agentId, deps.baseUrl);
|
|
137
|
-
const envelope = await inbox.getInbox(
|
|
137
|
+
const envelope = await inbox.getInbox({
|
|
138
|
+
...(waitSeconds > 0 ? { waitSeconds } : {}),
|
|
139
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
140
|
+
});
|
|
141
|
+
// A response may race shutdown. Do not enqueue another wake after stopping;
|
|
142
|
+
// an already-running pass keeps its lease through its final acknowledgement.
|
|
143
|
+
opts.signal?.throwIfAborted();
|
|
138
144
|
let messagesDelivered = 0;
|
|
139
145
|
let resourceEvents = 0;
|
|
140
146
|
let tasksDelivered = 0;
|
|
@@ -370,8 +376,19 @@ const ERROR_BACKOFF_MAX_MS = 30_000;
|
|
|
370
376
|
export function heldPassAfter(prev, pass) {
|
|
371
377
|
return !pass.acked && (pass.failures > 0 || pass.truncated) ? prev + 1 : 0;
|
|
372
378
|
}
|
|
373
|
-
function sleep(ms) {
|
|
374
|
-
return new Promise((resolve) =>
|
|
379
|
+
function sleep(ms, signal) {
|
|
380
|
+
return new Promise((resolve) => {
|
|
381
|
+
const finish = () => {
|
|
382
|
+
clearTimeout(timer);
|
|
383
|
+
signal?.removeEventListener('abort', finish);
|
|
384
|
+
resolve();
|
|
385
|
+
};
|
|
386
|
+
const timer = setTimeout(finish, ms);
|
|
387
|
+
if (signal?.aborted)
|
|
388
|
+
finish();
|
|
389
|
+
else
|
|
390
|
+
signal?.addEventListener('abort', finish, { once: true });
|
|
391
|
+
});
|
|
375
392
|
}
|
|
376
393
|
/**
|
|
377
394
|
* Run {@link pollInboxOnce} forever (until stopped). Each iteration long-polls
|
|
@@ -394,17 +411,18 @@ export function runInboxLoop(deps, opts = {}) {
|
|
|
394
411
|
let lastPassAt = null;
|
|
395
412
|
let lastAttemptAt = null;
|
|
396
413
|
let first = true;
|
|
414
|
+
const shutdown = new AbortController();
|
|
397
415
|
const main = (async () => {
|
|
398
416
|
while (running) {
|
|
399
417
|
try {
|
|
400
418
|
lastAttemptAt = Date.now();
|
|
401
|
-
const pass = await pollInboxOnce(deps, first ? 0 : waitSeconds);
|
|
419
|
+
const pass = await pollInboxOnce(deps, first ? 0 : waitSeconds, { signal: shutdown.signal });
|
|
402
420
|
lastPassAt = Date.now();
|
|
403
421
|
first = false;
|
|
404
422
|
consecutiveErrors = 0;
|
|
405
423
|
heldPasses = heldPassAfter(heldPasses, pass);
|
|
406
424
|
if (heldPasses)
|
|
407
|
-
await sleep(Math.min(ERROR_BACKOFF_MAX_MS, ERROR_BACKOFF_BASE_MS * 2 ** Math.min(heldPasses - 1, 5)));
|
|
425
|
+
await sleep(Math.min(ERROR_BACKOFF_MAX_MS, ERROR_BACKOFF_BASE_MS * 2 ** Math.min(heldPasses - 1, 5)), shutdown.signal);
|
|
408
426
|
}
|
|
409
427
|
catch (err) {
|
|
410
428
|
if (!running)
|
|
@@ -419,17 +437,17 @@ export function runInboxLoop(deps, opts = {}) {
|
|
|
419
437
|
if (throttleMs !== null) {
|
|
420
438
|
consecutiveErrors = 0;
|
|
421
439
|
warn(label, `inbox loop throttled: ${err?.message ?? err} — waiting ${Math.round(throttleMs)}ms as instructed`);
|
|
422
|
-
await sleep(throttleMs);
|
|
440
|
+
await sleep(throttleMs, shutdown.signal);
|
|
423
441
|
continue;
|
|
424
442
|
}
|
|
425
443
|
const backoff = Math.min(ERROR_BACKOFF_MAX_MS, ERROR_BACKOFF_BASE_MS * 2 ** (consecutiveErrors - 1));
|
|
426
444
|
const jitter = backoff * (0.5 + (consecutiveErrors % 7) / 14); // deterministic-ish, no Math.random
|
|
427
445
|
warn(label, `inbox loop pass failed (#${consecutiveErrors}): ${err?.message ?? err} — retrying in ~${Math.round(jitter)}ms`);
|
|
428
|
-
await sleep(jitter);
|
|
446
|
+
await sleep(jitter, shutdown.signal);
|
|
429
447
|
}
|
|
430
448
|
}
|
|
431
449
|
})();
|
|
432
|
-
const done = main;
|
|
450
|
+
const done = main.finally(() => releaseInboxHostBestEffort(deps));
|
|
433
451
|
return {
|
|
434
452
|
get running() {
|
|
435
453
|
return running;
|
|
@@ -442,10 +460,10 @@ export function runInboxLoop(deps, opts = {}) {
|
|
|
442
460
|
},
|
|
443
461
|
async stop() {
|
|
444
462
|
running = false;
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
|
|
448
|
-
|
|
463
|
+
// Cancel an idle long-poll, but let work already handed to the host
|
|
464
|
+
// finish and acknowledge before releasing its lease.
|
|
465
|
+
shutdown.abort();
|
|
466
|
+
await done;
|
|
449
467
|
},
|
|
450
468
|
};
|
|
451
469
|
}
|
|
@@ -57,7 +57,7 @@ cachedAgreements) {
|
|
|
57
57
|
case 'agreement-propose':
|
|
58
58
|
return proposeAgreement(services, payload, chatId, agents, actorId, writeKeys);
|
|
59
59
|
case 'agreement-subcontract':
|
|
60
|
-
return subcontractAgreement(services, payload, namedSubcontractChat(payload, chatId),
|
|
60
|
+
return subcontractAgreement(services, payload, namedSubcontractChat(payload, chatId), actorId, writeKeys);
|
|
61
61
|
case 'agreement-respond':
|
|
62
62
|
if (!payload.agreementId || !payload.action)
|
|
63
63
|
return null;
|
|
@@ -322,9 +322,6 @@ function agIsProvider(a, agentId) {
|
|
|
322
322
|
function agIsParty(a, agentId) {
|
|
323
323
|
return partyActorIds(a.parties).includes(agentId);
|
|
324
324
|
}
|
|
325
|
-
function agActive(a) {
|
|
326
|
-
return !a.status || !TERMINAL_STATUS.has(String(a.status));
|
|
327
|
-
}
|
|
328
325
|
// Spawnable = actually ACTIVE. A freshly-subcontracted sub is `draft` until its
|
|
329
326
|
// counterparty auto-approves; spawning under a draft (or citing an agreement the
|
|
330
327
|
// assignee isn't a party on) 400s and cascades into a bogus worker "connection
|
|
@@ -339,66 +336,25 @@ function namedSubcontractChat(payload, wakeChatId) {
|
|
|
339
336
|
return named.trim();
|
|
340
337
|
return wakeChatId;
|
|
341
338
|
}
|
|
342
|
-
|
|
339
|
+
/**
|
|
340
|
+
* A sub-agreement under the agreement this wake works for, with exactly the
|
|
341
|
+
* terms the model stated. When it states neither an end date nor a task count
|
|
342
|
+
* the server writes a standing sub-agreement, the same as over MCP — the
|
|
343
|
+
* shared tool description says so.
|
|
344
|
+
*
|
|
345
|
+
* This used to rewrite such terms to one count-bound task, and to reuse any
|
|
346
|
+
* open sub-agreement for the same worker. Both served a backend rule that
|
|
347
|
+
* closed a parent once every child had finished; that rule is gone (a parent
|
|
348
|
+
* now closes only by its own quota, expiry or an explicit close), so the
|
|
349
|
+
* rewrite only made a hosted agent's sub-deal end after one task where the same
|
|
350
|
+
* request over MCP stays open. A crash replay is still covered by the
|
|
351
|
+
* wake-derived idempotency key.
|
|
352
|
+
*/
|
|
353
|
+
async function subcontractAgreement(services, payload, chatId, actingAgentId, writeKeys = null) {
|
|
343
354
|
if (!payload.description || !payload.executorId)
|
|
344
355
|
return null;
|
|
345
|
-
// b1: idempotent subcontract — if a non-terminal sub-agreement for
|
|
346
|
-
// this executor already exists under the same parent, reuse it instead of
|
|
347
|
-
// creating a duplicate (which bloats the chain and blocks the fulfill cascade).
|
|
348
|
-
if (chatId) {
|
|
349
|
-
const parent = payload.parentAgreementId;
|
|
350
|
-
const existing = (await agreementsInChat(services, chatId, cachedAgreements)).find((a) => agActive(a) && agIsProvider(a, payload.executorId) &&
|
|
351
|
-
(!parent || a.parentAgreementId === parent || a.rootAgreementId === parent));
|
|
352
|
-
if (existing?.agreementId)
|
|
353
|
-
return existing;
|
|
354
|
-
}
|
|
355
356
|
const priced = applyFleetPricingForProtocol('agreement-subcontract', payload, actingAgentId);
|
|
356
357
|
const { operation: _op, ...fields } = priced;
|
|
357
|
-
// a subcontract must carry a working close condition, or it hangs
|
|
358
|
-
// 'active' forever — and because the backend only auto-fulfills a parent hire
|
|
359
|
-
// once every child sub-agreement is terminal (the fulfill cascade), one
|
|
360
|
-
// never-closing child leaves the whole engagement open. Deciding lifecycle is
|
|
361
|
-
// left to the model, which under load (context bloat, competing wakes) emits
|
|
362
|
-
// incomplete terms — e.g. time-bound with no expiresAt and no maxExecutions —
|
|
363
|
-
// that never reach fulfillment. Make the common single-delegated-task case
|
|
364
|
-
// deterministic: if the caller gave neither a real quota nor a real deadline,
|
|
365
|
-
// treat it as one count-bound execution so completing that task closes the
|
|
366
|
-
// sub — which then cascades the parent closed. A genuinely durable subcontract
|
|
367
|
-
// still opts out by setting maxExecutions>1 or a time-bound expiresAt.
|
|
368
|
-
const f = fields;
|
|
369
|
-
// repair incomplete terms instead of clobbering them. The model
|
|
370
|
-
// often emits a real quota or deadline but gets the envelope wrong — a
|
|
371
|
-
// numeric-string maxExecutions, or a quota/deadline with the lifecycle field
|
|
372
|
-
// missing or mismatched. The old check treated all of those as "no close
|
|
373
|
-
// condition" and forced count-bound/1, silently discarding the stated intent
|
|
374
|
-
// (e.g. maxExecutions=2 → 1, so the sub fulfills one task early and the next
|
|
375
|
-
// spawn under it fails).
|
|
376
|
-
if (typeof f.maxExecutions === 'string' && f.maxExecutions.trim() !== '' && Number.isFinite(Number(f.maxExecutions))) {
|
|
377
|
-
f.maxExecutions = Number(f.maxExecutions);
|
|
378
|
-
}
|
|
379
|
-
const hasQuota = typeof f.maxExecutions === 'number' && f.maxExecutions > 0;
|
|
380
|
-
const hasDeadline = typeof f.expiresAt === 'string' && f.expiresAt.length > 0;
|
|
381
|
-
if (f.lifecycle === 'count-bound' && hasQuota) {
|
|
382
|
-
// coherent as stated
|
|
383
|
-
}
|
|
384
|
-
else if (f.lifecycle === 'time-bound' && hasDeadline) {
|
|
385
|
-
// coherent as stated
|
|
386
|
-
}
|
|
387
|
-
else if (hasQuota) {
|
|
388
|
-
f.lifecycle = 'count-bound'; // quota given, lifecycle missing/mismatched — keep the quota
|
|
389
|
-
}
|
|
390
|
-
else if (hasDeadline) {
|
|
391
|
-
f.lifecycle = 'time-bound'; // deadline given, lifecycle missing/mismatched — keep the deadline
|
|
392
|
-
}
|
|
393
|
-
else {
|
|
394
|
-
// no usable close condition at all — the deterministic single-task default
|
|
395
|
-
f.lifecycle = 'count-bound';
|
|
396
|
-
f.maxExecutions = 1;
|
|
397
|
-
}
|
|
398
|
-
// Wake-derived idempotency key (delivery law): a crash-replay that slips past
|
|
399
|
-
// the b1 reuse check above (e.g. no chatId, or the sub not yet visible in the
|
|
400
|
-
// snapshot) resolves to the already-created sub-agreement server-side instead
|
|
401
|
-
// of minting a duplicate.
|
|
402
358
|
const idempotencyKey = writeKeys?.agreementSubcontractKey({
|
|
403
359
|
executorId: payload.executorId,
|
|
404
360
|
description: payload.description,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { connectionProxyCapability, requestConnectionCapability, ConnectionsClient, } from '@ziggs-ai/api-client';
|
|
2
2
|
import { defineTool } from '../../../tools/defineTool.js';
|
|
3
3
|
import { toolFromCapability } from '../../../tools/capabilityAdapter.js';
|
|
4
|
+
import { resolveBackendUrl } from '../../../shared/backendUrl.js';
|
|
4
5
|
// connection_proxy / connection_request are shared capability
|
|
5
6
|
// definitions (api-client). connection_list_grants stays SDK-only: the MCP
|
|
6
7
|
// surface folds connection listing into ziggs_connection_list (
|
|
@@ -19,7 +20,7 @@ export const connectionListGrantsTool = defineTool('connection_list_grants', {
|
|
|
19
20
|
const operatorKey = ctx?.['operatorKey'];
|
|
20
21
|
if (!operatorKey)
|
|
21
22
|
throw new Error('operatorKey missing from tool context');
|
|
22
|
-
const baseUrl =
|
|
23
|
+
const baseUrl = resolveBackendUrl();
|
|
23
24
|
const client = new ConnectionsClient(operatorKey, ctx?.['agentId'] || undefined, baseUrl);
|
|
24
25
|
try {
|
|
25
26
|
const grants = await client.listGrants({ connectionId: args['connectionId'] });
|
|
@@ -159,6 +159,16 @@ export declare class AgentMachine {
|
|
|
159
159
|
*/
|
|
160
160
|
private _parkAtInitial;
|
|
161
161
|
_step(outcome: Outcome, depth?: number): Promise<void>;
|
|
162
|
+
/**
|
|
163
|
+
* A turn threw. Route the error through the state's transitions; if none
|
|
164
|
+
* takes it, recover to workflow.initial so the next event is not judged by
|
|
165
|
+
* transitions written for this state.
|
|
166
|
+
*
|
|
167
|
+
* One method for thinking and tool (mechanical) states. The recovery used to
|
|
168
|
+
* live only in the thinking copy, so a tool state that threw with no error
|
|
169
|
+
* route stayed put and judged the next event as itself.
|
|
170
|
+
*/
|
|
171
|
+
_recoverFromTurnError(error: unknown, kind: 'thinking' | 'mechanical'): Promise<void>;
|
|
162
172
|
/**
|
|
163
173
|
* Unmatched thinking outcome: go back to workflow.initial when that state is
|
|
164
174
|
* parked. Used to require an error outcome, so a tool-result /
|
|
@@ -243,24 +243,7 @@ export class AgentMachine {
|
|
|
243
243
|
await this._step(turnOutcome, depth + 1);
|
|
244
244
|
}
|
|
245
245
|
catch (error) {
|
|
246
|
-
|
|
247
|
-
const errOutcome = {
|
|
248
|
-
kind: 'error',
|
|
249
|
-
source: 'unknown',
|
|
250
|
-
cause: error?.message || String(error),
|
|
251
|
-
retryable: false,
|
|
252
|
-
};
|
|
253
|
-
applyOutcomeToCtx(this.ctx, errOutcome);
|
|
254
|
-
this._running = false;
|
|
255
|
-
this._notify();
|
|
256
|
-
const fallback = this._evaluateTransitions(errOutcome);
|
|
257
|
-
if (fallback && fallback !== this.state) {
|
|
258
|
-
this.state = fallback;
|
|
259
|
-
this._notify();
|
|
260
|
-
}
|
|
261
|
-
else {
|
|
262
|
-
await this._sendFallbackIfUserWaiting('Something went wrong. Please try again.');
|
|
263
|
-
}
|
|
246
|
+
await this._recoverFromTurnError(error, 'mechanical');
|
|
264
247
|
}
|
|
265
248
|
return;
|
|
266
249
|
}
|
|
@@ -346,27 +329,37 @@ export class AgentMachine {
|
|
|
346
329
|
await this._step(turnOutcome, depth + 1);
|
|
347
330
|
}
|
|
348
331
|
catch (error) {
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
332
|
+
await this._recoverFromTurnError(error, 'thinking');
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* A turn threw. Route the error through the state's transitions; if none
|
|
337
|
+
* takes it, recover to workflow.initial so the next event is not judged by
|
|
338
|
+
* transitions written for this state.
|
|
339
|
+
*
|
|
340
|
+
* One method for thinking and tool (mechanical) states. The recovery used to
|
|
341
|
+
* live only in the thinking copy, so a tool state that threw with no error
|
|
342
|
+
* route stayed put and judged the next event as itself.
|
|
343
|
+
*/
|
|
344
|
+
async _recoverFromTurnError(error, kind) {
|
|
345
|
+
runtimeLog.error('AgentMachine', `${kind === 'mechanical' ? 'mechanical ' : ''}state=${this.state} error: ${error?.message || error}`);
|
|
346
|
+
const errOutcome = {
|
|
347
|
+
kind: 'error',
|
|
348
|
+
source: 'unknown',
|
|
349
|
+
cause: error?.message || String(error),
|
|
350
|
+
retryable: false,
|
|
351
|
+
};
|
|
352
|
+
applyOutcomeToCtx(this.ctx, errOutcome);
|
|
353
|
+
this._running = false;
|
|
354
|
+
this._notify();
|
|
355
|
+
const fallback = this._evaluateTransitions(errOutcome);
|
|
356
|
+
if (fallback && fallback !== this.state) {
|
|
357
|
+
this.state = fallback;
|
|
358
358
|
this._notify();
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
this.state = fallback;
|
|
364
|
-
this._notify();
|
|
365
|
-
}
|
|
366
|
-
else {
|
|
367
|
-
await this._sendFallbackIfUserWaiting('Something went wrong. Please try again.');
|
|
368
|
-
this._parkAtInitial();
|
|
369
|
-
}
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
await this._sendFallbackIfUserWaiting('Something went wrong. Please try again.');
|
|
362
|
+
this._parkAtInitial();
|
|
370
363
|
}
|
|
371
364
|
}
|
|
372
365
|
/**
|
|
@@ -125,6 +125,11 @@ export function validateWorkflow(workflow) {
|
|
|
125
125
|
else if (typeof p !== 'function') {
|
|
126
126
|
errors.push(`mechanical state "${name}" requires produces (OutcomeKind or fn)`);
|
|
127
127
|
}
|
|
128
|
+
// The same soft check thinking states get. A tool that throws
|
|
129
|
+
// with no error route sends the agent back to its initial state.
|
|
130
|
+
if (!hasErrorTransition(mech.transitions ?? [])) {
|
|
131
|
+
warnings.push(`mechanical state "${name}" has no transition for { kind: 'error' } — a failed tool call sends the agent back to "${workflow.initial}". Add an error transition if it should go elsewhere.`);
|
|
132
|
+
}
|
|
128
133
|
}
|
|
129
134
|
if (!Array.isArray(state.transitions)) {
|
|
130
135
|
errors.push(`state "${name}" requires a transitions array`);
|
|
@@ -189,17 +194,7 @@ export function validateWorkflow(workflow) {
|
|
|
189
194
|
if (!hasWaitFallthrough) {
|
|
190
195
|
warnings.push(`thinking state "${name}" has no fallthrough for { kind: 'wait' } — agent may stall when LLM picks wait. Spread thinkingDefaults().transitions or add explicitly.`);
|
|
191
196
|
}
|
|
192
|
-
|
|
193
|
-
if (!t.when)
|
|
194
|
-
return true;
|
|
195
|
-
try {
|
|
196
|
-
return t.when({ kind: 'error', source: 'unknown', cause: 'probe', retryable: false }, PROBE_CTX);
|
|
197
|
-
}
|
|
198
|
-
catch {
|
|
199
|
-
return false;
|
|
200
|
-
}
|
|
201
|
-
});
|
|
202
|
-
if (!hasErrorFallthrough) {
|
|
197
|
+
if (!hasErrorTransition(state.transitions)) {
|
|
203
198
|
warnings.push(`thinking state "${name}" has no fallthrough for { kind: 'error' } — empty/failed turns may silently recurse until the depth guard. Add an error transition or spread thinkingDefaults().transitions.`);
|
|
204
199
|
}
|
|
205
200
|
}
|
|
@@ -209,3 +204,16 @@ export function validateWorkflow(workflow) {
|
|
|
209
204
|
throw new WorkflowValidationError(errors);
|
|
210
205
|
return { errors, warnings };
|
|
211
206
|
}
|
|
207
|
+
/** Does some transition take an error outcome? A guard that throws counts as no. */
|
|
208
|
+
function hasErrorTransition(transitions) {
|
|
209
|
+
return transitions.some((t) => {
|
|
210
|
+
if (!t.when)
|
|
211
|
+
return true;
|
|
212
|
+
try {
|
|
213
|
+
return t.when({ kind: 'error', source: 'unknown', cause: 'probe', retryable: false }, PROBE_CTX);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The backend address, read one way everywhere in the SDK.
|
|
3
|
+
*
|
|
4
|
+
* Explicit config wins, then the runner's overrides (`ZIGGS_BACKEND_URL`,
|
|
5
|
+
* `BACKEND_URL`), then `getBackendUrl()` — what `configureApiClient` set, else
|
|
6
|
+
* production. There were five copies with three different orders, and the one
|
|
7
|
+
* in the per-user connect path fell back to `http://localhost:3000`, so an
|
|
8
|
+
* outside developer who configured the client and nothing else had connection
|
|
9
|
+
* requests posted to their own machine.
|
|
10
|
+
*/
|
|
11
|
+
export declare function resolveBackendUrl(explicit?: string | null): string;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { getBackendUrl } from '@ziggs-ai/api-client';
|
|
2
|
+
/**
|
|
3
|
+
* The backend address, read one way everywhere in the SDK.
|
|
4
|
+
*
|
|
5
|
+
* Explicit config wins, then the runner's overrides (`ZIGGS_BACKEND_URL`,
|
|
6
|
+
* `BACKEND_URL`), then `getBackendUrl()` — what `configureApiClient` set, else
|
|
7
|
+
* production. There were five copies with three different orders, and the one
|
|
8
|
+
* in the per-user connect path fell back to `http://localhost:3000`, so an
|
|
9
|
+
* outside developer who configured the client and nothing else had connection
|
|
10
|
+
* requests posted to their own machine.
|
|
11
|
+
*/
|
|
12
|
+
export function resolveBackendUrl(explicit) {
|
|
13
|
+
return (explicit ||
|
|
14
|
+
process.env.ZIGGS_BACKEND_URL ||
|
|
15
|
+
process.env.BACKEND_URL ||
|
|
16
|
+
getBackendUrl()).replace(/\/$/, '');
|
|
17
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineTool } from './defineTool.js';
|
|
2
2
|
import { laneFromToolContext } from './toolCreds.js';
|
|
3
|
+
import { resolveBackendUrl } from '../shared/backendUrl.js';
|
|
3
4
|
/**
|
|
4
5
|
* the SDK surface adapter for shared capability definitions
|
|
5
6
|
* (api-client `capabilities/`). One capability (schema + handler + guidance)
|
|
@@ -30,9 +31,7 @@ export function toolFromCapability(cap) {
|
|
|
30
31
|
...(agentId ? { agentId } : {}),
|
|
31
32
|
...(laneId ? { laneId } : {}),
|
|
32
33
|
},
|
|
33
|
-
|
|
34
|
-
// shared clients default to HTTP_URL (api-client convention) otherwise.
|
|
35
|
-
baseUrl: process.env.BACKEND_URL || process.env.ZIGGS_BACKEND_URL || undefined,
|
|
34
|
+
baseUrl: resolveBackendUrl(),
|
|
36
35
|
webUrl: process.env.ZIGGS_WEB_URL,
|
|
37
36
|
surface: 'sdk',
|
|
38
37
|
serviceAgreementId: typeof ctx.serviceAgreementId === 'string' ? ctx.serviceAgreementId : undefined,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ziggs-ai/agent-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.1",
|
|
4
4
|
"description": "Agent framework SDK for building autonomous agents on the Ziggs platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"@anthropic-ai/claude-agent-sdk": "^0.3.284",
|
|
27
27
|
"@anthropic-ai/sdk": "^0.110.0",
|
|
28
28
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
29
|
-
"@ziggs-ai/api-client": "0.
|
|
29
|
+
"@ziggs-ai/api-client": "0.32.1",
|
|
30
30
|
"ajv": "^8.17.1",
|
|
31
31
|
"dotenv": "^17.2.3",
|
|
32
32
|
"openai": "^4.0.0",
|