@ziggs-ai/agent-sdk 0.1.10 → 0.1.11

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.10",
3
+ "version": "0.1.11",
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,8 +16,9 @@
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/sdk": "^0.110.0",
19
20
  "@modelcontextprotocol/sdk": "^1.29.0",
20
- "@ziggs-ai/api-client": "^0.1.9",
21
+ "@ziggs-ai/api-client": "^0.1.25",
21
22
  "ajv": "^8.17.1",
22
23
  "dotenv": "^17.2.3",
23
24
  "openai": "^4.0.0"
package/src/AgentHost.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { AgentConfig, Ctx, EffectHandler, Identity, Workflow } from './types.js';
2
2
  import { Agent } from './agent/Agent.js';
3
3
  import { EventQueue } from './server/EventQueue.js';
4
+ import { StateTimers } from './server/StateTimers.js';
4
5
  import { createZiggsEffectHandler } from './server/ZiggsEffectHandler.js';
5
6
  import { normalizeIncomingEvent, type NormalizedEvent } from './ingress/normalizeIncoming.js';
6
7
  import { PromptBuilder } from './runtime/PromptBuilder.js';
@@ -21,6 +22,7 @@ import { ToolManager } from './tools/ToolManager.js';
21
22
  import type { ToolDefinition } from './tools/defineTool.js';
22
23
  import { PROTOCOL_TOOLS } from './server/tools/tier1/protocolTools.js';
23
24
  import { OpenAIAdapter } from './adapters/OpenAIAdapter.js';
