@ziggs-ai/agent-sdk 0.16.1 → 0.18.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.
Files changed (113) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +110 -88
  3. package/dist/{AgentHost.d.ts → Agent.d.ts} +50 -27
  4. package/dist/{AgentHost.js → Agent.js} +135 -129
  5. package/dist/adapters/AnthropicAdapter.d.ts +1 -1
  6. package/dist/adapters/AnthropicAdapter.js +1 -1
  7. package/dist/{agent/ClaudeSdkAgent.d.ts → brain/ClaudeBrain.d.ts} +23 -11
  8. package/dist/{agent/ClaudeSdkAgent.js → brain/ClaudeBrain.js} +99 -99
  9. package/dist/{agent/Agent.d.ts → brain/ZiggsBrain.d.ts} +23 -20
  10. package/dist/{agent/Agent.js → brain/ZiggsBrain.js} +12 -11
  11. package/dist/{agent → brain}/claudeCognition.d.ts +1 -1
  12. package/dist/{agent → brain}/claudeCognition.js +1 -1
  13. package/dist/{agent → brain}/claudeSdkProtocol.d.ts +1 -1
  14. package/dist/{agent → brain}/claudeSdkProtocol.js +1 -1
  15. package/dist/{agent → brain}/claudeSdkTools.d.ts +1 -1
  16. package/dist/{agent → brain}/claudeSdkTools.js +5 -14
  17. package/dist/{agent → brain}/hostedAgentConnection.d.ts +1 -1
  18. package/dist/{agent → brain}/hostedAgentConnection.js +3 -3
  19. package/dist/brain/index.d.ts +6 -6
  20. package/dist/brain/index.js +2 -2
  21. package/dist/brain/onBehalfAgreement.d.ts +51 -0
  22. package/dist/brain/onBehalfAgreement.js +99 -0
  23. package/dist/{agent → brain}/toolManagerToSdkMcp.js +10 -1
  24. package/dist/defineAgent.js +4 -4
  25. package/dist/formatters/HistoryFormatter.js +2 -1
  26. package/dist/index.d.ts +30 -31
  27. package/dist/index.js +24 -25
  28. package/dist/{adapters/McpAdapter.d.ts → mcp/createMcpAgent.d.ts} +16 -15
  29. package/dist/{adapters/McpAdapter.js → mcp/createMcpAgent.js} +82 -46
  30. package/dist/memory/MemoryStore.d.ts +18 -1
  31. package/dist/memory/MemoryStore.js +43 -0
  32. package/dist/{server → platform}/ConnectionPool.d.ts +9 -3
  33. package/dist/{server → platform}/ConnectionPool.js +16 -4
  34. package/dist/{server → platform}/EventQueue.d.ts +1 -1
  35. package/dist/{server → platform}/StateTimers.d.ts +1 -1
  36. package/dist/{server → platform}/StateTimers.js +1 -1
  37. package/dist/{server → platform}/ZiggsEffectHandler.d.ts +4 -4
  38. package/dist/{server → platform}/ZiggsEffectHandler.js +2 -2
  39. package/dist/{server → platform}/inboxLoop.d.ts +34 -1
  40. package/dist/{server → platform}/inboxLoop.js +122 -14
  41. package/dist/{server → platform}/polls/PollKind.d.ts +4 -7
  42. package/dist/{server → platform}/polls/PollKind.js +3 -6
  43. package/dist/{server → platform}/polls/PollRunner.d.ts +5 -6
  44. package/dist/{server → platform}/polls/PollRunner.js +24 -12
  45. package/dist/platform/polls/declaredPollRunner.d.ts +4 -0
  46. package/dist/platform/polls/declaredPollRunner.js +29 -0
  47. package/dist/{server → platform}/tools/tier2/mcpConnectionTools.js +32 -18
  48. package/dist/runtime/bootstrapCtx.d.ts +1 -1
  49. package/dist/runtime/bootstrapCtx.js +1 -1
  50. package/dist/runtime/buildOutcome.js +1 -1
  51. package/dist/runtime/runTurn.d.ts +1 -1
  52. package/dist/runtime/runTurn.js +18 -18
  53. package/dist/runtime/validateWorkflow.js +1 -1
  54. package/dist/skills/parseSkill.d.ts +7 -2
  55. package/dist/skills/parseSkill.js +22 -5
  56. package/dist/skills/skillReadReference.d.ts +6 -0
  57. package/dist/skills/skillReadReference.js +27 -0
  58. package/dist/tools/needs.js +2 -2
  59. package/dist/tools/toolCreds.d.ts +1 -1
  60. package/dist/tools/toolCreds.js +1 -1
  61. package/dist/types.d.ts +3 -3
  62. package/package.json +2 -2
  63. package/dist/server/createHealthServer.d.ts +0 -7
  64. package/dist/server/createHealthServer.js +0 -75
  65. package/dist/server/runLauncher.d.ts +0 -22
  66. package/dist/server/runLauncher.js +0 -160
  67. /package/dist/{agent → brain}/toolManagerToSdkMcp.d.ts +0 -0
  68. /package/dist/{server → platform}/EventQueue.js +0 -0
  69. /package/dist/{server → platform}/OutboxBuffer.d.ts +0 -0
  70. /package/dist/{server → platform}/OutboxBuffer.js +0 -0
  71. /package/dist/{server → platform}/SeenMessages.d.ts +0 -0
  72. /package/dist/{server → platform}/SeenMessages.js +0 -0
  73. /package/dist/{server → platform}/agreements/AgreementService.d.ts +0 -0
  74. /package/dist/{server → platform}/agreements/AgreementService.js +0 -0
  75. /package/dist/{server → platform}/proactive/ProactiveTrigger.d.ts +0 -0
  76. /package/dist/{server → platform}/proactive/ProactiveTrigger.js +0 -0
  77. /package/dist/{server → platform}/requestMatch.d.ts +0 -0
  78. /package/dist/{server → platform}/requestMatch.js +0 -0
  79. /package/dist/{server → platform}/tasks/TaskService.d.ts +0 -0
  80. /package/dist/{server → platform}/tasks/TaskService.js +0 -0
  81. /package/dist/{server → platform}/tasks/index.d.ts +0 -0
  82. /package/dist/{server → platform}/tasks/index.js +0 -0
  83. /package/dist/{server → platform}/telemetryIngest.d.ts +0 -0
  84. /package/dist/{server → platform}/telemetryIngest.js +0 -0
  85. /package/dist/{server → platform}/tools/index.d.ts +0 -0
  86. /package/dist/{server → platform}/tools/index.js +0 -0
  87. /package/dist/{server → platform}/tools/tier1/protocolRunner.d.ts +0 -0
  88. /package/dist/{server → platform}/tools/tier1/protocolRunner.js +0 -0
  89. /package/dist/{server → platform}/tools/tier1/protocolTools.d.ts +0 -0
  90. /package/dist/{server → platform}/tools/tier1/protocolTools.js +0 -0
  91. /package/dist/{server → platform}/tools/tier2/artifactTools.d.ts +0 -0
  92. /package/dist/{server → platform}/tools/tier2/artifactTools.js +0 -0
  93. /package/dist/{server → platform}/tools/tier2/chatTools.d.ts +0 -0
  94. /package/dist/{server → platform}/tools/tier2/chatTools.js +0 -0
  95. /package/dist/{server → platform}/tools/tier2/connectionTools.d.ts +0 -0
  96. /package/dist/{server → platform}/tools/tier2/connectionTools.js +0 -0
  97. /package/dist/{server → platform}/tools/tier2/contextTools.d.ts +0 -0
  98. /package/dist/{server → platform}/tools/tier2/contextTools.js +0 -0
  99. /package/dist/{server → platform}/tools/tier2/discoveryTools.d.ts +0 -0
  100. /package/dist/{server → platform}/tools/tier2/discoveryTools.js +0 -0
  101. /package/dist/{server → platform}/tools/tier2/grantsTools.d.ts +0 -0
  102. /package/dist/{server → platform}/tools/tier2/grantsTools.js +0 -0
  103. /package/dist/{server → platform}/tools/tier2/introductionTools.d.ts +0 -0
  104. /package/dist/{server → platform}/tools/tier2/introductionTools.js +0 -0
  105. /package/dist/{server → platform}/tools/tier2/linkTools.d.ts +0 -0
  106. /package/dist/{server → platform}/tools/tier2/linkTools.js +0 -0
  107. /package/dist/{server → platform}/tools/tier2/marketplaceTools.d.ts +0 -0
  108. /package/dist/{server → platform}/tools/tier2/marketplaceTools.js +0 -0
  109. /package/dist/{server → platform}/tools/tier2/mcpConnectionTools.d.ts +0 -0
  110. /package/dist/{server → platform}/tools/tier2/paymentTools.d.ts +0 -0
  111. /package/dist/{server → platform}/tools/tier2/paymentTools.js +0 -0
  112. /package/dist/{server → platform}/tools/tier2/taskTools.d.ts +0 -0
  113. /package/dist/{server → platform}/tools/tier2/taskTools.js +0 -0
