@ziggs-ai/agent-sdk 0.1.10 → 0.1.12

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 (35) hide show
  1. package/package.json +6 -3
  2. package/src/AgentHost.ts +347 -28
  3. package/src/adapters/AnthropicAdapter.ts +238 -0
  4. package/src/adapters/McpAdapter.ts +391 -15
  5. package/src/adapters/OpenAIAdapter.ts +20 -8
  6. package/src/adapters/index.ts +1 -0
  7. package/src/agent/Agent.ts +13 -2
  8. package/src/agent/ClaudeSdkAgent.ts +401 -0
  9. package/src/agent/claudeCognition.ts +139 -0
  10. package/src/agent/claudeSdkProtocol.ts +27 -0
  11. package/src/agent/claudeSdkTools.ts +81 -0
  12. package/src/agent/hostedAgentConnection.ts +41 -0
  13. package/src/agent/toolManagerToSdkMcp.ts +136 -0
  14. package/src/defineAgent.ts +22 -7
  15. package/src/index.ts +14 -5
  16. package/src/pricing/fleetTierByAgentId.gen.ts +53 -1
  17. package/src/runtime/AgentMachine.ts +143 -23
  18. package/src/runtime/PromptBuilder.ts +62 -6
  19. package/src/runtime/bootstrapCtx.ts +39 -0
  20. package/src/runtime/buildOutcome.ts +51 -6
  21. package/src/runtime/defaults.ts +12 -1
  22. package/src/runtime/deriveRespondsToProposals.ts +35 -0
  23. package/src/runtime/kickoff.ts +117 -0
  24. package/src/runtime/runMechanical.ts +106 -0
  25. package/src/runtime/runTurn.ts +236 -62
  26. package/src/runtime/validateWorkflow.ts +55 -15
  27. package/src/server/ConnectionPool.ts +56 -5
  28. package/src/server/InboxCatchUp.ts +37 -0
  29. package/src/server/StateTimers.ts +85 -0
  30. package/src/server/ZiggsEffectHandler.ts +198 -180
  31. package/src/server/agreements/AgreementService.ts +3 -3
  32. package/src/server/tasks/TaskService.ts +15 -3
  33. package/src/server/tools/tier1/protocolRunner.ts +221 -3
  34. package/src/server/tools/tier1/protocolTools.ts +19 -4
  35. package/src/types.ts +105 -29
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ziggs-ai/agent-sdk",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "Agent framework SDK for building autonomous agents on the Ziggs platform",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -16,11 +16,14 @@
16
16
  "test:watch": "node --experimental-test-module-mocks --import tsx/esm --test --watch test/*.test.ts"
17
17
  },
18
18
  "dependencies": {
19
+ "@anthropic-ai/claude-agent-sdk": "^0.3.204",
20
+ "@anthropic-ai/sdk": "^0.110.0",
19
21
  "@modelcontextprotocol/sdk": "^1.29.0",
20
- "@ziggs-ai/api-client": "^0.1.9",
22
+ "@ziggs-ai/api-client": "^0.1.28",
21
23
  "ajv": "^8.17.1",
22
24
  "dotenv": "^17.2.3",
23
- "openai": "^4.0.0"
25
+ "openai": "^4.0.0",
26
+ "zod": "^4.0.0"
24
27
  },
