@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 +5 -3
- package/src/AgentHost.ts +297 -28
- package/src/adapters/AnthropicAdapter.ts +42 -10
- package/src/adapters/McpAdapter.ts +350 -20
- package/src/adapters/OpenAIAdapter.ts +9 -2
- package/src/agent/Agent.ts +10 -2
- package/src/agent/ClaudeSdkAgent.ts +401 -0
- package/src/agent/claudeCognition.ts +139 -0
- package/src/agent/claudeSdkProtocol.ts +27 -0
- package/src/agent/claudeSdkTools.ts +81 -0
- package/src/agent/hostedAgentConnection.ts +41 -0
- package/src/agent/toolManagerToSdkMcp.ts +136 -0
- package/src/defineAgent.ts +9 -2
- package/src/index.ts +11 -5
- package/src/runtime/AgentMachine.ts +104 -22
- package/src/runtime/PromptBuilder.ts +42 -5
- package/src/runtime/bootstrapCtx.ts +39 -0
- package/src/runtime/defaults.ts +12 -1
- package/src/runtime/deriveRespondsToProposals.ts +35 -0
- package/src/runtime/runMechanical.ts +106 -0
- package/src/runtime/runTurn.ts +65 -8
- package/src/runtime/validateWorkflow.ts +33 -14
- package/src/server/InboxCatchUp.ts +37 -0
- package/src/server/ZiggsEffectHandler.ts +188 -179
- package/src/server/agreements/AgreementService.ts +3 -3
- package/src/server/tasks/TaskService.ts +2 -1
- package/src/server/tools/tier1/protocolRunner.ts +212 -3
- package/src/server/tools/tier1/protocolTools.ts +18 -3
- package/src/types.ts +49 -22
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ziggs-ai/agent-sdk",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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) ||
|
|
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 (
|
|
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)
|
|
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
|
-
|
|
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 =
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|