@ziggs-ai/agent-sdk 0.2.1 → 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 (62) hide show
  1. package/README.md +1 -1
  2. package/dist/AgentHost.d.ts +21 -20
  3. package/dist/AgentHost.js +60 -136
  4. package/dist/adapters/McpAdapter.js +14 -13
  5. package/dist/agent/Agent.js +4 -0
  6. package/dist/agent/ClaudeSdkAgent.d.ts +5 -0
  7. package/dist/agent/ClaudeSdkAgent.js +25 -6
  8. package/dist/agent/claudeCognition.d.ts +7 -0
  9. package/dist/agent/claudeCognition.js +38 -7
  10. package/dist/agent/claudeSdkProtocol.d.ts +8 -7
  11. package/dist/agent/claudeSdkProtocol.js +8 -7
  12. package/dist/agent/claudeSdkTools.d.ts +4 -2
  13. package/dist/agent/claudeSdkTools.js +26 -6
  14. package/dist/agent/toolManagerToSdkMcp.js +1 -0
  15. package/dist/index.d.ts +5 -9
  16. package/dist/index.js +12 -6
  17. package/dist/ingress/normalizeIncoming.d.ts +17 -3
  18. package/dist/ingress/normalizeIncoming.js +11 -26
  19. package/dist/runtime/AgentMachine.d.ts +5 -2
  20. package/dist/runtime/AgentMachine.js +8 -2
  21. package/dist/runtime/runMechanical.d.ts +3 -1
  22. package/dist/runtime/runMechanical.js +2 -2
  23. package/dist/runtime/runTurn.d.ts +3 -1
  24. package/dist/runtime/runTurn.js +25 -7
  25. package/dist/runtime/wakeWriteKeys.d.ts +25 -0
  26. package/dist/runtime/wakeWriteKeys.js +134 -0
  27. package/dist/server/ConnectionPool.d.ts +7 -4
  28. package/dist/server/ConnectionPool.js +11 -14
  29. package/dist/server/ZiggsEffectHandler.js +17 -12
  30. package/dist/server/inboxLoop.d.ts +50 -0
  31. package/dist/server/{InboxCatchUp.js → inboxLoop.js} +63 -52
  32. package/dist/server/runLauncher.js +1 -1
  33. package/dist/server/tasks/TaskService.d.ts +2 -1
  34. package/dist/server/tasks/TaskService.js +5 -2
  35. package/dist/server/tools/index.d.ts +7 -3
  36. package/dist/server/tools/index.js +13 -3
  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/artifactTools.d.ts +3 -0
  41. package/dist/server/tools/tier2/artifactTools.js +82 -0
  42. package/dist/server/tools/tier2/chatTools.d.ts +3 -0
  43. package/dist/server/tools/tier2/chatTools.js +37 -0
  44. package/dist/server/tools/tier2/connectionTools.d.ts +1 -0
  45. package/dist/server/tools/tier2/connectionTools.js +52 -5
  46. package/dist/server/tools/tier2/contextTools.d.ts +4 -2
  47. package/dist/server/tools/tier2/contextTools.js +105 -12
  48. package/dist/server/tools/tier2/grantsTools.d.ts +3 -0
  49. package/dist/server/tools/tier2/grantsTools.js +81 -0
  50. package/dist/server/tools/tier2/linkTools.d.ts +7 -0
  51. package/dist/server/tools/tier2/linkTools.js +156 -0
  52. package/dist/server/tools/tier2/paymentTools.d.ts +0 -1
  53. package/dist/server/tools/tier2/paymentTools.js +9 -13
  54. package/dist/types.d.ts +40 -0
  55. package/package.json +2 -2
  56. package/dist/server/InboxCatchUp.d.ts +0 -24
  57. package/dist/server/ziggsconnect/ZiggsConnectClient.d.ts +0 -35
  58. package/dist/server/ziggsconnect/ZiggsConnectClient.js +0 -95
  59. package/dist/server/ziggscontext/ZiggsContextClient.d.ts +0 -53
  60. package/dist/server/ziggscontext/ZiggsContextClient.js +0 -124
  61. package/dist/server/ziggspay/ZiggsPayClient.d.ts +0 -82
  62. package/dist/server/ziggspay/ZiggsPayClient.js +0 -203
