@ziggs-ai/agent-sdk 0.9.0 → 0.10.1

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 (55) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/AgentHost.d.ts +53 -14
  3. package/dist/AgentHost.js +137 -8
  4. package/dist/agent/ClaudeSdkAgent.d.ts +27 -2
  5. package/dist/agent/ClaudeSdkAgent.js +151 -78
  6. package/dist/agent/claudeSdkTools.js +39 -11
  7. package/dist/context/applyEffects.js +4 -3
  8. package/dist/defineAgent.js +4 -1
  9. package/dist/formatters/HistoryFormatter.js +11 -0
  10. package/dist/index.d.ts +3 -2
  11. package/dist/index.js +3 -2
  12. package/dist/ingress/normalizeIncoming.d.ts +5 -0
  13. package/dist/ingress/normalizeIncoming.js +23 -1
  14. package/dist/pricing/fleetDefaults.d.ts +0 -3
  15. package/dist/pricing/fleetDefaults.js +0 -6
  16. package/dist/runtime/PromptBuilder.js +9 -5
  17. package/dist/runtime/bootstrapCtx.js +6 -8
  18. package/dist/runtime/buildOutcome.d.ts +9 -1
  19. package/dist/runtime/buildOutcome.js +29 -6
  20. package/dist/runtime/runTurn.js +1 -1
  21. package/dist/runtime/taskBookkeeping.d.ts +0 -2
  22. package/dist/runtime/taskBookkeeping.js +0 -6
  23. package/dist/runtime/taskIntake.js +6 -4
  24. package/dist/runtime/wakeWriteKeys.js +23 -21
  25. package/dist/server/ConnectionPool.d.ts +10 -52
  26. package/dist/server/ConnectionPool.js +26 -57
  27. package/dist/server/EventQueue.d.ts +6 -0
  28. package/dist/server/EventQueue.js +21 -6
  29. package/dist/server/SeenMessages.d.ts +7 -0
  30. package/dist/server/SeenMessages.js +15 -0
  31. package/dist/server/ZiggsEffectHandler.d.ts +1 -0
  32. package/dist/server/ZiggsEffectHandler.js +21 -11
  33. package/dist/server/inboxLoop.d.ts +17 -2
  34. package/dist/server/inboxLoop.js +92 -16
  35. package/dist/server/polls/PollKind.d.ts +72 -0
  36. package/dist/server/polls/PollKind.js +53 -0
  37. package/dist/server/polls/PollRunner.d.ts +62 -0
  38. package/dist/server/polls/PollRunner.js +138 -0
  39. package/dist/server/questMatch.d.ts +5 -0
  40. package/dist/server/questMatch.js +14 -0
  41. package/dist/server/runLauncher.d.ts +0 -6
  42. package/dist/server/runLauncher.js +54 -27
  43. package/dist/server/tasks/TaskService.d.ts +2 -11
  44. package/dist/server/tools/tier1/protocolRunner.d.ts +3 -1
  45. package/dist/server/tools/tier1/protocolRunner.js +40 -41
  46. package/dist/server/tools/tier1/protocolTools.js +23 -13
  47. package/dist/server/tools/tier2/connectionTools.js +12 -1
  48. package/dist/tasks/protocolRegistry.d.ts +27 -0
  49. package/dist/tasks/protocolRegistry.js +36 -0
  50. package/dist/tools/capabilityAdapter.js +12 -1
  51. package/dist/tools/recordReport.js +8 -4
  52. package/dist/types.d.ts +24 -11
  53. package/package.json +2 -2
  54. package/dist/server/staleBacklogGate.d.ts +0 -57
  55. package/dist/server/staleBacklogGate.js +0 -100
@@ -1,6 +1,6 @@
1
1
  import { HistoryFormatter, AgreementFormatter } from '../formatters/index.js';
2
2
  import { validateContext } from '../cognition/validateContext.js';
3
- import { isBroadcastTarget } from '@ziggs-ai/api-client';
3
+ import { isBroadcastTarget, isOpaquePresentationRef } from '@ziggs-ai/api-client';
4
4
  import { LANE_BOOK_KEY, allCollectedResults, allDelegatedAgreementIds, allPendingSubtasks, } from './taskBookkeeping.js';
5
5
  /** Rough chars-per-token ratio for budgeting (English/JSON averages ~4). */
6
6
  const CHARS_PER_TOKEN = 4;
@@ -298,16 +298,20 @@ ${this._renderDecisionSchemaFromActions(actions, senderMeta, ctxObj, omitMessage
298
298
  const pid = parties[field];
299
299
  if (!pid || pid === myAgentId)
300
300
  continue;
301
+ // Opaque presentation refs are not principal slots (ZIG-1137).
302
+ if (isOpaquePresentationRef(pid))
303
+ continue;
301
304
  if (!agreementParties.has(pid))
302
305
  agreementParties.set(pid, []);
303
306
  agreementParties.get(pid).push({ agreementId: agId ?? '', role });
304
307
  }
305
308
  }