@@ -1,4 +1,4 @@
1
- import { type InboxRequestRef, type InboxTaskRef, type Creds } from '@ziggs-ai/api-client';
1
+ import { type InboxChatNews, type InboxRequestRef, type InboxTaskRef, type Creds } from '@ziggs-ai/api-client';
2
2
  import { SeenMessages } from './SeenMessages.js';
3
3
  /**
4
4
  * Per-agent inbox long-poll loop (delivery law, phase 4). This is the ONLY way
@@ -53,6 +53,19 @@ export interface InboxPassResult {
53
53
  */
54
54
  failures: number;
55
55
  }
56
+ /**
57
+ * Store publish waits on a `probe_*` chat reply, not inbox ack. Act those
58
+ * rooms before leftover mail so a standing host answers the gate even when
59
+ * the envelope also carries older chats.
60
+ */
61
+ export declare function chatsInWakeOrder(chats: InboxChatNews[]): InboxChatNews[];
62
+ /**
63
+ * Store publish waits on a chat reply, not inbox ack. The ordinary pass can
64
+ * already be inside leftover mail when that room appears, so a side read
65
+ * answers `probe_*` chats without waiting for the current pass to finish.
66
+ * It never acks — the main pass still owns the watermark.
67
+ */
68
+ export declare function answerPublishProbes(deps: InboxLoopDeps): Promise<number>;
56
69
  /**
57
70
  * One long-poll pass: hold up to `waitSeconds` for new inbox content, then read
58
71
  * the deltas, deliver them into the host, and ack. Returns what it delivered.
@@ -67,7 +80,26 @@ export interface InboxLoopHandle {
67
80
  stop(): Promise<void>;
68
81
  /** True while the loop is running. */
69
82
  readonly running: boolean;
