@ziggs-ai/agent-sdk 0.42.0 → 0.43.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 (86) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +1 -1
  3. package/dist/Agent.d.ts +27 -8
  4. package/dist/Agent.js +188 -98
  5. package/dist/brain/ClaudeBrain.d.ts +0 -1
  6. package/dist/brain/ClaudeBrain.js +18 -42
  7. package/dist/brain/claudeCognition.js +5 -6
  8. package/dist/brain/claudeSdkProtocol.js +9 -9
  9. package/dist/brain/claudeSdkTools.d.ts +5 -5
  10. package/dist/brain/claudeSdkTools.js +40 -135
  11. package/dist/brain/hostedToolSurface.js +1 -1
  12. package/dist/brain/standingOfferNotice.d.ts +0 -2
  13. package/dist/brain/standingOfferNotice.js +1 -21
  14. package/dist/brain/toolManagerToSdkMcp.js +1 -1
  15. package/dist/brain/wakeGate.d.ts +3 -1
  16. package/dist/brain/wakeGate.js +6 -0
  17. package/dist/index.d.ts +4 -3
  18. package/dist/index.js +4 -3
  19. package/dist/ingress/normalizeIncoming.d.ts +5 -3
  20. package/dist/ingress/normalizeIncoming.js +9 -8
  21. package/dist/mcp/createMcpAgent.d.ts +15 -0
  22. package/dist/mcp/createMcpAgent.js +47 -11
  23. package/dist/memory/MemoryStore.d.ts +1 -1
  24. package/dist/memory/MemoryStore.js +1 -6
  25. package/dist/platform/ConnectionPool.js +1 -1
  26. package/dist/platform/OutboxBuffer.d.ts +2 -2
  27. package/dist/platform/OutboxBuffer.js +17 -17
  28. package/dist/platform/StateTimers.d.ts +3 -1
  29. package/dist/platform/StateTimers.js +4 -3
  30. package/dist/platform/WakeLedger.d.ts +10 -6
  31. package/dist/platform/WakeLedger.js +16 -9
  32. package/dist/platform/ZiggsEffectHandler.d.ts +18 -4
  33. package/dist/platform/ZiggsEffectHandler.js +86 -55
  34. package/dist/platform/agreements/AgreementService.d.ts +1 -3
  35. package/dist/platform/agreements/AgreementService.js +1 -7
  36. package/dist/platform/inboxLoop.d.ts +5 -9
  37. package/dist/platform/inboxLoop.js +165 -158
  38. package/dist/platform/polls/PollKind.d.ts +13 -16
  39. package/dist/platform/polls/PollKind.js +4 -4
  40. package/dist/platform/polls/PollRunner.d.ts +3 -20
  41. package/dist/platform/polls/PollRunner.js +13 -15
  42. package/dist/platform/polls/declaredPollRunner.d.ts +1 -1
  43. package/dist/platform/polls/declaredPollRunner.js +1 -1
  44. package/dist/platform/tasks/TaskService.d.ts +4 -3
  45. package/dist/platform/tasks/TaskService.js +1 -4
  46. package/dist/platform/telemetryIngest.js +4 -4
  47. package/dist/platform/tools/index.d.ts +1 -2
  48. package/dist/platform/tools/index.js +1 -4
  49. package/dist/platform/tools/tier1/protocolRunner.d.ts +1 -1
  50. package/dist/platform/tools/tier1/protocolRunner.js +24 -17
  51. package/dist/platform/tools/tier1/protocolTools.js +2 -2
  52. package/dist/platform/tools/tier1/wakeTools.d.ts +1 -1
  53. package/dist/platform/tools/tier1/wakeTools.js +2 -1
  54. package/dist/platform/tools/tier2/mcpConnectionTools.js +1 -1
  55. package/dist/platform/tools/tier2/taskTools.d.ts +1 -0
  56. package/dist/platform/tools/tier2/taskTools.js +2 -1
  57. package/dist/runtime/AgentMachine.d.ts +2 -2
  58. package/dist/runtime/AgentMachine.js +4 -5
  59. package/dist/runtime/PromptBuilder.d.ts +1 -1
  60. package/dist/runtime/PromptBuilder.js +5 -4
  61. package/dist/runtime/buildOutcome.d.ts +1 -1
  62. package/dist/runtime/deliveryWriteKeys.d.ts +1 -1
  63. package/dist/runtime/deliveryWriteKeys.js +1 -1
  64. package/dist/runtime/kickoff.d.ts +14 -43
  65. package/dist/runtime/kickoff.js +22 -178
  66. package/dist/runtime/messageCommand.d.ts +9 -0
  67. package/dist/runtime/messageCommand.js +23 -0
  68. package/dist/runtime/requiredContext.d.ts +6 -0
  69. package/dist/runtime/requiredContext.js +23 -0
  70. package/dist/runtime/runTurn.js +31 -106
  71. package/dist/runtime/taskIntake.js +8 -22
  72. package/dist/runtime/wakeEffects.d.ts +3 -12
  73. package/dist/runtime/wakeEffects.js +17 -48
  74. package/dist/runtime/wakeOutcome.js +1 -1
  75. package/dist/skills/createSkillAgent.js +12 -1
  76. package/dist/tools/capabilityAdapter.js +1 -1
  77. package/dist/tools/recordReport.d.ts +1 -1
  78. package/dist/tools/recordReport.js +3 -3
  79. package/dist/tools/toolCreds.d.ts +1 -1
  80. package/dist/tools/toolCreds.js +4 -2
  81. package/dist/types.d.ts +17 -27
  82. package/package.json +3 -3
  83. package/dist/platform/tools/tier2/chatTools.d.ts +0 -3
  84. package/dist/platform/tools/tier2/chatTools.js +0 -7
  85. package/dist/probeChat.d.ts +0 -10
  86. package/dist/probeChat.js +0 -12
