@ziggs-ai/agent-sdk 0.15.0 → 0.17.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 (119) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +110 -88
  3. package/dist/{AgentHost.d.ts → Agent.d.ts} +66 -27
  4. package/dist/{AgentHost.js → Agent.js} +221 -119
  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} +145 -95
  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 +2 -2
  14. package/dist/{agent → brain}/claudeSdkProtocol.js +2 -2
  15. package/dist/{agent → brain}/claudeSdkTools.d.ts +2 -1
  16. package/dist/{agent → brain}/claudeSdkTools.js +9 -65
  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} +54 -40
  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}/agreements/AgreementService.d.ts +10 -1
  40. package/dist/{server → platform}/agreements/AgreementService.js +9 -1
  41. package/dist/{server → platform}/inboxLoop.d.ts +34 -1
  42. package/dist/{server → platform}/inboxLoop.js +122 -14
  43. package/dist/{server → platform}/polls/PollKind.d.ts +4 -7
  44. package/dist/{server → platform}/polls/PollKind.js +3 -6
  45. package/dist/{server → platform}/polls/PollRunner.d.ts +5 -6
  46. package/dist/{server → platform}/polls/PollRunner.js +24 -12
  47. package/dist/platform/polls/declaredPollRunner.d.ts +4 -0
  48. package/dist/platform/polls/declaredPollRunner.js +29 -0
  49. package/dist/{server → platform}/tools/index.d.ts +1 -0
  50. package/dist/{server → platform}/tools/index.js +1 -0
  51. package/dist/{server → platform}/tools/tier1/protocolRunner.js +33 -27
  52. package/dist/{server → platform}/tools/tier1/protocolTools.js +2 -7
  53. package/dist/{server → platform}/tools/tier2/mcpConnectionTools.js +6 -3
  54. package/dist/platform/tools/tier2/taskTools.d.ts +3 -0
  55. package/dist/platform/tools/tier2/taskTools.js +6 -0
  56. package/dist/runtime/bootstrapCtx.d.ts +1 -1
  57. package/dist/runtime/bootstrapCtx.js +1 -1
  58. package/dist/runtime/buildOutcome.d.ts +0 -8
  59. package/dist/runtime/buildOutcome.js +20 -6
  60. package/dist/runtime/kickoff.js +1 -1
  61. package/dist/runtime/runTurn.d.ts +1 -1
  62. package/dist/runtime/runTurn.js +18 -18
  63. package/dist/runtime/validateWorkflow.js +1 -1
  64. package/dist/runtime/wakeOutcome.d.ts +27 -0
  65. package/dist/runtime/wakeOutcome.js +43 -0
  66. package/dist/skills/parseSkill.d.ts +7 -2
  67. package/dist/skills/parseSkill.js +22 -5
  68. package/dist/skills/skillReadReference.d.ts +6 -0
  69. package/dist/skills/skillReadReference.js +27 -0
  70. package/dist/tools/needs.js +2 -2
  71. package/dist/tools/recordReport.d.ts +5 -0
  72. package/dist/tools/recordReport.js +5 -0
  73. package/dist/tools/toolCreds.d.ts +1 -1
  74. package/dist/tools/toolCreds.js +1 -1
  75. package/dist/types.d.ts +3 -3
  76. package/package.json +2 -2
  77. package/dist/server/createHealthServer.d.ts +0 -7
  78. package/dist/server/createHealthServer.js +0 -75
  79. package/dist/server/runLauncher.d.ts +0 -22
  80. package/dist/server/runLauncher.js +0 -160
  81. /package/dist/{agent → brain}/toolManagerToSdkMcp.d.ts +0 -0
  82. /package/dist/{server → platform}/EventQueue.js +0 -0
  83. /package/dist/{server → platform}/OutboxBuffer.d.ts +0 -0
  84. /package/dist/{server → platform}/OutboxBuffer.js +0 -0
  85. /package/dist/{server → platform}/SeenMessages.d.ts +0 -0
  86. /package/dist/{server → platform}/SeenMessages.js +0 -0
  87. /package/dist/{server → platform}/proactive/ProactiveTrigger.d.ts +0 -0
  88. /package/dist/{server → platform}/proactive/ProactiveTrigger.js +0 -0
  89. /package/dist/{server → platform}/requestMatch.d.ts +0 -0
  90. /package/dist/{server → platform}/requestMatch.js +0 -0
  91. /package/dist/{server → platform}/tasks/TaskService.d.ts +0 -0
  92. /package/dist/{server → platform}/tasks/TaskService.js +0 -0
  93. /package/dist/{server → platform}/tasks/index.d.ts +0 -0
  94. /package/dist/{server → platform}/tasks/index.js +0 -0
  95. /package/dist/{server → platform}/telemetryIngest.d.ts +0 -0
  96. /package/dist/{server → platform}/telemetryIngest.js +0 -0
  97. /package/dist/{server → platform}/tools/tier1/protocolRunner.d.ts +0 -0
  98. /package/dist/{server → platform}/tools/tier1/protocolTools.d.ts +0 -0
  99. /package/dist/{server → platform}/tools/tier2/artifactTools.d.ts +0 -0
  100. /package/dist/{server → platform}/tools/tier2/artifactTools.js +0 -0
  101. /package/dist/{server → platform}/tools/tier2/chatTools.d.ts +0 -0
  102. /package/dist/{server → platform}/tools/tier2/chatTools.js +0 -0
  103. /package/dist/{server → platform}/tools/tier2/connectionTools.d.ts +0 -0
  104. /package/dist/{server → platform}/tools/tier2/connectionTools.js +0 -0
  105. /package/dist/{server → platform}/tools/tier2/contextTools.d.ts +0 -0
  106. /package/dist/{server → platform}/tools/tier2/contextTools.js +0 -0
  107. /package/dist/{server → platform}/tools/tier2/discoveryTools.d.ts +0 -0
  108. /package/dist/{server → platform}/tools/tier2/discoveryTools.js +0 -0
  109. /package/dist/{server → platform}/tools/tier2/grantsTools.d.ts +0 -0
  110. /package/dist/{server → platform}/tools/tier2/grantsTools.js +0 -0
  111. /package/dist/{server → platform}/tools/tier2/introductionTools.d.ts +0 -0
  112. /package/dist/{server → platform}/tools/tier2/introductionTools.js +0 -0
  113. /package/dist/{server → platform}/tools/tier2/linkTools.d.ts +0 -0
  114. /package/dist/{server → platform}/tools/tier2/linkTools.js +0 -0
  115. /package/dist/{server → platform}/tools/tier2/marketplaceTools.d.ts +0 -0
  116. /package/dist/{server → platform}/tools/tier2/marketplaceTools.js +0 -0
  117. /package/dist/{server → platform}/tools/tier2/mcpConnectionTools.d.ts +0 -0
  118. /package/dist/{server → platform}/tools/tier2/paymentTools.d.ts +0 -0
  119. /package/dist/{server → platform}/tools/tier2/paymentTools.js +0 -0
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * ConnectionPool — the agents this launcher process hosts.
3
3
  *
