@ziggs-ai/agent-sdk 0.24.1 → 0.26.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.
@@ -49,90 +49,79 @@ function proposalIsMine(proposal, ownAgentId) {
49
49
  return (proposal.pendingApprovalPartyIds?.includes(ownAgentId) === true ||
50
50
  proposal.proposedTo === ownAgentId);
51
51
  }
52
- /**
53
- * The one hire a chat's assigned deliveries name, or null when the room
54
- * has none or more than one. Memory (and acting-for) treat "several
55
- * customers in one room" as the shared section.
56
- */
57
- export function soleHireAgreementIdForChat(deliveries, chatId, ownAgentId) {
58
- const ids = new Set();
59
- for (const d of deliveries) {
60
- if (d.chatId !== chatId || !d.agreementId)
61
- continue;
62
- if (d.assigneeId && d.assigneeId !== ownAgentId)
63
- continue;
64
- ids.add(d.agreementId);
65
- }
66
- return ids.size === 1 ? [...ids][0] : null;
67
- }
68
52
  /**
69
53
  * Read the messages behind one chat's deliveries and hand them to the host.
70
54
  *
71
- * The envelope says which chat and how many, not the bodies — so this still
55
+ * The envelope names exact assigned message ids, not their bodies — so this still
72
56
  * reads through `/context/read`, where the agent's own grants fence what it is
73
57
  * allowed to see. A delivery reference is an address, never an authorisation.
74
58
  */
