@ziggs-ai/agent-sdk 0.1.11 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ziggs-ai/agent-sdk",
3
- "version": "0.1.11",
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,12 +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",
19
20
  "@anthropic-ai/sdk": "^0.110.0",
20
21
  "@modelcontextprotocol/sdk": "^1.29.0",
21
- "@ziggs-ai/api-client": "^0.1.25",
22
+ "@ziggs-ai/api-client": "^0.1.28",
22
23
  "ajv": "^8.17.1",
23
24
  "dotenv": "^17.2.3",
24
- "openai": "^4.0.0"
25
+ "openai": "^4.0.0",
26
+ "zod": "^4.0.0"
25
27
  },
26
28
  "keywords": [
27
29
  "ziggs",
package/src/AgentHost.ts CHANGED
@@ -1,5 +1,10 @@
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';
4
9
  import { StateTimers } from './server/StateTimers.js';
5
10
  import { createZiggsEffectHandler } from './server/ZiggsEffectHandler.js';
@@ -25,10 +30,10 @@ import { OpenAIAdapter } from './adapters/OpenAIAdapter.js';
25
30
  import { AnthropicAdapter } from './adapters/AnthropicAdapter.js';
26
31
  import {
27
32
  WebSocketClient,
28
- MessagesClient,
29
33
  ArtifactsClient,
30
- ScopeClient,
34
+ ContextReadClient,
31
35
  TelemetryClient,
36
+ InboxClient,
32
37
  type MessageMetadata,
33
38
  } from '@ziggs-ai/api-client';
34
39
  import { TaskService } from './server/tasks/TaskService.js';
@@ -37,6 +42,15 @@ import { runInboxCatchUp } from './server/InboxCatchUp.js';
37
42
  import { SeenMessages } from './server/SeenMessages.js';
38
43
  import { runtimeLog } from './shared/runtimeLog.js';
39
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
+
40
54
  // ConnectionPool lives in a separate module; resolved via a computed-path
41
55
  // dynamic import to avoid a circular import with AgentHost.
42
56
  async function loadConnectionPool(): Promise<new (opts?: { maxActive?: number; idleTimeoutMs?: number }) => unknown> {
@@ -65,6 +79,7 @@ interface AgentServices {
65
79
  [key: string]: unknown;
66
80
  }
67
81
 
82
+
68
83
  export interface AgentHostOptions {
69
84
  openaiKey?: string;
70
85
  anthropicKey?: string;
@@ -77,6 +92,10 @@ export interface AgentHostOptions {
77
92
  specialization?: string | null;
78
93
  tools?: ToolDefinition[];
79
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;
80
99
  services?: AgentServices;
81
100
  workflow?: Workflow;
82
101
  // Convenience: states + initial without an explicit workflow.
@@ -101,6 +120,21 @@ export interface AgentHostOptions {
101
120
  effectTap?: (effect: import('./types.js').Effect, response: unknown) => void;
102
121
  /** Observability tap: called on every FSM state transition, no-match, depth-guard, or error-escape (ZIG-442). */
103
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;
104
138
  /** Production monitoring ingest (ZIG-360). Default on; set false to disable. */
105
139
  telemetryIngest?: boolean;
106
140
  /**
@@ -117,7 +151,8 @@ function createToolManager(options: AgentHostOptions): ToolManager {
117
151
  if (options.taskTools === 'none') taskTools = [];
118
152
  else if (Array.isArray(options.taskTools)) taskTools = PROTOCOL_TOOLS.filter((t: ToolDefinition) => (options.taskTools as string[]).includes(t.schema.function.name as string));
119
153
  else taskTools = PROTOCOL_TOOLS; // 'all' or undefined
120
- 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 : [])]);
121
156
  return toolManager;
122
157
  }
123
158
 
@@ -128,16 +163,22 @@ function isLlmAdapter(v: unknown): v is LlmAdapter {
128
163
  function createBackendServices(options: AgentHostOptions) {
129
164
  const toolManager = createToolManager(options);
130
165
  const model = (options.services?.llm as { model?: string } | undefined)?.model || options.model;
131
- const llm = isLlmAdapter(options.services?.llm)
132
- ? options.services!.llm as LlmAdapter
133
- : model?.startsWith('claude')
134
- ? new AnthropicAdapter({ key: options.anthropicKey, model })
135
- : new OpenAIAdapter({ key: options.openaiKey, 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 });
136
178
  const operatorKey = options.operatorKey!;
137
179
  const agentId = options.agentId!;
138
- const messagesClient = new MessagesClient(operatorKey, agentId);
139
180
  const artifactsClient = new ArtifactsClient(operatorKey, agentId);
140
- const scopeClient = new ScopeClient(operatorKey, agentId);
181
+ const contextReadClient = new ContextReadClient(operatorKey, agentId);
141
182
  const promptBuilder = new PromptBuilder({ description: options.description, specialization: options.specialization });
142
183
  const taskService = new TaskService(operatorKey, agentId);
143
184
  const agreementService = new AgreementService(operatorKey, agentId);
@@ -145,9 +186,8 @@ function createBackendServices(options: AgentHostOptions) {
145
186
  return {
146
187
  toolManager,
147
188
  llm,
148
- messagesClient,
149
189
  artifactsClient,
150
- scopeClient,
190
+ contextReadClient,
151
191
  promptBuilder,
152
192
  taskService,
153
193
  agreementService,
@@ -172,8 +212,10 @@ interface LaneSession {
172
212
  export class AgentHost {
173
213
  options: Required<Pick<AgentHostOptions, 'agentId' | 'operatorKey' | 'openaiKey'>> & AgentHostOptions;
174
214
  toolManager!: ToolManager;
175
- llm!: OpenAIAdapter | LlmAdapter; // retained for external inspection
176
- 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;
177
219
  wsClient!: WebSocketClient;
178
220
  private eff!: EffectHandler;
179
221
  private eventQueue!: EventQueue;
@@ -186,6 +228,8 @@ export class AgentHost {
186
228
  private agreementService!: AgreementService;
187
229
  private messageSender!: (message: string, receiverId: string, sessionId: string) => Promise<unknown>;
188
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;
189
233
  private stateTimers!: StateTimers;
190
234
  private catchUpInFlight: Promise<void> | null = null;
191
235
  private inboxCatchUpEnabled: boolean;
@@ -194,7 +238,7 @@ export class AgentHost {
194
238
  this.options = {
195
239
  openaiKey: options.openaiKey || process.env.OPENAI_API_KEY,
196
240
  anthropicKey: options.anthropicKey || process.env.ANTHROPIC_API_KEY,
197
- model: options.model || (!isLlmAdapter(options.services?.llm) ? options.services?.llm?.model : undefined) || 'gpt-5.4',
241
+ model: options.model || (!isLlmAdapter(options.services?.llm) ? options.services?.llm?.model : undefined) || DEFAULT_MODEL,
198
242
  operatorKey: options.operatorKey || process.env.ZIGGS_OPERATOR_KEY || undefined,
199
243
  agentId: options.agentId || '',
200
244
  name: options.name || null,
@@ -211,7 +255,10 @@ export class AgentHost {
211
255
  sessionResolver: options.sessionResolver,
212
256
  effectTap: options.effectTap,
213
257
  transitionTap: options.transitionTap,
258
+ metricsTap: options.metricsTap,
214
259
  inboxCatchUp: options.inboxCatchUp,
260
+ cognition: options.cognition ?? 'fsm',
261
+ claudeMaxTurns: options.claudeMaxTurns,
215
262
  } as Required<Pick<AgentHostOptions, 'agentId' | 'operatorKey' | 'openaiKey'>> & AgentHostOptions;
216
263
  this.inboxCatchUpEnabled = options.inboxCatchUp !== false;
217
264
  this.sessionResolver = options.sessionResolver || DEFAULT_SESSION_RESOLVER;
@@ -223,7 +270,11 @@ export class AgentHost {
223
270
  }
224
271
 
225
272
  private _validate(): void {
226
- if (!isLlmAdapter(this.options.services?.llm)) {
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)) {
227
278
  if (this.options.model?.startsWith('claude')) {
228
279
  if (!this.options.anthropicKey) throw new Error('anthropicKey is required for claude-* models — pass { anthropicKey } or set ANTHROPIC_API_KEY');
229
280
  } else if (!this.options.openaiKey) {
@@ -266,13 +317,26 @@ export class AgentHost {
266
317
  states: this.options.states,
267
318
  };
268
319
  }
269
- 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
+ }
270
335
 
271
336
  this.eff = createZiggsEffectHandler({
272
337
  llm: services.llm as unknown as import('./server/ZiggsEffectHandler.js').ZiggsEffectDeps['llm'],
273
- messagesClient: services.messagesClient,
274
338
  artifactsClient: services.artifactsClient,
275
- scopeClient: services.scopeClient,
339
+ contextReadClient: services.contextReadClient,
276
340
  taskService: services.taskService,
277
341
  agreementService: services.agreementService,
278
342
  messageSender,
@@ -285,11 +349,29 @@ export class AgentHost {
285
349
  effectTap: this.options.effectTap,
286
350
  });
287
351
 
288
- this.agent = new Agent({
289
- workflow,
290
- toolManager: services.toolManager,
291
- promptBuilder: services.promptBuilder,
292
- });
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
+ });
293
375
 
294
376
  this.eventQueue = new EventQueue((event, laneKey) => this._processEvent(event, laneKey));
295
377
  this.stateTimers = new StateTimers((ev, laneKey) => { void this.eventQueue.enqueue(ev, laneKey); });
@@ -354,6 +436,33 @@ export class AgentHost {
354
436
  const ownAgentId = this.options.agentId;
355
437
  if (!ownAgentId) return;
356
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
+
357
466
  // Map the event to a candidate session key using the configured resolver
358
467
  // on a synthetic normalized-event shape. Default resolver buckets by
359
468
  // chatId; agents using a different sessionResolver (agreementId, taskId,
@@ -416,7 +525,37 @@ export class AgentHost {
416
525
  if (!this.sessions.has(laneKey)) {
417
526
  this.sessions.set(laneKey, { sessionId, ownAgentId });
418
527
  }
419
- 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
+ }
420
559
  }
421
560
 
422
561
  private async _processEvent(event: unknown, laneKey: string): Promise<void> {
@@ -452,17 +591,52 @@ export class AgentHost {
452
591
  laneKey,
453
592
  };
454
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;
455
598
  try {
456
599
  const result = await Promise.race([
457
600
  this.agent.tick({ event: frame, ctx: session.ctx, state: session.state, identity, eff: this.eff, memory: this.memory, transitionTap: this.options.transitionTap }),
458
- new Promise<never>((_, reject) =>
459
- setTimeout(() => reject(new Error('tick timeout')), this.tickTimeoutMs),
460
- ),
601
+ new Promise<never>((_, reject) => {
602
+ tickWatchdog = setTimeout(() => reject(new Error('tick timeout')), this.tickTimeoutMs);
603
+ }),
461
604
  ]);
462
605
  session.ctx = result.ctx;
463
606
  session.state = result.state;
464
607
  this.sessions.set(laneKey, session);
465
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
+
466
640
  if (this.sessionStore) {
467
641
  this.sessionStore.save(laneKey, { ctx: session.ctx, state: session.state }).catch((err: unknown) => {
468
642
  runtimeLog.warn('AgentHost', `sessionStore.save failed laneKey=${laneKey}: ${(err as Error)?.message || err}`);
@@ -488,6 +662,8 @@ export class AgentHost {
488
662
  if (senderId && senderType === 'USER') {
489
663
  this.eff({ kind: 'send-message', sessionId: session.sessionId, receiverId: senderId, text: 'Something went wrong. Please try again.' }).catch(() => {});
490
664
  }
665
+ } finally {
666
+ clearTimeout(tickWatchdog);
491
667
  }
492
668
  }
493
669
 
@@ -514,9 +690,102 @@ export class AgentHost {
514
690
  await new Promise((r) => setTimeout(r, 750));
515
691
  this.catchUpInFlight = null;
516
692
  await this._ensureCatchUp();
693
+ await this._bootstrapAssignedActiveTasks();
517
694
  }
518
695
  return this;
519
696
  }
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
+
520
789
  disconnect() { this.stateTimers.clearAll(); this.wsClient.disconnect(); }
521
790
  /** Waits for all in-flight ticks to complete. Call before process.exit() on SIGTERM. */
522
791
  drain(timeoutMs = 30_000): Promise<void> { return this.eventQueue.waitForIdle(timeoutMs); }
@@ -48,13 +48,7 @@ export class AnthropicAdapter {
48
48
  _options: Record<string, unknown> = {},
49
49
  ): Promise<LlmResponse> {
50
50
  const { system, anthropicMessages } = translateMessages(messages);
51
- const anthropicTools: Anthropic.Tool[] | undefined = tools.length > 0
52
- ? tools.map(t => ({
53
- name: t.function.name,
54
- description: t.function.description ?? '',
55
- input_schema: (t.function.parameters ?? { type: 'object' }) as Anthropic.Tool.InputSchema,
56
- }))
57
- : undefined;
51
+ const anthropicTools = toAnthropicTools(tools);
58
52
 
59
53
  try {
60
54
  const res = await this.client.messages.create({
@@ -114,7 +108,7 @@ export class AnthropicAdapter {
114
108
  async chatMessagesStream(
115
109
  messages: LlmMessage[],
116
110
  onChunk: (text: string) => void,
117
- ): Promise<string> {
111
+ ): Promise<{ text: string; usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number } | undefined }> {
118
112
  const { system, anthropicMessages } = translateMessages(messages);
119
113
  const stream = this.client.messages.stream({
120
114
  model: this.model,
@@ -127,11 +121,49 @@ export class AnthropicAdapter {
127
121
  text += delta;
128
122
  onChunk(delta);
129
123
  });
130
- await stream.finalMessage();
131
- return text;
124
+ const final = await stream.finalMessage();
125
+ const u = final.usage;
126
+ // Fold cache read/creation into prompt tokens so the streamed reply is not
127
+ // undercounted (cache traffic was the bulk of the ZIG-759 drain).
128
+ const usage = u
129
+ ? {
130
+ prompt_tokens: (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0),
131
+ completion_tokens: u.output_tokens ?? 0,
132
+ total_tokens:
133
+ (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.output_tokens ?? 0),
134
+ }
135
+ : undefined;
136
+ return { text, usage };
132
137
  }
133
138
  }
134
139
 
140
+ /**
141
+ * Convert the runtime's tool list into Anthropic's `tools` param.
142
+ *
143
+ * The declared `LlmToolSchema` type (`{ type, function }`) is a lie at runtime:
144
+ * `runTurn` launders `ToolDefinition[]` (shape `{ schema: { function } }`)
145
+ * through an `as unknown as LlmToolSchema[]` cast before it reaches any adapter.
146
+ * OpenAIAdapter already reads `t.schema.function || t.schema`; this must do the
147
+ * same, or every claude-backed agent whose state exposes ≥1 tool crashes on
148
+ * `t.function` being undefined ("Cannot read properties of undefined (reading
149
+ * 'name')"). Both shapes are accepted so raw wire-shaped tools also work.
150
+ */
151
+ export function toAnthropicTools(tools: LlmToolSchema[] = []): Anthropic.Tool[] | undefined {
152
+ if (!tools.length) return undefined;
153
+ return tools.map(t => {
154
+ const td = t as unknown as {
155
+ schema?: { function?: Record<string, unknown> };
156
+ function?: Record<string, unknown>;
157
+ };
158
+ const fn = (td.schema?.function ?? td.function ?? {}) as Record<string, unknown>;
159
+ return {
160
+ name: fn['name'] as string,
161
+ description: (fn['description'] as string) ?? '',
162
+ input_schema: (fn['parameters'] ?? { type: 'object' }) as Anthropic.Tool.InputSchema,
163
+ };
164
+ });
165
+ }
166
+
135
167
  /**
136
168
  * OpenAI-wire-shaped LlmMessage[] → Anthropic { system, messages }.
137
169
  * - system messages concatenate into the top-level system param