@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 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. Skip
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
- const stopping = this.inboxLoop?.stop() ?? Promise.resolve();
1563
+ if (this.inboxLoop)
1564
+ this.inboxStopping = this.inboxLoop.stop();
1552
1565
  this.inboxLoop = null;
1553
- return stopping;
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) { return this.eventQueue.waitForIdle(timeoutMs); }
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, getBackendUrl } from '@ziggs-ai/api-client';
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
- // Explicit overrides still win, but the LAST resort is `getBackendUrl()` —
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
- // Explicit overrides still win, but the LAST resort is `getBackendUrl()` —
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
- const hosts = [...this.hosts.values()];
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, _opts?: {
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, _opts = {}) {
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(waitSeconds > 0 ? { waitSeconds } : {});
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) => setTimeout(resolve, ms));
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
- // Drop the lease immediately. Do not wait out a held poll — SIGTERM
446
- // then process.exit cannot sit on the 110s wait.
447
- await releaseInboxHostBestEffort(deps);
448
- void done.catch(() => { });
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), agents, actorId, writeKeys, cachedAgreements);
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
- async function subcontractAgreement(services, payload, chatId, _agents, actingAgentId, writeKeys = null, cachedAgreements) {
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 = process.env.BACKEND_URL || process.env.ZIGGS_BACKEND_URL || undefined;
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
- runtimeLog.error('AgentMachine', `mechanical state=${this.state} error: ${error?.message || error}`);
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
- runtimeLog.error('AgentMachine', `state=${this.state} error: ${error?.message || error}`);
350
- const errOutcome = {
351
- kind: 'error',
352
- source: 'unknown',
353
- cause: error?.message || String(error),
354
- retryable: false,
355
- };
356
- applyOutcomeToCtx(this.ctx, errOutcome);
357
- this._running = false;
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
- // Try to fall through using current state's transitions; if none match,
360
- // recover to workflow.initial so the next event is not judged here.
361
- const fallback = this._evaluateTransitions(errOutcome);
362
- if (fallback && fallback !== this.state) {
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
- const hasErrorFallthrough = state.transitions.some(t => {
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
- // BACKEND_URL / ZIGGS_BACKEND_URL are the SDK runner's overrides; the
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.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.32.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",