keystone-cli 1.0.2 → 1.1.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.
Files changed (155) hide show
  1. package/README.md +288 -24
  2. package/package.json +8 -4
  3. package/src/cli.ts +538 -419
  4. package/src/commands/doc.ts +31 -0
  5. package/src/commands/event.ts +29 -0
  6. package/src/commands/graph.ts +37 -0
  7. package/src/commands/index.ts +14 -0
  8. package/src/commands/init.ts +185 -0
  9. package/src/commands/run.ts +124 -0
  10. package/src/commands/schema.ts +40 -0
  11. package/src/commands/utils.ts +78 -0
  12. package/src/commands/validate.ts +111 -0
  13. package/src/db/memory-db.ts +50 -2
  14. package/src/db/workflow-db.test.ts +314 -0
  15. package/src/db/workflow-db.ts +810 -210
  16. package/src/expression/evaluator-audit.test.ts +4 -2
  17. package/src/expression/evaluator.test.ts +14 -1
  18. package/src/expression/evaluator.ts +166 -19
  19. package/src/parser/config-schema.ts +18 -0
  20. package/src/parser/schema.ts +153 -22
  21. package/src/parser/test-schema.ts +6 -6
  22. package/src/parser/workflow-parser.test.ts +24 -0
  23. package/src/parser/workflow-parser.ts +65 -3
  24. package/src/runner/auto-heal.test.ts +5 -6
  25. package/src/runner/blueprint-executor.test.ts +2 -2
  26. package/src/runner/debug-repl.test.ts +5 -8
  27. package/src/runner/debug-repl.ts +59 -16
  28. package/src/runner/durable-timers.test.ts +11 -2
  29. package/src/runner/engine-executor.test.ts +1 -1
  30. package/src/runner/events.ts +57 -0
  31. package/src/runner/executors/artifact-executor.ts +166 -0
  32. package/src/runner/{blueprint-executor.ts → executors/blueprint-executor.ts} +15 -7
  33. package/src/runner/{engine-executor.ts → executors/engine-executor.ts} +55 -7
  34. package/src/runner/executors/file-executor.test.ts +48 -0
  35. package/src/runner/executors/file-executor.ts +324 -0
  36. package/src/runner/{foreach-executor.ts → executors/foreach-executor.ts} +168 -80
  37. package/src/runner/executors/human-executor.ts +144 -0
  38. package/src/runner/executors/join-executor.ts +75 -0
  39. package/src/runner/executors/llm-executor.ts +1266 -0
  40. package/src/runner/executors/memory-executor.ts +71 -0
  41. package/src/runner/executors/plan-executor.ts +104 -0
  42. package/src/runner/executors/request-executor.ts +265 -0
  43. package/src/runner/executors/script-executor.ts +43 -0
  44. package/src/runner/executors/shell-executor.ts +403 -0
  45. package/src/runner/executors/subworkflow-executor.ts +114 -0
  46. package/src/runner/executors/types.ts +69 -0
  47. package/src/runner/executors/wait-executor.ts +59 -0
  48. package/src/runner/join-scheduling.test.ts +197 -0
  49. package/src/runner/llm-adapter-runtime.test.ts +209 -0
  50. package/src/runner/llm-adapter.test.ts +419 -24
  51. package/src/runner/llm-adapter.ts +414 -17
  52. package/src/runner/llm-clarification.test.ts +2 -1
  53. package/src/runner/llm-executor.test.ts +532 -17
  54. package/src/runner/mcp-client-audit.test.ts +1 -2
  55. package/src/runner/mcp-client.ts +136 -46
  56. package/src/runner/mcp-manager.test.ts +4 -0
  57. package/src/runner/mcp-server.test.ts +58 -0
  58. package/src/runner/mcp-server.ts +26 -0
  59. package/src/runner/memoization.test.ts +190 -0
  60. package/src/runner/optimization-runner.ts +4 -9
  61. package/src/runner/quality-gate.test.ts +69 -0
  62. package/src/runner/reflexion.test.ts +6 -17
  63. package/src/runner/resource-pool.ts +102 -14
  64. package/src/runner/services/context-builder.ts +144 -0
  65. package/src/runner/services/secret-manager.ts +105 -0
  66. package/src/runner/services/workflow-validator.ts +131 -0
  67. package/src/runner/shell-executor.test.ts +28 -4
  68. package/src/runner/standard-tools-ast.test.ts +196 -0
  69. package/src/runner/standard-tools-execution.test.ts +27 -0
  70. package/src/runner/standard-tools-integration.test.ts +6 -10
  71. package/src/runner/standard-tools.ts +339 -102
  72. package/src/runner/step-executor.test.ts +216 -4
  73. package/src/runner/step-executor.ts +69 -941
  74. package/src/runner/stream-utils.ts +7 -3
  75. package/src/runner/test-harness.ts +20 -1
  76. package/src/runner/timeout.test.ts +10 -0
  77. package/src/runner/timeout.ts +11 -2
  78. package/src/runner/tool-integration.test.ts +1 -1
  79. package/src/runner/wait-step.test.ts +102 -0
  80. package/src/runner/workflow-runner.test.ts +208 -15
  81. package/src/runner/workflow-runner.ts +890 -818
  82. package/src/runner/workflow-scheduler.ts +75 -0
  83. package/src/runner/workflow-state.ts +269 -0
  84. package/src/runner/workflow-subflows.test.ts +13 -12
  85. package/src/scripts/generate-schemas.ts +16 -0
  86. package/src/templates/agents/explore.md +1 -0
  87. package/src/templates/agents/general.md +1 -0
  88. package/src/templates/agents/handoff-router.md +14 -0
  89. package/src/templates/agents/handoff-specialist.md +15 -0
  90. package/src/templates/agents/keystone-architect.md +13 -44
  91. package/src/templates/agents/my-agent.md +1 -0
  92. package/src/templates/agents/software-engineer.md +1 -0
  93. package/src/templates/agents/summarizer.md +1 -0
  94. package/src/templates/agents/test-agent.md +1 -0
  95. package/src/templates/agents/tester.md +1 -0
  96. package/src/templates/{basic-inputs.yaml → basics/basic-inputs.yaml} +2 -0
  97. package/src/templates/{basic-shell.yaml → basics/basic-shell.yaml} +2 -1
  98. package/src/templates/{full-feature-demo.yaml → basics/full-feature-demo.yaml} +2 -0
  99. package/src/templates/{stop-watch.yaml → basics/stop-watch.yaml} +1 -0
  100. package/src/templates/{child-rollback.yaml → control-flow/child-rollback.yaml} +1 -0
  101. package/src/templates/{cleanup-finally.yaml → control-flow/cleanup-finally.yaml} +1 -0
  102. package/src/templates/{fan-out-fan-in.yaml → control-flow/fan-out-fan-in.yaml} +3 -0
  103. package/src/templates/control-flow/idempotency-example.yaml +30 -0
  104. package/src/templates/{loop-parallel.yaml → control-flow/loop-parallel.yaml} +3 -0
  105. package/src/templates/{parent-rollback.yaml → control-flow/parent-rollback.yaml} +1 -0
  106. package/src/templates/{retry-policy.yaml → control-flow/retry-policy.yaml} +3 -0
  107. package/src/templates/features/artifact-example.yaml +39 -0
  108. package/src/templates/{engine-example.yaml → features/engine-example.yaml} +1 -0
  109. package/src/templates/{human-interaction.yaml → features/human-interaction.yaml} +1 -0
  110. package/src/templates/{llm-agent.yaml → features/llm-agent.yaml} +1 -0
  111. package/src/templates/{memory-service.yaml → features/memory-service.yaml} +2 -0
  112. package/src/templates/{robust-automation.yaml → features/robust-automation.yaml} +3 -0
  113. package/src/templates/features/script-example.yaml +27 -0
  114. package/src/templates/patterns/agent-handoff.yaml +53 -0
  115. package/src/templates/{approval-process.yaml → patterns/approval-process.yaml} +1 -0
  116. package/src/templates/{batch-processor.yaml → patterns/batch-processor.yaml} +2 -0
  117. package/src/templates/{composition-child.yaml → patterns/composition-child.yaml} +1 -0
  118. package/src/templates/{composition-parent.yaml → patterns/composition-parent.yaml} +1 -0
  119. package/src/templates/{data-pipeline.yaml → patterns/data-pipeline.yaml} +2 -0
  120. package/src/templates/{decompose-implement.yaml → scaffolding/decompose-implement.yaml} +1 -0
  121. package/src/templates/{decompose-problem.yaml → scaffolding/decompose-problem.yaml} +1 -0
  122. package/src/templates/{decompose-research.yaml → scaffolding/decompose-research.yaml} +1 -0
  123. package/src/templates/{decompose-review.yaml → scaffolding/decompose-review.yaml} +1 -0
  124. package/src/templates/{dev.yaml → scaffolding/dev.yaml} +1 -0
  125. package/src/templates/scaffolding/review-loop.yaml +97 -0
  126. package/src/templates/{scaffold-feature.yaml → scaffolding/scaffold-feature.yaml} +2 -0
  127. package/src/templates/{scaffold-generate.yaml → scaffolding/scaffold-generate.yaml} +1 -0
  128. package/src/templates/{scaffold-plan.yaml → scaffolding/scaffold-plan.yaml} +1 -0
  129. package/src/templates/testing/invalid.yaml +6 -0
  130. package/src/ui/dashboard.tsx +191 -33
  131. package/src/utils/auth-manager.test.ts +337 -0
  132. package/src/utils/auth-manager.ts +157 -61
  133. package/src/utils/blueprint-utils.ts +4 -6
  134. package/src/utils/config-loader.test.ts +2 -0
  135. package/src/utils/config-loader.ts +12 -3
  136. package/src/utils/constants.ts +76 -0
  137. package/src/utils/container.ts +63 -0
  138. package/src/utils/context-injector.test.ts +200 -0
  139. package/src/utils/context-injector.ts +244 -0
  140. package/src/utils/doc-generator.ts +85 -0
  141. package/src/utils/env-filter.ts +45 -0
  142. package/src/utils/json-parser.test.ts +12 -0
  143. package/src/utils/json-parser.ts +30 -5
  144. package/src/utils/logger.ts +12 -1
  145. package/src/utils/mermaid.ts +4 -0
  146. package/src/utils/paths.ts +52 -1
  147. package/src/utils/process-sandbox-worker.test.ts +46 -0
  148. package/src/utils/process-sandbox.ts +227 -14
  149. package/src/utils/redactor.test.ts +11 -6
  150. package/src/utils/redactor.ts +25 -9
  151. package/src/utils/sandbox.ts +3 -0
  152. package/src/utils/workflow-registry.test.ts +2 -2
  153. package/src/runner/llm-executor.ts +0 -638
  154. package/src/runner/shell-executor.ts +0 -366
  155. package/src/templates/invalid.yaml +0 -5
