@ziggs-ai/agent-sdk 0.33.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 +38 -6
- package/dist/mcp/createMcpAgent.js +5 -23
- 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;
|
|
@@ -1444,6 +1446,16 @@ export class Agent {
|
|
|
1444
1446
|
return this.options.agentId || null;
|
|
1445
1447
|
}
|
|
1446
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.
|
|
1447
1459
|
if (this.started)
|
|
1448
1460
|
return this;
|
|
1449
1461
|
// Start the per-agent inbox long-poll loop — the agent's sole wake source.
|
|
@@ -1548,16 +1560,36 @@ export class Agent {
|
|
|
1548
1560
|
this.started = false;
|
|
1549
1561
|
this.pollRunner?.stop();
|
|
1550
1562
|
this.stateTimers.clearAll();
|
|
1551
|
-
|
|
1563
|
+
if (this.inboxLoop)
|
|
1564
|
+
this.inboxStopping = this.inboxLoop.stop();
|
|
1552
1565
|
this.inboxLoop = null;
|
|
1553
|
-
|
|
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();
|
|
1554
1569
|
}
|
|
1555
1570
|
/**
|
|
1556
1571
|
* Waits for in-flight ticks to finish. Call before process.exit() on SIGTERM.
|
|
1557
1572
|
* A timeout is returned, not thrown — the caller names it (lab verdict, fleet
|
|
1558
1573
|
* shutdown log) instead of EventQueue printing a stray warning.
|
|
1559
1574
|
*/
|
|
1560
|
-
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
|
+
}
|
|
1561
1593
|
registerTool(tool) { return this.toolManager.register(tool); }
|
|
1562
1594
|
isConnected() { return this.started && (!this.inboxLoopEnabled || (this.inboxLoop?.running ?? false)); }
|
|
1563
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
|
|
@@ -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.33.
|
|
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.32.
|
|
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",
|