agency-lang 0.6.3 → 0.6.5

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 (69) hide show
  1. package/dist/lib/agents/docs/appendix/appendix/callbacks.md +15 -0
  2. package/dist/lib/agents/docs/appendix/callbacks.md +15 -0
  3. package/dist/lib/agents/docs/guide/guide/llm.md +21 -1
  4. package/dist/lib/agents/docs/guide/llm.md +21 -1
  5. package/dist/lib/cli/eval/run.d.ts +18 -1
  6. package/dist/lib/cli/eval/run.js +55 -24
  7. package/dist/lib/cli/eval/run.workdir.test.d.ts +1 -0
  8. package/dist/lib/cli/eval/run.workdir.test.js +111 -0
  9. package/dist/lib/eval/runArtifacts.js +4 -10
  10. package/dist/lib/eval/runArtifacts.test.js +10 -22
  11. package/dist/lib/eval/runEvalInput.d.ts +11 -3
  12. package/dist/lib/eval/runEvalInput.js +16 -1
  13. package/dist/lib/eval/runTypes.d.ts +0 -4
  14. package/dist/lib/eval/runWorkdir.d.ts +25 -0
  15. package/dist/lib/eval/runWorkdir.js +46 -0
  16. package/dist/lib/eval/runWorkdir.test.d.ts +1 -0
  17. package/dist/lib/eval/runWorkdir.test.js +42 -0
  18. package/dist/lib/optimize/artifacts.js +8 -35
  19. package/dist/lib/optimize/artifacts.test.js +8 -4
  20. package/dist/lib/optimize/baseOptimizer.d.ts +15 -9
  21. package/dist/lib/optimize/baseOptimizer.js +24 -21
  22. package/dist/lib/optimize/baseOptimizer.test.js +21 -14
  23. package/dist/lib/optimize/baseOptimizer.workdir.test.d.ts +1 -0
  24. package/dist/lib/optimize/baseOptimizer.workdir.test.js +82 -0
  25. package/dist/lib/optimize/optimizers/example.test.js +0 -1
  26. package/dist/lib/optimize/optimizers/gepa.d.ts +1 -1
  27. package/dist/lib/optimize/optimizers/gepa.js +7 -10
  28. package/dist/lib/optimize/optimizers/gepa.test.js +3 -4
  29. package/dist/lib/optimize/optimizers/greedyReflective.d.ts +1 -1
  30. package/dist/lib/optimize/optimizers/greedyReflective.js +5 -7
  31. package/dist/lib/optimize/optimizers/greedyReflective.test.js +0 -1
  32. package/dist/lib/optimize/targets.d.ts +5 -0
  33. package/dist/lib/optimize/targets.js +13 -0
  34. package/dist/lib/optimize/workspace.d.ts +8 -30
  35. package/dist/lib/optimize/workspace.js +5 -78
  36. package/dist/lib/optimize/workspace.test.js +9 -70
  37. package/dist/lib/runtime/agencyLlm.d.ts +8 -1
  38. package/dist/lib/runtime/agencyLlm.js +10 -0
  39. package/dist/lib/runtime/errors.d.ts +3 -0
  40. package/dist/lib/runtime/errors.test.js +8 -0
  41. package/dist/lib/runtime/hooks.d.ts +12 -0
  42. package/dist/lib/runtime/llmClient.d.ts +46 -0
  43. package/dist/lib/runtime/llmClient.js +44 -0
  44. package/dist/lib/runtime/llmClient.test.d.ts +1 -0
  45. package/dist/lib/runtime/llmClient.test.js +33 -0
  46. package/dist/lib/runtime/llmRetry.d.ts +65 -0
  47. package/dist/lib/runtime/llmRetry.js +158 -0
  48. package/dist/lib/runtime/llmRetry.test.d.ts +1 -0
  49. package/dist/lib/runtime/llmRetry.test.js +94 -0
  50. package/dist/lib/runtime/prompt.d.ts +49 -1
  51. package/dist/lib/runtime/prompt.js +157 -7
  52. package/dist/lib/runtime/prompt.test.js +170 -1
  53. package/dist/lib/stdlib/llm.d.ts +6 -1
  54. package/dist/lib/stdlib/version.d.ts +1 -1
  55. package/dist/lib/stdlib/version.js +1 -1
  56. package/dist/lib/typeChecker/builtins.js +22 -0
  57. package/dist/lib/typeChecker/capabilities.test.d.ts +1 -0
  58. package/dist/lib/typeChecker/capabilities.test.js +74 -0
  59. package/dist/lib/types/function.d.ts +1 -1
  60. package/dist/lib/types/function.js +2 -0
  61. package/dist/lib/types/function.test.d.ts +1 -0
  62. package/dist/lib/types/function.test.js +11 -0
  63. package/dist/lib/utils/projectTree.d.ts +13 -0
  64. package/dist/lib/utils/projectTree.js +42 -0
  65. package/dist/lib/utils/projectTree.test.d.ts +1 -0
  66. package/dist/lib/utils/projectTree.test.js +49 -0
  67. package/package.json +2 -2
  68. package/stdlib/capabilities.agency +60 -0
  69. package/stdlib/capabilities.js +172 -0