4
- * Holds N `AgentHostOptions` configs and connects every one of them at boot
4
+ * Holds N `AgentOptions` configs and connects every one of them at boot
5
5
  *. Each host long-polls its own inbox for the life of the process —
6
6
  * there is no agent socket. There is no subset, no cap and no lifecycle policy:
7
7
  * a write releasing that poll is the only signal anything needs.
@@ -14,7 +14,7 @@
14
14
  * answer was "none of them".
15
15
  */
16
16
  import { ConnectionManager } from '@ziggs-ai/api-client';
17
- import { AgentHost } from '../AgentHost.js';
17
+ import { createAgent } from '../Agent.js';
18
18
  import { runtimeLog } from '../shared/runtimeLog.js';
19
19
  export class ConnectionPool {
20
20
  manager = new ConnectionManager();
@@ -48,7 +48,7 @@ export class ConnectionPool {
48
48
  const cfg = this.configs.get(agentId);
49
49
  const originalTap = cfg.effectTap;
50
50
  const pool = this;
51
- const host = new AgentHost({
51
+ const host = createAgent({
52
52
  ...cfg,
53
53
  effectTap(effect, response) {
54
54
  if (effect.kind === 'send-message') {
@@ -72,7 +72,11 @@ export class ConnectionPool {
72
72
  }
73
73
  /** Connect every registered agent. Reports the ones that could not come up. */
74
74
  async connectAll() {
75
- return this.manager.connectAll();
75
+ await this.manager.connectAll();
76
+ const connected = this.listActive().length;
77
+ if (connected !== this.size) {
78
+ throw new Error(`Only ${connected}/${this.size} agents started; process must restart`);
79
+ }
76
80
  }
77
81
  /** The host for one agent, connecting it if it somehow is not up yet. */
78
82
  async startAgent(agentId) {
@@ -181,6 +185,14 @@ export class ConnectionPool {
181
185
  query(filter) { return this.manager.query(filter); }
182
186
  list() { return this.manager.list(); }
183
187
  listActive() { return this.manager.listActive(); }
188
+ /**
189
+ * Inbox liveness for every live agent in this process. The runner's health
190
+ * route answers from this rather than from "the HTTP server replied", which
191
+ * a wedged process does happily while none of its agents read anything.
192
+ */
193
+ liveness() {
194
+ return [...this.hosts.values()].map((host) => host.inboxLiveness());
195
+ }
184
196
  get size() { return this.manager.size; }
185
197
  getMeta(id) { return this.manager.getMeta(id); }
186
198
  }
@@ -7,7 +7,7 @@ export interface EventQueueBusyLane {
7
7
  queued: number;
8
8
  processing: boolean;
9
9
  }
10
- /** Outcome of `waitForIdle` / `AgentHost.drain`. Timeout is data, not a throw. */
10
+ /** Outcome of `waitForIdle` / `Agent.drain`. Timeout is data, not a throw. */
11
11
  export interface WaitForIdleResult {
12
12
  idle: boolean;
13
13
  timedOut: boolean;
@@ -6,7 +6,7 @@ export interface StateTimeoutEvent {
6
6
  }
7
7
  /**
8
8
  * Per-lane liveness timers for parked states declaring timeoutMs. Owned by
9
- * AgentHost. A firing timer enqueues a state-timeout frame on the SAME lane
9
+ * Agent. A firing timer enqueues a state-timeout frame on the SAME lane
10
10
  * (caller-provided enqueue → EventQueue), so it serializes behind any real
11
11
  * event and can never race one. Epochs make stale frames identifiable: a
12
12
  * queued timeout frame whose epoch is not the lane's currently-armed epoch was
@@ -1,7 +1,7 @@
1
1
  import { runtimeLog } from '../shared/runtimeLog.js';
2
2
  /**
3
3
  * Per-lane liveness timers for parked states declaring timeoutMs. Owned by
4
- * AgentHost. A firing timer enqueues a state-timeout frame on the SAME lane
4
+ * Agent. A firing timer enqueues a state-timeout frame on the SAME lane
5
5
  * (caller-provided enqueue → EventQueue), so it serializes behind any real
6
6
  * event and can never race one. Epochs make stale frames identifiable: a
7
7
  * queued timeout frame whose epoch is not the lane's currently-armed epoch was
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * Ziggs-flavored EffectHandler.
3
3
  *
4
- * Translates the Agent's pure Effect requests into concrete backend calls:
4
+ * Translates the brain's Effect requests into concrete backend calls:
5
5
  * reads go through the grant-fenced ContextReadClient snapshot, writes through
6
6
  * artifactsClient / taskService / agreementService / messageSender, and LLM
7
- * turns through the adapter. This is the only place where Agent intent meets
7
+ * turns through the adapter. This is the only place where brain intent meets
8
8
  * backend reality.
9
9
  *
10
- * Lives in agent-sdk for now alongside `Agent`; destined for a separate
11
- * `@ziggs-ai/agent-server` package once the SDK split is complete.
10
+ * Belongs to the SDK body. The operator runner owns process startup and
11
+ * health serving; it does not perform the brain’s effects.
12
12
  */
13
13
  import type { Effect, EffectHandler, LlmMessage, LlmResponse, LlmToolSchema, ToolCallResult } from '../types.js';
14
14
  /** Minimal LLM adapter contract — OpenAIAdapter satisfies this. */
@@ -115,7 +115,7 @@ export async function executeToolWithSideEffects(deps, eff, effect, roster) {
115
115
  const argsPreview = previewToolArgs(args);
116
116
  // #6 — breadcrumbs are best-effort. Awaiting them bracketed every
117
117
  // tool call; a slow/wedged artifacts backend stalled the whole wake. Match
118
- // ClaudeSdkAgent thought writes (fire-and-forget).
118
+ // ClaudeBrain thought writes (fire-and-forget).
119
119
  void emitToolOperationArtifact(deps.artifactsClient, effect.sessionId, 'started', {
120
120
  state: 'started',
121
121
  tool: effect.name,
@@ -230,7 +230,7 @@ export function createZiggsEffectHandler(deps) {
230
230
  case 'read-context': {
231
231
  // Grant-fenced aggregate read: the backend composes the ContextSnapshot
232
232
  // (GET /context/snapshot) through the same access + temporal fence as
233
- // the per-type reads. Every producer — ClaudeSdkAgent.tick, runTurn,
233
+ // the per-type reads. Every producer — ClaudeBrain.tick, runTurn,
234
234
  // and the on-demand context_snapshot tool — routes through here.
235
235
  const snap = await buildSnapshot(deps, effect.sessionId, deps.agentId, {
236
236
  limit: effect.opts?.maxMessages,
@@ -1,4 +1,4 @@
1
- import { type Agreement, type ClaimedKind, type ClaimOptions, type ProposeAgreementData, type ProposeDirectInput, type ProposeBroadcastInput, type PublishOfferPayload, type DelegateAgreementData, type CounterAgreementData, type CreateLinkBody, type CreateLinkResult, type ListAgreementsFilters, type GetMyAgreementsFilters, type ChatLinkType, type ArtifactLinkType } from '@ziggs-ai/api-client';
1
+ import { type Agreement, type ClaimedKind, type ClaimOptions, type ProposeAgreementData, type ProposeDirectInput, type ProposeBroadcastInput, type PublishOfferPayload, type UnifiedProposeInput, type ProposeShape, type DelegateAgreementData, type CounterAgreementData, type CreateLinkBody, type CreateLinkResult, type ListAgreementsFilters, type GetMyAgreementsFilters, type ChatLinkType, type ArtifactLinkType } from '@ziggs-ai/api-client';
2
2
  export declare class AgreementService {
3
3
  private creds;
4
4
  constructor(operatorKey: string, agentId: string);
@@ -12,6 +12,15 @@ export declare class AgreementService {
12
12
  proposeBroadcast(payload: ProposeBroadcastInput): Promise<Agreement>;
13
13
  /** @deprecated Alias for {@link proposeDirect}. */
14
14
  propose(payload: ProposeAgreementData): Promise<Agreement>;
15
+ /**
16
+ * One propose grammar (direct / request / standing offer). Fleet pricing
17
+ * and the wake idempotency key stay on the caller — this is the shared
18
+ * routing, not a second copy of it.
19
+ */
20
+ proposeUnified(input: UnifiedProposeInput): Promise<{
21
+ agreement: Agreement;
22
+ shape: ProposeShape;
23
+ }>;
15
24
  /**
16
25
  * Seller-broadcast marketplace offer. `payload.audience` sets the `payer`
17
26
  * sentinel: 'everyone' (default, public) or 'org' (your active org only).
@@ -1,4 +1,4 @@
1
- import { proposeAgreement, proposeBroadcast as proposeBroadcastApi, delegateAgreement, respondToAgreement, counterAgreement, claimOpenAgreement, getAgreementStatus, listAgreements, getMyAgreements, getAgreement, publishOffer as publishOfferApi, createLink, revokeAgreement, fulfillAgreement, linkAgreementToChat, getChatsForAgreement, linkArtifactToAgreement, getArtifactsForAgreement, } from '@ziggs-ai/api-client';
1
+ import { proposeAgreement, proposeBroadcast as proposeBroadcastApi, delegateAgreement, respondToAgreement, counterAgreement, claimOpenAgreement, proposeUnified, getAgreementStatus, listAgreements, getMyAgreements, getAgreement, publishOffer as publishOfferApi, createLink, revokeAgreement, fulfillAgreement, linkAgreementToChat, getChatsForAgreement, linkArtifactToAgreement, getArtifactsForAgreement, } from '@ziggs-ai/api-client';
2
2
  export class AgreementService {
3
3
  creds;
4
4
  constructor(operatorKey, agentId) {
@@ -24,6 +24,14 @@ export class AgreementService {
24
24
  propose(payload) {
25
25
  return proposeAgreement(payload, this.creds);
26
26
  }
27
+ /**
28
+ * One propose grammar (direct / request / standing offer). Fleet pricing
29
+ * and the wake idempotency key stay on the caller — this is the shared
30
+ * routing, not a second copy of it.
31
+ */
32
+ proposeUnified(input) {
33
+ return proposeUnified(input, this.creds);
34
+ }
27
35
  /**
28
36
  * Seller-broadcast marketplace offer. `payload.audience` sets the `payer`
29
37
  * sentinel: 'everyone' (default, public) or 'org' (your active org only).
@@ -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
+ }