keystone-cli 0.8.0 → 1.0.1
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/README.md +486 -54
- package/package.json +8 -2
- package/src/__fixtures__/index.ts +100 -0
- package/src/cli.ts +809 -90
- package/src/db/memory-db.ts +35 -1
- package/src/db/workflow-db.test.ts +24 -0
- package/src/db/workflow-db.ts +469 -14
- package/src/expression/evaluator.ts +68 -4
- package/src/parser/agent-parser.ts +6 -3
- package/src/parser/config-schema.ts +38 -2
- package/src/parser/schema.ts +192 -7
- package/src/parser/test-schema.ts +29 -0
- package/src/parser/workflow-parser.test.ts +54 -0
- package/src/parser/workflow-parser.ts +153 -7
- package/src/runner/aggregate-error.test.ts +57 -0
- package/src/runner/aggregate-error.ts +46 -0
- package/src/runner/audit-verification.test.ts +2 -2
- package/src/runner/auto-heal.test.ts +1 -1
- package/src/runner/blueprint-executor.test.ts +63 -0
- package/src/runner/blueprint-executor.ts +157 -0
- package/src/runner/concurrency-limit.test.ts +82 -0
- package/src/runner/debug-repl.ts +18 -3
- package/src/runner/durable-timers.test.ts +200 -0
- package/src/runner/engine-executor.test.ts +464 -0
- package/src/runner/engine-executor.ts +489 -0
- package/src/runner/foreach-executor.ts +30 -12
- package/src/runner/llm-adapter.test.ts +282 -5
- package/src/runner/llm-adapter.ts +581 -8
- package/src/runner/llm-clarification.test.ts +79 -21
- package/src/runner/llm-errors.ts +83 -0
- package/src/runner/llm-executor.test.ts +258 -219
- package/src/runner/llm-executor.ts +226 -29
- package/src/runner/mcp-client.ts +70 -3
- package/src/runner/mcp-manager.test.ts +52 -52
- package/src/runner/mcp-manager.ts +12 -5
- package/src/runner/mcp-server.test.ts +117 -78
- package/src/runner/mcp-server.ts +13 -4
- package/src/runner/optimization-runner.ts +48 -31
- package/src/runner/reflexion.test.ts +1 -1
- package/src/runner/resource-pool.test.ts +113 -0
- package/src/runner/resource-pool.ts +164 -0
- package/src/runner/shell-executor.ts +130 -32
- package/src/runner/standard-tools-integration.test.ts +36 -36
- package/src/runner/standard-tools.test.ts +18 -0
- package/src/runner/standard-tools.ts +110 -37
- package/src/runner/step-executor.test.ts +176 -16
- package/src/runner/step-executor.ts +530 -86
- package/src/runner/stream-utils.test.ts +14 -0
- package/src/runner/subflow-outputs.test.ts +103 -0
- package/src/runner/test-harness.ts +161 -0
- package/src/runner/tool-integration.test.ts +73 -79
- package/src/runner/workflow-runner.test.ts +492 -15
- package/src/runner/workflow-runner.ts +1438 -79
- package/src/runner/workflow-subflows.test.ts +255 -0
- package/src/templates/agents/keystone-architect.md +19 -14
- package/src/templates/agents/tester.md +21 -0
- package/src/templates/batch-processor.yaml +1 -1
- package/src/templates/child-rollback.yaml +11 -0
- package/src/templates/decompose-implement.yaml +53 -0
- package/src/templates/decompose-problem.yaml +159 -0
- package/src/templates/decompose-research.yaml +52 -0
- package/src/templates/decompose-review.yaml +51 -0
- package/src/templates/dev.yaml +134 -0
- package/src/templates/engine-example.yaml +33 -0
- package/src/templates/fan-out-fan-in.yaml +61 -0
- package/src/templates/loop-parallel.yaml +1 -1
- package/src/templates/memory-service.yaml +1 -1
- package/src/templates/parent-rollback.yaml +16 -0
- package/src/templates/robust-automation.yaml +1 -1
- package/src/templates/scaffold-feature.yaml +29 -27
- package/src/templates/scaffold-generate.yaml +41 -0
- package/src/templates/scaffold-plan.yaml +53 -0
- package/src/types/status.ts +3 -0
- package/src/ui/dashboard.tsx +4 -3
- package/src/utils/assets.macro.ts +36 -0
- package/src/utils/auth-manager.ts +585 -8
- package/src/utils/blueprint-utils.test.ts +49 -0
- package/src/utils/blueprint-utils.ts +80 -0
- package/src/utils/circuit-breaker.test.ts +177 -0
- package/src/utils/circuit-breaker.ts +160 -0
- package/src/utils/config-loader.test.ts +100 -13
- package/src/utils/config-loader.ts +44 -17
- package/src/utils/constants.ts +62 -0
- package/src/utils/error-renderer.test.ts +267 -0
- package/src/utils/error-renderer.ts +320 -0
- package/src/utils/json-parser.test.ts +4 -0
- package/src/utils/json-parser.ts +18 -1
- package/src/utils/mermaid.ts +4 -0
- package/src/utils/paths.test.ts +46 -0
- package/src/utils/paths.ts +70 -0
- package/src/utils/process-sandbox.test.ts +128 -0
- package/src/utils/process-sandbox.ts +293 -0
- package/src/utils/rate-limiter.test.ts +143 -0
- package/src/utils/rate-limiter.ts +221 -0
- package/src/utils/redactor.test.ts +23 -15
- package/src/utils/redactor.ts +65 -25
- package/src/utils/resource-loader.test.ts +54 -0
- package/src/utils/resource-loader.ts +158 -0
- package/src/utils/sandbox.test.ts +69 -4
- package/src/utils/sandbox.ts +69 -6
- package/src/utils/schema-validator.ts +65 -0
- package/src/utils/workflow-registry.test.ts +57 -0
- package/src/utils/workflow-registry.ts +45 -25
- /package/src/expression/{evaluator.audit.test.ts → evaluator-audit.test.ts} +0 -0
- /package/src/runner/{mcp-client.audit.test.ts → mcp-client-audit.test.ts} +0 -0
|
@@ -3,6 +3,7 @@ import type { ExpressionContext } from '../expression/evaluator';
|
|
|
3
3
|
import { ExpressionEvaluator } from '../expression/evaluator';
|
|
4
4
|
import { parseAgent, resolveAgentPath } from '../parser/agent-parser';
|
|
5
5
|
import type { AgentTool, LlmStep, Step } from '../parser/schema';
|
|
6
|
+
import { LIMITS } from '../utils/constants';
|
|
6
7
|
import { extractJson } from '../utils/json-parser';
|
|
7
8
|
import { ConsoleLogger, type Logger } from '../utils/logger.ts';
|
|
8
9
|
import { RedactionBuffer, Redactor } from '../utils/redactor';
|
|
@@ -12,11 +13,117 @@ import type { MCPManager, MCPServerConfig } from './mcp-manager';
|
|
|
12
13
|
import { STANDARD_TOOLS, validateStandardToolSecurity } from './standard-tools';
|
|
13
14
|
import type { StepResult } from './step-executor';
|
|
14
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Truncate message history to prevent unbounded memory growth.
|
|
18
|
+
* Preserves system messages and keeps the most recent messages.
|
|
19
|
+
*/
|
|
20
|
+
function estimateMessageBytes(message: LLMMessage): number {
|
|
21
|
+
let size = 0;
|
|
22
|
+
if (typeof message.content === 'string') {
|
|
23
|
+
size += Buffer.byteLength(message.content, 'utf8');
|
|
24
|
+
}
|
|
25
|
+
if (message.tool_calls) {
|
|
26
|
+
size += Buffer.byteLength(JSON.stringify(message.tool_calls), 'utf8');
|
|
27
|
+
}
|
|
28
|
+
if (message.reasoning) {
|
|
29
|
+
size += Buffer.byteLength(JSON.stringify(message.reasoning), 'utf8');
|
|
30
|
+
}
|
|
31
|
+
if (message.name) {
|
|
32
|
+
size += Buffer.byteLength(message.name, 'utf8');
|
|
33
|
+
}
|
|
34
|
+
return size;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function truncateStringByBytes(value: string, maxBytes: number): string {
|
|
38
|
+
if (maxBytes <= 0) return '';
|
|
39
|
+
if (Buffer.byteLength(value, 'utf8') <= maxBytes) return value;
|
|
40
|
+
|
|
41
|
+
let low = 0;
|
|
42
|
+
let high = value.length;
|
|
43
|
+
while (low < high) {
|
|
44
|
+
const mid = Math.ceil((low + high) / 2);
|
|
45
|
+
const slice = value.slice(0, mid);
|
|
46
|
+
if (Buffer.byteLength(slice, 'utf8') <= maxBytes) {
|
|
47
|
+
low = mid;
|
|
48
|
+
} else {
|
|
49
|
+
high = mid - 1;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return value.slice(0, low);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function truncateToolOutput(content: string, maxBytes: number): string {
|
|
56
|
+
const contentBytes = Buffer.byteLength(content, 'utf8');
|
|
57
|
+
if (contentBytes <= maxBytes) return content;
|
|
58
|
+
|
|
59
|
+
const suffix = '... [truncated output]';
|
|
60
|
+
const suffixBytes = Buffer.byteLength(suffix, 'utf8');
|
|
61
|
+
const truncated = truncateStringByBytes(content, Math.max(0, maxBytes - suffixBytes));
|
|
62
|
+
return `${truncated}${suffix}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function safeJsonStringify(value: unknown): string {
|
|
66
|
+
try {
|
|
67
|
+
return JSON.stringify(value);
|
|
68
|
+
} catch {
|
|
69
|
+
const seen = new WeakSet<object>();
|
|
70
|
+
try {
|
|
71
|
+
return JSON.stringify(value, (_key, val) => {
|
|
72
|
+
if (typeof val === 'bigint') return val.toString();
|
|
73
|
+
if (typeof val === 'object' && val !== null) {
|
|
74
|
+
if (seen.has(val)) return '[Circular]';
|
|
75
|
+
seen.add(val);
|
|
76
|
+
}
|
|
77
|
+
return val;
|
|
78
|
+
});
|
|
79
|
+
} catch {
|
|
80
|
+
return String(value);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function truncateMessages(
|
|
86
|
+
messages: LLMMessage[],
|
|
87
|
+
maxHistory: number,
|
|
88
|
+
maxBytes: number
|
|
89
|
+
): LLMMessage[] {
|
|
90
|
+
if (messages.length === 0) return messages;
|
|
91
|
+
|
|
92
|
+
// Keep all system messages
|
|
93
|
+
const systemMessages = messages.filter((m) => m.role === 'system');
|
|
94
|
+
const nonSystem = messages.filter((m) => m.role !== 'system');
|
|
95
|
+
|
|
96
|
+
// Keep most recent non-system messages, accounting for system messages
|
|
97
|
+
const nonSystemLimit = Math.max(0, maxHistory - systemMessages.length);
|
|
98
|
+
let keep = nonSystem.slice(-nonSystemLimit);
|
|
99
|
+
|
|
100
|
+
// Enforce total byte budget with a most-recent tail
|
|
101
|
+
if (maxBytes > 0) {
|
|
102
|
+
const systemBytes = systemMessages.reduce((total, msg) => total + estimateMessageBytes(msg), 0);
|
|
103
|
+
let remaining = maxBytes - systemBytes;
|
|
104
|
+
if (remaining <= 0) {
|
|
105
|
+
return systemMessages;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const tail: LLMMessage[] = [];
|
|
109
|
+
for (let i = keep.length - 1; i >= 0; i--) {
|
|
110
|
+
const msg = keep[i];
|
|
111
|
+
const msgBytes = estimateMessageBytes(msg);
|
|
112
|
+
if (msgBytes > remaining) break;
|
|
113
|
+
tail.push(msg);
|
|
114
|
+
remaining -= msgBytes;
|
|
115
|
+
}
|
|
116
|
+
keep = tail.reverse();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return [...systemMessages, ...keep];
|
|
120
|
+
}
|
|
121
|
+
|
|
15
122
|
interface ToolDefinition {
|
|
16
123
|
name: string;
|
|
17
124
|
description?: string;
|
|
18
125
|
parameters: unknown;
|
|
19
|
-
source: 'agent' | 'step' | 'mcp' | 'standard';
|
|
126
|
+
source: 'agent' | 'step' | 'mcp' | 'standard' | 'handoff';
|
|
20
127
|
execution?: Step;
|
|
21
128
|
mcpClient?: MCPClient;
|
|
22
129
|
}
|
|
@@ -27,7 +134,9 @@ export async function executeLlmStep(
|
|
|
27
134
|
executeStepFn: (step: Step, context: ExpressionContext) => Promise<StepResult>,
|
|
28
135
|
logger: Logger = new ConsoleLogger(),
|
|
29
136
|
mcpManager?: MCPManager,
|
|
30
|
-
workflowDir?: string
|
|
137
|
+
workflowDir?: string,
|
|
138
|
+
abortSignal?: AbortSignal,
|
|
139
|
+
getAdapterFn?: typeof getAdapter
|
|
31
140
|
): Promise<StepResult> {
|
|
32
141
|
const agentPath = resolveAgentPath(step.agent, workflowDir);
|
|
33
142
|
const agent = parseAgent(agentPath);
|
|
@@ -37,19 +146,30 @@ export async function executeLlmStep(
|
|
|
37
146
|
const prompt = ExpressionEvaluator.evaluateString(step.prompt, context);
|
|
38
147
|
|
|
39
148
|
const fullModelString = provider ? `${provider}:${model}` : model;
|
|
40
|
-
const { adapter, resolvedModel } = getAdapter(fullModelString);
|
|
149
|
+
const { adapter, resolvedModel } = (getAdapterFn || getAdapter)(fullModelString);
|
|
41
150
|
|
|
42
151
|
// Inject schema instructions if present
|
|
43
152
|
let systemPrompt = agent.systemPrompt;
|
|
44
|
-
if (step.
|
|
45
|
-
systemPrompt += `\n\nIMPORTANT: You must output valid JSON that matches the following schema:\n${JSON.stringify(step.
|
|
153
|
+
if (step.outputSchema) {
|
|
154
|
+
systemPrompt += `\n\nIMPORTANT: You must output valid JSON that matches the following schema:\n${JSON.stringify(step.outputSchema, null, 2)}`;
|
|
46
155
|
}
|
|
47
156
|
|
|
48
|
-
|
|
157
|
+
let messages: LLMMessage[] = [];
|
|
158
|
+
const maxToolOutputBytes = LIMITS.MAX_TOOL_OUTPUT_BYTES;
|
|
49
159
|
|
|
50
160
|
// Resume from state if provided
|
|
51
|
-
|
|
52
|
-
|
|
161
|
+
const stepState =
|
|
162
|
+
context.steps && typeof context.steps === 'object'
|
|
163
|
+
? (context.steps as Record<string, { output?: unknown }>)[step.id]
|
|
164
|
+
: undefined;
|
|
165
|
+
const stepOutput = stepState?.output;
|
|
166
|
+
const resumeOutput =
|
|
167
|
+
stepOutput && typeof stepOutput === 'object' && 'messages' in stepOutput
|
|
168
|
+
? stepOutput
|
|
169
|
+
: context.output;
|
|
170
|
+
|
|
171
|
+
if (resumeOutput && typeof resumeOutput === 'object' && 'messages' in resumeOutput) {
|
|
172
|
+
messages.push(...(resumeOutput.messages as LLMMessage[]));
|
|
53
173
|
|
|
54
174
|
// If we have an answer in inputs, add it as a tool result for the last tool call
|
|
55
175
|
const stepInputs = context.inputs?.[step.id] as Record<string, unknown> | undefined;
|
|
@@ -62,7 +182,7 @@ export async function executeLlmStep(
|
|
|
62
182
|
role: 'tool',
|
|
63
183
|
tool_call_id: askCall.id,
|
|
64
184
|
name: 'ask',
|
|
65
|
-
content: String(answer),
|
|
185
|
+
content: truncateToolOutput(String(answer), maxToolOutputBytes),
|
|
66
186
|
});
|
|
67
187
|
}
|
|
68
188
|
}
|
|
@@ -72,11 +192,22 @@ export async function executeLlmStep(
|
|
|
72
192
|
|
|
73
193
|
const localMcpClients: MCPClient[] = [];
|
|
74
194
|
const allTools: ToolDefinition[] = [];
|
|
195
|
+
const toolRegistry = new Map<string, string>();
|
|
196
|
+
const registerTool = (tool: ToolDefinition) => {
|
|
197
|
+
const existing = toolRegistry.get(tool.name);
|
|
198
|
+
if (existing) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`Duplicate tool name "${tool.name}" from ${tool.source}; already defined by ${existing}. Rename one of them.`
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
toolRegistry.set(tool.name, tool.source);
|
|
204
|
+
allTools.push(tool);
|
|
205
|
+
};
|
|
75
206
|
|
|
76
207
|
try {
|
|
77
208
|
// 1. Add agent tools
|
|
78
209
|
for (const tool of agent.tools) {
|
|
79
|
-
|
|
210
|
+
registerTool({
|
|
80
211
|
name: tool.name,
|
|
81
212
|
description: tool.description,
|
|
82
213
|
parameters: tool.parameters || {
|
|
@@ -92,7 +223,7 @@ export async function executeLlmStep(
|
|
|
92
223
|
// 2. Add step tools
|
|
93
224
|
if (step.tools) {
|
|
94
225
|
for (const tool of step.tools) {
|
|
95
|
-
|
|
226
|
+
registerTool({
|
|
96
227
|
name: tool.name,
|
|
97
228
|
description: tool.description,
|
|
98
229
|
parameters: tool.parameters || {
|
|
@@ -109,7 +240,7 @@ export async function executeLlmStep(
|
|
|
109
240
|
// 3. Add Standard tools
|
|
110
241
|
if (step.useStandardTools) {
|
|
111
242
|
for (const tool of STANDARD_TOOLS) {
|
|
112
|
-
|
|
243
|
+
registerTool({
|
|
113
244
|
name: tool.name,
|
|
114
245
|
description: tool.description,
|
|
115
246
|
parameters: tool.parameters || {
|
|
@@ -123,7 +254,39 @@ export async function executeLlmStep(
|
|
|
123
254
|
}
|
|
124
255
|
}
|
|
125
256
|
|
|
126
|
-
// 4. Add
|
|
257
|
+
// 4. Add Engine handoff tool
|
|
258
|
+
if (step.handoff) {
|
|
259
|
+
const toolName = step.handoff.name || 'handoff';
|
|
260
|
+
const description =
|
|
261
|
+
step.handoff.description || `Delegate to engine ${step.handoff.engine.command}`;
|
|
262
|
+
const parameters = step.handoff.inputSchema || {
|
|
263
|
+
type: 'object',
|
|
264
|
+
properties: {},
|
|
265
|
+
additionalProperties: true,
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const handoffStep: Step = {
|
|
269
|
+
id: `${step.id}-handoff`,
|
|
270
|
+
type: 'engine',
|
|
271
|
+
command: step.handoff.engine.command,
|
|
272
|
+
args: step.handoff.engine.args,
|
|
273
|
+
env: step.handoff.engine.env,
|
|
274
|
+
cwd: step.handoff.engine.cwd,
|
|
275
|
+
timeout: step.handoff.engine.timeout,
|
|
276
|
+
outputSchema: step.handoff.engine.outputSchema,
|
|
277
|
+
input: step.handoff.engine.input ?? '${{ args }}',
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
registerTool({
|
|
281
|
+
name: toolName,
|
|
282
|
+
description,
|
|
283
|
+
parameters,
|
|
284
|
+
source: 'handoff',
|
|
285
|
+
execution: handoffStep,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// 5. Add MCP tools
|
|
127
290
|
const mcpServersToConnect: (string | MCPServerConfig)[] = [...(step.mcpServers || [])];
|
|
128
291
|
if (step.useGlobalMcp && mcpManager) {
|
|
129
292
|
const globalServers = mcpManager.getGlobalServers();
|
|
@@ -169,7 +332,7 @@ export async function executeLlmStep(
|
|
|
169
332
|
if (client) {
|
|
170
333
|
const mcpTools = await client.listTools();
|
|
171
334
|
for (const tool of mcpTools) {
|
|
172
|
-
|
|
335
|
+
registerTool({
|
|
173
336
|
name: tool.name,
|
|
174
337
|
description: tool.description,
|
|
175
338
|
parameters: tool.inputSchema,
|
|
@@ -200,6 +363,11 @@ export async function executeLlmStep(
|
|
|
200
363
|
}));
|
|
201
364
|
|
|
202
365
|
if (step.allowClarification) {
|
|
366
|
+
if (toolRegistry.has('ask')) {
|
|
367
|
+
throw new Error(
|
|
368
|
+
'Tool name "ask" is reserved for clarification. Rename your tool or disable allowClarification.'
|
|
369
|
+
);
|
|
370
|
+
}
|
|
203
371
|
llmTools.push({
|
|
204
372
|
type: 'function' as const,
|
|
205
373
|
function: {
|
|
@@ -230,23 +398,37 @@ export async function executeLlmStep(
|
|
|
230
398
|
};
|
|
231
399
|
|
|
232
400
|
// Create redactor once outside the loop for performance (regex compilation)
|
|
233
|
-
const redactor = new Redactor(context.secrets || {}
|
|
401
|
+
const redactor = new Redactor(context.secrets || {}, {
|
|
402
|
+
forcedSecrets: context.secretValues || [],
|
|
403
|
+
});
|
|
234
404
|
const redactionBuffer = new RedactionBuffer(redactor);
|
|
405
|
+
const maxHistory = step.maxMessageHistory || LIMITS.MAX_MESSAGE_HISTORY;
|
|
406
|
+
const maxConversationBytes = LIMITS.MAX_CONVERSATION_BYTES;
|
|
407
|
+
const formatToolContent = (content: string): string =>
|
|
408
|
+
truncateToolOutput(content, maxToolOutputBytes);
|
|
235
409
|
|
|
236
410
|
while (iterations < maxIterations) {
|
|
237
411
|
iterations++;
|
|
412
|
+
if (abortSignal?.aborted) {
|
|
413
|
+
throw new Error('Step canceled');
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Truncate message history to prevent unbounded growth
|
|
417
|
+
messages = truncateMessages(messages, maxHistory, maxConversationBytes);
|
|
418
|
+
const truncatedMessages = messages;
|
|
238
419
|
|
|
239
|
-
const response = await adapter.chat(
|
|
420
|
+
const response = await adapter.chat(truncatedMessages, {
|
|
240
421
|
model: resolvedModel,
|
|
241
422
|
tools: llmTools.length > 0 ? llmTools : undefined,
|
|
242
423
|
onStream: (chunk) => {
|
|
243
|
-
if (!step.
|
|
424
|
+
if (!step.outputSchema) {
|
|
244
425
|
process.stdout.write(redactionBuffer.process(chunk));
|
|
245
426
|
}
|
|
246
427
|
},
|
|
428
|
+
signal: abortSignal,
|
|
247
429
|
});
|
|
248
430
|
|
|
249
|
-
if (!step.
|
|
431
|
+
if (!step.outputSchema) {
|
|
250
432
|
process.stdout.write(redactionBuffer.flush());
|
|
251
433
|
}
|
|
252
434
|
|
|
@@ -263,7 +445,7 @@ export async function executeLlmStep(
|
|
|
263
445
|
let output = message.content;
|
|
264
446
|
|
|
265
447
|
// If schema is defined, attempt to parse JSON
|
|
266
|
-
if (step.
|
|
448
|
+
if (step.outputSchema && typeof output === 'string') {
|
|
267
449
|
try {
|
|
268
450
|
output = extractJson(output) as typeof output;
|
|
269
451
|
} catch (e) {
|
|
@@ -287,6 +469,9 @@ export async function executeLlmStep(
|
|
|
287
469
|
|
|
288
470
|
// Execute tools
|
|
289
471
|
for (const toolCall of message.tool_calls) {
|
|
472
|
+
if (abortSignal?.aborted) {
|
|
473
|
+
throw new Error('Step canceled');
|
|
474
|
+
}
|
|
290
475
|
const argsStr = toolCall.function.arguments;
|
|
291
476
|
let displayArgs = '';
|
|
292
477
|
try {
|
|
@@ -315,7 +500,9 @@ export async function executeLlmStep(
|
|
|
315
500
|
role: 'tool',
|
|
316
501
|
tool_call_id: toolCall.id,
|
|
317
502
|
name: 'ask',
|
|
318
|
-
content:
|
|
503
|
+
content: formatToolContent(
|
|
504
|
+
`Error: Invalid JSON in arguments: ${e instanceof Error ? e.message : String(e)}`
|
|
505
|
+
),
|
|
319
506
|
});
|
|
320
507
|
continue;
|
|
321
508
|
}
|
|
@@ -337,11 +524,12 @@ export async function executeLlmStep(
|
|
|
337
524
|
role: 'tool',
|
|
338
525
|
tool_call_id: toolCall.id,
|
|
339
526
|
name: 'ask',
|
|
340
|
-
content: String(result.output),
|
|
527
|
+
content: formatToolContent(String(result.output)),
|
|
341
528
|
});
|
|
342
529
|
continue;
|
|
343
530
|
}
|
|
344
531
|
// In non-TTY, we suspend
|
|
532
|
+
messages = truncateMessages(messages, maxHistory, maxConversationBytes);
|
|
345
533
|
return {
|
|
346
534
|
status: 'suspended',
|
|
347
535
|
output: {
|
|
@@ -356,7 +544,7 @@ export async function executeLlmStep(
|
|
|
356
544
|
role: 'tool',
|
|
357
545
|
tool_call_id: toolCall.id,
|
|
358
546
|
name: toolCall.function.name,
|
|
359
|
-
content: `Error: Tool ${toolCall.function.name} not found
|
|
547
|
+
content: formatToolContent(`Error: Tool ${toolCall.function.name} not found`),
|
|
360
548
|
});
|
|
361
549
|
continue;
|
|
362
550
|
}
|
|
@@ -369,7 +557,9 @@ export async function executeLlmStep(
|
|
|
369
557
|
role: 'tool',
|
|
370
558
|
tool_call_id: toolCall.id,
|
|
371
559
|
name: toolCall.function.name,
|
|
372
|
-
content:
|
|
560
|
+
content: formatToolContent(
|
|
561
|
+
`Error: Invalid JSON in arguments: ${e instanceof Error ? e.message : String(e)}`
|
|
562
|
+
),
|
|
373
563
|
});
|
|
374
564
|
continue;
|
|
375
565
|
}
|
|
@@ -381,14 +571,16 @@ export async function executeLlmStep(
|
|
|
381
571
|
role: 'tool',
|
|
382
572
|
tool_call_id: toolCall.id,
|
|
383
573
|
name: toolCall.function.name,
|
|
384
|
-
content:
|
|
574
|
+
content: formatToolContent(safeJsonStringify(result)),
|
|
385
575
|
});
|
|
386
576
|
} catch (error) {
|
|
387
577
|
messages.push({
|
|
388
578
|
role: 'tool',
|
|
389
579
|
tool_call_id: toolCall.id,
|
|
390
580
|
name: toolCall.function.name,
|
|
391
|
-
content:
|
|
581
|
+
content: formatToolContent(
|
|
582
|
+
`Error: ${error instanceof Error ? error.message : String(error)}`
|
|
583
|
+
),
|
|
392
584
|
});
|
|
393
585
|
}
|
|
394
586
|
} else if (toolInfo.execution) {
|
|
@@ -404,7 +596,9 @@ export async function executeLlmStep(
|
|
|
404
596
|
role: 'tool',
|
|
405
597
|
tool_call_id: toolCall.id,
|
|
406
598
|
name: toolCall.function.name,
|
|
407
|
-
content:
|
|
599
|
+
content: formatToolContent(
|
|
600
|
+
`Security Error: ${error instanceof Error ? error.message : String(error)}`
|
|
601
|
+
),
|
|
408
602
|
});
|
|
409
603
|
continue;
|
|
410
604
|
}
|
|
@@ -422,13 +616,16 @@ export async function executeLlmStep(
|
|
|
422
616
|
role: 'tool',
|
|
423
617
|
tool_call_id: toolCall.id,
|
|
424
618
|
name: toolCall.function.name,
|
|
425
|
-
content:
|
|
619
|
+
content: formatToolContent(
|
|
426
620
|
result.status === 'success'
|
|
427
|
-
?
|
|
428
|
-
: `Error: ${result.error}
|
|
621
|
+
? safeJsonStringify(result.output)
|
|
622
|
+
: `Error: ${result.error}`
|
|
623
|
+
),
|
|
429
624
|
});
|
|
430
625
|
}
|
|
431
626
|
}
|
|
627
|
+
|
|
628
|
+
messages = truncateMessages(messages, maxHistory, maxConversationBytes);
|
|
432
629
|
}
|
|
433
630
|
|
|
434
631
|
throw new Error('Max ReAct iterations reached');
|
package/src/runner/mcp-client.ts
CHANGED
|
@@ -80,7 +80,7 @@ export async function validateRemoteUrl(
|
|
|
80
80
|
try {
|
|
81
81
|
parsed = new URL(url);
|
|
82
82
|
} catch {
|
|
83
|
-
throw new Error(`Invalid
|
|
83
|
+
throw new Error(`Invalid URL: ${url}`);
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
// Skip all security checks if allowInsecure is set (for development/testing)
|
|
@@ -91,7 +91,7 @@ export async function validateRemoteUrl(
|
|
|
91
91
|
// Require HTTPS in production
|
|
92
92
|
if (parsed.protocol !== 'https:') {
|
|
93
93
|
throw new Error(
|
|
94
|
-
`SSRF Protection:
|
|
94
|
+
`SSRF Protection: URL must use HTTPS. Got: ${parsed.protocol}. Set allowInsecure option to true if you trust this server.`
|
|
95
95
|
);
|
|
96
96
|
}
|
|
97
97
|
|
|
@@ -587,6 +587,8 @@ export class MCPClient {
|
|
|
587
587
|
>();
|
|
588
588
|
private timeout: number;
|
|
589
589
|
private logger: Logger;
|
|
590
|
+
private _isHealthy = true;
|
|
591
|
+
private lastHealthCheck = Date.now();
|
|
590
592
|
|
|
591
593
|
constructor(
|
|
592
594
|
transportOrCommand: MCPTransport | string,
|
|
@@ -617,6 +619,21 @@ export class MCPClient {
|
|
|
617
619
|
});
|
|
618
620
|
}
|
|
619
621
|
|
|
622
|
+
/**
|
|
623
|
+
* Returns whether the client is considered healthy.
|
|
624
|
+
* Updated after each health check or failed request.
|
|
625
|
+
*/
|
|
626
|
+
get isHealthy(): boolean {
|
|
627
|
+
return this._isHealthy;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Returns the timestamp of the last health check.
|
|
632
|
+
*/
|
|
633
|
+
get lastHealthCheckTime(): number {
|
|
634
|
+
return this.lastHealthCheck;
|
|
635
|
+
}
|
|
636
|
+
|
|
620
637
|
static async createLocal(
|
|
621
638
|
command: string,
|
|
622
639
|
args: string[] = [],
|
|
@@ -660,6 +677,7 @@ export class MCPClient {
|
|
|
660
677
|
const timeoutId = setTimeout(() => {
|
|
661
678
|
if (this.pendingRequests.has(id)) {
|
|
662
679
|
this.pendingRequests.delete(id);
|
|
680
|
+
this._isHealthy = false;
|
|
663
681
|
reject(new Error(`MCP request timeout: ${method}`));
|
|
664
682
|
}
|
|
665
683
|
}, this.timeout);
|
|
@@ -669,13 +687,14 @@ export class MCPClient {
|
|
|
669
687
|
this.transport.send(message).catch((err) => {
|
|
670
688
|
clearTimeout(timeoutId);
|
|
671
689
|
this.pendingRequests.delete(id);
|
|
690
|
+
this._isHealthy = false;
|
|
672
691
|
reject(err);
|
|
673
692
|
});
|
|
674
693
|
});
|
|
675
694
|
}
|
|
676
695
|
|
|
677
696
|
async initialize() {
|
|
678
|
-
|
|
697
|
+
const response = await this.request('initialize', {
|
|
679
698
|
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
680
699
|
capabilities: {},
|
|
681
700
|
clientInfo: {
|
|
@@ -683,6 +702,8 @@ export class MCPClient {
|
|
|
683
702
|
version: pkg.version,
|
|
684
703
|
},
|
|
685
704
|
});
|
|
705
|
+
this._isHealthy = true;
|
|
706
|
+
return response;
|
|
686
707
|
}
|
|
687
708
|
|
|
688
709
|
async listTools(): Promise<MCPTool[]> {
|
|
@@ -701,6 +722,52 @@ export class MCPClient {
|
|
|
701
722
|
return response.result;
|
|
702
723
|
}
|
|
703
724
|
|
|
725
|
+
/**
|
|
726
|
+
* Perform a health check by sending a ping-like request.
|
|
727
|
+
* Uses a shorter timeout than normal requests.
|
|
728
|
+
*
|
|
729
|
+
* @param timeout Optional timeout in ms (default: 5000)
|
|
730
|
+
* @returns true if healthy, false otherwise
|
|
731
|
+
*/
|
|
732
|
+
async healthCheck(timeout = 5000): Promise<boolean> {
|
|
733
|
+
this.lastHealthCheck = Date.now();
|
|
734
|
+
const id = this.messageId++;
|
|
735
|
+
const message = {
|
|
736
|
+
jsonrpc: '2.0',
|
|
737
|
+
id,
|
|
738
|
+
method: 'ping', // Standard MCP ping or falls back gracefully
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
return new Promise((resolve) => {
|
|
742
|
+
const timeoutId = setTimeout(() => {
|
|
743
|
+
if (this.pendingRequests.has(id)) {
|
|
744
|
+
this.pendingRequests.delete(id);
|
|
745
|
+
this._isHealthy = false;
|
|
746
|
+
resolve(false);
|
|
747
|
+
}
|
|
748
|
+
}, timeout);
|
|
749
|
+
|
|
750
|
+
this.pendingRequests.set(id, {
|
|
751
|
+
resolve: () => {
|
|
752
|
+
this._isHealthy = true;
|
|
753
|
+
resolve(true);
|
|
754
|
+
},
|
|
755
|
+
reject: () => {
|
|
756
|
+
this._isHealthy = false;
|
|
757
|
+
resolve(false);
|
|
758
|
+
},
|
|
759
|
+
timeoutId,
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
this.transport.send(message).catch(() => {
|
|
763
|
+
clearTimeout(timeoutId);
|
|
764
|
+
this.pendingRequests.delete(id);
|
|
765
|
+
this._isHealthy = false;
|
|
766
|
+
resolve(false);
|
|
767
|
+
});
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
|
|
704
771
|
stop() {
|
|
705
772
|
// Reject all pending requests to prevent hanging callers
|
|
706
773
|
for (const [, pending] of this.pendingRequests) {
|