@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 +3 -2
- package/src/AgentHost.ts +54 -4
- package/src/adapters/AnthropicAdapter.ts +206 -0
- package/src/adapters/McpAdapter.ts +177 -32
- package/src/adapters/OpenAIAdapter.ts +11 -6
- package/src/adapters/index.ts +1 -0
- package/src/agent/Agent.ts +3 -0
- package/src/defineAgent.ts +13 -5
- package/src/index.ts +4 -1
- package/src/pricing/fleetTierByAgentId.gen.ts +53 -1
- package/src/runtime/AgentMachine.ts +50 -12
- package/src/runtime/PromptBuilder.ts +20 -1
- package/src/runtime/buildOutcome.ts +51 -6
- package/src/runtime/kickoff.ts +117 -0
- package/src/runtime/runTurn.ts +173 -56
- package/src/runtime/validateWorkflow.ts +26 -5
- package/src/server/ConnectionPool.ts +56 -5
- package/src/server/StateTimers.ts +85 -0
- package/src/server/ZiggsEffectHandler.ts +10 -1
- package/src/server/tasks/TaskService.ts +13 -2
- package/src/server/tools/index.ts +1 -1
- package/src/server/tools/tier1/protocolRunner.ts +10 -1
- package/src/server/tools/tier1/protocolTools.ts +1 -1
- package/src/server/tools/tier2/paymentTools.ts +2 -25
- package/src/types.ts +56 -7
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.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.
|
|
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
|
-
:
|
|
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.
|
|
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
|
-
| {
|
|
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
|
-
|
|
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
|
|
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
|
|
188
|
-
|
|
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
|
|
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
|
-
//
|
|
103
|
-
//
|
|
104
|
-
|
|
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:
|
|
175
|
+
max_completion_tokens: maxCompletionTokensForModel(this.model),
|
|
171
176
|
});
|
|
172
177
|
let text = '';
|
|
173
178
|
for await (const chunk of stream) {
|
package/src/adapters/index.ts
CHANGED