@ziggs-ai/agent-sdk 0.2.2 → 0.4.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 (45) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +1 -1
  3. package/dist/AgentHost.d.ts +12 -20
  4. package/dist/AgentHost.js +68 -142
  5. package/dist/agent/Agent.js +4 -0
  6. package/dist/agent/ClaudeSdkAgent.js +13 -4
  7. package/dist/agent/claudeSdkProtocol.d.ts +8 -7
  8. package/dist/agent/claudeSdkProtocol.js +8 -7
  9. package/dist/agent/claudeSdkTools.d.ts +4 -2
  10. package/dist/agent/claudeSdkTools.js +9 -4
  11. package/dist/agent/toolManagerToSdkMcp.js +1 -0
  12. package/dist/index.d.ts +5 -4
  13. package/dist/index.js +4 -2
  14. package/dist/ingress/normalizeIncoming.d.ts +23 -8
  15. package/dist/ingress/normalizeIncoming.js +21 -32
  16. package/dist/runtime/AgentMachine.d.ts +5 -2
  17. package/dist/runtime/AgentMachine.js +8 -2
  18. package/dist/runtime/PromptBuilder.js +2 -2
  19. package/dist/runtime/runMechanical.d.ts +3 -1
  20. package/dist/runtime/runMechanical.js +2 -2
  21. package/dist/runtime/runTurn.d.ts +3 -1
  22. package/dist/runtime/runTurn.js +27 -9
  23. package/dist/runtime/wakeWriteKeys.d.ts +25 -0
  24. package/dist/runtime/wakeWriteKeys.js +134 -0
  25. package/dist/server/ConnectionPool.d.ts +31 -9
  26. package/dist/server/ConnectionPool.js +21 -24
  27. package/dist/server/ZiggsEffectHandler.js +17 -12
  28. package/dist/server/createHealthServer.js +5 -5
  29. package/dist/server/inboxLoop.d.ts +50 -0
  30. package/dist/server/{InboxCatchUp.js → inboxLoop.js} +64 -81
  31. package/dist/server/runLauncher.d.ts +1 -1
  32. package/dist/server/runLauncher.js +20 -7
  33. package/dist/server/staleBacklogGate.d.ts +53 -0
  34. package/dist/server/staleBacklogGate.js +120 -0
  35. package/dist/server/tasks/TaskService.d.ts +2 -1
  36. package/dist/server/tasks/TaskService.js +5 -2
  37. package/dist/server/tools/tier1/protocolRunner.d.ts +2 -1
  38. package/dist/server/tools/tier1/protocolRunner.js +43 -9
  39. package/dist/server/tools/tier1/protocolTools.js +1 -1
  40. package/dist/server/tools/tier2/contextTools.js +1 -1
  41. package/dist/shared/operatorKeyClaims.d.ts +22 -0
  42. package/dist/shared/operatorKeyClaims.js +35 -0
  43. package/dist/types.d.ts +40 -0
  44. package/package.json +2 -2
  45. package/dist/server/InboxCatchUp.d.ts +0 -24
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Launcher startup backlog policy (ZIG-910).
3
+ *
4
+ * "Work waits" has no statute of limitations on the backend — the inbox never
5
+ * expires anything. But when a fleet comes up after a dark period, replaying
6
+ * every weeks-old actionable slice means a mass of LLM turns on stale work at
7
+ * real token cost. The consumer chooses what to pick up: this gate keeps the
8
+ * operator-poll lazy start from resurrecting agents whose newest actionable
9
+ * item is older than a cutoff. Skips are never silent — one loud aggregated
10
+ * summary line announces what was left down and how to opt back in.
11
+ *
12
+ * Env:
13
+ * FLEET_REPLAY_MAX_AGE cutoff, e.g. "3d" | "12h" | "30m" | bare number =
14
+ * days (default "3d")
15
+ * FLEET_REPLAY_STALE=1 replay everything (disable the gate)
16
+ *
17
+ * Scope is the launcher only. The backend inbox is untouched, explicit
18
+ * `pool.startAgent()` / preStart are untouched, and an agent with ANY fresh item
19
+ * starts normally (its own inbox loop then drains the stale backlog too).
20
+ */
21
+ import type { InboxEnvelope, InboxOperatorAgentEntry } from '@ziggs-ai/api-client';
22
+ export declare const DEFAULT_REPLAY_MAX_AGE_MS: number;
23
+ export interface ReplayPolicy {
24
+ /** Skip slices whose newest actionable item is older than this. */
25
+ maxAgeMs: number;
26
+ /** FLEET_REPLAY_STALE=1 — replay everything, gate disabled. */
27
+ replayAll: boolean;
28
+ }
29
+ /**
30
+ * "3d" | "12h" | "30m" | "45s"; a bare number means days (the knob's natural
31
+ * granularity). Returns null on garbage so the caller can warn and fall back.
32
+ */
33
+ export declare function parseReplayMaxAge(raw: string): number | null;
34
+ export declare function resolveReplayPolicy(env?: NodeJS.ProcessEnv): ReplayPolicy;
35
+ /**
36
+ * Newest actionable timestamp across an agent's slice: scope activity,
37
+ * proposals, and connection/MCP requests. Null when nothing carries a
38
+ * timestamp (e.g. a joined-but-idle chat) — age unprovable, treated as fresh.
39
+ */
40
+ export declare function newestActionableAt(inbox: InboxEnvelope): number | null;
41
+ export declare function formatAge(ms: number): string;
42
+ export interface StaleBacklogGateOptions extends ReplayPolicy {
43
+ label?: string;
44
+ now?: () => number;
45
+ log?: (line: string) => void;
46
+ }
47
+ /**
48
+ * Build the `shouldLazyStart` gate for the operator poll. Skipped agents
49
+ * resurface in every envelope (reading never acks), so announcements are
50
+ * per-agent-once: each poll pass that skips agents not yet announced emits one
51
+ * aggregated warn line covering them.
52
+ */
53
+ export declare function createStaleBacklogGate({ maxAgeMs, replayAll, label, now, log, }: StaleBacklogGateOptions): (entry: InboxOperatorAgentEntry) => boolean;
@@ -0,0 +1,120 @@
1
+ import { runtimeLog } from '../shared/runtimeLog.js';
2
+ export const DEFAULT_REPLAY_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000;
3
+ /**
4
+ * "3d" | "12h" | "30m" | "45s"; a bare number means days (the knob's natural
5
+ * granularity). Returns null on garbage so the caller can warn and fall back.
6
+ */
7
+ export function parseReplayMaxAge(raw) {
8
+ const m = /^\s*(\d+(?:\.\d+)?)\s*([dhms]?)\s*$/i.exec(raw);
9
+ if (!m)
10
+ return null;
11
+ const value = Number(m[1]);
12
+ if (!Number.isFinite(value) || value <= 0)
13
+ return null;
14
+ const unitMs = { d: 86_400_000, h: 3_600_000, m: 60_000, s: 1_000 }[(m[2] || 'd').toLowerCase()];
15
+ return value * unitMs;
16
+ }
17
+ export function resolveReplayPolicy(env = process.env) {
18
+ const replayAll = env.FLEET_REPLAY_STALE === '1' || env.FLEET_REPLAY_STALE === 'true';
19
+ const raw = env.FLEET_REPLAY_MAX_AGE?.trim();
20
+ let maxAgeMs = DEFAULT_REPLAY_MAX_AGE_MS;
21
+ if (raw) {
22
+ const parsed = parseReplayMaxAge(raw);
23
+ if (parsed == null) {
24
+ runtimeLog.warn('staleBacklogGate', `FLEET_REPLAY_MAX_AGE="${raw}" is not a duration (use e.g. "3d", "12h", "30m") — using default ${formatAge(DEFAULT_REPLAY_MAX_AGE_MS)}`);
25
+ }
26
+ else {
27
+ maxAgeMs = parsed;
28
+ }
29
+ }
30
+ return { maxAgeMs, replayAll };
31
+ }
32
+ /**
33
+ * Newest actionable timestamp across an agent's slice: scope activity,
34
+ * proposals, and connection/MCP requests. Null when nothing carries a
35
+ * timestamp (e.g. a joined-but-idle chat) — age unprovable, treated as fresh.
36
+ */
37
+ export function newestActionableAt(inbox) {
38
+ let newest = null;
39
+ const consider = (iso) => {
40
+ if (!iso)
41
+ return;
42
+ const t = Date.parse(iso);
43
+ if (!Number.isNaN(t) && (newest == null || t > newest))
44
+ newest = t;
45
+ };
46
+ for (const scope of inbox.scopes ?? [])
47
+ consider(scope.latestAt);
48
+ for (const p of inbox.proposalsAwaitingMe ?? [])
49
+ consider(p.proposedAt);
50
+ for (const r of inbox.connectionRequestsAwaitingMe ?? [])
51
+ consider(r.requestedAt);
52
+ for (const r of inbox.mcpServerRequestsAwaitingMe ?? [])
53
+ consider(r.requestedAt);
54
+ return newest;
55
+ }
56
+ /** Unread items in a slice, for the summary line. Counts are backend-capped. */
57
+ function countItems(inbox) {
58
+ let items = 0;
59
+ for (const scope of inbox.scopes ?? []) {
60
+ items += (scope.newMessages ?? 0) + (scope.newArtifacts ?? 0);
61
+ }
62
+ items += (inbox.proposalsAwaitingMe ?? []).length;
63
+ items += (inbox.connectionRequestsAwaitingMe ?? []).length;
64
+ items += (inbox.mcpServerRequestsAwaitingMe ?? []).length;
65
+ return items;
66
+ }
67
+ export function formatAge(ms) {
68
+ if (ms >= 86_400_000)
69
+ return `${trimNum(ms / 86_400_000)}d`;
70
+ if (ms >= 3_600_000)
71
+ return `${trimNum(ms / 3_600_000)}h`;
72
+ if (ms >= 60_000)
73
+ return `${trimNum(ms / 60_000)}m`;
74
+ return `${trimNum(ms / 1_000)}s`;
75
+ }
76
+ function trimNum(n) {
77
+ return Number(n.toFixed(1)).toString();
78
+ }
79
+ /**
80
+ * Build the `shouldLazyStart` gate for the operator poll. Skipped agents
81
+ * resurface in every envelope (reading never acks), so announcements are
82
+ * per-agent-once: each poll pass that skips agents not yet announced emits one
83
+ * aggregated warn line covering them.
84
+ */
85
+ export function createStaleBacklogGate({ maxAgeMs, replayAll, label = 'launcher', now = Date.now, log, }) {
86
+ const emit = log ?? ((line) => runtimeLog.warn(label, line));
87
+ const announced = new Set();
88
+ let pendingAgents = [];
89
+ let pendingItems = 0;
90
+ let flushScheduled = false;
91
+ // One envelope's entries are gated synchronously; a microtask-deferred
92
+ // flush turns a whole boot flood into a single summary line.
93
+ const flush = () => {
94
+ flushScheduled = false;
95
+ if (pendingAgents.length === 0)
96
+ return;
97
+ const preview = pendingAgents.slice(0, 5).join(', ');
98
+ const more = pendingAgents.length > 5 ? `, +${pendingAgents.length - 5} more` : '';
99
+ emit(`startup backlog policy: skipped ${pendingItems} stale item(s) across ${pendingAgents.length} agent(s) older than ${formatAge(maxAgeMs)} (${preview}${more}) — set FLEET_REPLAY_STALE=1 to replay them`);
100
+ pendingAgents = [];
101
+ pendingItems = 0;
102
+ };
103
+ return (entry) => {
104
+ if (replayAll)
105
+ return true;
106
+ const newest = newestActionableAt(entry.inbox);
107
+ if (newest == null || now() - newest <= maxAgeMs)
108
+ return true;
109
+ if (!announced.has(entry.agentId)) {
110
+ announced.add(entry.agentId);
111
+ pendingAgents.push(entry.agentId);
112
+ pendingItems += countItems(entry.inbox);
113
+ if (!flushScheduled) {
114
+ flushScheduled = true;
115
+ queueMicrotask(flush);
116
+ }
117
+ }
118
+ return false;
119
+ };
120
+ }
@@ -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;
@@ -129,7 +129,7 @@ export const contextDiscoverGrantableTool = defineTool('context_discover_grantab
129
129
  const items = await new ContextDiscoveryClient(operatorKey, agentId).discoverGrantable();
130
130
  return { count: items.length, items };
131
131
  }, {
132
- description: 'See what context EXISTS in your orgs that you CANNOT read yet — the inverse of list_grants. Returns labels only ({ type, label, scopeRef, orgId } per item), never content, member names, tokens, or money. Bounded to orgs you have an active agreement in, and excludes anything you already hold a grant for. Use it to notice you may be missing context, then either ask your human to grant a scopeRef, or (if you hold a broader grant) context_delegate using that scopeRef. Pair with list_grants (what you hold) and context_expand_reach (what a held grant covers).',
132
+ description: 'See what context EXISTS in your orgs that you CANNOT read yet — the inverse of list_grants. Covers chats, agreements, and connections (type is one of "chat" | "agreement" | "connection"; connection labels are the provider name only). Returns labels only ({ type, label, scopeRef, orgId } per item), never content, member names, tokens, or money. Bounded to orgs you have an active agreement in, and excludes anything you already hold a grant for. Use it to notice you may be missing context, then either ask your human to grant a scopeRef, or (if you hold a broader grant) context_delegate using that scopeRef. Pair with list_grants (what you hold) and context_expand_reach (what a held grant covers).',
133
133
  });
