@ziggs-ai/agent-sdk 0.2.2 → 0.3.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 (36) hide show
  1. package/README.md +1 -1
  2. package/dist/AgentHost.d.ts +12 -20
  3. package/dist/AgentHost.js +58 -136
  4. package/dist/agent/Agent.js +4 -0
  5. package/dist/agent/ClaudeSdkAgent.js +13 -4
  6. package/dist/agent/claudeSdkProtocol.d.ts +8 -7
  7. package/dist/agent/claudeSdkProtocol.js +8 -7
  8. package/dist/agent/claudeSdkTools.d.ts +4 -2
  9. package/dist/agent/claudeSdkTools.js +9 -4
  10. package/dist/agent/toolManagerToSdkMcp.js +1 -0
  11. package/dist/index.d.ts +3 -4
  12. package/dist/index.js +2 -2
  13. package/dist/ingress/normalizeIncoming.d.ts +17 -3
  14. package/dist/ingress/normalizeIncoming.js +11 -26
  15. package/dist/runtime/AgentMachine.d.ts +5 -2
  16. package/dist/runtime/AgentMachine.js +8 -2
  17. package/dist/runtime/runMechanical.d.ts +3 -1
  18. package/dist/runtime/runMechanical.js +2 -2
  19. package/dist/runtime/runTurn.d.ts +3 -1
  20. package/dist/runtime/runTurn.js +25 -7
  21. package/dist/runtime/wakeWriteKeys.d.ts +25 -0
  22. package/dist/runtime/wakeWriteKeys.js +134 -0
  23. package/dist/server/ConnectionPool.d.ts +7 -4
  24. package/dist/server/ConnectionPool.js +11 -14
  25. package/dist/server/ZiggsEffectHandler.js +17 -12
  26. package/dist/server/inboxLoop.d.ts +50 -0
  27. package/dist/server/{InboxCatchUp.js → inboxLoop.js} +63 -52
  28. package/dist/server/runLauncher.js +1 -1
  29. package/dist/server/tasks/TaskService.d.ts +2 -1
  30. package/dist/server/tasks/TaskService.js +5 -2
  31. package/dist/server/tools/tier1/protocolRunner.d.ts +2 -1
  32. package/dist/server/tools/tier1/protocolRunner.js +43 -9
  33. package/dist/server/tools/tier1/protocolTools.js +1 -1
  34. package/dist/types.d.ts +40 -0
  35. package/package.json +2 -2
  36. package/dist/server/InboxCatchUp.d.ts +0 -24
@@ -0,0 +1,50 @@
1
+ import { type Creds } from '@ziggs-ai/api-client';
2
+ import { SeenMessages } from './SeenMessages.js';
3
+ /**
4
+ * Per-agent inbox long-poll loop (delivery law, phase 4). This is the ONLY way
5
+ * a hosted agent consumes: `GET /inbox?wait=` → grant-fenced reads of the delta
6
+ * → deliver into the host's lane/tick machinery → `POST /inbox/ack`. No socket,
7
+ * no push, no separate catch-up channel — a crash-replay is just the next
8
+ * iteration (the cursor is server-side), and writes carry idempotency keys so
9
+ * re-processing a redelivered window is wasteful, never harmful. Act-before-ack
10
+ * is the one ordering rule.
11
+ */
12
+ export interface InboxLoopDeps {
13
+ creds: Creds;
14
+ ownAgentId: string;
15
+ baseUrl?: string;
16
+ seen: SeenMessages;
17
+ onMessage: (text: string, metadata: Record<string, unknown>) => Promise<void>;
18
+ onResourceEvent: (event: Record<string, unknown>) => Promise<void>;
19
+ label?: string;
20
+ }
21
+ export interface InboxPassResult {
22
+ scopes: number;
23
+ messagesDelivered: number;
24
+ resourceEvents: number;
25
+ acked: number;
26
+ truncated: boolean;
27
+ }
28
+ /**
29
+ * One long-poll pass: hold up to `waitSeconds` for new inbox content, then read
30
+ * the deltas, deliver them into the host, and ack. Returns what it delivered.
31
+ * Reused each iteration of {@link runInboxLoop}; also usable as a single catch-up
32
+ * read (`waitSeconds: 0`) at bootstrap.
33
+ */
34
+ export declare function pollInboxOnce(deps: InboxLoopDeps, waitSeconds?: number): Promise<InboxPassResult>;
35
+ export interface InboxLoopHandle {
36
+ /** Stop the loop after the current pass; resolves when it has fully exited. */
37
+ stop(): Promise<void>;
38
+ /** True while the loop is running. */
39
+ readonly running: boolean;
40
+ }
41
+ /**
42
+ * Run {@link pollInboxOnce} forever (until stopped). Each iteration long-polls
43
+ * for `waitSeconds`; a passing envelope returns instantly, an empty one holds.
44
+ * Errors retry with exponential backoff + jitter and never exit the loop —
45
+ * there is nothing to resynchronize (the cursor is server-side), so recovery is
46
+ * just the next iteration.
47
+ */
48
+ export declare function runInboxLoop(deps: InboxLoopDeps, opts?: {
49
+ waitSeconds?: number;
50
+ }): InboxLoopHandle;
@@ -1,4 +1,4 @@
1
- import { ContextReadClient, GrantsClient, InboxClient, getChatsForAgreement, getAgreementStatus, } from '@ziggs-ai/api-client';
1
+ import { ContextReadClient, GrantsClient, InboxClient, getChatsForAgreement, } from '@ziggs-ai/api-client';
2
2
  import { runtimeLog } from '../shared/runtimeLog.js';
