@yaag/runtime 0.7.0 → 0.8.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/agent/define-agent.ts +7 -0
- package/src/agent/spawn-extensions.ts +97 -0
- package/src/agent/spawn-request.ts +6 -0
- package/src/agent/spawn.ts +32 -13
- package/src/cassette/cassette-publish.ts +5 -1
- package/src/cassette/cassette-schema.ts +1 -0
- package/src/cassette/cassette.ts +8 -0
- package/src/cassette/replay-divergence.ts +14 -4
- package/src/config/config-file.ts +67 -0
- package/src/config/config-issues.ts +31 -0
- package/src/config/config-paths.ts +38 -0
- package/src/config/config-schema.ts +25 -0
- package/src/config/effective-config.ts +116 -0
- package/src/config/index.ts +22 -0
- package/src/errors.ts +1 -0
- package/src/extension/extension-paths.ts +15 -6
- package/src/index.ts +12 -0
- package/src/run/run.ts +8 -0
- package/src/transport/transport.ts +5 -0
- package/src/types.ts +5 -0
package/package.json
CHANGED
|
@@ -35,6 +35,13 @@ export interface AgentConfig {
|
|
|
35
35
|
readonly skills?: readonly string[];
|
|
36
36
|
/** Deny-list of skill names, applied after `skills`. */
|
|
37
37
|
readonly disallowedSkills?: readonly string[];
|
|
38
|
+
/**
|
|
39
|
+
* Load the Effective Config's `agents.extensions` for Agents of this
|
|
40
|
+
* definition. Default true. A spawn's own `SpawnOptions.configExtensions`
|
|
41
|
+
* wins over this value; spawn *overrides* stay topology-only, so the opt-out
|
|
42
|
+
* is not one of them (ADR-0019, ADR-0040).
|
|
43
|
+
*/
|
|
44
|
+
readonly configExtensions?: boolean;
|
|
38
45
|
/** Ask options applied to every Ask on this Agent unless overridden per call. */
|
|
39
46
|
readonly askDefaults?: AskOptions;
|
|
40
47
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { ConfigExtension } from "../config/index.ts";
|
|
2
|
+
import { resolveExtensionPaths } from "../extension/index.ts";
|
|
3
|
+
|
|
4
|
+
/** Everything one spawn needs to turn declarations into `-e` arguments. */
|
|
5
|
+
export interface SpawnExtensionRequest {
|
|
6
|
+
/** Effective Config entries in merge order: global → project → run (spec rule 3). */
|
|
7
|
+
readonly configExtensions: readonly ConfigExtension[];
|
|
8
|
+
/** `SpawnOptions.extensions`, resolved against the program file as before. */
|
|
9
|
+
readonly declared?: readonly string[] | undefined;
|
|
10
|
+
/** False when the spawn or its definition opted out (spec rule 7). */
|
|
11
|
+
readonly useConfigExtensions: boolean;
|
|
12
|
+
/** Orchestration Program source path, the base of a relative spawn declaration. */
|
|
13
|
+
readonly programFile?: string | undefined;
|
|
14
|
+
/** Agent working directory, the project install root for `npm:`/`git:`. */
|
|
15
|
+
readonly projectRoot: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The two projections of one Agent's extensions: what launches, and what identifies. */
|
|
19
|
+
export interface SpawnExtensions {
|
|
20
|
+
/** Absolute launch-ready `-e` arguments, deduplicated, in final order. */
|
|
21
|
+
readonly resolvedPaths: readonly string[];
|
|
22
|
+
/** The surviving declarations behind them, same order, unresolved (ADR-0040). */
|
|
23
|
+
readonly declared: readonly string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolves the extensions of one Agent: config-sourced first, spawn-declared
|
|
28
|
+
* last, deduplicated by resolved path with the first occurrence keeping its
|
|
29
|
+
* place. Returns undefined when nothing is declared at all, so an
|
|
30
|
+
* extension-free spawn keeps its request shape.
|
|
31
|
+
*
|
|
32
|
+
* The two bases are deliberately different: a relative *path* from a config
|
|
33
|
+
* file resolves against that file's directory, while an `npm:`/`git:`
|
|
34
|
+
* specifier still installs from the Agent working directory (`projectRoot`).
|
|
35
|
+
* An uninstalled specifier passes through verbatim and dedups by its literal
|
|
36
|
+
* string.
|
|
37
|
+
*
|
|
38
|
+
* A declaration joins `declared` only when it contributed at least one new
|
|
39
|
+
* resolved path, so the identity list describes what actually ran. Two
|
|
40
|
+
* different declarations that resolve to the same path therefore keep only the
|
|
41
|
+
* first, which is machine-dependent by nature of the resolution itself.
|
|
42
|
+
*/
|
|
43
|
+
export async function resolveSpawnExtensions(
|
|
44
|
+
request: SpawnExtensionRequest,
|
|
45
|
+
): Promise<SpawnExtensions | undefined> {
|
|
46
|
+
const entries = request.useConfigExtensions ? request.configExtensions : [];
|
|
47
|
+
if (request.declared === undefined && entries.length === 0) return undefined;
|
|
48
|
+
const resolved: { declaration: string; paths: readonly string[] }[] = [];
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
resolved.push({
|
|
51
|
+
declaration: entry.declaration,
|
|
52
|
+
paths: await resolveConfigEntry(entry, request.projectRoot),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
for (const declaration of request.declared ?? []) {
|
|
56
|
+
resolved.push({
|
|
57
|
+
declaration,
|
|
58
|
+
// One declaration per call: `resolveExtensionPaths` keeps no cross-entry
|
|
59
|
+
// state, so the loop preserves its order and its error messages.
|
|
60
|
+
paths: await resolveExtensionPaths([declaration], {
|
|
61
|
+
...(request.programFile === undefined ? {} : { programFile: request.programFile }),
|
|
62
|
+
projectRoot: request.projectRoot,
|
|
63
|
+
}),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
const seen = new Set<string>();
|
|
67
|
+
const resolvedPaths: string[] = [];
|
|
68
|
+
const declared: string[] = [];
|
|
69
|
+
for (const { declaration, paths } of resolved) {
|
|
70
|
+
let contributed = false;
|
|
71
|
+
for (const path of paths) {
|
|
72
|
+
if (seen.has(path)) continue;
|
|
73
|
+
seen.add(path);
|
|
74
|
+
resolvedPaths.push(path);
|
|
75
|
+
contributed = true;
|
|
76
|
+
}
|
|
77
|
+
if (contributed) declared.push(declaration);
|
|
78
|
+
}
|
|
79
|
+
return { resolvedPaths, declared };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Names the declaring config file, so a bad entry points at its own source. */
|
|
83
|
+
async function resolveConfigEntry(
|
|
84
|
+
entry: ConfigExtension,
|
|
85
|
+
projectRoot: string,
|
|
86
|
+
): Promise<readonly string[]> {
|
|
87
|
+
try {
|
|
88
|
+
return await resolveExtensionPaths([entry.declaration], {
|
|
89
|
+
baseDirectory: entry.baseDirectory,
|
|
90
|
+
projectRoot,
|
|
91
|
+
});
|
|
92
|
+
} catch (error) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`config "${entry.file}": ${error instanceof Error ? error.message : String(error)}`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -8,6 +8,7 @@ export interface OpenRequestOptions {
|
|
|
8
8
|
readonly cwd: string;
|
|
9
9
|
readonly spawnOptions: SpawnOptions;
|
|
10
10
|
readonly resolvedExtensionPaths?: readonly string[];
|
|
11
|
+
readonly declaredExtensions?: readonly string[];
|
|
11
12
|
readonly sessionDir: string | undefined;
|
|
12
13
|
}
|
|
13
14
|
|
|
@@ -45,6 +46,11 @@ export function openRequest(options: OpenRequestOptions): OpenOptions {
|
|
|
45
46
|
...(options.resolvedExtensionPaths === undefined
|
|
46
47
|
? {}
|
|
47
48
|
: { resolvedExtensionPaths: options.resolvedExtensionPaths }),
|
|
49
|
+
// An empty list means the same as no list at all, so it stays absent and an
|
|
50
|
+
// extension-free spawn keeps its recorded identity.
|
|
51
|
+
...(options.declaredExtensions === undefined || options.declaredExtensions.length === 0
|
|
52
|
+
? {}
|
|
53
|
+
: { declaredExtensions: options.declaredExtensions }),
|
|
48
54
|
...(spawnOptions.worktree === true ? { worktree: true as const } : {}),
|
|
49
55
|
...(options.sessionDir === undefined ? {} : { sessionDir: options.sessionDir }),
|
|
50
56
|
};
|
package/src/agent/spawn.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
|
+
import type { ConfigExtension } from "../config/index.ts";
|
|
2
3
|
import { YaagError } from "../errors.ts";
|
|
3
4
|
import type { EventSink } from "../events.ts";
|
|
4
|
-
import { resolveExtensionPaths } from "../extension/index.ts";
|
|
5
5
|
import {
|
|
6
6
|
ModelErrorHistory,
|
|
7
7
|
type ModelSelection,
|
|
@@ -21,6 +21,7 @@ import type {
|
|
|
21
21
|
import { Agent } from "./agent.ts";
|
|
22
22
|
import { uniqueAgentName } from "./agent-names.ts";
|
|
23
23
|
import { type AgentDefinition, agentDefinitionConfig, isAgentDefinition } from "./define-agent.ts";
|
|
24
|
+
import { resolveSpawnExtensions } from "./spawn-extensions.ts";
|
|
24
25
|
import { openRequest, withSelection } from "./spawn-request.ts";
|
|
25
26
|
|
|
26
27
|
/** Dependencies for one Run's Agent-spawn gate. */
|
|
@@ -30,6 +31,8 @@ export interface SpawnDependencies {
|
|
|
30
31
|
readonly emit: EventSink;
|
|
31
32
|
readonly sessionDir: string | undefined;
|
|
32
33
|
readonly programFile: string | undefined;
|
|
34
|
+
/** Effective Config entries, in merge order; empty when the Run has no config. */
|
|
35
|
+
readonly configExtensions: readonly ConfigExtension[];
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
/** A RunContext spawn function that can synchronously stop accepting new Agents. */
|
|
@@ -61,16 +64,25 @@ export function makeSpawn(deps: SpawnDependencies): SpawnGate {
|
|
|
61
64
|
const history = new ModelErrorHistory();
|
|
62
65
|
try {
|
|
63
66
|
// Extension paths do not vary per candidate, so a bad path fails once, generically.
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
67
|
+
const extensions = await resolveSpawnExtensions({
|
|
68
|
+
configExtensions: deps.configExtensions,
|
|
69
|
+
declared: request.spawnOptions.extensions,
|
|
70
|
+
useConfigExtensions: request.spawnOptions.configExtensions !== false,
|
|
71
|
+
...(deps.programFile === undefined ? {} : { programFile: deps.programFile }),
|
|
72
|
+
projectRoot: cwd,
|
|
73
|
+
}).catch((error: unknown) => {
|
|
74
|
+
if (error instanceof YaagError) throw error;
|
|
75
|
+
throw new YaagError("SPAWN_FAILED", `agent "${name}": ${String(error)}`, name);
|
|
76
|
+
});
|
|
77
|
+
// One local for both open sites, so the launch arguments and the recorded
|
|
78
|
+
// identity cannot drift apart.
|
|
79
|
+
const extensionFields =
|
|
80
|
+
extensions === undefined
|
|
81
|
+
? {}
|
|
82
|
+
: {
|
|
83
|
+
resolvedExtensionPaths: extensions.resolvedPaths,
|
|
84
|
+
declaredExtensions: extensions.declared,
|
|
85
|
+
};
|
|
74
86
|
const attempt = async (
|
|
75
87
|
selection: ModelSelection,
|
|
76
88
|
): Promise<{ opened: OpenedTransport; spawnOptions: ResolvedSpawnOptions }> => {
|
|
@@ -81,7 +93,7 @@ export function makeSpawn(deps: SpawnDependencies): SpawnGate {
|
|
|
81
93
|
name,
|
|
82
94
|
cwd,
|
|
83
95
|
spawnOptions: settled,
|
|
84
|
-
...
|
|
96
|
+
...extensionFields,
|
|
85
97
|
sessionDir: deps.sessionDir,
|
|
86
98
|
}),
|
|
87
99
|
spawnOptions: settled,
|
|
@@ -94,7 +106,7 @@ export function makeSpawn(deps: SpawnDependencies): SpawnGate {
|
|
|
94
106
|
name,
|
|
95
107
|
cwd,
|
|
96
108
|
spawnOptions: request.spawnOptions,
|
|
97
|
-
...
|
|
109
|
+
...extensionFields,
|
|
98
110
|
sessionDir: deps.sessionDir,
|
|
99
111
|
}),
|
|
100
112
|
);
|
|
@@ -194,6 +206,9 @@ function resolveRequest(
|
|
|
194
206
|
...(config.tools === undefined ? {} : { tools: config.tools }),
|
|
195
207
|
...(config.disallowedTools === undefined ? {} : { disallowedTools: config.disallowedTools }),
|
|
196
208
|
...(config.skills === undefined ? {} : { skills: config.skills }),
|
|
209
|
+
...(config.configExtensions === undefined
|
|
210
|
+
? {}
|
|
211
|
+
: { configExtensions: config.configExtensions }),
|
|
197
212
|
...(config.disallowedSkills === undefined
|
|
198
213
|
? {}
|
|
199
214
|
: { disallowedSkills: config.disallowedSkills }),
|
|
@@ -256,6 +271,7 @@ interface OpenTransportOptions {
|
|
|
256
271
|
readonly cwd: string;
|
|
257
272
|
readonly spawnOptions: ResolvedSpawnOptions;
|
|
258
273
|
readonly resolvedExtensionPaths?: readonly string[];
|
|
274
|
+
readonly declaredExtensions?: readonly string[];
|
|
259
275
|
readonly sessionDir: string | undefined;
|
|
260
276
|
}
|
|
261
277
|
|
|
@@ -275,6 +291,9 @@ async function openTransport(options: OpenTransportOptions): Promise<OpenedTrans
|
|
|
275
291
|
...(options.resolvedExtensionPaths === undefined
|
|
276
292
|
? {}
|
|
277
293
|
: { resolvedExtensionPaths: options.resolvedExtensionPaths }),
|
|
294
|
+
...(options.declaredExtensions === undefined
|
|
295
|
+
? {}
|
|
296
|
+
: { declaredExtensions: options.declaredExtensions }),
|
|
278
297
|
sessionDir: options.sessionDir,
|
|
279
298
|
}),
|
|
280
299
|
(report) => Object.assign(startup, report),
|
|
@@ -21,7 +21,11 @@ export async function publishCassette(path: string, cassette: Cassette): Promise
|
|
|
21
21
|
await syncDirectory(directory);
|
|
22
22
|
} catch (error) {
|
|
23
23
|
await discard(temp);
|
|
24
|
-
throw new Error(
|
|
24
|
+
throw new Error(
|
|
25
|
+
`failed to publish cassette ${path}: ${String(error)}; ` +
|
|
26
|
+
"see @yaag/extension docs/troubleshooting.md#failed-to-publish-cassette",
|
|
27
|
+
{ cause: error },
|
|
28
|
+
);
|
|
25
29
|
}
|
|
26
30
|
}
|
|
27
31
|
|
package/src/cassette/cassette.ts
CHANGED
|
@@ -83,6 +83,11 @@ export interface CassetteSpawn {
|
|
|
83
83
|
readonly disallowedSkills?: readonly string[];
|
|
84
84
|
/** Deterministic request, present only when the Agent requested a worktree. */
|
|
85
85
|
readonly worktree?: true;
|
|
86
|
+
/**
|
|
87
|
+
* Declared extensions in declaration order, unresolved so a Cassette matches
|
|
88
|
+
* across machines (ADR-0040). Absent when the Agent ran no extension.
|
|
89
|
+
*/
|
|
90
|
+
readonly declaredExtensions?: readonly string[];
|
|
86
91
|
}
|
|
87
92
|
|
|
88
93
|
/** The frames attributed to one Ask marker. */
|
|
@@ -264,6 +269,9 @@ function spawnIdentity(options: OpenOptions): CassetteSpawn {
|
|
|
264
269
|
...(options.disallowedSkills === undefined
|
|
265
270
|
? {}
|
|
266
271
|
: { disallowedSkills: [...options.disallowedSkills] }),
|
|
272
|
+
...(options.declaredExtensions === undefined || options.declaredExtensions.length === 0
|
|
273
|
+
? {}
|
|
274
|
+
: { declaredExtensions: [...options.declaredExtensions] }),
|
|
267
275
|
...(options.worktree === true ? { worktree: true } : {}),
|
|
268
276
|
};
|
|
269
277
|
}
|
|
@@ -73,13 +73,16 @@ export const replayMismatch = {
|
|
|
73
73
|
},
|
|
74
74
|
};
|
|
75
75
|
|
|
76
|
+
/** Where a user reads what a Divergence means and what to do about it. */
|
|
77
|
+
const DOCS_POINTER = "; see @yaag/extension docs/troubleshooting.md#replay-or-resume-mismatch";
|
|
78
|
+
|
|
76
79
|
/** Converts a detected mismatch into strict replay's public failure. */
|
|
77
80
|
export function strictReplay(mismatch: ReplayMismatch): never {
|
|
78
81
|
if (mismatch.kind === "changed-ask" && mismatch.definitionName !== undefined) {
|
|
79
82
|
const fields = mismatch.changedFields?.join(", ") ?? "hash inputs";
|
|
80
83
|
throw new YaagError(
|
|
81
84
|
"REPLAY_DIVERGED",
|
|
82
|
-
`replay diverged for agent "${mismatch.agent}" at ask #${mismatch.index}: definition "${mismatch.definitionName}" changed since recording (${fields})`,
|
|
85
|
+
`replay diverged for agent "${mismatch.agent}" at ask #${mismatch.index}: definition "${mismatch.definitionName}" changed since recording (${fields})${DOCS_POINTER}`,
|
|
83
86
|
mismatch.agent,
|
|
84
87
|
);
|
|
85
88
|
}
|
|
@@ -90,7 +93,7 @@ export function strictReplay(mismatch: ReplayMismatch): never {
|
|
|
90
93
|
: "";
|
|
91
94
|
throw new YaagError(
|
|
92
95
|
"REPLAY_DIVERGED",
|
|
93
|
-
`replay diverged for agent "${mismatch.agent}" at ${at}:${changed} expected ${mismatch.expectedHash}, actual ${mismatch.actualHash}`,
|
|
96
|
+
`replay diverged for agent "${mismatch.agent}" at ${at}:${changed} expected ${mismatch.expectedHash}, actual ${mismatch.actualHash}${DOCS_POINTER}`,
|
|
94
97
|
mismatch.agent,
|
|
95
98
|
);
|
|
96
99
|
}
|
|
@@ -123,7 +126,11 @@ export function recordedSpawnSelection(
|
|
|
123
126
|
};
|
|
124
127
|
}
|
|
125
128
|
|
|
126
|
-
/**
|
|
129
|
+
/**
|
|
130
|
+
* The spawn fields both Divergence reports compare, so the two cannot drift
|
|
131
|
+
* apart. Extensions are the deliberate exception: they identify the process
|
|
132
|
+
* start, not an Ask, so they join only the open-request list below.
|
|
133
|
+
*/
|
|
127
134
|
const SPAWN_IDENTITY_FIELDS = [
|
|
128
135
|
"cwd",
|
|
129
136
|
"model",
|
|
@@ -138,7 +145,7 @@ const SPAWN_IDENTITY_FIELDS = [
|
|
|
138
145
|
] as const;
|
|
139
146
|
|
|
140
147
|
/** The spawn identity fields plus the Agent name, which only an open request carries. */
|
|
141
|
-
const SPAWN_OPEN_FIELDS = ["name", ...SPAWN_IDENTITY_FIELDS] as const;
|
|
148
|
+
const SPAWN_OPEN_FIELDS = ["name", ...SPAWN_IDENTITY_FIELDS, "declaredExtensions"] as const;
|
|
142
149
|
|
|
143
150
|
/** The listed fields whose canonical JSON differs between two identity records. */
|
|
144
151
|
function changedAmong<Key extends string>(
|
|
@@ -206,6 +213,9 @@ function spawnHash(options: CassetteSpawn | OpenOptions): string {
|
|
|
206
213
|
...(options.appendSystemPrompt === undefined
|
|
207
214
|
? {}
|
|
208
215
|
: { appendSystemPrompt: options.appendSystemPrompt }),
|
|
216
|
+
...(options.declaredExtensions === undefined || options.declaredExtensions.length === 0
|
|
217
|
+
? {}
|
|
218
|
+
: { declaredExtensions: options.declaredExtensions }),
|
|
209
219
|
}),
|
|
210
220
|
);
|
|
211
221
|
return hasher.digest("hex");
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { Value } from "typebox/value";
|
|
4
|
+
import { YaagError } from "../errors.ts";
|
|
5
|
+
import { describeConfigIssues } from "./config-issues.ts";
|
|
6
|
+
import { type YaagConfigDocument, YaagConfigSchema } from "./config-schema.ts";
|
|
7
|
+
|
|
8
|
+
/** One config file that contributed to the Effective Config. */
|
|
9
|
+
export interface ConfigLayer {
|
|
10
|
+
/** The file this layer was read from. */
|
|
11
|
+
readonly file: string;
|
|
12
|
+
/** The file's own directory: relative paths inside it resolve here (spec rule 4). */
|
|
13
|
+
readonly directory: string;
|
|
14
|
+
readonly document: YaagConfigDocument;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Whether an absent file is an empty layer or a Run-start failure. */
|
|
18
|
+
export interface ReadConfigOptions {
|
|
19
|
+
/** True for the explicit Run Config: a missing file is then an error. */
|
|
20
|
+
readonly required: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Reads, parses, and validates one config layer.
|
|
25
|
+
*
|
|
26
|
+
* A missing file at a discovered location yields `undefined` (an empty layer);
|
|
27
|
+
* a missing explicit Run Config fails. Any other errno — `EACCES`, say — always
|
|
28
|
+
* throws: a config yaag cannot read is not an empty config.
|
|
29
|
+
*/
|
|
30
|
+
export async function readConfigLayer(
|
|
31
|
+
path: string,
|
|
32
|
+
options: ReadConfigOptions,
|
|
33
|
+
): Promise<ConfigLayer | undefined> {
|
|
34
|
+
let text: string;
|
|
35
|
+
try {
|
|
36
|
+
text = await readFile(path, "utf8");
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (isMissing(error)) {
|
|
39
|
+
if (!options.required) return undefined;
|
|
40
|
+
throw invalid(`config file "${path}" does not exist`);
|
|
41
|
+
}
|
|
42
|
+
throw invalid(`cannot read config "${path}": ${String(error)}`);
|
|
43
|
+
}
|
|
44
|
+
let value: unknown;
|
|
45
|
+
try {
|
|
46
|
+
value = JSON.parse(text);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
throw invalid(`cannot parse config "${path}": ${String(error)}`);
|
|
49
|
+
}
|
|
50
|
+
const errors = Value.Errors(YaagConfigSchema, value);
|
|
51
|
+
if (errors.length > 0) {
|
|
52
|
+
throw invalid(`invalid config "${path}": ${describeConfigIssues(value, errors).join("; ")}`);
|
|
53
|
+
}
|
|
54
|
+
// Sound: the strict schema above accepted `value`, so it has exactly this shape.
|
|
55
|
+
const document = value as YaagConfigDocument;
|
|
56
|
+
return { file: path, directory: dirname(path), document };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function invalid(message: string): YaagError {
|
|
60
|
+
return new YaagError("CONFIG_INVALID", message);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isMissing(error: unknown): boolean {
|
|
64
|
+
if (typeof error !== "object" || error === null || !("code" in error)) return false;
|
|
65
|
+
const { code } = error;
|
|
66
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
67
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { TLocalizedValidationError } from "typebox/error";
|
|
2
|
+
import { formatValidationErrors } from "../validation-errors.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Turns TypeBox errors into config diagnostics that name the offending key
|
|
6
|
+
* (spec rule 2).
|
|
7
|
+
*
|
|
8
|
+
* A strict object reports an unknown key twice: once at the key itself with the
|
|
9
|
+
* unhelpful `"schema is false"`, and once at the owning object carrying the key
|
|
10
|
+
* name. Neither alone reads well, so the pair collapses into one line. Every
|
|
11
|
+
* other keyword delegates to the shared formatter, which stays untouched
|
|
12
|
+
* because args validation and ADR-0032 steering text depend on its wording.
|
|
13
|
+
*/
|
|
14
|
+
export function describeConfigIssues(
|
|
15
|
+
value: unknown,
|
|
16
|
+
errors: Iterable<TLocalizedValidationError>,
|
|
17
|
+
): readonly string[] {
|
|
18
|
+
return Array.from(errors).flatMap((error) => {
|
|
19
|
+
if (error.keyword === "additionalProperties") {
|
|
20
|
+
const [line] = formatValidationErrors(value, [error]);
|
|
21
|
+
// The formatter renders `<path>: <message>`; the message carries no colon,
|
|
22
|
+
// so the last one is the separator even for quoted path segments.
|
|
23
|
+
const prefix = line === undefined ? "$" : line.slice(0, line.lastIndexOf(":"));
|
|
24
|
+
return error.params.additionalProperties.map(
|
|
25
|
+
(key) => `${prefix}: unknown key ${JSON.stringify(key)}`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
if (error.keyword === "boolean") return [];
|
|
29
|
+
return formatValidationErrors(value, [error]);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The part of the environment this module reads. It is structural on purpose:
|
|
6
|
+
* the vendored declaration tree compiles with no ambient Node globals.
|
|
7
|
+
*/
|
|
8
|
+
export interface ConfigEnvironment {
|
|
9
|
+
readonly [name: string]: string | undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Every config layer lives in a file with this name. */
|
|
13
|
+
export const CONFIG_FILE_NAME = "config.json";
|
|
14
|
+
|
|
15
|
+
/** The Project Config directory inside a Program Directory. */
|
|
16
|
+
export const PROJECT_CONFIG_DIR = ".yaag";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resolves the Global Config path: `YAAG_CONFIG_DIR`, then an absolute
|
|
20
|
+
* `XDG_CONFIG_HOME`, then `$HOME/.config`. A relative `XDG_CONFIG_HOME` is
|
|
21
|
+
* ignored (XDG spec), exactly as in `resolveCheckpointDirectory`.
|
|
22
|
+
*/
|
|
23
|
+
export function resolveGlobalConfigPath(env: ConfigEnvironment = process.env): string {
|
|
24
|
+
const override = env["YAAG_CONFIG_DIR"];
|
|
25
|
+
if (override !== undefined && override !== "") return join(override, CONFIG_FILE_NAME);
|
|
26
|
+
const xdg = env["XDG_CONFIG_HOME"];
|
|
27
|
+
if (xdg !== undefined && isAbsolute(xdg)) return join(xdg, "yaag", CONFIG_FILE_NAME);
|
|
28
|
+
const home = env["HOME"] !== undefined && env["HOME"] !== "" ? env["HOME"] : homedir();
|
|
29
|
+
return join(home, ".config", "yaag", CONFIG_FILE_NAME);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolves the Project Config path under a Program Directory. The Program
|
|
34
|
+
* Directory is an input: this module never walks upward looking for `.yaag/`.
|
|
35
|
+
*/
|
|
36
|
+
export function projectConfigPath(programDirectory: string): string {
|
|
37
|
+
return join(programDirectory, PROJECT_CONFIG_DIR, CONFIG_FILE_NAME);
|
|
38
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type Static, Type } from "typebox";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The config document of one layer (spec rule 1): plain JSON, namespaced as
|
|
5
|
+
* `{ "agents": { "extensions": ["..."] } }`.
|
|
6
|
+
*
|
|
7
|
+
* Every node sets `additionalProperties: false` on purpose (spec rule 2) — the
|
|
8
|
+
* opposite choice from `cassette-schema.ts`, which tolerates unknown fields so
|
|
9
|
+
* a newer artifact still loads. A config file is hand-written, so an unknown
|
|
10
|
+
* key is a typo the author wants named, not forward compatibility.
|
|
11
|
+
*/
|
|
12
|
+
export const YaagConfigSchema = Type.Object(
|
|
13
|
+
{
|
|
14
|
+
agents: Type.Optional(
|
|
15
|
+
Type.Object(
|
|
16
|
+
{ extensions: Type.Optional(Type.Array(Type.String())) },
|
|
17
|
+
{ additionalProperties: false },
|
|
18
|
+
),
|
|
19
|
+
),
|
|
20
|
+
},
|
|
21
|
+
{ additionalProperties: false },
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
/** The validated shape of one config file. */
|
|
25
|
+
export type YaagConfigDocument = Static<typeof YaagConfigSchema>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { type ConfigLayer, readConfigLayer } from "./config-file.ts";
|
|
2
|
+
import {
|
|
3
|
+
type ConfigEnvironment,
|
|
4
|
+
projectConfigPath,
|
|
5
|
+
resolveGlobalConfigPath,
|
|
6
|
+
} from "./config-paths.ts";
|
|
7
|
+
|
|
8
|
+
/** Which of the three layers a config value came from. */
|
|
9
|
+
export type ConfigLayerName = "global" | "project" | "run";
|
|
10
|
+
|
|
11
|
+
/** One extension declaration, kept exactly as written, with its resolution base. */
|
|
12
|
+
export interface ConfigExtension {
|
|
13
|
+
/** The declaration as written in the config file; never resolved here. */
|
|
14
|
+
readonly declaration: string;
|
|
15
|
+
/** The declaring config file's own directory (spec rule 4). */
|
|
16
|
+
readonly baseDirectory: string;
|
|
17
|
+
readonly layer: ConfigLayerName;
|
|
18
|
+
/** The declaring file, for diagnostics. */
|
|
19
|
+
readonly file: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The merged configuration one Run reads at start. */
|
|
23
|
+
export interface EffectiveConfig {
|
|
24
|
+
/** Extension declarations, concatenated global → project → run (spec rule 3). */
|
|
25
|
+
readonly extensions: readonly ConfigExtension[];
|
|
26
|
+
/** Config files that contributed, in merge order. */
|
|
27
|
+
readonly files: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** What to discover and read. */
|
|
31
|
+
export interface EffectiveConfigRequest {
|
|
32
|
+
/** Environment for Global Config discovery; defaults to `process.env`. */
|
|
33
|
+
readonly env?: ConfigEnvironment;
|
|
34
|
+
/** Program Directory of the Run; absent means no Project Config layer. */
|
|
35
|
+
readonly programDirectory?: string;
|
|
36
|
+
/** Explicit Run Config path; a missing file here is an error. */
|
|
37
|
+
readonly configPath?: string;
|
|
38
|
+
/** Suppresses the discovered layers only; an explicit `configPath` survives (spec rule 8). */
|
|
39
|
+
readonly noConfig?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The Effective Config of a Run that found no config at all. */
|
|
43
|
+
export const EMPTY_EFFECTIVE_CONFIG: EffectiveConfig = Object.freeze({
|
|
44
|
+
extensions: Object.freeze([]) as readonly ConfigExtension[],
|
|
45
|
+
files: Object.freeze([]) as readonly string[],
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
interface LoadedLayer {
|
|
49
|
+
readonly layer: ConfigLayer;
|
|
50
|
+
readonly name: ConfigLayerName;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface Candidate {
|
|
54
|
+
readonly path: string;
|
|
55
|
+
readonly layer: ConfigLayerName;
|
|
56
|
+
readonly required: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Locates, reads, validates, and merges the three config layers.
|
|
61
|
+
*
|
|
62
|
+
* The result is frozen: the Effective Config is read once at Run start, so a
|
|
63
|
+
* config edit mid-Run changes nothing until the next Run (spec rule 6). Nothing
|
|
64
|
+
* is resolved, existence-checked, or deduplicated here — dedup happens by
|
|
65
|
+
* resolved path once the spawn knows its base directories (ADR-0040).
|
|
66
|
+
*/
|
|
67
|
+
export async function loadEffectiveConfig(
|
|
68
|
+
request: EffectiveConfigRequest = {},
|
|
69
|
+
): Promise<EffectiveConfig> {
|
|
70
|
+
const found: LoadedLayer[] = [];
|
|
71
|
+
// Read in order, sequentially: the first failing file is the reported one.
|
|
72
|
+
for (const candidate of candidates(request)) {
|
|
73
|
+
const layer = await readConfigLayer(candidate.path, { required: candidate.required });
|
|
74
|
+
if (layer !== undefined) found.push({ layer, name: candidate.layer });
|
|
75
|
+
}
|
|
76
|
+
return mergeLayers(found);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function candidates(request: EffectiveConfigRequest): readonly Candidate[] {
|
|
80
|
+
const discovered: Candidate[] = [];
|
|
81
|
+
if (request.noConfig !== true) {
|
|
82
|
+
discovered.push({
|
|
83
|
+
path: resolveGlobalConfigPath(request.env),
|
|
84
|
+
layer: "global",
|
|
85
|
+
required: false,
|
|
86
|
+
});
|
|
87
|
+
if (request.programDirectory !== undefined) {
|
|
88
|
+
discovered.push({
|
|
89
|
+
path: projectConfigPath(request.programDirectory),
|
|
90
|
+
layer: "project",
|
|
91
|
+
required: false,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (request.configPath !== undefined) {
|
|
96
|
+
discovered.push({ path: request.configPath, layer: "run", required: true });
|
|
97
|
+
}
|
|
98
|
+
return discovered;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function mergeLayers(found: readonly LoadedLayer[]): EffectiveConfig {
|
|
102
|
+
const extensions = found.flatMap(({ layer, name }) =>
|
|
103
|
+
(layer.document.agents?.extensions ?? []).map((declaration) =>
|
|
104
|
+
Object.freeze({
|
|
105
|
+
declaration,
|
|
106
|
+
baseDirectory: layer.directory,
|
|
107
|
+
layer: name,
|
|
108
|
+
file: layer.file,
|
|
109
|
+
}),
|
|
110
|
+
),
|
|
111
|
+
);
|
|
112
|
+
return Object.freeze({
|
|
113
|
+
extensions: Object.freeze(extensions),
|
|
114
|
+
files: Object.freeze(found.map(({ layer }) => layer.file)),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public surface of the `config/` module: the Global, Project, and Run Config
|
|
3
|
+
* layers and the frozen Effective Config a Run reads at start (ADR-0040).
|
|
4
|
+
* Files inside this directory import each other directly.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
CONFIG_FILE_NAME,
|
|
9
|
+
type ConfigEnvironment,
|
|
10
|
+
PROJECT_CONFIG_DIR,
|
|
11
|
+
projectConfigPath,
|
|
12
|
+
resolveGlobalConfigPath,
|
|
13
|
+
} from "./config-paths.ts";
|
|
14
|
+
export type { YaagConfigDocument } from "./config-schema.ts";
|
|
15
|
+
export {
|
|
16
|
+
type ConfigExtension,
|
|
17
|
+
type ConfigLayerName,
|
|
18
|
+
type EffectiveConfig,
|
|
19
|
+
type EffectiveConfigRequest,
|
|
20
|
+
EMPTY_EFFECTIVE_CONFIG,
|
|
21
|
+
loadEffectiveConfig,
|
|
22
|
+
} from "./effective-config.ts";
|
package/src/errors.ts
CHANGED
|
@@ -46,6 +46,7 @@ export type YaagErrorCode =
|
|
|
46
46
|
| "ASK_STALLED" // no frame arrived within the silence budget; escalated, then kill
|
|
47
47
|
| "ASK_INVALID_OUTPUT" // settled text could not be extracted or satisfy outputSchema
|
|
48
48
|
| "ARGS_INVALID" // arguments failed schema validation before the Run started
|
|
49
|
+
| "CONFIG_INVALID" // a config layer is missing, unparsable, or violates the schema
|
|
49
50
|
| "OPTIONS_CONFLICT" // incompatible Run options were supplied
|
|
50
51
|
| "REPLAY_DIVERGED" // replayed program differed from its Cassette
|
|
51
52
|
| "RESUME_REFUSED" // resume metadata is absent or the recorded tree moved
|
|
@@ -7,16 +7,24 @@ import { parseExtensionSource } from "./extension-source.ts";
|
|
|
7
7
|
export interface ExtensionResolutionOptions extends ExtensionInstallRoots {
|
|
8
8
|
/** Orchestration Program source path, required by relative declarations. */
|
|
9
9
|
readonly programFile?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Directory a relative declaration resolves against, and which wins over
|
|
12
|
+
* `programFile`: a config-sourced entry resolves from its own config file's
|
|
13
|
+
* directory (ADR-0040, spec rule 4).
|
|
14
|
+
*/
|
|
15
|
+
readonly baseDirectory?: string;
|
|
10
16
|
}
|
|
11
17
|
|
|
12
18
|
/**
|
|
13
19
|
* Resolves declared extensions into launch-ready `pi -e` arguments before transport opening.
|
|
14
20
|
*
|
|
15
|
-
* Filesystem declarations are resolved against
|
|
21
|
+
* Filesystem declarations are resolved against `baseDirectory` when given, and against
|
|
22
|
+
* the Orchestration Program directory otherwise, and are
|
|
16
23
|
* checked for existence. `npm:`/`git:` specifiers expand to the entry points of the
|
|
17
24
|
* already-installed package (project install shadows the user install); an uninstalled
|
|
18
25
|
* specifier passes through verbatim so pi temp-installs it. Results retain declaration
|
|
19
|
-
* order. Rejects missing paths and a relative declaration
|
|
26
|
+
* order. Rejects missing paths, and a relative declaration that has neither
|
|
27
|
+
* `baseDirectory` nor `programFile`.
|
|
20
28
|
*/
|
|
21
29
|
export async function resolveExtensionPaths(
|
|
22
30
|
declarations: readonly string[],
|
|
@@ -35,7 +43,7 @@ async function resolveOne(
|
|
|
35
43
|
): Promise<readonly string[]> {
|
|
36
44
|
const source = parseExtensionSource(declaration);
|
|
37
45
|
if (source.kind === "path") {
|
|
38
|
-
const path = resolveDeclaration(declaration, options
|
|
46
|
+
const path = resolveDeclaration(declaration, options);
|
|
39
47
|
await requireExists(declaration, path);
|
|
40
48
|
return [path];
|
|
41
49
|
}
|
|
@@ -45,12 +53,13 @@ async function resolveOne(
|
|
|
45
53
|
return installed;
|
|
46
54
|
}
|
|
47
55
|
|
|
48
|
-
function resolveDeclaration(declaration: string,
|
|
56
|
+
function resolveDeclaration(declaration: string, options: ExtensionResolutionOptions): string {
|
|
49
57
|
if (isAbsolute(declaration)) return declaration;
|
|
50
|
-
if (
|
|
58
|
+
if (options.baseDirectory !== undefined) return resolve(options.baseDirectory, declaration);
|
|
59
|
+
if (options.programFile === undefined) {
|
|
51
60
|
throw new Error(`relative extension "${declaration}" requires RunOptions.programFile`);
|
|
52
61
|
}
|
|
53
|
-
return resolve(dirname(programFile), declaration);
|
|
62
|
+
return resolve(dirname(options.programFile), declaration);
|
|
54
63
|
}
|
|
55
64
|
|
|
56
65
|
async function requireExists(declaration: string, path: string): Promise<void> {
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,18 @@ export {
|
|
|
18
18
|
replayTransport,
|
|
19
19
|
resumeTransport,
|
|
20
20
|
} from "./cassette/index.ts";
|
|
21
|
+
export type {
|
|
22
|
+
ConfigEnvironment,
|
|
23
|
+
ConfigExtension,
|
|
24
|
+
ConfigLayerName,
|
|
25
|
+
EffectiveConfig,
|
|
26
|
+
EffectiveConfigRequest,
|
|
27
|
+
} from "./config/index.ts";
|
|
28
|
+
export {
|
|
29
|
+
EMPTY_EFFECTIVE_CONFIG,
|
|
30
|
+
loadEffectiveConfig,
|
|
31
|
+
PROJECT_CONFIG_DIR,
|
|
32
|
+
} from "./config/index.ts";
|
|
21
33
|
export type {
|
|
22
34
|
AskInvalidOutputOutcome,
|
|
23
35
|
AskLimitKind,
|
package/src/run/run.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
resumeTransport,
|
|
25
25
|
writeRecordingDiagnostic,
|
|
26
26
|
} from "../cassette/index.ts";
|
|
27
|
+
import { type EffectiveConfig, EMPTY_EFFECTIVE_CONFIG } from "../config/index.ts";
|
|
27
28
|
import { isYaagError, YaagError } from "../errors.ts";
|
|
28
29
|
import type { EventSink, LifecycleEvent, RunOutcome, StampedEventSink } from "../events.ts";
|
|
29
30
|
import { applyEvent, initialSummary, type RunSummary } from "../summary/index.ts";
|
|
@@ -69,6 +70,12 @@ export interface RunOptions {
|
|
|
69
70
|
readonly signal?: AbortSignal;
|
|
70
71
|
/** Overrides the default checkpoint directory a stopped Run publishes into (ADR-0021). */
|
|
71
72
|
readonly checkpointDir?: string;
|
|
73
|
+
/**
|
|
74
|
+
* Effective Config for this Run, read once by the launcher (ADR-0040). The
|
|
75
|
+
* Run never loads it itself: omission runs config-free, so no Run picks up a
|
|
76
|
+
* config it was not given, and every test stays hermetic.
|
|
77
|
+
*/
|
|
78
|
+
readonly config?: EffectiveConfig;
|
|
72
79
|
}
|
|
73
80
|
|
|
74
81
|
/**
|
|
@@ -152,6 +159,7 @@ export async function executeRun<Args, Result>(
|
|
|
152
159
|
emit,
|
|
153
160
|
sessionDir: options.sessionDir,
|
|
154
161
|
programFile: options.programFile,
|
|
162
|
+
configExtensions: options.config?.extensions ?? EMPTY_EFFECTIVE_CONFIG.extensions,
|
|
155
163
|
});
|
|
156
164
|
|
|
157
165
|
const ctx: RunContext<Args> = { args, spawn: spawnGate.spawn };
|
|
@@ -186,6 +186,11 @@ export interface OpenOptions {
|
|
|
186
186
|
readonly resolvedSkillPaths?: readonly string[];
|
|
187
187
|
/** Absolute launch-ready extension paths resolved above the seam, emitted as repeated `-e` flags. */
|
|
188
188
|
readonly resolvedExtensionPaths?: readonly string[];
|
|
189
|
+
/**
|
|
190
|
+
* Declared extensions in final concatenation order, unresolved (ADR-0040).
|
|
191
|
+
* Spawn identity only: it never becomes argv, and it is absent when empty.
|
|
192
|
+
*/
|
|
193
|
+
readonly declaredExtensions?: readonly string[];
|
|
189
194
|
/** Session storage directory. Used by the e2e suite to stay out of ~/.pi (ticket 06). */
|
|
190
195
|
readonly sessionDir?: string;
|
|
191
196
|
/** Resumes an existing pi session, translated to `--session <path>`. */
|
package/src/types.ts
CHANGED
|
@@ -38,6 +38,11 @@ export interface SpawnOptions {
|
|
|
38
38
|
* resolve from the Orchestration Program file; each becomes `pi -e <path>`.
|
|
39
39
|
*/
|
|
40
40
|
readonly extensions?: readonly string[];
|
|
41
|
+
/**
|
|
42
|
+
* Load the Effective Config's `agents.extensions` for this Agent. Default
|
|
43
|
+
* true; false spawns with only the extensions this call declares (ADR-0040).
|
|
44
|
+
*/
|
|
45
|
+
readonly configExtensions?: boolean;
|
|
41
46
|
/**
|
|
42
47
|
* Tool allowlist layered over the ADR-0026 baseline; omission is tool-free when hermetic.
|
|
43
48
|
* `disallowedTools` applies last. A non-empty surviving allowlist is verified after startup;
|