@@ -98,6 +98,21 @@ Called after an LLM call completes. You can return a `MessageJSON[]` array to ov
98
98
  - `timeTaken`: how long the call took in milliseconds
99
99
  - `messages`: the messages that were sent
100
100
 
101
+ ### onLLMRetry
102
+ Called just before the backend waits to retry an LLM call after a transient failure (see [retries and timeouts](../guide/llm.md#resilience-retries-and-timeouts)). Side-effect only — it cannot change whether the retry happens.
103
+
104
+ - `attempt`: the 1-based retry number (retry 1, 2, …)
105
+ - `maxRetries`: the configured retry count
106
+ - `delayMs`: how long the backend will wait before this retry
107
+ - `reason`: why we're retrying — `"timeout"`, `"connectionLost"`, `"streamInterrupted"`, `"rateLimit"`, `"serverError"`, or `"overloaded"`
108
+ - `detail`: the raw provider message
109
+
110
+ ### onLLMTimeout
111
+ Called whenever an LLM call exceeds its per-call deadline (`timeout`), whether or not a retry follows.
112
+
113
+ - `limitMs`: the deadline that was exceeded
114
+ - `attempt`: the 0-based attempt that timed out
115
+
101
116
  ### onFunctionStart
102
117
  Called when a function (tool) begins executing.
103
118
 
@@ -98,6 +98,21 @@ Called after an LLM call completes. You can return a `MessageJSON[]` array to ov
98
98
  - `timeTaken`: how long the call took in milliseconds
99
99
  - `messages`: the messages that were sent
100
100
 
101
+ ### onLLMRetry
102
+ Called just before the backend waits to retry an LLM call after a transient failure (see [retries and timeouts](../guide/llm.md#resilience-retries-and-timeouts)). Side-effect only — it cannot change whether the retry happens.
103
+
104
+ - `attempt`: the 1-based retry number (retry 1, 2, …)
105
+ - `maxRetries`: the configured retry count
106
+ - `delayMs`: how long the backend will wait before this retry
107
+ - `reason`: why we're retrying — `"timeout"`, `"connectionLost"`, `"streamInterrupted"`, `"rateLimit"`, `"serverError"`, or `"overloaded"`
108
+ - `detail`: the raw provider message
109
+
110
+ ### onLLMTimeout
111
+ Called whenever an LLM call exceeds its per-call deadline (`timeout`), whether or not a retry follows.
112
+
113
+ - `limitMs`: the deadline that was exceeded
114
+ - `attempt`: the 0-based attempt that timed out
115
+
101
116
  ### onFunctionStart
102
117
  Called when a function (tool) begins executing.
103
118
 
@@ -177,4 +177,24 @@ def myTool() {
177
177
 
178
178
  It does **not** work at module top level, inside a `callback(...)` registration block, or inside the `onAgentStart` lifecycle hook — those scopes run before any agent has started and have no conversation to append to. If you call `llm` from one of them you'll get a runtime error like *"Message threads are not available in this scope."* Move the call inside a `node` or `def` body to fix it.
179
179
 
180
- See [Message history and threads](./message-history-and-threads.md) for the full picture.
180
+ See [Message history and threads](./message-history-and-threads.md) for the full picture.
181
+ ## Resilience: retries and timeouts
182
+
183
+ Transient LLM failures — a dropped connection, a `429` rate limit, a `5xx`, or a call that simply hangs — are common and usually self-heal. `llm()` retries them automatically with exponential backoff and an optional per-call deadline, so your happy path stays clean and you don't have to check every call.
184
+
185
+ ```ts
186
+ llm("Summarize this", {
187
+ retries: 2, // max retry attempts (default 2; 0 disables)
188
+ timeout: 30s, // per-attempt deadline (default 10min; 0 disables)
189
+ backoff: { initial: 500ms, factor: 2, max: 10s }, // exponential, capped (these are the defaults)
190
+ })
191
+ ```
192
+
193
+ - **What's retried:** connection drops (ECONNRESET, fetch failed, …), `5xx`, `429` (honoring the server's `retry-after`), and `529` overloaded. A `429`'s `retry-after` overrides the computed backoff.
194
+ - **What's not:** `400`/auth/content-policy/context-window errors (terminal — no point retrying), and user cancels / guard trips (those propagate immediately and are never swallowed by the retry loop). A guard's time budget keeps ticking through backoff, so a `guard(time:)` still wins.
195
+ - **After retries exhaust** (or with `retries: 0`), the call surfaces a normal `Failure` you can handle with `try` / `isFailure` — it does not abort the run.
196
+ - **Cancellation:** pressing Esc during a backoff wait cancels the whole loop.
197
+
198
+ Set defaults for a whole branch with `setLlmOptions({ retries, timeout, backoff })` (per-call options still win). Classification is provider-neutral — it reads HTTP status from the LLM client adapter, so a custom (non-smoltalk) client works too, falling back to message matching.
199
+
200
+ To be notified of retries/timeouts (for a status line, logging, etc.), use the [`onLLMRetry` and `onLLMTimeout` callbacks](../appendix/callbacks.md).
@@ -177,4 +177,24 @@ def myTool() {
177
177
 
178
178
  It does **not** work at module top level, inside a `callback(...)` registration block, or inside the `onAgentStart` lifecycle hook — those scopes run before any agent has started and have no conversation to append to. If you call `llm` from one of them you'll get a runtime error like *"Message threads are not available in this scope."* Move the call inside a `node` or `def` body to fix it.
179
179
 
180
- See [Message history and threads](./message-history-and-threads.md) for the full picture.
180
+ See [Message history and threads](./message-history-and-threads.md) for the full picture.
181
+ ## Resilience: retries and timeouts
182
+
183
+ Transient LLM failures — a dropped connection, a `429` rate limit, a `5xx`, or a call that simply hangs — are common and usually self-heal. `llm()` retries them automatically with exponential backoff and an optional per-call deadline, so your happy path stays clean and you don't have to check every call.
184
+
185
+ ```ts
186
+ llm("Summarize this", {
187
+ retries: 2, // max retry attempts (default 2; 0 disables)
188
+ timeout: 30s, // per-attempt deadline (default 10min; 0 disables)
189
+ backoff: { initial: 500ms, factor: 2, max: 10s }, // exponential, capped (these are the defaults)
190
+ })
191
+ ```
192
+
193
+ - **What's retried:** connection drops (ECONNRESET, fetch failed, …), `5xx`, `429` (honoring the server's `retry-after`), and `529` overloaded. A `429`'s `retry-after` overrides the computed backoff.
194
+ - **What's not:** `400`/auth/content-policy/context-window errors (terminal — no point retrying), and user cancels / guard trips (those propagate immediately and are never swallowed by the retry loop). A guard's time budget keeps ticking through backoff, so a `guard(time:)` still wins.
195
+ - **After retries exhaust** (or with `retries: 0`), the call surfaces a normal `Failure` you can handle with `try` / `isFailure` — it does not abort the run.
196
+ - **Cancellation:** pressing Esc during a backoff wait cancels the whole loop.
197
+
198
+ Set defaults for a whole branch with `setLlmOptions({ retries, timeout, backoff })` (per-call options still win). Classification is provider-neutral — it reads HTTP status from the LLM client adapter, so a custom (non-smoltalk) client works too, falling back to message matching.
199
+
200
+ To be notified of retries/timeouts (for a status line, logging, etc.), use the [`onLLMRetry` and `onLLMTimeout` callbacks](../appendix/callbacks.md).
@@ -20,10 +20,27 @@ export type EvalRunLoadedInputsOptions = {
20
20
  continueOnError?: boolean;
21
21
  verbose?: boolean;
22
22
  config?: AgencyConfig;
23
- /** Suppress compile progress lines for the agent compile. */
23
+ /** Suppress compile progress lines for the agent compile.
24
+ * (Currently unused — kept for API compatibility.) */
24
25
  quietCompile?: boolean;
25
26
  /** Pipe agent subprocess stdout/stderr through to the console. Defaults to true. */
26
27
  pipeAgentOutput?: boolean;
28
+ /**
29
+ * Workdir seed override. When set, used verbatim for every input — no
30
+ * closure-base derivation, no relpath computation. The optimizer always
31
+ * sets this from `source.baseDir` + `source.entryFile`, so the seed
32
+ * cannot silently diverge from the closure walk.
33
+ *
34
+ * Mutually exclusive with per-input `working_dir`: setting both is an
35
+ * error.
36
+ */
37
+ seed?: {
38
+ dir: string;
39
+ agentRelPath: string;
40
+ };
41
+ /** Candidate-file overlay applied to every input in this call (over the
42
+ * seeded copy, before compile). Plain eval never sets this. */
43
+ overlayFiles?: Record<string, string>;
27
44
  };
28
45
  export declare function resolveEvalRunTarget(target: string): {
29
46
  agentFile: string;
@@ -2,13 +2,12 @@ import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { fork } from "child_process";
4
4
  import { nanoid } from "nanoid";
5
- import { compile } from "../../cli/commands.js";
6
5
  import { parseTarget } from "../../cli/util.js";
7
- import { RunStrategy } from "../../importStrategy.js";
8
6
  import { StatelogParser } from "../../eval/statelogParser.js";
9
7
  import { loadInputs, inputFromGoal } from "../../eval/loadInputs.js";
10
8
  import { initializeEvalRun, writeEvalRunSummary, } from "../../eval/runArtifacts.js";
11
9
  import { runEvalInput, } from "../../eval/runEvalInput.js";
10
+ import { agentClosureBaseDir } from "../../optimize/targets.js";
12
11
  import { buildForkOptions, buildRunInstruction, subprocessBootstrapPath, } from "../../runtime/ipc.js";
13
12
  /**
14
13
  * Per-task resource limits for subprocess invocations driven by `agency eval
@@ -66,14 +65,13 @@ export async function evalRun(opts, overrides = {}) {
66
65
  }
67
66
  export async function evalRunLoadedInputs(opts, overrides = {}) {
68
67
  const target = resolveEvalRunTarget(opts.agent);
68
+ const absoluteAgent = fs.realpathSync(target.agentFile);
69
69
  const runsDir = path.resolve(opts.runsDir ?? opts.config?.eval?.runsDir ?? "runs");
70
70
  const runId = opts.runId ?? nanoid();
71
71
  const continueOnError = opts.continueOnError ?? true;
72
- const compiled = await compileAgentForEvalRun({
73
- config: opts.config ?? {},
74
- agentFile: target.agentFile,
75
- quiet: opts.quietCompile ?? false,
76
- });
72
+ const config = opts.config ?? {};
73
+ // One closure walk per call when no caller-supplied seed; never per input.
74
+ const defaultSeed = opts.seed ?? deriveSeedFromAgent(target.agentFile, absoluteAgent);
77
75
  const state = initializeEvalRun({
78
76
  runId,
79
77
  runsDir,
@@ -87,10 +85,33 @@ export async function evalRunLoadedInputs(opts, overrides = {}) {
87
85
  const extractor = overrides.extractor ?? defaultEvalRecordExtractor;
88
86
  const results = [];
89
87
  for (const input of opts.inputs) {
88
+ let seed;
89
+ try {
90
+ seed = resolveInputSeed(input, defaultSeed, absoluteAgent, opts.seed !== undefined);
91
+ }
92
+ catch (err) {
93
+ // Seed-resolution errors (working_dir misuse, seed/working_dir conflict)
94
+ // are per-input prepare failures — never escape `evalRunLoadedInputs`.
95
+ const message = err instanceof Error ? err.message : String(err);
96
+ console.error(`[evalRun] seed resolution failed for input ${input.id ?? "(no id)"}: ${message}`);
97
+ results.push({
98
+ inputId: input.id ?? "",
99
+ status: "error",
100
+ evalRecordPath: "",
101
+ statelogPath: "",
102
+ workdirPath: "",
103
+ errorMessage: message,
104
+ });
105
+ if (!continueOnError)
106
+ break;
107
+ continue;
108
+ }
90
109
  const result = await runEvalInput({
91
110
  state,
92
111
  input,
93
- compiled,
112
+ seed,
113
+ overlayFiles: opts.overlayFiles,
114
+ config,
94
115
  defaultNode: target.node,
95
116
  runner,
96
117
  extractor,
@@ -101,18 +122,31 @@ export async function evalRunLoadedInputs(opts, overrides = {}) {
101
122
  }
102
123
  return writeEvalRunSummary(state, results);
103
124
  }
104
- async function compileAgentForEvalRun(args) {
105
- const compiledPath = compile(args.config, args.agentFile, undefined, {
106
- importStrategy: new RunStrategy(),
107
- quiet: args.quiet,
108
- });
109
- if (compiledPath === null) {
110
- throw new Error(`Failed to compile ${args.agentFile}`);
125
+ /** Derive seed from agent file via closure walk. Called at most once per
126
+ * `evalRunLoadedInputs` invocation, never per input. */
127
+ function deriveSeedFromAgent(agentFile, absoluteAgent) {
128
+ const dir = agentClosureBaseDir(agentFile);
129
+ return { dir, agentRelPath: path.relative(dir, absoluteAgent) };
130
+ }
131
+ /** Apply the per-input seed rule:
132
+ * - `opts.seed` (callerSetSeed=true) + `input.working_dir` → conflict, throw.
133
+ * - `input.working_dir` only → validate (must be a directory; must contain the agent).
134
+ * - neither → use the default seed. */
135
+ function resolveInputSeed(input, defaultSeed, absoluteAgent, callerSetSeed) {
136
+ if (!input.working_dir)
137
+ return defaultSeed;
138
+ if (callerSetSeed) {
139
+ throw new Error(`input.working_dir cannot be combined with a caller-supplied seed (input id=${input.id ?? "(no id)"})`);
111
140
  }
112
- return {
113
- moduleId: path.basename(compiledPath, ".js"),
114
- path: compiledPath,
115
- };
141
+ const resolved = fs.realpathSync(path.resolve(input.working_dir));
142
+ if (!fs.statSync(resolved).isDirectory()) {
143
+ throw new Error(`Eval input working_dir is not a directory: ${input.working_dir}`);
144
+ }
145
+ const rel = path.relative(resolved, absoluteAgent);
146
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
147
+ throw new Error(`working_dir must contain the agent file (working_dir=${resolved}, agent=${absoluteAgent})`);
148
+ }
149
+ return { dir: resolved, agentRelPath: rel };
116
150
  }
117
151
  const defaultEvalRecordExtractor = async ({ statelogPath, outPath, }) => {
118
152
  const record = new StatelogParser(statelogPath).evalRecord();
@@ -132,12 +166,9 @@ export const optimizeEvalRecordExtractor = async ({ statelogPath, outPath, }) =>
132
166
  fs.writeFileSync(outPath, JSON.stringify(record, null, 2));
133
167
  };
134
168
  function makeSubprocessEvalInputRunner(pipeAgentOutput) {
135
- return async ({ compiled, node, args, cwd, statelogPath }) => {
136
- if (!compiled.path) {
137
- return { ok: false, errorMessage: "Compiled agent has no path" };
138
- }
169
+ return async ({ compiledEntryPath, node, args, cwd, statelogPath }) => {
139
170
  return runCompiledAgentInSubprocess({
140
- compiledPath: compiled.path,
171
+ compiledPath: compiledEntryPath,
141
172
  node,
142
173
  args,
143
174
  cwd,
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,111 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { evalRunLoadedInputs } from "./run.js";
6
+ describe("evalRunLoadedInputs compiles + runs inside each input workdir", () => {
7
+ let proj;
8
+ beforeEach(() => {
9
+ proj = fs.mkdtempSync(path.join(os.tmpdir(), "evalwd-"));
10
+ fs.writeFileSync(path.join(proj, "agent.agency"), "node main() { return 1 }\n");
11
+ });
12
+ afterEach(() => {
13
+ fs.rmSync(proj, { recursive: true, force: true });
14
+ });
15
+ it("module-dir == cwd: compiled entry lives inside each input's workdir", async () => {
16
+ const runsDir = path.join(proj, "runs");
17
+ const seen = [];
18
+ const runner = vi.fn(async (args) => {
19
+ seen.push({ compiledEntryPath: args.compiledEntryPath, cwd: args.cwd });
20
+ return { ok: true };
21
+ });
22
+ const result = await evalRunLoadedInputs({
23
+ agent: path.join(proj, "agent.agency"),
24
+ inputs: [{ id: "input-1", goal: "g", args: {} }],
25
+ inputsSource: "test",
26
+ runsDir,
27
+ runId: "r1",
28
+ config: {},
29
+ pipeAgentOutput: false,
30
+ }, { runner });
31
+ const workdir = result.inputs[0].workdirPath;
32
+ expect(seen).toHaveLength(1);
33
+ expect(seen[0].cwd).toBe(workdir);
34
+ expect(seen[0].compiledEntryPath.startsWith(workdir + path.sep)).toBe(true);
35
+ expect(fs.existsSync(seen[0].compiledEntryPath)).toBe(true);
36
+ });
37
+ it("rejects working_dir that does not contain the agent file", async () => {
38
+ const outside = fs.mkdtempSync(path.join(os.tmpdir(), "outside-"));
39
+ const runner = vi.fn(async () => ({ ok: true }));
40
+ try {
41
+ const result = await evalRunLoadedInputs({
42
+ agent: path.join(proj, "agent.agency"),
43
+ inputs: [{ id: "input-1", goal: "g", args: {}, working_dir: outside }],
44
+ inputsSource: "test",
45
+ runsDir: path.join(proj, "runs"),
46
+ runId: "r-outside",
47
+ config: {},
48
+ pipeAgentOutput: false,
49
+ }, { runner });
50
+ expect(result.inputs[0].status).toBe("error");
51
+ expect(result.inputs[0].errorMessage).toMatch(/working_dir must contain the agent file/);
52
+ expect(runner).not.toHaveBeenCalled();
53
+ }
54
+ finally {
55
+ fs.rmSync(outside, { recursive: true, force: true });
56
+ }
57
+ });
58
+ it("rejects working_dir values that point to files", async () => {
59
+ const file = path.join(proj, "not-a-dir.txt");
60
+ fs.writeFileSync(file, "x");
61
+ const runner = vi.fn(async () => ({ ok: true }));
62
+ const result = await evalRunLoadedInputs({
63
+ agent: path.join(proj, "agent.agency"),
64
+ inputs: [{ id: "input-1", goal: "g", args: {}, working_dir: file }],
65
+ inputsSource: "test",
66
+ runsDir: path.join(proj, "runs"),
67
+ runId: "r-file",
68
+ config: {},
69
+ pipeAgentOutput: false,
70
+ }, { runner });
71
+ expect(result.inputs[0].status).toBe("error");
72
+ expect(result.inputs[0].errorMessage).toMatch(/working_dir is not a directory/);
73
+ });
74
+ it("overlayFiles overwrite the seed copy inside each input's workdir", async () => {
75
+ fs.writeFileSync(path.join(proj, "config.txt"), "original\n");
76
+ const observedCwd = [];
77
+ const runner = vi.fn(async (args) => {
78
+ observedCwd.push(fs.readFileSync(path.join(args.cwd, "config.txt"), "utf8"));
79
+ return { ok: true };
80
+ });
81
+ await evalRunLoadedInputs({
82
+ agent: path.join(proj, "agent.agency"),
83
+ inputs: [{ id: "input-1", goal: "g", args: {} }],
84
+ inputsSource: "test",
85
+ runsDir: path.join(proj, "runs"),
86
+ runId: "r-overlay",
87
+ config: {},
88
+ pipeAgentOutput: false,
89
+ seed: { dir: proj, agentRelPath: "agent.agency" },
90
+ overlayFiles: { "config.txt": "patched\n" },
91
+ }, { runner });
92
+ // The overlay wins inside the workdir; the source tree is untouched.
93
+ expect(observedCwd).toEqual(["patched\n"]);
94
+ expect(fs.readFileSync(path.join(proj, "config.txt"), "utf8")).toBe("original\n");
95
+ });
96
+ it("rejects combining a caller-supplied seed with input.working_dir", async () => {
97
+ const runner = vi.fn(async () => ({ ok: true }));
98
+ const result = await evalRunLoadedInputs({
99
+ agent: path.join(proj, "agent.agency"),
100
+ inputs: [{ id: "input-1", goal: "g", args: {}, working_dir: proj }],
101
+ inputsSource: "test",
102
+ runsDir: path.join(proj, "runs"),
103
+ runId: "r-seed-conflict",
104
+ config: {},
105
+ pipeAgentOutput: false,
106
+ seed: { dir: proj, agentRelPath: "agent.agency" },
107
+ }, { runner });
108
+ expect(result.inputs[0].status).toBe("error");
109
+ expect(result.inputs[0].errorMessage).toMatch(/cannot be combined with a caller-supplied seed/);
110
+ });
111
+ });
@@ -33,16 +33,10 @@ export function prepareInput(state, input) {
33
33
  const inputDir = path.join(state.inputsDir, id);
34
34
  const workdirPath = path.join(inputDir, "workdir");
35
35
  fs.mkdirSync(inputDir, { recursive: true });
36
- if (input.working_dir) {
37
- const workingDirStat = fs.statSync(input.working_dir);
38
- if (!workingDirStat.isDirectory()) {
39
- throw new Error("Eval input working_dir must be a directory");
40
- }
41
- fs.cpSync(input.working_dir, workdirPath, { recursive: true });
42
- }
43
- else {
44
- fs.mkdirSync(workdirPath, { recursive: true });
45
- }
36
+ // workdir is materialized by prepareRunDir (seed + overlay + compile); we
37
+ // only allocate the path here. working_dir validation moves to
38
+ // evalRunLoadedInputs, where both working_dir and the agent file are in
39
+ // scope (it must enforce "working_dir contains the agent file").
46
40
  const prepared = {
47
41
  input,
48
42
  inputDir,
@@ -46,11 +46,13 @@ Choose a different --run-id or delete the existing directory.`);
46
46
  expect(fs.existsSync(path.join(tmpDir, "existing", "inputs"))).toBe(false);
47
47
  expect(fs.existsSync(path.join(tmpDir, "existing", "config.json"))).toBe(false);
48
48
  });
49
- it("prepares per-input artifact paths and an empty workdir", () => {
49
+ it("prepares per-input artifact paths but leaves workdir to prepareRunDir", () => {
50
50
  const state = initializeState();
51
51
  const prepared = prepareInput(state, { id: "t1", goal: "goal", args: {} });
52
52
  expect(JSON.parse(fs.readFileSync(path.join(state.runDir, "inputs", "t1", "input.json"), "utf-8"))).toMatchObject({ id: "t1", goal: "goal" });
53
- expect(fs.existsSync(prepared.workdirPath)).toBe(true);
53
+ // prepareInput allocates the path but no longer creates the workdir — that's
54
+ // prepareRunDir's job (seed + overlay + compile inside the workdir).
55
+ expect(fs.existsSync(prepared.workdirPath)).toBe(false);
54
56
  expect(prepared.statelogPath).toBe(path.join(state.runDir, "inputs", "t1", "statelog.jsonl"));
55
57
  expect(prepared.evalRecordPath).toBe(path.join(state.runDir, "inputs", "t1", "eval-record.json"));
56
58
  });
@@ -69,26 +71,12 @@ Choose a different --run-id or delete the existing directory.`);
69
71
  const state = initializeState();
70
72
  expect(() => prepareInput(state, { id: "../escape", goal: "goal", args: {} })).toThrow("Invalid id");
71
73
  });
72
- it("copies a fixture working_dir into the input workdir", () => {
73
- const state = initializeState();
74
- const fixture = path.join(tmpDir, "fixture");
75
- fs.mkdirSync(fixture);
76
- fs.writeFileSync(path.join(fixture, "input.txt"), "fixture-data");
77
- const prepared = prepareInput(state, { id: "t1", goal: "goal", args: {}, working_dir: fixture });
78
- expect(fs.readFileSync(path.join(prepared.workdirPath, "input.txt"), "utf-8")).toBe("fixture-data");
79
- expect(fs.readFileSync(path.join(fixture, "input.txt"), "utf-8")).toBe("fixture-data");
80
- });
81
- it("rejects working_dir values that point to files", () => {
82
- const state = initializeState();
83
- const fixtureFile = path.join(tmpDir, "fixture.txt");
84
- fs.writeFileSync(fixtureFile, "fixture-data");
85
- expect(() => prepareInput(state, {
86
- id: "t1",
87
- goal: "goal",
88
- args: {},
89
- working_dir: fixtureFile,
90
- })).toThrow("working_dir must be a directory");
91
- });
74
+ // Notes on `working_dir` handling: prepareInput is no longer responsible
75
+ // for materializing the workdir from a `working_dir` fixture or for
76
+ // validating that the value points to a directory. Both responsibilities
77
+ // moved to `evalRunLoadedInputs.resolveInputSeed`/`prepareRunDir`, where
78
+ // the agent file is in scope (enabling the "working_dir must contain the
79
+ // agent file" check). See `lib/cli/eval/run.workdir.test.ts`.
92
80
  it("records prepare failures without touching artifact paths", () => {
93
81
  const result = recordInputPrepareFailure("t1", "invalid id");
94
82
  expect(result).toEqual({
@@ -1,5 +1,6 @@
1
+ import type { AgencyConfig } from "../config.js";
1
2
  import { type EvalRunState } from "./runArtifacts.js";
2
- import type { EvalRunCompiledAgent, Input, EvalRunInputResult } from "./runTypes.js";
3
+ import type { Input, EvalRunInputResult } from "./runTypes.js";
3
4
  /**
4
5
  * How to actually invoke the compiled agent for an input. The CLI plugs in a
5
6
  * subprocess fork; alternative callers (tests, in-process variants) can
@@ -11,7 +12,7 @@ import type { EvalRunCompiledAgent, Input, EvalRunInputResult } from "./runTypes
11
12
  * to the runner.
12
13
  */
13
14
  export type EvalInputRunner = (args: {
14
- compiled: EvalRunCompiledAgent;
15
+ compiledEntryPath: string;
15
16
  node: string;
16
17
  args: Record<string, any>;
17
18
  cwd: string;
@@ -48,7 +49,14 @@ export type EvalRecordExtractor = (args: {
48
49
  export declare function runEvalInput(args: {
49
50
  state: EvalRunState;
50
51
  input: Input;
51
- compiled: EvalRunCompiledAgent;
52
+ /** The workdir spec for this input: seed dir + agent path within it. */
53
+ seed: {
54
+ dir: string;
55
+ agentRelPath: string;
56
+ };
57
+ /** Optional candidate-file overlay applied on top of the seed before compile. */
58
+ overlayFiles?: Record<string, string>;
59
+ config: AgencyConfig;
52
60
  defaultNode: string;
53
61
  runner: EvalInputRunner;
54
62
  extractor: EvalRecordExtractor;
@@ -1,5 +1,6 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
+ import { prepareRunDir } from "./runWorkdir.js";
3
4
  import { prepareInput, recordInputPrepareFailure, recordInputRunFailure, recordInputSuccess, shouldExtractStatelog, } from "./runArtifacts.js";
4
5
  /**
5
6
  * Single source of truth for "run one eval input end-to-end."
@@ -23,8 +24,22 @@ export async function runEvalInput(args) {
23
24
  console.error(`[evalRun] prepare failed for input ${inputId}: ${message}`);
24
25
  return recordInputPrepareFailure(inputId, message);
25
26
  }
27
+ let dir;
28
+ try {
29
+ const spec = {
30
+ seedDir: args.seed.dir,
31
+ agentRelPath: args.seed.agentRelPath,
32
+ overlayFiles: args.overlayFiles,
33
+ };
34
+ dir = prepareRunDir(spec, prepared.workdirPath, args.config);
35
+ }
36
+ catch (err) {
37
+ const message = errMessage(err);
38
+ console.error(`[evalRun] prepareRunDir failed for input ${inputId}: ${message}`);
39
+ return recordInputRunFailure(prepared, message);
40
+ }
26
41
  const runResult = await args.runner({
27
- compiled: args.compiled,
42
+ compiledEntryPath: dir.compiledEntryPath,
28
43
  node: args.input.node ?? args.defaultNode,
29
44
  args: args.input.args,
30
45
  cwd: prepared.workdirPath,
@@ -44,7 +44,3 @@ export type EvalRunConfig = {
44
44
  continueOnError: boolean;
45
45
  verbose?: boolean;
46
46
  };
47
- export type EvalRunCompiledAgent = {
48
- moduleId: string;
49
- path?: string;
50
- };
@@ -0,0 +1,25 @@
1
+ import type { AgencyConfig } from "../config.js";
2
+ /** Declarative description of a per-input run directory: where the project
3
+ * tree is seeded from, where the entry agent lives within it, and any
4
+ * candidate-file overlay applied before compile. */
5
+ export type RunWorkdirSpec = {
6
+ /** Absolute dir whose contents seed the workdir (the agent's project tree). */
7
+ seedDir: string;
8
+ /** Entry agent path relative to seedDir, e.g. "examples/08-optimize.agency". */
9
+ agentRelPath: string;
10
+ /** Optional overlay applied after seeding — the optimizer's mutated candidate
11
+ * files, keyed by path relative to seedDir. Absent for plain eval. */
12
+ overlayFiles?: Record<string, string>;
13
+ };
14
+ export type PreparedRunDir = {
15
+ workdirPath: string;
16
+ /** Absolute path of the compiled entry JS inside the workdir. */
17
+ compiledEntryPath: string;
18
+ };
19
+ /**
20
+ * Materialize an isolated run directory: copy the seed project tree into
21
+ * `workdirPath`, overlay the candidate's mutated files (if any), then compile
22
+ * the entry agent in place. The result has module-dir == cwd == workdir, so the
23
+ * agent's reads, writes, and execs all resolve to this one isolated copy.
24
+ */
25
+ export declare function prepareRunDir(spec: RunWorkdirSpec, workdirPath: string, config: AgencyConfig): PreparedRunDir;
@@ -0,0 +1,46 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { compile } from "../cli/commands.js";
4
+ import { RunStrategy } from "../importStrategy.js";
5
+ import { copyProjectTree } from "../utils/projectTree.js";
6
+ /** Resolve `rel` against `workdirPath` and refuse paths that escape it (`..`,
7
+ * absolute). Overlay keys come from optimizer candidates today but may flow in
8
+ * from less-trusted callers later; this is the same guard the prior
9
+ * `WorkspaceManager.resolveWithin` provided. */
10
+ function resolveWithin(workdirPath, rel) {
11
+ const root = path.resolve(workdirPath);
12
+ const abs = path.resolve(root, rel);
13
+ if (abs !== root && !abs.startsWith(root + path.sep)) {
14
+ throw new Error(`Path ${JSON.stringify(rel)} escapes the workdir ${root}`);
15
+ }
16
+ return abs;
17
+ }
18
+ /** Write each overlay file (path relative to the workdir root) over the seeded copy. */
19
+ function applyOverlay(workdirPath, overlayFiles) {
20
+ for (const [rel, source] of Object.entries(overlayFiles)) {
21
+ const abs = resolveWithin(workdirPath, rel);
22
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
23
+ fs.writeFileSync(abs, source);
24
+ }
25
+ }
26
+ /**
27
+ * Materialize an isolated run directory: copy the seed project tree into
28
+ * `workdirPath`, overlay the candidate's mutated files (if any), then compile
29
+ * the entry agent in place. The result has module-dir == cwd == workdir, so the
30
+ * agent's reads, writes, and execs all resolve to this one isolated copy.
31
+ */
32
+ export function prepareRunDir(spec, workdirPath, config) {
33
+ copyProjectTree(spec.seedDir, workdirPath);
34
+ if (spec.overlayFiles) {
35
+ applyOverlay(workdirPath, spec.overlayFiles);
36
+ }
37
+ const entryAgency = resolveWithin(workdirPath, spec.agentRelPath);
38
+ const compiledEntryPath = compile(config, entryAgency, undefined, {
39
+ importStrategy: new RunStrategy(),
40
+ quiet: true,
41
+ });
42
+ if (compiledEntryPath === null) {
43
+ throw new Error(`Failed to compile ${entryAgency}`);
44
+ }
45
+ return { workdirPath, compiledEntryPath };
46
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,42 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { prepareRunDir } from "./runWorkdir.js";
6
+ describe("prepareRunDir", () => {
7
+ let root;
8
+ let seed;
9
+ beforeEach(() => {
10
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "prd-"));
11
+ seed = path.join(root, "proj");
12
+ fs.mkdirSync(path.join(seed, "demo"), { recursive: true });
13
+ fs.writeFileSync(path.join(seed, "agent.agency"), "node main() { return 1 }\n");
14
+ fs.writeFileSync(path.join(seed, "demo", "data.txt"), "original\n");
15
+ });
16
+ afterEach(() => {
17
+ fs.rmSync(root, { recursive: true, force: true });
18
+ });
19
+ it("seeds the workdir, applies overlay, and compiles the entry in place", () => {
20
+ const workdir = path.join(root, "wd");
21
+ const prepared = prepareRunDir({ seedDir: seed, agentRelPath: "agent.agency", overlayFiles: { "demo/data.txt": "patched\n" } }, workdir, {});
22
+ // overlay applied on top of the seeded copy
23
+ expect(fs.readFileSync(path.join(workdir, "demo", "data.txt"), "utf8")).toBe("patched\n");
24
+ // compiled entry lives inside the workdir, next to its source
25
+ expect(prepared.compiledEntryPath).toBe(path.join(workdir, "agent.js"));
26
+ expect(fs.existsSync(prepared.compiledEntryPath)).toBe(true);
27
+ expect(prepared.workdirPath).toBe(workdir);
28
+ });
29
+ it("refuses overlay keys that escape the workdir (traversal / absolute)", () => {
30
+ const workdir = path.join(root, "wd-escape");
31
+ const outside = path.join(root, "outside.txt");
32
+ expect(() => prepareRunDir({ seedDir: seed, agentRelPath: "agent.agency", overlayFiles: { "../outside.txt": "x" } }, workdir, {})).toThrow(/escapes the workdir/);
33
+ expect(() => prepareRunDir({ seedDir: seed, agentRelPath: "agent.agency", overlayFiles: { [outside]: "x" } }, path.join(root, "wd-escape-abs"), {})).toThrow(/escapes the workdir/);
34
+ expect(fs.existsSync(outside)).toBe(false);
35
+ });
36
+ it("works without an overlay", () => {
37
+ const workdir = path.join(root, "wd2");
38
+ const prepared = prepareRunDir({ seedDir: seed, agentRelPath: "agent.agency" }, workdir, {});
39
+ expect(fs.readFileSync(path.join(workdir, "demo", "data.txt"), "utf8")).toBe("original\n");
40
+ expect(fs.existsSync(prepared.compiledEntryPath)).toBe(true);
41
+ });
42
+ });