@@ -1,9 +1,8 @@
1
+ import { prepareWorkExecution } from '@ziggs-ai/api-client';
1
2
  import { deliveryHandlingId, withWorkExecution } from '@ziggs-ai/api-client';
2
- import { ContextReadClient, InboxClient, planInboxAck, planPartialInboxAck, } from '@ziggs-ai/api-client';
3
+ import { MessageRecordsClient, InboxClient, planInboxAck, planPartialInboxAck, } from '@ziggs-ai/api-client';
3
4
  import { runtimeLog } from '../shared/runtimeLog.js';
4
5
  import { isRateLimited } from '@ziggs-ai/api-client';
5
- import { isPublishProbeChat } from '../probeChat.js';
6
- const READ_PAGE = 100;
7
6
  function log(label, msg) {
8
7
  runtimeLog.info(label, msg);
9
8
  }
@@ -26,21 +25,6 @@ async function releaseInboxHostBestEffort(deps) {
26
25
  function warn(label, msg) {
27
26
  runtimeLog.warn(label, msg);
28
27
  }
29
- /**
30
- * A capability probe waits on a `probe_*` chat reply, not inbox ack. Act
31
- * those rooms before leftover mail so a standing host answers the gate
32
- * even when the envelope also carries older chats. The prefix only orders
33
- * the wake — it does not buy a canned answer.
34
- */
35
- export function chatsInWakeOrder(chats) {
36
- return [...chats].sort((a, b) => {
37
- const ap = isPublishProbeChat(a.chatId) ? 0 : 1;
38
- const bp = isPublishProbeChat(b.chatId) ? 0 : 1;
39
- if (ap !== bp)
40
- return ap - bp;
41
- return a.latestAt < b.latestAt ? 1 : -1;
42
- });
43
- }
44
28
  /**
45
29
  * Standing proposals share the merged inbox visibility model with deliveries,
46
30
  * but older envelopes predate an explicit assignee stamp. Prefer the server's
@@ -55,84 +39,41 @@ function proposalIsMine(proposal, ownAgentId) {
55
39
  return (proposal.pendingApprovalPartyIds?.includes(ownAgentId) === true ||
56
40
  proposal.proposedTo === ownAgentId);
57
41
  }
58
- /**
59
- * Read the messages behind one chat's deliveries and hand them to the host.
60
- *
61
- * The envelope names exact assigned message ids, not their bodies — so this still
62
- * reads through `/context/read`, where the agent's own grants fence what it is
63
- * allowed to see. A delivery reference is an address, never an authorisation.
64
- */
65
- async function deliverMessagesForChat(deps, news, refs, reader, attempt, handledIds) {
66
- const pending = new Map(refs.map(ref => [ref.resourceId, ref]));
67
- for (const id of pending.keys()) {
68
- if (deps.seen.has(deliveryHandlingId(pending.get(id)))) {
69
- handledIds.add(deliveryHandlingId(pending.get(id)));
70
- pending.delete(id);
71
- }
42
+ /** Read exact assigned records. Historical lookup is optional context, never handoff. */
43
+ async function deliverAssignedMessage(deps, ref, reader, attempt, handledIds) {
44
+ // A pending answer comes from its message owner, not from a delivery, so it
45
+ // has no receipt to settle. Its eventId still names that exact slot, which
46
+ // is enough to avoid a second wake for it.
47
+ const receiptId = ref.receiptId ? deliveryHandlingId(ref) : undefined;
48
+ const key = receiptId ?? ref.eventId;
49
+ if (deps.seen.has(key)) {
50
+ if (receiptId)
51
+ handledIds.add(receiptId);
52
+ return false;
72
53
  }
73
- if (!pending.size)
74
- return 0;
75
- let delivered = 0;
76
- const earliest = refs.map(ref => ref.ts).sort()[0];
77
- let after = new Date(Date.parse(earliest) - 1).toISOString();
78
- let cursor;
79
- for (let pages = 0; pending.size && pages < 100; pages++) {
80
- let page;
81
- try {
82
- page = await reader.read('messages', {
83
- via: `chat:${news.chatId}`, after, direction: 'forward', limit: READ_PAGE,
84
- ...(cursor ? { cursor } : {}),
85
- });
86
- }
87
- catch (err) {
88
- const status = err.status;
89
- if (status !== 403 && status !== 404)
90
- throw err;
91
- // This read explicitly establishes that these deliveries are inaccessible.
92
- // The server also excludes out-of-reach rows from its acknowledgment guard.
93
- // A context refusal (including changed audience) must be retried, never acknowledged as read.
94
- if (refs.some(ref => ref.executionContext))
95
- throw err;
96
- for (const id of pending.keys())
97
- handledIds.add(id);
98
- return delivered;
99
- }
100
- for (const row of page.items) {
101
- const id = String(row.messageId ?? '');
102
- const ref = pending.get(id);
103
- if (!ref)
104
- continue;
105
- const handled = await attempt(`chat ${news.chatId} message ${id}`, () => deps.onMessage(typeof row.text === 'string' ? row.text : '', {
106
- ...row,
107
- chatId: news.chatId,
108
- // Preserve the message's addressee. Assignment separately authorizes
109
- // this carrier to handle it, including a person's stand-in.
110
- assigneeId: deps.ownAgentId,
111
- ...(ref.responsibilityId ? { responsibilityId: ref.responsibilityId, executionContext: ref.executionContext } : {}),
54
+ const handled = await attempt(`message ${ref.resourceId}`, () => (async () => {
55
+ if (!ref.action)
56
+ throw new Error('Assigned message has no domain action');
57
+ const execution = await prepareWorkExecution(ref.action, deps.creds.operatorKey, deps.ownAgentId, deps.baseUrl);
58
+ return withWorkExecution(execution, async () => {
59
+ const row = await reader.get(ref.resourceId);
60
+ const metadata = { messageId: row.messageId, ...(row.mandate ?? {}),
61
+ sender: { id: row.sender.ref, type: row.sender.kind }, receiver: { id: deps.ownAgentId, type: 'agent' },
62
+ entryType: 'message', timestamp: row.timestamp, roomless: true };
63
+ await deps.onMessage(row.content.text, {
64
+ ...metadata, assigneeId: deps.ownAgentId, ...(receiptId ? { receiptId } : {}),
65
+ executionContext: execution,
112
66
  ...(ref.agreementId ? { agreementId: ref.agreementId } : {}),
113
- }));
114
- if (handled) {
115
- delivered++;
116
- handledIds.add(deliveryHandlingId(ref));
117
- }
118
- pending.delete(id);
119
- }
120
- if (!page.hasMore)
121
- break;
122
- if (page.nextCursor && page.nextCursor !== cursor)
123
- cursor = page.nextCursor;
124
- else if (page.latestSequence && page.latestSequence > after) {
125
- after = page.latestSequence;
126
- cursor = undefined;
127
- }
128
- else
129
- break;
130
- }
131
- if (pending.size)
132
- await attempt(`chat ${news.chatId} missing assigned messages`, async () => {
133
- throw new Error(`Assigned message(s) not read: ${[...pending.keys()].join(', ')}`);
67
+ ...(ref.taskId ? { taskId: ref.taskId } : {}),
68
+ });
134
69
  });
135
- return delivered;
70
+ })());
71
+ if (handled) {
72
+ deps.seen.mark(key);
73
+ if (receiptId)
74
+ handledIds.add(receiptId);
75
+ }
76
+ return handled;
136
77
  }
137
78
  /**
138
79
  * One long-poll pass: hold up to `waitSeconds` for new inbox content, then read
@@ -181,7 +122,15 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
181
122
  // Everything else in the merged view (a sibling carrier's mail, the owner's
182
123
  // needs-human rows, granted ambient lanes) is context the host may read on
183
124
  // demand — never a wake, never mine to ack as handled.
184
- const mine = (d) => d.assigneeId === deps.ownAgentId;
125
+ //
126
+ // A responsibility is its receipt: without one the server cannot settle the
127
+ // row, so it is not mine to act on. One such row (written by an older
128
+ // server) must not fail the whole pass and stop every other delivery.
129
+ const unreceipted = envelope.deliveries.filter(d => d.assigneeId === deps.ownAgentId && !d.receiptId);
130
+ if (unreceipted.length) {
131
+ warn(label, `${unreceipted.length} assigned deliver${unreceipted.length === 1 ? 'y has' : 'ies have'} no receiptId; skipped (${unreceipted.map(d => `${d.kind} ${d.resourceId}`).join(', ')})`);
132
+ }
133
+ const mine = (d) => d.assigneeId === deps.ownAgentId && Boolean(d.receiptId);
185
134
  // Give the mark back in steps instead of once at the end of the pass.
186
135
  //
187
136
  // A pass that acks only when it finishes freezes the watermark for its whole
@@ -205,7 +154,23 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
205
154
  if (blocked) {
206
155
  log(label, `${blocked} assignment(s) cannot run yet (no access or execution refused); left pending for the server to offer again`);
207
156
  }
208
- const handledSoFar = new Set();
157
+ if (deps.onTaskControlCheck) {
158
+ const blockedTasks = new Set([
159
+ ...envelope.deliveries.filter(d => mine(d) && d.kind === 'task-state' && d.executionBlocked && !d.outOfReach).map(d => d.resourceId),
160
+ ...(envelope.tasksAwaitingMe ?? []).filter(t => t.executionBlocked).map(t => t.taskId),
161
+ ]);
162
+ for (const taskId of blockedTasks) {
163
+ try {
164
+ await deps.onTaskControlCheck(taskId);
165
+ }
166
+ catch (error) {
167
+ warn(label, `task control observation failed for ${taskId}: ${error.message}`);
168
+ }
169
+ }
170
+ }
171
+ // A receipt with no owner action is transport-only: acknowledge observation
172
+ // without creating model work from an FYI.
173
+ const handledSoFar = new Set(envelope.deliveries.filter(d => mine(d) && d.kind !== 'request' && (!d.action || d.actionState === 'settled') && !d.outOfReach && !d.executionBlocked).map(deliveryHandlingId));
209
174
  const acknowledged = new Set();
210
175
  // A handled id the server would not settle (it lost access mid-wake) stays
211
176
  // pending there and comes back once it can run; this pass is not redone.
@@ -237,43 +202,36 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
237
202
  warn(label, `mid-pass ack did not land: ${err?.message ?? err}`);
238
203
  }
239
204
  };
240
- const assignedByChat = new Map();
241
- for (const d of envelope.deliveries) {
242
- if (d.kind !== 'message' || !d.chatId || !mine(d) || d.outOfReach || d.executionBlocked)
243
- continue;
244
- const groupId = d.executionContext?.contextId ?? d.chatId;
245
- const rows = assignedByChat.get(groupId) ?? [];
246
- rows.push(d);
247
- assignedByChat.set(groupId, rows);
248
- }
249
- const chats = [...assignedByChat].map(([chatId, refs]) => ({
250
- chatId, count: refs.length, latestAt: refs.map(ref => ref.ts).sort().at(-1),
251
- }));
252
- for (const group of chatsInWakeOrder(chats)) {
253
- const refs = assignedByChat.get(group.chatId);
254
- const news = { ...group, chatId: refs[0].chatId };
255
- // Read a room's mail as that room's wake would: with the room as the lane.
256
- // Without it the read is judged in the workspace the key signed in to, and
257
- // a room another company opened with a listed agent is filed in theirs, so
258
- // every stranger's message came back 403 and was acked unread.
259
- const roomReader = new ContextReadClient(deps.creds.operatorKey, deps.creds.agentId, deps.baseUrl);
260
- const n = await withWorkExecution(refs[0]?.executionContext, () => deliverMessagesForChat(deps, news, refs, roomReader, attempt, handledSoFar));
261
- messagesDelivered += n;
205
+ // Every assigned message is fetched by resource identity. Navigation is optional.
206
+ const assigned = envelope.deliveries
207
+ .filter(d => d.kind === 'message' && !!d.action && d.action.kind !== 'task' && d.actionState !== 'settled' && d.actionState !== 'waiting' && mine(d) && !d.outOfReach && !d.executionBlocked)
208
+ .sort((a, b) => b.ts.localeCompare(a.ts));
209
+ const records = new MessageRecordsClient(deps.creds.operatorKey, deps.creds.agentId, deps.baseUrl);
210
+ for (const delivery of assigned) {
211
+ if (await deliverAssignedMessage(deps, delivery, records, attempt, handledSoFar))
212
+ messagesDelivered++;
262
213
  await ackWhatIsFinished();
263
214
  }
215
+ // Recover owner actions even after their transport receipt was acknowledged.
216
+ const deliveredAnswerKeys = new Set(assigned.map(delivery => JSON.stringify(delivery.action)));
217
+ for (const answer of envelope.answersAwaitingMe ?? []) {
218
+ if (!answer.action || answer.executionBlocked || answer.outOfReach || deliveredAnswerKeys.has(JSON.stringify(answer.action)))
219
+ continue;
220
+ if (await deliverAssignedMessage(deps, answer, records, attempt, new Set()))
221
+ messagesDelivered++;
222
+ }
264
223
  // Everything that is not chat text is handed over as a resource event, from
265
224
  // the delivery itself — the reference already carries the ids the host needs,
266
225
  // so nothing has to be re-derived to name what changed.
267
- // Requests must never take this path — even if a pre-cut server
268
- // left them in `deliveries`, skip so they cannot wake the LLM before triage.
226
+ // Requests have a dedicated triage path and tasks use their canonical executor.
269
227
  // Unassigned rows never take it either: waking the brain on a sibling's
270
228
  // mail is the N-wakes-per-event cost the party-owned inbox exists to kill.
271
229
  for (const d of envelope.deliveries) {
272
- if (d.kind === 'message' || d.kind === 'request')
230
+ if (d.kind === 'message' || d.kind === 'request' || d.action?.kind === 'task')
273
231
  continue;
274
- if (!mine(d) || d.executionBlocked || d.outOfReach)
232
+ if (!mine(d) || d.executionBlocked || d.outOfReach || !d.action || d.actionState === 'settled' || d.actionState === 'waiting')
275
233
  continue;
276
- const key = d.responsibilityId ?? `delivery:${d.kind}:${d.resourceId}:${d.ts}`;
234
+ const key = deliveryHandlingId(d);
277
235
  // Handled on an earlier pass — it still settles this instant, exactly as
278
236
  // it does in the end-of-pass list.
279
237
  if (deps.seen.has(key)) {
@@ -281,20 +239,24 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
281
239
  continue;
282
240
  }
283
241
  deps.seen.mark(key);
284
- const handled = await attempt(`${d.kind} ${d.resourceId}`, () => deps.onResourceEvent({
285
- kind: d.kind,
286
- ts: d.ts,
287
- resourceId: d.resourceId,
288
- ...(d.responsibilityId ? { responsibilityId: d.responsibilityId, executionContext: d.executionContext } : {}),
289
- ...(d.chatId ? { chatId: d.chatId } : {}),
290
- ...(d.agreementId ? { agreementId: d.agreementId } : {}),
291
- ...(d.taskId ? { taskId: d.taskId } : {}),
292
- // claim/listing wakes carry reason + claimer; host needs
293
- // both for agrn lane routing, claim kickoff, and chat_open buyer id.
294
- ...(d.reason != null && d.reason !== '' ? { reason: d.reason } : {}),
295
- ...(d.actorId ? { actorId: d.actorId } : {}),
296
- change: 'created',
297
- }), () => deps.seen.unmark(key));
242
+ const handled = await attempt(`${d.kind} ${d.resourceId}`, async () => {
243
+ const executionContext = await prepareWorkExecution(d.action, deps.creds.operatorKey, deps.ownAgentId, deps.baseUrl);
244
+ return deps.onResourceEvent({
245
+ kind: d.kind,
246
+ ts: d.ts,
247
+ resourceId: d.resourceId,
248
+ ...(d.receiptId ? { receiptId: d.receiptId, executionContext } : {}),
249
+ ...(d.addressId ? { addressId: d.addressId } : {}),
250
+ ...(d.agreementId ? { agreementId: d.agreementId } : {}),
251
+ ...(d.taskId ? { taskId: d.taskId } : {}),
252
+ // claim/listing wakes carry reason + claimer; host needs
253
+ // both for agrn lane routing, claim kickoff, and chat_open buyer id.
254
+ ...(d.reason != null && d.reason !== '' ? { reason: d.reason } : {}),
255
+ ...(d.actorId ? { actorId: d.actorId } : {}),
256
+ change: 'created',
257
+ executionContext,
258
+ });
259
+ }, () => deps.seen.unmark(key));
298
260
  if (handled) {
299
261
  resourceEvents++;
300
262
  handledSoFar.add(deliveryHandlingId(d));
@@ -302,23 +264,65 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
302
264
  }
303
265
  // An older row here may have been holding the mark back behind the chats.
304
266
  await ackWhatIsFinished();
305
- // the work channel. An open assigned task is redelivered on every
306
- // pass until it closes (that IS the durable-wait contract), so dedup on
307
- // taskId+updatedAt — a genuinely touched task wakes again, an untouched one
308
- // doesn't storm the lane every long-poll.
267
+ // One canonical task action, however many receipts announced changes to it.
268
+ // A receipt is acknowledged only after this action was handed to the task executor.
269
+ const taskActions = new Map();
309
270
  for (const task of envelope.tasksAwaitingMe ?? []) {
271
+ taskActions.set(task.taskId, { task, receipts: [] });
272
+ }
273
+ for (const receipt of envelope.deliveries) {
274
+ if (!mine(receipt) || receipt.action?.kind !== 'task' || receipt.outOfReach)
275
+ continue;
276
+ if (receipt.actionState === 'settled') {
277
+ // Domain state ended or reassigned this work. No model execution is needed.
278
+ handledSoFar.add(deliveryHandlingId(receipt));
279
+ continue;
280
+ }
281
+ if (receipt.executionBlocked || receipt.actionState !== 'ready')
282
+ continue;
283
+ const id = receipt.action.id;
284
+ const existing = taskActions.get(id);
285
+ const task = {
286
+ taskId: id, agreementId: receipt.agreementId,
287
+ title: existing?.task.title ?? id, state: 'active',
288
+ updatedAt: receipt.actionVersion ?? null,
289
+ executionContext: receipt.executionContext,
290
+ };
291
+ if (!existing)
292
+ taskActions.set(id, { task, receipts: [receipt] });
293
+ else {
294
+ existing.receipts.push(receipt);
295
+ // Prefer the freshest owner snapshot, not the event's source timestamp.
296
+ if ((task.updatedAt ?? '') >= (existing.task.updatedAt ?? ''))
297
+ existing.task = task;
298
+ }
299
+ }
300
+ for (const { task, receipts } of taskActions.values()) {
310
301
  if (task.executionBlocked)
311
302
  continue;
312
303
  const key = `task:${task.taskId}:${task.updatedAt ?? ''}:${task.executionContext?.contextId ?? ''}`;
313
- if (deps.seen.has(key))
304
+ const receiptKeys = receipts.map(receipt => `task-receipt:${deliveryHandlingId(receipt)}`);
305
+ // A timestamp is not a causal sequence: a new input can share its owner's
306
+ // version with an earlier handoff. Only suppress receipts actually handled.
307
+ if (deps.seen.has(key) && receiptKeys.every(id => deps.seen.has(id))) {
308
+ receipts.forEach(receipt => handledSoFar.add(deliveryHandlingId(receipt)));
314
309
  continue;
315
- deps.seen.mark(key);
316
- if (deps.onTask) {
317
- const handled = await attempt(`task ${task.taskId}`, () => withWorkExecution(task.executionContext, () => deps.onTask(task)), () => deps.seen.unmark(key));
318
- if (handled)
319
- tasksDelivered++;
320
310
  }
311
+ if (!deps.onTask)
312
+ continue; // Unsupported work stays pending; delivery is not completion.
313
+ const handled = await attempt(`task ${task.taskId}`, async () => {
314
+ const executionContext = await prepareWorkExecution({ kind: 'task', id: task.taskId }, deps.creds.operatorKey, deps.ownAgentId, deps.baseUrl);
315
+ return withWorkExecution(executionContext, () => deps.onTask({ ...task, executionContext }, receipts.map(receipt => ({ kind: receipt.kind, id: receipt.resourceId, eventId: receipt.eventId ?? receipt.receiptId }))));
316
+ });
317
+ if (handled) {
318
+ deps.seen.mark(key);
319
+ receiptKeys.forEach(id => deps.seen.mark(id));
320
+ tasksDelivered++;
321
+ receipts.forEach(receipt => handledSoFar.add(deliveryHandlingId(receipt)));
322
+ }
323
+ await ackWhatIsFinished();
321
324
  }
325
+ await ackWhatIsFinished();
322
326
  if (envelope.truncatedTasks) {
323
327
  warn(label, `inbox truncatedTasks=${envelope.truncatedTasks} (more assigned tasks than one pass surfaces — they appear as earlier ones close)`);
324
328
  }
@@ -327,7 +331,7 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
327
331
  for (const request of envelope.openRequestsAwaitingMe ?? []) {
328
332
  const key = `request:${request.agreementId}:${request.ts}`;
329
333
  if (deps.seen.has(key)) {
330
- handledSoFar.add(request.responsibilityId);
334
+ handledSoFar.add(request.receiptId);
331
335
  continue;
332
336
  }
333
337
  deps.seen.mark(key);
@@ -335,14 +339,14 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
335
339
  const handled = await attempt(`request ${request.agreementId}`, () => deps.onRequest(request), () => deps.seen.unmark(key));
336
340
  if (handled) {
337
341
  requestsTriaged++;
338
- handledSoFar.add(request.responsibilityId);
342
+ handledSoFar.add(request.receiptId);
339
343
  }
340
344
  }
341
345
  else {
342
346
  // No handler → still count as handled (ack) so an old host cannot wedge
343
347
  // forever on request mail it does not understand.
344
348
  requestsTriaged++;
345
- handledSoFar.add(request.responsibilityId);
349
+ handledSoFar.add(request.receiptId);
346
350
  }
347
351
  }
348
352
  // Pending decisions are standing state, not deliveries: they keep appearing
@@ -359,15 +363,18 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
359
363
  if (deps.seen.has(key))
360
364
  continue;
361
365
  deps.seen.mark(key);
362
- const handled = await attempt(`pending proposal ${proposal.agreementId}`, () => deps.onResourceEvent({
363
- kind: 'agreement',
364
- ts: proposal.proposedAt ?? new Date().toISOString(),
365
- resourceId: proposal.agreementId,
366
- agreementId: proposal.agreementId,
367
- change: 'created',
368
- reason: 'proposal_pending',
369
- ...(proposal.executionContext ? { executionContext: proposal.executionContext } : {}),
370
- }), () => deps.seen.unmark(key));
366
+ const handled = await attempt(`pending proposal ${proposal.agreementId}`, async () => {
367
+ const executionContext = await prepareWorkExecution({ kind: 'agreement', id: proposal.agreementId }, deps.creds.operatorKey, deps.ownAgentId, deps.baseUrl);
368
+ return deps.onResourceEvent({
369
+ kind: 'agreement',
370
+ ts: proposal.proposedAt ?? new Date().toISOString(),
371
+ resourceId: proposal.agreementId,
372
+ agreementId: proposal.agreementId,
373
+ change: 'created',
374
+ reason: 'proposal_pending',
375
+ executionContext,
376
+ });
377
+ }, () => deps.seen.unmark(key));
371
378
  if (handled)
372
379
  resourceEvents++;
373
380
  }
@@ -9,39 +9,36 @@
9
9
  * both inbox delivery and external polls; disconnect stops both. The process
10
10
  * runner does not own a second copy of the polling lifecycle.
11
11
  *
12
- * A poll answers ONE question: is there work for this agent right now. It never
13
- * returns the work itself. The agent reads that for itself once woken, through
14
- * its own tools, so it cannot end up with a second, differently-shaped copy of
15
- * its input and no way to tell which is current.
12
+ * A source may report an existing task action for authorized host processing.
13
+ * A mechanical source may dispatch through its existing domain owner and return
14
+ * actionable:false; the resulting owner delivery supplies work to its assignee.
15
+ * It never creates a room or borrows the execution that started the timer.
16
16
  *
17
17
  * WHERE THE CURSOR LIVES: in the source, whenever the source has one. Sente
18
18
  * has `archived` on a message, so "already handled" is a fact about the
19
19
  * mailbox, not something we mirror. A kind whose source has no such notion has
20
20
  * to keep a cursor, and then it is the kind's business — never the agent's.
21
21
  */
22
- /**
23
- * Kind-specific config, verbatim from the agent's manifest.
24
- *
25
- * `chatId` is not kind-specific and is not optional in practice: a wake has to
26
- * resolve to a lane or Agent's session resolver returns null and drops it
27
- * silently. A poll has no engagement behind it to derive one from, so the
28
- * binding names the chat the agent thinks in — which also puts its triage
29
- * somewhere a human can watch. PollRunner refuses to register a binding without
30
- * one rather than let the wake evaporate at runtime.
31
- */
22
+ /** Kind-specific source configuration. It carries no room or execution authority. */
32
23
  export interface PollBinding {
33
24
  kind: string;
34
- /** Chat the poll wake is delivered into. Required; see above. */
35
- chatId?: string;
36
25
  [key: string]: unknown;
37
26
  }
38
27
  export interface PollContext {
39
28
  agentId: string;
40
29
  operatorKey?: string;
30
+ /** False after disconnect, including an older connection after reconnect. */
31
+ isActive(): boolean;
41
32
  }
42
33
  export interface PollResult {
43
34
  /** Whether the agent should be woken now. */
44
35
  actionable: boolean;
36
+ /** A model wake must name existing task work; owner preparation can refuse it.
37
+ * Mechanical sources dispatch through their owner and return actionable:false. */
38
+ action?: {
39
+ kind: 'task';
40
+ id: string;
41
+ };
45
42
  /**
46
43
  * One line telling the agent why it woke. Carries no payload — see above.
47
44
  * Omit and the runner sends the kind's default.
@@ -9,10 +9,10 @@
9
9
  * both inbox delivery and external polls; disconnect stops both. The process
10
10
  * runner does not own a second copy of the polling lifecycle.
11
11
  *
12
- * A poll answers ONE question: is there work for this agent right now. It never
13
- * returns the work itself. The agent reads that for itself once woken, through
14
- * its own tools, so it cannot end up with a second, differently-shaped copy of
15
- * its input and no way to tell which is current.
12
+ * A source may report an existing task action for authorized host processing.
13
+ * A mechanical source may dispatch through its existing domain owner and return
14
+ * actionable:false; the resulting owner delivery supplies work to its assignee.
15
+ * It never creates a room or borrows the execution that started the timer.
16
16
  *
17
17
  * WHERE THE CURSOR LIVES: in the source, whenever the source has one. Sente
18
18
  * has `archived` on a message, so "already handled" is a fact about the
@@ -1,25 +1,8 @@
1
- /**
2
- * PollRunner — executes declared external wake sources for agents.
3
- *
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.
7
- *
8
- * One loop per (agent, binding), and each loop AWAITS its own wake before
9
- * scheduling the next tick. That is the whole backpressure story: while an
10
- * agent is still dealing with what it was woken for, its next poll has not
11
- * happened yet, so it cannot be woken twice for the same thing. No in-flight
12
- * bookkeeping, no dedup table — the source is authoritative and a poll that
13
- * has not run yet cannot report anything stale.
14
- *
15
- * A failing source backs off exponentially and recovers on the first success.
16
- * Without it a source that is down is hit at full rate indefinitely, which is
17
- * how you get rate-limited by the thing you were trying to read.
18
- */
1
+ import { type WorkExecutionContext } from '@ziggs-ai/api-client';
19
2
  import { type PollRegistry } from './PollKind.js';
20
- /** The slice of ConnectionPool this needs. Kept narrow to avoid a cycle. */
3
+ /** Existing task-owner wake entry; called only after exact execution preparation. */
21
4
  export interface WakeTarget {
22
- sendTo(agentId: string, text: string, metadata?: Record<string, unknown>, opts?: {
5
+ wakeTask(agentId: string, taskId: string, execution: WorkExecutionContext, opts?: {
23
6
  timeoutMs?: number;
24
7
  }): Promise<unknown>;
25
8
  }
@@ -1,3 +1,4 @@
1
+ import { prepareWorkExecution, withAutonomousRequest, withWorkExecution } from '@ziggs-ai/api-client';
1
2
  /**
2
3
  * PollRunner — executes declared external wake sources for agents.
3
4
  *
@@ -49,19 +50,11 @@ export class PollRunner {
49
50
  runtimeLog.warn(label, `${agentId} declares unknown poll kind "${binding.kind}" — it will never be woken by it (registered: ${registry.ids.join(', ') || 'none'})`);
50
51
  continue;
51
52
  }
52
- if (!binding.chatId) {
53
- // Refused at startup rather than dropped per-wake: without a lane the
54
- // host's session resolver returns null and the wake disappears with
55
- // only a debug line, which looks exactly like "the source is quiet".
56
- runtimeLog.warn(label, `${agentId} declares poll "${binding.kind}" without a chatId — refusing to register it, because every wake would be silently dropped`);
57
- continue;
58
- }
59
53
  this.loops.push({
60
54
  agentId,
61
55
  binding,
62
56
  kind,
63
57
  intervalMs: kind.intervalMs ?? defaultIntervalMs,
64
- chatId: binding.chatId,
65
58
  timer: null,
66
59
  failures: 0,
67
60
  inFlight: false,
@@ -104,7 +97,8 @@ export class PollRunner {
104
97
  loop.timer = setTimeout(() => {
105
98
  loop.timer = null;
106
99
  loop.inFlight = true;
107
- void this.tick(loop, this.generation).finally(() => {
100
+ const generation = this.generation;
101
+ void withAutonomousRequest(() => this.tick(loop, generation)).finally(() => {
108
102
  loop.inFlight = false;
109
103
  this.schedule(loop);
110
104
  });
@@ -123,6 +117,7 @@ export class PollRunner {
123
117
  try {
124
118
  result = await loop.kind.poll(loop.binding, {
125
119
  agentId: loop.agentId,
120
+ isActive: () => this.running && generation === this.generation,
126
121
  ...(this.operatorKey ? { operatorKey: this.operatorKey } : {}),
127
122
  });
128
123
  loop.failures = 0;
@@ -136,13 +131,16 @@ export class PollRunner {
136
131
  if (!this.running || generation !== this.generation)
137
132
  return;
138
133
  if (result?.actionable && this.claimWake(loop)) {
139
- const hint = result.hint ?? `New work from ${loop.kind.id}. Check it.`;
140
134
  try {
141
- // Awaited on purpose — see the header. The next tick waits for the
142
- // agent to finish, which is what stops a double wake. chatId is what
143
- // makes the wake resolve to a lane at all, and wakeTimeoutMs has to
144
- // outlast a real multi-turn wake or the await stops meaning anything.
145
- await this.pool.sendTo(loop.agentId, hint, { chatId: loop.chatId, pollKind: loop.kind.id }, { timeoutMs: this.wakeTimeoutMs });
135
+ if (!this.operatorKey || result.action?.kind !== 'task' || !result.action.id?.trim()) {
136
+ throw new Error('Poll model wake requires an authenticated exact task action; unbound wake refused');
137
+ }
138
+ const execution = await prepareWorkExecution(result.action, this.operatorKey, loop.agentId);
139
+ if (!this.running || generation !== this.generation)
140
+ return;
141
+ if (execution.taskId !== result.action.id)
142
+ throw new Error('Poll preparation returned different task work');
143
+ await withWorkExecution(execution, () => this.pool.wakeTask(loop.agentId, result.action.id, execution, { timeoutMs: this.wakeTimeoutMs }));
146
144
  runtimeLog.info(this.label, `woke ${loop.agentId} for ${loop.kind.id}`);
147
145
  }
148
146
  catch (err) {
@@ -4,7 +4,7 @@ import { PollRunner, type WakeTarget } from './PollRunner.js';
4
4
  * Build the runner that executes this agent's declared wakes.
5
5
  *
6
6
  * A malformed `polls` value is a programming error and still throws. A
7
- * binding that names an unknown kind or has no chatId is not: PollRunner
7
+ * binding that names an unknown kind is not: PollRunner
8
8
  * warns and skips it, so one agent's invalid poll cannot refuse the host.
9
9
  */
10
10
  export declare function createDeclaredPollRunner(options: AgentOptions, target: WakeTarget): PollRunner | null;
@@ -4,7 +4,7 @@ import { PollRunner } from './PollRunner.js';
4
4
  * Build the runner that executes this agent's declared wakes.
5
5
  *
6
6
  * A malformed `polls` value is a programming error and still throws. A
7
- * binding that names an unknown kind or has no chatId is not: PollRunner
7
+ * binding that names an unknown kind is not: PollRunner
8
8
  * warns and skips it, so one agent's invalid poll cannot refuse the host.
9
9
  */
10
10
  export function createDeclaredPollRunner(options, target) {