3
3
  const READ_PAGE = 100;
4
4
  function log(label, msg) {
@@ -7,19 +7,6 @@ function log(label, msg) {
7
7
  function warn(label, msg) {
8
8
  runtimeLog.warn(label, msg);
9
9
  }
10
- const TERMINAL_AGREEMENT_STATUSES = new Set(['fulfilled', 'cancelled', 'refunded']);
11
- /** ZIG-832 P4: is this agreement finished (so its backlog must not be re-run)? */
12
- async function isTerminalAgreement(agreementId, creds) {
13
- try {
14
- const res = (await getAgreementStatus(agreementId, creds));
15
- const status = res?.status ?? res?.agreement?.status;
16
- return status != null && TERMINAL_AGREEMENT_STATUSES.has(status);
17
- }
18
- catch {
19
- // On lookup failure, don't skip — fall through to normal delivery.
20
- return false;
21
- }
22
- }
23
10
  /** Map Mongo message row → wire metadata for normalizeIncomingEvent. */
24
11
  function rowToMetadata(row, chatId) {
25
12
  const senderRaw = row.sender;
@@ -47,7 +34,7 @@ function rowToMetadata(row, chatId) {
47
34
  text: row.text,
48
35
  };
49
36
  }
50
- async function chatIdsForScope(scope, creds, baseUrl, grants) {
37
+ async function chatIdsForScope(scope, creds, grants) {
51
38
  const { kind, id } = scope.scope;
52
39
  switch (kind) {
53
40
  case 'chat':
@@ -57,8 +44,8 @@ async function chatIdsForScope(scope, creds, baseUrl, grants) {
57
44
  return [...new Set(links.map((l) => l.chatId).filter(Boolean))];
58
45
  }
59
46
  case 'org': {
60
- // All pages of live chat grants — an org-scope catch-up must not miss
61
- // chats hidden behind pagination (GET /grants defaults to one page).
47
+ // All pages of live chat grants — an org-scope read must not miss chats
48
+ // hidden behind pagination (GET /grants defaults to one page).
62
49
  const items = await grants.listAllGrants({
63
50
  scopeKind: 'chat',
64
51
  health: 'active',
@@ -71,7 +58,7 @@ async function chatIdsForScope(scope, creds, baseUrl, grants) {
71
58
  }
72
59
  async function deliverMessagesForScope(deps, scope, chatIds, reader, countCap) {
73
60
  let delivered = 0;
74
- const label = deps.label ?? 'InboxCatchUp';
61
+ const label = deps.label ?? 'inboxLoop';
75
62
  const expected = scope.newMessages;
76
63
  let remaining = expected >= countCap ? Number.POSITIVE_INFINITY : expected;
77
64
  for (const chatId of chatIds) {
@@ -113,49 +100,31 @@ async function deliverMessagesForScope(deps, scope, chatIds, reader, countCap) {
113
100
  return delivered;
114
101
  }
115
102
  /**
116
- * Pull-based recovery after reconnect (ZIG-434 / ZIG-446 / ZIG-454).
117
- * Inbox → read message deltas → deliver → ack. Push remains a hint;
118
- * this path survives backend restarts that drop in-memory pending queues.
103
+ * One long-poll pass: hold up to `waitSeconds` for new inbox content, then read
104
+ * the deltas, deliver them into the host, and ack. Returns what it delivered.
105
+ * Reused each iteration of {@link runInboxLoop}; also usable as a single catch-up
106
+ * read (`waitSeconds: 0`) at bootstrap.
119
107
  */
120
- export async function runInboxCatchUp(deps) {
121
- const label = deps.label ?? 'InboxCatchUp';
108
+ export async function pollInboxOnce(deps, waitSeconds = 0) {
109
+ const label = deps.label ?? 'inboxLoop';
122
110
  const inbox = new InboxClient(deps.creds.operatorKey, deps.creds.agentId, deps.baseUrl);
123
111
  const reader = new ContextReadClient(deps.creds.operatorKey, deps.creds.agentId, deps.baseUrl);
124
112
  const grants = new GrantsClient(deps.creds.operatorKey, deps.creds.agentId, deps.baseUrl);
125
- const envelope = await inbox.getInbox();
113
+ const envelope = await inbox.getInbox(waitSeconds > 0 ? { waitSeconds } : {});
126
114
  const countCap = envelope.countCap;
127
115
  let messagesDelivered = 0;
128
116
  let resourceEvents = 0;
129
117
  const toAck = [];
130
118
  if (envelope.truncatedScopes > 0) {
131
- // Informational only — does NOT block acking the scopes actually
132
- // returned below. truncatedScopes just means the caller's total reach
133
- // exceeded the server's per-call scope cap (backend/src/inbox/inbox.service.ts,
134
- // INBOX_MAX_SCOPES); each entry in envelope.scopes is still an
135
- // independently accurate, safe-to-ack unit of news for that scope. Never
136
- // acking here meant a busy long-lived agent (reach > cap) would never ack
137
- // anything and re-deliver its entire backlog on every reconnect forever.
119
+ // Informational only — does NOT block acking the scopes returned below.
120
+ // truncatedScopes just means the caller's total reach exceeded the server's
121
+ // per-call scope cap; each returned scope is still an independently accurate,
122
+ // safe-to-ack unit of news. Never acking here would make a busy agent
123
+ // (reach > cap) re-deliver its whole backlog every pass forever.
138
124
  warn(label, `inbox truncatedScopes=${envelope.truncatedScopes} (reach exceeds per-call cap — examined scopes are still acked normally)`);
139
125
  }
140
126
  for (const scope of envelope.scopes) {
141
- // ZIG-832 P4: a re-host runs catch-up from the server watermark and would
142
- // re-deliver an already-finished agreement's backlog, re-running a completed
143
- // pipeline and burning tokens. If this is an agreement scope that has reached
144
- // a terminal status, ack it (so it drops out of future catch-ups) and skip
145
- // re-delivery. Only agreement scopes — chat scopes may carry genuinely new
146
- // conversation, so those are never skipped on status. (The deeper fix, acking
147
- // the watermark on the live-push path so nothing re-delivers, is tracked
148
- // separately.)
149
- if (scope.scope.kind === 'agreement' &&
150
- (scope.newMessages > 0 || scope.newArtifacts > 0) &&
151
- (await isTerminalAgreement(scope.scope.id, deps.creds))) {
152
- if (scope.latestAt) {
153
- toAck.push({ kind: scope.scope.kind, id: scope.scope.id, upTo: scope.latestAt });
154
- }
155
- log(label, `scope agreement:${scope.scope.id} terminal — acked, not re-delivered`);
156
- continue;
157
- }
158
- const chatIds = await chatIdsForScope(scope, deps.creds, deps.baseUrl, grants);
127
+ const chatIds = await chatIdsForScope(scope, deps.creds, grants);
159
128
  if (!chatIds.length && (scope.newMessages > 0 || scope.newArtifacts > 0)) {
160
129
  warn(label, `no chat ids for scope ${scope.scope.kind}:${scope.scope.id} (${scope.newMessages} msgs)`);
161
130
  }
@@ -201,9 +170,6 @@ export async function runInboxCatchUp(deps) {
201
170
  acked = toAck.length;
202
171
  log(label, `acked ${acked} scope(s)`);
203
172
  }
204
- if (messagesDelivered > 0 || resourceEvents > 0) {
205
- log(label, `catch-up complete: ${messagesDelivered} message(s), ${resourceEvents} resource hint(s)`);
206
- }
207
173
  return {
208
174
  scopes: envelope.scopes.length,
209
175
  messagesDelivered,
@@ -212,3 +178,48 @@ export async function runInboxCatchUp(deps) {
212
178
  truncated: envelope.truncatedScopes > 0,
213
179
  };
214
180
  }
181
+ const DEFAULT_WAIT_SECONDS = 25;
182
+ const ERROR_BACKOFF_BASE_MS = 1_000;
183
+ const ERROR_BACKOFF_MAX_MS = 30_000;
184
+ function sleep(ms) {
185
+ return new Promise((resolve) => setTimeout(resolve, ms));
186
+ }
187
+ /**
188
+ * Run {@link pollInboxOnce} forever (until stopped). Each iteration long-polls
189
+ * for `waitSeconds`; a passing envelope returns instantly, an empty one holds.
190
+ * Errors retry with exponential backoff + jitter and never exit the loop —
191
+ * there is nothing to resynchronize (the cursor is server-side), so recovery is
192
+ * just the next iteration.
193
+ */
194
+ export function runInboxLoop(deps, opts = {}) {
195
+ const label = deps.label ?? 'inboxLoop';
196
+ const waitSeconds = opts.waitSeconds ?? DEFAULT_WAIT_SECONDS;
197
+ let running = true;
198
+ let consecutiveErrors = 0;
199
+ const done = (async () => {
200
+ while (running) {
201
+ try {
202
+ await pollInboxOnce(deps, waitSeconds);
203
+ consecutiveErrors = 0;
204
+ }
205
+ catch (err) {
206
+ if (!running)
207
+ break;
208
+ consecutiveErrors++;
209
+ const backoff = Math.min(ERROR_BACKOFF_MAX_MS, ERROR_BACKOFF_BASE_MS * 2 ** (consecutiveErrors - 1));
210
+ const jitter = backoff * (0.5 + (consecutiveErrors % 7) / 14); // deterministic-ish, no Math.random
211
+ warn(label, `inbox loop pass failed (#${consecutiveErrors}): ${err?.message ?? err} — retrying in ~${Math.round(jitter)}ms`);
212
+ await sleep(jitter);
213
+ }
214
+ }
215
+ })();
216
+ return {
217
+ get running() {
218
+ return running;
219
+ },
220
+ async stop() {
221
+ running = false;
222
+ await done.catch(() => { });
223
+ },
224
+ };
225
+ }
@@ -53,7 +53,7 @@ async function runFleet(configs, { operatorKey, wsUrl = process.env.WS_URL, port
53
53
  pool.startControl({ wsUrl, operatorKey: controlKey });
54
54
  }
55
55
  else {
56
- runtimeLog.warn(label, 'No operatorKey provided — skipping control socket');
56
+ runtimeLog.warn(label, 'No operatorKey provided — skipping fleet inbox poll (no agents will be lazily started)');
57
57
  }
58
58
  let primary = null;
59
59
  if (resolvedPrimaryConfig) {
@@ -15,8 +15,9 @@ export declare class TaskService {
15
15
  requireMidWorkPlanAck?: boolean;
16
16
  assigneeId?: string;
17
17
  inputArtifactIds?: string[];
18
+ idempotencyKey?: string;
18
19
  }): Promise<Task>;
19
- updateState(taskId: string, state: TaskState, result?: unknown): Promise<Task>;
20
+ updateState(taskId: string, state: TaskState, result?: unknown, idempotencyKey?: string): Promise<Task>;
20
21
  updatePlanStep(taskId: string, stepId: string, status: string, result?: unknown): Promise<Task>;
21
22
  cancel(taskId: string): Promise<Task>;
22
23
  report(taskId: string, message?: string): Promise<Task>;
@@ -57,9 +57,12 @@ export class TaskService {
57
57
  throw error;
58
58
  }
59
59
  }
60
- async updateState(taskId, state, result) {
60
+ async updateState(taskId, state, result, idempotencyKey) {
61
61
  try {
62
- const data = result !== undefined ? { result: safeJsonStringify(result) } : {};
62
+ const data = {
63
+ ...(result !== undefined ? { result: safeJsonStringify(result) } : {}),
64
+ ...(idempotencyKey ? { idempotencyKey } : {}),
65
+ };
63
66
  const updated = await updateTaskState(taskId, state, data, this.creds);
64
67
  runtimeLog.info('TaskService', `Task state: ${taskId} → ${state}`);
65
68
  return updated;
@@ -1,5 +1,6 @@
1
1
  import type { AgreementService } from '../../agreements/AgreementService.js';
2
2
  import type { TaskService } from '../../tasks/TaskService.js';
3
+ import type { WakeWriteKeys } from '../../../types.js';
3
4
  import type { ContextReadClient } from '@ziggs-ai/api-client';
4
5
  interface Services {
5
6
  agreementService: AgreementService;
@@ -35,5 +36,5 @@ interface ProtocolPayload {
35
36
  })[];
36
37
  [key: string]: unknown;
37
38
  }
38
- export declare function dispatchProtocolOp(services: Services, payload: ProtocolPayload, chatId: string | null, agents?: unknown[], actingAgentId?: string | null): Promise<unknown>;
39
+ export declare function dispatchProtocolOp(services: Services, payload: ProtocolPayload, chatId: string | null, agents?: unknown[], actingAgentId?: string | null, wakeKeys?: WakeWriteKeys | null): Promise<unknown>;
39
40
  export {};
@@ -34,7 +34,7 @@ function normalizePlanInPlace(payload) {
34
34
  payload.planReviewTiming = 'with_proposal';
35
35
  }
36
36
  }
37
- export async function dispatchProtocolOp(services, payload, chatId, agents = [], actingAgentId = null) {
37
+ export async function dispatchProtocolOp(services, payload, chatId, agents = [], actingAgentId = null, wakeKeys = null) {
38
38
  if (!payload?.operation)
39
39
  return null;
40
40
  normalizePlanInPlace(payload);
@@ -43,9 +43,9 @@ export async function dispatchProtocolOp(services, payload, chatId, agents = [],
43
43
  const actorId = actingAgentId?.trim() || null;
44
44
  switch (operation) {
45
45
  case 'agreement-propose':
46
- return proposeAgreement(services, payload, chatId, agents, actorId);
46
+ return proposeAgreement(services, payload, chatId, agents, actorId, wakeKeys);
47
47
  case 'agreement-subcontract':
48
- return subcontractAgreement(services, payload, chatId, agents, actorId);
48
+ return subcontractAgreement(services, payload, chatId, agents, actorId, wakeKeys);
49
49
  case 'agreement-respond':
50
50
  if (!payload.agreementId || !payload.action)
51
51
  return null;
@@ -146,12 +146,27 @@ export async function dispatchProtocolOp(services, payload, chatId, agents = [],
146
146
  inputArtifactIds: Array.isArray(payload.inputArtifactIds)
147
147
  ? payload.inputArtifactIds
148
148
  : undefined,
149
+ // Wake-derived idempotency key (delivery law): a crash-replay re-issuing
150
+ // this spawn resolves to the already-created task server-side instead of
151
+ // creating a duplicate. Keyed on what is actually written (the resolved
152
+ // agreement, not the LLM-cited one), so multiple spawns in one wake stay
153
+ // distinct.
154
+ idempotencyKey: wakeKeys?.taskSpawnKey({
155
+ agreementId: spawnAgreementId,
156
+ description: payload.description,
157
+ assigneeId: payload.assigneeId,
158
+ parentTaskId: payload.parentTaskId,
159
+ }),
149
160
  });
150
161
  }
151
- case 'task-update':
162
+ case 'task-update': {
152
163
  if (!payload.taskId || !payload.status)
153
164
  return null;
154
- return taskService.updateState(payload.taskId, normalizeState(payload.status), payload.result);
165
+ const state = normalizeState(payload.status);
166
+ // Wake-derived idempotency key: a crash-replay re-issuing this transition
167
+ // no-ops server-side instead of erroring on the already-terminal task.
168
+ return taskService.updateState(payload.taskId, state, payload.result, wakeKeys?.taskStateKey(payload.taskId, state));
169
+ }
155
170
  case 'task-update-plan-step':
156
171
  if (!payload.taskId)
157
172
  throw new Error('task-update-plan-step requires `taskId`');
@@ -173,7 +188,7 @@ export async function dispatchProtocolOp(services, payload, chatId, agents = [],
173
188
  throw new Error(`Unknown protocol operation: ${operation}`);
174
189
  }
175
190
  }
176
- async function proposeAgreement(services, payload, chatId, _agents, actingAgentId) {
191
+ async function proposeAgreement(services, payload, chatId, _agents, actingAgentId, wakeKeys = null) {
177
192
  if (!payload.description)
178
193
  throw new Error('agreement-propose requires `description`');
179
194
  if (!payload.proposedTo)
@@ -187,7 +202,17 @@ async function proposeAgreement(services, payload, chatId, _agents, actingAgentI
187
202
  }
188
203
  const priced = applyFleetPricingForProtocol('agreement-propose', payload, actingAgentId);
189
204
  const { operation: _op, ...fields } = priced;
190
- return services.agreementService.propose({ ...fields, chatId });
205
+ // Wake-derived idempotency key (delivery law): a crash-replay re-issuing this
206
+ // propose resolves to the already-created agreement server-side instead of
207
+ // minting a duplicate proposal. Keyed on what the proposal writes, so
208
+ // multiple proposals in one wake stay distinct.
209
+ const idempotencyKey = wakeKeys?.agreementProposeKey({
210
+ proposedTo: payload.proposedTo,
211
+ providerId: payload.providerId,
212
+ description: payload.description,
213
+ parentAgreementId: payload.parentAgreementId,
214
+ });
215
+ return services.agreementService.propose({ ...fields, chatId, idempotencyKey });
191
216
  }
192
217
  // ── ZIG-832: deterministic sub-agreement resolution ─────────────────────────
193
218
  // The orchestrator delegates one task per worker, but the LLM inconsistently
@@ -228,7 +253,7 @@ function agActive(a) {
228
253
  function agIsActiveStatus(a) {
229
254
  return String(a.status ?? '') === 'active';
230
255
  }
231
- async function subcontractAgreement(services, payload, chatId, _agents, actingAgentId) {
256
+ async function subcontractAgreement(services, payload, chatId, _agents, actingAgentId, wakeKeys = null) {
232
257
  if (!payload.description || !payload.executorId)
233
258
  return null;
234
259
  // b1 (ZIG-832): idempotent subcontract — if a non-terminal sub-agreement for
@@ -284,5 +309,14 @@ async function subcontractAgreement(services, payload, chatId, _agents, actingAg
284
309
  f.lifecycle = 'count-bound';
285
310
  f.maxExecutions = 1;
286
311
  }
287
- return services.agreementService.delegate({ ...fields, chatId });
312
+ // Wake-derived idempotency key (delivery law): a crash-replay that slips past
313
+ // the b1 reuse check above (e.g. no chatId, or the sub not yet visible in the
314
+ // snapshot) resolves to the already-created sub-agreement server-side instead
315
+ // of minting a duplicate.
316
+ const idempotencyKey = wakeKeys?.agreementSubcontractKey({
317
+ executorId: payload.executorId,
318
+ description: payload.description,
319
+ parentAgreementId: payload.parentAgreementId ?? '',
320
+ });
321
+ return services.agreementService.delegate({ ...fields, chatId, idempotencyKey });
288
322
  }
@@ -13,7 +13,7 @@ function handlerFor(operation) {
13
13
  taskService: c.taskService,
14
14
  agreementService: c.agreementService,
15
15
  contextReadClient: c.contextReadClient,
16
- }, { operation, ...args }, c.chatId ?? null, c.agents ?? [], typeof c.agentId === 'string' ? c.agentId : null);
16
+ }, { operation, ...args }, c.chatId ?? null, c.agents ?? [], typeof c.agentId === 'string' ? c.agentId : null, c.wakeKeys ?? null);
17
17
  if (result == null)
18
18
  throw new Error(`Operation ${operation} returned no result`);
19
19
  return result;
package/dist/types.d.ts CHANGED
@@ -346,6 +346,43 @@ export interface RecordedEntry {
346
346
  text?: string;
347
347
  [k: string]: unknown;
348
348
  }
349
+ /**
350
+ * Deterministic idempotency keys for one wake's writes (delivery law, item 2).
351
+ * Created once per tick from (laneKey, triggering event) — see
352
+ * `createWakeWriteKeys` — and threaded to every artifact / task-state write the
353
+ * wake performs, so a crash-replay of the same wake reproduces the same keys
354
+ * and the backend drops the duplicates. Never LLM-supplied, never random.
355
+ */
356
+ export interface WakeWriteKeys {
357
+ /** Stable id of this wake, or null when its trigger has no durable delivery identity (timer/synthetic frames) — null means writes go keyless, as before. */
358
+ readonly wakeKey: string | null;
359
+ /** Key for the next artifact write of this wake. Draws a per-wake ordinal — call exactly once per write, at the call site. */
360
+ artifactKey(): string | undefined;
361
+ /** Key for a task state transition. Derived from (taskId, state), so it is stable regardless of tool-call order within the wake. */
362
+ taskStateKey(taskId: string, state: string): string | undefined;
363
+ /** Default messageId for the next chat-message send of this wake — the backend dedups on (chatId, messageId). Draws the same per-wake ordinal as artifactKey — call exactly once per send, at the call site. */
364
+ messageKey(): string | undefined;
365
+ /** Key for a task spawn. Derived from what the spawn writes (agreement, description, assignee, parent), so it is stable regardless of tool-call order within the wake. */
366
+ taskSpawnKey(fields: {
367
+ agreementId: string;
368
+ description: string;
369
+ assigneeId?: string;
370
+ parentTaskId?: string;
371
+ }): string | undefined;
372
+ /** Key for an agreement proposal (`agreement_propose`). Derived from what the proposal writes (proposedTo, provider, description, parent), so a crash-replay dedups the row instead of minting a duplicate proposal — and multiple proposals in one wake stay distinct. */
373
+ agreementProposeKey(fields: {
374
+ proposedTo: string;
375
+ providerId?: string;
376
+ description: string;
377
+ parentAgreementId?: string;
378
+ }): string | undefined;
379
+ /** Key for a sub-agreement (`agreement_subcontract`). Derived from what the subcontract writes (executor, description, parent), so a replay dedups the row. */
380
+ agreementSubcontractKey(fields: {
381
+ executorId: string;
382
+ description: string;
383
+ parentAgreementId: string;
384
+ }): string | undefined;
385
+ }
349
386
  import type { MemoryStore } from './memory/MemoryStore.js';
350
387
  export type EffectKind = 'read-context' | 'llm-call' | 'stream-text' | 'tool-call' | 'send-message' | 'record-event';
351
388
  export type Effect = {
@@ -388,6 +425,8 @@ export type Effect = {
388
425
  runId?: string;
389
426
  /** FSM state name (ZIG-440). */
390
427
  stateId?: string;
428
+ /** Replay-stable write keys for this wake — write-capable tools derive their idempotency key from here, never from the LLM. */
429
+ wakeKeys?: WakeWriteKeys;
391
430
  } | {
392
431
  kind: 'send-message';
393
432
  sessionId: string;
@@ -398,6 +437,7 @@ export type Effect = {
398
437
  kind: 'record-event';
399
438
  sessionId: string;
400
439
  entry: RecordedEntry;
440
+ idempotencyKey?: string;
401
441
  };
402
442
  /**
403
443
  * Typed result map. With this shape, `await eff({ kind: 'read-context', ... })`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ziggs-ai/agent-sdk",
3
- "version": "0.2.2",
3
+ "version": "0.3.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.1.30",
29
+ "@ziggs-ai/api-client": "^0.2.0",
30
30
  "ajv": "^8.17.1",
31
31
  "dotenv": "^17.2.3",
32
32
  "openai": "^4.0.0",
@@ -1,24 +0,0 @@
1
- import { type Creds } from '@ziggs-ai/api-client';
2
- import { SeenMessages } from './SeenMessages.js';
3
- export interface InboxCatchUpDeps {
4
- creds: Creds;
5
- ownAgentId: string;
6
- baseUrl?: string;
7
- seen: SeenMessages;
8
- onMessage: (text: string, metadata: Record<string, unknown>) => Promise<void>;
9
- onResourceEvent: (event: Record<string, unknown>) => Promise<void>;
10
- label?: string;
11
- }
12
- export interface InboxCatchUpResult {
13
- scopes: number;
14
- messagesDelivered: number;
15
- resourceEvents: number;
16
- acked: number;
17
- truncated: boolean;
18
- }
19
- /**
20
- * Pull-based recovery after reconnect (ZIG-434 / ZIG-446 / ZIG-454).
21
- * Inbox → read message deltas → deliver → ack. Push remains a hint;
22
- * this path survives backend restarts that drop in-memory pending queues.
23
- */
24
- export declare function runInboxCatchUp(deps: InboxCatchUpDeps): Promise<InboxCatchUpResult>;