@ziggs-ai/agent-sdk 0.11.0 → 0.12.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 (49) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +1 -1
  3. package/dist/AgentHost.d.ts +108 -6
  4. package/dist/AgentHost.js +290 -18
  5. package/dist/adapters/McpAdapter.js +10 -12
  6. package/dist/agent/ClaudeSdkAgent.d.ts +9 -0
  7. package/dist/agent/ClaudeSdkAgent.js +127 -26
  8. package/dist/agent/claudeSdkProtocol.d.ts +1 -1
  9. package/dist/agent/claudeSdkProtocol.js +3 -2
  10. package/dist/agent/claudeSdkTools.js +71 -16
  11. package/dist/defineAgent.js +31 -2
  12. package/dist/index.d.ts +7 -3
  13. package/dist/index.js +11 -1
  14. package/dist/pricing/fleetTierByAgentId.gen.d.ts +2 -2
  15. package/dist/pricing/fleetTierByAgentId.gen.js +9 -2
  16. package/dist/runtime/PromptBuilder.js +13 -1
  17. package/dist/server/ConnectionPool.d.ts +19 -0
  18. package/dist/server/ConnectionPool.js +30 -0
  19. package/dist/server/OutboxBuffer.d.ts +1 -1
  20. package/dist/server/ZiggsEffectHandler.d.ts +1 -1
  21. package/dist/server/ZiggsEffectHandler.js +16 -3
  22. package/dist/server/agreements/AgreementService.d.ts +8 -3
  23. package/dist/server/agreements/AgreementService.js +10 -5
  24. package/dist/server/inboxLoop.d.ts +6 -6
  25. package/dist/server/inboxLoop.js +39 -21
  26. package/dist/server/polls/PollRunner.d.ts +36 -1
  27. package/dist/server/polls/PollRunner.js +42 -2
  28. package/dist/server/requestMatch.d.ts +5 -0
  29. package/dist/server/{questMatch.js → requestMatch.js} +2 -2
  30. package/dist/server/runLauncher.js +24 -3
  31. package/dist/server/tasks/TaskService.d.ts +5 -5
  32. package/dist/server/tools/index.d.ts +1 -1
  33. package/dist/server/tools/index.js +1 -1
  34. package/dist/server/tools/tier1/protocolRunner.js +2 -2
  35. package/dist/server/tools/tier1/protocolTools.js +7 -7
  36. package/dist/server/tools/tier2/marketplaceTools.js +2 -2
  37. package/dist/server/tools/tier2/mcpConnectionTools.d.ts +33 -0
  38. package/dist/server/tools/tier2/mcpConnectionTools.js +139 -7
  39. package/dist/server/tools/tier2/paymentTools.d.ts +0 -8
  40. package/dist/server/tools/tier2/paymentTools.js +8 -12
  41. package/dist/tools/ToolManager.d.ts +1 -1
  42. package/dist/tools/ToolManager.js +2 -1
  43. package/dist/tools/defineTool.d.ts +7 -0
  44. package/dist/tools/defineTool.js +12 -0
  45. package/dist/tools/needs.d.ts +54 -0
  46. package/dist/tools/needs.js +84 -0
  47. package/dist/types.d.ts +34 -5
  48. package/package.json +2 -2
  49. package/dist/server/questMatch.d.ts +0 -5
