@getpipher/armory-fleet 0.11.1 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/engine/concurrency-lock.ts +20 -15
- package/src/engine/spawnSubagent.ts +8 -2
- package/src/index.ts +104 -3
- package/src/panel/fleet-panel.ts +165 -11
- package/src/runtime/reconcile.ts +12 -9
- package/src/tools/fleet.ts +179 -0
- package/src/workflows/builtin/adversarial-review.js +19 -0
- package/src/workflows/builtin/code-review.js +13 -0
- package/src/workflows/builtin/codebase-audit.js +16 -0
- package/src/workflows/builtin/deep-research.js +12 -0
- package/src/workflows/builtin/multi-perspective.js +17 -0
- package/src/workflows/helpers/checkpoint.ts +15 -0
- package/src/workflows/helpers/completeness-check.ts +18 -0
- package/src/workflows/helpers/gate.ts +22 -0
- package/src/workflows/helpers/index.ts +8 -0
- package/src/workflows/helpers/judge-panel.ts +33 -0
- package/src/workflows/helpers/loop-until-dry.ts +21 -0
- package/src/workflows/helpers/retry.ts +17 -0
- package/src/workflows/helpers/types.ts +19 -0
- package/src/workflows/helpers/verify.ts +27 -0
- package/src/workflows/journal.ts +76 -0
- package/src/workflows/keyword.ts +22 -0
- package/src/workflows/panel/workflows-items.ts +150 -0
- package/src/workflows/panel/workflows-rows.ts +3 -0
- package/src/workflows/panel-host.ts +179 -0
- package/src/workflows/registry.ts +68 -0
- package/src/workflows/runner.ts +507 -0
- package/src/workflows/runtime/adapters.ts +182 -0
- package/src/workflows/runtime/controller.ts +493 -0
- package/src/workflows/runtime/hydrate.ts +116 -0
- package/src/workflows/runtime/pause-gate.ts +41 -0
- package/src/workflows/runtime/run-store.ts +31 -0
- package/src/workflows/runtime/save.ts +111 -0
- package/src/workflows/runtime/types.ts +78 -0
- package/src/workflows/source.ts +156 -0
- package/src/workflows/vm-realm.ts +106 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// SPEC-6-3 §9 — the model-callable fleet tool surface for workflows.
|
|
2
|
+
// Thin delegation to WorkflowController via a getter (prevents duplicate registration).
|
|
3
|
+
import { Type, type Static } from "typebox"
|
|
4
|
+
|
|
5
|
+
import type { WorkflowController } from "../workflows/runtime/controller.ts"
|
|
6
|
+
import type { WorkflowStartInput, WorkflowRunState, WorkflowStartReceipt } from "../workflows/runtime/types.ts"
|
|
7
|
+
import type { WorkflowRunResult } from "../workflows/runner.ts"
|
|
8
|
+
|
|
9
|
+
export interface FleetToolDeps {
|
|
10
|
+
getController: () => WorkflowController
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const fleetParams = Type.Object({
|
|
14
|
+
action: Type.Union([Type.Literal("workflow"), Type.Literal("workflow_control")], { description: "The fleet workflow action." }),
|
|
15
|
+
// workflow action
|
|
16
|
+
script: Type.Optional(Type.String({ description: "The JS workflow script (for action: 'workflow')." })),
|
|
17
|
+
name: Type.Optional(Type.String({ description: "Save-as name (for action: 'workflow')." })),
|
|
18
|
+
workflowName: Type.Optional(Type.String({ description: "Run a saved workflow by name." })),
|
|
19
|
+
overwrite: Type.Optional(Type.Boolean({ description: "Overwrite an existing saved workflow." })),
|
|
20
|
+
args: Type.Optional(Type.Unknown({ description: "Args passed to the script as `args`." })),
|
|
21
|
+
background: Type.Optional(Type.Boolean({ description: "Non-blocking (default true)." })),
|
|
22
|
+
resumeFromRunId: Type.Optional(Type.String({ description: "Edit-and-resume: replay the unchanged prefix, re-run the edited suffix." })),
|
|
23
|
+
maxAgents: Type.Optional(Type.Number({ description: "Hard cap on total agent() calls." })),
|
|
24
|
+
concurrency: Type.Optional(Type.Number({ description: "Parallel agent() concurrency." })),
|
|
25
|
+
agentRetries: Type.Optional(Type.Number({ description: "Default per-agent retries." })),
|
|
26
|
+
agentTimeoutMs: Type.Optional(Type.Number({ description: "Default per-agent timeoutMs." })),
|
|
27
|
+
tokenBudget: Type.Optional(Type.Number({ description: "Run-level token budget." })),
|
|
28
|
+
// workflow_control action
|
|
29
|
+
control: Type.Optional(Type.Union([Type.Literal("list"), Type.Literal("status"), Type.Literal("pause"), Type.Literal("resume"), Type.Literal("stop")], { description: "The control operation." })),
|
|
30
|
+
runId: Type.Optional(Type.String({ description: "The workflow runId (for status/pause/resume/stop)." })),
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
export type FleetInput = Static<typeof fleetParams>
|
|
34
|
+
|
|
35
|
+
export function createFleetTool(deps: FleetToolDeps) {
|
|
36
|
+
return {
|
|
37
|
+
name: "fleet",
|
|
38
|
+
label: "Fleet",
|
|
39
|
+
description: "Run + control armory-fleet workflows (JS orchestration with agent/parallel/pipeline/phase + journaled resume).",
|
|
40
|
+
promptSnippet: "Run or control a fleet workflow",
|
|
41
|
+
promptGuidelines: [
|
|
42
|
+
"Use action 'workflow' to run a JS workflow script or a saved workflow by name.",
|
|
43
|
+
"Use action 'workflow_control' with control 'list'/'status'/'pause'/'resume'/'stop' to manage a running workflow by runId.",
|
|
44
|
+
"Pass resumeFromRunId to edit-and-resume: the unchanged agent() prefix replays from cache; edited + new calls re-run.",
|
|
45
|
+
],
|
|
46
|
+
parameters: fleetParams,
|
|
47
|
+
async execute(_id: string, params: FleetInput, signal: AbortSignal | null, _onUpdate: unknown, _ctx: unknown) {
|
|
48
|
+
let controller: WorkflowController
|
|
49
|
+
try {
|
|
50
|
+
controller = deps.getController()
|
|
51
|
+
} catch {
|
|
52
|
+
return { isError: true, content: [{ type: "text" as const, text: "workflow runtime not initialized for this session" }] }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
if (params.action === "workflow") {
|
|
57
|
+
return await handleWorkflowAction(controller, params, signal)
|
|
58
|
+
}
|
|
59
|
+
return await handleControlAction(controller, params)
|
|
60
|
+
} catch (e) {
|
|
61
|
+
return { isError: true, content: [{ type: "text" as const, text: (e as Error).message }] }
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function handleWorkflowAction(controller: WorkflowController, params: FleetInput, signal: AbortSignal | null) {
|
|
68
|
+
const hasScript = params.script !== undefined
|
|
69
|
+
const hasName = params.workflowName !== undefined
|
|
70
|
+
|
|
71
|
+
if (hasScript && hasName) {
|
|
72
|
+
return { isError: true, content: [{ type: "text" as const, text: "provide exactly one of script or workflowName" }] }
|
|
73
|
+
}
|
|
74
|
+
if (!hasScript && !hasName) {
|
|
75
|
+
return { isError: true, content: [{ type: "text" as const, text: "action 'workflow' requires `script` or `workflowName`" }] }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// resumeFromRunId → editAndResume
|
|
79
|
+
if (params.resumeFromRunId) {
|
|
80
|
+
const result = await controller.editAndResume(params.resumeFromRunId, params.script ?? "")
|
|
81
|
+
return serializeRunResult(result)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Build the delegated WorkflowStartInput with conditional spreads (exact deepEqual match).
|
|
85
|
+
const input: WorkflowStartInput = {
|
|
86
|
+
mode: "auto",
|
|
87
|
+
...(params.script !== undefined ? { script: params.script } : {}),
|
|
88
|
+
...(params.workflowName !== undefined ? { workflowName: params.workflowName } : {}),
|
|
89
|
+
...(params.name !== undefined ? { name: params.name } : {}),
|
|
90
|
+
...(params.overwrite !== undefined ? { overwrite: params.overwrite } : {}),
|
|
91
|
+
...(params.args !== undefined ? { args: params.args } : {}),
|
|
92
|
+
...(params.background !== undefined ? { background: params.background } : {}),
|
|
93
|
+
...(params.concurrency !== undefined ? { concurrency: params.concurrency } : {}),
|
|
94
|
+
...(params.agentRetries !== undefined ? { agentRetries: params.agentRetries } : {}),
|
|
95
|
+
...(params.agentTimeoutMs !== undefined ? { agentTimeoutMs: params.agentTimeoutMs } : {}),
|
|
96
|
+
...(params.tokenBudget !== undefined ? { tokenBudget: params.tokenBudget } : {}),
|
|
97
|
+
...(params.maxAgents !== undefined ? { maxAgents: params.maxAgents } : {}),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Foreground: forward the tool signal. Background: detach (no signal).
|
|
101
|
+
const isForeground = params.background === false
|
|
102
|
+
const ctx = isForeground && signal ? { signal } : undefined
|
|
103
|
+
|
|
104
|
+
const result = await controller.start(input, ctx)
|
|
105
|
+
return serializeRunResult(result)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function handleControlAction(controller: WorkflowController, params: FleetInput) {
|
|
109
|
+
const control = params.control ?? "list"
|
|
110
|
+
|
|
111
|
+
if (control === "list") {
|
|
112
|
+
const runs = controller.runs()
|
|
113
|
+
return {
|
|
114
|
+
content: [{ type: "text" as const, text: `workflows: ${runs.length ? runs.map((r) => r.runId).join(", ") : "(none running)"}` }],
|
|
115
|
+
details: { runs: runs.map(summarizeRun) },
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!params.runId) {
|
|
120
|
+
return { isError: true, content: [{ type: "text" as const, text: `control '${control}' requires \`runId\`` }] }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (control === "status") {
|
|
124
|
+
const run = controller.getRun(params.runId)
|
|
125
|
+
if (!run) {
|
|
126
|
+
return { isError: true, content: [{ type: "text" as const, text: `workflow '${params.runId}' not found` }] }
|
|
127
|
+
}
|
|
128
|
+
return { content: [{ type: "text" as const, text: `workflow ${run.runId}: ${run.status}` }], details: { run: summarizeRun(run) } }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (control === "pause") {
|
|
132
|
+
controller.pause(params.runId)
|
|
133
|
+
return { content: [{ type: "text" as const, text: `workflow ${params.runId}: paused` }], details: { runId: params.runId, status: "paused" } }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (control === "resume") {
|
|
137
|
+
const receipt = await controller.resume(params.runId)
|
|
138
|
+
return { content: [{ type: "text" as const, text: `workflow ${params.runId}: resumed` }], details: { receipt } }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (control === "stop") {
|
|
142
|
+
await controller.stop(params.runId)
|
|
143
|
+
return { content: [{ type: "text" as const, text: `workflow ${params.runId}: stopped` }], details: { runId: params.runId, status: "aborted" } }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { isError: true, content: [{ type: "text" as const, text: `unknown control '${control}'` }] }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function summarizeRun(run: WorkflowRunState): Record<string, unknown> {
|
|
150
|
+
return {
|
|
151
|
+
runId: run.runId,
|
|
152
|
+
name: run.name,
|
|
153
|
+
status: run.status,
|
|
154
|
+
startedAt: run.startedAt,
|
|
155
|
+
...(run.endedAt !== undefined ? { endedAt: run.endedAt } : {}),
|
|
156
|
+
currentPhase: run.currentPhase,
|
|
157
|
+
phases: run.phases,
|
|
158
|
+
childRunIds: run.childRunIds,
|
|
159
|
+
tokenTotal: run.tokenTotal,
|
|
160
|
+
costTotal: run.costTotal,
|
|
161
|
+
...(run.error ? { error: run.error } : {}),
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function serializeRunResult(result: WorkflowStartReceipt | WorkflowRunResult) {
|
|
166
|
+
if ("status" in result && result.status === "background") {
|
|
167
|
+
return {
|
|
168
|
+
content: [{ type: "text" as const, text: `workflow ${result.runId}: background` }],
|
|
169
|
+
details: { runId: result.runId, status: "background" },
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const r = result as WorkflowRunResult
|
|
173
|
+
const isError = r.status === "aborted" || r.status === "failed"
|
|
174
|
+
return {
|
|
175
|
+
content: [{ type: "text" as const, text: isError ? (r.error ?? r.status) : `workflow ${r.runId}: ${r.status}` }],
|
|
176
|
+
details: { runId: r.runId, status: r.status },
|
|
177
|
+
isError: isError || undefined,
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const meta = {
|
|
2
|
+
name: 'adversarial-review',
|
|
3
|
+
description: 'Red-team + blue-team review with judge panel',
|
|
4
|
+
phases: [{ title: 'Attack' }, { title: 'Defend' }, { title: 'Judge' }],
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
phase('Attack')
|
|
8
|
+
const vulns = await parallel([
|
|
9
|
+
() => agent('Find injection vulnerabilities in this code.', { tier: 'medium' }),
|
|
10
|
+
() => agent('Find logic errors and race conditions.', { tier: 'medium' }),
|
|
11
|
+
() => agent('Find authentication bypass vectors.', { tier: 'medium' }),
|
|
12
|
+
])
|
|
13
|
+
|
|
14
|
+
phase('Defend')
|
|
15
|
+
const defenses = await parallel(vulns.map((v) => () => agent(`Propose a fix for: ${v}`, { tier: 'low' })))
|
|
16
|
+
|
|
17
|
+
phase('Judge')
|
|
18
|
+
const winner = await judgePanel([...vulns, ...defenses], { judges: 3, rubric: 'severity and fixability' })
|
|
19
|
+
return { vulnerabilities: vulns, defenses, verdict: winner }
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const meta = {
|
|
2
|
+
name: 'code-review',
|
|
3
|
+
description: '7 parallel review angles plus verification',
|
|
4
|
+
phases: [{ title: 'Review' }, { title: 'Verify' }],
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
phase('Review')
|
|
8
|
+
const angles = ['security', 'correctness', 'performance', 'readability', 'edge-cases', 'tests', 'api-design']
|
|
9
|
+
const findings = await parallel(angles.map((angle) => () => agent(`Review this diff for ${angle} issues. Report concrete findings only.`, { tier: 'medium' })))
|
|
10
|
+
|
|
11
|
+
phase('Verify')
|
|
12
|
+
const verified = await verify(findings.join('\n---\n'), { reviewers: 2, lens: 'false positives' })
|
|
13
|
+
return { findings, verified }
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const meta = {
|
|
2
|
+
name: 'codebase-audit',
|
|
3
|
+
description: 'File-tree scan with completeness check',
|
|
4
|
+
phases: [{ title: 'Scan' }, { title: 'Audit' }],
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
phase('Scan')
|
|
8
|
+
const files = await loopUntilDry({
|
|
9
|
+
round: (n) => n < 4 ? agent(`List files in dir ${n}. Return a JSON array of file path strings.`, { tier: 'low', schema: { type: 'array' }, retries: 1 }) : [],
|
|
10
|
+
consecutiveEmpty: 2,
|
|
11
|
+
maxRounds: 6,
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
phase('Audit')
|
|
15
|
+
const checked = await verify(`Found ${files.length} files.`, { reviewers: 1, lens: 'missing test coverage' })
|
|
16
|
+
return { files, checked }
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const meta = {
|
|
2
|
+
name: 'deep-research',
|
|
3
|
+
description: '3-round discovery loop with de-duplication',
|
|
4
|
+
phases: [{ title: 'Discover' }, { title: 'Synthesize' }],
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
phase('Discover')
|
|
8
|
+
const topics = await loopUntilDry({ round: (n) => n < 3 ? agent(`Find unique sources for round ${n}. Return a JSON array of source strings.`, { tier: 'low', schema: { type: 'array' }, retries: 1 }) : [], consecutiveEmpty: 2, maxRounds: 5 })
|
|
9
|
+
|
|
10
|
+
phase('Synthesize')
|
|
11
|
+
const summary = await agent(`Synthesize these ${topics.length} sources into a coherent report.`, { tier: 'medium' })
|
|
12
|
+
return { sources: topics, summary }
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const meta = {
|
|
2
|
+
name: 'multi-perspective',
|
|
3
|
+
description: '4 personas review the same artifact',
|
|
4
|
+
phases: [{ title: 'Review' }, { title: 'Merge' }],
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
phase('Review')
|
|
8
|
+
const personas = ['product manager', 'security engineer', 'UX designer', 'dev-ops lead']
|
|
9
|
+
const reviews = await parallel(personas.map((p) => () => agent(`Review this artifact as a ${p}. Focus on your domain only.`, { tier: 'medium' })))
|
|
10
|
+
|
|
11
|
+
phase('Merge')
|
|
12
|
+
const merged = await gate(
|
|
13
|
+
async (feedback, n) => n === 0 ? agent(`Initial synthesis of ${reviews.length} reviews.`, { tier: 'low' }) : agent(`Revise synthesis: ${feedback}`, { tier: 'low' }),
|
|
14
|
+
(v) => typeof v === 'string' && v.length > 100 ? { ok: true } : { ok: false, feedback: 'more detail needed' },
|
|
15
|
+
{ attempts: 3 },
|
|
16
|
+
)
|
|
17
|
+
return { reviews, synthesis: merged }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { HelperCtx } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/** SPEC-6-3 §3.3 — script-level human gate. Standalone (NOT CheckpointFn reuse).
|
|
4
|
+
* Interactive: resolves via ctx.onCheckpoint. Headless: returns opts.default (or aborts). */
|
|
5
|
+
export async function checkpoint(
|
|
6
|
+
prompt: string,
|
|
7
|
+
opts: { kind?: "confirm" | "input" | "select"; choices?: string[]; default?: unknown; headless?: "default" | "abort"; timeoutMs?: number } = {},
|
|
8
|
+
ctx: HelperCtx,
|
|
9
|
+
): Promise<unknown> {
|
|
10
|
+
// Interactive bridge present → use it (the Workflows view resolves the pending checkpoint).
|
|
11
|
+
if (ctx.onCheckpoint) return ctx.onCheckpoint(prompt, opts as Record<string, unknown>);
|
|
12
|
+
const headless = opts.headless ?? "default";
|
|
13
|
+
if (headless === "abort") throw new Error("checkpoint abort: headless mode with no UI");
|
|
14
|
+
return opts.default ?? true;
|
|
15
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { HelperCtx } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/** SPEC-6-3 §3.3 — judge whether results fully satisfy taskArgs via one agent. Returns null if agent can't judge. */
|
|
4
|
+
export async function completenessCheck(
|
|
5
|
+
taskArgs: unknown,
|
|
6
|
+
results: unknown,
|
|
7
|
+
ctx: HelperCtx,
|
|
8
|
+
opts: { tier?: string; model?: string; skills?: string[]; backend?: "pi" | "claude"; retries?: number; timeoutMs?: number } = {},
|
|
9
|
+
): Promise<{ complete: boolean; missing?: string[] } | null> {
|
|
10
|
+
const prompt = `You are a completeness judge. Given the task args and the results, decide if the results fully satisfy the task.\nTask args: ${JSON.stringify(taskArgs)}\nResults: ${JSON.stringify(results)}\nRespond as JSON: {"complete": boolean, "missing": string[]}`;
|
|
11
|
+
const res = await ctx.spawn(prompt, { agent: "reviewer", ...(opts.tier ? { tier: opts.tier } : {}), ...(opts.model ? { model: opts.model } : {}), ...(opts.skills ? { skills: opts.skills } : {}), ...(opts.backend ? { backend: opts.backend } : {}), ...(opts.retries ? { retries: opts.retries } : {}), ...(opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {}) });
|
|
12
|
+
if (!res || res.status !== "completed") return null;
|
|
13
|
+
try {
|
|
14
|
+
const parsed = JSON.parse(res.finalText) as { complete?: boolean; missing?: string[] };
|
|
15
|
+
if (typeof parsed.complete !== "boolean") return null;
|
|
16
|
+
return { complete: parsed.complete, ...(Array.isArray(parsed.missing) ? { missing: parsed.missing } : {}) };
|
|
17
|
+
} catch { return null; }
|
|
18
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { HelperCtx } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/** SPEC-6-3 §3.3 — run thunk, validate; on fail re-run with feedback up to `attempts`. */
|
|
4
|
+
export async function gate(
|
|
5
|
+
thunk: (feedback: string | undefined, attempt: number) => unknown | Promise<unknown>,
|
|
6
|
+
validator: (value: unknown) => { ok: boolean; feedback?: string } | Promise<{ ok: boolean; feedback?: string }>,
|
|
7
|
+
opts: { attempts?: number } = {},
|
|
8
|
+
ctx: HelperCtx,
|
|
9
|
+
): Promise<{ ok: boolean; value: unknown; attempts: number }> {
|
|
10
|
+
const attempts = opts.attempts ?? 3;
|
|
11
|
+
let feedback: string | undefined;
|
|
12
|
+
let lastValue: unknown;
|
|
13
|
+
for (let n = 0; n < attempts; n++) {
|
|
14
|
+
const value = await thunk(feedback, n);
|
|
15
|
+
lastValue = value;
|
|
16
|
+
const verdict = await validator(value);
|
|
17
|
+
if (verdict.ok) return { ok: true, value, attempts: n + 1 };
|
|
18
|
+
feedback = verdict.feedback;
|
|
19
|
+
}
|
|
20
|
+
// exhausted: return the last value from within the attempts budget (no extra run).
|
|
21
|
+
return { ok: false, value: lastValue, attempts };
|
|
22
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type { HelperCtx, HelperSpawnResult } from "./types.ts";
|
|
2
|
+
export { retry } from "./retry.ts";
|
|
3
|
+
export { gate } from "./gate.ts";
|
|
4
|
+
export { completenessCheck } from "./completeness-check.ts";
|
|
5
|
+
export { loopUntilDry } from "./loop-until-dry.ts";
|
|
6
|
+
export { verify } from "./verify.ts";
|
|
7
|
+
export { judgePanel } from "./judge-panel.ts";
|
|
8
|
+
export { checkpoint } from "./checkpoint.ts";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { HelperCtx } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
interface Judgment { score: number; reason?: string }
|
|
4
|
+
interface PanelResult { index: number; attempt: unknown; score: number; judgments: Judgment[] }
|
|
5
|
+
|
|
6
|
+
/** SPEC-6-3 §3.3 — N judges score every attempt; highest average score wins. Closes 6-2.1. */
|
|
7
|
+
export async function judgePanel(
|
|
8
|
+
attempts: unknown[],
|
|
9
|
+
opts: { judges?: number; rubric?: string; tier?: string; model?: string; skills?: string[]; backend?: "pi" | "claude"; retries?: number; timeoutMs?: number } = {},
|
|
10
|
+
ctx: HelperCtx,
|
|
11
|
+
): Promise<PanelResult | undefined> {
|
|
12
|
+
if (attempts.length === 0) return undefined;
|
|
13
|
+
const judges = opts.judges ?? 3;
|
|
14
|
+
const rubric = opts.rubric ?? "overall quality and correctness";
|
|
15
|
+
const perAttempt: Judgment[][] = attempts.map(() => []);
|
|
16
|
+
for (let j = 0; j < judges; j++) {
|
|
17
|
+
for (let a = 0; a < attempts.length; a++) {
|
|
18
|
+
const prompt = `You are judge ${j + 1} of ${judges}. Score this attempt on a 0-10 scale.\nRubric: ${rubric}\nAttempt ${a}: ${JSON.stringify(attempts[a])}\nRespond as JSON: {"score": number, "reason": string}`;
|
|
19
|
+
const res = await ctx.spawn(prompt, { agent: "reviewer", ...(opts.tier ? { tier: opts.tier } : {}), ...(opts.model ? { model: opts.model } : {}), ...(opts.skills ? { skills: opts.skills } : {}), ...(opts.backend ? { backend: opts.backend } : {}), ...(opts.retries ? { retries: opts.retries } : {}), ...(opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {}) });
|
|
20
|
+
if (res && res.status === "completed") {
|
|
21
|
+
try { const p = JSON.parse(res.finalText) as { score?: number; reason?: string }; perAttempt[a]!.push({ score: typeof p.score === "number" ? p.score : 0, ...(p.reason ? { reason: p.reason } : {}) }); }
|
|
22
|
+
catch { perAttempt[a]!.push({ score: 0 }); }
|
|
23
|
+
} else { perAttempt[a]!.push({ score: 0 }); }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
let best: PanelResult | undefined;
|
|
27
|
+
for (let a = 0; a < attempts.length; a++) {
|
|
28
|
+
const js = perAttempt[a]!;
|
|
29
|
+
const score = js.reduce((s, x) => s + x.score, 0) / (js.length || 1);
|
|
30
|
+
if (!best || score > best.score) best = { index: a, attempt: attempts[a], score, judgments: js };
|
|
31
|
+
}
|
|
32
|
+
return best;
|
|
33
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { HelperCtx } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/** SPEC-6-3 §3.3 — discovery loop: call round(n) → items; de-dupe by key; stop after consecutiveEmpty empty rounds or maxRounds. */
|
|
4
|
+
export async function loopUntilDry(
|
|
5
|
+
opts: { round: (roundIndex: number) => unknown[] | Promise<unknown[]>; key?: (item: unknown) => string; consecutiveEmpty?: number; maxRounds?: number },
|
|
6
|
+
ctx: HelperCtx,
|
|
7
|
+
): Promise<unknown[]> {
|
|
8
|
+
const consecutiveEmpty = opts.consecutiveEmpty ?? 2;
|
|
9
|
+
const maxRounds = opts.maxRounds ?? 50;
|
|
10
|
+
const key = opts.key ?? ((item: unknown) => JSON.stringify(item));
|
|
11
|
+
const seen = new Set<string>();
|
|
12
|
+
const acc: unknown[] = [];
|
|
13
|
+
let emptyStreak = 0;
|
|
14
|
+
for (let n = 0; n < maxRounds; n++) {
|
|
15
|
+
const items = await opts.round(n);
|
|
16
|
+
const fresh = (Array.isArray(items) ? items : []).filter((it) => { const k = key(it); if (seen.has(k)) return false; seen.add(k); return true; });
|
|
17
|
+
for (const f of fresh) acc.push(f);
|
|
18
|
+
if (fresh.length === 0) { emptyStreak++; if (emptyStreak >= consecutiveEmpty) break; } else emptyStreak = 0;
|
|
19
|
+
}
|
|
20
|
+
return acc;
|
|
21
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { HelperCtx } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/** SPEC-6-3 §3.3 — retry a thunk up to `attempts`; `until` decides when to stop. */
|
|
4
|
+
export async function retry(
|
|
5
|
+
thunk: (attempt: number) => unknown | Promise<unknown>,
|
|
6
|
+
opts: { attempts?: number; until?: (result: unknown) => boolean } = {},
|
|
7
|
+
ctx: HelperCtx,
|
|
8
|
+
): Promise<unknown> {
|
|
9
|
+
const attempts = opts.attempts ?? 3;
|
|
10
|
+
const until = opts.until ?? (() => true);
|
|
11
|
+
let last: unknown;
|
|
12
|
+
for (let n = 0; n < attempts; n++) {
|
|
13
|
+
try { last = await thunk(n); } catch { continue; } // recoverable: try again
|
|
14
|
+
if (until(last)) return last;
|
|
15
|
+
}
|
|
16
|
+
return last;
|
|
17
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { WorkflowJournal } from "../journal.ts";
|
|
2
|
+
|
|
3
|
+
export interface HelperSpawnResult {
|
|
4
|
+
finalText: string;
|
|
5
|
+
runId: string;
|
|
6
|
+
status: "completed" | "failed";
|
|
7
|
+
costTotal?: number;
|
|
8
|
+
tokenTotal?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface HelperCtx {
|
|
12
|
+
spawn: (prompt: string, opts?: { agent?: string; tier?: string; model?: string; skills?: string[]; backend?: "pi" | "claude"; retries?: number; timeoutMs?: number }) => Promise<HelperSpawnResult | null>;
|
|
13
|
+
journal: WorkflowJournal;
|
|
14
|
+
runId: string;
|
|
15
|
+
budget?: { spent: () => number; remaining: () => number };
|
|
16
|
+
onCheckpoint?: (prompt: string, opts: Record<string, unknown>) => Promise<unknown>;
|
|
17
|
+
getModelContextWindow?: (model: string) => number | undefined;
|
|
18
|
+
nextCallIndex: () => number;
|
|
19
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { HelperCtx } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
const REAL_RE = /\b(real|valid|correct|true|confirmed|legit)\b/i;
|
|
4
|
+
|
|
5
|
+
function judgeVote(text: string): { real: boolean; reason?: string } {
|
|
6
|
+
return { real: REAL_RE.test(text), reason: text.slice(0, 200) };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** SPEC-6-3 §3.3 — N reviewers vote; real = realCount/total >= threshold (default 0.5). */
|
|
10
|
+
export async function verify(
|
|
11
|
+
item: unknown,
|
|
12
|
+
opts: { reviewers?: number; threshold?: number; lens?: string | string[]; tier?: string; model?: string; skills?: string[]; backend?: "pi" | "claude"; retries?: number; timeoutMs?: number } = {},
|
|
13
|
+
ctx: HelperCtx,
|
|
14
|
+
): Promise<{ real: boolean; realCount: number; total: number; votes: Array<{ real: boolean; reason?: string }> }> {
|
|
15
|
+
const reviewers = opts.reviewers ?? 2;
|
|
16
|
+
const threshold = opts.threshold ?? 0.5;
|
|
17
|
+
const lens = opts.lens ? ` Focus lens: ${Array.isArray(opts.lens) ? opts.lens.join(", ") : opts.lens}.` : "";
|
|
18
|
+
const prompt = `You are an independent reviewer. Decide if the following item is REAL/valid.\nItem: ${JSON.stringify(item)}${lens}\nRespond with "real" or "fake" + a one-line reason.`;
|
|
19
|
+
const votes: Array<{ real: boolean; reason?: string }> = [];
|
|
20
|
+
for (let i = 0; i < reviewers; i++) {
|
|
21
|
+
const res = await ctx.spawn(prompt, { agent: "reviewer", ...(opts.tier ? { tier: opts.tier } : {}), ...(opts.model ? { model: opts.model } : {}), ...(opts.skills ? { skills: opts.skills } : {}), ...(opts.backend ? { backend: opts.backend } : {}), ...(opts.retries ? { retries: opts.retries } : {}), ...(opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {}) });
|
|
22
|
+
if (!res || res.status !== "completed") { votes.push({ real: false, reason: "reviewer failed" }); continue; }
|
|
23
|
+
votes.push(judgeVote(res.finalText));
|
|
24
|
+
}
|
|
25
|
+
const realCount = votes.filter((v) => v.real).length;
|
|
26
|
+
return { real: realCount / reviewers >= threshold, realCount, total: reviewers, votes };
|
|
27
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// SPEC-6-3 — the per-workflow positional-call-index journal.
|
|
2
|
+
// Append-only JSONL at <dir>/<runId>.jsonl. Crash-safe: a partial last line is discarded.
|
|
3
|
+
// Separate from RunLog (per-agent conversations/) and RunJournal (per-lifecycle runs/).
|
|
4
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
export interface WorkflowStartedEvent { type: "wf:started"; runId: string; script: string; args?: unknown; phases?: { title: string }[]; mode: "auto" | "checkpointed"; ts: number; }
|
|
8
|
+
export interface AgentCallEvent { type: "agent:call"; callIndex: number; label: string; phase: string; prompt: string; opts: Record<string, unknown>; childRunId?: string; ts: number; }
|
|
9
|
+
export interface AgentResultEvent { type: "agent:result"; callIndex: number; childRunId: string; result: unknown; status: "completed" | "failed"; costTotal?: number; tokenTotal?: number; ts: number; }
|
|
10
|
+
export interface HelperCallEvent { type: "helper:call"; callIndex: number; name: string; args: unknown; ts: number; }
|
|
11
|
+
export interface HelperResultEvent { type: "helper:result"; callIndex: number; name: string; result: unknown; ts: number; }
|
|
12
|
+
export interface CheckpointEvent { type: "checkpoint"; callIndex: number; prompt: string; response: unknown; ts: number; }
|
|
13
|
+
export interface WorkflowCompletedEvent { type: "wf:completed"; runId: string; result: unknown; costTotal?: number; tokenTotal?: number; ts: number; }
|
|
14
|
+
export interface WorkflowAbortedEvent { type: "wf:aborted"; runId: string; reason: string; ts: number; }
|
|
15
|
+
|
|
16
|
+
export interface WorkflowProgressJournalEvent {
|
|
17
|
+
type: "wf:progress";
|
|
18
|
+
kind: "started" | "phase" | "child-started" | "child-completed" | "child-failed" | "helper-started" | "helper-completed" | "log" | "checkpoint" | "checkpoint-resolved" | "completed" | "failed" | "aborted";
|
|
19
|
+
runId: string;
|
|
20
|
+
status: string;
|
|
21
|
+
currentPhase: string;
|
|
22
|
+
phases: Array<{ title: string; agents: number; cached: number; reRun: number }>;
|
|
23
|
+
childRunIds: string[];
|
|
24
|
+
logs: string[];
|
|
25
|
+
tokenTotal: number;
|
|
26
|
+
costTotal: number;
|
|
27
|
+
checkpoint?: { prompt: string; opts: Record<string, unknown> };
|
|
28
|
+
ts: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type WorkflowJournalEvent =
|
|
32
|
+
| WorkflowStartedEvent | AgentCallEvent | AgentResultEvent | HelperCallEvent
|
|
33
|
+
| HelperResultEvent | CheckpointEvent | WorkflowCompletedEvent | WorkflowAbortedEvent
|
|
34
|
+
| WorkflowProgressJournalEvent;
|
|
35
|
+
|
|
36
|
+
const WORKFLOW_TERMINAL = new Set<WorkflowJournalEvent["type"]>(["wf:completed", "wf:aborted"]);
|
|
37
|
+
|
|
38
|
+
export class WorkflowJournal {
|
|
39
|
+
constructor(private readonly dir: string) {}
|
|
40
|
+
|
|
41
|
+
private file(runId: string): string { return join(this.dir, `${runId}.jsonl`); }
|
|
42
|
+
|
|
43
|
+
append(runId: string, event: WorkflowJournalEvent): void {
|
|
44
|
+
try {
|
|
45
|
+
mkdirSync(this.dir, { recursive: true });
|
|
46
|
+
appendFileSync(this.file(runId), JSON.stringify(event) + "\n", "utf8");
|
|
47
|
+
} catch {
|
|
48
|
+
// best-effort: never fail the workflow because the journal couldn't persist.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
replay(runId: string): WorkflowJournalEvent[] {
|
|
53
|
+
const f = this.file(runId);
|
|
54
|
+
if (!existsSync(f)) return [];
|
|
55
|
+
const events: WorkflowJournalEvent[] = [];
|
|
56
|
+
for (const line of readFileSync(f, "utf8").split("\n")) {
|
|
57
|
+
if (!line) continue;
|
|
58
|
+
try { events.push(JSON.parse(line) as WorkflowJournalEvent); }
|
|
59
|
+
catch { /* partial last line (crash mid-append) — discard */ }
|
|
60
|
+
}
|
|
61
|
+
return events;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
scanNonTerminal(): string[] {
|
|
65
|
+
if (!existsSync(this.dir)) return [];
|
|
66
|
+
const ids: string[] = [];
|
|
67
|
+
for (const f of readdirSync(this.dir)) {
|
|
68
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
69
|
+
const runId = f.slice(0, -".jsonl".length);
|
|
70
|
+
const events = this.replay(runId);
|
|
71
|
+
const last = events[events.length - 1];
|
|
72
|
+
if (last && !WORKFLOW_TERMINAL.has(last.type)) ids.push(runId);
|
|
73
|
+
}
|
|
74
|
+
return ids;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// SPEC-6-3 §9 — bounded keyword authorization for workflow capability.
|
|
2
|
+
// Returns a bounded system hint when the prompt mentions "workflow"/"workflows"
|
|
3
|
+
// as a standalone word (not as part of an identifier or file path).
|
|
4
|
+
|
|
5
|
+
const WORKFLOW_KEYWORD = /(?:^|[^A-Za-z0-9_])workflows?(?=$|[^A-Za-z0-9_])/i
|
|
6
|
+
|
|
7
|
+
export function workflowKeywordHint(prompt: string): string | undefined {
|
|
8
|
+
const match = prompt.match(WORKFLOW_KEYWORD)
|
|
9
|
+
if (!match) return undefined
|
|
10
|
+
|
|
11
|
+
const fullMatch = match[0]!
|
|
12
|
+
// If the match includes a preceding non-word char, check if it's a path separator.
|
|
13
|
+
const firstChar = fullMatch[0]!
|
|
14
|
+
if (firstChar === "/" || firstChar === ".") return undefined
|
|
15
|
+
|
|
16
|
+
// Check the character after "workflow" in the original string.
|
|
17
|
+
const afterIdx = (match.index ?? 0) + fullMatch.length
|
|
18
|
+
const afterChar = prompt[afterIdx]
|
|
19
|
+
if (afterChar === "-" || afterChar === ".") return undefined
|
|
20
|
+
|
|
21
|
+
return "workflow capability authorized — use action:'workflow' with a script or workflowName"
|
|
22
|
+
}
|