@ziggs-ai/agent-sdk 0.33.1 → 0.34.0

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
@@ -15,7 +15,14 @@
15
15
  ### Fixed
16
16
 
17
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.
18
+ - `disconnect(timeoutMs)` is bounded and resolves once the inbox host lease is
19
+ released: an idle long-poll is cancelled at once, a pass in flight gets up to
20
+ the bound to acknowledge what it finished, and a pass that outlasts the bound
21
+ loses the lease anyway so the next host is handed what was not acknowledged.
22
+ `drain()` waits on the event queue only. A pool no longer refuses to start a
23
+ replacement host with "retry after its current delivery completes": the
24
+ replacement starts once the previous host's lease is released, and
25
+ `shutdown(timeoutMs)` spends one budget across stopping intake and draining.
19
26
 
20
27
  - `agreement_subcontract` sends the terms the model stated. With no end date
21
28
  and no task count the server writes a standing sub-agreement, as it does
package/README.md CHANGED
@@ -47,14 +47,18 @@ const agent = createAgent({
47
47
  await agent.connectAsync();
48
48
 
49
49
  // When your process shuts down:
50
- // agent.disconnect();
50
+ // await agent.disconnect();
51
51
  // await agent.drain();
52
52
  ```
53
53
 
54
54
  The identity must already exist on Ziggs and the operator key must authorize it.
55
55
  `connectAsync()` starts outbound delivery; network failures retry inside the
56
- inbox loop. It is safe to call again while connected. `disconnect()` stops new
57
- polling and clears state timers; `drain()` waits for queued and active wakes.
56
+ inbox loop. It is safe to call again while connected. `disconnect(timeoutMs)`
57
+ stops new polling and clears state timers, lets the inbox pass in flight
58
+ acknowledge what it finished, then releases the inbox host lease; a pass that
59
+ outlasts the bound (30 s by default) loses the lease anyway, and the next host
60
+ is handed what was not acknowledged. `drain()` waits for queued and active
61
+ wakes.
58
62
 
59
63
  The Claude brain’s default wake timeout is five minutes; the Ziggs brain’s is
60
64
  two minutes. `tickTimeoutMs` overrides either. Legacy `cognition: 'fsm' |
package/dist/Agent.d.ts CHANGED
@@ -256,7 +256,6 @@ 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;
260
259
  private inboxLoopEnabled;
261
260
  private started;
262
261
  private pollRunner;
@@ -419,7 +418,16 @@ export declare class Agent {
419
418
  wakeTaskAssignment(taskId: string, options?: {
420
419
  fromInbox?: boolean;
421
420
  }): Promise<void>;
422
- disconnect(): Promise<void>;
421
+ /**
422
+ * Stop taking work. Timers and polls stop at once; the inbox loop cancels
423
+ * an idle long-poll and lets a pass already handed to this host finish and
424
+ * acknowledge, then releases the host lease — within `timeoutMs`. A pass
425
+ * that outlasts the bound does not keep the lease: it is released anyway
426
+ * and the next host is handed what was not acknowledged. Resolves once the
427
+ * lease is released either way. In-flight wakes keep running in the event
428
+ * queue; `drain()` is what waits for those.
429
+ */
430
+ disconnect(timeoutMs?: number): Promise<void>;
423
431
  /**
424
432
  * Waits for in-flight ticks to finish. Call before process.exit() on SIGTERM.
425
433
  * A timeout is returned, not thrown — the caller names it (lab verdict, fleet
package/dist/Agent.js CHANGED
@@ -33,7 +33,7 @@ import { ArtifactsClient, ConnectionsClient, ContextReadClient, TelemetryClient,
33
33
  import { normalizeNeeds, unmetNeeds, unmetNeedsMessage, } from './tools/needs.js';
34
34
  import { TaskService } from './platform/tasks/TaskService.js';
35
35
  import { AgreementService } from './platform/agreements/AgreementService.js';
36
- import { runInboxLoop } from './platform/inboxLoop.js';
36
+ import { INBOX_STOP_TIMEOUT_MS, runInboxLoop } from './platform/inboxLoop.js';
37
37
  import { requestExactMatch } from './platform/requestMatch.js';
38
38
  import { SeenMessages } from './platform/SeenMessages.js';
39
39
  import { runtimeLog } from './shared/runtimeLog.js';
@@ -219,7 +219,6 @@ export class Agent {
219
219
  stateTimers;
220
220
  /** The per-agent inbox long-poll loop — this agent's sole wake source. */
221
221
  inboxLoop = null;
222
- inboxStopping = null;
223
222
  inboxLoopEnabled;
224
223
  started = false;
225
224
  pollRunner;
@@ -1446,16 +1445,6 @@ export class Agent {
1446
1445
  return this.options.agentId || null;
1447
1446
  }
1448
1447
  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.
1459
1448
  if (this.started)
1460
1449
  return this;
1461
1450
  // Start the per-agent inbox long-poll loop — the agent's sole wake source.
@@ -1556,40 +1545,30 @@ export class Agent {
1556
1545
  chatId: sessionId,
1557
1546
  }, laneKey);
1558
1547
  }
1559
- disconnect() {
1548
+ /**
1549
+ * Stop taking work. Timers and polls stop at once; the inbox loop cancels
1550
+ * an idle long-poll and lets a pass already handed to this host finish and
1551
+ * acknowledge, then releases the host lease — within `timeoutMs`. A pass
1552
+ * that outlasts the bound does not keep the lease: it is released anyway
1553
+ * and the next host is handed what was not acknowledged. Resolves once the
1554
+ * lease is released either way. In-flight wakes keep running in the event
1555
+ * queue; `drain()` is what waits for those.
1556
+ */
1557
+ async disconnect(timeoutMs = INBOX_STOP_TIMEOUT_MS) {
1560
1558
  this.started = false;
1561
1559
  this.pollRunner?.stop();
1562
1560
  this.stateTimers.clearAll();
1563
- if (this.inboxLoop)
1564
- this.inboxStopping = this.inboxLoop.stop();
1561
+ const loop = this.inboxLoop;
1565
1562
  this.inboxLoop = null;
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();
1563
+ if (loop)
1564
+ await loop.stop(timeoutMs);
1569
1565
  }
1570
1566
  /**
1571
1567
  * Waits for in-flight ticks to finish. Call before process.exit() on SIGTERM.
1572
1568
  * A timeout is returned, not thrown — the caller names it (lab verdict, fleet
1573
1569
  * shutdown log) instead of EventQueue printing a stray warning.
1574
1570
  */
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
- }
1571
+ drain(timeoutMs = 30_000) { return this.eventQueue.waitForIdle(timeoutMs); }
1593
1572
  registerTool(tool) { return this.toolManager.register(tool); }
1594
1573
  isConnected() { return this.started && (!this.inboxLoopEnabled || (this.inboxLoop?.running ?? false)); }
1595
1574
  /**
@@ -17,8 +17,7 @@ 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
+ private stopTimeoutMs;
22
21
  private readonly responseListeners;
23
22
  /**
24
23
  * Register N agent configs. Nothing connects until `connectAll()`. A config
@@ -47,12 +46,15 @@ export declare class ConnectionPool {
47
46
  * is already live and polling the same inboxes, so that window is where one
48
47
  * delivery gets handled twice: two hosts, one request, two answers.
49
48
  *
50
- * `disconnect()` only stops the inbox loop and timers; in-flight ticks live in
51
- * each host's event queue, which is what `drain()` waits on. So stopping
52
- * intake first costs nothing that is already running.
49
+ * `disconnect()` stops the inbox loop and timers, lets the pass in flight
50
+ * acknowledge what it finished, and releases the host lease — all inside
51
+ * the bound; in-flight ticks live in each host's event queue, which is what
52
+ * `drain()` waits on. One budget covers both, so a fleet on a 120s stop
53
+ * clock is not asked to wait twice.
53
54
  *
54
55
  * A host that cannot finish inside `timeoutMs` is left behind rather than
55
- * holding up the rest — the caller is on a shutdown clock of its own.
56
+ * holding up the rest — the caller is on a shutdown clock of its own. Its
57
+ * lease is released regardless, so the replacement can host at once.
56
58
  */
57
59
  shutdown(timeoutMs?: number): Promise<void>;
58
60
  /**
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import { ConnectionManager } from '@ziggs-ai/api-client';
17
17
  import { createAgent } from '../Agent.js';
18
+ import { INBOX_STOP_TIMEOUT_MS } from './inboxLoop.js';
18
19
  import { runtimeLog } from '../shared/runtimeLog.js';
19
20
  export class ConnectionPool {
20
21
  manager = new ConnectionManager();
@@ -23,12 +24,9 @@ export class ConnectionPool {
23
24
  // open/close callbacks and does not expose them, and re-reading one through
24
25
  // `startAgent` after a stop would reconnect the agent we are shutting down.
25
26
  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;
27
+ // How long a stopping host may spend acknowledging its pass in flight before
28
+ // its lease is released anyway. `shutdown()` sets it to the caller's budget.
29
+ stopTimeoutMs = INBOX_STOP_TIMEOUT_MS;
32
30
  // Listeners enqueued by requestFrom — drained on outbound send-message effects.
33
31
  responseListeners = new Map();
34
32
  /**
@@ -51,17 +49,6 @@ export class ConnectionPool {
51
49
  }
52
50
  this.configs.set(agentId, config);
53
51
  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
- }
65
52
  const cfg = this.configs.get(agentId);
66
53
  const originalTap = cfg.effectTap;
67
54
  const pool = this;
@@ -81,8 +68,9 @@ export class ConnectionPool {
81
68
  runtimeLog.debug('ConnectionPool', `connected "${agentId}"`);
82
69
  return host;
83
70
  }, async (host) => {
84
- this.retiringHosts.set(agentId, host);
85
- await host.disconnect();
71
+ // Resolves once the host's lease is released, so a replacement
72
+ // started next cannot share the inbox with a host still acking.
73
+ await host.disconnect(this.stopTimeoutMs);
86
74
  this.hosts.delete(agentId);
87
75
  runtimeLog.debug('ConnectionPool', `disconnected "${agentId}"`);
88
76
  }, metaByAgentId.get(agentId));
@@ -126,26 +114,24 @@ export class ConnectionPool {
126
114
  * is already live and polling the same inboxes, so that window is where one
127
115
  * delivery gets handled twice: two hosts, one request, two answers.
128
116
  *
129
- * `disconnect()` only stops the inbox loop and timers; in-flight ticks live in
130
- * each host's event queue, which is what `drain()` waits on. So stopping
131
- * intake first costs nothing that is already running.
117
+ * `disconnect()` stops the inbox loop and timers, lets the pass in flight
118
+ * acknowledge what it finished, and releases the host lease — all inside
119
+ * the bound; in-flight ticks live in each host's event queue, which is what
120
+ * `drain()` waits on. One budget covers both, so a fleet on a 120s stop
121
+ * clock is not asked to wait twice.
132
122
  *
133
123
  * A host that cannot finish inside `timeoutMs` is left behind rather than
134
- * holding up the rest — the caller is on a shutdown clock of its own.
124
+ * holding up the rest — the caller is on a shutdown clock of its own. Its
125
+ * lease is released regardless, so the replacement can host at once.
135
126
  */
136
127
  async shutdown(timeoutMs = 30_000) {
137
- this.shutdownGeneration++;
138
- const hosts = [...new Set([...this.hosts.values(), ...this.retiringHosts.values()])];
128
+ const deadline = Date.now() + timeoutMs;
129
+ const hosts = [...this.hosts.values()];
130
+ this.stopTimeoutMs = timeoutMs;
139
131
  await this.manager.stopAll();
140
132
  await Promise.all(hosts.map(async (host) => {
141
133
  try {
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
- }
134
+ const result = await host.drain(Math.max(0, deadline - Date.now()));
149
135
  if (result?.timedOut) {
150
136
  runtimeLog.warn('ConnectionPool', `drain timed out after ${timeoutMs}ms: ${result.queuedEvents} event(s) queued, ${result.busyLanes.length} busy lane(s)`);
151
137
  }
@@ -71,8 +71,16 @@ export declare function pollInboxOnce(deps: InboxLoopDeps, waitSeconds?: number,
71
71
  signal?: AbortSignal;
72
72
  }): Promise<InboxPassResult>;
73
73
  export interface InboxLoopHandle {
74
- /** Stop the loop after the current pass; resolves when it has fully exited. */
75
- stop(): Promise<void>;
74
+ /**
75
+ * Stop reading. An idle long-poll is cancelled at once; a pass already
76
+ * handed to the host runs on so it can acknowledge what it finished, and
77
+ * the host lease is released after that. A pass still running when
78
+ * `timeoutMs` is up does not hold the lease: it is released anyway, and the
79
+ * next host is handed whatever was not acknowledged. Resolves `true` when
80
+ * the pass finished inside the bound, `false` when the lease was released
81
+ * out from under it.
82
+ */
83
+ stop(timeoutMs?: number): Promise<boolean>;
76
84
  /** True while the loop is running. */
77
85
  readonly running: boolean;
78
86
  /**
@@ -91,6 +99,8 @@ export interface InboxLoopHandle {
91
99
  }
92
100
  /** Count held windows so retries can back off without discarding mail. */
93
101
  export declare function heldPassAfter(prev: number, pass: Pick<InboxPassResult, 'acked' | 'failures' | 'truncated'>): number;
102
+ /** How long `stop()` waits for a pass in flight before releasing the lease anyway. */
103
+ export declare const INBOX_STOP_TIMEOUT_MS = 30000;
94
104
  /**
95
105
  * Run {@link pollInboxOnce} forever (until stopped). Each iteration long-polls
96
106
  * for `waitSeconds`; a passing envelope returns instantly, an empty one holds.
@@ -390,6 +390,18 @@ function sleep(ms, signal) {
390
390
  signal?.addEventListener('abort', finish, { once: true });
391
391
  });
392
392
  }
393
+ /** How long `stop()` waits for a pass in flight before releasing the lease anyway. */
394
+ export const INBOX_STOP_TIMEOUT_MS = 30_000;
395
+ /** Resolves `true` when `promise` settles within `ms`, `false` otherwise. The timer never outlives the answer. */
396
+ function withinMs(promise, ms) {
397
+ return new Promise((resolve) => {
398
+ const timer = setTimeout(() => resolve(false), Math.max(0, ms));
399
+ promise.then(() => true, () => true).then((settled) => {
400
+ clearTimeout(timer);
401
+ resolve(settled);
402
+ });
403
+ });
404
+ }
393
405
  /**
394
406
  * Run {@link pollInboxOnce} forever (until stopped). Each iteration long-polls
395
407
  * for `waitSeconds`; a passing envelope returns instantly, an empty one holds.
@@ -412,6 +424,10 @@ export function runInboxLoop(deps, opts = {}) {
412
424
  let lastAttemptAt = null;
413
425
  let first = true;
414
426
  const shutdown = new AbortController();
427
+ // One DELETE, whichever gets there first: the loop's own exit, or a stop
428
+ // that ran out of patience with the pass in flight.
429
+ let released = null;
430
+ const releaseOnce = () => (released ??= releaseInboxHostBestEffort(deps));
415
431
  const main = (async () => {
416
432
  while (running) {
417
433
  try {
@@ -447,7 +463,7 @@ export function runInboxLoop(deps, opts = {}) {
447
463
  }
448
464
  }
449
465
  })();
450
- const done = main.finally(() => releaseInboxHostBestEffort(deps));
466
+ const done = main.finally(releaseOnce);
451
467
  return {
452
468
  get running() {
453
469
  return running;
@@ -458,12 +474,17 @@ export function runInboxLoop(deps, opts = {}) {
458
474
  get lastAttemptAt() {
459
475
  return lastAttemptAt;
460
476
  },
461
- async stop() {
477
+ async stop(timeoutMs = INBOX_STOP_TIMEOUT_MS) {
462
478
  running = false;
463
- // Cancel an idle long-poll, but let work already handed to the host
464
- // finish and acknowledge before releasing its lease.
479
+ // Cancel an idle long-poll; a pass already handed to the host keeps
480
+ // going so it can acknowledge what it finished before the lease goes.
465
481
  shutdown.abort();
466
- await done;
482
+ const finished = await withinMs(done, timeoutMs);
483
+ if (!finished) {
484
+ warn(label, `inbox pass still running after ${timeoutMs}ms — releasing the host lease now; what it has not acknowledged is redelivered to the next host`);
485
+ await releaseOnce();
486
+ }
487
+ return finished;
467
488
  },
468
489
  };
469
490
  }
@@ -6,7 +6,6 @@ export const EVAL_HARNESS_AGENT_IDS = new Set([
6
6
  'minimal-context-agent',
7
7
  'forwarding-agent',
8
8
  'failing-agent',
9
- 'messaging-only-agent',
10
9
  'principal-agent',
11
10
  'capability-probe-agent',
12
11
  ]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ziggs-ai/agent-sdk",
3
- "version": "0.33.1",
3
+ "version": "0.34.0",
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.1",
29
+ "@ziggs-ai/api-client": "0.33.0",
30
30
  "ajv": "^8.17.1",
31
31
  "dotenv": "^17.2.3",
32
32
  "openai": "^4.0.0",