83
+ /**
84
+ * When this loop last completed a pass against the server — the one true
85
+ * liveness signal for an agent, and the same fact the store shows as online.
86
+ * Null until the first pass returns.
87
+ */
88
+ readonly lastPassAt: number | null;
89
+ /**
90
+ * When this loop last STARTED an iteration, successful or not. Separate from
91
+ * `lastPassAt` on purpose: a backend outage stops passes while the loop is
92
+ * perfectly healthy, so a restart decision reads this one and a staleness
93
+ * report reads the other. `running` says the loop exists; this says it turns.
94
+ */
95
+ readonly lastAttemptAt: number | null;
70
96
  }
97
+ /**
98
+ * How many consecutive passes have refused to advance the watermark.
99
+ * A capped envelope used to reset the counter (zero handover failures),
100
+ * so `forceAck` never fired on the one hold that lasts forever.
101
+ */
102
+ export declare function heldPassAfter(prev: number, pass: Pick<InboxPassResult, 'acked' | 'failures' | 'truncated'>): number;
71
103
  /**
72
104
  * Run {@link pollInboxOnce} forever (until stopped). Each iteration long-polls
73
105
  * for `waitSeconds`; a passing envelope returns instantly, an empty one holds.
@@ -82,4 +114,5 @@ export interface InboxLoopHandle {
82
114
  */
83
115
  export declare function runInboxLoop(deps: InboxLoopDeps, opts?: {
84
116
  waitSeconds?: number;
117
+ probePollMs?: number;
85
118
  }): InboxLoopHandle;
