agency-lang 0.7.1 → 0.7.2

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 (39) hide show
  1. package/dist/lib/agents/agency-agent/agent.agency +48 -2
  2. package/dist/lib/agents/agency-agent/agent.js +120 -20
  3. package/dist/lib/agents/agency-agent/shared.agency +22 -0
  4. package/dist/lib/agents/agency-agent/shared.js +158 -1
  5. package/dist/lib/agents/agency-agent/tests/oneShotRounds.agency +20 -0
  6. package/dist/lib/agents/agency-agent/tests/oneShotRounds.js +555 -0
  7. package/dist/lib/agents/agency-agent/tests/oneShotRounds.test.json +9 -0
  8. package/dist/lib/cli/doctor.d.ts +8 -0
  9. package/dist/lib/cli/doctor.js +21 -13
  10. package/dist/lib/cli/doctor.test.js +34 -0
  11. package/dist/lib/cli/help.js +1 -5
  12. package/dist/lib/cli/runBundledAgent.d.ts +17 -1
  13. package/dist/lib/cli/runBundledAgent.js +73 -2
  14. package/dist/lib/cli/runBundledAgent.test.d.ts +1 -0
  15. package/dist/lib/cli/runBundledAgent.test.js +96 -0
  16. package/dist/lib/config.d.ts +42 -33
  17. package/dist/lib/config.js +108 -6
  18. package/dist/lib/config.test.js +121 -2
  19. package/dist/lib/parseCache.js +8 -13
  20. package/dist/lib/parseCache.test.js +5 -8
  21. package/dist/lib/parser.js +5 -12
  22. package/dist/lib/preprocessors/typescriptPreprocessor.d.ts +0 -28
  23. package/dist/lib/preprocessors/typescriptPreprocessor.js +0 -245
  24. package/dist/lib/runtime/configOverrides.d.ts +17 -16
  25. package/dist/lib/runtime/configOverrides.js +27 -16
  26. package/dist/lib/runtime/configOverrides.test.js +116 -2
  27. package/dist/lib/runtime/prompt.js +7 -3
  28. package/dist/lib/runtime/state/context.js +9 -2
  29. package/dist/lib/stdlib/llm.d.ts +1 -0
  30. package/dist/lib/stdlib/version.d.ts +1 -1
  31. package/dist/lib/stdlib/version.js +1 -1
  32. package/dist/scripts/agency.d.ts +2 -0
  33. package/dist/scripts/agency.js +55 -12
  34. package/dist/scripts/agency.test.js +73 -1
  35. package/package.json +1 -1
  36. package/stdlib/llm.agency +2 -1
  37. package/stdlib/llm.js +2 -2
  38. package/dist/lib/preprocessors/typescriptPreprocessor.config.test.js +0 -327
  39. /package/dist/lib/{preprocessors/typescriptPreprocessor.config.test.d.ts → cli/doctor.test.d.ts} +0 -0
@@ -1,9 +1,72 @@
1
+ import { applyCliFlags, CONFIG_OVERRIDES_ENV, serializeConfigOverrides, } from "../config.js";
1
2
  import { compile, compiledOutputNodeArgs } from "./commands.js";
2
- import { spawn } from "child_process";
3
+ import { spawn as realSpawn } from "child_process";
3
4
  import * as fs from "fs";
4
5
  import * as path from "path";
6
+ import { parseArgs } from "node:util";
5
7
  const currentDir = path.dirname(new URL(import.meta.url).pathname);
