@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
@@ -4,6 +4,12 @@ export interface NormalizedEvent {
4
4
  senderId?: string;
5
5
  senderType?: string;
6
6
  receiverId?: string;
7
+ /**
8
+ * Backend per-delivery id of the chat row this event came from. The one
9
+ * field of the event that is identical on live push and on catch-up
10
+ * redelivery, so it anchors the wake's idempotency keys (wakeWriteKeys).
11
+ */
12
+ messageId?: string;
7
13
  timestamp: number;
8
14
  type: 'task_result' | 'message' | 'agreement_lifecycle';
9
15
  operation?: string;
@@ -11,8 +17,6 @@ export interface NormalizedEvent {
11
17
  result?: unknown;
12
18
  text?: string;
13
19
  };
14
- shouldProcess: boolean;
15
- reason: string | null;
16
20
  }
17
21
  interface WireParties {
18
22
  creator?: string;
@@ -37,6 +41,7 @@ interface WireTask {
37
41
  interface WireMetadata {
38
42
  chatId?: string;
39
43
  chat_id?: string;
44
+ messageId?: string;
40
45
  entryType?: string;
41
46
  content_type?: string;
42
47
  contentType?: string;
@@ -61,5 +66,14 @@ interface NormalizeArgs {
61
66
  metadata?: WireMetadata;
62
67
  ownAgentId?: string | null;
63
68
  }
64
- export declare function normalizeIncomingEvent({ text, metadata, ownAgentId }: NormalizeArgs): NormalizedEvent;
69
+ /**
70
+ * Classify + shape a wire delivery into a lane-routable event. Targeting is
71
+ * backend policy now (delivery law, phase 4): the inbox loop only ever delivers
72
+ * what is fenced to this agent, so there is no client-side "is this for me"
73
+ * relevance gate — this function decides SHAPE (message vs task_result vs
74
+ * agreement_lifecycle) and the fields the FSM / SessionResolver / wakeWriteKeys
75
+ * depend on, nothing more. `ownAgentId` is accepted for call-site compatibility
76
+ * but no longer gates anything.
77
+ */
78
+ export declare function normalizeIncomingEvent({ text, metadata }: NormalizeArgs): NormalizedEvent;
65
79
  export {};
@@ -1,4 +1,3 @@
1
- import { OPEN_AGREEMENT_TARGET } from '@ziggs-ai/api-client';
2
1
  /** Backend wire operations that map to FSM `proposal-resolved`. */
3
2
  const PROPOSAL_RESOLVED_OPERATIONS = new Set([
4
3
  'proposal_approved',
@@ -20,11 +19,6 @@ function isTaskRelated(metadata, taskData) {
20
19
  || contentType === 'task' || contentType === 'task_update'
21
20
  || Boolean(taskData.state || taskData.proposal || taskData.parentTaskId || taskData.executorId || taskData.agentId || taskData.payerId || taskData.createdBy));
22
21
  }
23
- function isRelevantForAgent(ids, ownAgentId) {
24
- if (!ownAgentId)
25
- return true;
26
- return ids.includes(ownAgentId) || ids.includes(OPEN_AGREEMENT_TARGET);
27
- }
28
22
  function isAgreementLifecycleNotification(metadata) {
29
23
  const entryType = metadata.entryType || 'message';
30
24
  const operation = metadata.operation;
@@ -32,7 +26,16 @@ function isAgreementLifecycleNotification(metadata) {
32
26
  typeof operation === 'string' &&
33
27
  PROPOSAL_RESOLVED_OPERATIONS.has(operation));
34
28
  }
35
- export function normalizeIncomingEvent({ text, metadata = {}, ownAgentId = null }) {
29
+ /**
30
+ * Classify + shape a wire delivery into a lane-routable event. Targeting is
31
+ * backend policy now (delivery law, phase 4): the inbox loop only ever delivers
32
+ * what is fenced to this agent, so there is no client-side "is this for me"
33
+ * relevance gate — this function decides SHAPE (message vs task_result vs
34
+ * agreement_lifecycle) and the fields the FSM / SessionResolver / wakeWriteKeys
35
+ * depend on, nothing more. `ownAgentId` is accepted for call-site compatibility
36
+ * but no longer gates anything.
37
+ */
38
+ export function normalizeIncomingEvent({ text, metadata = {} }) {
36
39
  const taskData = metadata.task || null;
37
40
  const chatId = normalizeChatId(metadata);
38
41
  const receiverId = normalizeReceiverId(metadata, taskData);
@@ -41,13 +44,10 @@ export function normalizeIncomingEvent({ text, metadata = {}, ownAgentId = null
41
44
  senderId: metadata.sender?.id,
42
45
  senderType,
43
46
  receiverId: receiverId || undefined,
47
+ messageId: typeof metadata.messageId === 'string' && metadata.messageId ? metadata.messageId : undefined,
44
48
  timestamp: Date.now(),
45
49
  };
46
50
  if (isAgreementLifecycleNotification(metadata)) {
47
- const relevant = !ownAgentId ||
48
- !receiverId ||
49
- receiverId === ownAgentId ||
50
- receiverId === OPEN_AGREEMENT_TARGET;
51
51
  const agreementId = typeof metadata.agreementId === 'string' ? metadata.agreementId : '';
52
52
  return {
53
53
  chatId,
@@ -58,31 +58,16 @@ export function normalizeIncomingEvent({ text, metadata = {}, ownAgentId = null
58
58
  agreementId,
59
59
  text,
60
60
  },
61
- shouldProcess: relevant,
62
- reason: relevant ? null : 'message_not_targeted_to_agent',
63
61
  };
64
62
  }
65
63
  if (isTaskRelated(metadata, taskData)) {
66
- const receiverFromMeta = metadata.receiver?.id || metadata.receiverId || null;
67
- const parties = taskData.agreement?.parties ?? {};
68
- const candidateIds = [
69
- parties.creator, parties.provider, parties.payer, parties.proposedTo,
70
- taskData.agentId, taskData.executorId, taskData.payerId, taskData.proposedTo, taskData.createdBy,
71
- receiverFromMeta,
72
- ].filter(Boolean);
73
- const relevant = isRelevantForAgent(candidateIds, ownAgentId);
74
64
  return {
75
65
  chatId,
76
66
  event: { ...base, type: 'task_result', result: taskData, text },
77
- shouldProcess: relevant,
78
- reason: relevant ? null : 'task_not_targeted_to_agent',
79
67
  };
80
68
  }
81
- const relevant = !ownAgentId || !receiverId || receiverId === ownAgentId || receiverId === OPEN_AGREEMENT_TARGET;
82
69
  return {
83
70
  chatId,
84
71
  event: { ...base, type: 'message', text },
85
- shouldProcess: relevant,
86
- reason: relevant ? null : 'message_not_targeted_to_agent',
87
72
  };
88
73
  }
@@ -1,4 +1,4 @@
1
- import type { Ctx, Outcome, Workflow, Identity, EffectHandler } from '../types.js';
1
+ import type { Ctx, Outcome, Workflow, Identity, EffectHandler, WakeWriteKeys } from '../types.js';
2
2
  import { type RawEvent } from './buildOutcome.js';
3
3
  import type { runTurn as RunTurnFn } from './runTurn.js';
4
4
  import { PromptBuilder } from './PromptBuilder.js';
@@ -59,6 +59,8 @@ interface MachineOptions {
59
59
  transitionTap?: (event: TransitionEvent) => void;
60
60
  /** Runaway-loop backstop: max thinking turns without human input before parking. Default 100. */
61
61
  maxTurnsWithoutUserInput?: number;
62
+ /** This wake's replay-stable write keys — one stream shared by every turn of the tick. */
63
+ wakeKeys?: WakeWriteKeys;
62
64
  }
63
65
  interface IncomingFrame {
64
66
  event?: RawEvent;
@@ -84,6 +86,7 @@ export declare class AgentMachine {
84
86
  toolManager: ToolManager;
85
87
  promptBuilder: PromptBuilder;
86
88
  memory?: MemoryStore;
89
+ wakeKeys?: WakeWriteKeys;
87
90
  state: string;
88
91
  status: 'active' | 'stopped';
89
92
  ctx: Ctx;
@@ -111,7 +114,7 @@ export declare class AgentMachine {
111
114
  output: number;
112
115
  };
113
116
  get stateTimeoutUnhandled(): boolean;
114
- constructor({ workflow, executionCore, eff, toolManager, promptBuilder, memory, input, transitionTap, maxTurnsWithoutUserInput }: MachineOptions);
117
+ constructor({ workflow, executionCore, eff, toolManager, promptBuilder, memory, input, transitionTap, maxTurnsWithoutUserInput, wakeKeys }: MachineOptions);
115
118
  getSnapshot(): MachineSnapshot;
116
119
  /** Parked = currently in a parked state and not executing a turn. */
117
120
  get isParked(): boolean;
@@ -30,6 +30,7 @@ export class AgentMachine {
30
30
  toolManager;
31
31
  promptBuilder;
32
32
  memory;
33
+ wakeKeys;
33
34
  state;
34
35
  status = 'active';
35
36
  ctx;
@@ -49,13 +50,14 @@ export class AgentMachine {
49
50
  get llmCalls() { return this._llmCalls; }
50
51
  get tokens() { return this._tokens; }
51
52
  get stateTimeoutUnhandled() { return this._stateTimeoutUnhandled; }
52
- constructor({ workflow, executionCore, eff, toolManager, promptBuilder, memory, input, transitionTap, maxTurnsWithoutUserInput }) {
53
+ constructor({ workflow, executionCore, eff, toolManager, promptBuilder, memory, input, transitionTap, maxTurnsWithoutUserInput, wakeKeys }) {
53
54
  this.workflow = workflow;
54
55
  this.executionCore = executionCore;
55
56
  this.eff = eff;
56
57
  this.toolManager = toolManager;
57
58
  this.promptBuilder = promptBuilder;
58
59
  this.memory = memory;
60
+ this.wakeKeys = wakeKeys;
59
61
  this._transitionTap = transitionTap;
60
62
  this._maxTurnsWithoutUserInput = maxTurnsWithoutUserInput ?? DEFAULT_MAX_TURNS_WITHOUT_USER_INPUT;
61
63
  this.state = input.state || workflow.initial;
@@ -200,6 +202,7 @@ export class AgentMachine {
200
202
  eff: this.eff,
201
203
  memory: this.memory,
202
204
  incomingEvent: this._lastIncomingEvent,
205
+ wakeKeys: this.wakeKeys,
203
206
  });
204
207
  this.ctx.toolResults = toolResults;
205
208
  this._llmCalls += llmCalls;
@@ -293,6 +296,7 @@ export class AgentMachine {
293
296
  promptBuilder: this.promptBuilder,
294
297
  memory: this.memory,
295
298
  definition: this.workflow,
299
+ wakeKeys: this.wakeKeys,
296
300
  });
297
301
  this.ctx.toolResults = toolResults;
298
302
  this._llmCalls += llmCalls;
@@ -352,7 +356,9 @@ export class AgentMachine {
352
356
  if (!userId)
353
357
  return;
354
358
  try {
355
- await this.eff({ kind: 'send-message', sessionId: this.ctx.identity.sessionId, receiverId: userId, text });
359
+ // Wake-derived messageId: a crash-replay that errors the same way parks
360
+ // again and re-sends this apology — same key, backend drops the dupe.
361
+ await this.eff({ kind: 'send-message', sessionId: this.ctx.identity.sessionId, receiverId: userId, text, messageId: this.wakeKeys?.messageKey() });
356
362
  }
357
363
  catch { /* best-effort */ }
358
364
  }
@@ -1,4 +1,4 @@
1
- import type { Ctx, EffectHandler, MechanicalState, Outcome, ToolResultEntry } from '../types.js';
1
+ import type { Ctx, EffectHandler, MechanicalState, Outcome, ToolResultEntry, WakeWriteKeys } from '../types.js';
2
2
  import type { MemoryStore } from '../memory/MemoryStore.js';
3
3
  import { type RawEvent } from './buildOutcome.js';
4
4
  export interface RunMechanicalResult {
@@ -22,4 +22,6 @@ export declare function runMechanical(input: {
22
22
  eff: EffectHandler;
23
23
  memory?: MemoryStore;
24
24
  incomingEvent: RawEvent | null;
25
+ /** This wake's replay-stable write keys (shared across every turn of the tick — see AgentMachine). */
26
+ wakeKeys?: WakeWriteKeys;
25
27
  }): Promise<RunMechanicalResult>;
@@ -5,12 +5,12 @@ import { runtimeLog } from '../shared/runtimeLog.js';
5
5
  * Used by dumb coordinators whose only job is a fixed tool call on task-assigned.
6
6
  */
7
7
  export async function runMechanical(input) {
8
- const { stateId, state, ctx, eff, memory, incomingEvent } = input;
8
+ const { stateId, state, ctx, eff, memory, incomingEvent, wakeKeys } = input;
9
9
  const sessionId = ctx.identity.sessionId;
10
10
  const runId = crypto.randomUUID();
11
11
  const effWithTrace = (effect) => {
12
12
  if (effect.kind === 'tool-call') {
13
- return eff({ ...effect, runId, stateId });
13
+ return eff({ ...effect, runId, stateId, wakeKeys });
14
14
  }
15
15
  return eff(effect);
16
16
  };
@@ -1,4 +1,4 @@
1
- import type { Action, Ctx, Outcome, ToolResultEntry, EffectHandler } from '../types.js';
1
+ import type { Action, Ctx, Outcome, ToolResultEntry, EffectHandler, WakeWriteKeys } from '../types.js';
2
2
  import type { MemoryStore } from '../memory/MemoryStore.js';
3
3
  import { type RawEvent } from './buildOutcome.js';
4
4
  import { PromptBuilder } from './PromptBuilder.js';
@@ -32,6 +32,8 @@ interface RunTurnInput {
32
32
  * to disable it without a code change.
33
33
  */
34
34
  streamRespondActions?: boolean;
35
+ /** This wake's replay-stable write keys (shared across every turn of the tick — see AgentMachine). */
36
+ wakeKeys?: WakeWriteKeys;
35
37
  }
36
38
  interface RunTurnResult {
37
39
  outcome: Outcome;
@@ -45,17 +45,28 @@ function isGenericFallbackTool(name, toolManager) {
45
45
  * call and (optionally) wrapping it in telemetry / dedup / retries.
46
46
  */
47
47
  export async function runTurn(input) {
48
- const { stateId, statePrompt, actions, incomingEvent, ctx, eff, toolManager, promptBuilder, memory, definition } = input;
48
+ const { stateId, statePrompt, actions, incomingEvent, ctx, eff, toolManager, promptBuilder, memory, definition, wakeKeys } = input;
49
49
  // Default on (ZIG-838); ENABLE_LLM_STREAMING=false is the deployment
50
50
  // kill-switch. Any other value (unset, 'true', …) streams.
51
51
  const streamRespondActions = input.streamRespondActions ?? process.env.ENABLE_LLM_STREAMING !== 'false';
52
52
  const sessionId = ctx.identity.sessionId;
53
53
  const runId = crypto.randomUUID();
54
- // Wrap eff to stamp runId + stateId onto llm-call and tool-call effects.
54
+ // Wrap eff to stamp runId + stateId onto llm-call and tool-call effects,
55
+ // and the wake's replay-stable write keys onto tool-call effects.
56
+ // Send-message effects without an explicit messageId get a wake-derived one
57
+ // here — a crash-replay of the wake re-derives the same id and the backend's
58
+ // (chatId, messageId) dedup drops the duplicate (ZIG-718). Undefined on
59
+ // keyless wakes, where the transport generates a fresh id as before.
55
60
  const effWithTrace = (effect) => {
56
- if (effect.kind === 'llm-call' || effect.kind === 'tool-call') {
61
+ if (effect.kind === 'llm-call') {
57
62
  return eff({ ...effect, runId, stateId });
58
63
  }
64
+ if (effect.kind === 'tool-call') {
65
+ return eff({ ...effect, runId, stateId, wakeKeys });
66
+ }
67
+ if (effect.kind === 'send-message' && !effect.messageId) {
68
+ return eff({ ...effect, messageId: wakeKeys?.messageKey() });
69
+ }
59
70
  return eff(effect);
60
71
  };
61
72
  const emitThought = async (text) => {
@@ -65,7 +76,7 @@ export async function runTurn(input) {
65
76
  if (s.length === 0)
66
77
  return;
67
78
  try {
68
- await eff({ kind: 'record-event', sessionId, entry: { kind: 'thought', text: s } });
79
+ await eff({ kind: 'record-event', sessionId, entry: { kind: 'thought', text: s }, idempotencyKey: wakeKeys?.artifactKey() });
69
80
  }
70
81
  catch (err) {
71
82
  runtimeLog.warn('runTurn', `record-event:thought failed: ${err.message}`);
@@ -295,7 +306,10 @@ export async function runTurn(input) {
295
306
  const inboundFromAgent = String(incomingEvent?.senderType || '').toUpperCase() === 'AGENT';
296
307
  runtimeLog.info('runTurn', `streaming check: enabled=${streamRespondActions} receiverId=${decision.receiverId ?? 'none'} tool=${actionDef?.tool ?? 'none'} action=${chosenAction} inboundFromAgent=${inboundFromAgent}`);
297
308
  if (streamRespondActions && !inboundFromAgent && decision.receiverId && !actionDef?.tool && chosenAction !== 'wait') {
298
- const streamMessageId = crypto.randomUUID();
309
+ // Wake-derived when the wake has a durable identity: a crash-replay
310
+ // streams and persists under the SAME id, so the backend's
311
+ // (chatId, messageId) dedup drops the duplicate reply (ZIG-718).
312
+ const streamMessageId = wakeKeys?.messageKey() ?? crypto.randomUUID();
299
313
  runtimeLog.info('runTurn', `stream-text start action=${chosenAction} receiverId=${decision.receiverId} msgId=${streamMessageId}`);
300
314
  const streamMessages = [
301
315
  ...messages,
@@ -362,7 +376,9 @@ export async function runTurn(input) {
362
376
  }
363
377
  let result;
364
378
  try {
365
- result = await executeDecision(decision, actionDef, eff, sessionId, memory, null, addToolUsage);
379
+ // effWithTrace, not eff: JSON-path tool calls must carry runId/stateId
380
+ // and the wake's write keys exactly like tool-loop calls do.
381
+ result = await executeDecision(decision, actionDef, effWithTrace, sessionId, memory, null, addToolUsage);
366
382
  }
367
383
  catch (err) {
368
384
  // Only outbound sends can throw out of executeDecision — executeTool
@@ -409,8 +425,10 @@ export async function runTurn(input) {
409
425
  }
410
426
  }
411
427
  if (!turnError) {
428
+ // effWithTrace, not eff: the echoed summary is a user-visible send and
429
+ // must carry a wake-derived messageId like every other send of the turn.
412
430
  await echoOptInToolSummaryIfNeeded({
413
- allEmittedEvents, eff, sessionId,
431
+ allEmittedEvents, eff: effWithTrace, sessionId,
414
432
  context: ctxObj, toolManager, incomingEvent,
415
433
  });
416
434
  }
@@ -0,0 +1,25 @@
1
+ import type { WakeWriteKeys } from '../types.js';
2
+ /**
3
+ * Build the per-wake key derivation. Call once per tick with the lane and the
4
+ * raw triggering event, then thread the instance to every write of the wake.
5
+ *
6
+ * Key shapes (all well under the backend's 128-char cap):
7
+ * - artifacts: `wk_<wake-hash>_a<ordinal>` — ordinal increments per
8
+ * artifact write, so a wake writing several breadcrumbs keys each one.
9
+ * - task transitions: `wk_<wake-hash>_t_<hash(taskId, state)>` — derived from
10
+ * the transition itself, so it dedups regardless of the order in which the
11
+ * replayed wake happens to re-issue its tool calls.
12
+ * - chat messages: `sdk_idem_wk_<wake-hash>_m<ordinal>` — used directly as
13
+ * the messageId (the backend dedups on (chatId, messageId), ZIG-718), drawn
14
+ * from the same ordinal stream as artifacts so one counter keys the wake's
15
+ * whole write sequence. The prefix matches the tool-layer convention for
16
+ * deterministic ids (`sdk_idem_<llm-key>`).
17
+ * - task spawns: `wk_<wake-hash>_s_<hash(agreement, description, …)>` —
18
+ * derived from what the spawn writes, so it dedups regardless of re-issue
19
+ * order while still distinguishing multiple spawns in one wake.
20
+ * - agreement create: `wk_<wake-hash>_ap_<hash(…)>` (propose) and
21
+ * `wk_<wake-hash>_asc_<hash(…)>` (subcontract) — same content-derived scheme
22
+ * as task spawns; the backend dedups on (creator, key) so a replayed
23
+ * propose/subcontract resolves to the original agreement.
24
+ */
25
+ export declare function createWakeWriteKeys(laneKey: string, event: unknown): WakeWriteKeys;
@@ -0,0 +1,134 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { getBatchEvents } from '../context/batch.js';
3
+ /**
4
+ * Deterministic idempotency keys for hosted-agent writes (delivery law, item 2).
5
+ *
6
+ * A crash between acting and acking redelivers the SAME wake event; the agent
7
+ * re-thinks and re-writes. The backend drops duplicate artifact writes and
8
+ * no-ops duplicate task transitions — but only when the replayed wake derives
9
+ * the SAME key. So keys are a pure function of durable wake inputs (laneKey +
10
+ * the triggering event's delivery identity + a per-wake write ordinal). They
11
+ * are never LLM-supplied and never random: asking the model to invent a key
12
+ * makes replay-stability a matter of sampling temperature.
13
+ *
14
+ * One instance is created per wake (tick) and threaded to every write the wake
15
+ * performs — a second instance created for the same (laneKey, event), as a
16
+ * crash-replay does, reproduces the same key sequence.
17
+ */
18
+ /**
19
+ * Durable identity of the event that triggered this wake, or null when it has
20
+ * none. Null means the wake is not a redelivery candidate (timer and other
21
+ * host-synthesized frames fire fresh each time), so its writes carry no key —
22
+ * a derived-anyway key could only collide across genuinely distinct wakes and
23
+ * silently swallow their writes, which is worse than the duplicate it prevents.
24
+ */
25
+ function eventIdentity(event) {
26
+ if (!event || typeof event !== 'object')
27
+ return null;
28
+ const e = event;
29
+ if (e.type === 'batch') {
30
+ // EventQueue coalesces queued events into one frame. Identify the batch by
31
+ // its identifiable members (sorted — arrival order is not replay-stable).
32
+ // Members without identity are ignored rather than nulling the whole batch:
33
+ // an identified member can only recur via redelivery, which is exactly when
34
+ // matching keys are wanted.
35
+ const parts = getBatchEvents(event)
36
+ .map(eventIdentity)
37
+ .filter((p) => p !== null)
38
+ .sort();
39
+ return parts.length > 0 ? parts.join('|') : null;
40
+ }
41
+ // Per-delivery id stamped by the backend on chat rows — present on live
42
+ // pushes and on catch-up redelivery alike, and the most precise identity.
43
+ if (typeof e.messageId === 'string' && e.messageId)
44
+ return `msg:${e.messageId}`;
45
+ if (e.type === 'task_result') {
46
+ // Host-synthesized task frames (bootstrap of already-assigned tasks) carry
47
+ // no messageId; the task snapshot itself is the identity. Re-bootstrapping
48
+ // the same task in the same state IS re-processing, so the collision this
49
+ // produces is the dedup we want.
50
+ const result = (e.result ?? {});
51
+ const taskId = typeof result.taskId === 'string' ? result.taskId : null;
52
+ if (!taskId)
53
+ return null;
54
+ const state = typeof result.state === 'string' ? result.state : '';
55
+ const operation = typeof result._operation === 'string' ? result._operation : '';
56
+ return `task:${taskId}:${state}:${operation}`;
57
+ }
58
+ if (e.type === 'agreement_lifecycle') {
59
+ const agreementId = typeof e.agreementId === 'string' ? e.agreementId : null;
60
+ if (!agreementId)
61
+ return null;
62
+ return `agr:${agreementId}:${typeof e.operation === 'string' ? e.operation : ''}`;
63
+ }
64
+ // state-timeout, resource_changed, and anything else: no per-delivery id on
65
+ // the wire, and never redelivered by catch-up — keyless by design.
66
+ return null;
67
+ }
68
+ function sha(input) {
69
+ return createHash('sha256').update(input).digest('hex');
70
+ }
71
+ /**
72
+ * Build the per-wake key derivation. Call once per tick with the lane and the
73
+ * raw triggering event, then thread the instance to every write of the wake.
74
+ *
75
+ * Key shapes (all well under the backend's 128-char cap):
76
+ * - artifacts: `wk_<wake-hash>_a<ordinal>` — ordinal increments per
77
+ * artifact write, so a wake writing several breadcrumbs keys each one.
78
+ * - task transitions: `wk_<wake-hash>_t_<hash(taskId, state)>` — derived from
79
+ * the transition itself, so it dedups regardless of the order in which the
80
+ * replayed wake happens to re-issue its tool calls.
81
+ * - chat messages: `sdk_idem_wk_<wake-hash>_m<ordinal>` — used directly as
82
+ * the messageId (the backend dedups on (chatId, messageId), ZIG-718), drawn
83
+ * from the same ordinal stream as artifacts so one counter keys the wake's
84
+ * whole write sequence. The prefix matches the tool-layer convention for
85
+ * deterministic ids (`sdk_idem_<llm-key>`).
86
+ * - task spawns: `wk_<wake-hash>_s_<hash(agreement, description, …)>` —
87
+ * derived from what the spawn writes, so it dedups regardless of re-issue
88
+ * order while still distinguishing multiple spawns in one wake.
89
+ * - agreement create: `wk_<wake-hash>_ap_<hash(…)>` (propose) and
90
+ * `wk_<wake-hash>_asc_<hash(…)>` (subcontract) — same content-derived scheme
91
+ * as task spawns; the backend dedups on (creator, key) so a replayed
92
+ * propose/subcontract resolves to the original agreement.
93
+ */
94
+ export function createWakeWriteKeys(laneKey, event) {
95
+ const identity = eventIdentity(event);
96
+ const wakeHash = identity === null ? null : sha(`${laneKey}\n${identity}`).slice(0, 40);
97
+ let ordinal = 0;
98
+ return {
99
+ wakeKey: wakeHash === null ? null : `wk_${wakeHash}`,
100
+ artifactKey() {
101
+ if (wakeHash === null)
102
+ return undefined;
103
+ return `wk_${wakeHash}_a${ordinal++}`;
104
+ },
105
+ taskStateKey(taskId, state) {
106
+ if (wakeHash === null)
107
+ return undefined;
108
+ return `wk_${wakeHash}_t_${sha(`${taskId}\n${state}`).slice(0, 16)}`;
109
+ },
110
+ messageKey() {
111
+ if (wakeHash === null)
112
+ return undefined;
113
+ return `sdk_idem_wk_${wakeHash}_m${ordinal++}`;
114
+ },
115
+ taskSpawnKey(fields) {
116
+ if (wakeHash === null)
117
+ return undefined;
118
+ const spawnHash = sha(`${fields.agreementId}\n${fields.description}\n${fields.assigneeId ?? ''}\n${fields.parentTaskId ?? ''}`).slice(0, 16);
119
+ return `wk_${wakeHash}_s_${spawnHash}`;
120
+ },
121
+ agreementProposeKey(fields) {
122
+ if (wakeHash === null)
123
+ return undefined;
124
+ const proposeHash = sha(`${fields.proposedTo}\n${fields.providerId ?? ''}\n${fields.description}\n${fields.parentAgreementId ?? ''}`).slice(0, 16);
125
+ return `wk_${wakeHash}_ap_${proposeHash}`;
126
+ },
127
+ agreementSubcontractKey(fields) {
128
+ if (wakeHash === null)
129
+ return undefined;
130
+ const subHash = sha(`${fields.executorId}\n${fields.description}\n${fields.parentAgreementId}`).slice(0, 16);
131
+ return `wk_${wakeHash}_asc_${subHash}`;
132
+ },
133
+ };
134
+ }
@@ -52,11 +52,14 @@ export declare class ConnectionPool {
52
52
  */
53
53
  sendTo(agentId: string, text: string, metadata?: Record<string, unknown>, { timeoutMs }?: SendToOptions): Promise<null>;
54
54
  /**
55
- * Open a launcher (control) socket that the backend uses to push wake
56
- * hints for any of the registered agents. Pool spins up the right host
57
- * lazily when a hint arrives.
55
+ * Start the fleet lazy-start loop (delivery law, phase 4): the manager
56
+ * long-polls `GET /inbox/operator` with this operator key and starts the host
57
+ * for any owned agent whose slice has actionable content. Each started host
58
+ * then consumes via its own per-agent inbox loop — this is ordinary lazy
59
+ * process management, not a pushed signal. `wsUrl` is accepted for
60
+ * back-compat but unused (the poll is HTTP).
58
61
  */
59
- startControl({ wsUrl, operatorKey }?: ControlOptions): void;
62
+ startControl({ operatorKey }?: ControlOptions): void;
60
63
  stopControl(): void;
61
64
  query(filter: QueryFilter): string[];
62
65
  list(): string[];
@@ -137,25 +137,22 @@ export class ConnectionPool {
137
137
  return null;
138
138
  }
139
139
  /**
140
- * Open a launcher (control) socket that the backend uses to push wake
141
- * hints for any of the registered agents. Pool spins up the right host
142
- * lazily when a hint arrives.
140
+ * Start the fleet lazy-start loop (delivery law, phase 4): the manager
141
+ * long-polls `GET /inbox/operator` with this operator key and starts the host
142
+ * for any owned agent whose slice has actionable content. Each started host
143
+ * then consumes via its own per-agent inbox loop — this is ordinary lazy
144
+ * process management, not a pushed signal. `wsUrl` is accepted for
145
+ * back-compat but unused (the poll is HTTP).
143
146
  */
144
- startControl({ wsUrl, operatorKey } = {}) {
145
- if (!wsUrl || !operatorKey) {
146
- runtimeLog.warn('ConnectionPool', 'startControl: wsUrl and operatorKey are required — skipping');
147
+ startControl({ operatorKey } = {}) {
148
+ if (!operatorKey) {
149
+ runtimeLog.warn('ConnectionPool', 'startControl: operatorKey is required — skipping');
147
150
  return;
148
151
  }
149
- // ConnectionManager only accepts `control` at construction; mutating its
150
- // private field is the smallest way to add it later. (Cleaner fix lives
151
- // in api-client: expose a `setControl` setter.)
152
- this.manager['_controlOpts'] = { wsUrl, operatorKey };
153
- this.manager.start();
152
+ this.manager.start(operatorKey);
154
153
  }
155
154
  stopControl() {
156
- const m = this.manager;
157
- m['_controlHandle']?.close?.();
158
- m['_controlHandle'] = null;
155
+ void this.manager.stop();
159
156
  }
160
157
  query(filter) { return this.manager.query(filter); }
161
158
  list() { return this.manager.list(); }
@@ -69,7 +69,7 @@ function previewToolArgs(args) {
69
69
  * Chat timeline: `Message.tsx` parses `entryType: artifact`, `content_type: operation` with
70
70
  * `operation_*:{json}` where json includes `state` and `tool`.
71
71
  */
72
- async function emitToolOperationArtifact(client, chatId, phase, payload) {
72
+ async function emitToolOperationArtifact(client, chatId, phase, payload, idempotencyKey) {
73
73
  const prefix = phase === 'started' ? 'operation_started' : phase === 'completed' ? 'operation_completed' : 'operation_error';
74
74
  const text = `${prefix}:${JSON.stringify(payload)}`;
75
75
  try {
@@ -78,6 +78,7 @@ async function emitToolOperationArtifact(client, chatId, phase, payload) {
78
78
  text,
79
79
  content_type: 'operation',
80
80
  visibility: 'agent-private',
81
+ idempotencyKey,
81
82
  });
82
83
  }
83
84
  catch (e) {
@@ -108,7 +109,7 @@ export async function executeToolWithSideEffects(deps, eff, effect, roster) {
108
109
  state: 'started',
109
110
  tool: effect.name,
110
111
  ...(argsPreview ? { args: argsPreview } : {}),
111
- });
112
+ }, effect.wakeKeys?.artifactKey());
112
113
  try {
113
114
  const lastUser = roster.users.find((u) => u.userId || u.id);
114
115
  // Accumulate token usage from any nested LLM call the tool makes (llmTool),
@@ -141,6 +142,7 @@ export async function executeToolWithSideEffects(deps, eff, effect, roster) {
141
142
  senderType: effect.senderType ?? (lastUser ? 'USER' : undefined),
142
143
  onBehalfOfAgreementId: effect.onBehalfOfAgreementId,
143
144
  memory: effect.memory,
145
+ wakeKeys: effect.wakeKeys,
144
146
  reportUsage,
145
147
  turnScratch: {},
146
148
  });
@@ -148,7 +150,7 @@ export async function executeToolWithSideEffects(deps, eff, effect, roster) {
148
150
  state: 'completed',
149
151
  tool: effect.name,
150
152
  result: clipForOperationArtifact(result),
151
- });
153
+ }, effect.wakeKeys?.artifactKey());
152
154
  sendTelemetry(deps.telemetryClient, buildToolIngestPayload({
153
155
  sessionId: effect.sessionId,
154
156
  tool: effect.name,
@@ -166,7 +168,7 @@ export async function executeToolWithSideEffects(deps, eff, effect, roster) {
166
168
  state: 'failed',
167
169
  tool: effect.name,
168
170
  error: errMsg,
169
- });
171
+ }, effect.wakeKeys?.artifactKey());
170
172
  sendTelemetry(deps.telemetryClient, buildToolIngestPayload({
171
173
  sessionId: effect.sessionId,
172
174
  tool: effect.name,
@@ -242,13 +244,13 @@ export function createZiggsEffectHandler(deps) {
242
244
  let usage;
243
245
  if (deps.llm.chatMessagesStream) {
244
246
  const streamed = await deps.llm.chatMessagesStream(messages, (chunk) => {
245
- // Live-stream display is best-effort. The transport's send rejects
246
- // empty/whitespace-only content (WebSocketClient: content.trim() === ''),
247
- // and a whitespace-only chunk (e.g. a leading newline) is truthy yet
248
- // still throws — which, uncaught, aborts the whole stream and loses the
249
- // entire reply (e.g. a composer's final synthesis). Swallow chunk-send
250
- // failures; `text` accumulates independently and the final send-message
251
- // (below, in runTurn) carries the full reply regardless.
247
+ // Live-token display is best-effort and optional. Hosted agents wire
248
+ // no chunkSender (delivery law, phase 4: no agent socket, no live
249
+ // chunk transport), so partials are simply dropped — `text`
250
+ // accumulates independently and the final send-message (below, in
251
+ // runTurn) carries the full reply as one whole HTTP message. Any
252
+ // chunk-send failure is swallowed so a display hiccup never aborts
253
+ // the stream and loses the reply.
252
254
  try {
253
255
  if (chunk && chunk.trim())
254
256
  deps.chunkSender?.(sessionId, receiverId, chunk, messageId);
@@ -288,7 +290,9 @@ export function createZiggsEffectHandler(deps) {
288
290
  // visible to this agent across turns, not to other chat parties.
289
291
  try {
290
292
  if (effect.entry.kind === 'thought' && typeof effect.entry.text === 'string') {
291
- await deps.artifactsClient.recordThought(effect.sessionId, effect.entry.text);
293
+ await deps.artifactsClient.recordThought(effect.sessionId, effect.entry.text, {
294
+ idempotencyKey: effect.idempotencyKey,
295
+ });
292
296
  }
293
297
  else {
294
298
  await deps.artifactsClient.write({
@@ -296,6 +300,7 @@ export function createZiggsEffectHandler(deps) {
296
300
  text: JSON.stringify(effect.entry),
297
301
  content_type: String(effect.entry.kind ?? 'event'),
298
302
  visibility: 'agent-private',
303
+ idempotencyKey: effect.idempotencyKey,
299
304
  });
300
305
  }
301
306
  }