@@ -8,6 +8,62 @@ function log(label, msg) {
8
8
  function warn(label, msg) {
9
9
  runtimeLog.warn(label, msg);
10
10
  }
11
+ /**
12
+ * Store publish waits on a `probe_*` chat reply, not inbox ack. Act those
13
+ * rooms before leftover mail so a standing host answers the gate even when
14
+ * the envelope also carries older chats.
15
+ */
16
+ export function chatsInWakeOrder(chats) {
17
+ return [...chats].sort((a, b) => {
18
+ const ap = isPublishProbeChat(a.chatId) ? 0 : 1;
19
+ const bp = isPublishProbeChat(b.chatId) ? 0 : 1;
20
+ if (ap !== bp)
21
+ return ap - bp;
22
+ return a.latestAt < b.latestAt ? 1 : -1;
23
+ });
24
+ }
25
+ function isPublishProbeChat(chatId) {
26
+ return chatId.startsWith('probe_');
27
+ }
28
+ /**
29
+ * Store publish waits on a chat reply, not inbox ack. The ordinary pass can
30
+ * already be inside leftover mail when that room appears, so a side read
31
+ * answers `probe_*` chats without waiting for the current pass to finish.
32
+ * It never acks — the main pass still owns the watermark.
33
+ */
34
+ export async function answerPublishProbes(deps) {
35
+ const inbox = new InboxClient(deps.creds.operatorKey, deps.creds.agentId, deps.baseUrl);
36
+ const reader = new ContextReadClient(deps.creds.operatorKey, deps.creds.agentId, deps.baseUrl);
37
+ const envelope = await inbox.getInbox({});
38
+ const probes = (envelope.chats ?? []).filter((c) => isPublishProbeChat(c.chatId));
39
+ if (probes.length === 0)
40
+ return 0;
41
+ const mine = (d) => d.assigneeId === deps.ownAgentId;
42
+ const earliestByChat = new Map();
43
+ for (const d of envelope.deliveries) {
44
+ if (!d.chatId || !mine(d) || !isPublishProbeChat(d.chatId))
45
+ continue;
46
+ const seen = earliestByChat.get(d.chatId);
47
+ if (!seen || d.ts < seen)
48
+ earliestByChat.set(d.chatId, d.ts);
49
+ }
50
+ const attempt = async (what, handover) => {
51
+ try {
52
+ await handover();
53
+ return true;
54
+ }
55
+ catch (err) {
56
+ warn(deps.label ?? 'inboxLoop', `publish-probe ${what} was not handled: ${err?.message ?? err}`);
57
+ return false;
58
+ }
59
+ };
60
+ let delivered = 0;
61
+ for (const news of probes) {
62
+ const earliest = earliestByChat.get(news.chatId) ?? news.latestAt;
63
+ delivered += await deliverMessagesForChat(deps, news, earliest, reader, attempt);
64
+ }
65
+ return delivered;
66
+ }
11
67
  /**
12
68
  * Standing proposals share the merged inbox visibility model with deliveries,
13
69
  * but older envelopes predate an explicit assignee stamp. Prefer the server's
@@ -116,9 +172,9 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
116
172
  }
117
173
  };
118
174
  if (envelope.deliveriesCapped) {
119
- // Informational only — does NOT block acking what WAS handed over. The
120
- // tail stays unacked and arrives on the next pass; never acking here would
121
- // make a badly backed-up agent replay its whole backlog forever.
175
+ // The listed window is the oldest CAP. Acking it slides the cursor; the
176
+ // newer tail stays past ackTo and arrives on the next pass. Holding here
177
+ // was the wedge: the same over-cap window came back forever.
122
178
  warn(label, 'inbox deliveriesCapped (more mail than one envelope carries — the tail follows on the next pass)');
123
179
  }
124
180
  // The single-assign law: a row is MINE to act on iff it carries my stamp.
@@ -136,7 +192,7 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
136
192
  if (!seen || d.ts < seen)
137
193
  earliestByChat.set(d.chatId, d.ts);
138
194
  }
139
- for (const news of envelope.chats) {
195
+ for (const news of chatsInWakeOrder(envelope.chats ?? [])) {
140
196
  const earliest = earliestByChat.get(news.chatId) ?? news.latestAt;
141
197
  const n = await deliverMessagesForChat(deps, news, earliest, reader, attempt);
142
198
  messagesDelivered += n;
@@ -195,7 +251,7 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
195
251
  }
196
252
  // request channel. Triage is the host's job (exact string compare);
197
253
  // a miss must still count as handled so the watermark can advance with 0 tokens.
198
- for (const request of envelope.requestsAwaitingMe ?? []) {
254
+ for (const request of envelope.openRequestsAwaitingMe ?? []) {
199
255
  const key = `request:${request.agreementId}:${request.ts}`;
200
256
  if (deps.seen.has(key))
201
257
  continue;
@@ -245,12 +301,7 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
245
301
  // this window has failed too many times in a row and is now poison, in which
246
302
  // case it is dropped loudly rather than wedging the agent forever.
247
303
  let acked = false;
248
- // A capped envelope has unlisted older deliveries behind ackTo;
249
- // acking would refuse or bury them. Hold until a later uncapped pass.
250
- if (envelope.deliveriesCapped && envelope.ackTo) {
251
- warn(label, `holding the watermark at ${envelope.ackTo} — deliveriesCapped; ack would not cover the unlisted tail`);
252
- }
253
- else if (envelope.ackTo && (failures === 0 || opts.forceAck)) {
304
+ if (envelope.ackTo && (failures === 0 || opts.forceAck)) {
254
305
  if (failures > 0) {
255
306
  runtimeLog.error(label, `acking a window that failed ${failures} handover(s) — giving up on redelivery to keep the ` +
256
307
  `agent alive; this mail is lost (ackTo=${envelope.ackTo})`);
@@ -261,7 +312,7 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
261
312
  const handledResourceIds = [
262
313
  ...new Set([
263
314
  ...envelope.deliveries.filter(mine).map((d) => d.resourceId),
264
- ...(envelope.requestsAwaitingMe ?? []).map((q) => q.agreementId),
315
+ ...(envelope.openRequestsAwaitingMe ?? []).map((q) => q.agreementId),
265
316
  ]),
266
317
  ].filter((id) => typeof id === 'string' && id.length > 0);
267
318
  await inbox.ack(envelope.ackTo, { handledResourceIds });
@@ -289,6 +340,16 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
289
340
  * some defaults ship with. Lower it if your deployment sits behind a shorter one.
290
341
  */
291
342
  const DEFAULT_WAIT_SECONDS = 110;
343
+ /**
344
+ * Side-poll interval for store-publish `probe_*` rooms.
345
+ *
346
+ * Default **off**. A second GET /inbox beside the main long-poll burned the
347
+ * shared poll ceiling: every hosted agent side-polled, probes got 429s, and
348
+ * Register saw ALB HTML 504s. Main pass + probe-first ordering + pinned
349
+ * `probe_*` deliveries are enough; set `probePollMs` only when you knowingly
350
+ * need the extra loop.
351
+ */
352
+ const DEFAULT_PROBE_POLL_MS = 0;
292
353
  const ERROR_BACKOFF_BASE_MS = 1_000;
293
354
  const ERROR_BACKOFF_MAX_MS = 30_000;
294
355
  /**
@@ -299,6 +360,14 @@ const ERROR_BACKOFF_MAX_MS = 30_000;
299
360
  * rather than an unbounded loop.
300
361
  */
301
362
  const MAX_HELD_PASSES = 3;
363
+ /**
364
+ * How many consecutive passes have refused to advance the watermark.
365
+ * A capped envelope used to reset the counter (zero handover failures),
366
+ * so `forceAck` never fired on the one hold that lasts forever.
367
+ */
368
+ export function heldPassAfter(prev, pass) {
369
+ return !pass.acked && (pass.failures > 0 || pass.truncated) ? prev + 1 : 0;
370
+ }
302
371
  function sleep(ms) {
303
372
  return new Promise((resolve) => setTimeout(resolve, ms));
304
373
  }
@@ -317,21 +386,28 @@ function sleep(ms) {
317
386
  export function runInboxLoop(deps, opts = {}) {
318
387
  const label = deps.label ?? 'inboxLoop';
319
388
  const waitSeconds = opts.waitSeconds ?? DEFAULT_WAIT_SECONDS;
389
+ const probePollMs = opts.probePollMs ?? DEFAULT_PROBE_POLL_MS;
320
390
  let running = true;
321
391
  let consecutiveErrors = 0;
322
392
  let heldPasses = 0;
393
+ let lastPassAt = null;
394
+ let lastAttemptAt = null;
323
395
  let first = true;
324
- const done = (async () => {
396
+ const main = (async () => {
325
397
  while (running) {
326
398
  try {
399
+ lastAttemptAt = Date.now();
327
400
  const pass = await pollInboxOnce(deps, first ? 0 : waitSeconds, {
328
401
  forceAck: heldPasses >= MAX_HELD_PASSES,
329
402
  });
403
+ lastPassAt = Date.now();
330
404
  first = false;
331
405
  consecutiveErrors = 0;
332
406
  // A held pass means the same window comes back; count how many times we
333
407
  // have refused it so a permanently unhandleable frame cannot spin here.
334
- heldPasses = pass.failures > 0 && !pass.acked ? heldPasses + 1 : 0;
408
+ // Capped holds used to reset this: failures === 0, so the poison
409
+ // escape never climbed. Count any pass that refused to advance.
410
+ heldPasses = heldPassAfter(heldPasses, pass);
335
411
  }
336
412
  catch (err) {
337
413
  if (!running)
@@ -356,10 +432,42 @@ export function runInboxLoop(deps, opts = {}) {
356
432
  }
357
433
  }
358
434
  })();
435
+ const probes = (async () => {
436
+ if (probePollMs <= 0)
437
+ return;
438
+ while (running) {
439
+ const slice = 50;
440
+ let left = probePollMs;
441
+ while (left > 0 && running) {
442
+ const step = Math.min(slice, left);
443
+ await sleep(step);
444
+ left -= step;
445
+ }
446
+ if (!running)
447
+ break;
448
+ try {
449
+ const n = await answerPublishProbes(deps);
450
+ if (n > 0)
451
+ log(label, `publish-probe side poll delivered ${n} message(s)`);
452
+ }
453
+ catch (err) {
454
+ if (!running)
455
+ break;
456
+ warn(label, `publish-probe side poll failed: ${err?.message ?? err}`);
457
+ }
458
+ }
459
+ })();
460
+ const done = Promise.all([main, probes]);
359
461
  return {
360
462
  get running() {
361
463
  return running;
362
464
  },
465
+ get lastPassAt() {
466
+ return lastPassAt;
467
+ },
468
+ get lastAttemptAt() {
469
+ return lastAttemptAt;
470
+ },
363
471
  async stop() {
364
472
  running = false;
365
473
  await done.catch(() => { });
@@ -5,12 +5,9 @@
5
5
  *
6
6
  * polls: [{ kind: 'sente-email', identityId: 'idt_...' }]
7
7
  *
8
- * It declares; it does not execute. Execution belongs to PollRunner, which the
9
- * launcher runs once for the whole fleet — polling is a fleet concern the way
10
- * the health server is, not a per-host behaviour: one loop per (agent, binding)
11
- * with its own interval, backoff and backpressure, instead of every host
12
- * hand-rolling a timer inside its message loop. Declaration travels with the
13
- * agent; execution does not.
8
+ * The declaration and its PollRunner travel with the Agent. connectAsync starts
9
+ * both inbox delivery and external polls; disconnect stops both. The process
10
+ * runner does not own a second copy of the polling lifecycle.
14
11
  *
15
12
  * A poll answers ONE question: is there work for this agent right now. It never
16
13
  * returns the work itself. The agent reads that for itself once woken, through
@@ -26,7 +23,7 @@
26
23
  * Kind-specific config, verbatim from the agent's manifest.
27
24
  *
28
25
  * `chatId` is not kind-specific and is not optional in practice: a wake has to
29
- * resolve to a lane or AgentHost's session resolver returns null and drops it
26
+ * resolve to a lane or Agent's session resolver returns null and drops it
30
27
  * silently. A poll has no engagement behind it to derive one from, so the
31
28
  * binding names the chat the agent thinks in — which also puts its triage
32
29
  * somewhere a human can watch. PollRunner refuses to register a binding without
@@ -5,12 +5,9 @@
5
5
  *
6
6
  * polls: [{ kind: 'sente-email', identityId: 'idt_...' }]
7
7
  *
8
- * It declares; it does not execute. Execution belongs to PollRunner, which the
9
- * launcher runs once for the whole fleet — polling is a fleet concern the way
10
- * the health server is, not a per-host behaviour: one loop per (agent, binding)
11
- * with its own interval, backoff and backpressure, instead of every host
12
- * hand-rolling a timer inside its message loop. Declaration travels with the
13
- * agent; execution does not.
8
+ * The declaration and its PollRunner travel with the Agent. connectAsync starts
9
+ * both inbox delivery and external polls; disconnect stops both. The process
10
+ * runner does not own a second copy of the polling lifecycle.
14
11
  *
15
12
  * A poll answers ONE question: is there work for this agent right now. It never
16
13
  * returns the work itself. The agent reads that for itself once woken, through
@@ -1,11 +1,9 @@
1
1
  /**
2
- * PollRunner — executes declared polls for a whole fleet, outside any host.
2
+ * PollRunner — executes declared external wake sources for agents.
3
3
  *
4
- * Every host already long-polls its own Ziggs inbox; this applies the same wake
5
- * discipline to the sources Ziggs cannot see — mailboxes, feeds, whatever a
6
- * PollKind can ask. It runs in the launcher rather than inside any AgentHost
7
- * because none of it belongs to a host's message loop: a poll is per
8
- * (agent, binding) and carries its own interval, backoff and backpressure.
4
+ * Each Agent owns its runner and starts it with its inbox loop. A poll is per
5
+ * (agent, binding), with its own interval, backoff and backpressure. This same
6
+ * lifecycle applies to fleet, MCP-tool, lab and evaluation agents.
9
7
  *
10
8
  * One loop per (agent, binding), and each loop AWAITS its own wake before
11
9
  * scheduling the next tick. That is the whole backpressure story: while an
@@ -73,6 +71,7 @@ export declare class PollRunner {
73
71
  private readonly wakeWindowMs;
74
72
  private readonly loops;
75
73
  private running;
74
+ private generation;
76
75
  constructor({ pool, registry, agents, operatorKey, defaultIntervalMs, maxBackoffMs, wakeTimeoutMs, maxWakesPerWindow, wakeWindowMs, label, }: PollRunnerOptions);
77
76
  get size(): number;
78
77
  start(): void;
@@ -1,11 +1,9 @@
1
1
  /**
2
- * PollRunner — executes declared polls for a whole fleet, outside any host.
2
+ * PollRunner — executes declared external wake sources for agents.
3
3
  *
4
- * Every host already long-polls its own Ziggs inbox; this applies the same wake
5
- * discipline to the sources Ziggs cannot see — mailboxes, feeds, whatever a
6
- * PollKind can ask. It runs in the launcher rather than inside any AgentHost
7
- * because none of it belongs to a host's message loop: a poll is per
8
- * (agent, binding) and carries its own interval, backoff and backpressure.
4
+ * Each Agent owns its runner and starts it with its inbox loop. A poll is per
5
+ * (agent, binding), with its own interval, backoff and backpressure. This same
6
+ * lifecycle applies to fleet, MCP-tool, lab and evaluation agents.
9
7
  *
10
8
  * One loop per (agent, binding), and each loop AWAITS its own wake before
11
9
  * scheduling the next tick. That is the whole backpressure story: while an
@@ -30,6 +28,7 @@ export class PollRunner {
30
28
  wakeWindowMs;
31
29
  loops = [];
32
30
  running = false;
31
+ generation = 0;
33
32
  constructor({ pool, registry, agents, operatorKey, defaultIntervalMs = 60_000, maxBackoffMs = 15 * 60_000, wakeTimeoutMs = Number(process.env.CLAUDE_SDK_TICK_TIMEOUT_MS ?? 300_000), maxWakesPerWindow = 20, wakeWindowMs = 60 * 60_000, label = 'PollRunner', }) {
34
33
  this.pool = pool;
35
34
  this.operatorKey = operatorKey;
@@ -65,6 +64,7 @@ export class PollRunner {
65
64
  chatId: binding.chatId,
66
65
  timer: null,
67
66
  failures: 0,
67
+ inFlight: false,
68
68
  wakes: 0,
69
69
  windowStartedAt: Date.now(),
70
70
  capped: false,
@@ -79,14 +79,18 @@ export class PollRunner {
79
79
  if (this.running)
80
80
  return;
81
81
  this.running = true;
82
+ this.generation++;
82
83
  if (!this.loops.length)
83
84
  return;
84
- for (const loop of this.loops)
85
- this.schedule(loop, 0);
85
+ for (const loop of this.loops) {
86
+ if (!loop.inFlight)
87
+ this.schedule(loop, 0);
88
+ }
86
89
  runtimeLog.info(this.label, `started — ${this.loops.length} poll(s): ${this.loops.map((l) => `${l.agentId}:${l.kind.id}`).join(', ')}`);
87
90
  }
88
91
  stop() {
89
92
  this.running = false;
93
+ this.generation++;
90
94
  for (const loop of this.loops) {
91
95
  if (loop.timer)
92
96
  clearTimeout(loop.timer);
@@ -97,7 +101,14 @@ export class PollRunner {
97
101
  schedule(loop, delayMs = this.nextDelay(loop)) {
98
102
  if (!this.running)
99
103
  return;
100
- loop.timer = setTimeout(() => void this.tick(loop), delayMs);
104
+ loop.timer = setTimeout(() => {
105
+ loop.timer = null;
106
+ loop.inFlight = true;
107
+ void this.tick(loop, this.generation).finally(() => {
108
+ loop.inFlight = false;
109
+ this.schedule(loop);
110
+ });
111
+ }, delayMs);
101
112
  }
102
113
  /** Interval while healthy; doubling up to the ceiling while failing. */
103
114
  nextDelay(loop) {
@@ -105,7 +116,7 @@ export class PollRunner {
105
116
  return loop.intervalMs;
106
117
  return Math.min(loop.intervalMs * 2 ** loop.failures, this.maxBackoffMs);
107
118
  }
108
- async tick(loop) {
119
+ async tick(loop, generation = this.generation) {
109
120
  if (!this.running)
110
121
  return;
111
122
  let result;
@@ -119,9 +130,11 @@ export class PollRunner {
119
130
  catch (err) {
120
131
  loop.failures += 1;
121
132
  runtimeLog.error(this.label, `${loop.agentId}:${loop.kind.id} source failed (${loop.failures}x, next in ${this.nextDelay(loop)}ms): ${err.message}`);
122
- this.schedule(loop);
123
133
  return;
124
134
  }
135
+ // A source read may finish after disconnect or a new connection.
136
+ if (!this.running || generation !== this.generation)
137
+ return;
125
138
  if (result?.actionable && this.claimWake(loop)) {
126
139
  const hint = result.hint ?? `New work from ${loop.kind.id}. Check it.`;
127
140
  try {
@@ -140,7 +153,6 @@ export class PollRunner {
140
153
  runtimeLog.error(this.label, `${loop.agentId} wake failed (${loop.failures}x, next in ${this.nextDelay(loop)}ms): ${err.message}`);
141
154
  }
142
155
  }
143
- this.schedule(loop);
144
156
  }
145
157
  /**
146
158
  * Take one wake from this binding's budget, or refuse.
@@ -0,0 +1,4 @@
1
+ import type { AgentOptions } from '../../Agent.js';
2
+ import { PollRunner, type WakeTarget } from './PollRunner.js';
3
+ /** Validate before starting any work, so a declared wake source cannot vanish. */
4
+ export declare function createDeclaredPollRunner(options: AgentOptions, target: WakeTarget): PollRunner | null;
@@ -0,0 +1,29 @@
1
+ import { PollRegistry } from './PollKind.js';
2
+ import { PollRunner } from './PollRunner.js';
3
+ /** Validate before starting any work, so a declared wake source cannot vanish. */
4
+ export function createDeclaredPollRunner(options, target) {
5
+ const registry = new PollRegistry();
6
+ for (const kind of options.pollKinds ?? [])
7
+ registry.register(kind);
8
+ if (options.polls === undefined)
9
+ return null;
10
+ if (!Array.isArray(options.polls))
11
+ throw new Error('Agent: polls must be an array');
12
+ for (const binding of options.polls) {
13
+ if (!binding || !registry.get(binding.kind)) {
14
+ throw new Error(`Agent ${options.agentId}: unknown poll kind "${binding?.kind}"; declare its implementation in pollKinds`);
15
+ }
16
+ if (typeof binding.chatId !== 'string' || !binding.chatId.trim()) {
17
+ throw new Error(`Agent ${options.agentId}: poll "${binding.kind}" requires a chatId`);
18
+ }
19
+ }
20
+ if (!options.polls.length)
21
+ return null;
22
+ return new PollRunner({
23
+ pool: target,
24
+ registry,
25
+ agents: [{ agentId: options.agentId, polls: options.polls }],
26
+ operatorKey: options.operatorKey,
27
+ label: `${options.name || options.agentId}-polls`,
28
+ });
29
+ }
@@ -2,7 +2,8 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
2
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
3
3
  import { ConnectionsClient } from '@ziggs-ai/api-client';
4
4
  import { defineTool } from '../../../tools/defineTool.js';
5
- import { resolveZiggsGatewayTarget } from '../../../adapters/McpAdapter.js';
5
+ import { credsFromToolContext } from '../../../tools/toolCreds.js';
6
+ import { resolveZiggsGatewayTarget } from '../../../mcp/createMcpAgent.js';
6
7
  /**
7
8
  * Calling a tool on a connected MCP server, one shot.
8
9
  *
@@ -15,7 +16,7 @@ import { resolveZiggsGatewayTarget } from '../../../adapters/McpAdapter.js';
15
16
  *
16
17
  * The gateway (`POST /connections/:id/mcp`) has always been the right door and is
17
18
  * already in production; what was missing was a tool that walks through it. The
18
- * transport, headers and MCP handshake are the same ones `McpAdapter`'s `ziggs`
19
+ * transport, headers and MCP handshake are the same ones `createMcpAgent`'s `ziggs`
19
20
  * transport uses — reused rather than re-derived, so a change to the gateway
20
21
  * contract lands in one place.
21
22
  *
@@ -24,19 +25,20 @@ import { resolveZiggsGatewayTarget } from '../../../adapters/McpAdapter.js';
24
25
  * no argument to guess; several means the caller must name one, because picking
25
26
  * for them is how an agent reads the wrong workspace and reports it as fact.
26
27
  */
27
- async function resolveTarget(ctx, connectionId, grantId) {
28
+ async function resolveTarget(ctx, toolName, connectionId, grantId) {
28
29
  if (connectionId && grantId)
29
30
  return { connectionId, grantId };
30
- const operatorKey = ctx['operatorKey'];
31
- const agentId = ctx['agentId'];
32
- if (!operatorKey)
33
- throw new Error('operatorKey missing from tool context');
34
- if (!agentId)
35
- throw new Error('agentId missing from tool context');
31
+ const creds = credsFromToolContext(ctx, toolName);
32
+ // The lane belongs here as much as on the call itself: the grant list answers
33
+ // for the party the wake acts for, so a lane-less list is the agent's OWN
34
+ // grants only — and this is the path that PICKS one when the caller named no
35
+ // connection. Without it a hired agent discovers nothing and reports "no live
36
+ // connection grant" while holding its customer's.
37
+ //
36
38
  // No explicit baseUrl: ConnectionsClient already resolves it the way the fleet
37
39
  // configures it (HTTP_URL). Passing BACKEND_URL here was the same mistake as in
38
40
  // the transport — undefined in deployment.
39
- const groups = await new ConnectionsClient(operatorKey, agentId).listForHolder();
41
+ const groups = await new ConnectionsClient(creds.operatorKey, creds.agentId, undefined, creds.laneId).listForHolder();
40
42
  const live = groups.filter((g) => (g.grants ?? []).length > 0);
41
43
  if (live.length === 0) {
42
44
  throw new Error('No live connection grant. Ask the connection owner to issue one for this agent, then retry.');
@@ -59,14 +61,26 @@ async function resolveTarget(ctx, connectionId, grantId) {
59
61
  }
60
62
  return { connectionId: chosen.connectionId, grantId: grant.grantId };
61
63
  }
62
- async function withClient(ctx, connectionId, grantId, run) {
64
+ async function withClient(ctx, toolName, connectionId, grantId, run) {
65
+ /**
66
+ * The lane comes from `credsFromToolContext`, the same reader every other
67
+ * tool uses. Reading it off a field of its own (`ctx.laneId`) is how this
68
+ * door came to send no lane at all: the executor states the lane as
69
+ * `chatId` / `sessionId`, so a key by any other name is always undefined,
70
+ * and the gateway then resolves the wake to the agent's own operator and
71
+ * refuses every grant its customer gave it.
72
+ */
73
+ const creds = credsFromToolContext(ctx, toolName);
63
74
  const target = resolveZiggsGatewayTarget({
64
75
  type: 'ziggs',
65
76
  connectionId,
66
77
  grantId,
67
- operatorKey: ctx['operatorKey'],
68
- agentId: ctx['agentId'],
69
- }, { agentId: ctx['agentId'] });
78
+ operatorKey: creds.operatorKey,
79
+ agentId: creds.agentId,
80
+ }, {
81
+ agentId: creds.agentId,
82
+ laneId: creds.laneId,
83
+ });
70
84
  const transport = new StreamableHTTPClientTransport(target.url, {
71
85
  requestInit: { headers: target.headers },
72
86
  });
@@ -95,7 +109,7 @@ export const mcpToolsListTool = defineTool('mcp_tools_list', {
95
109
  description: 'Tool names to return the FULL parameters (inputSchema) for. Omit to get the names and descriptions only — a whole server of schemas is tens of thousands of characters and gets cut, which is worse than not asking.',
96
110
  },
97
111
  }, async (args, ctx) => {
98
- const t = await resolveTarget(ctx, args['connectionId'], args['grantId']);
112
+ const t = await resolveTarget(ctx, 'mcp_tools_list', args['connectionId'], args['grantId']);
99
113
  /**
100
114
  * Names first, schemas on demand — the same two-step this platform's own MCP
101
115
  * surface settled on. One Linear server answers 53 tools and 78,000
@@ -104,7 +118,7 @@ export const mcpToolsListTool = defineTool('mcp_tools_list', {
104
118
  * that survives still looks complete.
105
119
  */
106
120
  const wanted = args['names']?.filter((n) => typeof n === 'string' && n);
107
- const tools = await withClient(ctx, t.connectionId, t.grantId, async (c) => {
121
+ const tools = await withClient(ctx, 'mcp_tools_list', t.connectionId, t.grantId, async (c) => {
108
122
  const res = await c.listTools();
109
123
  const rows = res.tools ?? [];
110
124
  const picked = wanted?.length ? rows.filter((tool) => wanted.includes(tool.name)) : rows;
@@ -250,8 +264,8 @@ export const mcpToolCallTool = defineTool('mcp_tool_call', {
250
264
  const name = args['tool'];
251
265
  if (!name)
252
266
  throw new Error('tool is required');
253
- const t = await resolveTarget(ctx, args['connectionId'], args['grantId']);
254
- const raw = await withClient(ctx, t.connectionId, t.grantId, (c) => c.callTool({
267
+ const t = await resolveTarget(ctx, 'mcp_tools_list', args['connectionId'], args['grantId']);
268
+ const raw = await withClient(ctx, 'mcp_tool_call', t.connectionId, t.grantId, (c) => c.callTool({
255
269
  name,
256
270
  arguments: args['args'] ?? {},
257
271
  }));
@@ -3,7 +3,7 @@ import type { Ctx, Identity } from '../types.js';
3
3
  * The one place a per-lane `Ctx` is constructed.
4
4
  *
5
5
  * Both cognitions bootstrap the same Ctx: the FSM (`AgentMachine` constructor)
6
- * and the Claude Agent SDK brain (`ClaudeSdkAgent.tick`). Before this, each
6
+ * and the Claude Agent SDK brain (`ClaudeBrain.tick`). Before this, each
7
7
  * carried its own copy of the fresh-Ctx literal and the restore-path defaulting,
8
8
  * and they had already drifted (the "default them ONCE here" comment in
9
9
  * AgentMachine no longer held). Keep it here so the shape lives in a single spot.
@@ -3,7 +3,7 @@ import { migrateLegacyTaskFields } from './taskBookkeeping.js';
3
3
  * The one place a per-lane `Ctx` is constructed.
4
4
  *
5
5
  * Both cognitions bootstrap the same Ctx: the FSM (`AgentMachine` constructor)
6
- * and the Claude Agent SDK brain (`ClaudeSdkAgent.tick`). Before this, each
6
+ * and the Claude Agent SDK brain (`ClaudeBrain.tick`). Before this, each
7
7
  * carried its own copy of the fresh-Ctx literal and the restore-path defaulting,
8
8
  * and they had already drifted (the "default them ONCE here" comment in
9
9
  * AgentMachine no longer held). Keep it here so the shape lives in a single spot.
@@ -162,7 +162,7 @@ function singleEventToOutcome(ev, ownAgentId) {
162
162
  return null;
163
163
  }
164
164
  if (ev.type === 'state-timeout') {
165
- // Synthetic frame from AgentHost's StateTimers — host-constructed, fields always present.
165
+ // Synthetic frame from Agent's StateTimers — host-constructed, fields always present.
166
166
  return { kind: 'state-timeout', state: ev.state, waitedMs: ev.waitedMs };
167
167
  }
168
168
  // Only real inbound chat messages should drive the FSM. Backend bookkeeping
@@ -15,7 +15,7 @@ interface RunTurnInput {
15
15
  toolManager: ToolManager;
16
16
  promptBuilder: PromptBuilder;
17
17
  /**
18
- * Agent-private long-term memory, threaded from AgentHost. Currently
18
+ * Agent-private long-term memory, threaded from Agent. Currently
19
19
  * surfaced into `eff` as part of the tool-call effect payload so tools
20
20
  * can read/write notes; the prompt builder doesn't consume it yet.
21
21
  */