@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.
- package/package.json +6 -3
- package/src/AgentHost.ts +347 -28
- package/src/adapters/AnthropicAdapter.ts +238 -0
- package/src/adapters/McpAdapter.ts +391 -15
- package/src/adapters/OpenAIAdapter.ts +20 -8
- package/src/adapters/index.ts +1 -0
- package/src/agent/Agent.ts +13 -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 +22 -7
- package/src/index.ts +14 -5
- package/src/pricing/fleetTierByAgentId.gen.ts +53 -1
- package/src/runtime/AgentMachine.ts +143 -23
- package/src/runtime/PromptBuilder.ts +62 -6
- package/src/runtime/bootstrapCtx.ts +39 -0
- package/src/runtime/buildOutcome.ts +51 -6
- package/src/runtime/defaults.ts +12 -1
- package/src/runtime/deriveRespondsToProposals.ts +35 -0
- package/src/runtime/kickoff.ts +117 -0
- package/src/runtime/runMechanical.ts +106 -0
- package/src/runtime/runTurn.ts +236 -62
- package/src/runtime/validateWorkflow.ts +55 -15
- package/src/server/ConnectionPool.ts +56 -5
- package/src/server/InboxCatchUp.ts +37 -0
- package/src/server/StateTimers.ts +85 -0
- package/src/server/ZiggsEffectHandler.ts +198 -180
- package/src/server/agreements/AgreementService.ts +3 -3
- package/src/server/tasks/TaskService.ts +15 -3
- package/src/server/tools/tier1/protocolRunner.ts +221 -3
- package/src/server/tools/tier1/protocolTools.ts +19 -4
- 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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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 (
|
|
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)
|
|
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
|
-
|
|
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 =
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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); }
|