@ziggs-ai/agent-sdk 0.1.9 → 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.9",
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,120 @@ 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
+ }
22
+ | {
23
+ /**
24
+ * Brokered connection through the Ziggs MCP gateway: the agent speaks
25
+ * StreamableHTTP to `/connections/:id/mcp` and Ziggs injects the
26
+ * connection owner's OAuth token upstream. The agent never holds the
27
+ * token; every tools/call is checked against the grant's caveats.
28
+ */
29
+ type: 'ziggs';
30
+ /** Connection id (`con_…`) of the user's MCP server connection. */
31
+ connectionId: string;
32
+ /** ConnectionGrant id (`cgr_…`) issued to this agent. */
33
+ grantId: string;
34
+ /** Operator key — defaults to ZIGGS_OPERATOR_KEY. */
35
+ operatorKey?: string;
36
+ /** Ziggs backend base URL — defaults to BACKEND_URL / ZIGGS_BACKEND_URL. */
37
+ baseUrl?: string;
38
+ /** Agent to impersonate (X-Agent-Id) — defaults to the host agentId. */
39
+ agentId?: string;
40
+ };
41
+
42
+ /**
43
+ * Typed failure from the Ziggs MCP gateway (ZIG-702). `code` is the stable
44
+ * machine-readable reason — e.g. `GRANT_REVOKED`, `GRANT_EXPIRED`,
45
+ * `GRANT_HOLDER_MISMATCH`, `GRANT_CONNECTION_MISMATCH` — so agent code can
46
+ * branch ("revoked → tell the user and stop retrying" vs "network blip →
47
+ * retry") instead of parsing transport-error prose.
48
+ */
49
+ export class ZiggsGatewayError extends Error {
50
+ readonly code: string;
51
+ readonly status: number;
52
+
53
+ constructor(code: string, message: string, status: number) {
54
+ super(message);
55
+ this.name = 'ZiggsGatewayError';
56
+ this.code = code;
57
+ this.status = status;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Wrap fetch so structured gateway denials (401/403 with a `code` body)
63
+ * surface as ZiggsGatewayError instead of a generic transport failure.
64
+ */
65
+ export function wrapGatewayFetch(
66
+ baseFetch: typeof fetch = fetch,
67
+ ): typeof fetch {
68
+ return (async (input: RequestInfo | URL, init?: RequestInit) => {
69
+ const response = await baseFetch(input, init);
70
+ if (response.status === 401 || response.status === 403) {
71
+ const body = (await response
72
+ .clone()
73
+ .json()
74
+ .catch(() => null)) as { code?: unknown; message?: unknown } | null;
75
+ if (body && typeof body.code === 'string') {
76
+ throw new ZiggsGatewayError(
77
+ body.code,
78
+ typeof body.message === 'string' ? body.message : body.code,
79
+ response.status,
80
+ );
81
+ }
82
+ }
83
+ return response;
84
+ }) as typeof fetch;
85
+ }
86
+
87
+ /** Resolve the gateway URL + auth headers for a `type: 'ziggs'` transport. */
88
+ export function resolveZiggsGatewayTarget(
89
+ config: Extract<McpTransportConfig, { type: 'ziggs' }>,
90
+ defaults: { agentId?: string } = {},
91
+ ): { url: URL; headers: Record<string, string> } {
92
+ if (!config.connectionId) {
93
+ throw new Error('ziggs MCP transport requires a connectionId');
94
+ }
95
+ if (!config.grantId) {
96
+ throw new Error('ziggs MCP transport requires a grantId');
97
+ }
98
+ const operatorKey = config.operatorKey || process.env.ZIGGS_OPERATOR_KEY;
99
+ if (!operatorKey) {
100
+ throw new Error(
101
+ 'ziggs MCP transport requires an operatorKey (or ZIGGS_OPERATOR_KEY)',
102
+ );
103
+ }
104
+ const agentId = config.agentId || defaults.agentId;
105
+ if (!agentId) {
106
+ throw new Error(
107
+ 'ziggs MCP transport requires an agentId to impersonate (X-Agent-Id)',
108
+ );
109
+ }
110
+ const baseUrl = (
111
+ config.baseUrl ||
112
+ process.env.BACKEND_URL ||
113
+ process.env.ZIGGS_BACKEND_URL ||
114
+ 'http://localhost:3000'
115
+ ).replace(/\/$/, '');
116
+ return {
117
+ url: new URL(`${baseUrl}/connections/${encodeURIComponent(config.connectionId)}/mcp`),
118
+ headers: {
119
+ authorization: `Bearer ${operatorKey}`,
120
+ 'x-agent-id': agentId,
121
+ 'x-grant-id': config.grantId,
122
+ },
123
+ };
124
+ }
12
125
 
13
126
  export interface CreateMcpAgentOptions extends Omit<AgentHostOptions, 'tools' | 'workflow' | 'states' | 'initial'> {
14
127
  /** MCP server connection — stdio (local process) or HTTP (remote). */
@@ -17,6 +130,28 @@ export interface CreateMcpAgentOptions extends Omit<AgentHostOptions, 'tools' |
17
130
  extraTools?: ToolDefinition[];
18
131
  /** Override the default single-state thinking workflow. */
19
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);
20
155
  }
21
156
 
22
157
  export interface McpAgentHandle {
@@ -27,7 +162,7 @@ export interface McpAgentHandle {
27
162
  toolNames: string[];
28
163
  }
29
164
 
30
- function buildTransport(config: McpTransportConfig) {
165
+ export function buildTransport(config: McpTransportConfig, defaults: { agentId?: string } = {}) {
31
166
  if (config.type === 'stdio') {
32
167
  return new StdioClientTransport({
33
168
  command: config.command,
@@ -35,7 +170,19 @@ function buildTransport(config: McpTransportConfig) {
35
170
  env: config.env,
36
171
  });
37
172
  }
38
- return new StreamableHTTPClientTransport(new URL(config.url));
173
+ if (config.type === 'ziggs') {
174
+ const target = resolveZiggsGatewayTarget(config, defaults);
175
+ return new StreamableHTTPClientTransport(target.url, {
176
+ requestInit: { headers: target.headers },
177
+ fetch: wrapGatewayFetch(),
178
+ });
179
+ }
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
+ );
39
186
  }
40
187
 
41
188
  type McpTool = {
@@ -81,28 +228,6 @@ function mapMcpTools(client: Client, tools: McpTool[]): ToolDefinition[] {
81
228
  }));
82
229
  }
83
230
 
84
- /**
85
- * Wraps any MCP server as a Ziggs AgentHost.
86
- *
87
- * 1. Opens a connection to the MCP server (stdio or HTTP).
88
- * 2. Calls tools/list and maps each tool to a Ziggs ToolDefinition.
89
- * 3. Returns an AgentHost pre-loaded with those tools, ready to connectAsync().
90
- *
91
- * Usage:
92
- * const { host, closeMcp } = await createMcpAgent({
93
- * transport: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-github'] },
94
- * agentId: 'my-github-agent',
95
- * name: 'GitHub Agent',
96
- * description: 'Wraps the GitHub MCP server',
97
- * });
98
- * await host.connectAsync();
99
- *
100
- * process.on('SIGTERM', async () => {
101
- * host.disconnect();
102
- * await host.drain();
103
- * await closeMcp();
104
- * });
105
- */
106
231
  function buildDefaultWorkflow(description: string, mcpTools: McpTool[]): Workflow {
107
232
  const dflt = thinkingDefaults({ initial: 'idle' });
108
233
  // Bind each MCP tool as an explicit action so runTurn includes them in the
@@ -157,11 +282,11 @@ function buildDefaultWorkflow(description: string, mcpTools: McpTool[]): Workflo
157
282
  /**
158
283
  * Wraps any MCP server as a Ziggs AgentHost.
159
284
  *
160
- * 1. Opens a connection to the MCP server (stdio or HTTP).
285
+ * 1. Opens a connection to the MCP server (stdio, HTTP, or the Ziggs gateway).
161
286
  * 2. Calls tools/list and maps each tool to a Ziggs ToolDefinition.
162
287
  * 3. Returns an AgentHost pre-loaded with those tools, ready to connectAsync().
163
288
  *
164
- * Usage:
289
+ * Usage (local stdio server):
165
290
  * const { host, closeMcp } = await createMcpAgent({
166
291
  * transport: { type: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-github'] },
167
292
  * agentId: 'my-github-agent',
@@ -170,6 +295,17 @@ function buildDefaultWorkflow(description: string, mcpTools: McpTool[]): Workflo
170
295
  * });
171
296
  * await host.connectAsync();
172
297
  *
298
+ * Usage (OAuth-protected remote server, brokered through Ziggs — the user
299
+ * connected the server and issued this agent a grant; the agent never sees
300
+ * the user's token):
301
+ * const { host, closeMcp } = await createMcpAgent({
302
+ * transport: { type: 'ziggs', connectionId: 'con_abc', grantId: 'cgr_xyz' },
303
+ * agentId: 'my-notion-agent',
304
+ * name: 'Notion Agent',
305
+ * description: 'Works in the owner\'s Notion via their MCP connection',
306
+ * });
307
+ * await host.connectAsync();
308
+ *
173
309
  * process.on('SIGTERM', async () => {
174
310
  * host.disconnect();
175
311
  * await host.drain();
@@ -177,17 +313,26 @@ function buildDefaultWorkflow(description: string, mcpTools: McpTool[]): Workflo
177
313
  * });
178
314
  */
179
315
  export async function createMcpAgent(options: CreateMcpAgentOptions): Promise<McpAgentHandle> {
180
- const { transport: transportConfig, extraTools = [], workflow, ...hostOptions } = options;
316
+ const { transport: transportConfig, extraTools = [], workflow, maxTools = DEFAULT_MAX_TOOLS, ...hostOptions } = options;
181
317
 
182
- const transport = buildTransport(transportConfig);
318
+ const transport = buildTransport(transportConfig, { agentId: hostOptions.agentId });
183
319
  const client = new Client({ name: 'ziggs-mcp-agent', version: '1.0.0' });
184
320
 
185
321
  await client.connect(transport);
186
322
 
187
- const { tools: rawTools } = await client.listTools();
188
- 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);
189
334
 
190
- const resolvedWorkflow = workflow ?? buildDefaultWorkflow(hostOptions.description ?? '', rawTools as McpTool[]);
335
+ const resolvedWorkflow = workflow ?? buildDefaultWorkflow(hostOptions.description ?? '', rawTools);
191
336
 
192
337
  const host = new AgentHost({
193
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';