306
- // Chat and transfer surfaces default to the full directory: every chat
307
- // counterparty can receive a chat message; every id can receive a transfer
308
- // by their principal id until per-wallet routing is wired through.
309
+ // Chat keeps the full directory — including opaque `rpb_*` / `psn_*` refs
310
+ // the persona layer may surface. Echo those as chat_send receiverId; the
311
+ // backend resolves them in-room. Transfer (and any account-id action) must
312
+ // never list opaque refs — lookup / wake / pay against them 400s (ZIG-1137).
309
313
  const chatIds = new Set([...userIds, ...agentIds]);
310
- const transferIds = new Set([...userIds, ...agentIds]);
314
+ const transferIds = new Set([...userIds, ...agentIds].filter((id) => !isOpaquePresentationRef(id)));
311
315
  const lines = ['<recipients>'];
312
316
  lines.push('<users>');
313
317
  if (userIds.size === 0)
@@ -18,14 +18,15 @@ import { migrateLegacyTaskFields } from './taskBookkeeping.js';
18
18
  export function bootstrapCtx(identity, existing) {
19
19
  if (existing) {
20
20
  const legacy = existing;
21
+ // ZIG-1128: the lane-level mirrors are destructured OFF, not carried through.
22
+ // `migrateLegacyTaskFields` has already folded whatever they held into
23
+ // `tasks`; keeping the empty husks on the restored Ctx is what let a live FSM
24
+ // guard read one and see "nothing delegated" forever.
25
+ const { pendingSubtasks: _pendingSubtasks, collectedResults: _collectedResults, delegatedAgreementIds: _delegatedAgreementIds, ...rest } = existing;
21
26
  return {
22
- ...existing,
27
+ ...rest,
23
28
  identity,
24
29
  tasks: migrateLegacyTaskFields(legacy),
25
- // ZIG-1057: deprecated mirrors stay empty; use all*() readers.
26
- pendingSubtasks: {},
27
- collectedResults: [],
28
- delegatedAgreementIds: [],
29
30
  };
30
31
  }
31
32
  return {
@@ -34,9 +35,6 @@ export function bootstrapCtx(identity, existing) {
34
35
  activeAgreementId: null,
35
36
  activeTaskId: null,
36
37
  pendingProposalAgreementId: null,
37
- pendingSubtasks: {},
38
- collectedResults: [],
39
- delegatedAgreementIds: [],
40
38
  toolResults: [],
41
39
  lastOutcome: { kind: 'enter' },
42
40
  };
@@ -3,7 +3,6 @@ import type { Action, AgreementRef, Ctx, Outcome, TaskRef, ToolResultEntry } fro
3
3
  export interface WireResult {
4
4
  taskId?: string;
5
5
  state?: string;
6
- status?: string;
7
6
  agreement?: AgreementRef;
8
7
  agreementId?: string;
9
8
  proposal?: {
@@ -37,6 +36,15 @@ export interface EmittedEvent {
37
36
  senderId?: string;
38
37
  [key: string]: unknown;
39
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
+ */
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
42
50
  * machine.
@@ -1,6 +1,21 @@
1
1
  import { isProtocolToolName, mapProtocolToolToOperation } from '../tasks/protocolRegistry.js';
2
2
  import { getBatchEvents, isTaskResultRelevantToAgent } from '../context/batch.js';
3
3
  import { bookForDelegation, ensureTaskBook, findBookHoldingPending, removeTaskBook, } from './taskBookkeeping.js';
4
+ /**
5
+ * A chat message whose stated receiver is a DIFFERENT party. `receiverId` is
6
+ * the sender's addressing decision, carried on every wire frame; the
7
+ * human-broadcast sentinel counts as "elsewhere" for an agent (a broadcast is
8
+ * addressed to the room's humans). A frame with NO receiver keeps the turn —
9
+ * we cannot know it is not ours, and skipping on absence would deafen agents
10
+ * to malformed or legacy frames.
11
+ */
12
+ export function isMessageAddressedElsewhere(ev, ownAgentId) {
13
+ return Boolean(ev &&
14
+ ev.type === 'message' &&
15
+ ownAgentId &&
16
+ ev.receiverId &&
17
+ ev.receiverId !== ownAgentId);
18
+ }
4
19
  // ── outcomeFromEvent: incoming wire event → Outcome ──────────────────────────
5
20
  /**
6
21
  * Converts a raw incoming event into the single Outcome that re-enters the
@@ -139,6 +154,14 @@ function singleEventToOutcome(ev, ownAgentId) {
139
154
  // Suppress our own messages echoed back through the chat fanout.
140
155
  if (ownAgentId && ev.senderId && ev.senderId === ownAgentId)
141
156
  return null;
157
+ // Attention gate: a message addressed to someone else is room context, not
158
+ // a turn. Delivery is audience-based on purpose (everyone in the chat hears
159
+ // everything), so in a multi-agent room every agent used to burn an LLM turn
160
+ // per utterance just to decide it wasn't theirs — the sender already decided
161
+ // that by naming a receiver. The message still lands in chat history and
162
+ // rides in on the next wake's read-back; nothing is lost but the interrupt.
163
+ if (isMessageAddressedElsewhere(ev, ownAgentId))
164
+ return null;
142
165
  const text = typeof ev.text === 'string' ? ev.text : undefined;
143
166
  const senderType = typeof ev.senderType === 'string' ? ev.senderType : undefined;
144
167
  return { kind: 'message-received', text, senderId: ev.senderId, senderType };
@@ -286,8 +309,9 @@ function inferKindFromProtocolOp(op, result) {
286
309
  return 'task-delegated';
287
310
  }
288
311
  if (op === 'task-update') {
289
- const status = task?.state || r.state || r.status;
290
- if (status === 'completed' || status === 'failed')
312
+ // Same resolution as `isStateChangingProtocolEvent` above — `state` only.
313
+ const state = task?.state || r.state;
314
+ if (state === 'completed' || state === 'failed')
291
315
  return 'task-closed';
292
316
  return 'tool-result';
293
317
  }
@@ -462,10 +486,9 @@ export function applyOutcomeToCtx(ctx, outcome) {
462
486
  }
463
487
  case 'task-closed': {
464
488
  // Clear only this task's entry — sibling open tasks keep their books (ZIG-977).
465
- const closedId = outcome.taskId ||
466
- outcome.result?.task?.taskId ||
467
- outcome.result?.taskId ||
468
- ctx.activeTaskId;
489
+ // ZIG-1171: buildOutcomeOfKind already collapses result.task.taskId /
490
+ // result.taskId into outcome.taskId — do not re-walk the raw result here.
491
+ const closedId = outcome.taskId || ctx.activeTaskId;
469
492
  if (closedId)
470
493
  removeTaskBook(ctx, closedId);
471
494
  else {
@@ -84,7 +84,7 @@ export async function runTurn(input) {
84
84
  if (s.length === 0)
85
85
  return;
86
86
  try {
87
- await eff({ kind: 'record-event', sessionId, entry: { kind: 'thought', text: s }, idempotencyKey: wakeKeys?.artifactKey() });
87
+ await eff({ kind: 'record-event', sessionId, entry: { kind: 'thought', text: s }, idempotencyKey: wakeKeys?.artifactKey({ kind: 'thought', text: s }) });
88
88
  }
89
89
  catch (err) {
90
90
  runtimeLog.warn('runTurn', `record-event:thought failed: ${err.message}`);
@@ -7,8 +7,6 @@ import type { Ctx, TaskBookkeeping } from '../types.js';
7
7
  export declare const LANE_BOOK_KEY = "__lane__";
8
8
  export declare function emptyTaskBook(agreementId?: string | null): TaskBookkeeping;
9
9
  export declare function ensureTaskBook(ctx: Ctx, taskId: string, agreementId?: string | null): TaskBookkeeping;
10
- /** Focus task's book, or null when nothing is focused / missing. */
11
- export declare function focusTaskBook(ctx: Ctx): TaskBookkeeping | null;
12
10
  /**
13
11
  * Book that receives task-delegated / subtask-finished when we know the parent
14
12
  * engagement: focus task if assigned, else the coordinator lane bucket.
@@ -25,12 +25,6 @@ export function ensureTaskBook(ctx, taskId, agreementId) {
25
25
  }
26
26
  return book;
27
27
  }
28
- /** Focus task's book, or null when nothing is focused / missing. */
29
- export function focusTaskBook(ctx) {
30
- if (!ctx.activeTaskId)
31
- return null;
32
- return ctx.tasks?.[ctx.activeTaskId] ?? null;
33
- }
34
28
  /**
35
29
  * Book that receives task-delegated / subtask-finished when we know the parent
36
30
  * engagement: focus task if assigned, else the coordinator lane bucket.
@@ -49,9 +49,10 @@ export function buildTaskIntakeState(initial) {
49
49
  // Function form so a failed task_set_result surfaces as an error outcome
50
50
  // (→ dflt error transition) instead of a phantom task-closed.
51
51
  produces: (result) => {
52
+ // `state` only — ZIG-1057 cut `status` from the wire (ZIG-1128).
52
53
  const r = result;
53
- const status = r?.task?.state ?? r?.state ?? r?.status;
54
- return status === 'completed' || status === 'failed' ? 'task-closed' : 'error';
54
+ const state = r?.task?.state ?? r?.state;
55
+ return state === 'completed' || state === 'failed' ? 'task-closed' : 'error';
55
56
  },
56
57
  terminatesLoop: true,
57
58
  },
@@ -63,9 +64,10 @@ export function buildTaskIntakeState(initial) {
63
64
  },
64
65
  tool: 'task_set_result',
65
66
  produces: (result) => {
67
+ // `state` only — ZIG-1057 cut `status` from the wire (ZIG-1128).
66
68
  const r = result;
67
- const status = r?.task?.state ?? r?.state ?? r?.status;
68
- return status === 'completed' || status === 'failed' ? 'task-closed' : 'error';
69
+ const state = r?.task?.state ?? r?.state;
70
+ return state === 'completed' || state === 'failed' ? 'task-closed' : 'error';
69
71
  },
70
72
  terminatesLoop: true,
71
73
  },
@@ -97,41 +97,43 @@ export function createWakeWriteKeys(laneKey, event) {
97
97
  const identity = eventIdentity(event);
98
98
  const wakeHash = identity === null ? null : sha(`${laneKey}\n${identity}`).slice(0, 40);
99
99
  let ordinal = 0;
100
+ // ZIG-1171: one content-hash builder; tags differ, the shape does not.
101
+ const keyed = (tag, material, opts) => {
102
+ if (wakeHash === null)
103
+ return undefined;
104
+ const body = `wk_${wakeHash}_${tag}_${sha(material).slice(0, 16)}`;
105
+ return opts?.asMessageId ? `sdk_idem_${body}` : body;
106
+ };
100
107
  return {
101
108
  wakeKey: wakeHash === null ? null : `wk_${wakeHash}`,
102
- artifactKey() {
109
+ artifactKey(content) {
103
110
  if (wakeHash === null)
104
111
  return undefined;
105
- return `wk_${wakeHash}_a${ordinal++}`;
112
+ // Content-derived for the same reason messageKey is (ZIG-1045): the
113
+ // ordinal restarts at a0 on every wake, so a redelivered event whose
114
+ // re-think writes different artifacts collided with the first wake's
115
+ // and the backend discarded the new write. Harmless for a breadcrumb,
116
+ // not for a result artifact — which is the whole deliverable of an
117
+ // agent like agent-definer. Callers without content keep the ordinal.
118
+ if (content === undefined)
119
+ return `wk_${wakeHash}_a${ordinal++}`;
120
+ const material = typeof content === 'string' ? content : JSON.stringify(content) ?? '';
121
+ return keyed('a', material);
106
122
  },
107
123
  taskStateKey(taskId, state) {
108
- if (wakeHash === null)
109
- return undefined;
110
- return `wk_${wakeHash}_t_${sha(`${taskId}\n${state}`).slice(0, 16)}`;
124
+ return keyed('t', `${taskId}\n${state}`);
111
125
  },
112
126
  messageKey(fields) {
113
- if (wakeHash === null)
114
- return undefined;
115
- const msgHash = sha(`${fields.sessionId ?? ''}\n${fields.receiverId ?? ''}\n${fields.text}`).slice(0, 16);
116
- return `sdk_idem_wk_${wakeHash}_m_${msgHash}`;
127
+ return keyed('m', `${fields.sessionId ?? ''}\n${fields.receiverId ?? ''}\n${fields.text}`, { asMessageId: true });
117
128
  },
118
129
  taskSpawnKey(fields) {
119
- if (wakeHash === null)
120
- return undefined;
121
- const spawnHash = sha(`${fields.agreementId}\n${fields.description}\n${fields.assigneeId ?? ''}\n${fields.parentTaskId ?? ''}`).slice(0, 16);
122
- return `wk_${wakeHash}_s_${spawnHash}`;
130
+ return keyed('s', `${fields.agreementId}\n${fields.description}\n${fields.assigneeId ?? ''}\n${fields.parentTaskId ?? ''}`);
123
131
  },
124
132
  agreementProposeKey(fields) {
125
- if (wakeHash === null)
126
- return undefined;
127
- const proposeHash = sha(`${fields.proposedTo}\n${fields.providerId ?? ''}\n${fields.description}\n${fields.parentAgreementId ?? ''}`).slice(0, 16);
128
- return `wk_${wakeHash}_ap_${proposeHash}`;
133
+ return keyed('ap', `${fields.proposedTo}\n${fields.providerId ?? ''}\n${fields.description}\n${fields.parentAgreementId ?? ''}`);
129
134
  },
130
135
  agreementSubcontractKey(fields) {
131
- if (wakeHash === null)
132
- return undefined;
133
- const subHash = sha(`${fields.executorId}\n${fields.description}\n${fields.parentAgreementId}`).slice(0, 16);
134
- return `wk_${wakeHash}_asc_${subHash}`;
136
+ return keyed('asc', `${fields.executorId}\n${fields.description}\n${fields.parentAgreementId}`);
135
137
  },
136
138
  };
137
139
  }
@@ -1,17 +1,3 @@
1
- /**
2
- * ConnectionPool — multi-agent fleet manager.
3
- *
4
- * Holds N `AgentHostOptions` configs but only constructs/wakes an
5
- * `AgentHost` (one WS connection) when needed. Idle hosts are slept after
6
- * `idleTimeoutMs`. At most `maxActive` hosts are connected at a time; LRU
7
- * eviction drops the oldest if the cap is reached. With `startControl`, a
8
- * separate "launcher" socket receives backend-driven wake hints so the pool
9
- * spins up the right host on demand.
10
- *
11
- * This is how a single Node process serves a fleet of agents without
12
- * holding 1000 open sockets — typical config: 1000+ registered, ~50 live.
13
- */
14
- import { type InboxOperatorAgentEntry, type StartAgentOptions } from '@ziggs-ai/api-client';
15
1
  import { AgentHost, type AgentHostOptions } from '../AgentHost.js';
16
2
  interface ConnectionManagerMeta {
17
3
  domain?: string;
@@ -24,44 +10,27 @@ interface QueryFilter {
24
10
  expertise?: string[];
25
11
  tags?: string[];
26
12
  }
27
- export interface PoolOptions {
28
- maxActive?: number;
29
- idleTimeoutMs?: number;
30
- /**
31
- * Gate on the operator-poll lazy start (ZIG-910): return false to leave an
32
- * actionable agent's host down this pass. Explicit startAgent()s are not gated.
33
- */
34
- shouldLazyStart?: (entry: InboxOperatorAgentEntry) => boolean;
35
- }
36
13
  export interface SendToOptions {
37
14
  timeoutMs?: number;
38
15
  }
39
- export interface ControlOptions {
40
- wsUrl?: string;
41
- operatorKey?: string;
42
- }
43
16
  export declare class ConnectionPool {
44
17
  private readonly manager;
45
18
  private readonly configs;
46
19
  private readonly responseListeners;
47
- constructor({ maxActive, idleTimeoutMs, shouldLazyStart }?: PoolOptions);
48
20
  /**
49
- * Register N agent configs. Each will be lazily started (a fresh
50
- * `AgentHost` constructed + connected) the first time it's referenced.
21
+ * Register N agent configs. Nothing connects until `connectAll()`; a config
22
+ * registered and never connected is an agent nobody hosts, which is the
23
+ * failure this pool exists to make impossible.
51
24
  */
52
25
  register(configs: AgentHostOptions[], metaArr?: ConnectionManagerMeta[]): void;
53
- /**
54
- * Start (or return) an agent's host. `pinned` keeps it up for the process's
55
- * lifetime — no idle sweep, last to be evicted; use it for hosts the operator
56
- * named rather than ones work happened to reach.
57
- */
58
- startAgent(agentId: string, opts?: StartAgentOptions): Promise<AgentHost>;
59
- /** Agents kept up explicitly (launcher `preStart`), not by demand. */
60
- listPinned(): string[];
26
+ /** Connect every registered agent. Reports the ones that could not come up. */
27
+ connectAll(): Promise<void>;
28
+ /** The host for one agent, connecting it if it somehow is not up yet. */
29
+ startAgent(agentId: string): Promise<AgentHost>;
61
30
  sleep(agentId: string): Promise<void>;
62
31
  disconnectAll(): Promise<void>;
63
32
  /**
64
- * Start the named agent, deliver a message, and wait for its first outbound
33
+ * Deliver a message to the named agent and wait for its first outbound
65
34
  * `send-message` effect — i.e. the agent's reply. Returns the reply text.
66
35
  *
67
36
  * The effectTap injected in `register` drains a per-agent listener queue;
@@ -70,28 +39,17 @@ export declare class ConnectionPool {
70
39
  */
71
40
  requestFrom(agentId: string, text: string, metadata?: Record<string, unknown>, { timeoutMs }?: SendToOptions): Promise<string>;
72
41
  /**
73
- * Start the named agent and deliver one wire frame to it. Returns once the
74
- * host has finished processing.
42
+ * Deliver one wire frame to the named agent. Returns once the host has
43
+ * finished processing.
75
44
  *
76
45
  * Note: does not return the agent's response. For request/response
77
46
  * semantics use `requestFrom`.
78
47
  */
79
48
  sendTo(agentId: string, text: string, metadata?: Record<string, unknown>, { timeoutMs }?: SendToOptions): Promise<null>;
80
- /**
81
- * Start the fleet lazy-start loop (delivery law, phase 4): the manager
82
- * long-polls `GET /inbox/operator` with this operator key and starts the host
83
- * for any owned agent whose slice has actionable content. Each started host
84
- * then consumes via its own per-agent inbox loop — this is ordinary lazy
85
- * process management, not a pushed signal. `wsUrl` is accepted for
86
- * back-compat but unused (the poll is HTTP).
87
- */
88
- startControl({ operatorKey }?: ControlOptions): void;
89
- stopControl(): void;
90
49
  query(filter: QueryFilter): string[];
91
50
  list(): string[];
92
51
  listActive(): string[];
93
52
  get size(): number;
94
- get maxActive(): number;
95
53
  getMeta(id: string): ConnectionManagerMeta | undefined;
96
54
  }
97
55
  export {};
@@ -1,30 +1,30 @@
1
1
  /**
2
- * ConnectionPool — multi-agent fleet manager.
2
+ * ConnectionPool — the agents this launcher process hosts.
3
3
  *
4
- * Holds N `AgentHostOptions` configs but only constructs/wakes an
5
- * `AgentHost` (one WS connection) when needed. Idle hosts are slept after
6
- * `idleTimeoutMs`. At most `maxActive` hosts are connected at a time; LRU
7
- * eviction drops the oldest if the cap is reached. With `startControl`, a
8
- * separate "launcher" socket receives backend-driven wake hints so the pool
9
- * spins up the right host on demand.
4
+ * Holds N `AgentHostOptions` configs and connects every one of them at boot
5
+ * (ZIG-1108). Each host long-polls its own inbox for the life of the process —
6
+ * there is no agent socket. There is no subset, no cap and no lifecycle policy:
7
+ * a write releasing that poll is the only signal anything needs.
10
8
  *
11
- * This is how a single Node process serves a fleet of agents without
12
- * holding 1000 open sockets — typical config: 1000+ registered, ~50 live.
9
+ * What this replaced: a launcher that registered 1000+ configs and connected
10
+ * ~50, deciding which by long-polling `GET /inbox/operator` for a fleet-wide
11
+ * sweep, sleeping quiet hosts on an idle timer, evicting past `maxActive` by
12
+ * LRU, and exempting operator-named agents with a `pinned` flag. Whether an
13
+ * agent was alive came down to a Terraform string, and nothing noticed when the
14
+ * answer was "none of them".
13
15
  */
14
16
  import { ConnectionManager } from '@ziggs-ai/api-client';
15
17
  import { AgentHost } from '../AgentHost.js';
16
18
  import { runtimeLog } from '../shared/runtimeLog.js';
17
19
  export class ConnectionPool {
18
- manager;
20
+ manager = new ConnectionManager();
19
21
  configs = new Map();
20
22
  // Listeners enqueued by requestFrom — drained on outbound send-message effects.
21
23
  responseListeners = new Map();
22
- constructor({ maxActive = 50, idleTimeoutMs = 60_000, shouldLazyStart } = {}) {
23
- this.manager = new ConnectionManager({ maxActive, idleTimeoutMs, shouldLazyStart });
24
- }
25
24
  /**
26
- * Register N agent configs. Each will be lazily started (a fresh
27
- * `AgentHost` constructed + connected) the first time it's referenced.
25
+ * Register N agent configs. Nothing connects until `connectAll()`; a config
26
+ * registered and never connected is an agent nobody hosts, which is the
27
+ * failure this pool exists to make impossible.
28
28
  */
29
29
  register(configs, metaArr = []) {
30
30
  const metaByAgentId = new Map();
@@ -46,14 +46,7 @@ export class ConnectionPool {
46
46
  const pool = this;
47
47
  const host = new AgentHost({
48
48
  ...cfg,
49
- // Inbox activity and in-flight effects both reset the idle timer:
50
- // previously only pool.sendTo() touched it, so a host woken by its
51
- // own inbox loop was torn down every idleTimeoutMs (default 60s)
52
- // regardless of the work it was doing — losing lane ctx, Claude
53
- // session ids, and armed timers each time.
54
- onActivity: () => pool.manager.touch(agentId),
55
49
  effectTap(effect, response) {
56
- pool.manager.touch(agentId);
57
50
  if (effect.kind === 'send-message') {
58
51
  const listeners = pool.responseListeners.get(agentId);
59
52
  if (listeners?.length)
@@ -63,25 +56,21 @@ export class ConnectionPool {
63
56
  },
64
57
  });
65
58
  await host.connectAsync();
66
- runtimeLog.debug('ConnectionPool', `started "${agentId}" (active: ${this.manager.listActive().length}/${this.maxActive})`);
59
+ runtimeLog.debug('ConnectionPool', `connected "${agentId}"`);
67
60
  return host;
68
61
  }, async (host) => {
69
62
  host.disconnect();
70
- runtimeLog.debug('ConnectionPool', `slept "${agentId}"`);
63
+ runtimeLog.debug('ConnectionPool', `disconnected "${agentId}"`);
71
64
  }, metaByAgentId.get(agentId));
72
65
  }
73
66
  }
74
- /**
75
- * Start (or return) an agent's host. `pinned` keeps it up for the process's
76
- * lifetime — no idle sweep, last to be evicted; use it for hosts the operator
77
- * named rather than ones work happened to reach.
78
- */
79
- async startAgent(agentId, opts = {}) {
80
- return await this.manager.startAgent(agentId, opts);
67
+ /** Connect every registered agent. Reports the ones that could not come up. */
68
+ async connectAll() {
69
+ return this.manager.connectAll();
81
70
  }
82
- /** Agents kept up explicitly (launcher `preStart`), not by demand. */
83
- listPinned() {
84
- return this.manager.listPinned();
71
+ /** The host for one agent, connecting it if it somehow is not up yet. */
72
+ async startAgent(agentId) {
73
+ return await this.manager.startAgent(agentId);
85
74
  }
86
75
  async sleep(agentId) {
87
76
  return this.manager.sleep(agentId);
@@ -90,7 +79,7 @@ export class ConnectionPool {
90
79
  return this.manager.sleepAll();
91
80
  }
92
81
  /**
93
- * Start the named agent, deliver a message, and wait for its first outbound
82
+ * Deliver a message to the named agent and wait for its first outbound
94
83
  * `send-message` effect — i.e. the agent's reply. Returns the reply text.
95
84
  *
96
85
  * The effectTap injected in `register` drains a per-agent listener queue;
@@ -139,8 +128,8 @@ export class ConnectionPool {
139
128
  });
140
129
  }
141
130
  /**
142
- * Start the named agent and deliver one wire frame to it. Returns once the
143
- * host has finished processing.
131
+ * Deliver one wire frame to the named agent. Returns once the host has
132
+ * finished processing.
144
133
  *
145
134
  * Note: does not return the agent's response. For request/response
146
135
  * semantics use `requestFrom`.
@@ -149,31 +138,11 @@ export class ConnectionPool {
149
138
  const host = await this.startAgent(agentId);
150
139
  const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(`sendTo timeout for "${agentId}" after ${timeoutMs}ms`)), timeoutMs));
151
140
  await Promise.race([host.handleMessage(text, metadata), timeoutPromise]);
152
- this.manager.touch(agentId);
153
141
  return null;
154
142
  }
155
- /**
156
- * Start the fleet lazy-start loop (delivery law, phase 4): the manager
157
- * long-polls `GET /inbox/operator` with this operator key and starts the host
158
- * for any owned agent whose slice has actionable content. Each started host
159
- * then consumes via its own per-agent inbox loop — this is ordinary lazy
160
- * process management, not a pushed signal. `wsUrl` is accepted for
161
- * back-compat but unused (the poll is HTTP).
162
- */
163
- startControl({ operatorKey } = {}) {
164
- if (!operatorKey) {
165
- runtimeLog.warn('ConnectionPool', 'startControl: operatorKey is required — skipping');
166
- return;
167
- }
168
- this.manager.start(operatorKey);
169
- }
170
- stopControl() {
171
- void this.manager.stop();
172
- }
173
143
  query(filter) { return this.manager.query(filter); }
174
144
  list() { return this.manager.list(); }
175
145
  listActive() { return this.manager.listActive(); }
176
146
  get size() { return this.manager.size; }
177
- get maxActive() { return this.manager.maxActive; }
178
147
  getMeta(id) { return this.manager.getMeta(id); }
179
148
  }
@@ -7,6 +7,12 @@ export interface EventQueueOptions {
7
7
  * batch dispatch. The runtime sees at most one in-flight processEvent call
8
8
  * per laneKey at a time; multiple events arriving while busy are merged
9
9
  * into a single { type: 'batch', events: [...] } frame on the next pass.
10
+ *
11
+ * ZIG-1191: `enqueue` REJECTS when the dispatch failed. It used to log the error
12
+ * and resolve, which made every caller — including the inbox loop, whose next
13
+ * statement is the ack — unable to tell a handled event from a lost one. A
14
+ * caller that has nothing to do with a failure should say so with `.catch()`;
15
+ * the one holding a delivery must not have that decision made for it.
10
16
  */
11
17
  export declare class EventQueue {
12
18
  private _processEvent;
@@ -4,6 +4,12 @@ import { runtimeLog } from '../shared/runtimeLog.js';
4
4
  * batch dispatch. The runtime sees at most one in-flight processEvent call
5
5
  * per laneKey at a time; multiple events arriving while busy are merged
6
6
  * into a single { type: 'batch', events: [...] } frame on the next pass.
7
+ *
8
+ * ZIG-1191: `enqueue` REJECTS when the dispatch failed. It used to log the error
9
+ * and resolve, which made every caller — including the inbox loop, whose next
10
+ * statement is the ack — unable to tell a handled event from a lost one. A
11
+ * caller that has nothing to do with a failure should say so with `.catch()`;
12
+ * the one holding a delivery must not have that decision made for it.
7
13
  */
8
14
  export class EventQueue {
9
15
  _processEvent;
@@ -28,9 +34,12 @@ export class EventQueue {
28
34
  state = { events: [], processing: false };
29
35
  this._state.set(laneKey, state);
30
36
  }
31
- return new Promise((resolve) => {
37
+ return new Promise((resolve, reject) => {
32
38
  this._metrics.enqueued += 1;
33
- state.events.push({ event, resolve });
39
+ state.events.push({
40
+ event,
41
+ settle: (err) => (err === undefined ? resolve() : reject(err)),
42
+ });
34
43
  this._trimQueueIfNeeded(state, laneKey);
35
44
  this._processIfNeeded(laneKey);
36
45
  });
@@ -49,21 +58,25 @@ export class EventQueue {
49
58
  while (state.events.length > 0) {
50
59
  const batch = state.events.splice(0, state.events.length);
51
60
  const events = batch.map(e => e.event);
52
- const resolvers = batch.map(e => e.resolve);
61
+ const settlers = batch.map(e => e.settle);
53
62
  const coalesced = events.length === 1
54
63
  ? events[0]
55
64
  : { type: 'batch', events };
65
+ // Coalescing makes the batch the unit of success: the lane ran one tick
66
+ // for all of it, so a failure belongs to every event that was merged in.
67
+ let failure;
56
68
  try {
57
69
  await this._processEvent(coalesced, laneKey);
58
70
  }
59
71
  catch (error) {
72
+ failure = error ?? new Error('processEvent failed');
60
73
  this._metrics.errors += 1;
61
74
  runtimeLog.error('EventQueue', `processEvent error for laneKey=${laneKey}: ${error?.message || error}`);
62
75
  }
63
76
  this._metrics.processedBatches += 1;
64
77
  this._metrics.processedEvents += events.length;
65
- for (const r of resolvers)
66
- r();
78
+ for (const settle of settlers)
79
+ settle(failure);
67
80
  }
68
81
  state.processing = false;
69
82
  }
@@ -71,7 +84,9 @@ export class EventQueue {
71
84
  while (state.events.length > this._maxQueueLengthPerLane) {
72
85
  const dropped = state.events.shift();
73
86
  this._metrics.dropped += 1;
74
- dropped?.resolve();
87
+ // A dropped event was never handled, so its caller hears about it — the
88
+ // inbox loop holds the ack instead of acking work that fell off the queue.
89
+ dropped?.settle(new Error(`EventQueue dropped an event on laneKey=${laneKey} (queue over ${this._maxQueueLengthPerLane})`));
75
90
  runtimeLog.warn('EventQueue', `dropped oldest event laneKey=${laneKey} max=${this._maxQueueLengthPerLane}`);
76
91
  }
77
92
  }
@@ -10,4 +10,11 @@ export declare class SeenMessages {
10
10
  constructor(maxSize?: number);
11
11
  has(messageId: string | null | undefined): boolean;
12
12
  mark(messageId: string | null | undefined): void;
13
+ /**
14
+ * Forget a mark, so the next delivery of the same id is processed rather than
15
+ * skipped. ZIG-1191: marking happens BEFORE the work, to hold the door against
16
+ * a concurrent duplicate — which means a failure has to take the mark back, or
17
+ * the redelivery that a held ack buys is silently dropped as a duplicate.
18
+ */
19
+ unmark(messageId: string | null | undefined): void;
13
20
  }
@@ -28,4 +28,19 @@ export class SeenMessages {
28
28
  this.seen.delete(oldest);
29
29
  }
30
30
  }
31
+ /**
32
+ * Forget a mark, so the next delivery of the same id is processed rather than
33
+ * skipped. ZIG-1191: marking happens BEFORE the work, to hold the door against
34
+ * a concurrent duplicate — which means a failure has to take the mark back, or
35
+ * the redelivery that a held ack buys is silently dropped as a duplicate.
36
+ */
37
+ unmark(messageId) {
38
+ if (!messageId || typeof messageId !== 'string')
39
+ return;
40
+ if (!this.seen.delete(messageId))
41
+ return;
42
+ const at = this.order.indexOf(messageId);
43
+ if (at !== -1)
44
+ this.order.splice(at, 1);
45
+ }
31
46
  }