@@ -1,638 +0,0 @@
1
- import { join } from 'node:path';
2
- import type { ExpressionContext } from '../expression/evaluator';
3
- import { ExpressionEvaluator } from '../expression/evaluator';
4
- import { parseAgent, resolveAgentPath } from '../parser/agent-parser';
5
- import type { AgentTool, LlmStep, Step } from '../parser/schema';
6
- import { LIMITS } from '../utils/constants';
7
- import { extractJson } from '../utils/json-parser';
8
- import { ConsoleLogger, type Logger } from '../utils/logger.ts';
9
- import { RedactionBuffer, Redactor } from '../utils/redactor';
10
- import { type LLMMessage, getAdapter } from './llm-adapter';
11
- import { MCPClient } from './mcp-client';
12
- import type { MCPManager, MCPServerConfig } from './mcp-manager';
13
- import { STANDARD_TOOLS, validateStandardToolSecurity } from './standard-tools';
14
- import type { StepResult } from './step-executor';
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
-
122
- interface ToolDefinition {
123
- name: string;
124
- description?: string;
125
- parameters: unknown;
126
- source: 'agent' | 'step' | 'mcp' | 'standard' | 'handoff';
127
- execution?: Step;
128
- mcpClient?: MCPClient;
129
- }
130
-
131
- export async function executeLlmStep(
132
- step: LlmStep,
133
- context: ExpressionContext,
134
- executeStepFn: (step: Step, context: ExpressionContext) => Promise<StepResult>,
135
- logger: Logger = new ConsoleLogger(),
136
- mcpManager?: MCPManager,
137
- workflowDir?: string,
138
- abortSignal?: AbortSignal,
139
- getAdapterFn?: typeof getAdapter
140
- ): Promise<StepResult> {
141
- const agentPath = resolveAgentPath(step.agent, workflowDir);
142
- const agent = parseAgent(agentPath);
143
-
144
- const provider = step.provider || agent.provider;
145
- const model = step.model || agent.model || 'gpt-4o';
146
- const prompt = ExpressionEvaluator.evaluateString(step.prompt, context);
147
-
148
- const fullModelString = provider ? `${provider}:${model}` : model;
149
- const { adapter, resolvedModel } = (getAdapterFn || getAdapter)(fullModelString);
150
-
151
- // Inject schema instructions if present
152
- let systemPrompt = agent.systemPrompt;
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)}`;
155
- }
156
-
157
- let messages: LLMMessage[] = [];
158
- const maxToolOutputBytes = LIMITS.MAX_TOOL_OUTPUT_BYTES;
159
-
160
- // Resume from state if provided
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[]));
173
-
174
- // If we have an answer in inputs, add it as a tool result for the last tool call
175
- const stepInputs = context.inputs?.[step.id] as Record<string, unknown> | undefined;
176
- if (stepInputs && typeof stepInputs === 'object' && '__answer' in stepInputs) {
177
- const answer = stepInputs.__answer;
178
- const lastMessage = messages[messages.length - 1];
179
- const askCall = lastMessage?.tool_calls?.find((tc) => tc.function.name === 'ask');
180
- if (askCall) {
181
- messages.push({
182
- role: 'tool',
183
- tool_call_id: askCall.id,
184
- name: 'ask',
185
- content: truncateToolOutput(String(answer), maxToolOutputBytes),
186
- });
187
- }
188
- }
189
- } else {
190
- messages.push({ role: 'system', content: systemPrompt }, { role: 'user', content: prompt });
191
- }
192
-
193
- const localMcpClients: MCPClient[] = [];
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
- };
206
-
207
- try {
208
- // 1. Add agent tools
209
- for (const tool of agent.tools) {
210
- registerTool({
211
- name: tool.name,
212
- description: tool.description,
213
- parameters: tool.parameters || {
214
- type: 'object',
215
- properties: {},
216
- additionalProperties: true,
217
- },
218
- source: 'agent',
219
- execution: tool.execution,
220
- });
221
- }
222
-
223
- // 2. Add step tools
224
- if (step.tools) {
225
- for (const tool of step.tools) {
226
- registerTool({
227
- name: tool.name,
228
- description: tool.description,
229
- parameters: tool.parameters || {
230
- type: 'object',
231
- properties: {},
232
- additionalProperties: true,
233
- },
234
- source: 'step',
235
- execution: tool.execution,
236
- });
237
- }
238
- }
239
-
240
- // 3. Add Standard tools
241
- if (step.useStandardTools) {
242
- for (const tool of STANDARD_TOOLS) {
243
- registerTool({
244
- name: tool.name,
245
- description: tool.description,
246
- parameters: tool.parameters || {
247
- type: 'object',
248
- properties: {},
249
- additionalProperties: true,
250
- },
251
- source: 'standard',
252
- execution: tool.execution,
253
- });
254
- }
255
- }
256
-
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
290
- const mcpServersToConnect: (string | MCPServerConfig)[] = [...(step.mcpServers || [])];
291
- if (step.useGlobalMcp && mcpManager) {
292
- const globalServers = mcpManager.getGlobalServers();
293
- for (const globalServer of globalServers) {
294
- // Only add if not already explicitly listed
295
- const alreadyListed = mcpServersToConnect.some((s) => {
296
- const name = typeof s === 'string' ? s : s.name;
297
- return name === globalServer.name;
298
- });
299
- if (!alreadyListed) {
300
- mcpServersToConnect.push(globalServer);
301
- }
302
- }
303
- }
304
-
305
- if (mcpServersToConnect.length > 0) {
306
- await Promise.all(
307
- mcpServersToConnect.map(async (server) => {
308
- let client: MCPClient | undefined;
309
- const serverName = typeof server === 'string' ? server : server.name;
310
-
311
- try {
312
- if (mcpManager) {
313
- client = await mcpManager.getClient(server as string | MCPServerConfig, logger);
314
- } else {
315
- // Fallback if no manager (should not happen in normal workflow run)
316
- if (typeof server === 'string') {
317
- logger.error(
318
- ` ✗ Cannot reference global MCP server '${server}' without MCPManager`
319
- );
320
- return;
321
- }
322
- logger.log(` 🔌 Connecting to MCP server: ${server.name}`);
323
- client = await MCPClient.createLocal(
324
- (server as MCPServerConfig).command || 'node',
325
- (server as MCPServerConfig).args || [],
326
- (server as MCPServerConfig).env || {}
327
- );
328
- await client.initialize();
329
- localMcpClients.push(client);
330
- }
331
-
332
- if (client) {
333
- const mcpTools = await client.listTools();
334
- for (const tool of mcpTools) {
335
- registerTool({
336
- name: tool.name,
337
- description: tool.description,
338
- parameters: tool.inputSchema,
339
- source: 'mcp',
340
- mcpClient: client,
341
- });
342
- }
343
- }
344
- } catch (error) {
345
- logger.error(
346
- ` ✗ Failed to list tools from MCP server ${serverName}: ${error instanceof Error ? error.message : String(error)}`
347
- );
348
- if (!mcpManager && client) {
349
- client.stop();
350
- }
351
- }
352
- })
353
- );
354
- }
355
-
356
- const llmTools = allTools.map((t) => ({
357
- type: 'function' as const,
358
- function: {
359
- name: t.name,
360
- description: t.description,
361
- parameters: t.parameters as Record<string, unknown>,
362
- },
363
- }));
364
-
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
- }
371
- llmTools.push({
372
- type: 'function' as const,
373
- function: {
374
- name: 'ask',
375
- description:
376
- 'Ask the user a clarifying question if the initial request is ambiguous or missing information.',
377
- parameters: {
378
- type: 'object',
379
- properties: {
380
- question: {
381
- type: 'string',
382
- description: 'The question to ask the user',
383
- },
384
- },
385
- required: ['question'],
386
- } as Record<string, unknown>,
387
- },
388
- });
389
- }
390
-
391
- // ReAct Loop
392
- let iterations = 0;
393
- const maxIterations = step.maxIterations || 10;
394
- const totalUsage = {
395
- prompt_tokens: 0,
396
- completion_tokens: 0,
397
- total_tokens: 0,
398
- };
399
-
400
- // Create redactor once outside the loop for performance (regex compilation)
401
- const redactor = new Redactor(context.secrets || {}, {
402
- forcedSecrets: context.secretValues || [],
403
- });
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);
409
-
410
- while (iterations < maxIterations) {
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;
419
-
420
- const response = await adapter.chat(truncatedMessages, {
421
- model: resolvedModel,
422
- tools: llmTools.length > 0 ? llmTools : undefined,
423
- onStream: (chunk) => {
424
- if (!step.outputSchema) {
425
- process.stdout.write(redactionBuffer.process(chunk));
426
- }
427
- },
428
- signal: abortSignal,
429
- });
430
-
431
- if (!step.outputSchema) {
432
- process.stdout.write(redactionBuffer.flush());
433
- }
434
-
435
- if (response.usage) {
436
- totalUsage.prompt_tokens += response.usage.prompt_tokens;
437
- totalUsage.completion_tokens += response.usage.completion_tokens;
438
- totalUsage.total_tokens += response.usage.total_tokens;
439
- }
440
-
441
- const { message } = response;
442
- messages.push(message);
443
-
444
- if (!message.tool_calls || message.tool_calls.length === 0) {
445
- let output = message.content;
446
-
447
- // If schema is defined, attempt to parse JSON
448
- if (step.outputSchema && typeof output === 'string') {
449
- try {
450
- output = extractJson(output) as typeof output;
451
- } catch (e) {
452
- const errorMessage = `Failed to parse LLM output as JSON matching schema: ${e instanceof Error ? e.message : String(e)}`;
453
- logger.error(` ⚠️ ${errorMessage}. Retrying...`);
454
-
455
- messages.push({
456
- role: 'user',
457
- content: `Error: ${errorMessage}\n\nPlease correct your output to be valid JSON matching the schema.`,
458
- });
459
- continue;
460
- }
461
- }
462
-
463
- return {
464
- output,
465
- status: 'success',
466
- usage: totalUsage,
467
- };
468
- }
469
-
470
- // Execute tools
471
- for (const toolCall of message.tool_calls) {
472
- if (abortSignal?.aborted) {
473
- throw new Error('Step canceled');
474
- }
475
- const argsStr = toolCall.function.arguments;
476
- let displayArgs = '';
477
- try {
478
- const parsedArgs = JSON.parse(argsStr);
479
- const keys = Object.keys(parsedArgs);
480
- if (keys.length > 0) {
481
- const formatted = JSON.stringify(parsedArgs);
482
- displayArgs = formatted.length > 100 ? `${formatted.substring(0, 100)}...` : formatted;
483
- }
484
- } catch (e) {
485
- displayArgs = argsStr.length > 100 ? `${argsStr.substring(0, 100)}...` : argsStr;
486
- }
487
-
488
- logger.log(
489
- ` 🛠️ Tool Call: ${toolCall.function.name}${displayArgs ? ` ${displayArgs}` : ''}`
490
- );
491
- const toolInfo = allTools.find((t) => t.name === toolCall.function.name);
492
-
493
- if (!toolInfo) {
494
- if (toolCall.function.name === 'ask' && step.allowClarification) {
495
- let args: { question: string };
496
- try {
497
- args = JSON.parse(toolCall.function.arguments);
498
- } catch (e) {
499
- messages.push({
500
- role: 'tool',
501
- tool_call_id: toolCall.id,
502
- name: 'ask',
503
- content: formatToolContent(
504
- `Error: Invalid JSON in arguments: ${e instanceof Error ? e.message : String(e)}`
505
- ),
506
- });
507
- continue;
508
- }
509
-
510
- if (process.stdin.isTTY) {
511
- // In TTY, we can use a human step to get the answer immediately
512
- logger.log(`\n🤔 Question from ${agent.name}: ${args.question}`);
513
- const result = await executeStepFn(
514
- {
515
- id: `${step.id}-clarify`,
516
- type: 'human',
517
- message: args.question,
518
- inputType: 'text',
519
- } as Step,
520
- context
521
- );
522
-
523
- messages.push({
524
- role: 'tool',
525
- tool_call_id: toolCall.id,
526
- name: 'ask',
527
- content: formatToolContent(String(result.output)),
528
- });
529
- continue;
530
- }
531
- // In non-TTY, we suspend
532
- messages = truncateMessages(messages, maxHistory, maxConversationBytes);
533
- return {
534
- status: 'suspended',
535
- output: {
536
- messages,
537
- question: args.question,
538
- },
539
- usage: totalUsage,
540
- };
541
- }
542
-
543
- messages.push({
544
- role: 'tool',
545
- tool_call_id: toolCall.id,
546
- name: toolCall.function.name,
547
- content: formatToolContent(`Error: Tool ${toolCall.function.name} not found`),
548
- });
549
- continue;
550
- }
551
-
552
- let args: Record<string, unknown>;
553
- try {
554
- args = JSON.parse(toolCall.function.arguments);
555
- } catch (e) {
556
- messages.push({
557
- role: 'tool',
558
- tool_call_id: toolCall.id,
559
- name: toolCall.function.name,
560
- content: formatToolContent(
561
- `Error: Invalid JSON in arguments: ${e instanceof Error ? e.message : String(e)}`
562
- ),
563
- });
564
- continue;
565
- }
566
-
567
- if (toolInfo.source === 'mcp' && toolInfo.mcpClient) {
568
- try {
569
- const result = await toolInfo.mcpClient.callTool(toolInfo.name, args);
570
- messages.push({
571
- role: 'tool',
572
- tool_call_id: toolCall.id,
573
- name: toolCall.function.name,
574
- content: formatToolContent(safeJsonStringify(result)),
575
- });
576
- } catch (error) {
577
- messages.push({
578
- role: 'tool',
579
- tool_call_id: toolCall.id,
580
- name: toolCall.function.name,
581
- content: formatToolContent(
582
- `Error: ${error instanceof Error ? error.message : String(error)}`
583
- ),
584
- });
585
- }
586
- } else if (toolInfo.execution) {
587
- // Security validation for standard tools
588
- if (toolInfo.source === 'standard') {
589
- try {
590
- validateStandardToolSecurity(toolInfo.name, args, {
591
- allowOutsideCwd: step.allowOutsideCwd,
592
- allowInsecure: step.allowInsecure,
593
- });
594
- } catch (error) {
595
- messages.push({
596
- role: 'tool',
597
- tool_call_id: toolCall.id,
598
- name: toolCall.function.name,
599
- content: formatToolContent(
600
- `Security Error: ${error instanceof Error ? error.message : String(error)}`
601
- ),
602
- });
603
- continue;
604
- }
605
- }
606
-
607
- // Execute the tool as a step
608
- const toolContext: ExpressionContext = {
609
- ...context,
610
- args, // Use args to pass parameters to tool execution
611
- };
612
-
613
- const result = await executeStepFn(toolInfo.execution, toolContext);
614
-
615
- messages.push({
616
- role: 'tool',
617
- tool_call_id: toolCall.id,
618
- name: toolCall.function.name,
619
- content: formatToolContent(
620
- result.status === 'success'
621
- ? safeJsonStringify(result.output)
622
- : `Error: ${result.error}`
623
- ),
624
- });
625
- }
626
- }
627
-
628
- messages = truncateMessages(messages, maxHistory, maxConversationBytes);
629
- }
630
-
631
- throw new Error('Max ReAct iterations reached');
632
- } finally {
633
- // Cleanup LOCAL MCP clients only. Shared clients are managed by MCPManager.
634
- for (const client of localMcpClients) {
635
- client.stop();
636
- }
637
- }
638
- }