25
28
  "keywords": [
26
29
  "ziggs",
package/src/AgentHost.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  import type { AgentConfig, Ctx, EffectHandler, Identity, Workflow } from './types.js';
2
2
  import { Agent } from './agent/Agent.js';
3
+ import { ClaudeSdkAgent, type CognitionKind } from './agent/ClaudeSdkAgent.js';
4
+ import { CLAUDE_SDK_TOOLS } from './agent/claudeSdkTools.js';
5
+ import { resolveClaudeModel } from './agent/claudeCognition.js';
6
+
7
+ export type { CognitionKind };
3
8
  import { EventQueue } from './server/EventQueue.js';
9
+ import { StateTimers } from './server/StateTimers.js';
4
10
  import { createZiggsEffectHandler } from './server/ZiggsEffectHandler.js';
5
11
  import { normalizeIncomingEvent, type NormalizedEvent } from './ingress/normalizeIncoming.js';
6
12
  import { PromptBuilder } from './runtime/PromptBuilder.js';
@@ -21,12 +27,13 @@ import { ToolManager } from './tools/ToolManager.js';
21
27
  import type { ToolDefinition } from './tools/defineTool.js';
22
28
  import { PROTOCOL_TOOLS } from './server/tools/tier1/protocolTools.js';
23
29
  import { OpenAIAdapter } from './adapters/OpenAIAdapter.js';
30
+ import { AnthropicAdapter } from './adapters/AnthropicAdapter.js';
24
31
  import {
25
32
  WebSocketClient,
26
- MessagesClient,
27
33
  ArtifactsClient,
28
- ScopeClient,
34
+ ContextReadClient,
29
35
  TelemetryClient,
36
+ InboxClient,
30
37
  type MessageMetadata,
31
38
  } from '@ziggs-ai/api-client';
32
39
  import { TaskService } from './server/tasks/TaskService.js';
@@ -35,6 +42,15 @@ import { runInboxCatchUp } from './server/InboxCatchUp.js';
35
42
  import { SeenMessages } from './server/SeenMessages.js';
36
43
  import { runtimeLog } from './shared/runtimeLog.js';
37
44
 
45
+ /**
46
+ * The single fleet-wide default model — the one place the fallback lives.
47
+ * Change this (or set `LLM_MODEL`) to move the whole fleet; nothing hardcodes a
48
+ * model anywhere else. The id also picks the provider: a `claude-*` value routes
49
+ * to AnthropicAdapter (+ ANTHROPIC_API_KEY), anything else to OpenAIAdapter — see
50
+ * the `model?.startsWith('claude')` branch in createBackendServices below.
51
+ */
52
+ export const DEFAULT_MODEL = 'claude-haiku-4-5';
53
+
38
54
  // ConnectionPool lives in a separate module; resolved via a computed-path
39
55
  // dynamic import to avoid a circular import with AgentHost.
40
56
  async function loadConnectionPool(): Promise<new (opts?: { maxActive?: number; idleTimeoutMs?: number }) => unknown> {
@@ -63,8 +79,10 @@ interface AgentServices {
63
79
  [key: string]: unknown;
64
80
  }
65
81
 
82
+
66
83
  export interface AgentHostOptions {
67
84
  openaiKey?: string;
85
+ anthropicKey?: string;
68
86
  model?: string;
69
87
  operatorKey?: string;
70
88
  agentId?: string;
@@ -74,6 +92,10 @@ export interface AgentHostOptions {
74
92
  specialization?: string | null;
75
93
  tools?: ToolDefinition[];
76
94
  taskTools?: 'all' | 'none' | string[];
95
+ /** Cognition runtime: FSM (default) or Claude Agent SDK headless delegate. */
96
+ cognition?: CognitionKind;
97
+ /** Max agentic turns per Claude SDK wake. Default 30. */
98
+ claudeMaxTurns?: number;
77
99
  services?: AgentServices;
78
100
  workflow?: Workflow;
79
101
  // Convenience: states + initial without an explicit workflow.
@@ -98,6 +120,21 @@ export interface AgentHostOptions {
98
120
  effectTap?: (effect: import('./types.js').Effect, response: unknown) => void;
99
121
  /** Observability tap: called on every FSM state transition, no-match, depth-guard, or error-escape (ZIG-442). */
100
122
  transitionTap?: (event: import('./runtime/AgentMachine.js').TransitionEvent) => void;
123
+ /**
124
+ * Usage/cost tap: called once per settled tick with the tick's authoritative
125
+ * token usage (result.metrics). This is the SINGLE source of truth for
126
+ * usage/cost across every cognition — the FSM (llm-call + streamed reply +
127
+ * tool-internal LLM) and the Claude brain (incl. cache tokens) both funnel
128
+ * their spend here (ZIG-813). effectTap / tool generations are structural
129
+ * tracing only and must NOT also carry billable usage, or cost double-counts.
130
+ */
131
+ metricsTap?: (info: {
132
+ agentId: string;
133
+ sessionId: string;
134
+ model: string;
135
+ cognition: string;
136
+ metrics: import('./agent/Agent.js').TickOutput['metrics'];
137
+ }) => void;
101
138
  /** Production monitoring ingest (ZIG-360). Default on; set false to disable. */
102
139
  telemetryIngest?: boolean;
103
140
  /**
@@ -114,7 +151,8 @@ function createToolManager(options: AgentHostOptions): ToolManager {
114
151
  if (options.taskTools === 'none') taskTools = [];
115
152
  else if (Array.isArray(options.taskTools)) taskTools = PROTOCOL_TOOLS.filter((t: ToolDefinition) => (options.taskTools as string[]).includes(t.schema.function.name as string));
116
153
  else taskTools = PROTOCOL_TOOLS; // 'all' or undefined
117
- toolManager.registerAll([...taskTools, ...(Array.isArray(userTools) ? userTools : [])]);
154
+ const claudeSdkTools = options.cognition === 'claude-sdk' ? CLAUDE_SDK_TOOLS : [];
155
+ toolManager.registerAll([...taskTools, ...claudeSdkTools, ...(Array.isArray(userTools) ? userTools : [])]);
118
156
  return toolManager;
119
157
  }
120
158
 
@@ -124,14 +162,23 @@ function isLlmAdapter(v: unknown): v is LlmAdapter {
124
162
 
125
163
  function createBackendServices(options: AgentHostOptions) {
126
164
  const toolManager = createToolManager(options);
127
- const llm = isLlmAdapter(options.services?.llm)
128
- ? options.services!.llm as LlmAdapter
129
- : new OpenAIAdapter({ key: options.openaiKey, model: (options.services?.llm as { model?: string } | undefined)?.model || options.model });
165
+ const model = (options.services?.llm as { model?: string } | undefined)?.model || options.model;
166
+ // The Claude brain drives query() directly and never emits llm-call/stream-text
167
+ // effects, so it needs no adapter. Building one here would be dead — and worse,
168
+ // the provider client throws at construction on a missing key, so a claude-sdk
169
+ // agent left on the default (OpenAI) model with no openaiKey would fail to
170
+ // start over an adapter it never uses. Skip it.
171
+ const llm = options.cognition === 'claude-sdk'
172
+ ? undefined
173
+ : isLlmAdapter(options.services?.llm)
174
+ ? options.services!.llm as LlmAdapter
175
+ : model?.startsWith('claude')
176
+ ? new AnthropicAdapter({ key: options.anthropicKey, model })
177
+ : new OpenAIAdapter({ key: options.openaiKey, model });
130
178
  const operatorKey = options.operatorKey!;
131
179
  const agentId = options.agentId!;
132
- const messagesClient = new MessagesClient(operatorKey, agentId);
133
180
  const artifactsClient = new ArtifactsClient(operatorKey, agentId);
134
- const scopeClient = new ScopeClient(operatorKey, agentId);
181
+ const contextReadClient = new ContextReadClient(operatorKey, agentId);
135
182
  const promptBuilder = new PromptBuilder({ description: options.description, specialization: options.specialization });
136
183
  const taskService = new TaskService(operatorKey, agentId);
137
184
  const agreementService = new AgreementService(operatorKey, agentId);
@@ -139,9 +186,8 @@ function createBackendServices(options: AgentHostOptions) {
139
186
  return {
140
187
  toolManager,
141
188
  llm,
142
- messagesClient,
143
189
  artifactsClient,
144
- scopeClient,
190
+ contextReadClient,
145
191
  promptBuilder,
146
192
  taskService,
147
193
  agreementService,
@@ -166,8 +212,10 @@ interface LaneSession {
166
212
  export class AgentHost {
167
213
  options: Required<Pick<AgentHostOptions, 'agentId' | 'operatorKey' | 'openaiKey'>> & AgentHostOptions;
168
214
  toolManager!: ToolManager;
169
- llm!: OpenAIAdapter | LlmAdapter; // retained for external inspection
170
- agent!: Agent;
215
+ // Retained for external inspection. Undefined for the Claude brain, which
216
+ // drives query() directly and needs no LLM adapter.
217
+ llm?: OpenAIAdapter | LlmAdapter;
218
+ agent!: Agent | ClaudeSdkAgent;
171
219
  wsClient!: WebSocketClient;
172
220
  private eff!: EffectHandler;
173
221
  private eventQueue!: EventQueue;
@@ -180,13 +228,17 @@ export class AgentHost {
180
228
  private agreementService!: AgreementService;
181
229
  private messageSender!: (message: string, receiverId: string, sessionId: string) => Promise<unknown>;
182
230
  private seenMessages = new SeenMessages();
231
+ /** Lazy inbox client for advancing the watermark on the live-push path (ZIG-832 P4). */
232
+ private _inbox?: InboxClient;
233
+ private stateTimers!: StateTimers;
183
234
  private catchUpInFlight: Promise<void> | null = null;
184
235
  private inboxCatchUpEnabled: boolean;
185
236
 
186
237
  constructor(options: AgentHostOptions = {}) {
187
238
  this.options = {
188
239
  openaiKey: options.openaiKey || process.env.OPENAI_API_KEY,
189
- model: options.model || (!isLlmAdapter(options.services?.llm) ? options.services?.llm?.model : undefined) || 'gpt-5.4',
240
+ anthropicKey: options.anthropicKey || process.env.ANTHROPIC_API_KEY,
241
+ model: options.model || (!isLlmAdapter(options.services?.llm) ? options.services?.llm?.model : undefined) || DEFAULT_MODEL,
190
242
  operatorKey: options.operatorKey || process.env.ZIGGS_OPERATOR_KEY || undefined,
191
243
  agentId: options.agentId || '',
192
244
  name: options.name || null,
@@ -203,7 +255,10 @@ export class AgentHost {
203
255
  sessionResolver: options.sessionResolver,
204
256
  effectTap: options.effectTap,
205
257
  transitionTap: options.transitionTap,
258
+ metricsTap: options.metricsTap,
206
259
  inboxCatchUp: options.inboxCatchUp,
260
+ cognition: options.cognition ?? 'fsm',
261
+ claudeMaxTurns: options.claudeMaxTurns,
207
262
  } as Required<Pick<AgentHostOptions, 'agentId' | 'operatorKey' | 'openaiKey'>> & AgentHostOptions;
208
263
  this.inboxCatchUpEnabled = options.inboxCatchUp !== false;
209
264
  this.sessionResolver = options.sessionResolver || DEFAULT_SESSION_RESOLVER;
@@ -215,7 +270,17 @@ export class AgentHost {
215
270
  }
216
271
 
217
272
  private _validate(): void {
218
- if (!this.options.openaiKey) throw new Error('openaiKey is required');
273
+ if (this.options.cognition === 'claude-sdk') {
274
+ if (!this.options.anthropicKey) {
275
+ throw new Error('anthropicKey is required for claude-sdk cognition — pass { anthropicKey } or set ANTHROPIC_API_KEY');
276
+ }
277
+ } else if (!isLlmAdapter(this.options.services?.llm)) {
278
+ if (this.options.model?.startsWith('claude')) {
279
+ if (!this.options.anthropicKey) throw new Error('anthropicKey is required for claude-* models — pass { anthropicKey } or set ANTHROPIC_API_KEY');
280
+ } else if (!this.options.openaiKey) {
281
+ throw new Error('openaiKey is required');
282
+ }
283
+ }
219
284
  if (!this.options.operatorKey) throw new Error('operatorKey is required — pass { operatorKey } or set ZIGGS_OPERATOR_KEY');
220
285
  if (!this.options.agentId) throw new Error('agentId is required');
221
286
  }
@@ -252,13 +317,26 @@ export class AgentHost {
252
317
  states: this.options.states,
253
318
  };
254
319
  }
255
- if (!workflow?.states) throw new Error('AgentHost: workflow (or states + initial) is required');
320
+ if (!workflow?.states) {
321
+ // The Claude brain has no FSM — it drives query() per wake and parks. It
322
+ // still plugs into the host's lane/timer machinery via a trivial parked
323
+ // workflow, synthesized here so callers don't hand-write a dummy one.
324
+ if (this.options.cognition === 'claude-sdk') {
325
+ workflow = {
326
+ id: this.options.agentId,
327
+ description: this.options.description!,
328
+ initial: 'ready',
329
+ states: { ready: { kind: 'parked', transitions: [] } },
330
+ };
331
+ } else {
332
+ throw new Error('AgentHost: workflow (or states + initial) is required');
333
+ }
334
+ }
256
335
 
257
336
  this.eff = createZiggsEffectHandler({
258
337
  llm: services.llm as unknown as import('./server/ZiggsEffectHandler.js').ZiggsEffectDeps['llm'],
259
- messagesClient: services.messagesClient,
260
338
  artifactsClient: services.artifactsClient,
261
- scopeClient: services.scopeClient,
339
+ contextReadClient: services.contextReadClient,
262
340
  taskService: services.taskService,
263
341
  agreementService: services.agreementService,
264
342
  messageSender,
@@ -271,13 +349,32 @@ export class AgentHost {
271
349
  effectTap: this.options.effectTap,
272
350
  });
273
351
 
274
- this.agent = new Agent({
275
- workflow,
276
- toolManager: services.toolManager,
277
- promptBuilder: services.promptBuilder,
278
- });
352
+ this.agent = this.options.cognition === 'claude-sdk'
353
+ ? new ClaudeSdkAgent({
354
+ workflow,
355
+ toolManager: services.toolManager,
356
+ description: this.options.description!,
357
+ specialization: this.options.specialization,
358
+ model: resolveClaudeModel(
359
+ this.options.model,
360
+ (this.options.services?.llm as { model?: string } | undefined)?.model,
361
+ ),
362
+ anthropicKey: this.options.anthropicKey!,
363
+ operatorKey: this.options.operatorKey!,
364
+ agentId: this.options.agentId!,
365
+ // No taskService/agreementService: Claude-brain tools execute through
366
+ // eff({ kind: 'tool-call' }) → the shared executor, which sources those
367
+ // services from the effect handler's deps like the FSM path (ZIG-775).
368
+ maxTurns: this.options.claudeMaxTurns,
369
+ })
370
+ : new Agent({
371
+ workflow,
372
+ toolManager: services.toolManager,
373
+ promptBuilder: services.promptBuilder,
374
+ });
279
375
 
280
376
  this.eventQueue = new EventQueue((event, laneKey) => this._processEvent(event, laneKey));
377
+ this.stateTimers = new StateTimers((ev, laneKey) => { void this.eventQueue.enqueue(ev, laneKey); });
281
378
 
282
379
  this.wsClient.setMessageHandler((text: string, metadata: MessageMetadata) => this.handleMessage(text, metadata as unknown as Record<string, unknown>));
283
380
  this.wsClient.setResourceEventHandler((event) => this.handleResourceEvent(event as unknown as Record<string, unknown>));
@@ -339,6 +436,33 @@ export class AgentHost {
339
436
  const ownAgentId = this.options.agentId;
340
437
  if (!ownAgentId) return;
341
438
 
439
+ // The agent's own writes aren't news to it (same rule the backend inbox
440
+ // applies to counts). The backend already skips agent actors on emit;
441
+ // this guards the same invariant host-side so a self-caused event can
442
+ // never re-wake the brain — that echo was a token-burning wake loop for
443
+ // LLM cognitions (one wake's tool calls seeded the next wake, forever).
444
+ if (event.actorId != null && String(event.actorId) === ownAgentId) {
445
+ runtimeLog.debug('AgentHost', `skip self-caused resource event kind=${event.kind ?? 'unknown'} resourceId=${event.resourceId ?? 'null'}`);
446
+ return;
447
+ }
448
+
449
+ // A `message` resource event is the second channel for news the message
450
+ // push already delivered — the backend emits BOTH a `chat:message` frame
451
+ // and a `resource_changed {kind:'message', resourceId: messageId}` for
452
+ // every send. The resource frame exists for offline recovery (queue +
453
+ // launcher wake); when we already processed that messageId live via
454
+ // handleMessage, waking again just re-runs the brain over handled news
455
+ // (every inbound chat message cost a second wake). Unseen ids still wake:
456
+ // that's the recovery path working.
457
+ if (
458
+ event.kind === 'message' &&
459
+ typeof event.resourceId === 'string' &&
460
+ this.seenMessages.has(event.resourceId)
461
+ ) {
462
+ runtimeLog.debug('AgentHost', `skip resource event for already-handled message ${event.resourceId}`);
463
+ return;
464
+ }
465
+
342
466
  // Map the event to a candidate session key using the configured resolver
343
467
  // on a synthetic normalized-event shape. Default resolver buckets by
344
468
  // chatId; agents using a different sessionResolver (agreementId, taskId,
@@ -401,13 +525,52 @@ export class AgentHost {
401
525
  if (!this.sessions.has(laneKey)) {
402
526
  this.sessions.set(laneKey, { sessionId, ownAgentId });
403
527
  }
404
- return this.eventQueue.enqueue(normalized.event, laneKey);
528
+ const enqueued = this.eventQueue.enqueue(normalized.event, laneKey);
529
+ // ZIG-832 P4: advance this chat's inbox watermark for a message handled on
530
+ // the LIVE push path. Without it, only catch-up acks — so on a re-host the
531
+ // server watermark still sits before this message and catch-up re-delivers
532
+ // it, re-running a finished pipeline (the cross-wake token burn). Mirrors
533
+ // catch-up's ack-on-delivery; newer messages carry a later ts and still
534
+ // deliver, so a genuinely new turn in the same chat is not lost. Best-effort:
535
+ // never block or fail the wake.
536
+ const msgTs = (metadata.createdAt ?? metadata.timestamp ?? metadata.sentTimestamp) as string | undefined;
537
+ if (normalized.chatId && typeof msgTs === 'string' && msgTs) {
538
+ void this._ackChatWatermark(normalized.chatId, msgTs);
539
+ } else if (normalized.chatId) {
540
+ // ZIG-860: this ack silently not firing is exactly how the re-delivery
541
+ // bug hid — a live message with no usable timestamp must be loud.
542
+ runtimeLog.warn('AgentHost', `live-ack skipped: no timestamp on message chatId=${normalized.chatId} messageId=${messageId ?? 'null'}`);
543
+ }
544
+ return enqueued;
545
+ }
546
+
547
+ /** Best-effort: advance the inbox watermark for a chat handled live (ZIG-832 P4). */
548
+ private async _ackChatWatermark(chatId: string, upTo: string): Promise<void> {
549
+ try {
550
+ if (!this._inbox) {
551
+ this._inbox = new InboxClient(this.options.operatorKey!, this.options.agentId!);
552
+ }
553
+ await this._inbox.ack([{ kind: 'chat', id: chatId, upTo }]);
554
+ } catch (e: unknown) {
555
+ // warn, not debug (ZIG-860): a failing live-ack means catch-up will
556
+ // re-deliver this chat's backlog after the next host eviction.
557
+ runtimeLog.warn('AgentHost', `live-ack chat ${chatId} failed: ${(e as Error)?.message ?? e}`);
558
+ }
405
559
  }
406
560
 
407
561
  private async _processEvent(event: unknown, laneKey: string): Promise<void> {
408
562
  const session = this.sessions.get(laneKey);
409
563
  if (!session?.sessionId) return;
410
564
 
565
+ // Stale timer frames (superseded epoch) are dropped BEFORE touching the
566
+ // live timer — a pure-stale frame must not reset a freshly armed countdown.
567
+ const frame = this.stateTimers.stripStale(event, laneKey);
568
+ if (frame === null) return;
569
+
570
+ // Any event reaching the machine invalidates the countdown ("clear on any
571
+ // event for the lane"); re-armed below if the tick settles parked+timed.
572
+ this.stateTimers.clear(laneKey);
573
+
411
574
  // Cold-start recovery: restore FSM state from sessionStore when the
412
575
  // in-memory lane has no prior context (e.g. after a container restart).
413
576
  if (this.sessionStore && session.ctx === undefined && session.state === undefined) {
@@ -428,27 +591,90 @@ export class AgentHost {
428
591
  laneKey,
429
592
  };
430
593
 
594
+ // The watchdog timer must be cleared once the tick settles — leaving it
595
+ // armed pinned one live 2-minute timer per tick (and kept short-lived
596
+ // processes/tests alive until it fired).
597
+ let tickWatchdog: ReturnType<typeof setTimeout> | undefined;
431
598
  try {
432
599
  const result = await Promise.race([
433
- this.agent.tick({ event, ctx: session.ctx, state: session.state, identity, eff: this.eff, memory: this.memory, transitionTap: this.options.transitionTap }),
434
- new Promise<never>((_, reject) =>
435
- setTimeout(() => reject(new Error('tick timeout')), this.tickTimeoutMs),
436
- ),
600
+ this.agent.tick({ event: frame, ctx: session.ctx, state: session.state, identity, eff: this.eff, memory: this.memory, transitionTap: this.options.transitionTap }),
601
+ new Promise<never>((_, reject) => {
602
+ tickWatchdog = setTimeout(() => reject(new Error('tick timeout')), this.tickTimeoutMs);
603
+ }),
437
604
  ]);
438
605
  session.ctx = result.ctx;
439
606
  session.state = result.state;
440
607
  this.sessions.set(laneKey, session);
441
608
 
609
+ // One line per wake that actually spent LLM budget — the handle for
610
+ // tracking a hosted agent live (grep `agent=<id>`). Parked no-op ticks
611
+ // (FSM exits, wake-rate-skipped Claude wakes) report zero tokens and
612
+ // stay quiet. A regressed self-wake loop shows here as a rapid stream of
613
+ // these lines for one lane. Tokens are billable input (incl. cache).
614
+ const m = result.metrics;
615
+ if (m && (m.tokens?.total ?? 0) > 0) {
616
+ runtimeLog.info(
617
+ 'AgentHost',
618
+ `wake agent=${identity.agentId} lane=${laneKey} llmCalls=${m.llmCalls} ` +
619
+ `tokens_in=${m.tokens.input} tokens_out=${m.tokens.output} total=${m.tokens.total}`,
620
+ );
621
+ }
622
+
623
+ // Single source of truth for usage/cost (ZIG-813): emit the tick's
624
+ // authoritative token metrics once, for every cognition. Never let an
625
+ // observability failure break the tick.
626
+ if (this.options.metricsTap && result.metrics) {
627
+ try {
628
+ this.options.metricsTap({
629
+ agentId: identity.agentId,
630
+ sessionId: session.sessionId,
631
+ model: this.options.model ?? 'unknown',
632
+ cognition: this.options.cognition ?? 'fsm',
633
+ metrics: result.metrics,
634
+ });
635
+ } catch (err: unknown) {
636
+ runtimeLog.warn('AgentHost', `metricsTap failed: ${(err as Error)?.message || err}`);
637
+ }
638
+ }
639
+
442
640
  if (this.sessionStore) {
443
641
  this.sessionStore.save(laneKey, { ctx: session.ctx, state: session.state }).catch((err: unknown) => {
444
642
  runtimeLog.warn('AgentHost', `sessionStore.save failed laneKey=${laneKey}: ${(err as Error)?.message || err}`);
445
643
  });
446
644
  }
645
+
646
+ // Arm only when the timeout was handled: an unhandled state-timeout must
647
+ // not re-arm, or the lane would runtimeLog.error every timeoutMs forever.
648
+ if (!result.stateTimeoutUnhandled) this._armIfTimedParked(laneKey, result.state);
447
649
  } catch (err: unknown) {
448
650
  runtimeLog.warn('AgentHost', `tick failed laneKey=${laneKey}: ${(err as Error)?.message || err}`);
651
+ // Deliberate re-arm on failure. Exact scenario: the tick timed out at
652
+ // tickTimeoutMs (or sessionStore.load threw) leaving session.state at a
653
+ // timed parked state — without re-arming, that lane would never wake
654
+ // again unless a real event happens to arrive.
655
+ this._armIfTimedParked(laneKey, session.state ?? this.agent.workflow.initial);
656
+ // Best-effort: also notify the user so the conversation isn't silently
657
+ // stuck (ZIG-607). Complements the timer re-arm above — that recovers a
658
+ // parked lane; this tells a waiting human.
659
+ const ev = event as Record<string, unknown> | undefined;
660
+ const senderType = String(ev?.senderType ?? '').toUpperCase();
661
+ const senderId = ev?.senderId as string | undefined;
662
+ if (senderId && senderType === 'USER') {
663
+ this.eff({ kind: 'send-message', sessionId: session.sessionId, receiverId: senderId, text: 'Something went wrong. Please try again.' }).catch(() => {});
664
+ }
665
+ } finally {
666
+ clearTimeout(tickWatchdog);
449
667
  }
450
668
  }
451
669
 
670
+ /** Arms the lane's liveness timer iff stateName is a parked state declaring timeoutMs. */
671
+ private _armIfTimedParked(laneKey: string, stateName: string | undefined): void {
672
+ if (!stateName) return;
673
+ const sd = this.agent.workflow.states[stateName];
674
+ if (sd?.kind !== 'parked' || sd.timeoutMs === undefined) return;
675
+ this.stateTimers.arm(laneKey, stateName, sd.timeoutMs);
676
+ }
677
+
452
678
  private _resolveOwnAgentId(metadata: Record<string, unknown> = {}): string | null {
453
679
  const self = metadata.self as Record<string, unknown> | undefined;
454
680
  const agent = metadata.agent as Record<string, unknown> | undefined;
@@ -464,10 +690,103 @@ export class AgentHost {
464
690
  await new Promise((r) => setTimeout(r, 750));
465
691
  this.catchUpInFlight = null;
466
692
  await this._ensureCatchUp();
693
+ await this._bootstrapAssignedActiveTasks();
467
694
  }
468
695
  return this;
469
696
  }
470
- disconnect() { this.wsClient.disconnect(); }
697
+ private async _bootstrapAssignedActiveTasks(): Promise<void> {
698
+ const agentId = this.options.agentId;
699
+ if (!agentId || !this.taskService) return;
700
+
701
+ let tasks: Array<{ taskId?: string; assigneeId?: string | null }> = [];
702
+ try {
703
+ tasks = await this.taskService.getActiveTasksForAgent(agentId);
704
+ } catch (err: unknown) {
705
+ runtimeLog.warn(
706
+ this.options.name || 'agent',
707
+ `active-task bootstrap skipped: ${(err as Error)?.message ?? err}`,
708
+ );
709
+ return;
710
+ }
711
+
712
+ for (const row of tasks) {
713
+ const taskId = row.taskId;
714
+ if (!taskId) continue;
715
+ if (row.assigneeId && row.assigneeId !== agentId) continue;
716
+
717
+ let full: Record<string, unknown>;
718
+ try {
719
+ full = (await this.taskService.getTask(taskId)) as unknown as Record<string, unknown>;
720
+ } catch {
721
+ continue;
722
+ }
723
+ const state = String(full.state ?? '');
724
+ if (state !== 'active' && state !== 'in-progress') continue;
725
+
726
+ const sessionId =
727
+ (full.agreementId as string | undefined) ||
728
+ (full.originChatId as string | undefined) ||
729
+ 'default';
730
+ const laneKey = `${agentId}:${sessionId}`;
731
+ if (!this.sessions.has(laneKey)) {
732
+ this.sessions.set(laneKey, { sessionId, ownAgentId: agentId });
733
+ }
734
+
735
+ runtimeLog.info(
736
+ this.options.name || 'agent',
737
+ `bootstrap task-assigned ${taskId} (state=${state})`,
738
+ );
739
+ await this.eventQueue.enqueue(
740
+ {
741
+ type: 'task_result',
742
+ result: {
743
+ ...full,
744
+ taskId: (full.taskId as string | undefined) ?? taskId,
745
+ state,
746
+ providerIsYou: true,
747
+ },
748
+ chatId: full.originChatId as string | undefined,
749
+ },
750
+ laneKey,
751
+ );
752
+ }
753
+ }
754
+
755
+ /** ZIG-802: replay task-assigned for an existing active task (demo recovery). */
756
+ async wakeTaskAssignment(taskId: string): Promise<void> {
757
+ const agentId = this.options.agentId;
758
+ if (!agentId || !this.taskService) {
759
+ throw new Error('wakeTaskAssignment: agent not configured');
760
+ }
761
+ const full = (await this.taskService.getTask(taskId)) as unknown as Record<string, unknown>;
762
+ const state = String(full.state ?? '');
763
+ if (state !== 'active' && state !== 'in-progress') {
764
+ throw new Error(`wakeTaskAssignment: task ${taskId} is ${state}, not active`);
765
+ }
766
+ const sessionId =
767
+ (full.agreementId as string | undefined) ||
768
+ (full.originChatId as string | undefined) ||
769
+ 'default';
770
+ const laneKey = `${agentId}:${sessionId}`;
771
+ if (!this.sessions.has(laneKey)) {
772
+ this.sessions.set(laneKey, { sessionId, ownAgentId: agentId });
773
+ }
774
+ await this.eventQueue.enqueue(
775
+ {
776
+ type: 'task_result',
777
+ result: {
778
+ ...full,
779
+ taskId: (full.taskId as string | undefined) ?? taskId,
780
+ state,
781
+ providerIsYou: true,
782
+ },
783
+ chatId: full.originChatId as string | undefined,
784
+ },
785
+ laneKey,
786
+ );
787
+ }
788
+
789
+ disconnect() { this.stateTimers.clearAll(); this.wsClient.disconnect(); }
471
790
  /** Waits for all in-flight ticks to complete. Call before process.exit() on SIGTERM. */
472
791
  drain(timeoutMs = 30_000): Promise<void> { return this.eventQueue.waitForIdle(timeoutMs); }
473
792
  registerTool(tool: ToolDefinition) { return this.toolManager.register(tool); }