@@ -281,7 +281,19 @@ ${this._renderDecisionSchemaFromActions(actions, senderMeta, ctxObj, omitMessage
281
281
  const name = a.name && a.name !== id ? `${a.name} (${id})` : id;
282
282
  agentLabels.set(id, `${name}${desc}`);
283
283
  }
284
- const kindOf = (id) => userIds.has(id) ? 'user' : agentIds.has(id) ? 'agent' : 'unknown';
284
+ // The server declares what each participant is (`kind` on the snapshot row),
285
+ // so read that. Which array a row arrived in is a projection detail, and
286
+ // guessing from it mislabels the first participant that is neither a person
287
+ // nor an agent. A backend too old to send `kind` still resolves by array.
288
+ const declaredKinds = new Map();
289
+ for (const row of [...users, ...otherAgents]) {
290
+ const id = (row?.['userId'] ?? row?.['agentId']);
291
+ const kind = row?.['kind'];
292
+ if (id && typeof kind === 'string')
293
+ declaredKinds.set(id, kind);
294
+ }
295
+ const kindOf = (id) => declaredKinds.get(id) ??
296
+ (userIds.has(id) ? 'user' : agentIds.has(id) ? 'agent' : 'unknown');
285
297
  const labelOf = (id) => userLabels.get(id) ?? agentLabels.get(id) ?? id;
286
298
  const tagged = (id) => `${labelOf(id)} [${kindOf(id)}]`;
287
299
  const agreementParties = new Map();
@@ -16,6 +16,7 @@ export interface SendToOptions {
16
16
  export declare class ConnectionPool {
17
17
  private readonly manager;
18
18
  private readonly configs;
19
+ private readonly hosts;
19
20
  private readonly responseListeners;
20
21
  /**
21
22
  * Register N agent configs. Nothing connects until `connectAll()`; a config
@@ -29,6 +30,24 @@ export declare class ConnectionPool {
29
30
  startAgent(agentId: string): Promise<AgentHost>;
30
31
  stopAgent(agentId: string): Promise<void>;
31
32
  disconnectAll(): Promise<void>;
33
+ /**
34
+ * Shut the fleet down without abandoning work: stop taking new deliveries
35
+ * everywhere, THEN wait for in-flight turns to finish.
36
+ *
37
+ * The order is the whole point. Draining one agent while the others still
38
+ * long-poll leaves a window — as long as the drain — in which a host that is
39
+ * on its way out keeps accepting new work. On a rolling deploy the replacement
40
+ * is already live and polling the same inboxes, so that window is where one
41
+ * delivery gets handled twice: two hosts, one request, two answers.
42
+ *
43
+ * `disconnect()` only stops the inbox loop and timers; in-flight ticks live in
44
+ * each host's event queue, which is what `drain()` waits on. So stopping
45
+ * intake first costs nothing that is already running.
46
+ *
47
+ * A host that cannot finish inside `timeoutMs` is left behind rather than
48
+ * holding up the rest — the caller is on a shutdown clock of its own.
49
+ */
50
+ shutdown(timeoutMs?: number): Promise<void>;
32
51
  /**
33
52
  * Deliver a message to the named agent and wait for its first outbound
34
53
  * `send-message` effect — i.e. the agent's reply. Returns the reply text.
@@ -19,6 +19,10 @@ import { runtimeLog } from '../shared/runtimeLog.js';
19
19
  export class ConnectionPool {
20
20
  manager = new ConnectionManager();
21
21
  configs = new Map();
22
+ // Live hosts, so shutdown can drain them. The manager hands its handles to
23
+ // open/close callbacks and does not expose them, and re-reading one through
24
+ // `startAgent` after a stop would reconnect the agent we are shutting down.
25
+ hosts = new Map();
22
26
  // Listeners enqueued by requestFrom — drained on outbound send-message effects.
23
27
  responseListeners = new Map();
24
28
  /**
@@ -56,10 +60,12 @@ export class ConnectionPool {
56
60
  },
57
61
  });
58
62
  await host.connectAsync();
63
+ this.hosts.set(agentId, host);
59
64
  runtimeLog.debug('ConnectionPool', `connected "${agentId}"`);
60
65
  return host;
61
66
  }, async (host) => {
62
67
  host.disconnect();
68
+ this.hosts.delete(agentId);
63
69
  runtimeLog.debug('ConnectionPool', `disconnected "${agentId}"`);
64
70
  }, metaByAgentId.get(agentId));
65
71
  }
@@ -78,6 +84,30 @@ export class ConnectionPool {
78
84
  async disconnectAll() {
79
85
  return this.manager.stopAll();
80
86
  }
87
+ /**
88
+ * Shut the fleet down without abandoning work: stop taking new deliveries
89
+ * everywhere, THEN wait for in-flight turns to finish.
90
+ *
91
+ * The order is the whole point. Draining one agent while the others still
92
+ * long-poll leaves a window — as long as the drain — in which a host that is
93
+ * on its way out keeps accepting new work. On a rolling deploy the replacement
94
+ * is already live and polling the same inboxes, so that window is where one
95
+ * delivery gets handled twice: two hosts, one request, two answers.
96
+ *
97
+ * `disconnect()` only stops the inbox loop and timers; in-flight ticks live in
98
+ * each host's event queue, which is what `drain()` waits on. So stopping
99
+ * intake first costs nothing that is already running.
100
+ *
101
+ * A host that cannot finish inside `timeoutMs` is left behind rather than
102
+ * holding up the rest — the caller is on a shutdown clock of its own.
103
+ */
104
+ async shutdown(timeoutMs = 30_000) {
105
+ const hosts = [...this.hosts.values()];
106
+ await this.manager.stopAll();
107
+ await Promise.all(hosts.map((host) => host.drain(timeoutMs).catch((err) => {
108
+ runtimeLog.warn('ConnectionPool', `drain did not finish: ${err.message}`);
109
+ })));
110
+ }
81
111
  /**
82
112
  * Deliver a message to the named agent and wait for its first outbound
83
113
  * `send-message` effect — i.e. the agent's reply. Returns the reply text.
@@ -24,7 +24,7 @@ export declare class OutboxBuffer {
24
24
  constructor(options?: OutboxBufferOptions);
25
25
  track(chatId: string, msg: {
26
26
  text: string;
27
- receiverId: string;
27
+ receiverId?: string;
28
28
  }): void;
29
29
  merge(chatId: string, history: HistoryEntryShape[], agentId: string | null): void;
30
30
  gc(): void;
@@ -35,7 +35,7 @@ export interface ZiggsEffectDeps {
35
35
  contextReadClient: ContextReadClient;
36
36
  taskService: TaskService;
37
37
  agreementService: AgreementService;
38
- messageSender: (text: string, receiverId: string, sessionId: string, messageId?: string) => Promise<unknown>;
38
+ messageSender: (text: string, receiverId: string | undefined, sessionId: string, messageId?: string) => Promise<unknown>;
39
39
  /** Sends a streaming chunk to a client. Called per-token during stream-text effects. */
40
40
  chunkSender?: (sessionId: string, receiverId: string, text: string, messageId: string) => void;
41
41
  toolManager: ToolManager;
@@ -15,7 +15,7 @@ const emptySnapshot = (agentId, chatMissing = false) => ({
15
15
  history: [],
16
16
  agreements: [],
17
17
  users: [],
18
- agents: [{ agentId, role: 'self', isYou: true }],
18
+ agents: [{ agentId, role: 'self', isYou: true, kind: 'agent' }],
19
19
  latestSequence: null,
20
20
  ...(chatMissing ? { chatMissing: true } : {}),
21
21
  });
@@ -26,7 +26,11 @@ async function buildSnapshot(deps, chatId, agentId, opts = {}) {
26
26
  const snap = await deps.contextReadClient.snapshot(chatId, {
27
27
  maxMessages: opts.limit,
28
28
  });
29
- return snap;
29
+ // Built rather than cast: the roster rows are typed on both sides now (they
30
+ // carry the declared participant kind), and only `history` is loose on the
31
+ // wire, so a whole-object cast would be asserting past the one field that
32
+ // actually needs it.
33
+ return { ...snap, history: snap.history };
30
34
  }
31
35
  catch (err) {
32
36
  // Defensive: server normally signals a dead chat as 200 + chatMissing, but
@@ -331,7 +335,16 @@ export function createZiggsEffectHandler(deps) {
331
335
  };
332
336
  const handler = async (effect) => {
333
337
  const response = await dispatch(effect);
334
- deps.effectTap?.(effect, response);
338
+ // A tap observes; it must never decide the effect's fate. This call was bare,
339
+ // so a tap that threw took the effect down with it — and taps stringify
340
+ // arbitrary tool results, which throws on a circular value or a BigInt. The
341
+ // other two taps (metrics, wake) are already guarded; this is the last one.
342
+ try {
343
+ deps.effectTap?.(effect, response);
344
+ }
345
+ catch (err) {
346
+ runtimeLog.warn('ZiggsEffectHandler', `effectTap failed: ${err?.message || err}`);
347
+ }
335
348
  return response;
336
349
  };
337
350
  return handler;
@@ -1,4 +1,4 @@
1
- import { type Agreement, type ClaimedKind, type ProposeAgreementData, type ProposeDirectInput, type ProposeBroadcastInput, type PublishOfferPayload, type DelegateAgreementData, type CounterAgreementData, type CreateAgreementBody, type ListAgreementsFilters, type GetMyAgreementsFilters, type ChatLinkType, type ArtifactLinkType, type UserRole } from '@ziggs-ai/api-client';
1
+ import { type Agreement, type ClaimedKind, type ProposeAgreementData, type ProposeDirectInput, type ProposeBroadcastInput, type PublishOfferPayload, type DelegateAgreementData, type CounterAgreementData, type CreateLinkBody, type ListAgreementsFilters, type GetMyAgreementsFilters, type ChatLinkType, type ArtifactLinkType, type UserRole } from '@ziggs-ai/api-client';
2
2
  export declare class AgreementService {
3
3
  private creds;
4
4
  constructor(operatorKey: string, agentId: string);
@@ -20,7 +20,7 @@ export declare class AgreementService {
20
20
  delegate(payload: DelegateAgreementData): Promise<Agreement>;
21
21
  respond(agreementId: string, action: 'approve' | 'reject'): Promise<Agreement>;
22
22
  /**
23
- * claim any open broadcast (quest, standing offer, hand-off,
23
+ * claim any open broadcast (request, standing offer, hand-off,
24
24
  * link invite). Routing (offer vs agreement claim endpoint) is decided in
25
25
  * the shared flow; `kind` is the api-client ClaimedKind so this signature
26
26
  * can't drift from the flow again (a 'hand-off' addition broke the build).
@@ -45,7 +45,12 @@ export declare class AgreementService {
45
45
  */
46
46
  mine(filters?: GetMyAgreementsFilters): Promise<Agreement[]>;
47
47
  get(agreementId: string): Promise<Agreement | null>;
48
- create(body: CreateAgreementBody): Promise<{
48
+ /**
49
+ * Create a trust link — direct to one agent, or an open invite. Work
50
+ * agreements are created by `propose` / the marketplace claim routes, which
51
+ * run the term normalisation and consent gates this rail never had.
52
+ */
53
+ createLink(body: CreateLinkBody): Promise<{
49
54
  ok: boolean;
50
55
  agreement: Agreement;
51
56
  }>;
@@ -1,4 +1,4 @@
1
- import { proposeAgreement, proposeBroadcast as proposeBroadcastApi, delegateAgreement, respondToAgreement, counterAgreement, claimOpenAgreement, getAgreementStatus, listAgreements, getMyAgreements, getAgreement, publishOffer as publishOfferApi, createAgreement, revokeAgreement, fulfillAgreement, linkAgreementToChat, getChatsForAgreement, linkArtifactToAgreement, getArtifactsForAgreement, linkUserToAgreement, getUsersForAgreement, } from '@ziggs-ai/api-client';
1
+ import { proposeAgreement, proposeBroadcast as proposeBroadcastApi, delegateAgreement, respondToAgreement, counterAgreement, claimOpenAgreement, getAgreementStatus, listAgreements, getMyAgreements, getAgreement, publishOffer as publishOfferApi, createLink, revokeAgreement, fulfillAgreement, linkAgreementToChat, getChatsForAgreement, linkArtifactToAgreement, getArtifactsForAgreement, linkUserToAgreement, getUsersForAgreement, } from '@ziggs-ai/api-client';
2
2
  export class AgreementService {
3
3
  creds;
4
4
  constructor(operatorKey, agentId) {
@@ -38,7 +38,7 @@ export class AgreementService {
38
38
  return respondToAgreement(agreementId, action, this.creds);
39
39
  }
40
40
  /**
41
- * claim any open broadcast (quest, standing offer, hand-off,
41
+ * claim any open broadcast (request, standing offer, hand-off,
42
42
  * link invite). Routing (offer vs agreement claim endpoint) is decided in
43
43
  * the shared flow; `kind` is the api-client ClaimedKind so this signature
44
44
  * can't drift from the flow again (a 'hand-off' addition broke the build).
@@ -51,7 +51,7 @@ export class AgreementService {
51
51
  * agent; the target owner approves. Folds the old request_link tool.
52
52
  */
53
53
  async requestLink(providerId, description) {
54
- const { agreement } = await createAgreement({ engagementKind: 'link', providerId, ...(description ? { description } : {}) }, this.creds);
54
+ const { agreement } = await createLink({ targetAgentId: providerId, ...(description ? { description } : {}) }, this.creds);
55
55
  return agreement;
56
56
  }
57
57
  counter(agreementId, counter) {
@@ -75,8 +75,13 @@ export class AgreementService {
75
75
  get(agreementId) {
76
76
  return getAgreement(agreementId, this.creds);
77
77
  }
78
- create(body) {
79
- return createAgreement(body, this.creds);
78
+ /**
79
+ * Create a trust link — direct to one agent, or an open invite. Work
80
+ * agreements are created by `propose` / the marketplace claim routes, which
81
+ * run the term normalisation and consent gates this rail never had.
82
+ */
83
+ createLink(body) {
84
+ return createLink(body, this.creds);
80
85
  }
81
86
  revoke(agreementId) {
82
87
  return revokeAgreement(agreementId, this.creds);
@@ -1,4 +1,4 @@
1
- import { type InboxQuestRef, type InboxTaskRef, type Creds } from '@ziggs-ai/api-client';
1
+ import { 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
@@ -27,11 +27,11 @@ export interface InboxLoopDeps {
27
27
  */
28
28
  onTask?: (task: InboxTaskRef) => Promise<void>;
29
29
  /**
30
- * A marketplace quest doorbell. The host MUST triage with a plain
30
+ * A marketplace request doorbell. The host MUST triage with a plain
31
31
  * string compare and return without an LLM wake on miss — otherwise every
32
- * public quest burns a full Claude turn per published supplier.
32
+ * public request burns a full Claude turn per published supplier.
33
33
  */
34
- onQuest?: (quest: InboxQuestRef) => Promise<void>;
34
+ onRequest?: (request: InboxRequestRef) => Promise<void>;
35
35
  label?: string;
36
36
  }
37
37
  export interface InboxPassResult {
@@ -41,8 +41,8 @@ export interface InboxPassResult {
41
41
  resourceEvents: number;
42
42
  /** Assigned open tasks delivered this pass. */
43
43
  tasksDelivered: number;
44
- /** Quest refs triaged this pass — includes misses that only ack. */
45
- questsTriaged: number;
44
+ /** Request refs triaged this pass — includes misses that only ack. */
45
+ requestsTriaged: number;
46
46
  /** True when the watermark advanced this pass. */
47
47
  acked: boolean;
48
48
  /** True when there was more mail than one envelope carries. */
@@ -83,7 +83,7 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
83
83
  let messagesDelivered = 0;
84
84
  let resourceEvents = 0;
85
85
  let tasksDelivered = 0;
86
- let questsTriaged = 0;
86
+ let requestsTriaged = 0;
87
87
  let failures = 0;
88
88
  // a handover that failed must not be acked, and must not be
89
89
  // remembered as seen — otherwise the redelivery the held watermark buys is
@@ -107,10 +107,16 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
107
107
  // make a badly backed-up agent replay its whole backlog forever.
108
108
  warn(label, 'inbox deliveriesCapped (more mail than one envelope carries — the tail follows on the next pass)');
109
109
  }
110
- // Oldest delivery per chat: where to start reading that chat's messages.
110
+ // The single-assign law: a row is MINE to act on iff it carries my stamp.
111
+ // Everything else in the merged view (a sibling carrier's mail, the owner's
112
+ // needs-human rows, granted ambient lanes) is context the host may read on
113
+ // demand — never a wake, never mine to ack as handled.
114
+ const mine = (d) => d.assigneeId === deps.ownAgentId;
115
+ // Oldest ASSIGNED delivery per chat: where to start reading that chat's
116
+ // messages. Ambient chat rows must not drag the read window earlier.
111
117
  const earliestByChat = new Map();
112
118
  for (const d of envelope.deliveries) {
113
- if (!d.chatId)
119
+ if (!d.chatId || !mine(d))
114
120
  continue;
115
121
  const seen = earliestByChat.get(d.chatId);
116
122
  if (!seen || d.ts < seen)
@@ -126,10 +132,14 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
126
132
  // Everything that is not chat text is handed over as a resource event, from
127
133
  // the delivery itself — the reference already carries the ids the host needs,
128
134
  // so nothing has to be re-derived to name what changed.
129
- // Quests must never take this path — even if a pre-cut server
135
+ // Requests must never take this path — even if a pre-cut server
130
136
  // left them in `deliveries`, skip so they cannot wake the LLM before triage.
137
+ // Unassigned rows never take it either: waking the brain on a sibling's
138
+ // mail is the N-wakes-per-event cost the party-owned inbox exists to kill.
131
139
  for (const d of envelope.deliveries) {
132
- if (d.kind === 'message' || d.kind === 'quest')
140
+ if (d.kind === 'message' || d.kind === 'request')
141
+ continue;
142
+ if (!mine(d))
133
143
  continue;
134
144
  const key = `delivery:${d.kind}:${d.resourceId}:${d.ts}`;
135
145
  if (deps.seen.has(key))
@@ -169,26 +179,26 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
169
179
  if (envelope.truncatedTasks) {
170
180
  warn(label, `inbox truncatedTasks=${envelope.truncatedTasks} (more assigned tasks than one pass surfaces — they appear as earlier ones close)`);
171
181
  }
172
- // quest channel. Triage is the host's job (exact string compare);
182
+ // request channel. Triage is the host's job (exact string compare);
173
183
  // a miss must still count as handled so the watermark can advance with 0 tokens.
174
- for (const quest of envelope.questsAwaitingMe ?? []) {
175
- const key = `quest:${quest.agreementId}:${quest.ts}`;
184
+ for (const request of envelope.requestsAwaitingMe ?? []) {
185
+ const key = `request:${request.agreementId}:${request.ts}`;
176
186
  if (deps.seen.has(key))
177
187
  continue;
178
188
  deps.seen.mark(key);
179
- if (deps.onQuest) {
180
- const handled = await attempt(`quest ${quest.agreementId}`, () => deps.onQuest(quest), () => deps.seen.unmark(key));
189
+ if (deps.onRequest) {
190
+ const handled = await attempt(`request ${request.agreementId}`, () => deps.onRequest(request), () => deps.seen.unmark(key));
181
191
  if (handled)
182
- questsTriaged++;
192
+ requestsTriaged++;
183
193
  }
184
194
  else {
185
195
  // No handler → still count as handled (ack) so an old host cannot wedge
186
- // forever on quest mail it does not understand.
187
- questsTriaged++;
196
+ // forever on request mail it does not understand.
197
+ requestsTriaged++;
188
198
  }
189
199
  }
190
- if (envelope.truncatedQuests) {
191
- warn(label, `inbox truncatedQuests=${envelope.truncatedQuests} (more quests than one pass surfaces — the tail follows on the next pass)`);
200
+ if (envelope.truncatedRequests) {
201
+ warn(label, `inbox truncatedRequests=${envelope.truncatedRequests} (more requests than one pass surfaces — the tail follows on the next pass)`);
192
202
  }
193
203
  // Pending decisions are standing state, not deliveries: they keep appearing
194
204
  // until answered, so dedup them the same way tasks are deduped.
@@ -216,20 +226,23 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
216
226
  // this window has failed too many times in a row and is now poison, in which
217
227
  // case it is dropped loudly rather than wedging the agent forever.
218
228
  let acked = false;
219
- // ZIG-1305 — a capped envelope has unlisted older deliveries behind ackTo;
229
+ // A capped envelope has unlisted older deliveries behind ackTo;
220
230
  // acking would refuse or bury them. Hold until a later uncapped pass.
221
231
  if (envelope.deliveriesCapped && envelope.ackTo) {
222
- warn(label, `holding the watermark at ${envelope.ackTo} — deliveriesCapped; ack would not cover the unlisted tail (ZIG-1305)`);
232
+ warn(label, `holding the watermark at ${envelope.ackTo} — deliveriesCapped; ack would not cover the unlisted tail`);
223
233
  }
224
234
  else if (envelope.ackTo && (failures === 0 || opts.forceAck)) {
225
235
  if (failures > 0) {
226
236
  runtimeLog.error(label, `acking a window that failed ${failures} handover(s) — giving up on redelivery to keep the ` +
227
237
  `agent alive; this mail is lost (ackTo=${envelope.ackTo})`);
228
238
  }
239
+ // Assigned rows only: the server's bury-guard checks what was MINE to
240
+ // handle. Ambient rows slide past the cursor unlisted — they were never
241
+ // this agent's to act on, and the owner's seen mark tracks them.
229
242
  const handledResourceIds = [
230
243
  ...new Set([
231
- ...envelope.deliveries.map((d) => d.resourceId),
232
- ...(envelope.questsAwaitingMe ?? []).map((q) => q.agreementId),
244
+ ...envelope.deliveries.filter(mine).map((d) => d.resourceId),
245
+ ...(envelope.requestsAwaitingMe ?? []).map((q) => q.agreementId),
233
246
  ]),
234
247
  ].filter((id) => typeof id === 'string' && id.length > 0);
235
248
  await inbox.ack(envelope.ackTo, { handledResourceIds });
@@ -245,13 +258,18 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
245
258
  messagesDelivered,
246
259
  resourceEvents,
247
260
  tasksDelivered,
248
- questsTriaged,
261
+ requestsTriaged,
249
262
  acked,
250
263
  truncated: envelope.deliveriesCapped,
251
264
  failures,
252
265
  };
253
266
  }
254
- const DEFAULT_WAIT_SECONDS = 25;
267
+ /**
268
+ * Default long-poll hold for hosted agents. Matches the server's own ceiling,
269
+ * and assumes a proxy idle timeout comfortably above it rather than the 25s
270
+ * some defaults ship with. Lower it if your deployment sits behind a shorter one.
271
+ */
272
+ const DEFAULT_WAIT_SECONDS = 110;
255
273
  const ERROR_BACKOFF_BASE_MS = 1_000;
256
274
  const ERROR_BACKOFF_MAX_MS = 30_000;
257
275
  /**
@@ -41,6 +41,26 @@ export interface PollRunnerOptions {
41
41
  * through every single time.
42
42
  */
43
43
  wakeTimeoutMs?: number;
44
+ /**
45
+ * Most actionable wakes one binding may cause per {@link wakeWindowMs}.
46
+ * Default 20.
47
+ *
48
+ * A poll wakes its agent whenever the source says `actionable`, and each wake
49
+ * is a real model run. Nothing else bounds that: the agent polls the outside
50
+ * world and triggers itself, so the backend never sees it and the agreement
51
+ * wake ceiling never gets a chance to fire. A mailbox that stops being
52
+ * archived keeps answering "yes, there is unhandled mail" forever, at one wake
53
+ * per interval, indefinitely.
54
+ *
55
+ * 20 an hour is far above any healthy agent and roughly 20 minutes of a
56
+ * runaway at the 60s default interval.
57
+ *
58
+ * Deliberately a runner option and NOT read from the PollKind or the binding:
59
+ * a bound that the bounded thing can raise is not a bound.
60
+ */
61
+ maxWakesPerWindow?: number;
62
+ /** Window the wake cap is measured over. Default 1 hour. */
63
+ wakeWindowMs?: number;
44
64
  label?: string;
45
65
  }
46
66
  export declare class PollRunner {
@@ -49,9 +69,11 @@ export declare class PollRunner {
49
69
  private readonly label;
50
70
  private readonly maxBackoffMs;
51
71
  private readonly wakeTimeoutMs;
72
+ private readonly maxWakesPerWindow;
73
+ private readonly wakeWindowMs;
52
74
  private readonly loops;
53
75
  private running;
54
- constructor({ pool, registry, agents, operatorKey, defaultIntervalMs, maxBackoffMs, wakeTimeoutMs, label, }: PollRunnerOptions);
76
+ constructor({ pool, registry, agents, operatorKey, defaultIntervalMs, maxBackoffMs, wakeTimeoutMs, maxWakesPerWindow, wakeWindowMs, label, }: PollRunnerOptions);
55
77
  get size(): number;
56
78
  start(): void;
57
79
  stop(): void;
@@ -59,4 +81,17 @@ export declare class PollRunner {
59
81
  /** Interval while healthy; doubling up to the ceiling while failing. */
60
82
  private nextDelay;
61
83
  private tick;
84
+ /**
85
+ * Take one wake from this binding's budget, or refuse.
86
+ *
87
+ * Refusing is announced once per window, at error level, and resuming is
88
+ * announced too. A poll that simply goes quiet is worse than one that wakes
89
+ * too much: from the outside it is indistinguishable from a mailbox that is
90
+ * genuinely empty, so nobody looks.
91
+ *
92
+ * The source keeps being polled while capped. Stopping the poll as well would
93
+ * mean the first tick after the window rolls is the one that discovers the
94
+ * backlog, and a source that recovered would go unnoticed for a whole window.
95
+ */
96
+ private claimWake;
62
97
  }
@@ -26,14 +26,18 @@ export class PollRunner {
26
26
  label;
27
27
  maxBackoffMs;
28
28
  wakeTimeoutMs;
29
+ maxWakesPerWindow;
30
+ wakeWindowMs;
29
31
  loops = [];
30
32
  running = false;
31
- constructor({ pool, registry, agents, operatorKey, defaultIntervalMs = 60_000, maxBackoffMs = 15 * 60_000, wakeTimeoutMs = Number(process.env.CLAUDE_SDK_TICK_TIMEOUT_MS ?? 300_000), label = 'PollRunner', }) {
33
+ 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', }) {
32
34
  this.pool = pool;
33
35
  this.operatorKey = operatorKey;
34
36
  this.label = label;
35
37
  this.maxBackoffMs = maxBackoffMs;
36
38
  this.wakeTimeoutMs = wakeTimeoutMs;
39
+ this.maxWakesPerWindow = maxWakesPerWindow;
40
+ this.wakeWindowMs = wakeWindowMs;
37
41
  for (const config of agents) {
38
42
  const agentId = config['agentId'];
39
43
  if (typeof agentId !== 'string' || !agentId)
@@ -61,6 +65,9 @@ export class PollRunner {
61
65
  chatId: binding.chatId,
62
66
  timer: null,
63
67
  failures: 0,
68
+ wakes: 0,
69
+ windowStartedAt: Date.now(),
70
+ capped: false,
64
71
  });
65
72
  }
66
73
  }
@@ -115,7 +122,7 @@ export class PollRunner {
115
122
  this.schedule(loop);
116
123
  return;
117
124
  }
118
- if (result?.actionable) {
125
+ if (result?.actionable && this.claimWake(loop)) {
119
126
  const hint = result.hint ?? `New work from ${loop.kind.id}. Check it.`;
120
127
  try {
121
128
  // Awaited on purpose — see the header. The next tick waits for the
@@ -135,4 +142,37 @@ export class PollRunner {
135
142
  }
136
143
  this.schedule(loop);
137
144
  }
145
+ /**
146
+ * Take one wake from this binding's budget, or refuse.
147
+ *
148
+ * Refusing is announced once per window, at error level, and resuming is
149
+ * announced too. A poll that simply goes quiet is worse than one that wakes
150
+ * too much: from the outside it is indistinguishable from a mailbox that is
151
+ * genuinely empty, so nobody looks.
152
+ *
153
+ * The source keeps being polled while capped. Stopping the poll as well would
154
+ * mean the first tick after the window rolls is the one that discovers the
155
+ * backlog, and a source that recovered would go unnoticed for a whole window.
156
+ */
157
+ claimWake(loop) {
158
+ const now = Date.now();
159
+ if (now - loop.windowStartedAt >= this.wakeWindowMs) {
160
+ if (loop.capped) {
161
+ runtimeLog.info(this.label, `${loop.agentId}:${loop.kind.id} wake budget reset — waking again (cap ${this.maxWakesPerWindow}/${this.wakeWindowMs}ms)`);
162
+ }
163
+ loop.windowStartedAt = now;
164
+ loop.wakes = 0;
165
+ loop.capped = false;
166
+ }
167
+ if (loop.wakes >= this.maxWakesPerWindow) {
168
+ if (!loop.capped) {
169
+ loop.capped = true;
170
+ runtimeLog.error(this.label, `${loop.agentId}:${loop.kind.id} hit the wake cap (${this.maxWakesPerWindow} in ${this.wakeWindowMs}ms) and will NOT be woken again until the window rolls. ` +
171
+ `The source keeps reporting actionable, so either it is genuinely that busy or something downstream stopped clearing it.`);
172
+ }
173
+ return false;
174
+ }
175
+ loop.wakes += 1;
176
+ return true;
177
+ }
138
178
  }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * exact-match triage for marketplace requests.
3
+ * Empty match never hits (token-safe default). Comparison is exact after trim.
4
+ */
5
+ export declare function requestExactMatch(match: string, tags: ReadonlyArray<string | null | undefined>): boolean;
@@ -1,8 +1,8 @@
1
1
  /**
2
- * exact-match triage for marketplace quests.
2
+ * exact-match triage for marketplace requests.
3
3
  * Empty match never hits (token-safe default). Comparison is exact after trim.
4
4
  */
5
- export function questExactMatch(match, tags) {
5
+ export function requestExactMatch(match, tags) {
6
6
  const needle = typeof match === 'string' ? match.trim() : '';
7
7
  if (!needle)
8
8
  return false;
@@ -5,6 +5,14 @@ import { boundKeyFleetWarning } from '../shared/operatorKeyClaims.js';
5
5
  import { runtimeLog } from '../shared/runtimeLog.js';
6
6
  import { PollRegistry } from './polls/PollKind.js';
7
7
  import { PollRunner } from './polls/PollRunner.js';
8
+ /**
9
+ * How long a shutdown waits for in-flight turns after it has stopped taking new
10
+ * work. An agent turn is an LLM call, so the old 30s was under the length of the
11
+ * work it was meant to protect; the container's stop timeout is what has to be
12
+ * larger still, or the drain is cut short by SIGKILL regardless (ECS
13
+ * `stopTimeout`, 120s on the fleet task definitions).
14
+ */
15
+ const DRAIN_TIMEOUT_MS = Number(process.env.FLEET_DRAIN_TIMEOUT_MS) || 90_000;
8
16
  /**
9
17
  * Collect the poll kinds the agents themselves ship and start a runner for
10
18
  * whatever they declared. This lives in the launcher, not in a caller's
@@ -57,7 +65,7 @@ async function runSingle(config, { operatorKey, label = 'agent', port = process.
57
65
  runtimeLog.info(label, 'SIGTERM — shutting down');
58
66
  pollRunner?.stop();
59
67
  agent.disconnect();
60
- await agent.drain(30_000);
68
+ await agent.drain(DRAIN_TIMEOUT_MS);
61
69
  try {
62
70
  await onShutdown?.();
63
71
  }
@@ -122,10 +130,23 @@ async function runFleet(configs, { operatorKey, port = process.env.PORT || 8080,
122
130
  });
123
131
  process.on('SIGTERM', async () => {
124
132
  runtimeLog.info(label, 'SIGTERM — shutting down');
133
+ // Stop taking new work everywhere first, then wait for what is running.
134
+ //
135
+ // This used to disconnect and drain only `primary` — which `run.ts` never
136
+ // sets, so in the deployed fleet the drain was a no-op on null and the
137
+ // process exited straight through `disconnectAll`, killing every in-flight
138
+ // turn. A buyer whose request landed mid-rollout got a host that stopped
139
+ // mid-sentence and never reported.
140
+ //
141
+ // Draining before stopping intake is the other half of the same bug: the
142
+ // wait is exactly the window in which a host on its way out keeps accepting
143
+ // deliveries the incoming host is also being handed.
125
144
  pollRunner?.stop();
126
145
  primary?.disconnect?.();
127
- await primary?.drain?.(30_000);
128
- await pool.disconnectAll();
146
+ await Promise.all([
147
+ pool.shutdown(DRAIN_TIMEOUT_MS),
148
+ primary?.drain?.(DRAIN_TIMEOUT_MS) ?? Promise.resolve(),
149
+ ]);
129
150
  try {
130
151
  await onShutdown?.();
131
152
  }
@@ -8,10 +8,10 @@ export declare class TaskService {
8
8
  getActiveTasksForChat(chatId: string): Promise<Task[]>;
9
9
  getSubtasks(parentTaskId: string): Promise<Task[]>;
10
10
  spawnUnderAgreement(agreementId: string, taskData: Omit<CreateTaskData, 'agreementId'>): Promise<Task>;
11
- updateState(taskId: string, state: TaskState, result?: unknown, idempotencyKey?: string): Promise<Task>;
12
- updatePlanStep(taskId: string, stepId: string, status: string, result?: unknown): Promise<Task>;
13
- cancel(taskId: string): Promise<Task>;
14
- report(taskId: string, message?: string): Promise<Task>;
15
- setSatisfaction(taskId: string, satisfaction: 'positive' | 'negative'): Promise<Task>;
11
+ updateState(taskId: string, state: TaskState, result?: unknown, idempotencyKey?: string): Promise<import("@ziggs-ai/api-client").TaskWriteConfirm>;
12
+ updatePlanStep(taskId: string, stepId: string, status: string, result?: unknown): Promise<import("@ziggs-ai/api-client").TaskWriteConfirm>;
13
+ cancel(taskId: string): Promise<import("@ziggs-ai/api-client").TaskWriteConfirm>;
14
+ report(taskId: string, message?: string): Promise<import("@ziggs-ai/api-client").TaskWriteConfirm>;
15
+ setSatisfaction(taskId: string, satisfaction: 'positive' | 'negative'): Promise<import("@ziggs-ai/api-client").TaskWriteConfirm>;
16
16
  count(filters?: CountTasksFilters): Promise<number>;
17
17
  }
@@ -19,7 +19,7 @@ export { PROTOCOL_TOOLS, agreementProposeTool, agreementSubcontractTool, agreeme
19
19
  export { dispatchProtocolOp } from './tier1/protocolRunner.js';
20
20
  export { DISCOVERY_TOOLS, agentSearchTool, agentGetTool } from './tier2/discoveryTools.js';
21
21
  export { MARKETPLACE_TOOLS, marketplaceViewTool } from './tier2/marketplaceTools.js';
22
- export { PAYMENT_TOOLS, paymentBalanceTool, paymentTransferTool, paymentWaitForApprovalTool, paymentHoldTool, paymentReleaseTool, paymentResolveWalletTool, paymentIssueGrantTool, paymentAttenuateGrantTool, paymentRevokeGrantTool, } from './tier2/paymentTools.js';
22
+ export { PAYMENT_TOOLS, paymentBalanceTool } from './tier2/paymentTools.js';
23
23
  export { CONNECTION_TOOLS, connectionProxyTool, connectionListGrantsTool, requestConnectionTool, } from './tier2/connectionTools.js';
24
24
  export { CONTEXT_TOOLS, contextReadTool, contextDelegateTool, contextExpandReachTool, contextDiscoverGrantableTool, } from './tier2/contextTools.js';
25
25
  export { GRANTS_TOOLS, listGrantsTool } from './tier2/grantsTools.js';
@@ -21,7 +21,7 @@ export { dispatchProtocolOp } from './tier1/protocolRunner.js';
21
21
  // ── Tier 2: capability bundles ──────────────────────────────────────────────
22
22
  export { DISCOVERY_TOOLS, agentSearchTool, agentGetTool } from './tier2/discoveryTools.js';
23
23
  export { MARKETPLACE_TOOLS, marketplaceViewTool } from './tier2/marketplaceTools.js';
24
- export { PAYMENT_TOOLS, paymentBalanceTool, paymentTransferTool, paymentWaitForApprovalTool, paymentHoldTool, paymentReleaseTool, paymentResolveWalletTool, paymentIssueGrantTool, paymentAttenuateGrantTool, paymentRevokeGrantTool, } from './tier2/paymentTools.js';
24
+ export { PAYMENT_TOOLS, paymentBalanceTool } from './tier2/paymentTools.js';
25
25
  export { CONNECTION_TOOLS, connectionProxyTool, connectionListGrantsTool, requestConnectionTool, } from './tier2/connectionTools.js';
26
26
  export { CONTEXT_TOOLS, contextReadTool, contextDelegateTool, contextExpandReachTool, contextDiscoverGrantableTool, } from './tier2/contextTools.js';
27
27
  // one grant lister across every rail (retires context_discover +