@miphamai/cli 0.9.0 → 0.11.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/dist/mipham +0 -0
- package/package.json +1 -1
- package/src/agent/agent-context.ts +60 -1
- package/src/agent/agent-registry.ts +1 -0
- package/src/agent/background-registry.ts +192 -0
- package/src/agent/message-bus.ts +113 -0
- package/src/agent/sub-agent.ts +101 -3
- package/src/agent/types.ts +6 -0
- package/src/agent-view/agent-view-manager.ts +10 -0
- package/src/agent-view/dashboard.tsx +20 -1
- package/src/core/engine.ts +90 -0
- package/src/core/hooks.ts +48 -0
- package/src/core/output-styles.ts +95 -0
- package/src/core/rules-loader.ts +175 -0
- package/src/mcp/client.ts +9 -0
- package/src/shared/types.ts +16 -1
- package/src/tools/agent/agent.ts +41 -2
- package/src/tools/agent/enter-plan.ts +92 -0
- package/src/tools/agent/exit-plan.ts +54 -0
- package/src/tools/agent/report-findings.ts +186 -0
- package/src/tools/agent/send-message.ts +67 -0
- package/src/tools/agent/workflow.ts +25 -5
- package/src/tools/exec/enter-worktree.ts +142 -0
- package/src/tools/exec/exit-worktree.ts +162 -0
- package/src/tools/exec/task.ts +215 -22
- package/src/tools/index.ts +14 -0
- package/src/tools/system/tool-search.ts +143 -0
- package/src/ui/app.tsx +49 -19
- package/src/ui/commands.ts +172 -55
- package/src/workflow/primitives/agent.ts +77 -15
- package/src/workflow/runtime.ts +84 -7
- package/src/workflow/schema-validator.ts +162 -0
package/src/ui/commands.ts
CHANGED
|
@@ -392,18 +392,16 @@ const skillsCmd: CommandHandler = (ctx) => {
|
|
|
392
392
|
// Workflow
|
|
393
393
|
// ═══════════════════════════════════════════════════════════════
|
|
394
394
|
|
|
395
|
-
const planCmd: CommandHandler = (_ctx) =>
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
`,
|
|
406
|
-
})
|
|
395
|
+
const planCmd: CommandHandler = (_ctx, args) => {
|
|
396
|
+
const description = args.join(' ') || undefined
|
|
397
|
+
return {
|
|
398
|
+
content:
|
|
399
|
+
'── Plan Mode ──\n\nEntering plan mode — read-only analysis and design.\nUse EnterPlanMode to start, ExitPlanMode to submit for approval.',
|
|
400
|
+
forwardToAI: description
|
|
401
|
+
? `Use EnterPlanMode with description: "${description}". Then explore, design, and use ExitPlanMode when ready for approval.`
|
|
402
|
+
: 'Use EnterPlanMode to enter plan mode. Explore the codebase, design an approach, then use ExitPlanMode to submit for approval.',
|
|
403
|
+
}
|
|
404
|
+
}
|
|
407
405
|
|
|
408
406
|
const tddCmd: CommandHandler = (_ctx, args) => {
|
|
409
407
|
const target = args.join(' ') || 'the current task'
|
|
@@ -3184,64 +3182,183 @@ Full changelog: https://mipham.ai/code/releases`,
|
|
|
3184
3182
|
// IDE — IDE integration guide
|
|
3185
3183
|
// ═══════════════════════════════════════════════════════════════
|
|
3186
3184
|
|
|
3187
|
-
const ideCmd: CommandHandler = () =>
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
VS Code:
|
|
3191
|
-
Install the Mipham Code extension from the VS Code marketplace.
|
|
3192
|
-
• Open Command Palette (Cmd+Shift+P)
|
|
3193
|
-
• Search "Mipham Code: Start"
|
|
3194
|
-
• The terminal panel opens with Mipham Code loaded
|
|
3185
|
+
const ideCmd: CommandHandler = async (_ctx) => {
|
|
3186
|
+
const { mkdirSync, writeFileSync, existsSync } = await import('node:fs')
|
|
3187
|
+
const { join } = await import('node:path')
|
|
3195
3188
|
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
"mipham": { "path": "bun", "args": ["run", "mipham"] }
|
|
3200
|
-
}
|
|
3201
|
-
}
|
|
3189
|
+
const cwd = process.cwd()
|
|
3190
|
+
const vscodeDir = join(cwd, '.vscode')
|
|
3191
|
+
mkdirSync(vscodeDir, { recursive: true })
|
|
3202
3192
|
|
|
3203
|
-
|
|
3204
|
-
• Settings → Tools → Terminal → Shell path
|
|
3205
|
-
• Set to: bun run ~/path/to/mipham-code/apps/cli/bin/mipham
|
|
3193
|
+
const files: string[] = []
|
|
3206
3194
|
|
|
3207
|
-
|
|
3208
|
-
|
|
3195
|
+
// ── Detect bun path ──
|
|
3196
|
+
let bunPath = '/opt/homebrew/bin/bun'
|
|
3197
|
+
try {
|
|
3198
|
+
const { execSync } = await import('node:child_process')
|
|
3199
|
+
const detected = execSync('which bun 2>/dev/null || echo /opt/homebrew/bin/bun', {
|
|
3200
|
+
encoding: 'utf-8',
|
|
3201
|
+
}).trim()
|
|
3202
|
+
if (detected) bunPath = detected
|
|
3203
|
+
} catch {
|
|
3204
|
+
// Use default
|
|
3205
|
+
}
|
|
3206
|
+
|
|
3207
|
+
// ── settings.json: terminal profile ──
|
|
3208
|
+
const settingsPath = join(vscodeDir, 'settings.json')
|
|
3209
|
+
const settings = {
|
|
3210
|
+
'terminal.integrated.profiles.osx': {
|
|
3211
|
+
mipham: {
|
|
3212
|
+
path: bunPath,
|
|
3213
|
+
args: ['run', 'mipham'],
|
|
3214
|
+
cwd: '${workspaceFolder}',
|
|
3215
|
+
},
|
|
3216
|
+
},
|
|
3217
|
+
}
|
|
3218
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8')
|
|
3219
|
+
files.push('.vscode/settings.json')
|
|
3220
|
+
|
|
3221
|
+
// ── keybindings.json: Cmd+Esc launch ──
|
|
3222
|
+
const keybindingsPath = join(vscodeDir, 'keybindings.json')
|
|
3223
|
+
const keybindings = [
|
|
3224
|
+
{
|
|
3225
|
+
key: 'cmd+escape',
|
|
3226
|
+
command: 'workbench.action.terminal.focus',
|
|
3227
|
+
when: 'terminalProcessSupported',
|
|
3228
|
+
},
|
|
3229
|
+
{
|
|
3230
|
+
key: 'cmd+shift+m',
|
|
3231
|
+
command: 'workbench.action.terminal.new',
|
|
3232
|
+
},
|
|
3233
|
+
]
|
|
3234
|
+
writeFileSync(keybindingsPath, JSON.stringify(keybindings, null, 2) + '\n', 'utf-8')
|
|
3235
|
+
files.push('.vscode/keybindings.json')
|
|
3209
3236
|
|
|
3210
|
-
|
|
3237
|
+
// ── extensions.json: recommended ──
|
|
3238
|
+
const extensionsPath = join(vscodeDir, 'extensions.json')
|
|
3239
|
+
const extensions = {
|
|
3240
|
+
recommendations: ['miphamai.mipham-code'],
|
|
3241
|
+
}
|
|
3242
|
+
writeFileSync(extensionsPath, JSON.stringify(extensions, null, 2) + '\n', 'utf-8')
|
|
3243
|
+
files.push('.vscode/extensions.json')
|
|
3211
3244
|
|
|
3212
|
-
|
|
3213
|
-
|
|
3245
|
+
return {
|
|
3246
|
+
content: [
|
|
3247
|
+
'── VS Code Integration ──',
|
|
3248
|
+
'',
|
|
3249
|
+
`Generated in ${vscodeDir}:`,
|
|
3250
|
+
...files.map((f) => ` ✅ ${f}`),
|
|
3251
|
+
'',
|
|
3252
|
+
'What was configured:',
|
|
3253
|
+
' • Terminal profile "mipham" — opens Mipham Code in integrated terminal',
|
|
3254
|
+
' • Keyboard shortcut Cmd+Esc — focus terminal',
|
|
3255
|
+
' • Cmd+Shift+M — new terminal',
|
|
3256
|
+
'',
|
|
3257
|
+
'To use:',
|
|
3258
|
+
' 1. Restart VS Code (or reload window: Cmd+Shift+P → Reload Window)',
|
|
3259
|
+
' 2. Open terminal: Ctrl+` or Cmd+Esc',
|
|
3260
|
+
' 3. Select "mipham" profile from the terminal dropdown',
|
|
3261
|
+
'',
|
|
3262
|
+
'Install the VS Code extension for full integration:',
|
|
3263
|
+
` code --install-extension miphamai.mipham-code`,
|
|
3264
|
+
'',
|
|
3265
|
+
'JetBrains: Settings → Tools → Terminal → Shell path → bun run mipham',
|
|
3266
|
+
].join('\n'),
|
|
3267
|
+
}
|
|
3268
|
+
}
|
|
3214
3269
|
|
|
3215
3270
|
// ═══════════════════════════════════════════════════════════════
|
|
3216
3271
|
// Terminal Setup — shell integration guide
|
|
3217
3272
|
// ═══════════════════════════════════════════════════════════════
|
|
3218
3273
|
|
|
3219
|
-
const terminalSetupCmd: CommandHandler = () =>
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
${NPM_INSTALL_COMMAND}
|
|
3224
|
-
mipham
|
|
3225
|
-
|
|
3226
|
-
One-liner install:
|
|
3227
|
-
curl -fsSL https://mipham.ai/install.sh | bash
|
|
3274
|
+
const terminalSetupCmd: CommandHandler = async () => {
|
|
3275
|
+
const { writeFileSync, appendFileSync, existsSync, mkdirSync } = await import('node:fs')
|
|
3276
|
+
const { join } = await import('node:path')
|
|
3277
|
+
const { homedir } = await import('node:os')
|
|
3228
3278
|
|
|
3229
|
-
|
|
3230
|
-
|
|
3279
|
+
const home = homedir()
|
|
3280
|
+
const lines: string[] = ['── Terminal Setup ──', '']
|
|
3281
|
+
|
|
3282
|
+
// ── 1. Generate standalone shell setup script ──
|
|
3283
|
+
const miphamDir = join(home, '.mipham')
|
|
3284
|
+
mkdirSync(miphamDir, { recursive: true })
|
|
3285
|
+
|
|
3286
|
+
const shellScript = join(miphamDir, 'shell-setup.sh')
|
|
3287
|
+
const shellContent = [
|
|
3288
|
+
'#!/bin/bash',
|
|
3289
|
+
'# Mipham Code — Shell Integration',
|
|
3290
|
+
'# Source this file in your shell profile:',
|
|
3291
|
+
'# source ~/.mipham/shell-setup.sh',
|
|
3292
|
+
'',
|
|
3293
|
+
`export MIPHAM_HOME="${home}/.mipham"`,
|
|
3294
|
+
'',
|
|
3295
|
+
'# Alias: launch Mipham Code in current directory',
|
|
3296
|
+
`alias mipham='cd $(pwd) && ${NPM_INSTALL_COMMAND} > /dev/null 2>&1; mipham'`,
|
|
3297
|
+
'',
|
|
3298
|
+
'# Or use the local development version:',
|
|
3299
|
+
'# alias mipham="bun run /path/to/mipham-code/apps/cli/bin/mipham"',
|
|
3300
|
+
'',
|
|
3301
|
+
'# Auto-detect provider from config',
|
|
3302
|
+
'if [ -f ~/.mipham/config.yml ]; then',
|
|
3303
|
+
' export MIPHAM_PROVIDER=$(grep "defaultProvider:" ~/.mipham/config.yml | awk "{print \$2}")',
|
|
3304
|
+
'fi',
|
|
3305
|
+
].join('\n')
|
|
3306
|
+
writeFileSync(shellScript, shellContent + '\n', 'utf-8')
|
|
3307
|
+
lines.push(` ✅ Generated: ${shellScript}`)
|
|
3308
|
+
|
|
3309
|
+
// ── 2. Append to shell profile ──
|
|
3310
|
+
const shell = process.env.SHELL || '/bin/zsh'
|
|
3311
|
+
const profileName = shell.includes('zsh') ? '.zshrc' : '.bashrc'
|
|
3312
|
+
const profilePath = join(home, profileName)
|
|
3313
|
+
const sourceLine = `\n# Mipham Code shell integration\n[ -f ~/.mipham/shell-setup.sh ] && source ~/.mipham/shell-setup.sh\n`
|
|
3231
3314
|
|
|
3232
|
-
|
|
3233
|
-
|
|
3315
|
+
try {
|
|
3316
|
+
const existing = existsSync(profilePath)
|
|
3317
|
+
? require('node:fs').readFileSync(profilePath, 'utf-8')
|
|
3318
|
+
: ''
|
|
3319
|
+
if (existing.includes('shell-setup.sh')) {
|
|
3320
|
+
lines.push(` ⏭ ${profileName} already has Mipham Code integration`)
|
|
3321
|
+
} else {
|
|
3322
|
+
appendFileSync(profilePath, sourceLine, 'utf-8')
|
|
3323
|
+
lines.push(` ✅ Added to ~/${profileName}`)
|
|
3324
|
+
}
|
|
3325
|
+
} catch {
|
|
3326
|
+
lines.push(` ⚠️ Could not update ~/${profileName}. Add manually:`)
|
|
3327
|
+
lines.push(` echo '${sourceLine.trim()}' >> ~/${profileName}`)
|
|
3328
|
+
}
|
|
3234
3329
|
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3330
|
+
// ── 3. Global install check ──
|
|
3331
|
+
lines.push('')
|
|
3332
|
+
lines.push('── Installation ──')
|
|
3333
|
+
try {
|
|
3334
|
+
const { execSync } = await import('node:child_process')
|
|
3335
|
+
const miphamPath = execSync('which mipham 2>/dev/null || echo ""', { encoding: 'utf-8' }).trim()
|
|
3336
|
+
if (miphamPath) {
|
|
3337
|
+
lines.push(` ✅ mipham found at: ${miphamPath}`)
|
|
3338
|
+
const version = execSync('mipham --version 2>/dev/null || echo "unknown"', {
|
|
3339
|
+
encoding: 'utf-8',
|
|
3340
|
+
}).trim()
|
|
3341
|
+
lines.push(` 📦 Version: ${version}`)
|
|
3342
|
+
} else {
|
|
3343
|
+
lines.push(` ⚠️ mipham not in PATH. Install globally:`)
|
|
3344
|
+
lines.push(` ${NPM_INSTALL_COMMAND}`)
|
|
3345
|
+
lines.push(` or: curl -fsSL https://mipham.ai/install.sh | bash`)
|
|
3346
|
+
}
|
|
3347
|
+
} catch {
|
|
3348
|
+
lines.push(` 💡 Install: ${NPM_INSTALL_COMMAND}`)
|
|
3349
|
+
}
|
|
3238
3350
|
|
|
3239
|
-
Verify
|
|
3240
|
-
|
|
3241
|
-
|
|
3351
|
+
// ── 4. Verify ──
|
|
3352
|
+
lines.push('')
|
|
3353
|
+
lines.push('── Next Steps ──')
|
|
3354
|
+
lines.push(' 1. Restart your terminal or run: source ~/.mipham/shell-setup.sh')
|
|
3355
|
+
lines.push(' 2. Run: mipham --version')
|
|
3356
|
+
lines.push(' 3. Start coding: cd your-project && mipham')
|
|
3357
|
+
lines.push('')
|
|
3358
|
+
lines.push(`Works with: Zsh, Bash. Shell: ${shell}`)
|
|
3242
3359
|
|
|
3243
|
-
|
|
3244
|
-
}
|
|
3360
|
+
return { content: lines.join('\n') }
|
|
3361
|
+
}
|
|
3245
3362
|
|
|
3246
3363
|
// ═══════════════════════════════════════════════════════════════
|
|
3247
3364
|
// Phase 4 — MCP Server Management
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { SubAgent } from '../../agent/sub-agent'
|
|
2
2
|
import type { ProviderRegistry } from '../../providers/registry'
|
|
3
3
|
import type { ToolDefinition } from '../../shared/index.ts'
|
|
4
|
+
import { validateJSONSchema, formatValidationErrors } from '../schema-validator'
|
|
4
5
|
|
|
5
6
|
export interface WorkflowAgentOpts {
|
|
6
7
|
label?: string
|
|
@@ -9,11 +10,19 @@ export interface WorkflowAgentOpts {
|
|
|
9
10
|
model?: string
|
|
10
11
|
provider?: string
|
|
11
12
|
effort?: 'low' | 'medium' | 'high' | 'max'
|
|
13
|
+
/** Maximum retries on schema validation failure (default: 2). */
|
|
14
|
+
maxRetries?: number
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
/**
|
|
15
18
|
* Workflow agent() primitive — creates a SubAgent with optional
|
|
16
|
-
* provider/model override and structured output schema.
|
|
19
|
+
* provider/model override and structured output schema validation.
|
|
20
|
+
*
|
|
21
|
+
* When `schema` is provided:
|
|
22
|
+
* 1. The sub-agent is prompted to return valid JSON matching the schema.
|
|
23
|
+
* 2. The result is JSON.parsed and validated against the schema.
|
|
24
|
+
* 3. On validation failure, the sub-agent is retried with error feedback.
|
|
25
|
+
* 4. Returns the validated object, or { raw, validationErrors } on final failure.
|
|
17
26
|
*/
|
|
18
27
|
export async function workflowAgent(
|
|
19
28
|
prompt: string,
|
|
@@ -28,24 +37,77 @@ export async function workflowAgent(
|
|
|
28
37
|
registry.switchProvider(registry.getActive().config.id, opts.model)
|
|
29
38
|
}
|
|
30
39
|
|
|
31
|
-
const
|
|
32
|
-
const result = await sub.execute(prompt, opts.label || 'workflow-agent', {
|
|
33
|
-
type: 'general',
|
|
34
|
-
modelOverride: opts.model,
|
|
35
|
-
allowedTools: undefined, // use all tools by default
|
|
36
|
-
})
|
|
40
|
+
const maxRetries = opts.maxRetries ?? 2
|
|
37
41
|
|
|
38
|
-
//
|
|
42
|
+
// Build the prompt — if schema is requested, instruct the model to return JSON
|
|
43
|
+
let effectivePrompt = prompt
|
|
39
44
|
if (opts.schema) {
|
|
45
|
+
const schemaDesc = JSON.stringify(opts.schema, null, 2)
|
|
46
|
+
effectivePrompt =
|
|
47
|
+
`${prompt}\n\n` +
|
|
48
|
+
`IMPORTANT: Your response MUST be valid JSON matching this schema:\n` +
|
|
49
|
+
`\`\`\`json\n${schemaDesc}\n\`\`\`\n` +
|
|
50
|
+
`Return ONLY the JSON object, no other text. Do not wrap in markdown code fences.`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const sub = new SubAgent(registry, toolRegistry)
|
|
54
|
+
|
|
55
|
+
// ── Attempt execution with optional schema validation + retry ──
|
|
56
|
+
let lastResult = ''
|
|
57
|
+
let lastErrors: string[] = []
|
|
58
|
+
|
|
59
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
60
|
+
const retryPrompt =
|
|
61
|
+
attempt === 0
|
|
62
|
+
? effectivePrompt
|
|
63
|
+
: `${effectivePrompt}\n\n` +
|
|
64
|
+
`[RETRY #${attempt}] Your previous response did NOT match the required schema.\n` +
|
|
65
|
+
`Validation errors:\n${lastErrors.map((e) => ` • ${e}`).join('\n')}\n\n` +
|
|
66
|
+
`Please fix the errors and return a valid JSON object matching the schema exactly.`
|
|
67
|
+
|
|
68
|
+
const result = await sub.execute(retryPrompt, opts.label || 'workflow-agent', {
|
|
69
|
+
type: 'general',
|
|
70
|
+
modelOverride: opts.model,
|
|
71
|
+
allowedTools: undefined, // use all tools by default
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
lastResult = result
|
|
75
|
+
|
|
76
|
+
// No schema — return raw result
|
|
77
|
+
if (!opts.schema) {
|
|
78
|
+
return result
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Try to parse and validate
|
|
40
82
|
try {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
83
|
+
// Extract JSON from potential markdown fences
|
|
84
|
+
let jsonStr = result.trim()
|
|
85
|
+
const fenceMatch = jsonStr.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/)
|
|
86
|
+
if (fenceMatch) {
|
|
87
|
+
jsonStr = fenceMatch[1]!.trim()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const parsed = JSON.parse(jsonStr)
|
|
91
|
+
const errors = validateJSONSchema(parsed, opts.schema as Record<string, unknown>)
|
|
92
|
+
|
|
93
|
+
if (errors.length === 0) {
|
|
94
|
+
// Valid! Return the parsed object
|
|
95
|
+
return parsed
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Not valid — collect errors for retry
|
|
99
|
+
lastErrors = [formatValidationErrors(errors)]
|
|
100
|
+
} catch (err) {
|
|
101
|
+
// JSON parse error
|
|
102
|
+
lastErrors = [`JSON parse error: ${String(err)}. Ensure your response is valid JSON only.`]
|
|
47
103
|
}
|
|
48
104
|
}
|
|
49
105
|
|
|
50
|
-
return result
|
|
106
|
+
// All retries exhausted — return raw result with validation errors
|
|
107
|
+
try {
|
|
108
|
+
const parsed = JSON.parse(lastResult)
|
|
109
|
+
return { raw: lastResult, parsed, validationErrors: lastErrors }
|
|
110
|
+
} catch {
|
|
111
|
+
return { raw: lastResult, validationErrors: lastErrors }
|
|
112
|
+
}
|
|
51
113
|
}
|
package/src/workflow/runtime.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createSandbox } from './sandbox'
|
|
2
|
-
import { createJournal, appendJournal } from './journal'
|
|
2
|
+
import { createJournal, appendJournal, loadJournal, loadScript } from './journal'
|
|
3
3
|
import { createBudget } from './budget'
|
|
4
4
|
import { workflowAgent } from './primitives/agent'
|
|
5
5
|
import { parallel } from './primitives/parallel'
|
|
@@ -12,30 +12,103 @@ export interface WorkflowRunResult {
|
|
|
12
12
|
runId: string
|
|
13
13
|
result: unknown
|
|
14
14
|
journalEntries: number
|
|
15
|
+
/** Number of agent() calls served from cache (only set when resumeFromRunId is used). */
|
|
16
|
+
cacheHits: number
|
|
17
|
+
/** Number of agent() calls executed live (only set when resumeFromRunId is used). */
|
|
18
|
+
cacheMisses: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Generate a cache key for an agent() call.
|
|
23
|
+
* Uses prompt + deterministic subset of opts.
|
|
24
|
+
*/
|
|
25
|
+
function agentCacheKey(prompt: string, opts: Record<string, unknown> = {}): string {
|
|
26
|
+
const relevant = {
|
|
27
|
+
prompt,
|
|
28
|
+
label: opts.label,
|
|
29
|
+
phase: opts.phase,
|
|
30
|
+
model: opts.model,
|
|
31
|
+
provider: opts.provider,
|
|
32
|
+
}
|
|
33
|
+
return JSON.stringify(relevant)
|
|
15
34
|
}
|
|
16
35
|
|
|
17
36
|
/**
|
|
18
37
|
* Execute a workflow script string.
|
|
19
38
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
39
|
+
* When `resumeFromRunId` is provided:
|
|
40
|
+
* - Loads the journal from the prior run
|
|
41
|
+
* - Compares the current script against the saved script
|
|
42
|
+
* - Builds a cache from prior agent() results (keyed by prompt+opts)
|
|
43
|
+
* - New agent() calls that match the cache return the cached result
|
|
44
|
+
* - New/changed agent() calls execute live and append to the journal
|
|
45
|
+
* - Returns cacheHits and cacheMisses counts
|
|
46
|
+
*
|
|
47
|
+
* The sandbox blocks non-deterministic APIs (Date.now, Math.random) to make
|
|
48
|
+
* this deterministic replay possible.
|
|
22
49
|
*/
|
|
23
50
|
export async function runWorkflow(
|
|
24
51
|
script: string,
|
|
25
52
|
engine: QueryEngine,
|
|
26
53
|
args: unknown = {},
|
|
27
54
|
budgetTotal: number | null = null,
|
|
55
|
+
resumeFromRunId?: string,
|
|
28
56
|
): Promise<WorkflowRunResult> {
|
|
29
|
-
|
|
30
|
-
|
|
57
|
+
let runId: string
|
|
58
|
+
let cacheHits = 0
|
|
59
|
+
let cacheMisses = 0
|
|
60
|
+
|
|
61
|
+
// ── Resume mode: load prior journal, build cache ──
|
|
62
|
+
let resultCache: Map<string, unknown> | null = null
|
|
63
|
+
if (resumeFromRunId) {
|
|
64
|
+
const savedScript = loadScript(resumeFromRunId)
|
|
65
|
+
if (savedScript && savedScript !== script) {
|
|
66
|
+
// Script changed — warn but proceed (cache may have stale entries)
|
|
67
|
+
process.stderr.write(
|
|
68
|
+
`[workflow] ⚠ Script differs from saved run "${resumeFromRunId}". ` +
|
|
69
|
+
`Cache may contain stale entries for changed agent calls.\n`,
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const priorEntries = loadJournal(resumeFromRunId)
|
|
74
|
+
if (priorEntries.length === 0) {
|
|
75
|
+
process.stderr.write(
|
|
76
|
+
`[workflow] ⚠ No journal entries found for run "${resumeFromRunId}". Proceeding without cache.\n`,
|
|
77
|
+
)
|
|
78
|
+
} else {
|
|
79
|
+
resultCache = new Map()
|
|
80
|
+
for (const entry of priorEntries) {
|
|
81
|
+
if (entry.type === 'agent' && entry.prompt && entry.result !== undefined) {
|
|
82
|
+
const key = agentCacheKey(entry.prompt, entry.opts || {})
|
|
83
|
+
resultCache.set(key, entry.result)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Re-use the existing run directory for appending
|
|
89
|
+
runId = resumeFromRunId
|
|
90
|
+
} else {
|
|
91
|
+
runId = `run-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
|
92
|
+
createJournal(runId, script)
|
|
93
|
+
}
|
|
31
94
|
|
|
32
95
|
const registry: ProviderRegistry = engine.getRegistry()
|
|
33
96
|
const toolRegistry = engine.getTools()
|
|
34
97
|
|
|
35
98
|
const budget = createBudget(budgetTotal)
|
|
36
99
|
|
|
37
|
-
// Wrap primitives with journal recording
|
|
100
|
+
// Wrap primitives with journal recording + cache support
|
|
38
101
|
const agent = async (prompt: string, opts?: Record<string, unknown>) => {
|
|
102
|
+
// Check cache first
|
|
103
|
+
if (resultCache) {
|
|
104
|
+
const key = agentCacheKey(prompt, opts || {})
|
|
105
|
+
if (resultCache.has(key)) {
|
|
106
|
+
cacheHits++
|
|
107
|
+
return resultCache.get(key)!
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
cacheMisses++
|
|
111
|
+
|
|
39
112
|
const result = await workflowAgent(
|
|
40
113
|
prompt,
|
|
41
114
|
registry,
|
|
@@ -83,5 +156,9 @@ export async function runWorkflow(
|
|
|
83
156
|
|
|
84
157
|
const result = await scriptFn(agent, parallel, pipeline, wrappedPhase, log, args, budget)
|
|
85
158
|
|
|
86
|
-
|
|
159
|
+
// Count journal entries from state
|
|
160
|
+
const priorEntries = loadJournal(runId)
|
|
161
|
+
const journalEntries = priorEntries.length
|
|
162
|
+
|
|
163
|
+
return { runId, result, journalEntries, cacheHits, cacheMisses }
|
|
87
164
|
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight JSON Schema validator for workflow agent() schema parameter.
|
|
3
|
+
*
|
|
4
|
+
* Supports:
|
|
5
|
+
* - type: string | number | integer | boolean | object | array
|
|
6
|
+
* - properties (objects), items (arrays), required, enum
|
|
7
|
+
* - Nested objects and arrays (recursive validation)
|
|
8
|
+
*
|
|
9
|
+
* Zero external dependencies — pure TypeScript implementation.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface ValidationError {
|
|
13
|
+
path: string // JSON path to the error, e.g. "$.name" or "$.items[0].value"
|
|
14
|
+
message: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Validate `data` against a JSON Schema definition.
|
|
19
|
+
* Returns an array of ValidationError — empty means valid.
|
|
20
|
+
*/
|
|
21
|
+
export function validateJSONSchema(
|
|
22
|
+
data: unknown,
|
|
23
|
+
schema: Record<string, unknown>,
|
|
24
|
+
): ValidationError[] {
|
|
25
|
+
const errors: ValidationError[] = []
|
|
26
|
+
_validate(data, schema, '$', errors)
|
|
27
|
+
return errors
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Format validation errors into a human-readable message for LLM feedback.
|
|
32
|
+
*/
|
|
33
|
+
export function formatValidationErrors(errors: ValidationError[]): string {
|
|
34
|
+
if (errors.length === 0) return ''
|
|
35
|
+
return errors.map((e) => ` • ${e.path}: ${e.message}`).join('\n')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Internal recursive validator ──
|
|
39
|
+
|
|
40
|
+
function _validate(
|
|
41
|
+
data: unknown,
|
|
42
|
+
schema: Record<string, unknown>,
|
|
43
|
+
path: string,
|
|
44
|
+
errors: ValidationError[],
|
|
45
|
+
): void {
|
|
46
|
+
const type = schema.type as string | undefined
|
|
47
|
+
|
|
48
|
+
// ── Null check ──
|
|
49
|
+
if (data === null || data === undefined) {
|
|
50
|
+
// If required, the parent object handles this. Here we just check type mismatch.
|
|
51
|
+
if (type && type !== 'null') {
|
|
52
|
+
errors.push({ path, message: `expected ${type}, got null` })
|
|
53
|
+
}
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Type checking ──
|
|
58
|
+
if (type) {
|
|
59
|
+
const actualType = Array.isArray(data) ? 'array' : typeof data
|
|
60
|
+
const expectedType = type === 'integer' ? 'number' : type
|
|
61
|
+
|
|
62
|
+
if (expectedType === 'number' && actualType === 'number') {
|
|
63
|
+
// OK — integer is a subset of number
|
|
64
|
+
} else if (expectedType !== actualType) {
|
|
65
|
+
errors.push({ path, message: `expected type "${type}", got "${actualType}"` })
|
|
66
|
+
return // Can't validate further if type is wrong
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Integer check
|
|
70
|
+
if (type === 'integer' && !Number.isInteger(data)) {
|
|
71
|
+
errors.push({ path, message: `expected integer, got ${data}` })
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Enum validation ──
|
|
76
|
+
if (schema.enum && Array.isArray(schema.enum)) {
|
|
77
|
+
if (!schema.enum.includes(data)) {
|
|
78
|
+
errors.push({
|
|
79
|
+
path,
|
|
80
|
+
message: `value must be one of: ${schema.enum.map((v) => JSON.stringify(v)).join(', ')}`,
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Object validation ──
|
|
86
|
+
if (type === 'object' || (!type && typeof data === 'object' && !Array.isArray(data))) {
|
|
87
|
+
const obj = data as Record<string, unknown>
|
|
88
|
+
const properties = schema.properties as Record<string, Record<string, unknown>> | undefined
|
|
89
|
+
const required = schema.required as string[] | undefined
|
|
90
|
+
|
|
91
|
+
// Required fields
|
|
92
|
+
if (required) {
|
|
93
|
+
for (const field of required) {
|
|
94
|
+
if (obj[field] === undefined || obj[field] === null) {
|
|
95
|
+
errors.push({ path: `${path}.${field}`, message: `required field is missing` })
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Property validation
|
|
101
|
+
if (properties) {
|
|
102
|
+
for (const [key, propSchema] of Object.entries(properties)) {
|
|
103
|
+
const value = obj[key]
|
|
104
|
+
if (value !== undefined && value !== null) {
|
|
105
|
+
_validate(value, propSchema, `${path}.${key}`, errors)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Array validation ──
|
|
112
|
+
if (type === 'array' || (!type && Array.isArray(data))) {
|
|
113
|
+
const arr = data as unknown[]
|
|
114
|
+
const items = schema.items as Record<string, unknown> | undefined
|
|
115
|
+
|
|
116
|
+
if (items) {
|
|
117
|
+
for (let i = 0; i < arr.length; i++) {
|
|
118
|
+
_validate(arr[i], items, `${path}[${i}]`, errors)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// minItems / maxItems
|
|
123
|
+
if (schema.minItems !== undefined && arr.length < (schema.minItems as number)) {
|
|
124
|
+
errors.push({
|
|
125
|
+
path,
|
|
126
|
+
message: `array has ${arr.length} items, minimum is ${schema.minItems}`,
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
if (schema.maxItems !== undefined && arr.length > (schema.maxItems as number)) {
|
|
130
|
+
errors.push({
|
|
131
|
+
path,
|
|
132
|
+
message: `array has ${arr.length} items, maximum is ${schema.maxItems}`,
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── String validation ──
|
|
138
|
+
if ((type === 'string' || typeof data === 'string') && typeof data === 'string') {
|
|
139
|
+
if (schema.minLength !== undefined && data.length < (schema.minLength as number)) {
|
|
140
|
+
errors.push({
|
|
141
|
+
path,
|
|
142
|
+
message: `string length ${data.length} is less than minimum ${schema.minLength}`,
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
if (schema.maxLength !== undefined && data.length > (schema.maxLength as number)) {
|
|
146
|
+
errors.push({
|
|
147
|
+
path,
|
|
148
|
+
message: `string length ${data.length} exceeds maximum ${schema.maxLength}`,
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── Number validation ──
|
|
154
|
+
if ((type === 'number' || type === 'integer') && typeof data === 'number') {
|
|
155
|
+
if (schema.minimum !== undefined && data < (schema.minimum as number)) {
|
|
156
|
+
errors.push({ path, message: `value ${data} is less than minimum ${schema.minimum}` })
|
|
157
|
+
}
|
|
158
|
+
if (schema.maximum !== undefined && data > (schema.maximum as number)) {
|
|
159
|
+
errors.push({ path, message: `value ${data} exceeds maximum ${schema.maximum}` })
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|