odai-dsh-agent 0.0.2 → 0.0.4
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/LICENSE +21 -21
- package/README.md +86 -86
- package/bin/odai-dsh-agent.mjs +106 -87
- package/package.json +68 -68
- package/preset/odai/agent.cordis.yml +260 -260
- package/preset/odai/preset.yml +3 -3
- package/preset/odai/runtime/governance.mjs +120 -120
- package/preset/odai/runtime/index.mjs +587 -584
- package/preset/odai/runtime/router.mjs +290 -290
- package/preset/odai/runtime/routing-config.mjs +323 -322
- package/preset/odai/runtime/session-compat.mjs +387 -0
- package/preset/odai/runtime/session-evidence.mjs +344 -0
- package/preset/odai/skills/odai/SKILL.md +65 -65
- package/preset/odai/skills/odai/agents/openai.yaml +4 -4
- package/preset/odai/skills/odai/assets/claude-agents/agent.md +11 -11
- package/preset/odai/skills/odai/assets/codex-agents/config.toml +11 -11
- package/preset/odai/skills/odai/assets/codex-agents/role.toml +5 -5
- package/preset/odai/skills/odai/assets/copilot-agents/agent.md +13 -13
- package/preset/odai/skills/odai/assets/hooks-policy.example.json +24 -24
- package/preset/odai/skills/odai/assets/routing-roles/controller.md +15 -15
- package/preset/odai/skills/odai/assets/routing-roles/executor.md +11 -11
- package/preset/odai/skills/odai/assets/routing-roles/planner.md +29 -29
- package/preset/odai/skills/odai/assets/routing-roles/reviewer.md +11 -11
- package/preset/odai/skills/odai/assets/task-state.md +12 -12
- package/preset/odai/skills/odai/references/craft.md +53 -53
- package/preset/odai/skills/odai/references/dao.md +30 -30
- package/preset/odai/skills/odai/references/leverage.md +45 -45
- package/preset/odai/skills/odai/references/support.md +36 -36
- package/preset/odai/skills/odai/references/verification.md +23 -23
- package/preset/odai/skills/odai/scripts/build-hooks.mjs +273 -273
- package/preset/odai/skills/odai/scripts/build-routing.mjs +239 -239
- package/preset/odai/skills/odai/scripts/install-routing.mjs +375 -375
- package/preset/odai/skills/odai/scripts/odai-hook.mjs +457 -457
- package/preset/odai/skills/odai/scripts/run-role.mjs +336 -336
- package/preset/odai/skills/odai/scripts/run-routing.mjs +1154 -1154
- package/preset/odai/skills/odai/scripts/verify-routing.mjs +103 -103
- package/scripts/verify-agent-load.mjs +246 -217
- package/src/dsh-version.mjs +20 -0
- package/src/installer.mjs +323 -270
|
@@ -1,322 +1,323 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
existsSync,
|
|
4
|
-
mkdirSync,
|
|
5
|
-
readFileSync,
|
|
6
|
-
renameSync,
|
|
7
|
-
rmSync,
|
|
8
|
-
statSync,
|
|
9
|
-
writeFileSync,
|
|
10
|
-
} from "node:fs";
|
|
11
|
-
import { homedir } from "node:os";
|
|
12
|
-
import { dirname, resolve } from "node:path";
|
|
13
|
-
|
|
14
|
-
export const CONFIGURABLE_ROLES = Object.freeze(["planner", "executor", "reviewer"]);
|
|
15
|
-
export const ROUTING_CONFIG_PROMPT = [
|
|
16
|
-
"## Odai responsibility model configuration",
|
|
17
|
-
"When the user naturally asks to inspect, set, change, or remove the planning/planner, execution/executor, or review/acceptance/reviewer model, use odai_routing_config.",
|
|
18
|
-
"Translate the user's natural responsibility wording into the tool's responsibility field. Do not require internal routing terms or a special prompt form.",
|
|
19
|
-
"For set, call the tool only after the user explicitly supplies both provider and model. Pass reasoningEffort or maxTokens only when the user supplies them; otherwise omit them.",
|
|
20
|
-
"Never infer, recommend as chosen, or silently select any provider, model, effort, or token limit. Ask a concise clarification when provider or model is ambiguous.",
|
|
21
|
-
"Do not ask the user to edit YAML, JSON, managed Agent files, or Plugin configuration. The tool owns persistence. A set/remove change applies from the next user turn.",
|
|
22
|
-
"Base controller selection remains host-owned and is not changed by this tool.",
|
|
23
|
-
].join("\n");
|
|
24
|
-
const ROLE_ROUTE_FIELDS = new Set(["provider", "model", "reasoningEffort", "maxTokens"]);
|
|
25
|
-
const STORE_SCHEMA_VERSION = 1;
|
|
26
|
-
|
|
27
|
-
function assertPlainObject(value, field) {
|
|
28
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
29
|
-
throw new TypeError(`${field} must be an object`);
|
|
30
|
-
}
|
|
31
|
-
return value;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function resolveRoleRoute(value, role) {
|
|
35
|
-
if (value === undefined) return undefined;
|
|
36
|
-
if (!CONFIGURABLE_ROLES.includes(role)) throw new TypeError(`unknown odai routing responsibility: ${role}`);
|
|
37
|
-
const route = assertPlainObject(value, `routing role ${role}`);
|
|
38
|
-
const unknown = Object.keys(route).filter((field) => !ROLE_ROUTE_FIELDS.has(field));
|
|
39
|
-
if (unknown.length > 0) {
|
|
40
|
-
throw new TypeError(`routing role ${role} has unknown fields: ${unknown.join(", ")}`);
|
|
41
|
-
}
|
|
42
|
-
if (typeof route.provider !== "string" || route.provider.trim() === "") {
|
|
43
|
-
throw new TypeError(`routing role ${role}.provider must be a non-empty string`);
|
|
44
|
-
}
|
|
45
|
-
if (typeof route.model !== "string" || route.model.trim() === "") {
|
|
46
|
-
throw new TypeError(`routing role ${role}.model must be a non-empty string`);
|
|
47
|
-
}
|
|
48
|
-
if (route.reasoningEffort !== undefined
|
|
49
|
-
&& (typeof route.reasoningEffort !== "string" || route.reasoningEffort.trim() === "")) {
|
|
50
|
-
throw new TypeError(`routing role ${role}.reasoningEffort must be a non-empty string`);
|
|
51
|
-
}
|
|
52
|
-
if (route.maxTokens !== undefined
|
|
53
|
-
&& (!Number.isSafeInteger(route.maxTokens) || route.maxTokens <= 0)) {
|
|
54
|
-
throw new TypeError(`routing role ${role}.maxTokens must be a positive integer`);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return Object.freeze({
|
|
58
|
-
provider: route.provider.trim(),
|
|
59
|
-
model: route.model.trim(),
|
|
60
|
-
...(route.reasoningEffort === undefined ? {} : { reasoningEffort: route.reasoningEffort.trim() }),
|
|
61
|
-
...(route.maxTokens === undefined ? {} : { maxTokens: route.maxTokens }),
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function resolveRoutingConfigPath(configuredPath, env = process.env) {
|
|
66
|
-
if (configuredPath !== undefined) {
|
|
67
|
-
if (typeof configuredPath !== "string" || configuredPath.trim() === "") {
|
|
68
|
-
throw new TypeError("config.routing.configPath must be a non-empty string");
|
|
69
|
-
}
|
|
70
|
-
return resolve(configuredPath.trim());
|
|
71
|
-
}
|
|
72
|
-
const dshHome = typeof env.DSH_HOME === "string" && env.DSH_HOME.trim() !== ""
|
|
73
|
-
? resolve(env.DSH_HOME.trim())
|
|
74
|
-
: resolve(homedir(), ".dsh");
|
|
75
|
-
return resolve(dshHome, "odai", "routing.json");
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export function readRoutingStore(configPath) {
|
|
79
|
-
if (!existsSync(configPath)) {
|
|
80
|
-
return Object.freeze({ schemaVersion: STORE_SCHEMA_VERSION, roles: Object.freeze({}) });
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
let parsed;
|
|
84
|
-
try {
|
|
85
|
-
parsed = JSON.parse(readFileSync(configPath, "utf8"));
|
|
86
|
-
} catch (error) {
|
|
87
|
-
throw new Error(`cannot read odai routing config ${configPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
88
|
-
}
|
|
89
|
-
const store = assertPlainObject(parsed, `odai routing config ${configPath}`);
|
|
90
|
-
const unknownStoreFields = Object.keys(store).filter((field) => !["schemaVersion", "roles"].includes(field));
|
|
91
|
-
if (unknownStoreFields.length > 0) {
|
|
92
|
-
throw new TypeError(`odai routing config ${configPath} has unknown fields: ${unknownStoreFields.join(", ")}`);
|
|
93
|
-
}
|
|
94
|
-
if (store.schemaVersion !== STORE_SCHEMA_VERSION) {
|
|
95
|
-
throw new TypeError(`odai routing config ${configPath} has unsupported schemaVersion ${String(store.schemaVersion)}`);
|
|
96
|
-
}
|
|
97
|
-
const rawRoles = assertPlainObject(store.roles, `odai routing config ${configPath}.roles`);
|
|
98
|
-
const unknown = Object.keys(rawRoles).filter((role) => !CONFIGURABLE_ROLES.includes(role));
|
|
99
|
-
if (unknown.length > 0) {
|
|
100
|
-
throw new TypeError(`odai routing config ${configPath} has unknown roles: ${unknown.join(", ")}`);
|
|
101
|
-
}
|
|
102
|
-
return Object.freeze({
|
|
103
|
-
schemaVersion: STORE_SCHEMA_VERSION,
|
|
104
|
-
roles: Object.freeze(Object.fromEntries(CONFIGURABLE_ROLES.flatMap((role) => {
|
|
105
|
-
const route = resolveRoleRoute(rawRoles[role], role);
|
|
106
|
-
return route ? [[role, route]] : [];
|
|
107
|
-
}))),
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
export function effectiveRoleRoute(configPath, configuredRoles, role) {
|
|
112
|
-
return readRoutingStore(configPath).roles[role] ?? configuredRoles[role];
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function writeRoutingStore(configPath, roles) {
|
|
116
|
-
mkdirSync(dirname(configPath), { recursive: true, mode: 0o700 });
|
|
117
|
-
const temporary = `${configPath}.tmp-${process.pid}-${randomUUID()}`;
|
|
118
|
-
const value = `${JSON.stringify({ schemaVersion: STORE_SCHEMA_VERSION, roles }, null, 2)}\n`;
|
|
119
|
-
try {
|
|
120
|
-
writeFileSync(temporary, value, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
121
|
-
renameSync(temporary, configPath);
|
|
122
|
-
} finally {
|
|
123
|
-
rmSync(temporary, { force: true });
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function acquireRoutingStoreLock(configPath) {
|
|
128
|
-
mkdirSync(dirname(configPath), { recursive: true, mode: 0o700 });
|
|
129
|
-
const lockPath = `${configPath}.lock`;
|
|
130
|
-
const create = () => writeFileSync(lockPath, `${process.pid}\n`, {
|
|
131
|
-
encoding: "utf8",
|
|
132
|
-
mode: 0o600,
|
|
133
|
-
flag: "wx",
|
|
134
|
-
});
|
|
135
|
-
try {
|
|
136
|
-
create();
|
|
137
|
-
} catch (error) {
|
|
138
|
-
if (error?.code !== "EEXIST") throw error;
|
|
139
|
-
const stale = (() => {
|
|
140
|
-
try {
|
|
141
|
-
return Date.now() - statSync(lockPath).mtimeMs > 30_000;
|
|
142
|
-
} catch {
|
|
143
|
-
return false;
|
|
144
|
-
}
|
|
145
|
-
})();
|
|
146
|
-
if (!stale) throw new Error("Odai routing configuration is being updated; retry the tool call");
|
|
147
|
-
rmSync(lockPath, { force: true });
|
|
148
|
-
create();
|
|
149
|
-
}
|
|
150
|
-
return () => rmSync(lockPath, { force: true });
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function preserveInvalidRoutingStore(configPath) {
|
|
154
|
-
if (!existsSync(configPath)) return;
|
|
155
|
-
renameSync(configPath, `${configPath}.invalid-${Date.now()}-${randomUUID()}`);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function routeSchema(required) {
|
|
159
|
-
return {
|
|
160
|
-
type: "object",
|
|
161
|
-
additionalProperties: false,
|
|
162
|
-
...(required ? { required: ["provider", "model"] } : {}),
|
|
163
|
-
properties: {
|
|
164
|
-
provider: { type: "string" },
|
|
165
|
-
model: { type: "string" },
|
|
166
|
-
reasoningEffort: { type: "string" },
|
|
167
|
-
maxTokens: { type: "integer" },
|
|
168
|
-
},
|
|
169
|
-
};
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function resultFor(configPath, action, roles, responsibility, recoveredInvalidStore = false) {
|
|
173
|
-
return {
|
|
174
|
-
action,
|
|
175
|
-
...(responsibility ? { responsibility } : {}),
|
|
176
|
-
configPath,
|
|
177
|
-
roles,
|
|
178
|
-
requiresNextTurn: action !== "show",
|
|
179
|
-
...(recoveredInvalidStore ? { recoveredInvalidStore: true } : {}),
|
|
180
|
-
};
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
export function createRoutingConfigTool(configPath) {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
"
|
|
189
|
-
"
|
|
190
|
-
"
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
...(route.
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
...(value.
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
if (!
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
...(args.
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
let
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
}
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
renameSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
statSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { dirname, resolve } from "node:path";
|
|
13
|
+
|
|
14
|
+
export const CONFIGURABLE_ROLES = Object.freeze(["planner", "executor", "reviewer"]);
|
|
15
|
+
export const ROUTING_CONFIG_PROMPT = [
|
|
16
|
+
"## Odai responsibility model configuration",
|
|
17
|
+
"When the user naturally asks to inspect, set, change, or remove the planning/planner, execution/executor, or review/acceptance/reviewer model, use odai_routing_config.",
|
|
18
|
+
"Translate the user's natural responsibility wording into the tool's responsibility field. Do not require internal routing terms or a special prompt form.",
|
|
19
|
+
"For set, call the tool only after the user explicitly supplies both provider and model. Pass reasoningEffort or maxTokens only when the user supplies them; otherwise omit them.",
|
|
20
|
+
"Never infer, recommend as chosen, or silently select any provider, model, effort, or token limit. Ask a concise clarification when provider or model is ambiguous.",
|
|
21
|
+
"Do not ask the user to edit YAML, JSON, managed Agent files, or Plugin configuration. The tool owns persistence. A set/remove change applies from the next user turn.",
|
|
22
|
+
"Base controller selection remains host-owned and is not changed by this tool.",
|
|
23
|
+
].join("\n");
|
|
24
|
+
const ROLE_ROUTE_FIELDS = new Set(["provider", "model", "reasoningEffort", "maxTokens"]);
|
|
25
|
+
const STORE_SCHEMA_VERSION = 1;
|
|
26
|
+
|
|
27
|
+
function assertPlainObject(value, field) {
|
|
28
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
29
|
+
throw new TypeError(`${field} must be an object`);
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function resolveRoleRoute(value, role) {
|
|
35
|
+
if (value === undefined) return undefined;
|
|
36
|
+
if (!CONFIGURABLE_ROLES.includes(role)) throw new TypeError(`unknown odai routing responsibility: ${role}`);
|
|
37
|
+
const route = assertPlainObject(value, `routing role ${role}`);
|
|
38
|
+
const unknown = Object.keys(route).filter((field) => !ROLE_ROUTE_FIELDS.has(field));
|
|
39
|
+
if (unknown.length > 0) {
|
|
40
|
+
throw new TypeError(`routing role ${role} has unknown fields: ${unknown.join(", ")}`);
|
|
41
|
+
}
|
|
42
|
+
if (typeof route.provider !== "string" || route.provider.trim() === "") {
|
|
43
|
+
throw new TypeError(`routing role ${role}.provider must be a non-empty string`);
|
|
44
|
+
}
|
|
45
|
+
if (typeof route.model !== "string" || route.model.trim() === "") {
|
|
46
|
+
throw new TypeError(`routing role ${role}.model must be a non-empty string`);
|
|
47
|
+
}
|
|
48
|
+
if (route.reasoningEffort !== undefined
|
|
49
|
+
&& (typeof route.reasoningEffort !== "string" || route.reasoningEffort.trim() === "")) {
|
|
50
|
+
throw new TypeError(`routing role ${role}.reasoningEffort must be a non-empty string`);
|
|
51
|
+
}
|
|
52
|
+
if (route.maxTokens !== undefined
|
|
53
|
+
&& (!Number.isSafeInteger(route.maxTokens) || route.maxTokens <= 0)) {
|
|
54
|
+
throw new TypeError(`routing role ${role}.maxTokens must be a positive integer`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return Object.freeze({
|
|
58
|
+
provider: route.provider.trim(),
|
|
59
|
+
model: route.model.trim(),
|
|
60
|
+
...(route.reasoningEffort === undefined ? {} : { reasoningEffort: route.reasoningEffort.trim() }),
|
|
61
|
+
...(route.maxTokens === undefined ? {} : { maxTokens: route.maxTokens }),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function resolveRoutingConfigPath(configuredPath, env = process.env) {
|
|
66
|
+
if (configuredPath !== undefined) {
|
|
67
|
+
if (typeof configuredPath !== "string" || configuredPath.trim() === "") {
|
|
68
|
+
throw new TypeError("config.routing.configPath must be a non-empty string");
|
|
69
|
+
}
|
|
70
|
+
return resolve(configuredPath.trim());
|
|
71
|
+
}
|
|
72
|
+
const dshHome = typeof env.DSH_HOME === "string" && env.DSH_HOME.trim() !== ""
|
|
73
|
+
? resolve(env.DSH_HOME.trim())
|
|
74
|
+
: resolve(homedir(), ".dsh");
|
|
75
|
+
return resolve(dshHome, "odai", "routing.json");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function readRoutingStore(configPath) {
|
|
79
|
+
if (!existsSync(configPath)) {
|
|
80
|
+
return Object.freeze({ schemaVersion: STORE_SCHEMA_VERSION, roles: Object.freeze({}) });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let parsed;
|
|
84
|
+
try {
|
|
85
|
+
parsed = JSON.parse(readFileSync(configPath, "utf8"));
|
|
86
|
+
} catch (error) {
|
|
87
|
+
throw new Error(`cannot read odai routing config ${configPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
88
|
+
}
|
|
89
|
+
const store = assertPlainObject(parsed, `odai routing config ${configPath}`);
|
|
90
|
+
const unknownStoreFields = Object.keys(store).filter((field) => !["schemaVersion", "roles"].includes(field));
|
|
91
|
+
if (unknownStoreFields.length > 0) {
|
|
92
|
+
throw new TypeError(`odai routing config ${configPath} has unknown fields: ${unknownStoreFields.join(", ")}`);
|
|
93
|
+
}
|
|
94
|
+
if (store.schemaVersion !== STORE_SCHEMA_VERSION) {
|
|
95
|
+
throw new TypeError(`odai routing config ${configPath} has unsupported schemaVersion ${String(store.schemaVersion)}`);
|
|
96
|
+
}
|
|
97
|
+
const rawRoles = assertPlainObject(store.roles, `odai routing config ${configPath}.roles`);
|
|
98
|
+
const unknown = Object.keys(rawRoles).filter((role) => !CONFIGURABLE_ROLES.includes(role));
|
|
99
|
+
if (unknown.length > 0) {
|
|
100
|
+
throw new TypeError(`odai routing config ${configPath} has unknown roles: ${unknown.join(", ")}`);
|
|
101
|
+
}
|
|
102
|
+
return Object.freeze({
|
|
103
|
+
schemaVersion: STORE_SCHEMA_VERSION,
|
|
104
|
+
roles: Object.freeze(Object.fromEntries(CONFIGURABLE_ROLES.flatMap((role) => {
|
|
105
|
+
const route = resolveRoleRoute(rawRoles[role], role);
|
|
106
|
+
return route ? [[role, route]] : [];
|
|
107
|
+
}))),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function effectiveRoleRoute(configPath, configuredRoles, role) {
|
|
112
|
+
return readRoutingStore(configPath).roles[role] ?? configuredRoles[role];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function writeRoutingStore(configPath, roles) {
|
|
116
|
+
mkdirSync(dirname(configPath), { recursive: true, mode: 0o700 });
|
|
117
|
+
const temporary = `${configPath}.tmp-${process.pid}-${randomUUID()}`;
|
|
118
|
+
const value = `${JSON.stringify({ schemaVersion: STORE_SCHEMA_VERSION, roles }, null, 2)}\n`;
|
|
119
|
+
try {
|
|
120
|
+
writeFileSync(temporary, value, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
121
|
+
renameSync(temporary, configPath);
|
|
122
|
+
} finally {
|
|
123
|
+
rmSync(temporary, { force: true });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function acquireRoutingStoreLock(configPath) {
|
|
128
|
+
mkdirSync(dirname(configPath), { recursive: true, mode: 0o700 });
|
|
129
|
+
const lockPath = `${configPath}.lock`;
|
|
130
|
+
const create = () => writeFileSync(lockPath, `${process.pid}\n`, {
|
|
131
|
+
encoding: "utf8",
|
|
132
|
+
mode: 0o600,
|
|
133
|
+
flag: "wx",
|
|
134
|
+
});
|
|
135
|
+
try {
|
|
136
|
+
create();
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (error?.code !== "EEXIST") throw error;
|
|
139
|
+
const stale = (() => {
|
|
140
|
+
try {
|
|
141
|
+
return Date.now() - statSync(lockPath).mtimeMs > 30_000;
|
|
142
|
+
} catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
})();
|
|
146
|
+
if (!stale) throw new Error("Odai routing configuration is being updated; retry the tool call");
|
|
147
|
+
rmSync(lockPath, { force: true });
|
|
148
|
+
create();
|
|
149
|
+
}
|
|
150
|
+
return () => rmSync(lockPath, { force: true });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function preserveInvalidRoutingStore(configPath) {
|
|
154
|
+
if (!existsSync(configPath)) return;
|
|
155
|
+
renameSync(configPath, `${configPath}.invalid-${Date.now()}-${randomUUID()}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function routeSchema(required) {
|
|
159
|
+
return {
|
|
160
|
+
type: "object",
|
|
161
|
+
additionalProperties: false,
|
|
162
|
+
...(required ? { required: ["provider", "model"] } : {}),
|
|
163
|
+
properties: {
|
|
164
|
+
provider: { type: "string" },
|
|
165
|
+
model: { type: "string" },
|
|
166
|
+
reasoningEffort: { type: "string" },
|
|
167
|
+
maxTokens: { type: "integer" },
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function resultFor(configPath, action, roles, responsibility, recoveredInvalidStore = false) {
|
|
173
|
+
return {
|
|
174
|
+
action,
|
|
175
|
+
...(responsibility ? { responsibility } : {}),
|
|
176
|
+
configPath,
|
|
177
|
+
roles,
|
|
178
|
+
requiresNextTurn: action !== "show",
|
|
179
|
+
...(recoveredInvalidStore ? { recoveredInvalidStore: true } : {}),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function createRoutingConfigTool(configPath, options = {}) {
|
|
184
|
+
const onConfigured = typeof options.onConfigured === "function" ? options.onConfigured : () => {};
|
|
185
|
+
return {
|
|
186
|
+
name: "odai_routing_config",
|
|
187
|
+
description: [
|
|
188
|
+
"Inspect, set, or remove Odai model mappings for planner, executor, and reviewer responsibilities.",
|
|
189
|
+
"Use this only when the user naturally and explicitly asks to inspect/remove a mapping or names the provider/model to set.",
|
|
190
|
+
"Never choose a provider, model, reasoning effort, or token limit on the user's behalf.",
|
|
191
|
+
"For set/remove, handle one responsibility per call. Persisted mappings are shared by the Odai DSH Plugin and Agent and apply from the next user turn.",
|
|
192
|
+
].join(" "),
|
|
193
|
+
parameters: {
|
|
194
|
+
type: "object",
|
|
195
|
+
additionalProperties: false,
|
|
196
|
+
required: ["action"],
|
|
197
|
+
properties: {
|
|
198
|
+
action: {
|
|
199
|
+
type: "string",
|
|
200
|
+
enum: ["show", "set", "remove"],
|
|
201
|
+
description: "Show all mappings, set one user-specified mapping, or remove one mapping.",
|
|
202
|
+
},
|
|
203
|
+
responsibility: {
|
|
204
|
+
type: "string",
|
|
205
|
+
enum: [...CONFIGURABLE_ROLES],
|
|
206
|
+
description: "Required for set/remove; omitted for show.",
|
|
207
|
+
},
|
|
208
|
+
provider: {
|
|
209
|
+
type: "string",
|
|
210
|
+
description: "Provider id explicitly supplied by the user; required for set.",
|
|
211
|
+
},
|
|
212
|
+
model: {
|
|
213
|
+
type: "string",
|
|
214
|
+
description: "Model id explicitly supplied by the user; required for set.",
|
|
215
|
+
},
|
|
216
|
+
reasoningEffort: {
|
|
217
|
+
type: "string",
|
|
218
|
+
description: "Optional reasoning effort explicitly supplied by the user.",
|
|
219
|
+
},
|
|
220
|
+
maxTokens: {
|
|
221
|
+
type: "integer",
|
|
222
|
+
description: "Optional positive child output limit explicitly supplied by the user.",
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
output: {
|
|
227
|
+
schema: {
|
|
228
|
+
type: "object",
|
|
229
|
+
additionalProperties: false,
|
|
230
|
+
required: ["action", "configPath", "roles", "requiresNextTurn"],
|
|
231
|
+
properties: {
|
|
232
|
+
action: { type: "string", enum: ["show", "set", "remove"] },
|
|
233
|
+
responsibility: { type: "string", enum: [...CONFIGURABLE_ROLES] },
|
|
234
|
+
configPath: { type: "string" },
|
|
235
|
+
roles: {
|
|
236
|
+
type: "object",
|
|
237
|
+
additionalProperties: false,
|
|
238
|
+
properties: Object.fromEntries(CONFIGURABLE_ROLES.map((role) => [role, routeSchema(true)])),
|
|
239
|
+
},
|
|
240
|
+
requiresNextTurn: { type: "boolean" },
|
|
241
|
+
recoveredInvalidStore: { type: "boolean" },
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
render(_args, value) {
|
|
245
|
+
const mapping = Object.entries(value.roles)
|
|
246
|
+
.map(([role, route]) => {
|
|
247
|
+
const options = [
|
|
248
|
+
...(route.reasoningEffort ? [`reasoningEffort=${route.reasoningEffort}`] : []),
|
|
249
|
+
...(route.maxTokens ? [`maxTokens=${route.maxTokens}`] : []),
|
|
250
|
+
];
|
|
251
|
+
return `${role}: ${route.provider}/${route.model}${options.length > 0 ? ` (${options.join(", ")})` : ""}`;
|
|
252
|
+
})
|
|
253
|
+
.join("\n") || "No Odai responsibility models are configured.";
|
|
254
|
+
return [{
|
|
255
|
+
type: "text",
|
|
256
|
+
text: [
|
|
257
|
+
`${value.action === "show" ? "Current" : "Updated"} Odai routing configuration:\n${mapping}`,
|
|
258
|
+
...(value.recoveredInvalidStore ? ["\nAn invalid prior store was preserved and replaced."] : []),
|
|
259
|
+
...(value.requiresNextTurn ? ["\nThe change applies from the next user turn."] : []),
|
|
260
|
+
].join(""),
|
|
261
|
+
}];
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
execute(args, execution) {
|
|
265
|
+
if (!execution.agent) throw new Error("odai_routing_config requires an owning agent session");
|
|
266
|
+
const header = execution.agent.session?.header;
|
|
267
|
+
if (header?.origin === "subagent" || (Number.isSafeInteger(header?.delegationDepth) && header.delegationDepth > 0)) {
|
|
268
|
+
throw new Error("child agents may not change Odai routing configuration");
|
|
269
|
+
}
|
|
270
|
+
if (!args || typeof args !== "object" || Array.isArray(args)) throw new TypeError("arguments must be an object");
|
|
271
|
+
if (!["show", "set", "remove"].includes(args.action)) throw new TypeError("action must be show, set, or remove");
|
|
272
|
+
|
|
273
|
+
if (args.action === "show") {
|
|
274
|
+
return Promise.resolve(resultFor(configPath, "show", readRoutingStore(configPath).roles));
|
|
275
|
+
}
|
|
276
|
+
if (!CONFIGURABLE_ROLES.includes(args.responsibility)) {
|
|
277
|
+
throw new TypeError("responsibility must be planner, executor, or reviewer for set/remove");
|
|
278
|
+
}
|
|
279
|
+
const proposedRoute = args.action === "set" ? resolveRoleRoute({
|
|
280
|
+
provider: args.provider,
|
|
281
|
+
model: args.model,
|
|
282
|
+
...(args.reasoningEffort === undefined ? {} : { reasoningEffort: args.reasoningEffort }),
|
|
283
|
+
...(args.maxTokens === undefined ? {} : { maxTokens: args.maxTokens }),
|
|
284
|
+
}, args.responsibility) : undefined;
|
|
285
|
+
|
|
286
|
+
const releaseLock = acquireRoutingStoreLock(configPath);
|
|
287
|
+
try {
|
|
288
|
+
let current;
|
|
289
|
+
let recoveredInvalidStore = false;
|
|
290
|
+
try {
|
|
291
|
+
current = readRoutingStore(configPath);
|
|
292
|
+
} catch (error) {
|
|
293
|
+
if (args.action !== "set") {
|
|
294
|
+
throw new Error("Odai routing configuration is invalid; ask the user to set a responsibility mapping to repair it automatically", { cause: error });
|
|
295
|
+
}
|
|
296
|
+
preserveInvalidRoutingStore(configPath);
|
|
297
|
+
current = { roles: {} };
|
|
298
|
+
recoveredInvalidStore = true;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const roles = { ...current.roles };
|
|
302
|
+
if (args.action === "remove") delete roles[args.responsibility];
|
|
303
|
+
else roles[args.responsibility] = proposedRoute;
|
|
304
|
+
writeRoutingStore(configPath, roles);
|
|
305
|
+
onConfigured(execution.agent, {
|
|
306
|
+
action: args.action,
|
|
307
|
+
responsibility: args.responsibility,
|
|
308
|
+
route: roles[args.responsibility],
|
|
309
|
+
...(recoveredInvalidStore ? { recoveredInvalidStore: true } : {}),
|
|
310
|
+
});
|
|
311
|
+
return Promise.resolve(resultFor(
|
|
312
|
+
configPath,
|
|
313
|
+
args.action,
|
|
314
|
+
roles,
|
|
315
|
+
args.responsibility,
|
|
316
|
+
recoveredInvalidStore,
|
|
317
|
+
));
|
|
318
|
+
} finally {
|
|
319
|
+
releaseLock();
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
}
|