134
134
  export const CONTEXT_TOOLS = [
135
135
  contextReadTool,
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Client-side peek at an operator key's JWT payload (signature NOT verified —
3
+ * identity hint only, the backend remains the authority). Used by the fleet
4
+ * launcher to warn when the key is agent-bound (ZIG-911): a bound key
5
+ * collapses the operator multiplex to its one agent and 403s every other
6
+ * per-agent loop, so a large fleet quietly watches a single agent.
7
+ */
8
+ export interface OperatorKeyClaims {
9
+ type?: string;
10
+ keyId?: string;
11
+ ownerId?: string;
12
+ boundAgentId?: string | null;
13
+ exp?: number;
14
+ }
15
+ export declare function decodeOperatorKeyClaims(token: string): OperatorKeyClaims | null;
16
+ /**
17
+ * The ZIG-911 startup check: warn (never refuse — a one-agent launcher on a
18
+ * bound key is legitimate) when the fleet's control key is bound to a single
19
+ * agent while more than one agent is registered. Returns the warning line, or
20
+ * null when the setup is coherent.
21
+ */
22
+ export declare function boundKeyFleetWarning(operatorKey: string, registeredAgents: number): string | null;
@@ -0,0 +1,35 @@
1
+ export function decodeOperatorKeyClaims(token) {
2
+ const parts = token.trim().split('.');
3
+ if (parts.length !== 3)
4
+ return null;
5
+ try {
6
+ const json = Buffer.from(parts[1], 'base64url').toString('utf8');
7
+ const payload = JSON.parse(json);
8
+ return {
9
+ type: payload.type,
10
+ keyId: payload.keyId,
11
+ ownerId: payload.ownerId,
12
+ boundAgentId: payload.boundAgentId ?? null,
13
+ exp: payload.exp,
14
+ };
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ /**
21
+ * The ZIG-911 startup check: warn (never refuse — a one-agent launcher on a
22
+ * bound key is legitimate) when the fleet's control key is bound to a single
23
+ * agent while more than one agent is registered. Returns the warning line, or
24
+ * null when the setup is coherent.
25
+ */
26
+ export function boundKeyFleetWarning(operatorKey, registeredAgents) {
27
+ if (registeredAgents <= 1)
28
+ return null;
29
+ const bound = decodeOperatorKeyClaims(operatorKey)?.boundAgentId;
30
+ if (!bound)
31
+ return null;
32
+ return (`fleet key is bound to agent "${bound}": the operator multiplex covers 1 of ${registeredAgents} registered agents, ` +
33
+ `and per-agent inbox loops for the rest will 403 ("cannot impersonate"). ` +
34
+ `Use an unbound owner key with agents:impersonate to run a fleet.`);
35
+ }
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.4.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.3.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>;