crewly 1.11.6 → 1.12.0
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/config/skills/agent/onboarding/synthesize-hierarchy/SKILL.md +65 -0
- package/config/skills/agent/onboarding/synthesize-hierarchy/execute.sh +61 -0
- package/config/skills/agent/web-search/SKILL.md +70 -0
- package/config/skills/agent/web-search/execute.sh +170 -0
- package/config/skills/agent/web-search/skill.json +23 -0
- package/dist/backend/backend/src/constants.d.ts +12 -0
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +12 -0
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/controllers/cloud/cloud.controller.d.ts +22 -0
- package/dist/backend/backend/src/controllers/cloud/cloud.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/cloud/cloud.controller.js +58 -0
- package/dist/backend/backend/src/controllers/cloud/cloud.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/cloud/cloud.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/cloud/cloud.routes.js +3 -1
- package/dist/backend/backend/src/controllers/cloud/cloud.routes.js.map +1 -1
- package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.controller.d.ts +27 -0
- package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.controller.js +108 -0
- package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.routes.d.ts +6 -2
- package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.routes.js +9 -3
- package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.routes.js.map +1 -1
- package/dist/backend/backend/src/index.d.ts.map +1 -1
- package/dist/backend/backend/src/index.js +36 -2
- package/dist/backend/backend/src/index.js.map +1 -1
- package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.d.ts +18 -0
- package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js +24 -2
- package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js.map +1 -1
- package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.d.ts +102 -0
- package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js +164 -0
- package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js.map +1 -0
- package/dist/backend/backend/src/services/fission/fission-guard.service.d.ts +21 -0
- package/dist/backend/backend/src/services/fission/fission-guard.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/fission/fission-guard.service.js +30 -0
- package/dist/backend/backend/src/services/fission/fission-guard.service.js.map +1 -1
- package/dist/backend/backend/src/services/intent-task/intent-classifier.rules.d.ts +4 -0
- package/dist/backend/backend/src/services/intent-task/intent-classifier.rules.d.ts.map +1 -1
- package/dist/backend/backend/src/services/intent-task/intent-classifier.rules.js +8 -0
- package/dist/backend/backend/src/services/intent-task/intent-classifier.rules.js.map +1 -1
- package/dist/backend/backend/src/services/orchestrator/onboarding/materialize-team.d.ts +79 -58
- package/dist/backend/backend/src/services/orchestrator/onboarding/materialize-team.d.ts.map +1 -1
- package/dist/backend/backend/src/services/orchestrator/onboarding/materialize-team.js +140 -65
- package/dist/backend/backend/src/services/orchestrator/onboarding/materialize-team.js.map +1 -1
- package/dist/backend/backend/src/services/orchestrator/onboarding/synthesize-hierarchy.d.ts +117 -0
- package/dist/backend/backend/src/services/orchestrator/onboarding/synthesize-hierarchy.d.ts.map +1 -0
- package/dist/backend/backend/src/services/orchestrator/onboarding/synthesize-hierarchy.js +189 -0
- package/dist/backend/backend/src/services/orchestrator/onboarding/synthesize-hierarchy.js.map +1 -0
- package/dist/backend/backend/src/services/orchestrator/onboarding-mode-loader.d.ts.map +1 -1
- package/dist/backend/backend/src/services/orchestrator/onboarding-mode-loader.js +1 -0
- package/dist/backend/backend/src/services/orchestrator/onboarding-mode-loader.js.map +1 -1
- package/dist/backend/backend/src/services/orchestrator/onboarding-mode.skill-allowlist.d.ts.map +1 -1
- package/dist/backend/backend/src/services/orchestrator/onboarding-mode.skill-allowlist.js +2 -0
- package/dist/backend/backend/src/services/orchestrator/onboarding-mode.skill-allowlist.js.map +1 -1
- package/dist/backend/backend/src/services/orchestrator/prompts/onboarding-mode.prompt.d.ts.map +1 -1
- package/dist/backend/backend/src/services/orchestrator/prompts/onboarding-mode.prompt.js +17 -1
- package/dist/backend/backend/src/services/orchestrator/prompts/onboarding-mode.prompt.js.map +1 -1
- package/dist/backend/backend/src/services/reconciler/reconcile-rules.d.ts +50 -0
- package/dist/backend/backend/src/services/reconciler/reconcile-rules.d.ts.map +1 -1
- package/dist/backend/backend/src/services/reconciler/reconcile-rules.js +71 -0
- package/dist/backend/backend/src/services/reconciler/reconcile-rules.js.map +1 -1
- package/dist/backend/backend/src/services/reconciler/reconciler.service.d.ts +18 -0
- package/dist/backend/backend/src/services/reconciler/reconciler.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/reconciler/reconciler.service.js +75 -1
- package/dist/backend/backend/src/services/reconciler/reconciler.service.js.map +1 -1
- package/dist/backend/backend/src/services/session/pty/pty-session-backend.d.ts +115 -0
- package/dist/backend/backend/src/services/session/pty/pty-session-backend.d.ts.map +1 -1
- package/dist/backend/backend/src/services/session/pty/pty-session-backend.js +189 -3
- package/dist/backend/backend/src/services/session/pty/pty-session-backend.js.map +1 -1
- package/dist/backend/backend/src/services/session/pty/pty-session.d.ts +28 -0
- package/dist/backend/backend/src/services/session/pty/pty-session.d.ts.map +1 -1
- package/dist/backend/backend/src/services/session/pty/pty-session.js +61 -1
- package/dist/backend/backend/src/services/session/pty/pty-session.js.map +1 -1
- package/dist/backend/backend/src/services/template/template.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/template/template.service.js +67 -2
- package/dist/backend/backend/src/services/template/template.service.js.map +1 -1
- package/dist/backend/backend/src/services/v3/cascade-request-status.d.ts +19 -1
- package/dist/backend/backend/src/services/v3/cascade-request-status.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/cascade-request-status.js +39 -2
- package/dist/backend/backend/src/services/v3/cascade-request-status.js.map +1 -1
- package/dist/backend/backend/src/services/v3/escalation-router.service.d.ts +41 -0
- package/dist/backend/backend/src/services/v3/escalation-router.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/escalation-router.service.js +169 -0
- package/dist/backend/backend/src/services/v3/escalation-router.service.js.map +1 -1
- package/dist/backend/backend/src/services/v3/request-cascade.subscriber.d.ts +4 -1
- package/dist/backend/backend/src/services/v3/request-cascade.subscriber.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/request-cascade.subscriber.js +21 -0
- package/dist/backend/backend/src/services/v3/request-cascade.subscriber.js.map +1 -1
- package/dist/backend/backend/src/types/intent-task.types.d.ts.map +1 -1
- package/dist/backend/backend/src/types/intent-task.types.js +8 -0
- package/dist/backend/backend/src/types/intent-task.types.js.map +1 -1
- package/dist/backend/backend/src/types/v2/request.types.d.ts +1 -1
- package/dist/backend/backend/src/types/v2/request.types.d.ts.map +1 -1
- package/dist/backend/backend/src/types/v2/request.types.js +1 -0
- package/dist/backend/backend/src/types/v2/request.types.js.map +1 -1
- package/dist/cli/backend/src/constants.d.ts +12 -0
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +12 -0
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/package.json +9 -3
- package/packages/crewly-agent/README.md +27 -0
- package/packages/crewly-agent/bin/crewly-agent +33 -0
- package/packages/crewly-agent/package.json +39 -0
- package/packages/crewly-agent/src/cli.ts +168 -0
- package/packages/crewly-agent/src/runtime/agent-runner.service.test.ts +2355 -0
- package/packages/crewly-agent/src/runtime/agent-runner.service.ts +1827 -0
- package/packages/crewly-agent/src/runtime/agent-stream.service.test.ts +153 -0
- package/packages/crewly-agent/src/runtime/agent-stream.service.ts +225 -0
- package/packages/crewly-agent/src/runtime/agent-worker.test.ts +171 -0
- package/packages/crewly-agent/src/runtime/agent-worker.ts +193 -0
- package/packages/crewly-agent/src/runtime/api-client.ts +143 -0
- package/packages/crewly-agent/src/runtime/approval-queue.service.ts +307 -0
- package/packages/crewly-agent/src/runtime/audit-log.service.test.ts +208 -0
- package/packages/crewly-agent/src/runtime/audit-log.service.ts +332 -0
- package/packages/crewly-agent/src/runtime/audit-trail.service.test.ts +178 -0
- package/packages/crewly-agent/src/runtime/audit-trail.service.ts +151 -0
- package/packages/crewly-agent/src/runtime/auditor-tools.test.ts +274 -0
- package/packages/crewly-agent/src/runtime/auditor-tools.ts +311 -0
- package/packages/crewly-agent/src/runtime/cloud-config.ts +67 -0
- package/packages/crewly-agent/src/runtime/deepseek-sse-transform.test.ts +165 -0
- package/packages/crewly-agent/src/runtime/deepseek-sse-transform.ts +168 -0
- package/packages/crewly-agent/src/runtime/env-isolation.service.ts +246 -0
- package/packages/crewly-agent/src/runtime/in-process-log-buffer.test.ts +280 -0
- package/packages/crewly-agent/src/runtime/in-process-log-buffer.ts +317 -0
- package/packages/crewly-agent/src/runtime/index.ts +38 -0
- package/packages/crewly-agent/src/runtime/mcp-tool-bridge.test.ts +352 -0
- package/packages/crewly-agent/src/runtime/mcp-tool-bridge.ts +244 -0
- package/packages/crewly-agent/src/runtime/model-manager.test.ts +326 -0
- package/packages/crewly-agent/src/runtime/model-manager.ts +363 -0
- package/packages/crewly-agent/src/runtime/output-filter.service.ts +175 -0
- package/packages/crewly-agent/src/runtime/prompt-guard.service.ts +303 -0
- package/packages/crewly-agent/src/runtime/rate-limiter.test.ts +228 -0
- package/packages/crewly-agent/src/runtime/rate-limiter.ts +353 -0
- package/packages/crewly-agent/src/runtime/tool-registry.test.ts +2510 -0
- package/packages/crewly-agent/src/runtime/tool-registry.ts +2104 -0
- package/packages/crewly-agent/src/runtime/types.test.ts +519 -0
- package/packages/crewly-agent/src/runtime/types.ts +637 -0
- package/packages/crewly-agent/src/runtime/web-search.tool.test.ts +131 -0
- package/packages/crewly-agent/src/runtime/web-search.tool.ts +140 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Tool Bridge
|
|
3
|
+
*
|
|
4
|
+
* Converts external MCP server tools into Crewly Agent ToolDefinitions
|
|
5
|
+
* so they can be used alongside built-in tools during agent execution.
|
|
6
|
+
* All MCP-sourced tools default to 'sensitive' classification for audit
|
|
7
|
+
* purposes unless explicitly overridden.
|
|
8
|
+
*
|
|
9
|
+
* @module services/agent/crewly-agent/mcp-tool-bridge
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
import type {
|
|
14
|
+
McpClientLike,
|
|
15
|
+
McpToolInfo,
|
|
16
|
+
McpServerConfig,
|
|
17
|
+
ToolDefinition,
|
|
18
|
+
ToolSensitivity,
|
|
19
|
+
} from './types.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Prefix applied to MCP tool names to avoid collisions with built-in tools.
|
|
23
|
+
*/
|
|
24
|
+
export const MCP_TOOL_PREFIX = 'mcp_' as const;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Default sensitivity for MCP-sourced tools.
|
|
28
|
+
* External tools are classified as 'sensitive' because they interact
|
|
29
|
+
* with systems outside the agent's direct control.
|
|
30
|
+
*/
|
|
31
|
+
export const MCP_DEFAULT_SENSITIVITY: ToolSensitivity = 'sensitive';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Configuration for MCP tool sensitivity overrides.
|
|
35
|
+
* Maps `serverName:toolName` or just `toolName` to a sensitivity level.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```typescript
|
|
39
|
+
* const overrides: McpSensitivityOverrides = {
|
|
40
|
+
* 'filesystem:read_file': 'safe',
|
|
41
|
+
* 'github:create_issue': 'sensitive',
|
|
42
|
+
* 'admin:drop_database': 'destructive',
|
|
43
|
+
* };
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
/**
|
|
47
|
+
* Convert a JSON Schema object from an MCP tool into a Zod schema.
|
|
48
|
+
*
|
|
49
|
+
* MCP tools declare their input using JSON Schema. The AI SDK expects
|
|
50
|
+
* Zod schemas. This function creates a z.object({}) passthrough schema
|
|
51
|
+
* that accepts any object — actual validation is done server-side by
|
|
52
|
+
* the MCP server itself.
|
|
53
|
+
*
|
|
54
|
+
* @param inputSchema - JSON Schema from the MCP tool definition
|
|
55
|
+
* @returns A Zod schema that passes through any object
|
|
56
|
+
*/
|
|
57
|
+
export function jsonSchemaToZodPassthrough(
|
|
58
|
+
inputSchema: Record<string, unknown>,
|
|
59
|
+
): z.ZodType {
|
|
60
|
+
// Extract property names from JSON Schema for documentation,
|
|
61
|
+
// but use a passthrough object since the MCP server validates inputs.
|
|
62
|
+
const properties = inputSchema.properties as Record<string, unknown> | undefined;
|
|
63
|
+
if (properties && typeof properties === 'object') {
|
|
64
|
+
const shape: Record<string, z.ZodType> = {};
|
|
65
|
+
for (const key of Object.keys(properties)) {
|
|
66
|
+
shape[key] = z.unknown().optional().describe(
|
|
67
|
+
String((properties[key] as Record<string, unknown>)?.description || key),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return z.object(shape).passthrough();
|
|
71
|
+
}
|
|
72
|
+
// Fallback: accept any object
|
|
73
|
+
return z.record(z.unknown());
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Build the namespaced tool name for an MCP tool.
|
|
78
|
+
*
|
|
79
|
+
* Format: `mcp_{serverName}_{toolName}` to prevent collisions
|
|
80
|
+
* with built-in Crewly tools and tools from other MCP servers.
|
|
81
|
+
*
|
|
82
|
+
* @param serverName - Name of the MCP server
|
|
83
|
+
* @param toolName - Original tool name from the MCP server
|
|
84
|
+
* @returns Namespaced tool name
|
|
85
|
+
*/
|
|
86
|
+
export function buildMcpToolName(serverName: string, toolName: string): string {
|
|
87
|
+
return `${MCP_TOOL_PREFIX}${serverName}_${toolName}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Resolve the sensitivity level for an MCP tool.
|
|
92
|
+
*
|
|
93
|
+
* Checks overrides in order of specificity:
|
|
94
|
+
* 1. `serverName:toolName` (most specific)
|
|
95
|
+
* 2. `toolName` (tool-level default)
|
|
96
|
+
* 3. Falls back to MCP_DEFAULT_SENSITIVITY ('sensitive')
|
|
97
|
+
*
|
|
98
|
+
* @param serverName - Name of the MCP server
|
|
99
|
+
* @param toolName - Original tool name
|
|
100
|
+
* @param overrides - Optional sensitivity overrides map
|
|
101
|
+
* @returns Resolved sensitivity level
|
|
102
|
+
*/
|
|
103
|
+
export function resolveSensitivity(
|
|
104
|
+
serverName: string,
|
|
105
|
+
toolName: string,
|
|
106
|
+
overrides?: McpSensitivityOverrides,
|
|
107
|
+
): ToolSensitivity {
|
|
108
|
+
if (!overrides) return MCP_DEFAULT_SENSITIVITY;
|
|
109
|
+
|
|
110
|
+
// Check server-specific override first
|
|
111
|
+
const serverSpecific = overrides[`${serverName}:${toolName}`];
|
|
112
|
+
if (serverSpecific) return serverSpecific;
|
|
113
|
+
|
|
114
|
+
// Check tool-level override
|
|
115
|
+
const toolLevel = overrides[toolName];
|
|
116
|
+
if (toolLevel) return toolLevel;
|
|
117
|
+
|
|
118
|
+
return MCP_DEFAULT_SENSITIVITY;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Convert a single MCP tool into a Crewly ToolDefinition.
|
|
123
|
+
*
|
|
124
|
+
* The resulting tool definition:
|
|
125
|
+
* - Has a namespaced name (`mcp_{server}_{tool}`)
|
|
126
|
+
* - Uses a passthrough Zod schema for input validation
|
|
127
|
+
* - Delegates execution to McpClientService.callTool()
|
|
128
|
+
* - Defaults to 'sensitive' classification for auditing
|
|
129
|
+
*
|
|
130
|
+
* @param mcpClient - The MCP client service for executing tool calls
|
|
131
|
+
* @param toolInfo - Tool metadata from the MCP server
|
|
132
|
+
* @param overrides - Optional sensitivity overrides
|
|
133
|
+
* @returns A ToolDefinition compatible with the Crewly Agent runtime
|
|
134
|
+
*/
|
|
135
|
+
export function convertMcpTool(
|
|
136
|
+
mcpClient: McpClientLike,
|
|
137
|
+
toolInfo: McpToolInfo,
|
|
138
|
+
overrides?: McpSensitivityOverrides,
|
|
139
|
+
): ToolDefinition {
|
|
140
|
+
const sensitivity = resolveSensitivity(
|
|
141
|
+
toolInfo.serverName,
|
|
142
|
+
toolInfo.name,
|
|
143
|
+
overrides,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
description: toolInfo.description
|
|
148
|
+
? `[MCP:${toolInfo.serverName}] ${toolInfo.description}`
|
|
149
|
+
: `[MCP:${toolInfo.serverName}] ${toolInfo.name}`,
|
|
150
|
+
inputSchema: jsonSchemaToZodPassthrough(toolInfo.inputSchema),
|
|
151
|
+
sensitivity,
|
|
152
|
+
execute: async (args: Record<string, unknown>): Promise<unknown> => {
|
|
153
|
+
try {
|
|
154
|
+
const result = await mcpClient.callTool(
|
|
155
|
+
toolInfo.serverName,
|
|
156
|
+
toolInfo.name,
|
|
157
|
+
args,
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// Flatten text content for simpler tool results
|
|
161
|
+
if (!result.isError && result.content.length === 1
|
|
162
|
+
&& result.content[0].type === 'text' && 'text' in result.content[0]) {
|
|
163
|
+
return { success: true, text: result.content[0].text };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
success: !result.isError,
|
|
168
|
+
content: result.content,
|
|
169
|
+
...(result.isError && { error: 'MCP tool returned an error' }),
|
|
170
|
+
};
|
|
171
|
+
} catch (error) {
|
|
172
|
+
return {
|
|
173
|
+
success: false,
|
|
174
|
+
error: `MCP tool call failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Load all tools from connected MCP servers and convert them to ToolDefinitions.
|
|
183
|
+
*
|
|
184
|
+
* This is the primary entry point for integrating MCP tools into the agent
|
|
185
|
+
* runtime. It queries all connected MCP servers, converts their tools to
|
|
186
|
+
* the Crewly ToolDefinition format, and returns a map ready to merge with
|
|
187
|
+
* the built-in tool registry.
|
|
188
|
+
*
|
|
189
|
+
* @param mcpClient - The MCP client service with active server connections
|
|
190
|
+
* @param overrides - Optional sensitivity overrides
|
|
191
|
+
* @returns Map of namespaced tool name -> ToolDefinition
|
|
192
|
+
*
|
|
193
|
+
* @example
|
|
194
|
+
* ```typescript
|
|
195
|
+
* const mcpClient = new McpClientService();
|
|
196
|
+
* await mcpClient.connectServer('filesystem', config);
|
|
197
|
+
* const mcpTools = loadMcpTools(mcpClient);
|
|
198
|
+
* // mcpTools = { mcp_filesystem_read_file: {...}, mcp_filesystem_write_file: {...} }
|
|
199
|
+
* ```
|
|
200
|
+
*/
|
|
201
|
+
export function loadMcpTools(
|
|
202
|
+
mcpClient: McpClientLike,
|
|
203
|
+
overrides?: McpSensitivityOverrides,
|
|
204
|
+
): Record<string, ToolDefinition> {
|
|
205
|
+
const tools: Record<string, ToolDefinition> = {};
|
|
206
|
+
const mcpToolInfos = mcpClient.listTools();
|
|
207
|
+
|
|
208
|
+
for (const toolInfo of mcpToolInfos) {
|
|
209
|
+
const toolName = buildMcpToolName(toolInfo.serverName, toolInfo.name);
|
|
210
|
+
tools[toolName] = convertMcpTool(mcpClient, toolInfo, overrides);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return tools;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Connect to MCP servers and load their tools in one step.
|
|
218
|
+
*
|
|
219
|
+
* Convenience function that handles the full lifecycle:
|
|
220
|
+
* 1. Connects to all configured MCP servers (tolerates failures)
|
|
221
|
+
* 2. Loads and converts all available tools
|
|
222
|
+
* 3. Returns tools ready to merge into the agent's tool registry
|
|
223
|
+
*
|
|
224
|
+
* @param mcpClient - The MCP client service instance
|
|
225
|
+
* @param serverConfigs - Map of server name -> server configuration
|
|
226
|
+
* @param overrides - Optional sensitivity overrides
|
|
227
|
+
* @returns Object with loaded tools and any connection errors
|
|
228
|
+
*
|
|
229
|
+
* @example
|
|
230
|
+
* ```typescript
|
|
231
|
+
* const { tools, errors } = await connectAndLoadMcpTools(mcpClient, {
|
|
232
|
+
* filesystem: { command: 'npx', args: ['-y', '@anthropic/mcp-filesystem'] },
|
|
233
|
+
* });
|
|
234
|
+
* ```
|
|
235
|
+
*/
|
|
236
|
+
export async function connectAndLoadMcpTools(
|
|
237
|
+
mcpClient: McpClientLike,
|
|
238
|
+
serverConfigs: Record<string, McpServerConfig>,
|
|
239
|
+
overrides?: McpSensitivityOverrides,
|
|
240
|
+
): Promise<{ tools: Record<string, ToolDefinition>; errors: Map<string, Error> }> {
|
|
241
|
+
const errors = await mcpClient.connectAll(serverConfigs);
|
|
242
|
+
const tools = loadMcpTools(mcpClient, overrides);
|
|
243
|
+
return { tools, errors };
|
|
244
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi, type Mocked, type MockInstance } from 'vitest';
|
|
2
|
+
import { ModelManager } from './model-manager.js';
|
|
3
|
+
|
|
4
|
+
// Mock provider SDKs so we don't make real API calls. vitest's vi.mock is
|
|
5
|
+
// hoisted to the top of the file, so it intercepts ModelManager's imports.
|
|
6
|
+
vi.mock('@ai-sdk/anthropic', () => ({
|
|
7
|
+
anthropic: vi.fn((modelId: string) => ({ provider: 'anthropic', modelId })),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock('@ai-sdk/openai', () => ({
|
|
11
|
+
openai: vi.fn((modelId: string) => ({ provider: 'openai', modelId })),
|
|
12
|
+
// DeepSeek wires through createOpenAI({ baseURL }) — return an object with
|
|
13
|
+
// a .chat factory that mints the same { provider, modelId } stub shape so
|
|
14
|
+
// tests can assert routing without standing up a real provider.
|
|
15
|
+
createOpenAI: vi.fn(() => {
|
|
16
|
+
const chatFactory = (modelId: string) => ({ provider: 'openai.chat', modelId });
|
|
17
|
+
return Object.assign(chatFactory, { chat: chatFactory });
|
|
18
|
+
}),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock('@ai-sdk/google', () => ({
|
|
22
|
+
google: vi.fn((modelId: string) => ({ provider: 'google', modelId })),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
vi.mock('ollama-ai-provider', () => ({
|
|
26
|
+
createOllama: vi.fn(() => {
|
|
27
|
+
const provider = vi.fn((modelId: string) => ({ provider: 'ollama', modelId }));
|
|
28
|
+
return provider;
|
|
29
|
+
}),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
// Note: the standalone ModelManager inlines its own getSettingsService that
|
|
33
|
+
// reads API keys from process.env directly (no settings file). Tests below
|
|
34
|
+
// just set/clear env vars to control key availability — no module mock needed.
|
|
35
|
+
|
|
36
|
+
describe('ModelManager', () => {
|
|
37
|
+
let manager: ModelManager;
|
|
38
|
+
const originalEnv = { ...process.env };
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
manager = new ModelManager();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
manager.clearCache();
|
|
46
|
+
process.env = { ...originalEnv };
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('getModel', () => {
|
|
50
|
+
it('should create an Anthropic model', async () => {
|
|
51
|
+
const model = await manager.getModel({ provider: 'anthropic', modelId: 'claude-sonnet-4-20250514' });
|
|
52
|
+
expect(model).toBeDefined();
|
|
53
|
+
expect((model as any).modelId).toBe('claude-sonnet-4-20250514');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should create an OpenAI model', async () => {
|
|
57
|
+
const model = await manager.getModel({ provider: 'openai', modelId: 'gpt-4o' });
|
|
58
|
+
expect(model).toBeDefined();
|
|
59
|
+
expect((model as any).modelId).toBe('gpt-4o');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should create a Google model', async () => {
|
|
63
|
+
const model = await manager.getModel({ provider: 'google', modelId: 'gemini-2.0-flash' });
|
|
64
|
+
expect(model).toBeDefined();
|
|
65
|
+
expect((model as any).modelId).toBe('gemini-2.0-flash');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should create an Ollama model', async () => {
|
|
69
|
+
const model = await manager.getModel({ provider: 'ollama', modelId: 'llama3.3:70b' });
|
|
70
|
+
expect(model).toBeDefined();
|
|
71
|
+
expect((model as any).modelId).toBe('llama3.3:70b');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should create a DeepSeek model via the OpenAI-compatible API', async () => {
|
|
75
|
+
process.env.DEEPSEEK_API_KEY = 'test-deepseek-key';
|
|
76
|
+
const model = await manager.getModel({ provider: 'deepseek', modelId: 'deepseek-chat' });
|
|
77
|
+
expect(model).toBeDefined();
|
|
78
|
+
// The DeepSeek model is built on top of @ai-sdk/openai's createOpenAI
|
|
79
|
+
// pointed at https://api.deepseek.com/v1; we only assert that the model
|
|
80
|
+
// instance is produced without throwing — baseURL routing is exercised
|
|
81
|
+
// implicitly via the createOpenAI factory, which is covered by upstream
|
|
82
|
+
// tests in @ai-sdk/openai itself.
|
|
83
|
+
expect((model as any).modelId).toBe('deepseek-chat');
|
|
84
|
+
// Regression guard: must route via the .chat() factory (chat-completions
|
|
85
|
+
// path), not the bare function-call form (which @ai-sdk/openai routes to
|
|
86
|
+
// /responses — unsupported by DeepSeek). See PR #400 review M1 / M2.
|
|
87
|
+
expect((model as any).provider).toBe('openai.chat');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should use default config when none provided', async () => {
|
|
91
|
+
const model = await manager.getModel();
|
|
92
|
+
expect(model).toBeDefined();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should throw for unknown provider', async () => {
|
|
96
|
+
await expect(
|
|
97
|
+
manager.getModel({ provider: 'azure' as any, modelId: 'test' })
|
|
98
|
+
).rejects.toThrow('Unknown model provider: azure');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should cache provider imports', async () => {
|
|
102
|
+
await manager.getModel({ provider: 'anthropic', modelId: 'model-1' });
|
|
103
|
+
await manager.getModel({ provider: 'anthropic', modelId: 'model-2' });
|
|
104
|
+
// Should only import once — the second call uses cached provider function
|
|
105
|
+
// We verify by checking the model is still created correctly
|
|
106
|
+
const model = await manager.getModel({ provider: 'anthropic', modelId: 'model-3' });
|
|
107
|
+
expect((model as any).modelId).toBe('model-3');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('getAvailableProviders', () => {
|
|
112
|
+
it('should report providers based on environment variables', async () => {
|
|
113
|
+
delete process.env.ANTHROPIC_API_KEY;
|
|
114
|
+
delete process.env.OPENAI_API_KEY;
|
|
115
|
+
delete process.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
|
116
|
+
delete process.env.GEMINI_API_KEY;
|
|
117
|
+
delete process.env.DEEPSEEK_API_KEY;
|
|
118
|
+
|
|
119
|
+
const available = await manager.getAvailableProviders();
|
|
120
|
+
|
|
121
|
+
expect(available.anthropic).toBe(false);
|
|
122
|
+
expect(available.openai).toBe(false);
|
|
123
|
+
expect(available.google).toBe(false);
|
|
124
|
+
expect(available.ollama).toBe(true); // Ollama is always available (local)
|
|
125
|
+
expect(available.deepseek).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should detect DeepSeek API key from env', async () => {
|
|
129
|
+
process.env.DEEPSEEK_API_KEY = 'test-deepseek-key';
|
|
130
|
+
const available = await manager.getAvailableProviders();
|
|
131
|
+
expect(available.deepseek).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should detect Anthropic API key', async () => {
|
|
135
|
+
process.env.ANTHROPIC_API_KEY = 'test-key';
|
|
136
|
+
const available = await manager.getAvailableProviders();
|
|
137
|
+
expect(available.anthropic).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should detect Google via GEMINI_API_KEY fallback', async () => {
|
|
141
|
+
delete process.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
|
142
|
+
process.env.GEMINI_API_KEY = 'test-key';
|
|
143
|
+
const available = await manager.getAvailableProviders();
|
|
144
|
+
expect(available.google).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('ensureApiKeyInEnv (settings override)', () => {
|
|
149
|
+
it('should override existing env var with settings key', async () => {
|
|
150
|
+
process.env.GOOGLE_GENERATIVE_AI_API_KEY = 'stale-free-key';
|
|
151
|
+
// getModel calls ensureApiKeyInEnv internally; the mock resolves from process.env
|
|
152
|
+
// But in production, settings.getApiKey returns the paid key which overwrites env
|
|
153
|
+
await manager.getModel({ provider: 'google', modelId: 'gemini-2.0-flash' });
|
|
154
|
+
// The key should now be whatever settings returned (in our mock: the env value itself,
|
|
155
|
+
// but the important thing is ensureApiKeyInEnv does NOT skip when env already set)
|
|
156
|
+
expect(process.env.GOOGLE_GENERATIVE_AI_API_KEY).toBeDefined();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('should set env var when settings returns a key and env is empty', async () => {
|
|
160
|
+
delete process.env.ANTHROPIC_API_KEY;
|
|
161
|
+
process.env.ANTHROPIC_API_KEY = 'paid-key-from-settings';
|
|
162
|
+
await manager.getModel({ provider: 'anthropic', modelId: 'claude-sonnet-4-20250514' });
|
|
163
|
+
expect(process.env.ANTHROPIC_API_KEY).toBe('paid-key-from-settings');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* B1: deepseek now flows through the settings service like every other
|
|
168
|
+
* cloud provider. This test pins down the new wiring — getModel for
|
|
169
|
+
* deepseek must trigger ensureApiKeyInEnv, which calls
|
|
170
|
+
* settingsService.getApiKey('deepseek', ...) and writes the result back
|
|
171
|
+
* to process.env.DEEPSEEK_API_KEY for the @ai-sdk/openai factory.
|
|
172
|
+
*
|
|
173
|
+
* Pre-B1, model-manager.ts short-circuited `if (provider === 'deepseek') return;`
|
|
174
|
+
* inside ensureApiKeyInEnv, meaning deepseek-via-settings was a dead path.
|
|
175
|
+
*/
|
|
176
|
+
it('should resolve deepseek key via settings service and write to DEEPSEEK_API_KEY (B1)', async () => {
|
|
177
|
+
// Mock resolves from the env var, simulating either a settings entry or env fallback.
|
|
178
|
+
// Either way, the wired flow must end in process.env.DEEPSEEK_API_KEY being set.
|
|
179
|
+
process.env.DEEPSEEK_API_KEY = 'paid-deepseek-key';
|
|
180
|
+
await manager.getModel({ provider: 'deepseek', modelId: 'deepseek-chat' });
|
|
181
|
+
expect(process.env.DEEPSEEK_API_KEY).toBe('paid-deepseek-key');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should not throw when no deepseek key is configured (B1)', async () => {
|
|
185
|
+
delete process.env.DEEPSEEK_API_KEY;
|
|
186
|
+
// ensureApiKeyInEnv should silently no-op when settings returns undefined,
|
|
187
|
+
// letting the @ai-sdk/openai factory raise its own clear error if the
|
|
188
|
+
// model is actually invoked.
|
|
189
|
+
await expect(
|
|
190
|
+
manager.getModel({ provider: 'deepseek', modelId: 'deepseek-reasoner' })
|
|
191
|
+
).resolves.toBeDefined();
|
|
192
|
+
expect(process.env.DEEPSEEK_API_KEY).toBeUndefined();
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('clearCache', () => {
|
|
197
|
+
it('should clear the provider cache', async () => {
|
|
198
|
+
await manager.getModel({ provider: 'anthropic', modelId: 'test' });
|
|
199
|
+
manager.clearCache();
|
|
200
|
+
// After clear, the next call should re-import
|
|
201
|
+
const model = await manager.getModel({ provider: 'anthropic', modelId: 'test-2' });
|
|
202
|
+
expect((model as any).modelId).toBe('test-2');
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* I2 — DeepSeek-R1 reasoning_content extraction via custom fetch wrapper.
|
|
208
|
+
*
|
|
209
|
+
* The wrapper is installed when getModel('deepseek') is called. We exercise
|
|
210
|
+
* it by stubbing globalThis.fetch, calling the wrapper directly through
|
|
211
|
+
* an internal accessor, and asserting reasoning is buffered for consume.
|
|
212
|
+
*
|
|
213
|
+
* Note: we don't go through the real @ai-sdk/openai SDK here — that would
|
|
214
|
+
* require simulating the entire chat-completions request lifecycle. Instead
|
|
215
|
+
* we test the seam where reasoning extraction happens (the custom fetch),
|
|
216
|
+
* which is the unit boundary we own. Integration with @ai-sdk is exercised
|
|
217
|
+
* by the Round 3 smoke test (live DeepSeek call).
|
|
218
|
+
*/
|
|
219
|
+
describe('DeepSeek custom fetch (I2 reasoning_content)', () => {
|
|
220
|
+
let originalFetch: typeof globalThis.fetch;
|
|
221
|
+
|
|
222
|
+
beforeEach(() => {
|
|
223
|
+
originalFetch = globalThis.fetch;
|
|
224
|
+
process.env.DEEPSEEK_API_KEY = 'test-deepseek-key';
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
afterEach(() => {
|
|
228
|
+
globalThis.fetch = originalFetch;
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('extracts reasoning_content from a streaming SSE response', async () => {
|
|
232
|
+
// Stub fetch to return a fake DeepSeek SSE response.
|
|
233
|
+
const sseBody = [
|
|
234
|
+
'data: {"choices":[{"delta":{"reasoning_content":"chain-of-thought "}}]}\n\n',
|
|
235
|
+
'data: {"choices":[{"delta":{"reasoning_content":"goes here"}}]}\n\n',
|
|
236
|
+
'data: {"choices":[{"delta":{"content":"the answer"}}]}\n\n',
|
|
237
|
+
'data: [DONE]\n\n',
|
|
238
|
+
].join('');
|
|
239
|
+
const encoder = new TextEncoder();
|
|
240
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
241
|
+
start(controller) {
|
|
242
|
+
controller.enqueue(encoder.encode(sseBody));
|
|
243
|
+
controller.close();
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
globalThis.fetch = vi.fn<any>().mockResolvedValue(
|
|
247
|
+
new Response(stream, {
|
|
248
|
+
status: 200,
|
|
249
|
+
headers: { 'content-type': 'text/event-stream' },
|
|
250
|
+
}),
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
// Trigger model creation (installs the custom fetch wrapper inside the provider).
|
|
254
|
+
await manager.getModel({ provider: 'deepseek', modelId: 'deepseek-reasoner' });
|
|
255
|
+
|
|
256
|
+
// Directly invoke the wrapper via the underlying provider invocation path.
|
|
257
|
+
// We can't easily reach `customFetch` without exporting it, so we instead
|
|
258
|
+
// call the known wrapper-creator method and exercise it.
|
|
259
|
+
const customFetch = (manager as any).makeDeepseekFetch();
|
|
260
|
+
const response: Response = await customFetch('https://api.deepseek.com/v1/chat/completions', {});
|
|
261
|
+
|
|
262
|
+
// Drain the consumer side (mimics what AI SDK does)
|
|
263
|
+
const reader = response.body!.getReader();
|
|
264
|
+
const decoder = new TextDecoder();
|
|
265
|
+
let drained = '';
|
|
266
|
+
while (true) {
|
|
267
|
+
const { done, value } = await reader.read();
|
|
268
|
+
if (done) break;
|
|
269
|
+
drained += decoder.decode(value, { stream: true });
|
|
270
|
+
}
|
|
271
|
+
expect(drained).toBe(sseBody); // passthrough must be byte-identical
|
|
272
|
+
|
|
273
|
+
const reasoning = await manager.consumeDeepseekReasoning();
|
|
274
|
+
expect(reasoning).toBe('chain-of-thought goes here');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('returns null from consumeDeepseekReasoning when no fetch happened', async () => {
|
|
278
|
+
const reasoning = await manager.consumeDeepseekReasoning();
|
|
279
|
+
expect(reasoning).toBeNull();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('passes through non-SSE responses unchanged', async () => {
|
|
283
|
+
// 4xx error with JSON body — wrapper must NOT touch it.
|
|
284
|
+
const errorBody = JSON.stringify({ error: 'bad request' });
|
|
285
|
+
globalThis.fetch = vi.fn<any>().mockResolvedValue(
|
|
286
|
+
new Response(errorBody, {
|
|
287
|
+
status: 400,
|
|
288
|
+
headers: { 'content-type': 'application/json' },
|
|
289
|
+
}),
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
const customFetch = (manager as any).makeDeepseekFetch();
|
|
293
|
+
const response: Response = await customFetch('https://api.deepseek.com/v1/chat/completions', {});
|
|
294
|
+
expect(response.status).toBe(400);
|
|
295
|
+
const text = await response.text();
|
|
296
|
+
expect(text).toBe(errorBody);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('consumes reasoning and resets buffer to null on second call', async () => {
|
|
300
|
+
const sseBody =
|
|
301
|
+
'data: {"choices":[{"delta":{"reasoning_content":"first"}}]}\n\ndata: [DONE]\n\n';
|
|
302
|
+
const encoder = new TextEncoder();
|
|
303
|
+
globalThis.fetch = vi.fn<any>().mockResolvedValue(
|
|
304
|
+
new Response(
|
|
305
|
+
new ReadableStream<Uint8Array>({
|
|
306
|
+
start(c) {
|
|
307
|
+
c.enqueue(encoder.encode(sseBody));
|
|
308
|
+
c.close();
|
|
309
|
+
},
|
|
310
|
+
}),
|
|
311
|
+
{ status: 200, headers: { 'content-type': 'text/event-stream' } },
|
|
312
|
+
),
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
const customFetch = (manager as any).makeDeepseekFetch();
|
|
316
|
+
const r1 = await customFetch('https://api.deepseek.com/v1/chat/completions', {});
|
|
317
|
+
// Drain to ensure the parser branch sees [DONE]
|
|
318
|
+
const reader = r1.body!.getReader();
|
|
319
|
+
while (!(await reader.read()).done) { /* drain */ }
|
|
320
|
+
|
|
321
|
+
expect(await manager.consumeDeepseekReasoning()).toBe('first');
|
|
322
|
+
// Second call: nothing new fetched, buffer was cleared
|
|
323
|
+
expect(await manager.consumeDeepseekReasoning()).toBeNull();
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
});
|