75
- async function deliverMessagesForChat(deps, news, earliestTs, reader, attempt, hireAgreementId) {
59
+ async function deliverMessagesForChat(deps, news, refs, reader, attempt, handledIds) {
60
+ const pending = new Map(refs.map(ref => [ref.resourceId, ref]));
61
+ for (const id of pending.keys()) {
62
+ if (deps.seen.has(id)) {
63
+ pending.delete(id);
64
+ handledIds.add(id);
65
+ }
66
+ }
67
+ if (!pending.size)
68
+ return 0;
76
69
  let delivered = 0;
77
- let remaining = news.count;
78
- // `after` is exclusive, so step back a millisecond to include the oldest
79
- // delivery itself. Re-reading a neighbour that shares its timestamp is
80
- // harmless — `seen` dedups by messageId.
81
- let after = new Date(Date.parse(earliestTs) - 1).toISOString();
82
- let pages = 0;
83
- const maxPages = 20;
84
- while (remaining > 0 && pages < maxPages) {
85
- const limit = Math.min(READ_PAGE, remaining);
70
+ const earliest = refs.map(ref => ref.ts).sort()[0];
71
+ let after = new Date(Date.parse(earliest) - 1).toISOString();
72
+ let cursor;
73
+ for (let pages = 0; pending.size && pages < 100; pages++) {
86
74
  let page;
87
75
  try {
88
76
  page = await reader.read('messages', {
89
- via: `chat:${news.chatId}`,
90
- after,
91
- direction: 'forward',
92
- limit,
77
+ via: `chat:${news.chatId}`, after, direction: 'forward', limit: READ_PAGE,
78
+ ...(cursor ? { cursor } : {}),
93
79
  });
94
80
  }
95
81
  catch (err) {
96
82
  const status = err.status;
97
83
  if (status !== 403 && status !== 404)
98
84
  throw err;
99
- // Addressed but not readable. Since the delivery log and the grants are
100
- // now separate, the two can disagree — a grant revoked between the write
101
- // and this read, or a chat since deleted. Skipping keeps the pass alive
102
- // so the rest of the mail is delivered and the watermark still advances;
103
- // failing here would wedge the agent on one unreadable item forever,
104
- // re-woken by the same delivery on every pass.
105
- warn(deps.label ?? 'inboxLoop', `chat ${news.chatId}: addressed but not readable (${status}) — skipping ${remaining} delivery/deliveries`);
85
+ // This read explicitly establishes that these deliveries are inaccessible.
86
+ // The server also excludes out-of-reach rows from its acknowledgment guard.
87
+ for (const id of pending.keys())
88
+ handledIds.add(id);
106
89
  return delivered;
107
90
  }
108
91
  for (const row of page.items) {
109
- const messageId = String(row.messageId ?? '');
110
- if (deps.seen.has(messageId))
111
- continue;
112
- const text = typeof row.text === 'string' ? row.text : '';
113
- if (!text.trim())
92
+ const id = String(row.messageId ?? '');
93
+ const ref = pending.get(id);
94
+ if (!ref)
114
95
  continue;
115
- // The host un-marks its own `seen` entry when a message fails, so there is
116
- // nothing to forget here — but the failure still holds the watermark.
117
- const handled = await attempt(`chat ${news.chatId} message ${messageId}`, () => deps.onMessage(text, {
96
+ const handled = await attempt(`chat ${news.chatId} message ${id}`, () => deps.onMessage(typeof row.text === 'string' ? row.text : '', {
118
97
  ...row,
119
98
  chatId: news.chatId,
120
- // Assigned inbox mail is this agent's to answer. Chat `receiver`
121
- // is often the human the stand-in (or any assignee) is covering;
122
- // leaving it made ClaudeBrain treat the wake as notice-only.
123
- receiverId: deps.ownAgentId,
124
- receiver: { id: deps.ownAgentId },
125
- ...(hireAgreementId ? { agreementId: hireAgreementId } : {}),
99
+ // Preserve the message's addressee. Assignment separately authorizes
100
+ // this carrier to handle it, including a person's stand-in.
101
+ assigneeId: deps.ownAgentId,
102
+ ...(ref.agreementId ? { agreementId: ref.agreementId } : {}),
126
103
  }));
127
- if (handled)
104
+ if (handled) {
128
105
  delivered++;
129
- remaining--;
106
+ handledIds.add(id);
107
+ }
108
+ pending.delete(id);
130
109
  }
131
- if (!page.hasMore || !page.latestSequence)
110
+ if (!page.hasMore)
111
+ break;
112
+ if (page.nextCursor && page.nextCursor !== cursor)
113
+ cursor = page.nextCursor;
114
+ else if (page.latestSequence && page.latestSequence > after) {
115
+ after = page.latestSequence;
116
+ cursor = undefined;
117
+ }
118
+ else
132
119
  break;
133
- after = page.latestSequence;
134
- pages++;
135
120
  }
121
+ if (pending.size)
122
+ await attempt(`chat ${news.chatId} missing assigned messages`, async () => {
123
+ throw new Error(`Assigned message(s) not read: ${[...pending.keys()].join(', ')}`);
124
+ });
136
125
  return delivered;
137
126
  }
138
127
  /**
@@ -141,7 +130,7 @@ async function deliverMessagesForChat(deps, news, earliestTs, reader, attempt, h
141
130
  * Reused each iteration of {@link runInboxLoop}; also usable as a single catch-up
142
131
  * read (`waitSeconds: 0`) at bootstrap.
143
132
  */
144
- export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
133
+ export async function pollInboxOnce(deps, waitSeconds = 0, _opts = {}) {
145
134
  const label = deps.label ?? 'inboxLoop';
146
135
  const inbox = inboxClient(deps);
147
136
  const reader = new ContextReadClient(deps.creds.operatorKey, deps.creds.agentId, deps.baseUrl);
@@ -213,37 +202,21 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
213
202
  warn(label, `mid-pass ack did not land: ${err?.message ?? err}`);
214
203
  }
215
204
  };
216
- // Oldest ASSIGNED delivery per chat: where to start reading that chat's
217
- // messages. Ambient chat rows must not drag the read window earlier.
218
- const earliestByChat = new Map();
219
- const assignedRowsByChat = new Map();
205
+ const assignedByChat = new Map();
220
206
  for (const d of envelope.deliveries) {
221
- if (!d.chatId || !mine(d))
207
+ if (d.kind !== 'message' || !d.chatId || !mine(d) || d.outOfReach)
222
208
  continue;
223
- const seen = earliestByChat.get(d.chatId);
224
- if (!seen || d.ts < seen)
225
- earliestByChat.set(d.chatId, d.ts);
226
- const rows = assignedRowsByChat.get(d.chatId);
227
- if (rows)
228
- rows.push(d.resourceId);
229
- else
230
- assignedRowsByChat.set(d.chatId, [d.resourceId]);
209
+ const rows = assignedByChat.get(d.chatId) ?? [];
210
+ rows.push(d);
211
+ assignedByChat.set(d.chatId, rows);
231
212
  }
232
- for (const news of chatsInWakeOrder(envelope.chats ?? [])) {
233
- const earliest = earliestByChat.get(news.chatId) ?? news.latestAt;
234
- const failuresBefore = failures;
235
- const n = await deliverMessagesForChat(deps, news, earliest, reader, attempt, soleHireAgreementIdForChat(envelope.deliveries, news.chatId, deps.ownAgentId));
213
+ const chats = [...assignedByChat].map(([chatId, refs]) => ({
214
+ chatId, count: refs.length, latestAt: refs.map(ref => ref.ts).sort().at(-1),
215
+ }));
216
+ for (const news of chatsInWakeOrder(chats)) {
217
+ const n = await deliverMessagesForChat(deps, news, assignedByChat.get(news.chatId), reader, attempt, handledSoFar);
236
218
  messagesDelivered += n;
237
- if (n > 0)
238
- log(label, `chat ${news.chatId} delivered ${n} message(s)`);
239
- // A fold that dropped a handover has not finished its rows, and the
240
- // watermark must wait for the redelivery it is holding open.
241
- if (failures === failuresBefore) {
242
- for (const id of assignedRowsByChat.get(news.chatId) ?? []) {
243
- handledSoFar.add(id);
244
- }
245
- await ackWhatIsFinished();
246
- }
219
+ await ackWhatIsFinished();
247
220
  }
248
221
  // Everything that is not chat text is handed over as a resource event, from
249
222
  // the delivery itself — the reference already carries the ids the host needs,
@@ -355,23 +328,17 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
355
328
  // Act, then ack: everything above has been handed to the host, so the
356
329
  // watermark may now move past it. A crash before this point redelivers.
357
330
  //
358
- // "act" now means acted, not attempted. A pass with failures holds
359
- // the watermark so the server redelivers the window — unless `forceAck` says
360
- // this window has failed too many times in a row and is now poison, in which
361
- // case it is dropped loudly rather than wedging the agent forever.
331
+ // Advance only after every assigned id has an explicit disposition.
332
+ // Repeated failures never turn unread mail into an acknowledged delivery.
362
333
  // One function, shared with the MCP inbox tool — do not restate the list
363
334
  // or the allow-gate here.
364
335
  let acked = false;
365
336
  const ack = planInboxAck(envelope, {
366
337
  ownAgentId: deps.ownAgentId,
367
338
  failures,
368
- forceAck: opts.forceAck,
339
+ forceAck: false,
369
340
  });
370
- if (ack.allowed) {
371
- if (failures > 0) {
372
- runtimeLog.error(label, `acking a window that failed ${failures} handover(s) — giving up on redelivery to keep the ` +
373
- `agent alive; this mail is lost (ackTo=${envelope.ackTo})`);
374
- }
341
+ if (ack.allowed && ack.handledResourceIds.every(id => handledSoFar.has(id))) {
375
342
  await inbox.ack(envelope.ackTo, { handledResourceIds: ack.handledResourceIds });
376
343
  acked = true;
377
344
  log(label, `acked up to ${envelope.ackTo}`);
@@ -399,19 +366,7 @@ export async function pollInboxOnce(deps, waitSeconds = 0, opts = {}) {
399
366
  const DEFAULT_WAIT_SECONDS = 110;
400
367
  const ERROR_BACKOFF_BASE_MS = 1_000;
401
368
  const ERROR_BACKOFF_MAX_MS = 30_000;
402
- /**
403
- * How many passes in a row may hold the watermark before the window is treated
404
- * as poison and acked away. Three is enough for the failures we have
405
- * actually seen — a redeployed container, a wedged backend, a transient throw —
406
- * and short enough that a frame the host can never handle costs three wakes
407
- * rather than an unbounded loop.
408
- */
409
- const MAX_HELD_PASSES = 3;
410
- /**
411
- * How many consecutive passes have refused to advance the watermark.
412
- * A capped envelope used to reset the counter (zero handover failures),
413
- * so `forceAck` never fired on the one hold that lasts forever.
414
- */
369
+ /** Count held windows so retries can back off without discarding mail. */
415
370
  export function heldPassAfter(prev, pass) {
416
371
  return !pass.acked && (pass.failures > 0 || pass.truncated) ? prev + 1 : 0;
417
372
  }
@@ -443,17 +398,13 @@ export function runInboxLoop(deps, opts = {}) {
443
398
  while (running) {
444
399
  try {
445
400
  lastAttemptAt = Date.now();
446
- const pass = await pollInboxOnce(deps, first ? 0 : waitSeconds, {
447
- forceAck: heldPasses >= MAX_HELD_PASSES,
448
- });
401
+ const pass = await pollInboxOnce(deps, first ? 0 : waitSeconds);
449
402
  lastPassAt = Date.now();
450
403
  first = false;
451
404
  consecutiveErrors = 0;
452
- // A held pass means the same window comes back; count how many times we
453
- // have refused it so a permanently unhandleable frame cannot spin here.
454
- // Capped holds used to reset this: failures === 0, so the poison
455
- // escape never climbed. Count any pass that refused to advance.
456
405
  heldPasses = heldPassAfter(heldPasses, pass);
406
+ if (heldPasses)
407
+ await sleep(Math.min(ERROR_BACKOFF_MAX_MS, ERROR_BACKOFF_BASE_MS * 2 ** Math.min(heldPasses - 1, 5)));
457
408
  }
458
409
  catch (err) {
459
410
  if (!running)
@@ -21,7 +21,7 @@ export { DISCOVERY_TOOLS, agentSearchTool, agentGetTool } from './tier2/discover
21
21
  export { MARKETPLACE_TOOLS, marketplaceViewTool } from './tier2/marketplaceTools.js';
22
22
  export { PAYMENT_TOOLS, paymentBalanceTool } from './tier2/paymentTools.js';
23
23
  export { CONNECTION_TOOLS, connectionProxyTool, connectionListGrantsTool, requestConnectionTool, } from './tier2/connectionTools.js';
24
- export { CONTEXT_TOOLS, contextReadTool, contextRequestTool, contextDelegateTool, contextExpandReachTool, contextDiscoverGrantableTool, } from './tier2/contextTools.js';
24
+ export { CONTEXT_TOOLS, contextReadTool, contextRequestTool, accessRequestTool, contextDelegateTool, contextExpandReachTool, contextDiscoverGrantableTool, } from './tier2/contextTools.js';
25
25
  export { GRANTS_TOOLS, listGrantsTool } from './tier2/grantsTools.js';
26
26
  export { TASK_TOOLS, listTasksTool, cancelTaskTool } from './tier2/taskTools.js';
27
27
  export { PLAN_TOOLS, taskReplacePlanTool } from './tier2/planTools.js';
@@ -23,7 +23,7 @@ export { DISCOVERY_TOOLS, agentSearchTool, agentGetTool } from './tier2/discover
23
23
  export { MARKETPLACE_TOOLS, marketplaceViewTool } from './tier2/marketplaceTools.js';
24
24
  export { PAYMENT_TOOLS, paymentBalanceTool } from './tier2/paymentTools.js';
25
25
  export { CONNECTION_TOOLS, connectionProxyTool, connectionListGrantsTool, requestConnectionTool, } from './tier2/connectionTools.js';
26
- export { CONTEXT_TOOLS, contextReadTool, contextRequestTool, contextDelegateTool, contextExpandReachTool, contextDiscoverGrantableTool, } from './tier2/contextTools.js';
26
+ export { CONTEXT_TOOLS, contextReadTool, contextRequestTool, accessRequestTool, contextDelegateTool, contextExpandReachTool, contextDiscoverGrantableTool, } from './tier2/contextTools.js';
27
27
  // one grant lister across every rail (retires context_discover +
28
28
  // payment_list_grants). Always-available on the hosted agent — see CLAUDE_SDK_TOOLS.
29
29
  export { GRANTS_TOOLS, listGrantsTool } from './tier2/grantsTools.js';
@@ -2,6 +2,7 @@ import type { ToolDefinition } from '../../../tools/defineTool.js';
2
2
  export declare const contextReadTool: ToolDefinition;
3
3
  export declare const openTool: ToolDefinition;
4
4
  export declare const accessExplainTool: ToolDefinition;
5
+ export declare const accessRequestTool: ToolDefinition;
5
6
  export declare const contextRequestTool: ToolDefinition;
6
7
  export declare const contextDelegateTool: ToolDefinition;
7
8
  export declare const contextExpandReachTool: ToolDefinition;
@@ -1,4 +1,4 @@
1
- import { CONTEXT_CAPABILITIES, contextReadCapability, openCapability, accessExplainCapability, contextDelegateCapability, contextExpandReachCapability, contextDiscoverGrantableCapability, contextRequestCapability, } from '@ziggs-ai/api-client';
1
+ import { CONTEXT_CAPABILITIES, contextReadCapability, openCapability, accessExplainCapability, contextDelegateCapability, contextExpandReachCapability, contextDiscoverGrantableCapability, contextRequestCapability, accessRequestCapability, } from '@ziggs-ai/api-client';
2
2
  import { toolFromCapability, toolsFromCapabilities } from '../../../tools/capabilityAdapter.js';
3
3
  // context grant tools ride the same api-client
4
4
  // clients as ziggs-mcp; the whole definition (schema + handler) is now shared
@@ -6,6 +6,7 @@ import { toolFromCapability, toolsFromCapabilities } from '../../../tools/capabi
6
6
  export const contextReadTool = toolFromCapability(contextReadCapability);
7
7
  export const openTool = toolFromCapability(openCapability);
8
8
  export const accessExplainTool = toolFromCapability(accessExplainCapability);
9
+ export const accessRequestTool = toolFromCapability(accessRequestCapability);
9
10
  export const contextRequestTool = toolFromCapability(contextRequestCapability);
10
11
  export const contextDelegateTool = toolFromCapability(contextDelegateCapability);
11
12
  export const contextExpandReachTool = toolFromCapability(contextExpandReachCapability);
@@ -382,7 +382,7 @@ ${this._renderDecisionSchemaFromActions(actions, senderMeta, ctxObj, omitMessage
382
382
  lines.push('Return ONE JSON object. ALWAYS include "thought" as the FIRST field — briefly explain your reasoning before choosing an action.');
383
383
  lines.push('Set "action" to exactly one of the action names below.');
384
384
  lines.push('Message text is rendered as markdown (CommonMark + GFM). You MAY use **bold**, *italic*, `code`, fenced code blocks, lists, links, and tables when they improve clarity — plain text is also fine.\n');
385
- const replyTo = senderMeta.defaultReplyReceiverId || '<receiverId>';
385
+ const replyTo = '<receiverId from <recipients>>';
386
386
  const pendingProposals = (ctxObj.agreements || []).filter((a) => a.proposal?.status === 'pending' && (a.proposedToIsYou || isBroadcastTarget(a.parties?.proposedTo?.principal)));
387
387
  if (pendingProposals.length > 0) {
388
388
  lines.push(`PRIORITY: You have ${pendingProposals.length} pending proposal(s) directed at you. Respond BEFORE other actions.`);
@@ -439,23 +439,7 @@ ${this._renderDecisionSchemaFromActions(actions, senderMeta, ctxObj, omitMessage
439
439
  const senderId = String(ev?.senderId || 'system');
440
440
  const senderType = ev?.senderType ? String(ev.senderType).toUpperCase() : 'SYSTEM';
441
441
  const myId = this._getMyAgentId(ctxObj);
442
- const isNonRoutableSenderType = (t) => t === 'SYSTEM' || t === 'SERVICE';
443
- let defaultReplyReceiverId = senderId;
444
- const isNonRoutable = isNonRoutableSenderType(senderType);
445
- if (isNonRoutable) {
446
- const history = ctxObj.history || [];
447
- for (let i = history.length - 1; i >= 0; i--) {
448
- const row = history[i];
449
- const sender = row.sender;
450
- const id = String(sender?.id || '');
451
- const type = String(sender?.type || '').toUpperCase();
452
- if (id && id !== myId && !isNonRoutableSenderType(type)) {
453
- defaultReplyReceiverId = id;
454
- break;
455
- }
456
- }
457
- }
458
- return { senderId, senderType, myAgentId: myId, defaultReplyReceiverId: defaultReplyReceiverId || senderId };
442
+ return { senderId, senderType, myAgentId: myId };
459
443
  }
460
444
  _getMyAgentId(ctxObj) {
461
445
  const agents = ctxObj.agents || [];
@@ -36,6 +36,14 @@ export interface EmittedEvent {
36
36
  senderId?: string;
37
37
  [key: string]: unknown;
38
38
  }
39
+ /**
40
+ * A chat message whose stated receiver is a DIFFERENT party. `receiverId` is
41
+ * the sender's addressing decision, carried on every wire frame; the
42
+ * human-broadcast sentinel counts as "elsewhere" for an agent (a broadcast is
43
+ * addressed to the room's humans). A frame with NO receiver keeps the turn —
44
+ * we cannot know it is not ours, and skipping on absence would deafen agents
45
+ * to malformed or legacy frames.
46
+ */
39
47
  export declare function isMessageAddressedElsewhere(ev: RawEvent | null | undefined, ownAgentId: string | null | undefined): boolean;
40
48
  /**
41
49
  * Converts a raw incoming event into the single Outcome that re-enters the
@@ -9,24 +9,11 @@ import { bookForDelegation, ensureTaskBook, findBookHoldingPending, removeTaskBo
9
9
  * we cannot know it is not ours, and skipping on absence would deafen agents
10
10
  * to malformed or legacy frames.
11
11
  */
12
- function isSyntheticOrgReceiver(id) {
13
- if (/^([a-z0-9][a-z0-9-]*)\1@ziggs\.local$/i.test(id))
14
- return true;
15
- const sep = id.indexOf(':::');
16
- if (sep > 0)
17
- return /@ziggs\.local$/i.test(id.slice(sep + 3));
18
- return false;
19
- }
20
12
  export function isMessageAddressedElsewhere(ev, ownAgentId) {
21
13
  if (!ev || ev.type !== 'message' || !ownAgentId || !ev.receiverId) {
22
14
  return false;
23
15
  }
24
- if (ev.receiverId === ownAgentId)
25
- return false;
26
- // Fleet synthetic users are not a party that will answer. A hire room
27
- // born with the org ghost as the other face used to address the human's
28
- // message there, and the agent skipped the turn — claimed, acked, silence.
29
- if (isSyntheticOrgReceiver(String(ev.receiverId)))
16
+ if (ev.assigneeId === ownAgentId || ev.receiverId === ownAgentId)
30
17
  return false;
31
18
  return true;
32
19
  }
package/dist/types.d.ts CHANGED
@@ -515,6 +515,8 @@ export type Effect = {
515
515
  text: string;
516
516
  messageId?: string;
517
517
  conversationAgreementId?: string;
518
+ underAgreementId?: string;
519
+ taskId?: string;
518
520
  } | {
519
521
  kind: 'record-event';
520
522
  sessionId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ziggs-ai/agent-sdk",
3
- "version": "0.24.1",
3
+ "version": "0.26.0",
4
4
  "description": "Agent framework SDK for building autonomous agents on the Ziggs platform",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -26,7 +26,7 @@
26
26
  "@anthropic-ai/claude-agent-sdk": "^0.3.204",
27
27
  "@anthropic-ai/sdk": "^0.110.0",
28
28
  "@modelcontextprotocol/sdk": "^1.29.0",
29
- "@ziggs-ai/api-client": "0.23.1",
29
+ "@ziggs-ai/api-client": "0.25.0",
30
30
  "ajv": "^8.17.1",
31
31
  "dotenv": "^17.2.3",
32
32
  "openai": "^4.0.0",