@ziggs-ai/agent-sdk 0.1.11 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -3
- package/src/AgentHost.ts +297 -28
- package/src/adapters/AnthropicAdapter.ts +42 -10
- package/src/adapters/McpAdapter.ts +350 -20
- package/src/adapters/OpenAIAdapter.ts +9 -2
- package/src/agent/Agent.ts +10 -2
- package/src/agent/ClaudeSdkAgent.ts +401 -0
- package/src/agent/claudeCognition.ts +139 -0
- package/src/agent/claudeSdkProtocol.ts +27 -0
- package/src/agent/claudeSdkTools.ts +81 -0
- package/src/agent/hostedAgentConnection.ts +41 -0
- package/src/agent/toolManagerToSdkMcp.ts +136 -0
- package/src/defineAgent.ts +9 -2
- package/src/index.ts +11 -5
- package/src/runtime/AgentMachine.ts +104 -22
- package/src/runtime/PromptBuilder.ts +42 -5
- package/src/runtime/bootstrapCtx.ts +39 -0
- package/src/runtime/defaults.ts +12 -1
- package/src/runtime/deriveRespondsToProposals.ts +35 -0
- package/src/runtime/runMechanical.ts +106 -0
- package/src/runtime/runTurn.ts +65 -8
- package/src/runtime/validateWorkflow.ts +33 -14
- package/src/server/InboxCatchUp.ts +37 -0
- package/src/server/ZiggsEffectHandler.ts +188 -179
- package/src/server/agreements/AgreementService.ts +3 -3
- package/src/server/tasks/TaskService.ts +2 -1
- package/src/server/tools/tier1/protocolRunner.ts +212 -3
- package/src/server/tools/tier1/protocolTools.ts +18 -3
- package/src/types.ts +49 -22
|
@@ -37,6 +37,32 @@ export type McpTransportConfig =
|
|
|
37
37
|
baseUrl?: string;
|
|
38
38
|
/** Agent to impersonate (X-Agent-Id) — defaults to the host agentId. */
|
|
39
39
|
agentId?: string;
|
|
40
|
+
}
|
|
41
|
+
| {
|
|
42
|
+
/**
|
|
43
|
+
* Per-user brokered connection (ZIG-771): the agent holds NO
|
|
44
|
+
* connectionId/grantId. It speaks StreamableHTTP to
|
|
45
|
+
* `/connections/mcp/relay` and asserts the invoking end-user via
|
|
46
|
+
* `x-on-behalf-of-user`; the gateway resolves that user's own connection
|
|
47
|
+
* + grant and injects their token. One MCP client is created per invoking
|
|
48
|
+
* user so each keeps an isolated upstream session. Tool schemas cannot be
|
|
49
|
+
* discovered at boot (there is no connection yet) — pass `toolSchema`.
|
|
50
|
+
*/
|
|
51
|
+
type: 'ziggs-per-user';
|
|
52
|
+
/** Operator key — defaults to ZIGGS_OPERATOR_KEY. */
|
|
53
|
+
operatorKey?: string;
|
|
54
|
+
/** Ziggs backend base URL — defaults to BACKEND_URL / ZIGGS_BACKEND_URL. */
|
|
55
|
+
baseUrl?: string;
|
|
56
|
+
/** Agent to impersonate (X-Agent-Id) — defaults to the host agentId. */
|
|
57
|
+
agentId?: string;
|
|
58
|
+
/**
|
|
59
|
+
* The MCP server URL this agent fronts. Not used for gateway calls (the
|
|
60
|
+
* gateway derives the server from the resolved connection), but needed to
|
|
61
|
+
* open a connection request when the invoking user hasn't connected yet
|
|
62
|
+
* (ZIG-771). Without it, a NO_USER_CONNECTION surfaces as an error rather
|
|
63
|
+
* than an auto-created consent prompt.
|
|
64
|
+
*/
|
|
65
|
+
serverUrl?: string;
|
|
40
66
|
};
|
|
41
67
|
|
|
42
68
|
/**
|
|
@@ -123,6 +149,43 @@ export function resolveZiggsGatewayTarget(
|
|
|
123
149
|
};
|
|
124
150
|
}
|
|
125
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Resolve the per-user gateway URL + headers (ZIG-771). No connectionId/grantId —
|
|
154
|
+
* the gateway resolves the invoking user's own connection + grant from
|
|
155
|
+
* `x-on-behalf-of-user` + the impersonated agent.
|
|
156
|
+
*/
|
|
157
|
+
export function resolvePerUserGatewayTarget(
|
|
158
|
+
config: Extract<McpTransportConfig, { type: 'ziggs-per-user' }>,
|
|
159
|
+
agentId: string,
|
|
160
|
+
userId: string,
|
|
161
|
+
onBehalfOfAgreementId?: string,
|
|
162
|
+
): { url: URL; headers: Record<string, string> } {
|
|
163
|
+
const operatorKey = config.operatorKey || process.env.ZIGGS_OPERATOR_KEY;
|
|
164
|
+
if (!operatorKey) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
'ziggs-per-user MCP transport requires an operatorKey (or ZIGGS_OPERATOR_KEY)',
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
const baseUrl = (
|
|
170
|
+
config.baseUrl ||
|
|
171
|
+
process.env.BACKEND_URL ||
|
|
172
|
+
process.env.ZIGGS_BACKEND_URL ||
|
|
173
|
+
'http://localhost:3000'
|
|
174
|
+
).replace(/\/$/, '');
|
|
175
|
+
return {
|
|
176
|
+
url: new URL(`${baseUrl}/connections/mcp/relay`),
|
|
177
|
+
headers: {
|
|
178
|
+
authorization: `Bearer ${operatorKey}`,
|
|
179
|
+
'x-agent-id': agentId,
|
|
180
|
+
'x-on-behalf-of-user': userId,
|
|
181
|
+
// ZIG-831: when this call runs under a delegation, userId is the delegating
|
|
182
|
+
// agent (senderId), not the end-user who owns the connection. The gateway
|
|
183
|
+
// uses this agreement to resolve the delegation root's user.
|
|
184
|
+
...(onBehalfOfAgreementId ? { 'x-on-behalf-of-agreement': onBehalfOfAgreementId } : {}),
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
126
189
|
export interface CreateMcpAgentOptions extends Omit<AgentHostOptions, 'tools' | 'workflow' | 'states' | 'initial'> {
|
|
127
190
|
/** MCP server connection — stdio (local process) or HTTP (remote). */
|
|
128
191
|
transport: McpTransportConfig;
|
|
@@ -138,6 +201,26 @@ export interface CreateMcpAgentOptions extends Omit<AgentHostOptions, 'tools' |
|
|
|
138
201
|
* See ZIG-759; Phase 2 will replace truncation with query-aware selection.
|
|
139
202
|
*/
|
|
140
203
|
maxTools?: number;
|
|
204
|
+
/**
|
|
205
|
+
* Restrict the bound tool set to only tools whose name matches one of these
|
|
206
|
+
* case-insensitive substrings, applied BEFORE the maxTools cap. Lets a large
|
|
207
|
+
* MCP server (e.g. PostHog's 146 tools) be narrowed to the handful this agent
|
|
208
|
+
* actually needs — cutting per-turn cost and stopping the model from thrashing
|
|
209
|
+
* across (or never binding) the tool it should call. Empty/undefined = bind
|
|
210
|
+
* all (unchanged behavior). ZIG-759 Phase 2 = name-based selection.
|
|
211
|
+
*/
|
|
212
|
+
allowedTools?: string[];
|
|
213
|
+
/**
|
|
214
|
+
* Pre-discovered MCP tool schemas. REQUIRED for `type: 'ziggs-per-user'`
|
|
215
|
+
* (ZIG-771) — there is no boot connection to call `tools/list` on, so the
|
|
216
|
+
* schema is baked at provision time and passed here. Ignored for the other
|
|
217
|
+
* transports, which discover tools live at boot.
|
|
218
|
+
*/
|
|
219
|
+
toolSchema?: Array<{
|
|
220
|
+
name: string;
|
|
221
|
+
description?: string;
|
|
222
|
+
inputSchema?: Record<string, unknown>;
|
|
223
|
+
}>;
|
|
141
224
|
}
|
|
142
225
|
|
|
143
226
|
/** Provider `tools` array cap (OpenAI's is 128). */
|
|
@@ -177,6 +260,11 @@ export function buildTransport(config: McpTransportConfig, defaults: { agentId?:
|
|
|
177
260
|
fetch: wrapGatewayFetch(),
|
|
178
261
|
});
|
|
179
262
|
}
|
|
263
|
+
if (config.type === 'ziggs-per-user') {
|
|
264
|
+
// Per-user transports are built lazily per invoking user in createMcpAgent
|
|
265
|
+
// (each needs a different x-on-behalf-of-user header), not here.
|
|
266
|
+
throw new Error('ziggs-per-user transport is created per invoking user, not via buildTransport');
|
|
267
|
+
}
|
|
180
268
|
return new StreamableHTTPClientTransport(
|
|
181
269
|
new URL(config.url),
|
|
182
270
|
config.headers && Object.keys(config.headers).length > 0
|
|
@@ -204,7 +292,17 @@ function mcpContentToResult(content: McpContent[]): unknown {
|
|
|
204
292
|
}
|
|
205
293
|
}
|
|
206
294
|
|
|
207
|
-
|
|
295
|
+
/**
|
|
296
|
+
* Calls one MCP tool. The single-client path closes over a fixed client; the
|
|
297
|
+
* per-user path (ZIG-771) uses `context.senderId` to pick that user's client.
|
|
298
|
+
*/
|
|
299
|
+
type McpToolCaller = (
|
|
300
|
+
toolName: string,
|
|
301
|
+
args: Record<string, unknown>,
|
|
302
|
+
context: Record<string, unknown>,
|
|
303
|
+
) => Promise<{ isError?: boolean; content: McpContent[] }>;
|
|
304
|
+
|
|
305
|
+
function mapMcpTools(callTool: McpToolCaller, tools: McpTool[]): ToolDefinition[] {
|
|
208
306
|
return tools.map(tool => ({
|
|
209
307
|
schema: {
|
|
210
308
|
type: 'function' as const,
|
|
@@ -214,11 +312,11 @@ function mapMcpTools(client: Client, tools: McpTool[]): ToolDefinition[] {
|
|
|
214
312
|
parameters: tool.inputSchema ?? { type: 'object', properties: {} },
|
|
215
313
|
},
|
|
216
314
|
},
|
|
217
|
-
handler: async (
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
315
|
+
handler: async (
|
|
316
|
+
args: Record<string, unknown>,
|
|
317
|
+
context: Record<string, unknown>,
|
|
318
|
+
) => {
|
|
319
|
+
const result = await callTool(tool.name, args, context);
|
|
222
320
|
if (result.isError) {
|
|
223
321
|
throw new Error(mcpContentToResult(result.content) as string);
|
|
224
322
|
}
|
|
@@ -279,6 +377,90 @@ function buildDefaultWorkflow(description: string, mcpTools: McpTool[]): Workflo
|
|
|
279
377
|
};
|
|
280
378
|
}
|
|
281
379
|
|
|
380
|
+
/**
|
|
381
|
+
* Max chars for a single tool description bound to the LLM. Verbose MCP servers
|
|
382
|
+
* embed whole manuals in their descriptions (PostHog's `execute-sql` alone is
|
|
383
|
+
* ~30K chars of HogQL docs; its 146 tools total ~54K tokens of descriptions) and
|
|
384
|
+
* that full block rides every turn. Keep the lead — what the tool does + how to
|
|
385
|
+
* call it — and drop the embedded reference; agents fetch detail via dedicated
|
|
386
|
+
* schema/help tools at call time. Tunable; big enough to preserve real guidance.
|
|
387
|
+
* ZIG-759.
|
|
388
|
+
*/
|
|
389
|
+
export const MAX_TOOL_DESC_CHARS = 2000;
|
|
390
|
+
|
|
391
|
+
/** Truncate one description at a sentence/newline boundary near the cap. */
|
|
392
|
+
export function trimDescription(desc: string | undefined): string | undefined {
|
|
393
|
+
if (!desc || desc.length <= MAX_TOOL_DESC_CHARS) return desc;
|
|
394
|
+
const slice = desc.slice(0, MAX_TOOL_DESC_CHARS);
|
|
395
|
+
const boundary = Math.max(slice.lastIndexOf('\n'), slice.lastIndexOf('. '));
|
|
396
|
+
const head = boundary > MAX_TOOL_DESC_CHARS * 0.6 ? slice.slice(0, boundary + 1) : slice;
|
|
397
|
+
return `${head.trimEnd()} […description truncated; use the server's schema/help tools for full detail]`;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Trim oversized descriptions across the tool set (cost control, ZIG-759). */
|
|
401
|
+
function trimToolDescriptions(tools: McpTool[], agentId: string | undefined): McpTool[] {
|
|
402
|
+
let trimmedCount = 0;
|
|
403
|
+
let savedChars = 0;
|
|
404
|
+
const out = tools.map((t) => {
|
|
405
|
+
const nextDesc = trimDescription(t.description);
|
|
406
|
+
if (nextDesc === t.description) return t;
|
|
407
|
+
trimmedCount += 1;
|
|
408
|
+
savedChars += (t.description?.length ?? 0) - (nextDesc?.length ?? 0);
|
|
409
|
+
return { ...t, description: nextDesc };
|
|
410
|
+
});
|
|
411
|
+
if (trimmedCount > 0) {
|
|
412
|
+
console.warn(
|
|
413
|
+
`[McpAdapter] ${agentId}: trimmed ${trimmedCount} oversized tool description(s) to ${MAX_TOOL_DESC_CHARS} chars, saving ~${Math.round(savedChars / 4 / 1000)}K tokens/turn. (ZIG-759)`,
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
return out;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Keep only tools whose name matches one of the allowlist substrings
|
|
421
|
+
* (case-insensitive). No allowlist → pass through unchanged. Applied before the
|
|
422
|
+
* maxTools cap so a targeted agent binds exactly the tools it needs. ZIG-759.
|
|
423
|
+
*/
|
|
424
|
+
function filterToolsByAllowlist(
|
|
425
|
+
tools: McpTool[],
|
|
426
|
+
allowedTools: string[] | undefined,
|
|
427
|
+
agentId: string | undefined,
|
|
428
|
+
): McpTool[] {
|
|
429
|
+
if (!allowedTools || allowedTools.length === 0) return tools;
|
|
430
|
+
const patterns = allowedTools.map((p) => p.toLowerCase());
|
|
431
|
+
const kept = tools.filter((t) =>
|
|
432
|
+
patterns.some((p) => t.name.toLowerCase().includes(p)),
|
|
433
|
+
);
|
|
434
|
+
console.warn(
|
|
435
|
+
`[McpAdapter] ${agentId}: tool allowlist active — bound ${kept.length}/${tools.length} tools matching [${allowedTools.join(', ')}]${kept.length === 0 ? ' ⚠️ NONE MATCHED — check patterns against the server\'s tool names' : ': ' + kept.map((t) => t.name).join(', ')}.`,
|
|
436
|
+
);
|
|
437
|
+
return kept;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Cap the tool set bound to the LLM at the provider limit (OpenAI 128) —
|
|
442
|
+
* truncate and log rather than crash — and trim oversized descriptions so the
|
|
443
|
+
* bound schema doesn't ship a manual every turn. ZIG-759 Phase 1. Shared by the
|
|
444
|
+
* boot-time (single-client) and per-user paths. When `allowedTools` is set the
|
|
445
|
+
* server's tools are narrowed by name first, so the cap rarely bites.
|
|
446
|
+
*/
|
|
447
|
+
function capTools(
|
|
448
|
+
tools: McpTool[],
|
|
449
|
+
agentId: string | undefined,
|
|
450
|
+
maxTools: number,
|
|
451
|
+
extraToolCount: number,
|
|
452
|
+
allowedTools?: string[],
|
|
453
|
+
): McpTool[] {
|
|
454
|
+
const allowed = filterToolsByAllowlist(tools, allowedTools, agentId);
|
|
455
|
+
const trimmed = trimToolDescriptions(allowed, agentId);
|
|
456
|
+
const budget = mcpToolBudget(maxTools, extraToolCount);
|
|
457
|
+
if (trimmed.length <= budget) return trimmed;
|
|
458
|
+
console.warn(
|
|
459
|
+
`[McpAdapter] ${agentId}: MCP server exposes ${trimmed.length} tools, over the ${maxTools}-tool LLM cap; binding the first ${budget}, dropping ${trimmed.length - budget}. (ZIG-759 Phase 2 will select per query.)`,
|
|
460
|
+
);
|
|
461
|
+
return trimmed.slice(0, budget);
|
|
462
|
+
}
|
|
463
|
+
|
|
282
464
|
/**
|
|
283
465
|
* Wraps any MCP server as a Ziggs AgentHost.
|
|
284
466
|
*
|
|
@@ -313,27 +495,175 @@ function buildDefaultWorkflow(description: string, mcpTools: McpTool[]): Workflo
|
|
|
313
495
|
* });
|
|
314
496
|
*/
|
|
315
497
|
export async function createMcpAgent(options: CreateMcpAgentOptions): Promise<McpAgentHandle> {
|
|
316
|
-
const { transport: transportConfig, extraTools = [], workflow, maxTools = DEFAULT_MAX_TOOLS, ...hostOptions } = options;
|
|
498
|
+
const { transport: transportConfig, extraTools = [], workflow, maxTools = DEFAULT_MAX_TOOLS, allowedTools, toolSchema, ...hostOptions } = options;
|
|
499
|
+
|
|
500
|
+
// ── Per-user path (ZIG-771): no boot connection; one MCP client per invoking
|
|
501
|
+
// user, each carrying that user's on-behalf-of header for an isolated session.
|
|
502
|
+
if (transportConfig.type === 'ziggs-per-user') {
|
|
503
|
+
const agentId = transportConfig.agentId || hostOptions.agentId;
|
|
504
|
+
if (!agentId) {
|
|
505
|
+
throw new Error('ziggs-per-user MCP transport requires an agentId to impersonate');
|
|
506
|
+
}
|
|
507
|
+
if (!toolSchema || toolSchema.length === 0) {
|
|
508
|
+
throw new Error('ziggs-per-user MCP transport requires toolSchema (no boot connection to discover tools)');
|
|
509
|
+
}
|
|
510
|
+
const tools = capTools(toolSchema as McpTool[], agentId, maxTools, extraTools.length, allowedTools);
|
|
317
511
|
|
|
512
|
+
const clients = new Map<string, Promise<Client>>();
|
|
513
|
+
const clientFor = (
|
|
514
|
+
userId: string,
|
|
515
|
+
onBehalfOfAgreementId?: string,
|
|
516
|
+
): Promise<Client> => {
|
|
517
|
+
// Key by user + agreement: the gateway may resolve a different end-user
|
|
518
|
+
// per agreement (delegation), so sessions must not be shared across them.
|
|
519
|
+
const cacheKey = `${userId}|${onBehalfOfAgreementId ?? ''}`;
|
|
520
|
+
let p = clients.get(cacheKey);
|
|
521
|
+
if (!p) {
|
|
522
|
+
p = (async () => {
|
|
523
|
+
const target = resolvePerUserGatewayTarget(transportConfig, agentId, userId, onBehalfOfAgreementId);
|
|
524
|
+
const transport = new StreamableHTTPClientTransport(target.url, {
|
|
525
|
+
requestInit: { headers: target.headers },
|
|
526
|
+
fetch: wrapGatewayFetch(),
|
|
527
|
+
});
|
|
528
|
+
const c = new Client({ name: 'ziggs-mcp-agent', version: '1.0.0' });
|
|
529
|
+
await c.connect(transport);
|
|
530
|
+
return c;
|
|
531
|
+
})().catch((err: unknown) => {
|
|
532
|
+
// Don't cache a failed connect (e.g. NO_USER_CONNECTION before the
|
|
533
|
+
// user consents) — the next turn must be able to retry.
|
|
534
|
+
clients.delete(cacheKey);
|
|
535
|
+
throw err;
|
|
536
|
+
});
|
|
537
|
+
clients.set(cacheKey, p);
|
|
538
|
+
}
|
|
539
|
+
return p;
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
// When the invoking user hasn't connected this server, open a connection
|
|
543
|
+
// request targeting THEM (X-On-Behalf-Of-User) so the consent prompt lands
|
|
544
|
+
// in their inbox. The backend dedups pending requests, so retrying every
|
|
545
|
+
// turn is safe. ZIG-771.
|
|
546
|
+
const backendBase = (
|
|
547
|
+
transportConfig.baseUrl ||
|
|
548
|
+
process.env.BACKEND_URL ||
|
|
549
|
+
process.env.ZIGGS_BACKEND_URL ||
|
|
550
|
+
'http://localhost:3000'
|
|
551
|
+
).replace(/\/$/, '');
|
|
552
|
+
// App URL for the user-facing connect link (ZIG-790): api.ziggsai.com → ziggsai.com.
|
|
553
|
+
const appUrl = backendBase.includes('localhost')
|
|
554
|
+
? 'http://localhost:1234'
|
|
555
|
+
: backendBase.replace(/\/\/api\./, '//');
|
|
556
|
+
const serverLabel = transportConfig.serverUrl
|
|
557
|
+
? new URL(transportConfig.serverUrl).host
|
|
558
|
+
: 'the external service';
|
|
559
|
+
// Open a connection request for this user; returns its id so the caller can
|
|
560
|
+
// deep-link the user straight to it on the Connections page (ZIG-790).
|
|
561
|
+
const triggerConnectionRequest = async (userId: string): Promise<string | null> => {
|
|
562
|
+
if (!transportConfig.serverUrl) return null;
|
|
563
|
+
const operatorKey = transportConfig.operatorKey || process.env.ZIGGS_OPERATOR_KEY;
|
|
564
|
+
const res = await fetch(`${backendBase}/connections/mcp/requests`, {
|
|
565
|
+
method: 'POST',
|
|
566
|
+
headers: {
|
|
567
|
+
'content-type': 'application/json',
|
|
568
|
+
authorization: `Bearer ${operatorKey}`,
|
|
569
|
+
'x-agent-id': agentId,
|
|
570
|
+
'x-on-behalf-of-user': userId,
|
|
571
|
+
},
|
|
572
|
+
body: JSON.stringify({
|
|
573
|
+
serverUrl: transportConfig.serverUrl,
|
|
574
|
+
tools: tools.map(t => t.name),
|
|
575
|
+
}),
|
|
576
|
+
});
|
|
577
|
+
const body = (await res.json().catch(() => null)) as { request?: { requestId?: string } } | null;
|
|
578
|
+
return body?.request?.requestId ?? null;
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
const caller: McpToolCaller = async (name, args, context) => {
|
|
582
|
+
const userId = typeof context.senderId === 'string' ? context.senderId : undefined;
|
|
583
|
+
if (!userId) {
|
|
584
|
+
throw new Error('per-user MCP tool call requires an invoking user (context.senderId is missing)');
|
|
585
|
+
}
|
|
586
|
+
// ZIG-831: for a delegated wake, senderId is the delegating agent, not the
|
|
587
|
+
// connection-owning end-user; pass the agreement so the gateway resolves
|
|
588
|
+
// the delegation root's user.
|
|
589
|
+
const onBehalfOfAgreementId =
|
|
590
|
+
typeof context.onBehalfOfAgreementId === 'string' ? context.onBehalfOfAgreementId : undefined;
|
|
591
|
+
// ZIG-832 (B): per-user resolution is intermittently flaky even when the
|
|
592
|
+
// connection is valid and resolvable — the same on-behalf call fails with
|
|
593
|
+
// NO_USER_CONNECTION on one wake and succeeds on the next. clientFor never
|
|
594
|
+
// caches a failed connect, so each attempt re-resolves from scratch. Retry
|
|
595
|
+
// a few times (with backoff) before surfacing the connect prompt, so a
|
|
596
|
+
// transient miss no longer fails the whole fetch step.
|
|
597
|
+
const MAX_ATTEMPTS = 3;
|
|
598
|
+
let lastErr: unknown;
|
|
599
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
600
|
+
try {
|
|
601
|
+
const c = await clientFor(userId, onBehalfOfAgreementId);
|
|
602
|
+
return (await c.callTool({ name, arguments: args })) as {
|
|
603
|
+
isError?: boolean;
|
|
604
|
+
content: McpContent[];
|
|
605
|
+
};
|
|
606
|
+
} catch (err) {
|
|
607
|
+
lastErr = err;
|
|
608
|
+
const resolutionMiss =
|
|
609
|
+
err instanceof ZiggsGatewayError && err.code === 'NO_USER_CONNECTION';
|
|
610
|
+
const transient = resolutionMiss || !(err instanceof ZiggsGatewayError);
|
|
611
|
+
if (attempt < MAX_ATTEMPTS && transient) {
|
|
612
|
+
console.warn(
|
|
613
|
+
`[McpAdapter] per-user ${name} attempt ${attempt}/${MAX_ATTEMPTS} failed (${(err as Error).message}); retrying with a fresh connection`,
|
|
614
|
+
);
|
|
615
|
+
await new Promise((r) => setTimeout(r, 500 * attempt));
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
break;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
if (
|
|
622
|
+
lastErr instanceof ZiggsGatewayError &&
|
|
623
|
+
lastErr.code === 'NO_USER_CONNECTION' &&
|
|
624
|
+
transportConfig.serverUrl
|
|
625
|
+
) {
|
|
626
|
+
const requestId = await triggerConnectionRequest(userId).catch(() => null);
|
|
627
|
+
const link = `${appUrl}/app/settings/connections${requestId ? `?request=${encodeURIComponent(requestId)}` : ''}`;
|
|
628
|
+
return {
|
|
629
|
+
content: [
|
|
630
|
+
{
|
|
631
|
+
type: 'text',
|
|
632
|
+
text: `ACTION NEEDED: the user has not connected ${serverLabel}. Ask them to approve the connection here: ${link} — then retry.`,
|
|
633
|
+
},
|
|
634
|
+
],
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
throw lastErr;
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
const host = new AgentHost({
|
|
641
|
+
...hostOptions,
|
|
642
|
+
workflow: workflow ?? buildDefaultWorkflow(hostOptions.description ?? '', tools),
|
|
643
|
+
tools: [...mapMcpTools(caller, tools), ...extraTools],
|
|
644
|
+
});
|
|
645
|
+
return {
|
|
646
|
+
host,
|
|
647
|
+
closeMcp: async () => {
|
|
648
|
+
await Promise.all(
|
|
649
|
+
[...clients.values()].map(p => p.then(c => c.close()).catch(() => {})),
|
|
650
|
+
);
|
|
651
|
+
},
|
|
652
|
+
toolNames: tools.map(t => t.name),
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ── Single-client path (stdio / http / brokered ziggs): discover tools at boot.
|
|
318
657
|
const transport = buildTransport(transportConfig, { agentId: hostOptions.agentId });
|
|
319
658
|
const client = new Client({ name: 'ziggs-mcp-agent', version: '1.0.0' });
|
|
320
|
-
|
|
321
659
|
await client.connect(transport);
|
|
322
660
|
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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);
|
|
661
|
+
const rawTools = capTools((await client.listTools()).tools as McpTool[], hostOptions.agentId, maxTools, extraTools.length, allowedTools);
|
|
662
|
+
const caller: McpToolCaller = (name, args) =>
|
|
663
|
+
client.callTool({ name, arguments: args }) as Promise<{ isError?: boolean; content: McpContent[] }>;
|
|
664
|
+
const mcpTools = mapMcpTools(caller, rawTools);
|
|
334
665
|
|
|
335
666
|
const resolvedWorkflow = workflow ?? buildDefaultWorkflow(hostOptions.description ?? '', rawTools);
|
|
336
|
-
|
|
337
667
|
const host = new AgentHost({
|
|
338
668
|
...hostOptions,
|
|
339
669
|
workflow: resolvedWorkflow,
|
|
@@ -167,21 +167,28 @@ export class OpenAIAdapter {
|
|
|
167
167
|
async chatMessagesStream(
|
|
168
168
|
messages: OpenAI.Chat.ChatCompletionMessageParam[],
|
|
169
169
|
onChunk: (text: string) => void,
|
|
170
|
-
): Promise<string> {
|
|
170
|
+
): Promise<{ text: string; usage: OpenAI.CompletionUsage | undefined }> {
|
|
171
171
|
const stream = await this.client.chat.completions.create({
|
|
172
172
|
model: this.model,
|
|
173
173
|
messages,
|
|
174
174
|
stream: true,
|
|
175
|
+
// Without this, a streamed completion never returns a usage block — the
|
|
176
|
+
// streamed reply (often the largest output) would bill tokens that are
|
|
177
|
+
// counted nowhere (ZIG-813). The usage arrives on a final chunk whose
|
|
178
|
+
// `choices` is empty.
|
|
179
|
+
stream_options: { include_usage: true },
|
|
175
180
|
max_completion_tokens: maxCompletionTokensForModel(this.model),
|
|
176
181
|
});
|
|
177
182
|
let text = '';
|
|
183
|
+
let usage: OpenAI.CompletionUsage | undefined;
|
|
178
184
|
for await (const chunk of stream) {
|
|
185
|
+
if (chunk.usage) usage = chunk.usage;
|
|
179
186
|
const delta = chunk.choices[0]?.delta?.content;
|
|
180
187
|
if (delta) {
|
|
181
188
|
text += delta;
|
|
182
189
|
onChunk(delta);
|
|
183
190
|
}
|
|
184
191
|
}
|
|
185
|
-
return text;
|
|
192
|
+
return { text, usage };
|
|
186
193
|
}
|
|
187
194
|
}
|
package/src/agent/Agent.ts
CHANGED
|
@@ -42,8 +42,16 @@ export interface TickOutput {
|
|
|
42
42
|
parked: boolean;
|
|
43
43
|
/** True when a state-timeout outcome matched no transition this tick — the host must NOT re-arm. */
|
|
44
44
|
stateTimeoutUnhandled: boolean;
|
|
45
|
-
/**
|
|
46
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Accumulated LLM call count and token usage for this tick.
|
|
47
|
+
* `cacheRead` / `cacheCreation` are populated by the Claude brain (its usage
|
|
48
|
+
* exposes cache traffic, which is the bulk of real cost — ZIG-759/813); the
|
|
49
|
+
* FSM path leaves them undefined. `total` includes cache tokens when present.
|
|
50
|
+
*/
|
|
51
|
+
metrics: {
|
|
52
|
+
llmCalls: number;
|
|
53
|
+
tokens: { total: number; input: number; output: number; cacheRead?: number; cacheCreation?: number };
|
|
54
|
+
};
|
|
47
55
|
}
|
|
48
56
|
|
|
49
57
|
/**
|