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.
Files changed (105) hide show
  1. package/README.md +486 -54
  2. package/package.json +8 -2
  3. package/src/__fixtures__/index.ts +100 -0
  4. package/src/cli.ts +809 -90
  5. package/src/db/memory-db.ts +35 -1
  6. package/src/db/workflow-db.test.ts +24 -0
  7. package/src/db/workflow-db.ts +469 -14
  8. package/src/expression/evaluator.ts +68 -4
  9. package/src/parser/agent-parser.ts +6 -3
  10. package/src/parser/config-schema.ts +38 -2
  11. package/src/parser/schema.ts +192 -7
  12. package/src/parser/test-schema.ts +29 -0
  13. package/src/parser/workflow-parser.test.ts +54 -0
  14. package/src/parser/workflow-parser.ts +153 -7
  15. package/src/runner/aggregate-error.test.ts +57 -0
  16. package/src/runner/aggregate-error.ts +46 -0
  17. package/src/runner/audit-verification.test.ts +2 -2
  18. package/src/runner/auto-heal.test.ts +1 -1
  19. package/src/runner/blueprint-executor.test.ts +63 -0
  20. package/src/runner/blueprint-executor.ts +157 -0
  21. package/src/runner/concurrency-limit.test.ts +82 -0
  22. package/src/runner/debug-repl.ts +18 -3
  23. package/src/runner/durable-timers.test.ts +200 -0
  24. package/src/runner/engine-executor.test.ts +464 -0
  25. package/src/runner/engine-executor.ts +489 -0
  26. package/src/runner/foreach-executor.ts +30 -12
  27. package/src/runner/llm-adapter.test.ts +282 -5
  28. package/src/runner/llm-adapter.ts +581 -8
  29. package/src/runner/llm-clarification.test.ts +79 -21
  30. package/src/runner/llm-errors.ts +83 -0
  31. package/src/runner/llm-executor.test.ts +258 -219
  32. package/src/runner/llm-executor.ts +226 -29
  33. package/src/runner/mcp-client.ts +70 -3
  34. package/src/runner/mcp-manager.test.ts +52 -52
  35. package/src/runner/mcp-manager.ts +12 -5
  36. package/src/runner/mcp-server.test.ts +117 -78
  37. package/src/runner/mcp-server.ts +13 -4
  38. package/src/runner/optimization-runner.ts +48 -31
  39. package/src/runner/reflexion.test.ts +1 -1
  40. package/src/runner/resource-pool.test.ts +113 -0
  41. package/src/runner/resource-pool.ts +164 -0
  42. package/src/runner/shell-executor.ts +130 -32
  43. package/src/runner/standard-tools-integration.test.ts +36 -36
  44. package/src/runner/standard-tools.test.ts +18 -0
  45. package/src/runner/standard-tools.ts +110 -37
  46. package/src/runner/step-executor.test.ts +176 -16
  47. package/src/runner/step-executor.ts +530 -86
  48. package/src/runner/stream-utils.test.ts +14 -0
  49. package/src/runner/subflow-outputs.test.ts +103 -0
  50. package/src/runner/test-harness.ts +161 -0
  51. package/src/runner/tool-integration.test.ts +73 -79
  52. package/src/runner/workflow-runner.test.ts +492 -15
  53. package/src/runner/workflow-runner.ts +1438 -79
  54. package/src/runner/workflow-subflows.test.ts +255 -0
  55. package/src/templates/agents/keystone-architect.md +19 -14
  56. package/src/templates/agents/tester.md +21 -0
  57. package/src/templates/batch-processor.yaml +1 -1
  58. package/src/templates/child-rollback.yaml +11 -0
  59. package/src/templates/decompose-implement.yaml +53 -0
  60. package/src/templates/decompose-problem.yaml +159 -0
  61. package/src/templates/decompose-research.yaml +52 -0
  62. package/src/templates/decompose-review.yaml +51 -0
  63. package/src/templates/dev.yaml +134 -0
  64. package/src/templates/engine-example.yaml +33 -0
  65. package/src/templates/fan-out-fan-in.yaml +61 -0
  66. package/src/templates/loop-parallel.yaml +1 -1
  67. package/src/templates/memory-service.yaml +1 -1
  68. package/src/templates/parent-rollback.yaml +16 -0
  69. package/src/templates/robust-automation.yaml +1 -1
  70. package/src/templates/scaffold-feature.yaml +29 -27
  71. package/src/templates/scaffold-generate.yaml +41 -0
  72. package/src/templates/scaffold-plan.yaml +53 -0
  73. package/src/types/status.ts +3 -0
  74. package/src/ui/dashboard.tsx +4 -3
  75. package/src/utils/assets.macro.ts +36 -0
  76. package/src/utils/auth-manager.ts +585 -8
  77. package/src/utils/blueprint-utils.test.ts +49 -0
  78. package/src/utils/blueprint-utils.ts +80 -0
  79. package/src/utils/circuit-breaker.test.ts +177 -0
  80. package/src/utils/circuit-breaker.ts +160 -0
  81. package/src/utils/config-loader.test.ts +100 -13
  82. package/src/utils/config-loader.ts +44 -17
  83. package/src/utils/constants.ts +62 -0
  84. package/src/utils/error-renderer.test.ts +267 -0
  85. package/src/utils/error-renderer.ts +320 -0
  86. package/src/utils/json-parser.test.ts +4 -0
  87. package/src/utils/json-parser.ts +18 -1
  88. package/src/utils/mermaid.ts +4 -0
  89. package/src/utils/paths.test.ts +46 -0
  90. package/src/utils/paths.ts +70 -0
  91. package/src/utils/process-sandbox.test.ts +128 -0
  92. package/src/utils/process-sandbox.ts +293 -0
  93. package/src/utils/rate-limiter.test.ts +143 -0
  94. package/src/utils/rate-limiter.ts +221 -0
  95. package/src/utils/redactor.test.ts +23 -15
  96. package/src/utils/redactor.ts +65 -25
  97. package/src/utils/resource-loader.test.ts +54 -0
  98. package/src/utils/resource-loader.ts +158 -0
  99. package/src/utils/sandbox.test.ts +69 -4
  100. package/src/utils/sandbox.ts +69 -6
  101. package/src/utils/schema-validator.ts +65 -0
  102. package/src/utils/workflow-registry.test.ts +57 -0
  103. package/src/utils/workflow-registry.ts +45 -25
  104. /package/src/expression/{evaluator.audit.test.ts → evaluator-audit.test.ts} +0 -0
  105. /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.schema) {
45
- systemPrompt += `\n\nIMPORTANT: You must output valid JSON that matches the following schema:\n${JSON.stringify(step.schema, null, 2)}`;
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
- const messages: LLMMessage[] = [];
157
+ let messages: LLMMessage[] = [];
158
+ const maxToolOutputBytes = LIMITS.MAX_TOOL_OUTPUT_BYTES;
49
159
 
50
160
  // Resume from state if provided
51
- if (context.output && typeof context.output === 'object' && 'messages' in context.output) {
52
- messages.push(...(context.output.messages as LLMMessage[]));
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
- allTools.push({
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
- allTools.push({
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
- allTools.push({
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 MCP tools
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
- allTools.push({
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(messages, {
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.schema) {
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.schema) {
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.schema && typeof output === 'string') {
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: `Error: Invalid JSON in arguments: ${e instanceof Error ? e.message : String(e)}`,
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: `Error: Invalid JSON in arguments: ${e instanceof Error ? e.message : String(e)}`,
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: JSON.stringify(result),
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: `Error: ${error instanceof Error ? error.message : String(error)}`,
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: `Security Error: ${error instanceof Error ? error.message : String(error)}`,
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
- ? JSON.stringify(result.output)
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');
@@ -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 MCP server URL: ${url}`);
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: MCP remote URL must use HTTPS. Got: ${parsed.protocol}. Set allowInsecure option to true if you trust this server.`
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
- return this.request('initialize', {
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) {