odai-dsh-agent 0.0.6 → 0.0.7
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/README.md
CHANGED
|
@@ -53,6 +53,22 @@ The controller calls `odai_routing_config` to persist that explicit choice. The
|
|
|
53
53
|
|
|
54
54
|
Planner, executor, and reviewer are independent optional responsibilities. If any needed one has no mapping, high-impact work fails closed and remains read-only; lower-impact work continues only where it does not depend on the missing independent responsibility. Odai never chooses a model on the user's behalf.
|
|
55
55
|
|
|
56
|
+
## Controller output policy
|
|
57
|
+
|
|
58
|
+
The Agent defaults to **soft concise** output and shares three controller output modes with the Plugin. A user can naturally ask to inspect or change the mode; `odai_output_config` persists an explicit override in `$DSH_HOME/odai/output.json`:
|
|
59
|
+
|
|
60
|
+
| Mode | Policy | Behavior |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| normal | `concise: false`, no `maxTokens` | use the host's normal presentation and controller budget |
|
|
63
|
+
| soft concise (default) | `concise: true`, no `maxTokens` | shorten only the final user-facing presentation without relaxing required results, evidence, risks, blockers, or verification |
|
|
64
|
+
| economy (optional) | `concise: true`, positive `maxTokens` | add a provider output-ceiling request; default to `500` when the user names economy without another value, or use the user's supplied positive value |
|
|
65
|
+
|
|
66
|
+
Natural requests include `use normal output`, `use soft concise output`, `enable economy mode`, and `set economy mode to 1200 tokens`. Removing the persisted override restores soft concise. Existing pre-mode stores that combined `concise: false` with a ceiling remain readable for compatibility, but new named-mode changes cannot create that legacy combination. The selected mode is stable within one turn and changes from the next user turn.
|
|
67
|
+
|
|
68
|
+
An economy ceiling only tightens an existing lower host request value and is not a locally enforceable hard billing boundary. A provider may count hidden reasoning inside it, exceed or ignore it, or end before useful final text, especially at a high reasoning effort; strict compliance must be checked from per-request usage. Odai enables economy only when requested and never invents a non-default custom value. The mode does not alter child-agent role budgets, compaction, checkpoints, or other internal context; an incomplete token-capped compaction fails closed instead of replacing history.
|
|
69
|
+
|
|
70
|
+
A same-provider/model compaction that inherits controller reasoning also requests long prompt-cache retention while keeping its independent summary budget. Explicit compaction retention is preserved. `ODAI_COMPACTION_CACHE_RETENTION=provider-default` restores the host default; `short`, `long`, and `none` are also accepted deployment overrides. The first controller request after a landed summary still rebuilds the changed summary prefix.
|
|
71
|
+
|
|
56
72
|
## Skill sources
|
|
57
73
|
|
|
58
74
|
The Agent keeps the managed preset's complete skill copy as its `bundled` default, so existing installations do not change behavior. When the user explicitly asks to show, set, or reset the Odai skill source, the controller uses `odai_skill_source_config` and stores the choice in `$DSH_HOME/odai/source.json`, outside the managed preset:
|
package/package.json
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
activeRouteProtection,
|
|
17
17
|
createChildToolGuard,
|
|
18
18
|
createRouteProtectionGuard,
|
|
19
|
+
isSubagent,
|
|
19
20
|
summarizeToolResult,
|
|
20
21
|
} from "./governance.mjs";
|
|
21
22
|
import {
|
|
@@ -26,6 +27,14 @@ import {
|
|
|
26
27
|
resolveRoleRoute,
|
|
27
28
|
resolveRoutingConfigPath,
|
|
28
29
|
} from "./routing-config.mjs";
|
|
30
|
+
import {
|
|
31
|
+
DEFAULT_OUTPUT_POLICY,
|
|
32
|
+
createOutputConfigTool,
|
|
33
|
+
effectiveOutputPolicy,
|
|
34
|
+
renderOutputPolicyPrompt,
|
|
35
|
+
resolveOutputConfigPath,
|
|
36
|
+
} from "./output-config.mjs";
|
|
37
|
+
import { selectSharedOutputPolicyForTurn } from "./output-policy-state.mjs";
|
|
29
38
|
import { createSessionEvidence, resolveSessionEvidenceRoot } from "./session-evidence.mjs";
|
|
30
39
|
import {
|
|
31
40
|
SKILL_SOURCE_CONFIG_PROMPT,
|
|
@@ -50,6 +59,7 @@ export const inject = ["systemPrompt", "tools", "subagents", "sessions"];
|
|
|
50
59
|
|
|
51
60
|
const PLUGIN_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
52
61
|
const ROUTING_MODES = new Set(["off", "observe", "auto", "execute"]);
|
|
62
|
+
const COMPACTION_CACHE_RETENTIONS = new Set(["provider-default", "short", "long", "none"]);
|
|
53
63
|
const ROUTED_ROLES = CONFIGURABLE_ROLES;
|
|
54
64
|
const DEFAULT_ROUTING_MODE = "auto";
|
|
55
65
|
|
|
@@ -66,6 +76,8 @@ export function resolveConfig(rawConfig = {}) {
|
|
|
66
76
|
const routing = assertPlainObject(raw.routing, "config.routing");
|
|
67
77
|
const roles = assertPlainObject(routing.roles, "config.routing.roles");
|
|
68
78
|
const governance = assertPlainObject(raw.governance, "config.governance");
|
|
79
|
+
const output = assertPlainObject(raw.output, "config.output");
|
|
80
|
+
const compaction = assertPlainObject(raw.compaction, "config.compaction");
|
|
69
81
|
const mode = routing.mode ?? DEFAULT_ROUTING_MODE;
|
|
70
82
|
const provider = routing.provider ?? "spawn";
|
|
71
83
|
const maxInputChars = routing.maxInputChars ?? 12_000;
|
|
@@ -73,7 +85,13 @@ export function resolveConfig(rawConfig = {}) {
|
|
|
73
85
|
const additionalDeniedTools = governance.additionalDeniedTools ?? [];
|
|
74
86
|
const skillSource = governance.skillSource ?? "bundled";
|
|
75
87
|
const skillConfigPath = resolveSkillSourceConfigPath(governance.skillConfigPath);
|
|
88
|
+
const outputConfigPath = resolveOutputConfigPath(output.configPath);
|
|
89
|
+
const compactionCacheRetention = compaction.cacheRetention
|
|
90
|
+
?? process.env.ODAI_COMPACTION_CACHE_RETENTION
|
|
91
|
+
?? "long";
|
|
76
92
|
const unknownRoles = Object.keys(roles).filter((role) => !ROUTED_ROLES.includes(role));
|
|
93
|
+
const unknownOutputFields = Object.keys(output).filter((field) => field !== "configPath");
|
|
94
|
+
const unknownCompactionFields = Object.keys(compaction).filter((field) => field !== "cacheRetention");
|
|
77
95
|
|
|
78
96
|
if (!ROUTING_MODES.has(mode)) {
|
|
79
97
|
throw new TypeError("config.routing.mode must be off, observe, auto, or execute");
|
|
@@ -87,6 +105,15 @@ export function resolveConfig(rawConfig = {}) {
|
|
|
87
105
|
if (unknownRoles.length > 0) {
|
|
88
106
|
throw new TypeError(`config.routing.roles has unknown roles: ${unknownRoles.join(", ")}`);
|
|
89
107
|
}
|
|
108
|
+
if (unknownOutputFields.length > 0) {
|
|
109
|
+
throw new TypeError(`config.output has unknown fields: ${unknownOutputFields.join(", ")}`);
|
|
110
|
+
}
|
|
111
|
+
if (unknownCompactionFields.length > 0) {
|
|
112
|
+
throw new TypeError(`config.compaction has unknown fields: ${unknownCompactionFields.join(", ")}`);
|
|
113
|
+
}
|
|
114
|
+
if (!COMPACTION_CACHE_RETENTIONS.has(compactionCacheRetention)) {
|
|
115
|
+
throw new TypeError("config.compaction.cacheRetention must be provider-default, short, long, or none");
|
|
116
|
+
}
|
|
90
117
|
if (!Array.isArray(additionalDeniedTools)
|
|
91
118
|
|| additionalDeniedTools.some((tool) => typeof tool !== "string" || tool.trim() === "")) {
|
|
92
119
|
throw new TypeError("config.governance.additionalDeniedTools must be an array of non-empty strings");
|
|
@@ -115,10 +142,12 @@ export function resolveConfig(rawConfig = {}) {
|
|
|
115
142
|
skillSource,
|
|
116
143
|
skillConfigPath,
|
|
117
144
|
}),
|
|
145
|
+
output: Object.freeze({ configPath: outputConfigPath }),
|
|
146
|
+
compaction: Object.freeze({ cacheRetention: compactionCacheRetention }),
|
|
118
147
|
});
|
|
119
148
|
}
|
|
120
149
|
|
|
121
|
-
export function inheritCompactionReasoning(options, sessions) {
|
|
150
|
+
export function inheritCompactionReasoning(options, sessions, cacheRetention = "long") {
|
|
122
151
|
if (options?.purpose !== "compaction"
|
|
123
152
|
|| options.reasoningEffort !== undefined
|
|
124
153
|
|| options.sessionId === undefined
|
|
@@ -135,6 +164,9 @@ export function inheritCompactionReasoning(options, sessions) {
|
|
|
135
164
|
}
|
|
136
165
|
|
|
137
166
|
options.reasoningEffort = route.reasoningEffort;
|
|
167
|
+
if (options.cacheRetention === undefined && cacheRetention !== "provider-default") {
|
|
168
|
+
options.cacheRetention = cacheRetention;
|
|
169
|
+
}
|
|
138
170
|
return true;
|
|
139
171
|
}
|
|
140
172
|
|
|
@@ -315,7 +347,7 @@ function canonicalPrompt(selection) {
|
|
|
315
347
|
`Canonical skill: ${bundle.manifest.skillVersion}; runtime contract: ${bundle.manifest.runtimeContract}; digest: ${bundle.digest}.`,
|
|
316
348
|
...(fallback ? [fallback] : []),
|
|
317
349
|
"Apply this governance to every request. Keep the controller as the final delivery owner; use another role only for a real independent gap with observable net benefit.",
|
|
318
|
-
"Odai governance is already loaded by this runtime; do not call the skill tool to load odai again.",
|
|
350
|
+
"Odai governance is already loaded by this runtime; do not call the skill tool or read SKILL.md to load odai again.",
|
|
319
351
|
"",
|
|
320
352
|
bundle.skillText,
|
|
321
353
|
].join("\n");
|
|
@@ -351,7 +383,7 @@ export function apply(ctx, rawConfig) {
|
|
|
351
383
|
};
|
|
352
384
|
const hasSessionEvent = (agent, type, predicate) => evidence.has(agent, type, predicate);
|
|
353
385
|
ctx.on("llm/stream", (options, next) => {
|
|
354
|
-
inheritCompactionReasoning(options, ctx.sessions);
|
|
386
|
+
inheritCompactionReasoning(options, ctx.sessions, config.compaction.cacheRetention);
|
|
355
387
|
return next();
|
|
356
388
|
});
|
|
357
389
|
const skillPath = resolveSkillPath(config.skillPath);
|
|
@@ -387,6 +419,11 @@ export function apply(ctx, rawConfig) {
|
|
|
387
419
|
order: -18,
|
|
388
420
|
text: SKILL_SOURCE_CONFIG_PROMPT,
|
|
389
421
|
});
|
|
422
|
+
ctx.systemPrompt.section({
|
|
423
|
+
name: "odai:controller-output-policy",
|
|
424
|
+
order: -17,
|
|
425
|
+
text: "",
|
|
426
|
+
});
|
|
390
427
|
|
|
391
428
|
const optionalSkillRegistry = () => {
|
|
392
429
|
try {
|
|
@@ -422,12 +459,28 @@ export function apply(ctx, rawConfig) {
|
|
|
422
459
|
skills: optionalSkillRegistry(),
|
|
423
460
|
});
|
|
424
461
|
};
|
|
462
|
+
const selectOutputForAgent = () => {
|
|
463
|
+
try {
|
|
464
|
+
return effectiveOutputPolicy(config.output.configPath);
|
|
465
|
+
} catch (error) {
|
|
466
|
+
logger.warn(`Odai output configuration is invalid; using the default policy: ${error instanceof Error ? error.message : String(error)}`);
|
|
467
|
+
return Object.freeze({
|
|
468
|
+
policy: DEFAULT_OUTPUT_POLICY,
|
|
469
|
+
source: "default",
|
|
470
|
+
status: "fallback",
|
|
471
|
+
reasonCode: "output-config-invalid",
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
};
|
|
425
475
|
ctx.on("system-prompt/assemble", async (assembly, context, next) => {
|
|
426
476
|
const downstream = await next();
|
|
427
477
|
const agent = context.agent;
|
|
428
478
|
if (!agent) return downstream;
|
|
429
479
|
const selection = await selectSharedSkillForTurn(agent, () => selectForAgent(agent, context));
|
|
430
480
|
const turn = currentAgentTurn(agent);
|
|
481
|
+
const outputSelection = isSubagentSession(agent)
|
|
482
|
+
? Object.freeze({ policy: DEFAULT_OUTPUT_POLICY, source: "default" })
|
|
483
|
+
: await selectSharedOutputPolicyForTurn(agent, turn, selectOutputForAgent);
|
|
431
484
|
const selectionEvidence = {
|
|
432
485
|
...(turn === undefined ? {} : { turn }),
|
|
433
486
|
requestedMode: selection.mode,
|
|
@@ -445,11 +498,21 @@ export function apply(ctx, rawConfig) {
|
|
|
445
498
|
if (selection.status === "fallback") {
|
|
446
499
|
logger.warn(`Odai skill source ${selection.mode} fell back to bundled governance (${selection.reasonCode})`);
|
|
447
500
|
}
|
|
501
|
+
const outputPrompt = renderOutputPolicyPrompt(outputSelection.policy);
|
|
502
|
+
if (outputPrompt && !hasSessionEvent(agent, "odai/output-policy-selected", (data) => data?.turn === turn)) {
|
|
503
|
+
appendEvent(agent, "odai/output-policy-selected", {
|
|
504
|
+
...(turn === undefined ? {} : { turn }),
|
|
505
|
+
source: outputSelection.source,
|
|
506
|
+
policy: outputSelection.policy,
|
|
507
|
+
});
|
|
508
|
+
}
|
|
448
509
|
return {
|
|
449
510
|
...downstream,
|
|
450
|
-
sections: downstream.sections.map((section) =>
|
|
451
|
-
|
|
452
|
-
: section
|
|
511
|
+
sections: downstream.sections.map((section) => {
|
|
512
|
+
if (section.name === "odai:canonical-governance") return { ...section, text: canonicalPrompt(selection) };
|
|
513
|
+
if (section.name === "odai:controller-output-policy") return { ...section, text: outputPrompt };
|
|
514
|
+
return section;
|
|
515
|
+
}),
|
|
453
516
|
};
|
|
454
517
|
});
|
|
455
518
|
|
|
@@ -499,6 +562,12 @@ export function apply(ctx, rawConfig) {
|
|
|
499
562
|
},
|
|
500
563
|
},
|
|
501
564
|
));
|
|
565
|
+
ctx.tools.register(createOutputConfigTool(config.output.configPath, {
|
|
566
|
+
isChild: isSubagent,
|
|
567
|
+
onConfigured(agent, data) {
|
|
568
|
+
appendEvent(agent, "odai/output-configured", data);
|
|
569
|
+
},
|
|
570
|
+
}));
|
|
502
571
|
ctx.tools.guard((execution) => childGuard(execution) ?? routeProtectionGuard(execution));
|
|
503
572
|
|
|
504
573
|
ctx.on("tools/result", (execution, result) => {
|
|
@@ -508,7 +577,7 @@ export function apply(ctx, rawConfig) {
|
|
|
508
577
|
appendEvent(execution.agent, "odai/tool-observed", summary, logger);
|
|
509
578
|
});
|
|
510
579
|
|
|
511
|
-
ctx.on("agent/request", async ({ agent, turn }, next) => {
|
|
580
|
+
ctx.on("agent/request", async ({ agent, turn, step }, next) => {
|
|
512
581
|
const proposed = await next();
|
|
513
582
|
const childRole = routedRoleOf(agent);
|
|
514
583
|
const childRoleState = childRole ? configuredRole(childRole) : undefined;
|
|
@@ -518,16 +587,37 @@ export function apply(ctx, rawConfig) {
|
|
|
518
587
|
: upgrade && upgrade.turn === turn
|
|
519
588
|
? upgrade.route
|
|
520
589
|
: undefined;
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
590
|
+
let request = proposed;
|
|
591
|
+
if (roleRoute) {
|
|
592
|
+
const { reasoningEffort: _inheritedEffort, ...withoutInheritedEffort } = proposed;
|
|
593
|
+
request = Object.freeze({
|
|
594
|
+
...withoutInheritedEffort,
|
|
595
|
+
provider: roleRoute.provider,
|
|
596
|
+
model: roleRoute.model,
|
|
597
|
+
...(roleRoute.reasoningEffort === undefined ? {} : { reasoningEffort: roleRoute.reasoningEffort }),
|
|
598
|
+
...(childRole && roleRoute.maxTokens !== undefined ? { maxTokens: roleRoute.maxTokens } : {}),
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
if (childRole || isSubagentSession(agent)) return request;
|
|
602
|
+
|
|
603
|
+
const outputSelection = await selectSharedOutputPolicyForTurn(agent, turn, selectOutputForAgent);
|
|
604
|
+
const configuredMaxTokens = outputSelection.policy.maxTokens;
|
|
605
|
+
if (configuredMaxTokens === undefined) return request;
|
|
606
|
+
const priorMaxTokens = request.maxTokens;
|
|
607
|
+
const effectiveMaxTokens = priorMaxTokens === undefined
|
|
608
|
+
? configuredMaxTokens
|
|
609
|
+
: Math.min(priorMaxTokens, configuredMaxTokens);
|
|
610
|
+
if (!hasSessionEvent(agent, "odai/output-budget-applied", (data) => data?.turn === turn && data?.step === step)) {
|
|
611
|
+
appendEvent(agent, "odai/output-budget-applied", {
|
|
612
|
+
turn,
|
|
613
|
+
...(step === undefined ? {} : { step }),
|
|
614
|
+
configuredMaxTokens,
|
|
615
|
+
...(priorMaxTokens === undefined ? {} : { priorMaxTokens }),
|
|
616
|
+
effectiveMaxTokens,
|
|
617
|
+
semantics: "provider-request-ceiling",
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
return Object.freeze({ ...request, maxTokens: effectiveMaxTokens });
|
|
531
621
|
}, { prepend: true });
|
|
532
622
|
|
|
533
623
|
const protectController = (agent, turn, step, decision, source, failure) => {
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
renameSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { dirname, resolve } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { acquireOwnedStoreLock } from "./store-lock.mjs";
|
|
13
|
+
|
|
14
|
+
const STORE_SCHEMA_VERSION = 1;
|
|
15
|
+
const POLICY_FIELDS = new Set(["concise", "maxTokens"]);
|
|
16
|
+
const OUTPUT_MODES = new Set(["normal", "concise", "economy"]);
|
|
17
|
+
const DEFAULT_ECONOMY_MAX_TOKENS = 500;
|
|
18
|
+
export const DEFAULT_OUTPUT_POLICY = Object.freeze({ concise: true });
|
|
19
|
+
|
|
20
|
+
export class OutputPolicyStoreValidationError extends Error {}
|
|
21
|
+
|
|
22
|
+
function assertPlainObject(value, field) {
|
|
23
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
24
|
+
throw new TypeError(`${field} must be an object`);
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function resolveOutputPolicy(value, field = "Odai output policy") {
|
|
30
|
+
const policy = assertPlainObject(value, field);
|
|
31
|
+
const unknown = Object.keys(policy).filter((name) => !POLICY_FIELDS.has(name));
|
|
32
|
+
if (unknown.length > 0) throw new TypeError(`${field} has unknown fields: ${unknown.join(", ")}`);
|
|
33
|
+
if (!Object.hasOwn(policy, "concise") || typeof policy.concise !== "boolean") {
|
|
34
|
+
throw new TypeError(`${field}.concise must be an own boolean property`);
|
|
35
|
+
}
|
|
36
|
+
if (policy.maxTokens !== undefined
|
|
37
|
+
&& (!Number.isSafeInteger(policy.maxTokens) || policy.maxTokens <= 0)) {
|
|
38
|
+
throw new TypeError(`${field}.maxTokens must be a positive integer`);
|
|
39
|
+
}
|
|
40
|
+
return Object.freeze({
|
|
41
|
+
concise: policy.concise,
|
|
42
|
+
...(policy.maxTokens === undefined ? {} : { maxTokens: policy.maxTokens }),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function resolveOutputConfigPath(configuredPath, env = process.env) {
|
|
47
|
+
if (configuredPath !== undefined) {
|
|
48
|
+
if (typeof configuredPath !== "string" || configuredPath.trim() === "") {
|
|
49
|
+
throw new TypeError("config.output.configPath must be a non-empty string");
|
|
50
|
+
}
|
|
51
|
+
return resolve(configuredPath.trim());
|
|
52
|
+
}
|
|
53
|
+
const dshHome = typeof env.DSH_HOME === "string" && env.DSH_HOME.trim() !== ""
|
|
54
|
+
? resolve(env.DSH_HOME.trim())
|
|
55
|
+
: resolve(homedir(), ".dsh");
|
|
56
|
+
return resolve(dshHome, "odai", "output.json");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function readOutputPolicyStore(configPath) {
|
|
60
|
+
let text;
|
|
61
|
+
try {
|
|
62
|
+
text = readFileSync(configPath, "utf8");
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (error?.code === "ENOENT") return Object.freeze({ schemaVersion: STORE_SCHEMA_VERSION });
|
|
65
|
+
throw new Error(`cannot read Odai output configuration ${configPath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
66
|
+
}
|
|
67
|
+
let parsed;
|
|
68
|
+
try {
|
|
69
|
+
parsed = JSON.parse(text);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
throw new OutputPolicyStoreValidationError(`Odai output configuration ${configPath} is not valid JSON`, { cause: error });
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const store = assertPlainObject(parsed, `Odai output configuration ${configPath}`);
|
|
75
|
+
const unknown = Object.keys(store).filter((field) => !["schemaVersion", "policy"].includes(field));
|
|
76
|
+
if (unknown.length > 0) {
|
|
77
|
+
throw new TypeError(`Odai output configuration ${configPath} has unknown fields: ${unknown.join(", ")}`);
|
|
78
|
+
}
|
|
79
|
+
if (store.schemaVersion !== STORE_SCHEMA_VERSION) {
|
|
80
|
+
throw new TypeError(`Odai output configuration ${configPath} has unsupported schemaVersion ${String(store.schemaVersion)}`);
|
|
81
|
+
}
|
|
82
|
+
if (store.policy === undefined) return Object.freeze({ schemaVersion: STORE_SCHEMA_VERSION });
|
|
83
|
+
return Object.freeze({
|
|
84
|
+
schemaVersion: STORE_SCHEMA_VERSION,
|
|
85
|
+
policy: resolveOutputPolicy(store.policy, `Odai output configuration ${configPath}.policy`),
|
|
86
|
+
});
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (error instanceof OutputPolicyStoreValidationError) throw error;
|
|
89
|
+
throw new OutputPolicyStoreValidationError(
|
|
90
|
+
`Odai output configuration ${configPath} failed validation: ${error instanceof Error ? error.message : String(error)}`,
|
|
91
|
+
{ cause: error },
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function effectiveOutputPolicy(configPath) {
|
|
97
|
+
const stored = readOutputPolicyStore(configPath);
|
|
98
|
+
return Object.freeze({
|
|
99
|
+
policy: stored.policy ?? DEFAULT_OUTPUT_POLICY,
|
|
100
|
+
source: stored.policy ? "persisted" : "default",
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function writeOutputPolicyStore(configPath, policy) {
|
|
105
|
+
mkdirSync(dirname(configPath), { recursive: true, mode: 0o700 });
|
|
106
|
+
const temporary = `${configPath}.tmp-${process.pid}-${randomUUID()}`;
|
|
107
|
+
const value = `${JSON.stringify({ schemaVersion: STORE_SCHEMA_VERSION, policy }, null, 2)}\n`;
|
|
108
|
+
try {
|
|
109
|
+
writeFileSync(temporary, value, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
110
|
+
renameSync(temporary, configPath);
|
|
111
|
+
} finally {
|
|
112
|
+
rmSync(temporary, { force: true });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function preserveInvalidStore(configPath) {
|
|
117
|
+
try {
|
|
118
|
+
renameSync(configPath, `${configPath}.invalid-${Date.now()}-${randomUUID()}`);
|
|
119
|
+
return true;
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (error?.code === "ENOENT") return false;
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function resultFor(configPath, action, selection, recoveredInvalidStore = false) {
|
|
127
|
+
return {
|
|
128
|
+
action,
|
|
129
|
+
configPath,
|
|
130
|
+
policy: selection.policy,
|
|
131
|
+
source: selection.source,
|
|
132
|
+
requiresNextTurn: action !== "show",
|
|
133
|
+
...(recoveredInvalidStore ? { recoveredInvalidStore: true } : {}),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function renderOutputPolicyPrompt(policy) {
|
|
138
|
+
if (!policy.concise && policy.maxTokens === undefined) return "";
|
|
139
|
+
return [
|
|
140
|
+
"## Odai controller output policy",
|
|
141
|
+
...(policy.concise ? [
|
|
142
|
+
"Keep the final user-facing response concise. Include only the result, decisive evidence, unresolved items, and necessary next action; omit routine process narration and repeated context unless the user explicitly asks for detail.",
|
|
143
|
+
] : []),
|
|
144
|
+
...(policy.maxTokens === undefined ? [] : [
|
|
145
|
+
`Each controller model request carries a provider output ceiling request of ${policy.maxTokens} tokens, which may include reasoning. Provider enforcement is not guaranteed; prioritize completion and finish before the requested ceiling.`,
|
|
146
|
+
]),
|
|
147
|
+
"This policy applies only to controller requests and the final user-facing response. It never reduces child-agent, compaction, checkpoint, or other internal context budgets.",
|
|
148
|
+
"The policy changes presentation and the requested controller budget only; it never permits omitting required results, evidence, risks, blockers, or verification.",
|
|
149
|
+
].join("\n");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function resolveSetPolicy(args) {
|
|
153
|
+
if (args.mode === undefined) {
|
|
154
|
+
if (args.concise === false && args.maxTokens !== undefined) {
|
|
155
|
+
throw new TypeError("maxTokens requires concise=true or mode=economy");
|
|
156
|
+
}
|
|
157
|
+
return resolveOutputPolicy({
|
|
158
|
+
concise: args.concise,
|
|
159
|
+
...(args.maxTokens === undefined ? {} : { maxTokens: args.maxTokens }),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (!OUTPUT_MODES.has(args.mode)) {
|
|
163
|
+
throw new TypeError("mode must be normal, concise, or economy");
|
|
164
|
+
}
|
|
165
|
+
if (args.concise !== undefined) {
|
|
166
|
+
throw new TypeError("concise must be omitted when mode is supplied");
|
|
167
|
+
}
|
|
168
|
+
if (args.mode !== "economy" && args.maxTokens !== undefined) {
|
|
169
|
+
throw new TypeError("maxTokens is accepted only with economy mode");
|
|
170
|
+
}
|
|
171
|
+
return resolveOutputPolicy({
|
|
172
|
+
concise: args.mode !== "normal",
|
|
173
|
+
...(args.mode === "economy"
|
|
174
|
+
? { maxTokens: args.maxTokens ?? DEFAULT_ECONOMY_MAX_TOKENS }
|
|
175
|
+
: {}),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function createOutputConfigTool(configPath, options = {}) {
|
|
180
|
+
const onConfigured = typeof options.onConfigured === "function" ? options.onConfigured : () => {};
|
|
181
|
+
const isChild = typeof options.isChild === "function"
|
|
182
|
+
? options.isChild
|
|
183
|
+
: (agent) => {
|
|
184
|
+
const header = agent?.session?.header;
|
|
185
|
+
return header?.origin === "subagent"
|
|
186
|
+
|| (Number.isSafeInteger(header?.delegationDepth) && header.delegationDepth > 0);
|
|
187
|
+
};
|
|
188
|
+
return {
|
|
189
|
+
name: "odai_output_config",
|
|
190
|
+
description: [
|
|
191
|
+
"Inspect, set, or remove the shared Odai controller output policy; the package default is soft concise.",
|
|
192
|
+
"Normal mode is concise=false with no maxTokens; soft concise is concise=true with no maxTokens; economy mode is concise=true with a user-adjustable maxTokens request ceiling and defaults to 500 only when the user names economy without another value.",
|
|
193
|
+
"Use only when the user explicitly requests an output mode or supplies a custom maxTokens; never invent a non-default custom value. Remove restores soft concise.",
|
|
194
|
+
"Changes start next user turn. maxTokens is a provider request ceiling, not locally enforced; providers may include reasoning, exceed or ignore it, or truncate responses. Child-agent, compaction, checkpoint, and other internal budgets stay unchanged.",
|
|
195
|
+
].join(" "),
|
|
196
|
+
parameters: {
|
|
197
|
+
type: "object",
|
|
198
|
+
additionalProperties: false,
|
|
199
|
+
required: ["action"],
|
|
200
|
+
properties: {
|
|
201
|
+
action: {
|
|
202
|
+
type: "string",
|
|
203
|
+
enum: ["show", "set", "remove"],
|
|
204
|
+
},
|
|
205
|
+
mode: {
|
|
206
|
+
type: "string",
|
|
207
|
+
enum: ["normal", "concise", "economy"],
|
|
208
|
+
description: "Preferred for set; economy defaults maxTokens to 500 unless the user supplies another positive value.",
|
|
209
|
+
},
|
|
210
|
+
concise: {
|
|
211
|
+
type: "boolean",
|
|
212
|
+
description: "Backward-compatible alternative for set; omit when mode is supplied.",
|
|
213
|
+
},
|
|
214
|
+
maxTokens: {
|
|
215
|
+
type: "integer",
|
|
216
|
+
description: "Optional positive replacement for economy mode's default 500-token provider request ceiling.",
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
output: {
|
|
221
|
+
schema: {
|
|
222
|
+
type: "object",
|
|
223
|
+
additionalProperties: false,
|
|
224
|
+
required: ["action", "configPath", "policy", "source", "requiresNextTurn"],
|
|
225
|
+
properties: {
|
|
226
|
+
action: { type: "string", enum: ["show", "set", "remove"] },
|
|
227
|
+
configPath: { type: "string" },
|
|
228
|
+
policy: {
|
|
229
|
+
type: "object",
|
|
230
|
+
additionalProperties: false,
|
|
231
|
+
required: ["concise"],
|
|
232
|
+
properties: {
|
|
233
|
+
concise: { type: "boolean" },
|
|
234
|
+
maxTokens: { type: "integer" },
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
source: { type: "string", enum: ["default", "persisted"] },
|
|
238
|
+
requiresNextTurn: { type: "boolean" },
|
|
239
|
+
recoveredInvalidStore: { type: "boolean" },
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
render(_args, value) {
|
|
243
|
+
const settings = [
|
|
244
|
+
`concise=${value.policy.concise ? "on" : "off"}`,
|
|
245
|
+
...(value.policy.maxTokens === undefined ? [] : [`maxTokens=${value.policy.maxTokens}`]),
|
|
246
|
+
].join(", ");
|
|
247
|
+
return [{
|
|
248
|
+
type: "text",
|
|
249
|
+
text: [
|
|
250
|
+
`${value.action === "show" ? "Current" : "Updated"} Odai controller output policy (${value.source}): ${settings}.`,
|
|
251
|
+
...(value.policy.maxTokens === undefined ? [] : [" maxTokens is sent as a provider request ceiling; strict provider compliance is not guaranteed and must be checked from usage."]),
|
|
252
|
+
...(value.recoveredInvalidStore ? [" An invalid prior store was preserved and replaced."] : []),
|
|
253
|
+
...(value.requiresNextTurn ? [" The change applies from the next user turn."] : []),
|
|
254
|
+
].join(""),
|
|
255
|
+
}];
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
execute(args, execution) {
|
|
259
|
+
if (!execution.agent) throw new Error("odai_output_config requires an owning agent session");
|
|
260
|
+
if (isChild(execution.agent)) throw new Error("child agents may not change Odai output configuration");
|
|
261
|
+
if (!args || typeof args !== "object" || Array.isArray(args)) throw new TypeError("arguments must be an object");
|
|
262
|
+
const unknown = Object.keys(args).filter((field) => !["action", "mode", "concise", "maxTokens"].includes(field));
|
|
263
|
+
if (unknown.length > 0) throw new TypeError(`unknown arguments: ${unknown.join(", ")}`);
|
|
264
|
+
if (!["show", "set", "remove"].includes(args.action)) throw new TypeError("action must be show, set, or remove");
|
|
265
|
+
if (args.action === "show") {
|
|
266
|
+
if (args.mode !== undefined || args.concise !== undefined || args.maxTokens !== undefined) {
|
|
267
|
+
throw new TypeError("mode, concise, and maxTokens must be omitted for show");
|
|
268
|
+
}
|
|
269
|
+
return Promise.resolve(resultFor(configPath, "show", effectiveOutputPolicy(configPath)));
|
|
270
|
+
}
|
|
271
|
+
if (args.action === "remove"
|
|
272
|
+
&& (args.mode !== undefined || args.concise !== undefined || args.maxTokens !== undefined)) {
|
|
273
|
+
throw new TypeError("mode, concise, and maxTokens must be omitted for remove");
|
|
274
|
+
}
|
|
275
|
+
const proposed = args.action === "set" ? resolveSetPolicy(args) : undefined;
|
|
276
|
+
|
|
277
|
+
const releaseLock = acquireOwnedStoreLock(configPath, "Odai output configuration");
|
|
278
|
+
try {
|
|
279
|
+
let recoveredInvalidStore = false;
|
|
280
|
+
try {
|
|
281
|
+
readOutputPolicyStore(configPath);
|
|
282
|
+
} catch (error) {
|
|
283
|
+
if (!(error instanceof OutputPolicyStoreValidationError)) {
|
|
284
|
+
throw new Error("Odai output configuration could not be read safely; no changes were made", { cause: error });
|
|
285
|
+
}
|
|
286
|
+
recoveredInvalidStore = preserveInvalidStore(configPath);
|
|
287
|
+
}
|
|
288
|
+
if (args.action === "set") writeOutputPolicyStore(configPath, proposed);
|
|
289
|
+
else rmSync(configPath, { force: true });
|
|
290
|
+
const selection = args.action === "set"
|
|
291
|
+
? Object.freeze({ policy: proposed, source: "persisted" })
|
|
292
|
+
: Object.freeze({ policy: DEFAULT_OUTPUT_POLICY, source: "default" });
|
|
293
|
+
onConfigured(execution.agent, {
|
|
294
|
+
action: args.action,
|
|
295
|
+
policy: selection.policy,
|
|
296
|
+
...(recoveredInvalidStore ? { recoveredInvalidStore: true } : {}),
|
|
297
|
+
});
|
|
298
|
+
return Promise.resolve(resultFor(configPath, args.action, selection, recoveredInvalidStore));
|
|
299
|
+
} finally {
|
|
300
|
+
releaseLock();
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const SHARED_OUTPUT_POLICY_STATE = Symbol.for("odai.dsh.output-policy-state.v1");
|
|
2
|
+
|
|
3
|
+
function sharedStore() {
|
|
4
|
+
if (!globalThis[SHARED_OUTPUT_POLICY_STATE]) {
|
|
5
|
+
Object.defineProperty(globalThis, SHARED_OUTPUT_POLICY_STATE, {
|
|
6
|
+
configurable: false,
|
|
7
|
+
enumerable: false,
|
|
8
|
+
writable: false,
|
|
9
|
+
value: Object.freeze({ selections: new WeakMap() }),
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
return globalThis[SHARED_OUTPUT_POLICY_STATE];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function generationFor(turn) {
|
|
16
|
+
return Number.isSafeInteger(turn) && turn >= 0 ? turn : "unknown";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function selectSharedOutputPolicyForTurn(agent, turn, select) {
|
|
20
|
+
if (!agent || typeof agent !== "object") throw new TypeError("an agent is required for Odai output policy selection");
|
|
21
|
+
if (typeof select !== "function") throw new TypeError("an Odai output policy selector is required");
|
|
22
|
+
const generation = generationFor(turn);
|
|
23
|
+
const store = sharedStore();
|
|
24
|
+
const existing = store.selections.get(agent);
|
|
25
|
+
if (existing?.generation === generation) return existing.promise;
|
|
26
|
+
|
|
27
|
+
const state = { generation };
|
|
28
|
+
state.promise = Promise.resolve().then(select).then((selection) => {
|
|
29
|
+
state.selection = selection;
|
|
30
|
+
return selection;
|
|
31
|
+
}).catch((error) => {
|
|
32
|
+
if (store.selections.get(agent) === state) store.selections.delete(agent);
|
|
33
|
+
throw error;
|
|
34
|
+
});
|
|
35
|
+
store.selections.set(agent, state);
|
|
36
|
+
return state.promise;
|
|
37
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
fsyncSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
openSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
writeSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { dirname } from "node:path";
|
|
12
|
+
|
|
13
|
+
function readLockOwner(lockPath) {
|
|
14
|
+
try {
|
|
15
|
+
const value = readFileSync(lockPath, "utf8").trim();
|
|
16
|
+
const separator = value.indexOf(":");
|
|
17
|
+
const pid = Number(separator < 0 ? "" : value.slice(0, separator));
|
|
18
|
+
return Number.isSafeInteger(pid) && pid > 0 && separator < value.length - 1
|
|
19
|
+
? { value, pid }
|
|
20
|
+
: undefined;
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (error?.code === "ENOENT") return undefined;
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function processIsAlive(pid) {
|
|
28
|
+
try {
|
|
29
|
+
process.kill(pid, 0);
|
|
30
|
+
return true;
|
|
31
|
+
} catch (error) {
|
|
32
|
+
return error?.code !== "ESRCH";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function createLock(lockPath, owner) {
|
|
37
|
+
const handle = openSync(lockPath, "wx", 0o600);
|
|
38
|
+
try {
|
|
39
|
+
const content = Buffer.from(`${owner}\n`, "utf8");
|
|
40
|
+
if (writeSync(handle, content, 0, content.length) !== content.length) {
|
|
41
|
+
throw new Error(`short write while acquiring Odai configuration lock: ${lockPath}`);
|
|
42
|
+
}
|
|
43
|
+
fsyncSync(handle);
|
|
44
|
+
return handle;
|
|
45
|
+
} catch (error) {
|
|
46
|
+
closeSync(handle);
|
|
47
|
+
rmSync(lockPath, { force: true });
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function releaseLock(handle, lockPath, owner) {
|
|
53
|
+
closeSync(handle);
|
|
54
|
+
const current = readLockOwner(lockPath);
|
|
55
|
+
if (current === undefined) throw new Error(`Odai configuration lock disappeared before release: ${lockPath}`);
|
|
56
|
+
if (current.value !== owner) throw new Error(`Odai configuration lock ownership changed before release: ${lockPath}`);
|
|
57
|
+
rmSync(lockPath);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function acquireOwnedStoreLock(path, label) {
|
|
61
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
62
|
+
const lockPath = `${path}.lock`;
|
|
63
|
+
const claimPath = `${lockPath}.claim`;
|
|
64
|
+
const owner = `${process.pid}:${randomUUID()}`;
|
|
65
|
+
const claimOwner = `${process.pid}:${randomUUID()}`;
|
|
66
|
+
let claimHandle;
|
|
67
|
+
try {
|
|
68
|
+
claimHandle = createLock(claimPath, claimOwner);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error?.code === "EEXIST") {
|
|
71
|
+
throw new Error(`${label} lock acquisition is already in progress; retry the tool call`);
|
|
72
|
+
}
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let handle;
|
|
77
|
+
try {
|
|
78
|
+
try {
|
|
79
|
+
handle = createLock(lockPath, owner);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error?.code !== "EEXIST") throw error;
|
|
82
|
+
const current = readLockOwner(lockPath);
|
|
83
|
+
if (current === undefined || processIsAlive(current.pid)) {
|
|
84
|
+
throw new Error(`${label} is being updated; retry the tool call`);
|
|
85
|
+
}
|
|
86
|
+
rmSync(lockPath);
|
|
87
|
+
handle = createLock(lockPath, owner);
|
|
88
|
+
}
|
|
89
|
+
} finally {
|
|
90
|
+
try {
|
|
91
|
+
releaseLock(claimHandle, claimPath, claimOwner);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (handle !== undefined) {
|
|
94
|
+
try {
|
|
95
|
+
releaseLock(handle, lockPath, owner);
|
|
96
|
+
} catch {}
|
|
97
|
+
handle = undefined;
|
|
98
|
+
}
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (handle === undefined) throw new Error(`could not lock ${label}`);
|
|
104
|
+
return () => releaseLock(handle, lockPath, owner);
|
|
105
|
+
}
|