6
- export function runBundledAgent(config, agentName, args = []) {
8
+ // The debug flags every bundled agent understands. Same names the agent
9
+ // declares (for --help) and that `agency run` accepts.
10
+ const AGENT_DEBUG_FLAGS = ["--trace", "--log-file"];
11
+ /**
12
+ * Rewrite a bare debug flag to its empty attached form (`--trace` → `--trace=`)
13
+ * when its next token is absent or starts with `-`. This replicates exactly
14
+ * what `std::args` does before parsing (lib/stdlib/args.ts:499-505), so the
15
+ * agent's own parser and this pre-scan agree: `--trace --print` and `--trace
16
+ * -p` are BOTH bare here, not a trace file named "--print" / "-p". Stops at the
17
+ * `--` terminator (everything after is positional).
18
+ */
19
+ function normalizeBareDebugFlags(args) {
20
+ const out = [];
21
+ for (let i = 0; i < args.length; i++) {
22
+ const token = args[i];
23
+ if (token === "--") {
24
+ out.push(...args.slice(i));
25
+ break;
26
+ }
27
+ if (AGENT_DEBUG_FLAGS.includes(token)) {
28
+ const next = args[i + 1];
29
+ const bare = next === undefined || next === "--" || next.startsWith("-");
30
+ out.push(bare ? `${token}=` : token);
31
+ }
32
+ else {
33
+ out.push(token);
34
+ }
35
+ }
36
+ return out;
37
+ }
38
+ /**
39
+ * Extract the debug flags (`--trace`, `--log-file`) from a bundled agent's
40
+ * forwarded argv, as a config override. This is the ONE place these flags are
41
+ * handled — every bundled agent gets them for free by going through
42
+ * `runBundledAgent`; no agent writes flag code.
43
+ *
44
+ * Tokenization is `node:util.parseArgs` (which `std::args` is itself built on)
45
+ * after the same bare-flag normalization std::args applies, so `--flag=value`,
46
+ * the `--` terminator, last-wins-on-repeat, AND a following-flag-is-not-a-value
47
+ * all match the agent's own parser. An empty/bare `--trace` maps to a per-run
48
+ * trace file in cwd; the flag→config meaning lives in `applyCliFlags`.
49
+ */
50
+ export function agentConfigOverride(args) {
51
+ const { values } = parseArgs({
52
+ args: normalizeBareDebugFlags(args),
53
+ options: { trace: { type: "string" }, "log-file": { type: "string" } },
54
+ strict: false,
55
+ allowPositionals: true,
56
+ });
57
+ const flags = {};
58
+ // After normalization a present --trace is always a string ("" when bare).
59
+ if (typeof values.trace === "string") {
60
+ flags.trace = values.trace;
61
+ }
62
+ // --log-file requires a value; a bare (now "") --log-file is ignored.
63
+ if (typeof values["log-file"] === "string" && values["log-file"] !== "") {
64
+ flags.logFile = values["log-file"];
65
+ }
66
+ return applyCliFlags({}, flags);
67
+ }
68
+ export function runBundledAgent(config, agentName, args = [], deps = {}) {
69
+ const spawn = deps.spawn ?? realSpawn;
7
70
  const agentDir = path.resolve(currentDir, `../agents/${agentName}`);
8
71
  const agencyFile = path.join(agentDir, "agent.agency");
9
72
  const precompiledFile = path.join(agentDir, "agent.js");
@@ -21,9 +84,17 @@ export function runBundledAgent(config, agentName, args = []) {
21
84
  console.error(`Failed to compile agent ${agentName}.`);
22
85
  process.exit(1);
23
86
  }
87
+ const overrides = agentConfigOverride(args);
88
+ const env = Object.keys(overrides).length > 0
89
+ ? {
90
+ ...process.env,
91
+ [CONFIG_OVERRIDES_ENV]: serializeConfigOverrides(overrides),
92
+ }
93
+ : process.env;
24
94
  const nodeProcess = spawn(process.execPath, [...compiledOutputNodeArgs(), runFile, ...args], {
25
95
  stdio: "inherit",
26
96
  shell: false,
97
+ env,
27
98
  });
28
99
  nodeProcess.on("error", (error) => {
29
100
  console.error(`Failed to run ${agentName}:`, error);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,96 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ // Force the precompiled-file branch so runBundledAgent never invokes the
3
+ // compiler; spawn is injected, so nothing actually launches.
4
+ vi.mock("fs", async (importOriginal) => {
5
+ const actual = await importOriginal();
6
+ return { ...actual, existsSync: () => true };
7
+ });
8
+ import { CONFIG_OVERRIDES_ENV } from "../config.js";
9
+ import { agentConfigOverride, runBundledAgent } from "./runBundledAgent.js";
10
+ describe("agentConfigOverride", () => {
11
+ it("--trace <file> → traceFile", () => {
12
+ expect(agentConfigOverride(["--trace", "out.agencytrace"])).toEqual({
13
+ trace: true,
14
+ traceFile: "out.agencytrace",
15
+ });
16
+ });
17
+ it("--trace=<file> attached form", () => {
18
+ expect(agentConfigOverride(["--trace=x.trace"])).toEqual({
19
+ trace: true,
20
+ traceFile: "x.trace",
21
+ });
22
+ });
23
+ it("bare --trace → per-run dir (traceDir)", () => {
24
+ expect(agentConfigOverride(["--trace"])).toEqual({ trace: true, traceDir: "." });
25
+ });
26
+ it("empty --trace= behaves identically to bare --trace (the divergence bug)", () => {
27
+ expect(agentConfigOverride(["--trace="])).toEqual({ trace: true, traceDir: "." });
28
+ });
29
+ it("a following flag is NOT consumed as the --trace value (matches std::args)", () => {
30
+ // Both are bare on the agent side, so they must be bare here too — not a
31
+ // trace file named "--print" / "-p".
32
+ expect(agentConfigOverride(["--trace", "--print", "hi"])).toEqual({
33
+ trace: true,
34
+ traceDir: ".",
35
+ });
36
+ expect(agentConfigOverride(["--trace", "-p"])).toEqual({ trace: true, traceDir: "." });
37
+ });
38
+ it("bare --log-file (or one followed by a flag) is ignored, not a file named --print", () => {
39
+ expect(agentConfigOverride(["--log-file"])).toEqual({});
40
+ expect(agentConfigOverride(["--log-file", "--print"])).toEqual({});
41
+ });
42
+ it("--log-file <path> → log.logFile + observability, space and attached forms", () => {
43
+ const expected = { log: { logFile: "l.jsonl" }, observability: true };
44
+ expect(agentConfigOverride(["--log-file", "l.jsonl"])).toEqual(expected);
45
+ expect(agentConfigOverride(["--log-file=l.jsonl"])).toEqual(expected);
46
+ });
47
+ it("stops at the -- terminator", () => {
48
+ expect(agentConfigOverride(["--", "--trace", "x"])).toEqual({});
49
+ });
50
+ it("last --trace wins on repeats", () => {
51
+ expect(agentConfigOverride(["--trace", "a", "--trace", "b"])).toEqual({
52
+ trace: true,
53
+ traceFile: "b",
54
+ });
55
+ });
56
+ it("combines trace + log and ignores the agent's own flags/positionals", () => {
57
+ expect(agentConfigOverride(["--model", "gpt", "hi", "--trace", "t", "--log-file", "l"])).toEqual({
58
+ trace: true,
59
+ traceFile: "t",
60
+ log: { logFile: "l" },
61
+ observability: true,
62
+ });
63
+ });
64
+ it("empty when neither flag is present", () => {
65
+ expect(agentConfigOverride(["--print", "do it"])).toEqual({});
66
+ });
67
+ });
68
+ describe("runBundledAgent passes config overrides to the child via env", () => {
69
+ afterEach(() => {
70
+ vi.restoreAllMocks();
71
+ });
72
+ it("serializes the override into AGENCY_CONFIG_OVERRIDES", () => {
73
+ const onMock = vi.fn();
74
+ const spawnMock = vi.fn((..._args) => ({ on: onMock }));
75
+ runBundledAgent({}, "agency-agent", ["--trace", "t.trace", "--log-file", "l.jsonl"], {
76
+ spawn: spawnMock,
77
+ });
78
+ expect(spawnMock).toHaveBeenCalledTimes(1);
79
+ const opts = spawnMock.mock.calls[0][2];
80
+ const overrides = JSON.parse(opts.env[CONFIG_OVERRIDES_ENV]);
81
+ expect(overrides).toEqual({
82
+ trace: true,
83
+ traceFile: "t.trace",
84
+ log: { logFile: "l.jsonl" },
85
+ observability: true,
86
+ });
87
+ // Parent env is preserved (spread), so PATH survives.
88
+ expect(opts.env.PATH).toBe(process.env.PATH);
89
+ });
90
+ it("does not set the env var when no debug flags are present", () => {
91
+ const spawnMock = vi.fn((..._args) => ({ on: vi.fn() }));
92
+ runBundledAgent({}, "agency-agent", ["--print", "hi"], { spawn: spawnMock });
93
+ const opts = spawnMock.mock.calls[0][2];
94
+ expect(opts.env[CONFIG_OVERRIDES_ENV]).toBeUndefined();
95
+ });
96
+ });
@@ -24,34 +24,6 @@ export interface AgencyConfig {
24
24
  verbose?: boolean;
25
25
  logLevel?: LogLevel;
26
26
  outDir?: string;
27
- /**
28
- * Array of AST node types to exclude from code generation
29
- * Example: ["comment", "typeAlias"]
30
- */
31
- excludeNodeTypes?: string[];
32
- /**
33
- * Array of built-in function names to exclude from code generation
34
- * Example: ["fetch", "write"]
35
- */
36
- excludeBuiltinFunctions?: string[];
37
- /**
38
- * Array of domains allowed for fetch operations
39
- * If specified, only these domains can be fetched
40
- * Example: ["api.example.com", "data.mysite.com"]
41
- */
42
- allowedFetchDomains?: string[];
43
- /**
44
- * Array of domains disallowed for fetch operations
45
- * These domains will throw an error if fetch is attempted
46
- * If both allowed and disallowed are set, takes the intersection
47
- * (only allowed domains, minus disallowed ones)
48
- * Example: ["malicious.com", "blocked.site.com"]
49
- */
50
- disallowedFetchDomains?: string[];
51
- /**
52
- * Optionally specify a custom host for tarsec trace collection
53
- */
54
- tarsecTraceHost?: string;
55
27
  /**
56
28
  * Number of times the LLM can go back and forth between calling tools
57
29
  * and responding to their outputs before halting execution to prevent infinite loops.
@@ -339,11 +311,6 @@ export declare const AgencyConfigSchema: z.ZodObject<{
339
311
  error: "error";
340
312
  }>>;
341
313
  outDir: z.ZodOptional<z.ZodString>;
342
- excludeNodeTypes: z.ZodOptional<z.ZodArray<z.ZodString>>;
343
- excludeBuiltinFunctions: z.ZodOptional<z.ZodArray<z.ZodString>>;
344
- allowedFetchDomains: z.ZodOptional<z.ZodArray<z.ZodString>>;
345
- disallowedFetchDomains: z.ZodOptional<z.ZodArray<z.ZodString>>;
346
- tarsecTraceHost: z.ZodOptional<z.ZodString>;
347
314
  maxToolCallRounds: z.ZodOptional<z.ZodNumber>;
348
315
  observability: z.ZodOptional<z.ZodBoolean>;
349
316
  log: z.ZodOptional<z.ZodObject<{
@@ -507,3 +474,45 @@ export declare function loadConfigSafe(configPath: string): {
507
474
  * Returns the directory containing agency.json, or null if not found.
508
475
  */
509
476
  export declare function findProjectRoot(startPath: string): string | null;
477
+ /** Per-invocation flags accepted by `agency run`/`compile` and forwarded to the
478
+ * bundled agents. Mapped onto AgencyConfig by applyCliFlags. `trace` is
479
+ * `string` for `--trace <file>`, `true` for a bare `--trace`. */
480
+ export type CliFlags = {
481
+ trace?: string | true;
482
+ logFile?: string;
483
+ observability?: boolean;
484
+ strict?: boolean;
485
+ maxToolCallRounds?: number;
486
+ maxToolResultChars?: number;
487
+ };
488
+ /**
489
+ * Fold per-invocation CLI flags onto a config COPY (never mutates the input).
490
+ * The single definition of what each debug flag means:
491
+ * --trace <file> → trace + traceFile=<file>
492
+ * --trace (bare) → trace + traceFile=<input>.trace when an input path is
493
+ * known (agency run), else traceDir="." (a bundled agent
494
+ * with no input file → a per-run file in cwd)
495
+ * --log-file <p> → log.logFile=<p> and observability=true
496
+ * --observability → observability=true
497
+ * --strict → typechecker.strict + strictTypes (the compile-path gate
498
+ * never runs the checker on strictTypes alone)
499
+ * --max-tool-call-rounds <n> → maxToolCallRounds=<n> (baked into runPrompt at
500
+ * compile time; overrides agency.json for this run)
501
+ * --max-tool-result-chars <n> → client.maxToolResultChars=<n> (0 disables the
502
+ * cap; overrides agency.json for this run)
503
+ */
504
+ export declare function applyCliFlags(config: AgencyConfig, flags: CliFlags, input?: string): AgencyConfig;
505
+ /** The one env var carrying a JSON Partial<AgencyConfig> into an already-compiled
506
+ * process (see the source-of-truth note above, source 3). */
507
+ export declare const CONFIG_OVERRIDES_ENV = "AGENCY_CONFIG_OVERRIDES";
508
+ /** Serialize config overrides for a child process's AGENCY_CONFIG_OVERRIDES. */
509
+ export declare function serializeConfigOverrides(overrides: Partial<AgencyConfig>): string;
510
+ /** Read + validate AGENCY_CONFIG_OVERRIDES. Returns {} when the var is absent,
511
+ * unparseable, or fails schema validation, so a malformed value can never
512
+ * brick startup. */
513
+ export declare function readConfigOverrides(env?: NodeJS.ProcessEnv): Partial<AgencyConfig>;
514
+ /** Return a deep copy of `config` with secret-bearing fields masked, for
515
+ * human-facing output (`agency config show`). Masks every `apiKey` — the
516
+ * top-level `log.apiKey` string and each key under `client.apiKey` /
517
+ * `client.statelog.apiKey` — to `•••<last4>`. */
518
+ export declare function redactConfigSecrets(config: AgencyConfig): AgencyConfig;
@@ -29,12 +29,10 @@ export const AgencyConfigSchema = z
29
29
  verbose: z.boolean(),
30
30
  logLevel: z.enum(["debug", "info", "warn", "error"]),
31
31
  outDir: z.string(),
32
- excludeNodeTypes: z.array(z.string()),
33
- excludeBuiltinFunctions: z.array(z.string()),
34
- allowedFetchDomains: z.array(z.string()),
35
- disallowedFetchDomains: z.array(z.string()),
36
- tarsecTraceHost: z.string(),
37
- maxToolCallRounds: z.number(),
32
+ // Positive integer: the generated code does `maxToolCallRounds || 10`, so a
33
+ // 0 would silently mean 10 and a float/negative is meaningless — reject at
34
+ // load rather than surprise. Mirrors maxCallDepth.
35
+ maxToolCallRounds: z.number().int().positive(),
38
36
  observability: z.boolean(),
39
37
  log: z
40
38
  .object({
@@ -228,3 +226,107 @@ export function findProjectRoot(startPath) {
228
226
  current = parent;
229
227
  }
230
228
  }
229
+ /**
230
+ * Fold per-invocation CLI flags onto a config COPY (never mutates the input).
231
+ * The single definition of what each debug flag means:
232
+ * --trace <file> → trace + traceFile=<file>
233
+ * --trace (bare) → trace + traceFile=<input>.trace when an input path is
234
+ * known (agency run), else traceDir="." (a bundled agent
235
+ * with no input file → a per-run file in cwd)
236
+ * --log-file <p> → log.logFile=<p> and observability=true
237
+ * --observability → observability=true
238
+ * --strict → typechecker.strict + strictTypes (the compile-path gate
239
+ * never runs the checker on strictTypes alone)
240
+ * --max-tool-call-rounds <n> → maxToolCallRounds=<n> (baked into runPrompt at
241
+ * compile time; overrides agency.json for this run)
242
+ * --max-tool-result-chars <n> → client.maxToolResultChars=<n> (0 disables the
243
+ * cap; overrides agency.json for this run)
244
+ */
245
+ export function applyCliFlags(config, flags, input) {
246
+ const next = { ...config };
247
+ if (flags.trace !== undefined) {
248
+ next.trace = true;
249
+ const explicitFile = typeof flags.trace === "string" && flags.trace !== "";
250
+ if (explicitFile) {
251
+ next.traceFile = flags.trace;
252
+ }
253
+ else if (input) {
254
+ next.traceFile = input.replace(/\.agency$/, ".trace");
255
+ }
256
+ else {
257
+ next.traceDir = ".";
258
+ }
259
+ }
260
+ if (flags.logFile) {
261
+ next.log = { ...next.log, logFile: flags.logFile };
262
+ next.observability = true;
263
+ }
264
+ if (flags.observability) {
265
+ next.observability = true;
266
+ }
267
+ if (flags.strict) {
268
+ next.typechecker = { ...next.typechecker, strict: true, strictTypes: true };
269
+ }
270
+ if (flags.maxToolCallRounds !== undefined) {
271
+ next.maxToolCallRounds = flags.maxToolCallRounds;
272
+ }
273
+ if (flags.maxToolResultChars !== undefined) {
274
+ next.client = { ...next.client, maxToolResultChars: flags.maxToolResultChars };
275
+ }
276
+ return next;
277
+ }
278
+ /** The one env var carrying a JSON Partial<AgencyConfig> into an already-compiled
279
+ * process (see the source-of-truth note above, source 3). */
280
+ export const CONFIG_OVERRIDES_ENV = "AGENCY_CONFIG_OVERRIDES";
281
+ /** Serialize config overrides for a child process's AGENCY_CONFIG_OVERRIDES. */
282
+ export function serializeConfigOverrides(overrides) {
283
+ return JSON.stringify(overrides);
284
+ }
285
+ /** Read + validate AGENCY_CONFIG_OVERRIDES. Returns {} when the var is absent,
286
+ * unparseable, or fails schema validation, so a malformed value can never
287
+ * brick startup. */
288
+ export function readConfigOverrides(env = process.env) {
289
+ const raw = env[CONFIG_OVERRIDES_ENV];
290
+ if (!raw)
291
+ return {};
292
+ try {
293
+ const result = AgencyConfigSchema.safeParse(JSON.parse(raw));
294
+ if (!result.success) {
295
+ // Don't brick startup, but never fail silently — a typo'd override that
296
+ // makes --trace/--log-file quietly do nothing is the worst failure mode.
297
+ console.error(`Ignoring invalid ${CONFIG_OVERRIDES_ENV}: ${result.error.issues
298
+ .map((i) => i.path.join("."))
299
+ .join(", ")}`);
300
+ return {};
301
+ }
302
+ return result.data;
303
+ }
304
+ catch (err) {
305
+ console.error(`Ignoring unparseable ${CONFIG_OVERRIDES_ENV}: ${err instanceof Error ? err.message : String(err)}`);
306
+ return {};
307
+ }
308
+ }
309
+ /** Return a deep copy of `config` with secret-bearing fields masked, for
310
+ * human-facing output (`agency config show`). Masks every `apiKey` — the
311
+ * top-level `log.apiKey` string and each key under `client.apiKey` /
312
+ * `client.statelog.apiKey` — to `•••<last4>`. */
313
+ export function redactConfigSecrets(config) {
314
+ const mask = (value) => value.length <= 4 ? "•••" : `•••${value.slice(-4)}`;
315
+ const clone = JSON.parse(JSON.stringify(config));
316
+ const redactKeyMap = (obj) => {
317
+ if (!obj)
318
+ return;
319
+ for (const key of Object.keys(obj)) {
320
+ if (typeof obj[key] === "string")
321
+ obj[key] = mask(obj[key]);
322
+ }
323
+ };
324
+ if (clone.log && typeof clone.log.apiKey === "string") {
325
+ clone.log.apiKey = mask(clone.log.apiKey);
326
+ }
327
+ redactKeyMap(clone.client?.apiKey);
328
+ if (clone.client?.statelog && typeof clone.client.statelog.apiKey === "string") {
329
+ clone.client.statelog.apiKey = mask(clone.client.statelog.apiKey);
330
+ }
331
+ return clone;
332
+ }
@@ -1,5 +1,8 @@
1
- import { describe, it, expect } from "vitest";
2
- import { AgencyConfigSchema } from "./config.js";
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+ import { AgencyConfigSchema, applyCliFlags, CONFIG_OVERRIDES_ENV, loadConfigSafe, readConfigOverrides, redactConfigSecrets, serializeConfigOverrides, } from "./config.js";
3
6
  describe("AgencyConfigSchema", () => {
4
7
  it("should accept an empty config", () => {
5
8
  const result = AgencyConfigSchema.safeParse({});
@@ -79,6 +82,16 @@ describe("AgencyConfig maxCallDepth key", () => {
79
82
  expect(AgencyConfigSchema.safeParse({ maxCallDepth: 12.5 }).success).toBe(false);
80
83
  });
81
84
  });
85
+ describe("AgencyConfig maxToolCallRounds key", () => {
86
+ it("accepts a positive integer", () => {
87
+ expect(AgencyConfigSchema.safeParse({ maxToolCallRounds: 20 }).success).toBe(true);
88
+ });
89
+ it("rejects 0, negative, and non-integer (0 would silently mean 10 via `|| 10`)", () => {
90
+ expect(AgencyConfigSchema.safeParse({ maxToolCallRounds: 0 }).success).toBe(false);
91
+ expect(AgencyConfigSchema.safeParse({ maxToolCallRounds: -1 }).success).toBe(false);
92
+ expect(AgencyConfigSchema.safeParse({ maxToolCallRounds: 1.5 }).success).toBe(false);
93
+ });
94
+ });
82
95
  describe("AgencyConfig typechecker key", () => {
83
96
  it("accepts the new typechecker object", () => {
84
97
  const result = AgencyConfigSchema.safeParse({
@@ -102,3 +115,109 @@ describe("AgencyConfig typechecker key", () => {
102
115
  expect(result.success).toBe(true);
103
116
  });
104
117
  });
118
+ describe("loadConfigSafe — removed options are ignored, not rejected", () => {
119
+ it("loads an agency.json with the removed keys, and a stale wrong-typed key, without error", () => {
120
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cfg-removed-"));
121
+ const cfgPath = path.join(dir, "agency.json");
122
+ fs.writeFileSync(cfgPath, JSON.stringify({
123
+ excludeNodeTypes: "oops-was-string-array", // stale + wrong type: Zod error before removal, ignored after
124
+ excludeBuiltinFunctions: ["write"],
125
+ allowedFetchDomains: ["api.example.com"],
126
+ disallowedFetchDomains: ["blocked.com"],
127
+ outDir: "./dist",
128
+ }));
129
+ const { config, error } = loadConfigSafe(cfgPath);
130
+ expect(error).toBeUndefined();
131
+ expect(config.outDir).toBe("./dist");
132
+ fs.rmSync(dir, { recursive: true, force: true });
133
+ });
134
+ });
135
+ describe("applyCliFlags", () => {
136
+ it("--trace <file> sets trace + traceFile", () => {
137
+ const out = applyCliFlags({}, { trace: "out.trace" });
138
+ expect(out.trace).toBe(true);
139
+ expect(out.traceFile).toBe("out.trace");
140
+ expect(out.traceDir).toBeUndefined();
141
+ });
142
+ it("bare --trace with an input derives <input>.trace (agency run)", () => {
143
+ const out = applyCliFlags({}, { trace: true }, "prog.agency");
144
+ expect(out.traceFile).toBe("prog.trace");
145
+ });
146
+ it("bare or empty --trace with no input uses traceDir='.' (bundled agent)", () => {
147
+ expect(applyCliFlags({}, { trace: true }).traceDir).toBe(".");
148
+ // Empty attached (--trace=) must behave like bare, not set traceFile="".
149
+ expect(applyCliFlags({}, { trace: "" }).traceDir).toBe(".");
150
+ expect(applyCliFlags({}, { trace: "" }).traceFile).toBeUndefined();
151
+ });
152
+ it("--log-file sets log.logFile and enables observability, preserving log.host", () => {
153
+ const out = applyCliFlags({ log: { host: "https://h" } }, { logFile: "x" });
154
+ expect(out.log).toEqual({ host: "https://h", logFile: "x" });
155
+ expect(out.observability).toBe(true);
156
+ });
157
+ it("--strict sets strict AND strictTypes", () => {
158
+ expect(applyCliFlags({}, { strict: true }).typechecker).toEqual({
159
+ strict: true,
160
+ strictTypes: true,
161
+ });
162
+ });
163
+ it("--max-tool-call-rounds sets the top-level maxToolCallRounds", () => {
164
+ expect(applyCliFlags({}, { maxToolCallRounds: 20 }).maxToolCallRounds).toBe(20);
165
+ });
166
+ it("--max-tool-result-chars sets client.maxToolResultChars, preserving other client fields", () => {
167
+ const out = applyCliFlags({ client: { defaultModel: "gpt" } }, { maxToolResultChars: 50000 });
168
+ expect(out.client).toEqual({ defaultModel: "gpt", maxToolResultChars: 50000 });
169
+ });
170
+ it("keeps maxToolResultChars=0 (0 disables the cap, not falsy-skipped)", () => {
171
+ expect(applyCliFlags({}, { maxToolResultChars: 0 }).client?.maxToolResultChars).toBe(0);
172
+ });
173
+ it("does not mutate the input config", () => {
174
+ const input = {};
175
+ applyCliFlags(input, { strict: true, logFile: "x", trace: "t", maxToolCallRounds: 5 });
176
+ expect(input).toEqual({});
177
+ });
178
+ });
179
+ describe("config overrides env round-trip", () => {
180
+ it("serialize → read yields the same Partial<AgencyConfig>", () => {
181
+ const overrides = { trace: true, traceDir: ".", observability: true, log: { logFile: "l.jsonl" } };
182
+ const env = { [CONFIG_OVERRIDES_ENV]: serializeConfigOverrides(overrides) };
183
+ expect(readConfigOverrides(env)).toEqual(overrides);
184
+ });
185
+ it("returns {} for an absent value without warning", () => {
186
+ const spy = vi.spyOn(console, "error").mockImplementation(() => { });
187
+ expect(readConfigOverrides({})).toEqual({});
188
+ expect(spy).not.toHaveBeenCalled();
189
+ spy.mockRestore();
190
+ });
191
+ it("returns {} and warns (never throws) for unparseable JSON", () => {
192
+ const spy = vi.spyOn(console, "error").mockImplementation(() => { });
193
+ expect(readConfigOverrides({ [CONFIG_OVERRIDES_ENV]: "not json" })).toEqual({});
194
+ expect(spy).toHaveBeenCalledWith(expect.stringContaining("unparseable"));
195
+ spy.mockRestore();
196
+ });
197
+ it("returns {} and warns for a schema-violating field", () => {
198
+ const spy = vi.spyOn(console, "error").mockImplementation(() => { });
199
+ expect(readConfigOverrides({ [CONFIG_OVERRIDES_ENV]: '{"maxCallDepth":-1}' })).toEqual({});
200
+ expect(spy).toHaveBeenCalledWith(expect.stringContaining("invalid"));
201
+ spy.mockRestore();
202
+ });
203
+ });
204
+ describe("redactConfigSecrets", () => {
205
+ it("masks log.apiKey and client.apiKey.* while keeping other fields", () => {
206
+ const redacted = redactConfigSecrets({
207
+ outDir: "./dist",
208
+ log: { host: "https://h", apiKey: "sk-secret-1234" },
209
+ client: { defaultModel: "gpt", apiKey: { openAi: "sk-openai-abcd", anthropic: "x" } },
210
+ });
211
+ expect(redacted.outDir).toBe("./dist");
212
+ expect(redacted.log?.host).toBe("https://h");
213
+ expect(redacted.log?.apiKey).toBe("•••1234");
214
+ expect(redacted.client?.apiKey?.openAi).toBe("•••abcd");
215
+ expect(redacted.client?.apiKey?.anthropic).toBe("•••"); // <=4 chars fully masked
216
+ expect(redacted.client?.defaultModel).toBe("gpt");
217
+ });
218
+ it("does not mutate the input", () => {
219
+ const input = { log: { apiKey: "sk-secret-1234" } };
220
+ redactConfigSecrets(input);
221
+ expect(input.log.apiKey).toBe("sk-secret-1234");
222
+ });
223
+ });
@@ -27,23 +27,18 @@ export function parseAgencyFileCached(absPath, config = {}, applyTemplate = !isN
27
27
  rest: "",
28
28
  };
29
29
  }
30
- const bypass = !!config.tarsecTraceHost;
31
30
  const key = `${applyTemplate ? "t" : "r"}:${absPath}`;
32
- if (!bypass) {
33
- const entry = cache[key];
34
- if (entry && entry.mtimeMs === stat.mtimeMs && entry.size === stat.size) {
35
- stats.hits++;
36
- return {
37
- success: true,
38
- result: structuredClone(entry.program),
39
- rest: "",
40
- };
41
- }
31
+ const entry = cache[key];
32
+ if (entry && entry.mtimeMs === stat.mtimeMs && entry.size === stat.size) {
33
+ stats.hits++;
34
+ return {
35
+ success: true,
36
+ result: structuredClone(entry.program),
37
+ rest: "",
38
+ };
42
39
  }
43
40
  const contents = fs.readFileSync(absPath, "utf-8");
44
41
  const result = parseAgency(contents, config, applyTemplate);
45
- if (bypass)
46
- return result;
47
42
  stats.misses++;
48
43
  if (result.success) {
49
44
  cache[key] = {
@@ -86,15 +86,12 @@ describe("parseAgencyFileCached", () => {
86
86
  expect(_internal.stats.hits).toBe(0);
87
87
  expect(_internal.stats.misses).toBe(2);
88
88
  });
89
- test("bypasses the cache entirely when tarsecTraceHost is set", () => {
89
+ test("a stale tarsecTraceHost key no longer bypasses the cache", () => {
90
90
  const file = writeTempAgencyFile(VALID_PROGRAM);
91
- const traced = parseAgencyFileCached(file, { tarsecTraceHost: true });
92
- expect(traced.success).toBe(true);
93
- expect(_internal.stats.hits).toBe(0);
94
- expect(_internal.stats.misses).toBe(0);
95
- // The traced parse must not have stored an entry either.
96
- parseAgencyFileCached(file, {});
97
- expect(_internal.stats.hits).toBe(0);
91
+ parseAgencyFileCached(file, { tarsecTraceHost: "http://x" });
92
+ parseAgencyFileCached(file, { tarsecTraceHost: "http://x" });
93
+ // Old code: bypass → hits 0, misses 0 (nothing stored). New code: caches.
94
+ expect(_internal.stats.hits).toBe(1);
98
95
  expect(_internal.stats.misses).toBe(1);
99
96
  });
100
97
  test("failed parses are not cached", () => {
@@ -1,5 +1,4 @@
1
- import { buildLineTable, capture, eof, getErrorMessage, getRightmostFailure, many, map, offsetToPosition, or, resetMemos, seqC, set, setInputStr, setTraceHost, setTraceId, success, TarsecError, trace, } from "tarsec";
2
- import { nanoid } from "nanoid";
1
+ import { buildLineTable, capture, eof, getErrorMessage, getRightmostFailure, many, map, offsetToPosition, or, resetMemos, seqC, set, setInputStr, success, TarsecError, trace, } from "tarsec";
3
2
  import { lowerPatterns, PatternLoweringError } from "./lowering/patternLowering.js";
4
3
  import { LoweringError } from "./lowering/loweringError.js";
5
4
  import render from "./templates/backends/agency/template.js";
@@ -48,16 +47,10 @@ export function _parseAgency(input, config = {}) {
48
47
  // Clear memo caches so loc info derived from `setInputStr` in a previous
49
48
  // parse (which may have used a different source) doesn't leak through.
50
49
  resetMemos();
51
- // CAUTION (parse cache): `tarsecTraceHost` is currently the ONLY config
52
- // field the parse path reads, and lib/parseCache.ts relies on that — its
53
- // cache key deliberately excludes config (tracing is handled via a full
54
- // cache bypass). If you add any config-driven parse behavior here (e.g. a
55
- // syntax feature flag), you MUST add that field to the parseCache key, or
56
- // the cache will silently serve one config's AST to another.
57
- if (config.tarsecTraceHost) {
58
- setTraceHost("http://localhost:1465");
59
- setTraceId(nanoid());
60
- }
50
+ // NOTE (parse cache): the parse path reads NO field of `config`, so
51
+ // lib/parseCache.ts can key entries without config. If you ever add
52
+ // config-driven parse behavior here, you MUST add that field to the
53
+ // parseCache key, or the cache will serve one config's AST to another.
61
54
  const result = agencyParser(normalized);
62
55
  if (!result.success) {
63
56
  const betterMessage = getErrorMessage();