Skip to content

Commit cc7aec6

Browse files
DylanMerigaudclaude
andcommitted
Phase 3: multi-instruction edit agent (plan → apply → validate → self-correct)
The real agentic layer. A single message can ask for SEVERAL changes; the agent plans an ORDERED list of ops, applies them in order, validates the result with the validator (the tool), and on errors feeds them back for a correction round — returning one combined diff to approve. This is what makes "agent" honest: it plans a sequence of actions and checks its work with a tool, not one stateless guess. - lib/workflow-edit-agent.ts: runEditAgent(model, current, instruction) — bounded loop (max 4 steps), pure orchestration, model injected. Returns { proposed, ops, changes, issues, reason }. - lib/workflow-edit.ts: WorkflowEditPlan = { ops: WorkflowEditOp[] } (flat array, no maxItems — the grammar-limit lesson) + planPrompt that includes prior-attempt validation issues as correction feedback. - lib/workflow-edit-model.ts: anthropicPlanModel.planOps (Sonnet, structured output) alongside the existing single-op model. - route: /api/workflow/edit now drives runEditAgent (same { proposed, changes, reason } response, so the editor is drop-in). - eval/edit-agent-*: scores the OUTCOME (final workflow validates clean + enough ops for a multi-part ask). Dry-run stubs (free) + live. Verified: agent eval 3/3 LIVE (incl. a 3-part instruction → 3 ops, and a parallel AND-join), and one live UI multi-edit (Slack notif + a controller AND-join sign-off → 5 changes, validator flags the new gate's unresolved approver). All green: typecheck, lint (0/0), knip, build, 174 tests, prettier, edit-agent eval 3/3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c36b8a0 commit cc7aec6

8 files changed

Lines changed: 524 additions & 6 deletions

File tree

app/api/workflow/edit/route.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { z } from "zod";
22

33
import { ApprovalWorkflow } from "@/lib/approval-workflow";
44
import { checkRateLimit, clientIpFrom } from "@/lib/ratelimit";
5-
import { proposeEdit } from "@/lib/workflow-edit";
6-
import { anthropicEditModel } from "@/lib/workflow-edit-model";
5+
import { runEditAgent } from "@/lib/workflow-edit-agent";
6+
import { anthropicPlanModel } from "@/lib/workflow-edit-model";
77

88
/**
99
* POST /api/workflow/edit — propose a conversational edit to an approval workflow.
@@ -49,13 +49,13 @@ export const POST = async (request: Request): Promise<Response> => {
4949
}
5050

5151
try {
52-
const { proposed, op, changes } = await proposeEdit(
53-
anthropicEditModel,
52+
// The agent plans an ordered list of ops, applies them, and self-corrects
53+
// against the validator until the workflow is sound (or its step budget).
54+
const { proposed, changes, reason } = await runEditAgent(
55+
anthropicPlanModel,
5456
workflow,
5557
instruction,
5658
);
57-
// `op.reason` (only present on a `none`) explains why nothing changed.
58-
const reason = op.op === "none" ? op.reason : null;
5959
return Response.json({ proposed, changes, reason });
6060
} catch {
6161
// A validation failure (the model produced an invalid graph) or a model error

eval/edit-agent-cases.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { EDIT_FIXTURE } from "@/eval/edit-cases";
2+
import type { WorkflowEditOp } from "@/lib/workflow-edit";
3+
4+
/**
5+
* Corpus for the EDIT-AGENT eval. Unlike the single-op edit eval (which scores the
6+
* chosen op kind), this scores the OUTCOME of the multi-instruction agent: after it
7+
* plans + applies + self-corrects, does the final workflow VALIDATE CLEAN, and did
8+
* it dispatch the expected number of ops for a multi-part instruction.
9+
*/
10+
11+
export { EDIT_FIXTURE };
12+
13+
export type AgentCase = {
14+
id: string;
15+
instruction: string;
16+
/** Minimum real (non-none) ops we expect for this instruction. */
17+
minOps: number;
18+
why: string;
19+
/** Dry-run stub: the op list a correct plan would return (exercises the loop). */
20+
stub: WorkflowEditOp[];
21+
};
22+
23+
export const AGENT_CASES: AgentCase[] = [
24+
{
25+
id: "single-add",
26+
instruction: "Above $50,000, also require CFO approval",
27+
minOps: 1,
28+
why: "one gate added; result still sound",
29+
stub: [
30+
{
31+
op: "add-approval",
32+
label: "CFO review",
33+
approverTitle: "CFO",
34+
amountOver: 50000,
35+
department: null,
36+
},
37+
],
38+
},
39+
{
40+
id: "multi-three",
41+
instruction:
42+
"Add a CFO approval over $50k, send a Slack message when a bill posts, and open a Jira ticket for IT bills",
43+
minOps: 3,
44+
why: "three independent changes dispatched in one go",
45+
stub: [
46+
{
47+
op: "add-approval",
48+
label: "CFO review",
49+
approverTitle: "CFO",
50+
amountOver: 50000,
51+
department: null,
52+
},
53+
{ op: "add-integration", label: "Notify on Slack", integration: "slack" },
54+
{ op: "add-integration", label: "Open Jira ticket", integration: "jira" },
55+
],
56+
},
57+
{
58+
id: "parallel-then-join",
59+
instruction:
60+
"After the manager and director reviews, add a final controller sign-off that waits for both",
61+
minOps: 1,
62+
why: "an AND-join gate after two steps (add-parallel-after)",
63+
stub: [
64+
{
65+
op: "add-parallel-after",
66+
afterStepIds: ["manager-review", "director-review"],
67+
label: "Controller sign-off",
68+
approverTitle: "Controller",
69+
amountOver: null,
70+
department: null,
71+
},
72+
],
73+
},
74+
];

eval/edit-agent-run.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* Edit-AGENT eval — `tsx eval/edit-agent-run.ts [--dry-run]`.
3+
*
4+
* Proves the multi-instruction agent end to end: it plans an ordered op list,
5+
* applies it, self-corrects against the validator, and the FINAL workflow validates
6+
* clean. Scores the outcome (sound result + enough ops for a multi-part ask), not
7+
* the individual op kinds (that's the single-op edit eval's job).
8+
*
9+
* `--dry-run` (CI) drives `runEditAgent` with a fake planner returning each case's
10+
* stub ops — exercises the apply+validate loop with ZERO API calls. Live (no flag)
11+
* uses the real Sonnet planner; needs ANTHROPIC_API_KEY.
12+
*/
13+
import { join } from "node:path";
14+
15+
import {
16+
AGENT_CASES,
17+
EDIT_FIXTURE,
18+
type AgentCase,
19+
} from "@/eval/edit-agent-cases";
20+
import {
21+
runEditAgent,
22+
type PlanModel,
23+
type AgentEditResult,
24+
} from "@/lib/workflow-edit-agent";
25+
import { validateWorkflow, isActivatable } from "@/lib/workflow-validate";
26+
27+
const dryRun = process.argv.includes("--dry-run");
28+
29+
const loadEnv = (): void => {
30+
for (const f of [".env.local", ".env"]) {
31+
try {
32+
process.loadEnvFile(join(process.cwd(), f));
33+
} catch {
34+
/* absent — fine */
35+
}
36+
}
37+
};
38+
39+
/** A fake planner that returns the case's stub once, then nothing (loop ends). */
40+
const stubModel = (c: AgentCase): PlanModel => {
41+
let called = false;
42+
return {
43+
planOps: () => {
44+
if (called) return Promise.resolve([]);
45+
called = true;
46+
return Promise.resolve(c.stub);
47+
},
48+
};
49+
};
50+
51+
type Row = { id: string; pass: boolean; detail: string };
52+
53+
const score = (c: AgentCase, r: AgentEditResult): Row => {
54+
const realOps = r.ops.filter((o) => o.op !== "none").length;
55+
const sound = isActivatable(r.issues);
56+
const enoughOps = realOps >= c.minOps;
57+
const pass = sound && enoughOps;
58+
return {
59+
id: c.id,
60+
pass,
61+
detail: `${realOps} op(s), ${sound ? "sound" : `${r.issues.filter((i) => i.severity === "error").length} error(s)`}`,
62+
};
63+
};
64+
65+
const main = async (): Promise<void> => {
66+
loadEnv();
67+
console.log(`edit-agent eval — ${dryRun ? "dry-run (no API)" : "live"}\n`);
68+
69+
if (!dryRun && !process.env.ANTHROPIC_API_KEY) {
70+
console.error(
71+
"✖ Live mode needs ANTHROPIC_API_KEY. Use --dry-run offline.",
72+
);
73+
process.exit(1);
74+
}
75+
76+
// The real planner loads lib/env; import it only for a live run.
77+
let liveModel: PlanModel | null = null;
78+
if (!dryRun) {
79+
const mod = await import("@/lib/workflow-edit-model");
80+
liveModel = mod.anthropicPlanModel;
81+
}
82+
83+
// The fixture is sound to start (sanity) so any final error is the agent's doing.
84+
if (!isActivatable(validateWorkflow(EDIT_FIXTURE))) {
85+
console.error("✖ The eval fixture itself isn't sound — fix the fixture.");
86+
process.exit(1);
87+
}
88+
89+
const rows: Row[] = [];
90+
for (const c of AGENT_CASES) {
91+
const model = dryRun ? stubModel(c) : (liveModel ?? stubModel(c));
92+
const result = await runEditAgent(model, EDIT_FIXTURE, c.instruction);
93+
const row = score(c, result);
94+
rows.push(row);
95+
console.log(
96+
`${row.pass ? "✓" : "✗"} ${row.id}${row.detail} (${c.why})`,
97+
);
98+
}
99+
100+
const passed = rows.filter((r) => r.pass).length;
101+
console.log(`\n${passed}/${rows.length} sound`);
102+
if (passed !== rows.length) process.exit(1);
103+
console.log("✓ Every instruction produced a sound workflow.");
104+
};
105+
106+
void main();

lib/workflow-edit-agent.test.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import assert from "node:assert/strict";
2+
import { test } from "node:test";
3+
4+
import type { ApprovalWorkflow as TWorkflow } from "@/lib/approval-workflow";
5+
import type { WorkflowEditOp } from "@/lib/workflow-edit";
6+
import { runEditAgent, type PlanModel } from "@/lib/workflow-edit-agent";
7+
8+
/**
9+
* The agent's ORCHESTRATION is what's tested here (the model is faked): it applies a
10+
* planned op list in order, validates with the validator, and on errors feeds them
11+
* back for a correction pass. The model itself (op selection) is exercised live in
12+
* the eval.
13+
*/
14+
15+
const base: TWorkflow = {
16+
name: "base",
17+
roots: ["manager"],
18+
steps: [
19+
{
20+
id: "manager",
21+
kind: "approval",
22+
label: "Manager review",
23+
when: { kind: "always" },
24+
approverTitle: "Manager",
25+
approverName: "Riley Carter",
26+
next: ["post"],
27+
},
28+
{
29+
id: "post",
30+
kind: "integration",
31+
label: "Post to NetSuite",
32+
when: { kind: "always" },
33+
integration: "netsuite",
34+
next: [],
35+
},
36+
],
37+
};
38+
39+
test("dispatches a multi-op plan in order (one round)", async () => {
40+
const model: PlanModel = {
41+
planOps: () =>
42+
Promise.resolve<WorkflowEditOp[]>([
43+
{
44+
op: "add-approval",
45+
label: "CFO review",
46+
approverTitle: "CFO",
47+
amountOver: 50000,
48+
department: null,
49+
},
50+
{
51+
op: "add-integration",
52+
label: "Notify on Slack",
53+
integration: "slack",
54+
},
55+
]),
56+
};
57+
const { proposed, ops, changes } = await runEditAgent(
58+
model,
59+
base,
60+
"add a CFO gate over 50k and a Slack notice",
61+
);
62+
assert.equal(ops.length, 2, "both ops applied");
63+
assert.ok(proposed.steps.some((s) => s.label === "CFO review"));
64+
assert.ok(proposed.steps.some((s) => s.label === "Notify on Slack"));
65+
assert.ok(changes.some((c) => c.kind === "added"));
66+
});
67+
68+
test("on an erroring plan, it re-plans with the validator's errors as feedback", async () => {
69+
// The defining agentic behaviour: if the first plan leaves the workflow with a
70+
// validation ERROR, the agent calls the planner AGAIN and hands it those errors so
71+
// it can correct. (We assert the feedback contract — the loop's correction round.)
72+
let sawFeedbackError = false;
73+
let call = 0;
74+
const model: PlanModel = {
75+
planOps: ({ feedback }) => {
76+
call++;
77+
if (feedback?.issues.some((i) => i.severity === "error"))
78+
sawFeedbackError = true;
79+
if (call === 1) {
80+
// remove the post → "no-post" / "post-not-reached" error
81+
return Promise.resolve<WorkflowEditOp[]>([
82+
{ op: "remove-step", stepId: "post" },
83+
]);
84+
}
85+
return Promise.resolve<WorkflowEditOp[]>([]); // give up on the correction
86+
},
87+
};
88+
await runEditAgent(model, base, "tidy up");
89+
assert.equal(call >= 2, true, "it ran a correction round");
90+
assert.equal(
91+
sawFeedbackError,
92+
true,
93+
"the errors were fed back to the planner",
94+
);
95+
});
96+
97+
test("returns a reason when the plan is all no-ops (no change)", async () => {
98+
const model: PlanModel = {
99+
planOps: () =>
100+
Promise.resolve<WorkflowEditOp[]>([
101+
{ op: "none", reason: "already does that" },
102+
]),
103+
};
104+
const { changes, reason } = await runEditAgent(
105+
model,
106+
base,
107+
"do nothing useful",
108+
);
109+
assert.equal(
110+
changes.filter((c) => c.kind !== "unchanged").length,
111+
0,
112+
"no real change",
113+
);
114+
assert.match(reason ?? "", /already does that/);
115+
});
116+
117+
test("stops at the step budget on a stubborn error (doesn't hang)", async () => {
118+
// The model keeps removing the post (always leaving a no-post error) — the loop
119+
// must terminate at the budget rather than spin forever.
120+
let calls = 0;
121+
const model: PlanModel = {
122+
planOps: () => {
123+
calls++;
124+
return Promise.resolve<WorkflowEditOp[]>([
125+
{ op: "remove-step", stepId: "post" },
126+
]);
127+
},
128+
};
129+
const { issues } = await runEditAgent(model, base, "break it");
130+
assert.ok(calls <= 4, "bounded to the step budget");
131+
// It returns (doesn't hang); the remaining error is surfaced.
132+
assert.ok(issues.some((i) => i.severity === "error"));
133+
});

0 commit comments

Comments
 (0)