25
+ import { AnthropicAdapter } from './adapters/AnthropicAdapter.js';
24
26
  import {
25
27
  WebSocketClient,
26
28
  MessagesClient,
@@ -65,6 +67,7 @@ interface AgentServices {
65
67
 
66
68
  export interface AgentHostOptions {
67
69
  openaiKey?: string;
70
+ anthropicKey?: string;
68
71
  model?: string;
69
72
  operatorKey?: string;
70
73
  agentId?: string;
@@ -124,9 +127,12 @@ function isLlmAdapter(v: unknown): v is LlmAdapter {
124
127
 
125
128
  function createBackendServices(options: AgentHostOptions) {
126
129
  const toolManager = createToolManager(options);
130
+ const model = (options.services?.llm as { model?: string } | undefined)?.model || options.model;
127
131
  const llm = isLlmAdapter(options.services?.llm)
128
132
  ? options.services!.llm as LlmAdapter
129
- : new OpenAIAdapter({ key: options.openaiKey, model: (options.services?.llm as { model?: string } | undefined)?.model || options.model });
133
+ : model?.startsWith('claude')
134
+ ? new AnthropicAdapter({ key: options.anthropicKey, model })
135
+ : new OpenAIAdapter({ key: options.openaiKey, model });
130
136
  const operatorKey = options.operatorKey!;
131
137
  const agentId = options.agentId!;
132
138
  const messagesClient = new MessagesClient(operatorKey, agentId);
@@ -180,12 +186,14 @@ export class AgentHost {
180
186
  private agreementService!: AgreementService;
181
187
  private messageSender!: (message: string, receiverId: string, sessionId: string) => Promise<unknown>;
182
188
  private seenMessages = new SeenMessages();
189
+ private stateTimers!: StateTimers;
183
190
  private catchUpInFlight: Promise<void> | null = null;
184
191
  private inboxCatchUpEnabled: boolean;
185
192
 
186
193
  constructor(options: AgentHostOptions = {}) {
187
194
  this.options = {
188
195
  openaiKey: options.openaiKey || process.env.OPENAI_API_KEY,
196
+ anthropicKey: options.anthropicKey || process.env.ANTHROPIC_API_KEY,
189
197
  model: options.model || (!isLlmAdapter(options.services?.llm) ? options.services?.llm?.model : undefined) || 'gpt-5.4',
190
198
  operatorKey: options.operatorKey || process.env.ZIGGS_OPERATOR_KEY || undefined,
191
199
  agentId: options.agentId || '',
@@ -215,7 +223,13 @@ export class AgentHost {
215
223
  }
216
224
 
217
225
  private _validate(): void {
218
- if (!this.options.openaiKey) throw new Error('openaiKey is required');
226
+ if (!isLlmAdapter(this.options.services?.llm)) {
227
+ if (this.options.model?.startsWith('claude')) {
228
+ if (!this.options.anthropicKey) throw new Error('anthropicKey is required for claude-* models — pass { anthropicKey } or set ANTHROPIC_API_KEY');
229
+ } else if (!this.options.openaiKey) {
230
+ throw new Error('openaiKey is required');
231
+ }
232
+ }
219
233
  if (!this.options.operatorKey) throw new Error('operatorKey is required — pass { operatorKey } or set ZIGGS_OPERATOR_KEY');
220
234
  if (!this.options.agentId) throw new Error('agentId is required');
221
235
  }
@@ -278,6 +292,7 @@ export class AgentHost {
278
292
  });
279
293
 
280
294
  this.eventQueue = new EventQueue((event, laneKey) => this._processEvent(event, laneKey));
295
+ this.stateTimers = new StateTimers((ev, laneKey) => { void this.eventQueue.enqueue(ev, laneKey); });
281
296
 
282
297
  this.wsClient.setMessageHandler((text: string, metadata: MessageMetadata) => this.handleMessage(text, metadata as unknown as Record<string, unknown>));
283
298
  this.wsClient.setResourceEventHandler((event) => this.handleResourceEvent(event as unknown as Record<string, unknown>));
@@ -408,6 +423,15 @@ export class AgentHost {
408
423
  const session = this.sessions.get(laneKey);
409
424
  if (!session?.sessionId) return;
410
425
 
426
+ // Stale timer frames (superseded epoch) are dropped BEFORE touching the
427
+ // live timer — a pure-stale frame must not reset a freshly armed countdown.
428
+ const frame = this.stateTimers.stripStale(event, laneKey);
429
+ if (frame === null) return;
430
+
431
+ // Any event reaching the machine invalidates the countdown ("clear on any
432
+ // event for the lane"); re-armed below if the tick settles parked+timed.
433
+ this.stateTimers.clear(laneKey);
434
+
411
435
  // Cold-start recovery: restore FSM state from sessionStore when the
412
436
  // in-memory lane has no prior context (e.g. after a container restart).
413
437
  if (this.sessionStore && session.ctx === undefined && session.state === undefined) {
@@ -430,7 +454,7 @@ export class AgentHost {
430
454
 
431
455
  try {
432
456
  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 }),
457
+ this.agent.tick({ event: frame, ctx: session.ctx, state: session.state, identity, eff: this.eff, memory: this.memory, transitionTap: this.options.transitionTap }),
434
458
  new Promise<never>((_, reject) =>
435
459
  setTimeout(() => reject(new Error('tick timeout')), this.tickTimeoutMs),
436
460
  ),
@@ -444,11 +468,37 @@ export class AgentHost {
444
468
  runtimeLog.warn('AgentHost', `sessionStore.save failed laneKey=${laneKey}: ${(err as Error)?.message || err}`);
445
469
  });
446
470
  }
471
+
472
+ // Arm only when the timeout was handled: an unhandled state-timeout must
473
+ // not re-arm, or the lane would runtimeLog.error every timeoutMs forever.
474
+ if (!result.stateTimeoutUnhandled) this._armIfTimedParked(laneKey, result.state);
447
475
  } catch (err: unknown) {
448
476
  runtimeLog.warn('AgentHost', `tick failed laneKey=${laneKey}: ${(err as Error)?.message || err}`);
477
+ // Deliberate re-arm on failure. Exact scenario: the tick timed out at
478
+ // tickTimeoutMs (or sessionStore.load threw) leaving session.state at a
479
+ // timed parked state — without re-arming, that lane would never wake
480
+ // again unless a real event happens to arrive.
481
+ this._armIfTimedParked(laneKey, session.state ?? this.agent.workflow.initial);
482
+ // Best-effort: also notify the user so the conversation isn't silently
483
+ // stuck (ZIG-607). Complements the timer re-arm above — that recovers a
484
+ // parked lane; this tells a waiting human.
485
+ const ev = event as Record<string, unknown> | undefined;
486
+ const senderType = String(ev?.senderType ?? '').toUpperCase();
487
+ const senderId = ev?.senderId as string | undefined;
488
+ if (senderId && senderType === 'USER') {
489
+ this.eff({ kind: 'send-message', sessionId: session.sessionId, receiverId: senderId, text: 'Something went wrong. Please try again.' }).catch(() => {});
490
+ }
449
491
  }
450
492
  }
451
493
 
494
+ /** Arms the lane's liveness timer iff stateName is a parked state declaring timeoutMs. */
495
+ private _armIfTimedParked(laneKey: string, stateName: string | undefined): void {
496
+ if (!stateName) return;
497
+ const sd = this.agent.workflow.states[stateName];
498
+ if (sd?.kind !== 'parked' || sd.timeoutMs === undefined) return;
499
+ this.stateTimers.arm(laneKey, stateName, sd.timeoutMs);
500
+ }
501
+
452
502
  private _resolveOwnAgentId(metadata: Record<string, unknown> = {}): string | null {
453
503
  const self = metadata.self as Record<string, unknown> | undefined;
454
504
  const agent = metadata.agent as Record<string, unknown> | undefined;
@@ -467,7 +517,7 @@ export class AgentHost {
467
517
  }
468
518
  return this;
469
519
  }
470
- disconnect() { this.wsClient.disconnect(); }
520
+ disconnect() { this.stateTimers.clearAll(); this.wsClient.disconnect(); }
471
521
  /** Waits for all in-flight ticks to complete. Call before process.exit() on SIGTERM. */
472
522
  drain(timeoutMs = 30_000): Promise<void> { return this.eventQueue.waitForIdle(timeoutMs); }
473
523
  registerTool(tool: ToolDefinition) { return this.toolManager.register(tool); }
@@ -0,0 +1,206 @@
1
+ import Anthropic from '@anthropic-ai/sdk';
2
+ import type { LlmMessage, LlmResponse, LlmToolCall, LlmToolSchema } from '../types.js';
3
+ import { runtimeLog } from '../shared/runtimeLog.js';
4
+
5
+ interface AdapterOptions {
6
+ key?: string;
7
+ model?: string;
8
+ }
9
+
10
+ // Claude 4.7+ models reject sampling params (temperature/top_p/top_k) with a
11
+ // 400 and run without thinking when the `thinking` param is omitted. We omit
12
+ // both on purpose: the SDK's LlmMessage history cannot round-trip thinking
13
+ // blocks through the tool loop, so thinking must stay off.
14
+ const MAX_COMPLETION_TOKENS = 16000;
15
+
16
+ /**
17
+ * Anthropic adapter satisfying the same LlmAdapter contract as OpenAIAdapter:
18
+ * OpenAI-wire-shaped LlmMessage[] in, { content, tool_calls, usage } out.
19
+ * Selected by AgentHost when the configured model id starts with "claude".
20
+ */
21
+ export class AnthropicAdapter {
22
+ private client: Anthropic;
23
+ model: string;
24
+
25
+ constructor({ key, model = 'claude-opus-4-8' }: AdapterOptions) {
26
+ this.client = new Anthropic({ apiKey: key });
27
+ this.model = model;
28
+ }
29
+
30
+ async chat(
31
+ prompt: string,
32
+ tools: LlmToolSchema[] = [],
33
+ _temperature = 0,
34
+ systemHints: LlmMessage[] = [],
35
+ options: Record<string, unknown> = {},
36
+ ): Promise<LlmResponse> {
37
+ const messages: LlmMessage[] = [
38
+ { role: 'system', content: 'Respond with valid JSON.' },
39
+ ...systemHints,
40
+ { role: 'user', content: prompt },
41
+ ];
42
+ return this.chatMessages(messages, tools, options);
43
+ }
44
+
45
+ async chatMessages(
46
+ messages: LlmMessage[],
47
+ tools: LlmToolSchema[] = [],
48
+ _options: Record<string, unknown> = {},
49
+ ): Promise<LlmResponse> {
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;
58
+
59
+ try {
60
+ const res = await this.client.messages.create({
61
+ model: this.model,
62
+ max_tokens: MAX_COMPLETION_TOKENS,
63
+ ...(system ? { system } : {}),
64
+ messages: anthropicMessages,
65
+ ...(anthropicTools ? { tools: anthropicTools } : {}),
66
+ });
67
+
68
+ if (res.stop_reason === 'refusal') {
69
+ throw new Error(`LLM refused the request (stop_reason=refusal)`);
70
+ }
71
+
72
+ const content = res.content
73
+ .filter((b): b is Anthropic.TextBlock => b.type === 'text')
74
+ .map(b => b.text)
75
+ .join('')
76
+ .trim() || null;
77
+ const toolCalls: LlmToolCall[] | null = (() => {
78
+ const uses = res.content.filter((b): b is Anthropic.ToolUseBlock => b.type === 'tool_use');
79
+ if (!uses.length) return null;
80
+ return uses.map(u => ({
81
+ id: u.id,
82
+ function: { name: u.name, arguments: JSON.stringify(u.input ?? {}) },
83
+ }));
84
+ })();
85
+
86
+ if (!content && !toolCalls && res.stop_reason === 'max_tokens') {
87
+ throw new Error(
88
+ `LLM returned no usable output — completion token budget exhausted before any content (stop_reason=max_tokens)`,
89
+ );
90
+ }
91
+
92
+ return {
93
+ content: content ?? undefined,
94
+ tool_calls: toolCalls ?? undefined,
95
+ usage: res.usage
96
+ ? {
97
+ prompt_tokens: res.usage.input_tokens,
98
+ completion_tokens: res.usage.output_tokens,
99
+ total_tokens: res.usage.input_tokens + res.usage.output_tokens,
100
+ }
101
+ : undefined,
102
+ };
103
+ } catch (error) {
104
+ const err = error as Error & { status?: number };
105
+ runtimeLog.error('AnthropicAdapter', `LLM API call failed: ${err.message}`, {
106
+ model: this.model,
107
+ error: err.message,
108
+ status: err.status ?? 'unknown',
109
+ });
110
+ throw error;
111
+ }
112
+ }
113
+
114
+ async chatMessagesStream(
115
+ messages: LlmMessage[],
116
+ onChunk: (text: string) => void,
117
+ ): Promise<string> {
118
+ const { system, anthropicMessages } = translateMessages(messages);
119
+ const stream = this.client.messages.stream({
120
+ model: this.model,
121
+ max_tokens: MAX_COMPLETION_TOKENS,
122
+ ...(system ? { system } : {}),
123
+ messages: anthropicMessages,
124
+ });
125
+ let text = '';
126
+ stream.on('text', (delta) => {
127
+ text += delta;
128
+ onChunk(delta);
129
+ });
130
+ await stream.finalMessage();
131
+ return text;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * OpenAI-wire-shaped LlmMessage[] → Anthropic { system, messages }.
137
+ * - system messages concatenate into the top-level system param
138
+ * - assistant tool_calls become tool_use blocks (arguments JSON-parsed)
139
+ * - role:"tool" results become user-turn tool_result blocks
140
+ * Consecutive same-role turns are merged (Anthropic requires alternation to
141
+ * start with user; the API tolerates consecutive same-role, but tool_result
142
+ * blocks must open the user turn that answers a tool_use turn).
143
+ */
144
+ function translateMessages(messages: LlmMessage[]): {
145
+ system: string | undefined;
146
+ anthropicMessages: Anthropic.MessageParam[];
147
+ } {
148
+ const systemParts: string[] = [];
149
+ const out: Anthropic.MessageParam[] = [];
150
+
151
+ for (const m of messages) {
152
+ if (m.role === 'system') {
153
+ if (m.content) systemParts.push(m.content);
154
+ continue;
155
+ }
156
+ if (m.role === 'tool') {
157
+ const block: Anthropic.ToolResultBlockParam = {
158
+ type: 'tool_result',
159
+ tool_use_id: m.tool_call_id ?? '',
160
+ content: m.content ?? '',
161
+ };
162
+ const last = out[out.length - 1];
163
+ if (last && last.role === 'user' && Array.isArray(last.content)) {
164
+ (last.content as Anthropic.ContentBlockParam[]).push(block);
165
+ } else {
166
+ out.push({ role: 'user', content: [block] });
167
+ }
168
+ continue;
169
+ }
170
+ if (m.role === 'assistant' && m.tool_calls?.length) {
171
+ const blocks: Anthropic.ContentBlockParam[] = [];
172
+ if (m.content) blocks.push({ type: 'text', text: m.content });
173
+ for (const tc of m.tool_calls) {
174
+ blocks.push({
175
+ type: 'tool_use',
176
+ id: tc.id,
177
+ name: tc.function.name,
178
+ input: safeParseJson(tc.function.arguments),
179
+ });
180
+ }
181
+ out.push({ role: 'assistant', content: blocks });
182
+ continue;
183
+ }
184
+ out.push({ role: m.role, content: m.content ?? '' });
185
+ }
186
+
187
+ if (!out.length || out[0].role !== 'user') {
188
+ out.unshift({ role: 'user', content: '(session start)' });
189
+ }
190
+
191
+ return {
192
+ system: systemParts.length ? systemParts.join('\n\n') : undefined,
193
+ anthropicMessages: out,
194
+ };
195
+ }
196
+
197
+ function safeParseJson(s: string): Record<string, unknown> {
198
+ try {
199
+ const v = JSON.parse(s);
200
+ return typeof v === 'object' && v !== null ? v : {};
201
+ } catch {
202
+ // Malformed arguments string from a prior turn's history: replaying it as
203
+ // an empty input beats crashing the whole turn on someone else's output.
204
+ return {};
205
+ }
206
+ }
@@ -8,7 +8,17 @@ import type { Workflow } from '../types.js';
8
8
 
9
9
  export type McpTransportConfig =
10
10
  | { type: 'stdio'; command: string; args?: string[]; env?: Record<string, string> }
11
- | { type: 'http'; url: string }
11
+ | {
12
+ type: 'http';
13
+ url: string;
14
+ /**
15
+ * Optional static request headers — e.g. a fixed API key the server is
16
+ * keyed by. Sent on every request to the server. Values are used as-is;
17
+ * `$VAR` interpolation (if any) is the caller's job, mirroring how the
18
+ * stdio path leaves `env` interpolation to the caller.
19
+ */
20
+ headers?: Record<string, string>;
21
+ }
12
22
  | {
13
23
  /**
14
24
  * Brokered connection through the Ziggs MCP gateway: the agent speaks
@@ -120,6 +130,28 @@ export interface CreateMcpAgentOptions extends Omit<AgentHostOptions, 'tools' |
120
130
  extraTools?: ToolDefinition[];
121
131
  /** Override the default single-state thinking workflow. */
122
132
  workflow?: Workflow;
133
+ /**
134
+ * Max tools to bind to the LLM per turn. LLM providers cap the `tools` array
135
+ * (OpenAI: 128); an MCP server exposing more (e.g. PostHog: 146) makes every
136
+ * turn fail with `400 ... array too long`. When the server's tool count
137
+ * exceeds this budget the list is truncated (and logged). Defaults to 128.
138
+ * See ZIG-759; Phase 2 will replace truncation with query-aware selection.
139
+ */
140
+ maxTools?: number;
141
+ }
142
+
143
+ /** Provider `tools` array cap (OpenAI's is 128). */
144
+ const DEFAULT_MAX_TOOLS = 128;
145
+ /** Headroom reserved for control actions (wait/respond) + future additions. */
146
+ const CONTROL_TOOL_RESERVE = 4;
147
+
148
+ /**
149
+ * How many MCP tools may be bound to the LLM, given the provider cap and the
150
+ * number of extra (non-MCP) tools the agent also carries. Always >= 1 so a
151
+ * pathological config still binds something rather than nothing. ZIG-759.
152
+ */
153
+ export function mcpToolBudget(maxTools: number, extraToolCount: number): number {
154
+ return Math.max(1, maxTools - CONTROL_TOOL_RESERVE - extraToolCount);
123
155
  }
124
156
 
125
157
  export interface McpAgentHandle {
@@ -130,7 +162,7 @@ export interface McpAgentHandle {
130
162
  toolNames: string[];
131
163
  }
132
164
 
133
- function buildTransport(config: McpTransportConfig, defaults: { agentId?: string }) {
165
+ export function buildTransport(config: McpTransportConfig, defaults: { agentId?: string } = {}) {
134
166
  if (config.type === 'stdio') {
135
167
  return new StdioClientTransport({
136
168
  command: config.command,
@@ -145,7 +177,12 @@ function buildTransport(config: McpTransportConfig, defaults: { agentId?: string
145
177
  fetch: wrapGatewayFetch(),
146
178
  });
147
179
  }
148
- return new StreamableHTTPClientTransport(new URL(config.url));
180
+ return new StreamableHTTPClientTransport(
181
+ new URL(config.url),
182
+ config.headers && Object.keys(config.headers).length > 0
183
+ ? { requestInit: { headers: config.headers } }
184
+ : undefined,
185
+ );
149
186
  }
150
187
 
151
188
  type McpTool = {
@@ -276,17 +313,26 @@ function buildDefaultWorkflow(description: string, mcpTools: McpTool[]): Workflo
276
313
  * });
277
314
  */
278
315
  export async function createMcpAgent(options: CreateMcpAgentOptions): Promise<McpAgentHandle> {
279
- const { transport: transportConfig, extraTools = [], workflow, ...hostOptions } = options;
316
+ const { transport: transportConfig, extraTools = [], workflow, maxTools = DEFAULT_MAX_TOOLS, ...hostOptions } = options;
280
317
 
281
318
  const transport = buildTransport(transportConfig, { agentId: hostOptions.agentId });
282
319
  const client = new Client({ name: 'ziggs-mcp-agent', version: '1.0.0' });
283
320
 
284
321
  await client.connect(transport);
285
322
 
286
- const { tools: rawTools } = await client.listTools();
287
- const mcpTools = mapMcpTools(client, rawTools as McpTool[]);
323
+ const listed = (await client.listTools()).tools as McpTool[];
324
+ // Cap the tool set bound to the LLM: providers reject a `tools` array longer
325
+ // than their limit (OpenAI 128). Truncate — never crash — and log what dropped
326
+ // so a large server (e.g. PostHog: 146 tools) still answers. ZIG-759 Phase 1.
327
+ const budget = mcpToolBudget(maxTools, extraTools.length);
328
+ let rawTools = listed;
329
+ if (listed.length > budget) {
330
+ rawTools = listed.slice(0, budget);
331
+ console.warn(`[McpAdapter] ${hostOptions.agentId}: MCP server exposes ${listed.length} tools, over the ${maxTools}-tool LLM cap; binding the first ${rawTools.length}, dropping ${listed.length - rawTools.length}. (ZIG-759 Phase 2 will select per query.)`);
332
+ }
333
+ const mcpTools = mapMcpTools(client, rawTools);
288
334
 
289
- const resolvedWorkflow = workflow ?? buildDefaultWorkflow(hostOptions.description ?? '', rawTools as McpTool[]);
335
+ const resolvedWorkflow = workflow ?? buildDefaultWorkflow(hostOptions.description ?? '', rawTools);
290
336
 
291
337
  const host = new AgentHost({
292
338
  ...hostOptions,
@@ -14,6 +14,13 @@ interface AdapterOptions {
14
14
  model?: string;
15
15
  }
16
16
 
17
+ /** OpenAI completion-token cap varies by model family (e.g. gpt-4o-mini max 16384). */
18
+ export function maxCompletionTokensForModel(model: string): number {
19
+ const m = model.toLowerCase();
20
+ if (m.includes('gpt-4o')) return 16384;
21
+ return 25000;
22
+ }
23
+
17
24
  export class OpenAIAdapter {
18
25
  private client: OpenAI;
19
26
  model: string;
@@ -99,11 +106,9 @@ export class OpenAIAdapter {
99
106
  model: this.model,
100
107
  messages: finalMessages,
101
108
  stream: false,
102
- // 25000 reserves room for hidden reasoning tokens on reasoning models
103
- // (gpt-5.x/o-series) — they're billed against this cap before any
104
- // visible content, so a low cap can exhaust the budget and return
105
- // empty content with finish_reason "length".
106
- max_completion_tokens: 25000,
109
+ // Reasoning models (gpt-5.x/o-series) need a high cap for hidden
110
+ // reasoning tokens; gpt-4o family is capped lower by the API.
111
+ max_completion_tokens: maxCompletionTokensForModel(this.model),
107
112
  response_format: responseFormat,
108
113
  ...(openAITools && { tools: openAITools }),
109
114
  ...restOptions,
@@ -167,7 +172,7 @@ export class OpenAIAdapter {
167
172
  model: this.model,
168
173
  messages,
169
174
  stream: true,
170
- max_completion_tokens: 25000,
175
+ max_completion_tokens: maxCompletionTokensForModel(this.model),
171
176
  });
172
177
  let text = '';
173
178
  for await (const chunk of stream) {
@@ -1 +1,2 @@
1
1
  export { OpenAIAdapter } from './OpenAIAdapter.js';
2
+ export { AnthropicAdapter } from './AnthropicAdapter.js';
@@ -40,6 +40,8 @@ export interface TickOutput {
40
40
  state: string;
41
41
  /** Whether the machine settled at a parked state. */
42
42
  parked: boolean;
43
+ /** True when a state-timeout outcome matched no transition this tick — the host must NOT re-arm. */
44
+ stateTimeoutUnhandled: boolean;
43
45
  /** Accumulated LLM call count and token usage for this tick. */
44
46
  metrics: { llmCalls: number; tokens: { total: number; input: number; output: number } };
45
47
  }
@@ -95,6 +97,7 @@ export class Agent {
95
97
  ctx: machine.ctx,
96
98
  state: machine.state,
97
99
  parked: machine.isParked,
100
+ stateTimeoutUnhandled: machine.stateTimeoutUnhandled,
98
101
  metrics: { llmCalls: machine.llmCalls, tokens: machine.tokens },
99
102
  };
100
103
  }
@@ -1,12 +1,16 @@
1
1
  import type { AgentConfig, DefineAgentInput, Workflow } from './types.js';
2
2
  import { validateWorkflow } from './runtime/validateWorkflow.js';
3
+ import { injectKickoffDefault } from './runtime/kickoff.js';
3
4
  import { runtimeLog } from './shared/runtimeLog.js';
4
5
 
5
6
  /**
6
7
  * Validates the workflow synchronously, resolves merged tools and model, and
7
- * returns a frozen AgentConfig. Does NOT mutate the input — `wait` action and
8
- * `activeWait` transitions are the workflow author's responsibility (visible
9
- * spread of `thinkingDefaults`).
8
+ * returns a frozen AgentConfig. Never mutates the input. `wait` defaults remain
9
+ * the author's visible spread of `thinkingDefaults`. ONE default is injected (as
10
+ * new objects): the agreement-kickoff state `kickingOff` plus a last-position
11
+ * transition into it, appended to every state that does not already handle a
12
+ * proposer-side proposal-resolved/approve outcome (runtime/kickoff.ts). The
13
+ * state name 'kickingOff' is reserved; defining it throws.
10
14
  *
11
15
  * Validation throws WorkflowValidationError on hard problems and logs warnings
12
16
  * on soft ones (e.g. missing wait fallthrough).
@@ -17,12 +21,14 @@ export function defineAgent(input: DefineAgentInput): AgentConfig {
17
21
  if (!input?.states) throw new Error('[defineAgent] states is required');
18
22
  if (!input?.initial) throw new Error('[defineAgent] initial state is required');
19
23
 
20
- const workflow: Workflow = {
24
+ // Agreement-kickoff default (see runtime/kickoff.ts): injected BEFORE
25
+ // validation so the injected state/transitions are validated too.
26
+ const workflow: Workflow = injectKickoffDefault({
21
27
  id: input.agentId,
22
28
  description: input.description,
23
29
  initial: input.initial,
24
30
  states: input.states,
25
- };
31
+ });
26
32
 
27
33
  const { warnings } = validateWorkflow(workflow);
28
34
  for (const w of warnings) runtimeLog.warn('defineAgent', w);
@@ -34,6 +40,7 @@ export function defineAgent(input: DefineAgentInput): AgentConfig {
34
40
 
35
41
  const model =
36
42
  input.services?.llm?.model ||
43
+ process.env.LLM_MODEL ||
37
44
  process.env.OPENAI_MODEL ||
38
45
  'gpt-5.4';
39
46
 
@@ -46,6 +53,7 @@ export function defineAgent(input: DefineAgentInput): AgentConfig {
46
53
 
47
54
  const config: AgentConfig = {
48
55
  openaiKey: process.env.OPENAI_API_KEY,
56
+ anthropicKey: process.env.ANTHROPIC_API_KEY,
49
57
  ...(operatorKey ? { operatorKey } : {}),
50
58
  agentId,
51
59
  description,
package/src/index.ts CHANGED
@@ -18,6 +18,8 @@ export type { SessionResolver, AgentHostOptions } from './AgentHost.js';
18
18
  export { createZiggsEffectHandler } from './server/ZiggsEffectHandler.js';
19
19
  export type { ZiggsEffectDeps } from './server/ZiggsEffectHandler.js';
20
20
  export { EventQueue } from './server/EventQueue.js';
21
+ export { StateTimers } from './server/StateTimers.js';
22
+ export type { StateTimeoutEvent } from './server/StateTimers.js';
21
23
  export { OutboxBuffer } from './server/OutboxBuffer.js';
22
24
  export { normalizeIncomingEvent } from './ingress/normalizeIncoming.js';
23
25
 
@@ -26,6 +28,7 @@ export { AgentMachine, type TransitionEvent } from './runtime/AgentMachine.js';
26
28
  export { runTurn } from './runtime/runTurn.js';
27
29
  export { PromptBuilder } from './runtime/PromptBuilder.js';
28
30
  export { thinkingDefaults, defineThinkingState, DEFAULT_WAIT_PROMPT } from './runtime/defaults.js';
31
+ export { KICKOFF_STATE, injectKickoffDefault, buildKickoffState } from './runtime/kickoff.js';
29
32
  export { validateWorkflow, WorkflowValidationError } from './runtime/validateWorkflow.js';
30
33
  export { outcomeFromEvent, outcomeFromActionResult, applyOutcomeToCtx } from './runtime/buildOutcome.js';
31
34