package/README.md CHANGED
@@ -105,7 +105,7 @@ Examples in the repo: `examples/agents/*.js` (e.g. coffee, expense, delivery age
105
105
  - **Tier 1 — protocol grammar (`server/tools/tier1/`, `PROTOCOL_TOOLS`).** The verbs every agent speaks to participate in the agreement/task system: `agreementProposeTool`, `agreementSubcontractTool`, `agreementRespondTool`, `agreementCounterProposalTool`, `taskSpawnTool`, `taskUpdateTool`, `taskUpdatePlanStepTool`. **Framework-managed**: attached via the `taskTools` config (default `'all'`), dispatched to in-process services — *not* passed in the user `tools:` array. Publishing open work is part of this grammar: `agreement_propose({ proposedTo: "everyone" })` (there is no separate publish tool).
106
106
  - **Tier 2 — capability bundles (`server/tools/tier2/`, opt-in, HTTP-backed).** Discrete capability domains an agent chooses to have. **Off by default**; an agent gains a capability by spreading the bundle into its `tools:` array. Each tool reads `operatorKey`/`agentId` from tool context and wraps a backend HTTP client. Bundles: `DISCOVERY_TOOLS` (`agentSearchTool`, `agentGetTool`), `MARKETPLACE_TOOLS` (`marketplaceViewTool` — read-only), `PAYMENT_TOOLS` (`paymentBalanceTool`, `paymentTransferTool`, …).
107
107
  - **Adapters / utils:** `OpenAIAdapter`, JSON helpers, formatters
108
- - **Re-exported** from `@ziggs-ai/api-client`: `WebSocketClient`, `ContextReadClient`, `ContextDiscoveryClient`, `ContextGrantsClient`, `ArtifactsClient`, `MessagesClient`, `ScopeClient`, `AgentSearchClient`, `getBackendUrl`, `getWebSocketUrl`, etc. (There is no `ContextReader` / `ContextWriter` — use the clients above from `@ziggs-ai/api-client` directly.)
108
+ - **Re-exported** from `@ziggs-ai/api-client`: `ContextReadClient`, `ContextDiscoveryClient`, `ContextGrantsClient`, `ArtifactsClient`, `MessagesClient`, `ScopeClient`, `AgentSearchClient`, `getBackendUrl`, `getWebSocketUrl`, etc. (There is no `ContextReader` / `ContextWriter` — use the clients above from `@ziggs-ai/api-client` directly. The WebSocket client was removed — delivery is HTTP inbox long-poll; read/ack via `InboxClient` from `@ziggs-ai/api-client`.)
109
109
 
110
110
  Package export: `"."` → `dist/index.js` (see `package.json`; `npm run build` before publish or monorepo tests).
111
111
 
@@ -25,7 +25,6 @@ export interface SessionStore {
25
25
  import { ToolManager } from './tools/ToolManager.js';
26
26
  import type { ToolDefinition } from './tools/defineTool.js';
27
27
  import { OpenAIAdapter } from './adapters/OpenAIAdapter.js';
28
- import { WebSocketClient } from '@ziggs-ai/api-client';
29
28
  /**
30
29
  * The single fleet-wide default model — the one place the fallback lives.
31
30
  * Change this (or set `LLM_MODEL`) to move the whole fleet; nothing hardcodes a
@@ -75,6 +74,15 @@ export interface AgentHostOptions {
75
74
  cognition?: CognitionKind;
76
75
  /** Max agentic turns per Claude SDK wake. Default 30. */
77
76
  claudeMaxTurns?: number;
77
+ /**
78
+ * ZIG-860: claude-sdk agents resume their transcript across wakes by default,
79
+ * so per-wake input grows monotonically on a long lane. When true, each wake
80
+ * starts a fresh Claude session — the protocol is backend-authoritative (the
81
+ * brain re-reads via ziggs_context_snapshot / the wake event), so the
82
+ * transcript is convenience memory, not source of truth. Ideal for
83
+ * step-driven orchestrators whose every wake already carries what it needs.
84
+ */
85
+ claudeStatelessPerWake?: boolean;
78
86
  services?: AgentServices;
79
87
  workflow?: Workflow;
80
88
  states?: Workflow['states'];
@@ -133,7 +141,6 @@ export declare class AgentHost {
133
141
  toolManager: ToolManager;
134
142
  llm?: OpenAIAdapter | LlmAdapter;
135
143
  agent: Agent | ClaudeSdkAgent;
136
- wsClient: WebSocketClient;
137
144
  private eff;
138
145
  private eventQueue;
139
146
  private sessions;
@@ -145,39 +152,33 @@ export declare class AgentHost {
145
152
  private agreementService;
146
153
  private messageSender;
147
154
  private seenMessages;
148
- /** Lazy inbox client for advancing the watermark on the live-push path (ZIG-832 P4). */
149
- private _inbox?;
150
155
  private stateTimers;
151
- private catchUpInFlight;
152
- private inboxCatchUpEnabled;
156
+ /** The per-agent inbox long-poll loop — this agent's sole wake source. */
157
+ private inboxLoop;
158
+ private inboxLoopEnabled;
153
159
  constructor(options?: AgentHostOptions);
154
160
  private _validate;
155
161
  private _setup;
156
162
  /**
157
- * Coalesce concurrent connect/reconnect bursts into one catch-up pass.
158
- */
159
- private _ensureCatchUp;
160
- private _runInboxCatchUp;
161
- /**
162
- * Push notification handler — invoked when the backend sees a relevant
163
- * resource change (artifact created, task state transition, agreement
164
- * updated). The agent decides which session/lane to wake based on the
165
- * event payload; it pulls the actual data via the primitive endpoints
166
- * from inside the tick.
163
+ * Handler for a resource change (artifact created, task state transition,
164
+ * agreement updated) surfaced by the inbox loop. The agent decides which
165
+ * session/lane to route it to from the event payload, then pulls the actual
166
+ * data via the primitive endpoints from inside the tick.
167
167
  *
168
+ * No client-side relevance or echo guard: event targeting is backend policy
169
+ * now (the law never routes an agent its own writes), and the inbox loop is a
170
+ * single delta-read channel — there is no second channel to dedup against.
168
171
  * Lightweight: defers all work to a tick by enqueueing a synthetic event
169
- * carrying just enough context for the agent to route it.
172
+ * carrying just enough context to route it.
170
173
  */
171
174
  handleResourceEvent(event: Record<string, unknown>): Promise<void>;
172
175
  /** Wire entry. Normalize → resolve session → enqueue on the right lane. */
173
176
  handleMessage(text: string, metadata?: Record<string, unknown>): Promise<void>;
174
- /** Best-effort: advance the inbox watermark for a chat handled live (ZIG-832 P4). */
175
- private _ackChatWatermark;
176
177
  private _processEvent;
177
178
  /** Arms the lane's liveness timer iff stateName is a parked state declaring timeoutMs. */
178
179
  private _armIfTimedParked;
179
180
  private _resolveOwnAgentId;
180
- connectAsync(timeout?: number): Promise<this>;
181
+ connectAsync(_timeout?: number): Promise<this>;
181
182
  private _bootstrapAssignedActiveTasks;
182
183
  /** ZIG-802: replay task-assigned for an existing active task (demo recovery). */
183
184
  wakeTaskAssignment(taskId: string): Promise<void>;
package/dist/AgentHost.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { Agent } from './agent/Agent.js';
2
3
  import { ClaudeSdkAgent } from './agent/ClaudeSdkAgent.js';
3
4
  import { applyBrainToHostOptions, isClaudeCognition, } from './brain/index.js';
@@ -13,10 +14,10 @@ import { ToolManager } from './tools/ToolManager.js';
13
14
  import { PROTOCOL_TOOLS } from './server/tools/tier1/protocolTools.js';
14
15
  import { OpenAIAdapter } from './adapters/OpenAIAdapter.js';
15
16
  import { AnthropicAdapter } from './adapters/AnthropicAdapter.js';
16
- import { WebSocketClient, ArtifactsClient, ContextReadClient, TelemetryClient, InboxClient, } from '@ziggs-ai/api-client';
17
+ import { ArtifactsClient, ContextReadClient, TelemetryClient, sendChatMessage, } from '@ziggs-ai/api-client';
17
18
  import { TaskService } from './server/tasks/TaskService.js';
18
19
  import { AgreementService } from './server/agreements/AgreementService.js';
19
- import { runInboxCatchUp } from './server/InboxCatchUp.js';
20
+ import { runInboxLoop } from './server/inboxLoop.js';
20
21
  import { SeenMessages } from './server/SeenMessages.js';
21
22
  import { runtimeLog } from './shared/runtimeLog.js';
22
23
  /**
@@ -100,7 +101,6 @@ export class AgentHost {
100
101
  // drives query() directly and needs no LLM adapter.
101
102
  llm;
102
103
  agent;
103
- wsClient;
104
104
  eff;
105
105
  eventQueue;
106
106
  sessions = new Map();
@@ -112,11 +112,10 @@ export class AgentHost {
112
112
  agreementService;
113
113
  messageSender;
114
114
  seenMessages = new SeenMessages();
115
- /** Lazy inbox client for advancing the watermark on the live-push path (ZIG-832 P4). */
116
- _inbox;
117
115
  stateTimers;
118
- catchUpInFlight = null;
119
- inboxCatchUpEnabled;
116
+ /** The per-agent inbox long-poll loop — this agent's sole wake source. */
117
+ inboxLoop = null;
118
+ inboxLoopEnabled;
120
119
  constructor(options = {}) {
121
120
  const resolved = applyBrainToHostOptions(options);
122
121
  this.options = {
@@ -144,8 +143,9 @@ export class AgentHost {
144
143
  brain: resolved.brain,
145
144
  cognition: resolved.cognition ?? 'fsm',
146
145
  claudeMaxTurns: resolved.claudeMaxTurns,
146
+ claudeStatelessPerWake: resolved.claudeStatelessPerWake,
147
147
  };
148
- this.inboxCatchUpEnabled = options.inboxCatchUp !== false;
148
+ this.inboxLoopEnabled = options.inboxCatchUp !== false;
149
149
  this.sessionResolver = options.sessionResolver || DEFAULT_SESSION_RESOLVER;
150
150
  this.tickTimeoutMs = options.tickTimeoutMs ?? 120_000;
151
151
  this.memory = options.memory ?? new InMemoryStore();
@@ -177,19 +177,24 @@ export class AgentHost {
177
177
  const services = createBackendServices(this.options);
178
178
  this.toolManager = services.toolManager;
179
179
  this.llm = services.llm;
180
- this.wsClient = new WebSocketClient({
181
- wsUrl: this.options.wsUrl,
180
+ // Outbound send is HTTP (delivery law, phase 4): the agent no longer holds a
181
+ // socket. `POST /chats/:id/messages` persists the whole message — the same
182
+ // row the WS path persisted. Live-token streaming had no HTTP path, so
183
+ // `chunkSender` is intentionally dropped: partials degrade, the full reply
184
+ // is never lost (runTurn always follows a stream with a whole send-message).
185
+ const creds = {
182
186
  operatorKey: this.options.operatorKey,
183
187
  agentId: this.options.agentId,
184
- label: this.options.name || 'agent',
185
- });
186
- const messageSender = (message, receiverId, sessionId, messageId) => {
187
- this.wsClient.send(sessionId, receiverId, message, messageId ? { messageId } : undefined);
188
- return Promise.resolve(undefined);
189
- };
190
- const chunkSender = (sessionId, receiverId, text, messageId) => {
191
- this.wsClient.send(sessionId, receiverId, text, { partial: true, messageId });
192
188
  };
189
+ const messageSender = (message, receiverId, sessionId, messageId) => sendChatMessage({
190
+ chatId: sessionId,
191
+ receiverId,
192
+ text: message,
193
+ // messageId is the backend's (chatId, messageId) dedup key. Callers
194
+ // pass a wake-derived id for replay-stability; fall back to a fresh id
195
+ // only when absent (matches the old WS path letting the server assign).
196
+ messageId: messageId ?? `sdk_${randomUUID()}`,
197
+ }, creds);
193
198
  this.taskService = services.taskService;
194
199
  this.agreementService = services.agreementService;
195
200
  this.messageSender = messageSender;
@@ -225,7 +230,6 @@ export class AgentHost {
225
230
  taskService: services.taskService,
226
231
  agreementService: services.agreementService,
227
232
  messageSender,
228
- chunkSender,
229
233
  toolManager: services.toolManager,
230
234
  operatorKey: this.options.operatorKey,
231
235
  agentId: this.options.agentId,
@@ -247,6 +251,7 @@ export class AgentHost {
247
251
  // eff({ kind: 'tool-call' }) → the shared executor, which sources those
248
252
  // services from the effect handler's deps like the FSM path (ZIG-775).
249
253
  maxTurns: this.options.claudeMaxTurns,
254
+ statelessPerWake: this.options.claudeStatelessPerWake,
250
255
  })
251
256
  : new Agent({
252
257
  workflow,
@@ -255,54 +260,18 @@ export class AgentHost {
255
260
  });
256
261
  this.eventQueue = new EventQueue((event, laneKey) => this._processEvent(event, laneKey));
257
262
  this.stateTimers = new StateTimers((ev, laneKey) => { void this.eventQueue.enqueue(ev, laneKey); });
258
- this.wsClient.setMessageHandler((text, metadata) => this.handleMessage(text, metadata));
259
- this.wsClient.setResourceEventHandler((event) => this.handleResourceEvent(event));
260
- if (this.inboxCatchUpEnabled) {
261
- this.wsClient.onConnect(() => {
262
- void this._ensureCatchUp();
263
- });
264
- }
265
- }
266
- /**
267
- * Coalesce concurrent connect/reconnect bursts into one catch-up pass.
268
- */
269
- _ensureCatchUp() {
270
- if (!this.catchUpInFlight) {
271
- this.catchUpInFlight = this._runInboxCatchUp().finally(() => {
272
- this.catchUpInFlight = null;
273
- });
274
- }
275
- return this.catchUpInFlight;
276
- }
277
- async _runInboxCatchUp() {
278
- const label = this.options.name || 'agent';
279
- try {
280
- await runInboxCatchUp({
281
- creds: {
282
- operatorKey: this.options.operatorKey,
283
- agentId: this.options.agentId,
284
- },
285
- ownAgentId: this.options.agentId,
286
- baseUrl: this.options.wsUrl ? undefined : undefined,
287
- seen: this.seenMessages,
288
- label,
289
- onMessage: (text, metadata) => this.handleMessage(text, metadata),
290
- onResourceEvent: (event) => this.handleResourceEvent(event),
291
- });
292
- }
293
- catch (err) {
294
- runtimeLog.warn(label, `inbox catch-up failed: ${err?.message ?? err}`);
295
- }
296
263
  }
297
264
  /**
298
- * Push notification handler — invoked when the backend sees a relevant
299
- * resource change (artifact created, task state transition, agreement
300
- * updated). The agent decides which session/lane to wake based on the
301
- * event payload; it pulls the actual data via the primitive endpoints
302
- * from inside the tick.
265
+ * Handler for a resource change (artifact created, task state transition,
266
+ * agreement updated) surfaced by the inbox loop. The agent decides which
267
+ * session/lane to route it to from the event payload, then pulls the actual
268
+ * data via the primitive endpoints from inside the tick.
303
269
  *
270
+ * No client-side relevance or echo guard: event targeting is backend policy
271
+ * now (the law never routes an agent its own writes), and the inbox loop is a
272
+ * single delta-read channel — there is no second channel to dedup against.
304
273
  * Lightweight: defers all work to a tick by enqueueing a synthetic event
305
- * carrying just enough context for the agent to route it.
274
+ * carrying just enough context to route it.
306
275
  */
307
276
  async handleResourceEvent(event) {
308
277
  if (!event || typeof event !== 'object')
@@ -310,29 +279,6 @@ export class AgentHost {
310
279
  const ownAgentId = this.options.agentId;
311
280
  if (!ownAgentId)
312
281
  return;
313
- // The agent's own writes aren't news to it (same rule the backend inbox
314
- // applies to counts). The backend already skips agent actors on emit;
315
- // this guards the same invariant host-side so a self-caused event can
316
- // never re-wake the brain — that echo was a token-burning wake loop for
317
- // LLM cognitions (one wake's tool calls seeded the next wake, forever).
318
- if (event.actorId != null && String(event.actorId) === ownAgentId) {
319
- runtimeLog.debug('AgentHost', `skip self-caused resource event kind=${event.kind ?? 'unknown'} resourceId=${event.resourceId ?? 'null'}`);
320
- return;
321
- }
322
- // A `message` resource event is the second channel for news the message
323
- // push already delivered — the backend emits BOTH a `chat:message` frame
324
- // and a `resource_changed {kind:'message', resourceId: messageId}` for
325
- // every send. The resource frame exists for offline recovery (queue +
326
- // launcher wake); when we already processed that messageId live via
327
- // handleMessage, waking again just re-runs the brain over handled news
328
- // (every inbound chat message cost a second wake). Unseen ids still wake:
329
- // that's the recovery path working.
330
- if (event.kind === 'message' &&
331
- typeof event.resourceId === 'string' &&
332
- this.seenMessages.has(event.resourceId)) {
333
- runtimeLog.debug('AgentHost', `skip resource event for already-handled message ${event.resourceId}`);
334
- return;
335
- }
336
282
  // Map the event to a candidate session key using the configured resolver
337
283
  // on a synthetic normalized-event shape. Default resolver buckets by
338
284
  // chatId; agents using a different sessionResolver (agreementId, taskId,
@@ -341,8 +287,6 @@ export class AgentHost {
341
287
  const fakeNormalized = {
342
288
  chatId: event.chatId,
343
289
  event: { type: 'message', timestamp: Date.now(), text: '' },
344
- shouldProcess: true,
345
- reason: null,
346
290
  };
347
291
  const sessionId = this.sessionResolver(fakeNormalized, ownAgentId);
348
292
  if (!sessionId)
@@ -375,12 +319,10 @@ export class AgentHost {
375
319
  enrichedText = String(terms.description);
376
320
  }
377
321
  }
322
+ // Classification + shape only. Targeting is backend policy now — the inbox
323
+ // loop delivers only what is fenced to this agent, so there is no
324
+ // client-side "is this for me" gate to apply.
378
325
  const normalized = normalizeIncomingEvent({ text: enrichedText, metadata, ownAgentId });
379
- if (!normalized.shouldProcess) {
380
- const receiverId = metadata.receiver?.id ?? metadata.receiverId;
381
- runtimeLog.warn('AgentHost', `skip reason=${normalized.reason || 'not_relevant'} ownAgentId=${ownAgentId} receiverId=${receiverId ?? 'null'} chatId=${normalized.chatId ?? 'null'}`);
382
- return;
383
- }
384
326
  const sessionId = this.sessionResolver(normalized, ownAgentId);
385
327
  if (!sessionId) {
386
328
  runtimeLog.warn('AgentHost', `session resolver returned null ownAgentId=${ownAgentId} chatId=${normalized.chatId ?? 'null'}`);
@@ -390,38 +332,10 @@ export class AgentHost {
390
332
  if (!this.sessions.has(laneKey)) {
391
333
  this.sessions.set(laneKey, { sessionId, ownAgentId });
392
334
  }
393
- const enqueued = this.eventQueue.enqueue(normalized.event, laneKey);
394
- // ZIG-832 P4: advance this chat's inbox watermark for a message handled on
395
- // the LIVE push path. Without it, only catch-up acks — so on a re-host the
396
- // server watermark still sits before this message and catch-up re-delivers
397
- // it, re-running a finished pipeline (the cross-wake token burn). Mirrors
398
- // catch-up's ack-on-delivery; newer messages carry a later ts and still
399
- // deliver, so a genuinely new turn in the same chat is not lost. Best-effort:
400
- // never block or fail the wake.
401
- const msgTs = (metadata.createdAt ?? metadata.timestamp ?? metadata.sentTimestamp);
402
- if (normalized.chatId && typeof msgTs === 'string' && msgTs) {
403
- void this._ackChatWatermark(normalized.chatId, msgTs);
404
- }
405
- else if (normalized.chatId) {
406
- // ZIG-860: this ack silently not firing is exactly how the re-delivery
407
- // bug hid — a live message with no usable timestamp must be loud.
408
- runtimeLog.warn('AgentHost', `live-ack skipped: no timestamp on message chatId=${normalized.chatId} messageId=${messageId ?? 'null'}`);
409
- }
410
- return enqueued;
411
- }
412
- /** Best-effort: advance the inbox watermark for a chat handled live (ZIG-832 P4). */
413
- async _ackChatWatermark(chatId, upTo) {
414
- try {
415
- if (!this._inbox) {
416
- this._inbox = new InboxClient(this.options.operatorKey, this.options.agentId);
417
- }
418
- await this._inbox.ack([{ kind: 'chat', id: chatId, upTo }]);
419
- }
420
- catch (e) {
421
- // warn, not debug (ZIG-860): a failing live-ack means catch-up will
422
- // re-deliver this chat's backlog after the next host eviction.
423
- runtimeLog.warn('AgentHost', `live-ack chat ${chatId} failed: ${e?.message ?? e}`);
424
- }
335
+ // The inbox loop owns ack (act-before-ack, delivery law); the host only
336
+ // routes the event onto its lane. No live-path watermark ack — there is no
337
+ // separate live/push channel to reconcile against anymore.
338
+ return this.eventQueue.enqueue(normalized.event, laneKey);
425
339
  }
426
340
  async _processEvent(event, laneKey) {
427
341
  const session = this.sessions.get(laneKey);
@@ -540,16 +454,26 @@ export class AgentHost {
540
454
  const agent = metadata.agent;
541
455
  return this.options.agentId || self?.id || agent?.id || metadata.ziggsAgentId || null;
542
456
  }
543
- async connectAsync(timeout) {
544
- await this.wsClient.connectAsync(timeout);
545
- if (this.inboxCatchUpEnabled) {
546
- await this._ensureCatchUp();
547
- // Backend flushPendingMessages can emit after auth; re-poll once so inbox
548
- // ack catches stragglers and we don't leave newMessages > 0 (ZIG-462).
549
- await new Promise((r) => setTimeout(r, 750));
550
- this.catchUpInFlight = null;
551
- await this._ensureCatchUp();
457
+ async connectAsync(_timeout) {
458
+ // Bootstrap active assigned tasks once (HTTP pull → lane), then start the
459
+ // per-agent inbox long-poll loop — the agent's sole wake source. There is
460
+ // no socket to connect and no reconnect: a crash is just the loop's next
461
+ // iteration (the cursor is server-side). `_timeout` is accepted for
462
+ // backward-compatible call sites but no longer gates a handshake.
463
+ if (this.inboxLoopEnabled) {
552
464
  await this._bootstrapAssignedActiveTasks();
465
+ const label = this.options.name || 'agent';
466
+ this.inboxLoop = runInboxLoop({
467
+ creds: {
468
+ operatorKey: this.options.operatorKey,
469
+ agentId: this.options.agentId,
470
+ },
471
+ ownAgentId: this.options.agentId,
472
+ seen: this.seenMessages,
473
+ label,
474
+ onMessage: (text, metadata) => this.handleMessage(text, metadata),
475
+ onResourceEvent: (event) => this.handleResourceEvent(event),
476
+ });
553
477
  }
554
478
  return this;
555
479
  }
@@ -630,11 +554,11 @@ export class AgentHost {
630
554
  chatId: full.originChatId,
631
555
  }, laneKey);
632
556
  }
633
- disconnect() { this.stateTimers.clearAll(); this.wsClient.disconnect(); }
557
+ disconnect() { this.stateTimers.clearAll(); void this.inboxLoop?.stop(); this.inboxLoop = null; }
634
558
  /** Waits for all in-flight ticks to complete. Call before process.exit() on SIGTERM. */
635
559
  drain(timeoutMs = 30_000) { return this.eventQueue.waitForIdle(timeoutMs); }
636
560
  registerTool(tool) { return this.toolManager.register(tool); }
637
- isConnected() { return this.wsClient.isConnected(); }
561
+ isConnected() { return this.inboxLoop?.running ?? false; }
638
562
  }
639
563
  /**
640
564
  * createAgent(config) → AgentHost
@@ -1,6 +1,7 @@
1
1
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
2
  import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
3
3
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
4
+ import { ConnectionsClient } from '@ziggs-ai/api-client';
4
5
  import { AgentHost } from '../AgentHost.js';
5
6
  import { thinkingDefaults } from '../runtime/defaults.js';
6
7
  /**
@@ -368,21 +369,21 @@ export async function createMcpAgent(options) {
368
369
  if (!transportConfig.serverUrl)
369
370
  return null;
370
371
  const operatorKey = transportConfig.operatorKey || process.env.ZIGGS_OPERATOR_KEY;
371
- const res = await fetch(`${backendBase}/connections/mcp/requests`, {
372
- method: 'POST',
373
- headers: {
374
- 'content-type': 'application/json',
375
- authorization: `Bearer ${operatorKey}`,
376
- 'x-agent-id': agentId,
377
- 'x-on-behalf-of-user': userId,
378
- },
379
- body: JSON.stringify({
372
+ if (!operatorKey)
373
+ return null;
374
+ try {
375
+ const body = await new ConnectionsClient(operatorKey, agentId, backendBase).requestMcpConnection({
380
376
  serverUrl: transportConfig.serverUrl,
381
377
  tools: tools.map(t => t.name),
382
- }),
383
- });
384
- const body = (await res.json().catch(() => null));
385
- return body?.request?.requestId ?? null;
378
+ onBehalfOfUserId: userId,
379
+ });
380
+ return (body?.request?.requestId) ?? null;
381
+ }
382
+ catch {
383
+ // Best-effort: the backend dedups pending requests and the caller falls
384
+ // back to a plain Connections-page link when there's no requestId.
385
+ return null;
386
+ }
386
387
  };
387
388
  const caller = async (name, args, context) => {
388
389
  const userId = typeof context.senderId === 'string' ? context.senderId : undefined;
@@ -1,4 +1,5 @@
1
1
  import { AgentMachine } from '../runtime/AgentMachine.js';
2
+ import { createWakeWriteKeys } from '../runtime/wakeWriteKeys.js';
2
3
  import { runTurn } from '../runtime/runTurn.js';
3
4
  import { PromptBuilder } from '../runtime/PromptBuilder.js';
4
5
  /**
@@ -43,6 +44,9 @@ export class Agent {
43
44
  memory,
44
45
  input: { identity, ctx, state },
45
46
  transitionTap,
47
+ // One key stream per wake, shared by every state this tick runs through —
48
+ // per-state streams would restart the ordinal and collide across states.
49
+ wakeKeys: createWakeWriteKeys(identity.laneKey, event),
46
50
  });
47
51
  // `send` awaits the recursive _step until the machine settles at a
48
52
  // parked state (or stops). When it returns, we have the final state.
@@ -13,6 +13,10 @@ export interface ClaudeSdkAgentOptions {
13
13
  operatorKey: string;
14
14
  agentId: string;
15
15
  maxTurns?: number;
16
+ /** ZIG-860: when true, never resume the prior Claude session — each wake is a
17
+ * fresh transcript. Keeps per-wake input flat on step-driven orchestrators
18
+ * whose wakes already carry their state. */
19
+ statelessPerWake?: boolean;
16
20
  /** Injectable for tests. Defaults to @anthropic-ai/claude-agent-sdk query(). */
17
21
  queryFn?: typeof query;
18
22
  }
@@ -32,6 +36,7 @@ export declare class ClaudeSdkAgent {
32
36
  private operatorKey;
33
37
  private agentId;
34
38
  private maxTurns;
39
+ private statelessPerWake;
35
40
  private queryFn;
36
41
  constructor(opts: ClaudeSdkAgentOptions);
37
42
  tick({ event, ctx, state, identity, eff }: TickInput): Promise<TickOutput>;
@@ -1,6 +1,7 @@
1
1
  import { query } from '@anthropic-ai/claude-agent-sdk';
2
2
  import { outcomeFromEvent } from '../runtime/buildOutcome.js';
3
3
  import { bootstrapCtx } from '../runtime/bootstrapCtx.js';
4
+ import { createWakeWriteKeys } from '../runtime/wakeWriteKeys.js';
4
5
  import { CLAUDE_SDK_PROTOCOL } from './claudeSdkProtocol.js';
5
6
  import { buildZiggsSdkMcpServer } from './toolManagerToSdkMcp.js';
6
7
  import { claudeQueryOptions, buildClaudeEventPrompt, summarizeContext, extractAssistantText, } from './claudeCognition.js';
@@ -24,9 +25,9 @@ const MAX_TASK_INPUT_TOKENS = Number(process.env.CLAUDE_SDK_MAX_INPUT_TOKENS ??
24
25
  /**
25
26
  * Per-lane wake-rate circuit breaker. The per-wake caps above bound one wake;
26
27
  * nothing bounded the NUMBER of wakes — the agent-private artifact echo drove
27
- * one wake per wake, forever. The echo is fixed at the source (backend skips
28
- * agent actors; host drops self-caused events), so this is a backstop for any
29
- * future echo path: a lane waking faster than real traffic plausibly drives it
28
+ * one wake per wake, forever. The echo is fixed at the source (backend
29
+ * targeting never routes an agent its own writes), so this is a backstop for
30
+ * any future echo path: a lane waking faster than real traffic plausibly drives it
30
31
  * skips the LLM (and logs loudly) instead of draining the fleet key. */
31
32
  const WAKE_WINDOW_MS = 10 * 60_000;
32
33
  const MAX_WAKES_PER_WINDOW = Number(process.env.CLAUDE_SDK_MAX_WAKES_PER_WINDOW ?? 40);
@@ -141,6 +142,7 @@ export class ClaudeSdkAgent {
141
142
  operatorKey;
142
143
  agentId;
143
144
  maxTurns;
145
+ statelessPerWake;
144
146
  queryFn;
145
147
  constructor(opts) {
146
148
  if (!opts.workflow?.states)
@@ -158,6 +160,7 @@ export class ClaudeSdkAgent {
158
160
  this.operatorKey = opts.operatorKey;
159
161
  this.agentId = opts.agentId;
160
162
  this.maxTurns = opts.maxTurns ?? DEFAULT_MAX_TURNS;
163
+ this.statelessPerWake = opts.statelessPerWake ?? false;
161
164
  this.queryFn = opts.queryFn ?? query;
162
165
  }
163
166
  async tick({ event, ctx, state, identity, eff }) {
@@ -214,6 +217,10 @@ export class ClaudeSdkAgent {
214
217
  ].filter(Boolean).join('\n');
215
218
  const systemPrompt = `${CLAUDE_SDK_PROTOCOL}\n\n${roleBlock}`;
216
219
  const runId = crypto.randomUUID();
220
+ // Replay-stable write keys for this wake (runId is random and can't serve):
221
+ // a crash-replay of the same event derives the same keys, so the backend
222
+ // drops the duplicate artifacts / no-ops the repeated task transitions.
223
+ const wakeKeys = createWakeWriteKeys(identity.laneKey, rawEvent);
217
224
  const toolContext = {
218
225
  operatorKey: this.operatorKey,
219
226
  agentId: this.agentId,
@@ -228,6 +235,7 @@ export class ClaudeSdkAgent {
228
235
  // not just task_result events — see resolveOnBehalfAgreementId.
229
236
  onBehalfOfAgreementId: resolveOnBehalfAgreementId(rawEvent, contextSnap, this.agentId, identity.sessionId),
230
237
  runId,
238
+ wakeKeys,
231
239
  };
232
240
  const availableTools = await this.toolManager.getAvailableTools();
233
241
  const mcpServer = buildZiggsSdkMcpServer(availableTools, () => toolContext, {
@@ -248,7 +256,10 @@ export class ClaudeSdkAgent {
248
256
  if (line)
249
257
  runtimeLog.warn('ClaudeSdkAgent', `stderr session=${identity.sessionId}: ${line}`);
250
258
  },
251
- resume: nextCtx.claudeSessionId,
259
+ // ZIG-860: stateless-per-wake never resumes — each wake is a fresh
260
+ // transcript, so per-wake input stays flat instead of replaying the
261
+ // whole lane history every turn.
262
+ resume: this.statelessPerWake ? undefined : nextCtx.claudeSessionId,
252
263
  });
253
264
  let metrics = { llmCalls: 0, tokens: { total: 0, input: 0, output: 0, cacheRead: 0, cacheCreation: 0 } };
254
265
  let sessionId = nextCtx.claudeSessionId;
@@ -274,7 +285,10 @@ export class ClaudeSdkAgent {
274
285
  if (message.type === 'assistant') {
275
286
  const text = extractAssistantText(message).trim();
276
287
  if (text) {
277
- void eff({ kind: 'record-event', sessionId: identity.sessionId, entry: { kind: 'thought', text } })
288
+ // Ordinal drawn synchronously so the fire-and-forget below can't
289
+ // reorder key assignment against other writes of this wake.
290
+ const idempotencyKey = wakeKeys.artifactKey();
291
+ void eff({ kind: 'record-event', sessionId: identity.sessionId, entry: { kind: 'thought', text }, idempotencyKey })
278
292
  .catch((err) => runtimeLog.warn('ClaudeSdkAgent', `record thought failed session=${identity.sessionId}: ${err.message}`));
279
293
  }
280
294
  }
@@ -298,7 +312,12 @@ export class ClaudeSdkAgent {
298
312
  runtimeLog.warn('ClaudeSdkAgent', `query failed session=${identity.sessionId}: ${err.message}`);
299
313
  throw err;
300
314
  }
301
- if (sessionId)
315
+ // Stateless-per-wake keeps no session id — clear any carried over from the
316
+ // prior ctx so the next wake starts fresh too (ZIG-860). Otherwise remember
317
+ // this wake's session for resume.
318
+ if (this.statelessPerWake)
319
+ nextCtx.claudeSessionId = undefined;
320
+ else if (sessionId)
302
321
  nextCtx.claudeSessionId = sessionId;
303
322
  // ZIG-816: bound resumed-transcript growth. `metrics.tokens.input` is the
304
323
  // wake's billable input off the result message (dominated by the resumed
@@ -63,5 +63,12 @@ export declare function summarizeContext(snap: Record<string, unknown> | null |
63
63
  * Turn a Ziggs wake event + context digest into the user prompt that starts a
64
64
  * Claude-brain turn. The agent's standing instructions come from its system
65
65
  * prompt (protocol + role); this is just "here's what woke you, go handle it."
66
+ *
67
+ * ZIG-880: EventQueue coalesces events that arrive while the lane is busy into
68
+ * a `{type:'batch', events:[...]}` frame. The FSM path unwraps those
69
+ * (PromptBuilder), but this path used to render just "Event type: batch" —
70
+ * every payload inside (including task results) was invisible to the model,
71
+ * stalling delegation pipelines whose notifications share one chat lane.
72
+ * Render each batched event in order instead.
66
73
  */
67
74
  export declare function buildClaudeEventPrompt(event: RawEvent | undefined, contextSummary: string): string;