odai-dsh-agent 0.0.6 → 0.0.8
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 +23 -1
- package/package.json +1 -1
- package/preset/odai/runtime/compaction-config.mjs +336 -0
- package/preset/odai/runtime/index.mjs +151 -20
- package/preset/odai/runtime/output-config.mjs +304 -0
- package/preset/odai/runtime/output-policy-state.mjs +37 -0
- package/preset/odai/runtime/store-lock.mjs +105 -0
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ $DSH_HOME/.agent-presets/odai/
|
|
|
13
13
|
.odai-agent.json
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
The composition preserves every capability row and setting from the `standard` agent surface in `@deepseek-ai/dsh@0.1.0-rc.6`, then adds the scoped Odai runtime in default `auto` mode. Release verification compares the complete derived composition against that exact DSH Standard source; a missing, added, reordered, or changed Standard row fails the probe. Host-owned persistence, sandbox, approval, registries, and base controller selection remain in the selected DSH profile. Ordinary requests stay on that controller. A complete contextual high-impact decision gap upgrades the same turn only when a planner mapping exists; the controller's durable `request/header` proves the actual route. Explicit independent planning/review requests still start a verified child when their mappings exist. `execute` remains an explicit all-delegation comparison mode. `observe` changes no model and starts no child; it injects a local evidence-and-decision protocol and makes an unresolved high-impact turn read-only. For DSH compaction on the same provider/model, an omitted summarizer reasoning setting inherits the session's current user-selected reasoning effort so the auxiliary call remains cache-compatible
|
|
16
|
+
The composition preserves every capability row and setting from the `standard` agent surface in `@deepseek-ai/dsh@0.1.0-rc.6`, then adds the scoped Odai runtime in default `auto` mode. Release verification compares the complete derived composition against that exact DSH Standard source; a missing, added, reordered, or changed Standard row fails the probe. Host-owned persistence, sandbox, approval, registries, and base controller selection remain in the selected DSH profile. Ordinary requests stay on that controller. A complete contextual high-impact decision gap upgrades the same turn only when a planner mapping exists; the controller's durable `request/header` proves the actual route. Explicit independent planning/review requests still start a verified child when their mappings exist. `execute` remains an explicit all-delegation comparison mode. `observe` changes no model and starts no child; it injects a local evidence-and-decision protocol and makes an unresolved high-impact turn read-only. For DSH compaction on the same provider/model, an omitted summarizer reasoning setting inherits the session's current user-selected reasoning effort so the auxiliary call remains cache-compatible. A user-selected cross-model target removes an effort matching that durable route; a distinct preselected effort is preserved because the request envelope exposes no stronger provenance.
|
|
17
17
|
|
|
18
18
|
## Install
|
|
19
19
|
|
|
@@ -53,6 +53,28 @@ 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 inherits controller reasoning while keeping its independent summary budget. Odai leaves prompt-cache retention unset by default; `ODAI_COMPACTION_CACHE_RETENTION` can explicitly select `short`, `long`, or `none`. `provider-default` means Odai adds no retention, while any explicit incoming retention remains authoritative; configured retention still applies when host routing has already supplied reasoning. Custom preset compositions can set the same value through runtime `compaction.cacheRetention`. The first controller request after a landed summary still rebuilds the changed summary prefix.
|
|
71
|
+
|
|
72
|
+
## Compaction model
|
|
73
|
+
|
|
74
|
+
The default compaction-summary model is `inherit`, which preserves the conversation's current provider/model behavior. A user can explicitly set a separate target in natural language, such as `压缩模型用 provider-x/model-summary`; `odai_compaction_config` persists that provider/model pair in `$DSH_HOME/odai/compaction.json`. Removing it restores inheritance. Agent and Plugin share the store when both are deliberately present.
|
|
75
|
+
|
|
76
|
+
The target applies only to future `compaction` summary requests. It does not change the controller, planner, executor, reviewer, ordinary conversation, summary output budget, or cache-retention policy, never selects a new reasoning effort, and a cross-model target removes only reasoning proven by equality with the durable controller route while preserving a distinct preselected effort. Each configured target receives one provider-neutral integrity suffix that keeps current facts above superseded/rejected history, preserves continuation-critical opaque values exactly, and self-checks contradictions; duplicate Agent/Plugin runtime instances add it only once. Odai never chooses the target on the user's behalf. An invalid store is reported by the tool while runtime requests inherit safely until `set` or `remove` repairs it. An unavailable route, provider error, or incomplete summary fails closed: DSH retains the original history rather than replacing it with a partial checkpoint.
|
|
77
|
+
|
|
56
78
|
## Skill sources
|
|
57
79
|
|
|
58
80
|
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
|
@@ -0,0 +1,336 @@
|
|
|
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 TARGET_FIELDS = new Set(["provider", "model"]);
|
|
16
|
+
const STATE_PROTOCOL_SOURCE = Object.freeze({
|
|
17
|
+
kind: "plugin",
|
|
18
|
+
plugin: "odai-dsh-runtime",
|
|
19
|
+
form: "instructions",
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export const COMPACTION_STATE_PROTOCOL = [
|
|
23
|
+
"Additional checkpoint integrity protocol:",
|
|
24
|
+
"- Treat facts explicitly marked CURRENT, corrected, or authoritative as the active state.",
|
|
25
|
+
"- Facts marked SUPERSEDED, REJECTED, obsolete, or historical may be retained only as rejected history; never describe them as current, latest, selected, or pending.",
|
|
26
|
+
"- Preserve opaque identifiers, paths, commands, status values, delimiters, and numeric values byte-for-byte when they determine continuation state.",
|
|
27
|
+
"- Before emitting the checkpoint, self-check that no rejected fact is framed as active and that no active value conflicts with another section.",
|
|
28
|
+
].join("\n");
|
|
29
|
+
|
|
30
|
+
export const COMPACTION_CONFIG_PROMPT = [
|
|
31
|
+
"## Odai compaction model configuration",
|
|
32
|
+
"When the user naturally asks to inspect, set, change, or reset the model used to summarize compacted context, use odai_compaction_config.",
|
|
33
|
+
"Set a compaction model only when the user explicitly supplies both provider and model. Never infer or silently choose either value. Ask a concise clarification when one is missing or ambiguous.",
|
|
34
|
+
"The default is inherit: compaction uses the current conversation route. A configured target changes only compaction-summary requests; it does not change the controller, planner, executor, reviewer, normal conversation, summary output budget, or cache-retention policy.",
|
|
35
|
+
"Configured targets also receive a provider-neutral checkpoint integrity protocol that keeps current facts above superseded or rejected history and preserves continuation-critical opaque values exactly.",
|
|
36
|
+
"Do not ask the user to edit YAML, JSON, managed Agent files, or Plugin configuration. The tool owns persistence. Set/remove changes apply to the next compaction request.",
|
|
37
|
+
"An invalid persisted store is visible through the tool while runtime requests safely inherit the conversation route. If the configured route fails or produces an incomplete summary, DSH must preserve the original history rather than landing a partial checkpoint.",
|
|
38
|
+
].join("\n");
|
|
39
|
+
|
|
40
|
+
export class CompactionModelStoreValidationError extends Error {}
|
|
41
|
+
|
|
42
|
+
function assertPlainObject(value, field) {
|
|
43
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
44
|
+
throw new TypeError(`${field} must be an object`);
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function resolveCompactionTarget(value, field = "Odai compaction model target") {
|
|
50
|
+
const target = assertPlainObject(value, field);
|
|
51
|
+
const unknown = Object.keys(target).filter((name) => !TARGET_FIELDS.has(name));
|
|
52
|
+
if (unknown.length > 0) throw new TypeError(`${field} has unknown fields: ${unknown.join(", ")}`);
|
|
53
|
+
if (typeof target.provider !== "string" || target.provider.trim() === "") {
|
|
54
|
+
throw new TypeError(`${field}.provider must be a non-empty string`);
|
|
55
|
+
}
|
|
56
|
+
if (typeof target.model !== "string" || target.model.trim() === "") {
|
|
57
|
+
throw new TypeError(`${field}.model must be a non-empty string`);
|
|
58
|
+
}
|
|
59
|
+
return Object.freeze({ provider: target.provider.trim(), model: target.model.trim() });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function resolveCompactionConfigPath(configuredPath, env = process.env) {
|
|
63
|
+
if (configuredPath !== undefined) {
|
|
64
|
+
if (typeof configuredPath !== "string" || configuredPath.trim() === "") {
|
|
65
|
+
throw new TypeError("config.compaction.configPath must be a non-empty string");
|
|
66
|
+
}
|
|
67
|
+
return resolve(configuredPath.trim());
|
|
68
|
+
}
|
|
69
|
+
const dshHome = typeof env.DSH_HOME === "string" && env.DSH_HOME.trim() !== ""
|
|
70
|
+
? resolve(env.DSH_HOME.trim())
|
|
71
|
+
: resolve(homedir(), ".dsh");
|
|
72
|
+
return resolve(dshHome, "odai", "compaction.json");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function readCompactionModelStore(configPath) {
|
|
76
|
+
let text;
|
|
77
|
+
try {
|
|
78
|
+
text = readFileSync(configPath, "utf8");
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (error?.code === "ENOENT") return Object.freeze({ schemaVersion: STORE_SCHEMA_VERSION });
|
|
81
|
+
throw new Error(`cannot read Odai compaction model configuration ${configPath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
82
|
+
}
|
|
83
|
+
let parsed;
|
|
84
|
+
try {
|
|
85
|
+
parsed = JSON.parse(text);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
throw new CompactionModelStoreValidationError(`Odai compaction model configuration ${configPath} is not valid JSON`, { cause: error });
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const store = assertPlainObject(parsed, `Odai compaction model configuration ${configPath}`);
|
|
91
|
+
const unknown = Object.keys(store).filter((field) => !["schemaVersion", "target"].includes(field));
|
|
92
|
+
if (unknown.length > 0) {
|
|
93
|
+
throw new TypeError(`Odai compaction model configuration ${configPath} has unknown fields: ${unknown.join(", ")}`);
|
|
94
|
+
}
|
|
95
|
+
if (store.schemaVersion !== STORE_SCHEMA_VERSION) {
|
|
96
|
+
throw new TypeError(`Odai compaction model configuration ${configPath} has unsupported schemaVersion ${String(store.schemaVersion)}`);
|
|
97
|
+
}
|
|
98
|
+
if (store.target === undefined) return Object.freeze({ schemaVersion: STORE_SCHEMA_VERSION });
|
|
99
|
+
return Object.freeze({
|
|
100
|
+
schemaVersion: STORE_SCHEMA_VERSION,
|
|
101
|
+
target: resolveCompactionTarget(store.target, `Odai compaction model configuration ${configPath}.target`),
|
|
102
|
+
});
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (error instanceof CompactionModelStoreValidationError) throw error;
|
|
105
|
+
throw new CompactionModelStoreValidationError(
|
|
106
|
+
`Odai compaction model configuration ${configPath} failed validation: ${error instanceof Error ? error.message : String(error)}`,
|
|
107
|
+
{ cause: error },
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function effectiveCompactionTarget(configPath) {
|
|
113
|
+
const stored = readCompactionModelStore(configPath);
|
|
114
|
+
return Object.freeze({
|
|
115
|
+
...(stored.target === undefined ? {} : { target: stored.target }),
|
|
116
|
+
source: stored.target === undefined ? "inherit" : "persisted",
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function applyCompactionTarget(options, target, sessions) {
|
|
121
|
+
if (options?.purpose !== "compaction" || target === undefined) return false;
|
|
122
|
+
const currentRoute = options.sessionId === undefined
|
|
123
|
+
? undefined
|
|
124
|
+
: sessions?.get?.(options.sessionId)?.requestHeader?.()?.config;
|
|
125
|
+
const durableRouteKnown = typeof currentRoute?.provider === "string"
|
|
126
|
+
&& currentRoute.provider.length > 0
|
|
127
|
+
&& typeof currentRoute.model === "string"
|
|
128
|
+
&& currentRoute.model.length > 0;
|
|
129
|
+
const targetChangesDurableRoute = durableRouteKnown
|
|
130
|
+
&& (currentRoute.provider !== target.provider || currentRoute.model !== target.model);
|
|
131
|
+
const inheritedReasoning = targetChangesDurableRoute
|
|
132
|
+
&& typeof currentRoute.reasoningEffort === "string"
|
|
133
|
+
&& currentRoute.reasoningEffort.length > 0
|
|
134
|
+
&& options.reasoningEffort === currentRoute.reasoningEffort;
|
|
135
|
+
const changesRoute = options.provider !== target.provider || options.model !== target.model;
|
|
136
|
+
if (!changesRoute && !inheritedReasoning) return false;
|
|
137
|
+
if (!Object.isExtensible(options)) {
|
|
138
|
+
throw new Error("configured Odai compaction model cannot be applied to an immutable request");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (inheritedReasoning) delete options.reasoningEffort;
|
|
142
|
+
if (changesRoute) {
|
|
143
|
+
options.provider = target.provider;
|
|
144
|
+
options.model = target.model;
|
|
145
|
+
}
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function applyCompactionStateProtocol(options, target) {
|
|
150
|
+
if (options?.purpose !== "compaction" || target === undefined) return false;
|
|
151
|
+
if (!Array.isArray(options.messages) || !Object.isExtensible(options)) {
|
|
152
|
+
throw new Error("configured Odai compaction model requires a mutable message envelope for checkpoint integrity");
|
|
153
|
+
}
|
|
154
|
+
const alreadyApplied = options.messages.some((message) => (
|
|
155
|
+
message?.source?.kind === STATE_PROTOCOL_SOURCE.kind
|
|
156
|
+
&& message.source.plugin === STATE_PROTOCOL_SOURCE.plugin
|
|
157
|
+
&& message.source.form === STATE_PROTOCOL_SOURCE.form
|
|
158
|
+
&& Array.isArray(message.content)
|
|
159
|
+
&& message.content.some((block) => block?.type === "text" && block.text === COMPACTION_STATE_PROTOCOL)
|
|
160
|
+
));
|
|
161
|
+
if (alreadyApplied) return false;
|
|
162
|
+
options.messages = [...options.messages, {
|
|
163
|
+
id: randomUUID(),
|
|
164
|
+
role: "user",
|
|
165
|
+
content: [{ type: "text", text: COMPACTION_STATE_PROTOCOL }],
|
|
166
|
+
source: STATE_PROTOCOL_SOURCE,
|
|
167
|
+
}];
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function writeCompactionModelStore(configPath, target) {
|
|
172
|
+
mkdirSync(dirname(configPath), { recursive: true, mode: 0o700 });
|
|
173
|
+
const temporary = `${configPath}.tmp-${process.pid}-${randomUUID()}`;
|
|
174
|
+
const value = `${JSON.stringify({ schemaVersion: STORE_SCHEMA_VERSION, target }, null, 2)}\n`;
|
|
175
|
+
try {
|
|
176
|
+
writeFileSync(temporary, value, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
177
|
+
renameSync(temporary, configPath);
|
|
178
|
+
} finally {
|
|
179
|
+
rmSync(temporary, { force: true });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function preserveInvalidStore(configPath) {
|
|
184
|
+
try {
|
|
185
|
+
renameSync(configPath, `${configPath}.invalid-${Date.now()}-${randomUUID()}`);
|
|
186
|
+
return true;
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (error?.code === "ENOENT") return false;
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function resultFor(configPath, action, selection, recoveredInvalidStore = false) {
|
|
194
|
+
return {
|
|
195
|
+
action,
|
|
196
|
+
configPath,
|
|
197
|
+
source: selection.source,
|
|
198
|
+
...(selection.target === undefined ? {} : { target: selection.target }),
|
|
199
|
+
requiresNextCompaction: action !== "show",
|
|
200
|
+
...(selection.invalidStore ? { invalidStore: true } : {}),
|
|
201
|
+
...(recoveredInvalidStore ? { recoveredInvalidStore: true } : {}),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function createCompactionConfigTool(configPath, options = {}) {
|
|
206
|
+
const onConfigured = typeof options.onConfigured === "function" ? options.onConfigured : () => {};
|
|
207
|
+
const isChild = typeof options.isChild === "function"
|
|
208
|
+
? options.isChild
|
|
209
|
+
: (agent) => {
|
|
210
|
+
const header = agent?.session?.header;
|
|
211
|
+
return header?.origin === "subagent"
|
|
212
|
+
|| (Number.isSafeInteger(header?.delegationDepth) && header.delegationDepth > 0);
|
|
213
|
+
};
|
|
214
|
+
return {
|
|
215
|
+
name: "odai_compaction_config",
|
|
216
|
+
description: [
|
|
217
|
+
"Inspect, set, or remove the shared Odai compaction-summary model target.",
|
|
218
|
+
"Use only when the user naturally asks about the compaction model or explicitly supplies the provider and model to set. Never select either value on the user's behalf.",
|
|
219
|
+
"The default is inherit. A persisted target changes only future compaction-summary requests; controller and responsibility routes, output budget, and cache retention stay unchanged.",
|
|
220
|
+
"Persisted targets also receive a provider-neutral integrity protocol that keeps current facts above superseded history and preserves continuation-critical opaque values exactly.",
|
|
221
|
+
"An invalid store is reported while runtime requests safely inherit. If the configured route or summary fails, DSH preserves the original history. Changes apply from the next compaction request.",
|
|
222
|
+
].join(" "),
|
|
223
|
+
parameters: {
|
|
224
|
+
type: "object",
|
|
225
|
+
additionalProperties: false,
|
|
226
|
+
required: ["action"],
|
|
227
|
+
properties: {
|
|
228
|
+
action: {
|
|
229
|
+
type: "string",
|
|
230
|
+
enum: ["show", "set", "remove"],
|
|
231
|
+
description: "Show the effective target, set a user-specified provider/model, or remove it to restore inheritance.",
|
|
232
|
+
},
|
|
233
|
+
provider: {
|
|
234
|
+
type: "string",
|
|
235
|
+
description: "Provider id explicitly supplied by the user; required for set.",
|
|
236
|
+
},
|
|
237
|
+
model: {
|
|
238
|
+
type: "string",
|
|
239
|
+
description: "Model id explicitly supplied by the user; required for set.",
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
output: {
|
|
244
|
+
schema: {
|
|
245
|
+
type: "object",
|
|
246
|
+
additionalProperties: false,
|
|
247
|
+
required: ["action", "configPath", "source", "requiresNextCompaction"],
|
|
248
|
+
properties: {
|
|
249
|
+
action: { type: "string", enum: ["show", "set", "remove"] },
|
|
250
|
+
configPath: { type: "string" },
|
|
251
|
+
source: { type: "string", enum: ["inherit", "persisted"] },
|
|
252
|
+
target: {
|
|
253
|
+
type: "object",
|
|
254
|
+
additionalProperties: false,
|
|
255
|
+
required: ["provider", "model"],
|
|
256
|
+
properties: {
|
|
257
|
+
provider: { type: "string" },
|
|
258
|
+
model: { type: "string" },
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
requiresNextCompaction: { type: "boolean" },
|
|
262
|
+
invalidStore: { type: "boolean" },
|
|
263
|
+
recoveredInvalidStore: { type: "boolean" },
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
render(_args, value) {
|
|
267
|
+
const target = value.target
|
|
268
|
+
? `${value.target.provider}/${value.target.model}`
|
|
269
|
+
: "inherit the current conversation model";
|
|
270
|
+
return [{
|
|
271
|
+
type: "text",
|
|
272
|
+
text: [
|
|
273
|
+
`${value.action === "show" ? "Current" : "Updated"} Odai compaction model (${value.source}): ${target}.`,
|
|
274
|
+
" This affects compaction summaries only; other model routes, the summary budget, and cache retention are unchanged.",
|
|
275
|
+
...(value.invalidStore ? [" The persisted store is invalid, so runtime requests currently inherit; set or remove the target to repair it."] : []),
|
|
276
|
+
...(value.recoveredInvalidStore ? [" An invalid prior store was preserved before the configuration was repaired or reset."] : []),
|
|
277
|
+
...(value.requiresNextCompaction ? [" The change applies from the next compaction request."] : []),
|
|
278
|
+
].join(""),
|
|
279
|
+
}];
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
execute(args, execution) {
|
|
283
|
+
if (!execution.agent) throw new Error("odai_compaction_config requires an owning agent session");
|
|
284
|
+
if (isChild(execution.agent)) throw new Error("child agents may not change Odai compaction model configuration");
|
|
285
|
+
if (!args || typeof args !== "object" || Array.isArray(args)) throw new TypeError("arguments must be an object");
|
|
286
|
+
const unknown = Object.keys(args).filter((field) => !["action", "provider", "model"].includes(field));
|
|
287
|
+
if (unknown.length > 0) throw new TypeError(`unknown arguments: ${unknown.join(", ")}`);
|
|
288
|
+
if (!["show", "set", "remove"].includes(args.action)) throw new TypeError("action must be show, set, or remove");
|
|
289
|
+
if (args.action === "show") {
|
|
290
|
+
if (args.provider !== undefined || args.model !== undefined) {
|
|
291
|
+
throw new TypeError("provider and model must be omitted for show");
|
|
292
|
+
}
|
|
293
|
+
let selection;
|
|
294
|
+
try {
|
|
295
|
+
selection = effectiveCompactionTarget(configPath);
|
|
296
|
+
} catch (error) {
|
|
297
|
+
if (!(error instanceof CompactionModelStoreValidationError)) throw error;
|
|
298
|
+
selection = Object.freeze({ source: "inherit", invalidStore: true });
|
|
299
|
+
}
|
|
300
|
+
return Promise.resolve(resultFor(configPath, "show", selection));
|
|
301
|
+
}
|
|
302
|
+
if (args.action === "remove" && (args.provider !== undefined || args.model !== undefined)) {
|
|
303
|
+
throw new TypeError("provider and model must be omitted for remove");
|
|
304
|
+
}
|
|
305
|
+
const proposed = args.action === "set"
|
|
306
|
+
? resolveCompactionTarget({ provider: args.provider, model: args.model })
|
|
307
|
+
: undefined;
|
|
308
|
+
|
|
309
|
+
const releaseLock = acquireOwnedStoreLock(configPath, "Odai compaction model configuration");
|
|
310
|
+
try {
|
|
311
|
+
let recoveredInvalidStore = false;
|
|
312
|
+
try {
|
|
313
|
+
readCompactionModelStore(configPath);
|
|
314
|
+
} catch (error) {
|
|
315
|
+
if (!(error instanceof CompactionModelStoreValidationError)) {
|
|
316
|
+
throw new Error("Odai compaction model configuration could not be read safely; no changes were made", { cause: error });
|
|
317
|
+
}
|
|
318
|
+
recoveredInvalidStore = preserveInvalidStore(configPath);
|
|
319
|
+
}
|
|
320
|
+
if (args.action === "set") writeCompactionModelStore(configPath, proposed);
|
|
321
|
+
else rmSync(configPath, { force: true });
|
|
322
|
+
const selection = args.action === "set"
|
|
323
|
+
? Object.freeze({ target: proposed, source: "persisted" })
|
|
324
|
+
: Object.freeze({ source: "inherit" });
|
|
325
|
+
onConfigured(execution.agent, {
|
|
326
|
+
action: args.action,
|
|
327
|
+
...(selection.target === undefined ? {} : { target: selection.target }),
|
|
328
|
+
...(recoveredInvalidStore ? { recoveredInvalidStore: true } : {}),
|
|
329
|
+
});
|
|
330
|
+
return Promise.resolve(resultFor(configPath, args.action, selection, recoveredInvalidStore));
|
|
331
|
+
} finally {
|
|
332
|
+
releaseLock();
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
}
|
|
@@ -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,22 @@ 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";
|
|
38
|
+
import {
|
|
39
|
+
COMPACTION_CONFIG_PROMPT,
|
|
40
|
+
applyCompactionStateProtocol,
|
|
41
|
+
applyCompactionTarget,
|
|
42
|
+
createCompactionConfigTool,
|
|
43
|
+
effectiveCompactionTarget,
|
|
44
|
+
resolveCompactionConfigPath,
|
|
45
|
+
} from "./compaction-config.mjs";
|
|
29
46
|
import { createSessionEvidence, resolveSessionEvidenceRoot } from "./session-evidence.mjs";
|
|
30
47
|
import {
|
|
31
48
|
SKILL_SOURCE_CONFIG_PROMPT,
|
|
@@ -50,6 +67,7 @@ export const inject = ["systemPrompt", "tools", "subagents", "sessions"];
|
|
|
50
67
|
|
|
51
68
|
const PLUGIN_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
52
69
|
const ROUTING_MODES = new Set(["off", "observe", "auto", "execute"]);
|
|
70
|
+
const COMPACTION_CACHE_RETENTIONS = new Set(["provider-default", "short", "long", "none"]);
|
|
53
71
|
const ROUTED_ROLES = CONFIGURABLE_ROLES;
|
|
54
72
|
const DEFAULT_ROUTING_MODE = "auto";
|
|
55
73
|
|
|
@@ -66,6 +84,8 @@ export function resolveConfig(rawConfig = {}) {
|
|
|
66
84
|
const routing = assertPlainObject(raw.routing, "config.routing");
|
|
67
85
|
const roles = assertPlainObject(routing.roles, "config.routing.roles");
|
|
68
86
|
const governance = assertPlainObject(raw.governance, "config.governance");
|
|
87
|
+
const output = assertPlainObject(raw.output, "config.output");
|
|
88
|
+
const compaction = assertPlainObject(raw.compaction, "config.compaction");
|
|
69
89
|
const mode = routing.mode ?? DEFAULT_ROUTING_MODE;
|
|
70
90
|
const provider = routing.provider ?? "spawn";
|
|
71
91
|
const maxInputChars = routing.maxInputChars ?? 12_000;
|
|
@@ -73,7 +93,14 @@ export function resolveConfig(rawConfig = {}) {
|
|
|
73
93
|
const additionalDeniedTools = governance.additionalDeniedTools ?? [];
|
|
74
94
|
const skillSource = governance.skillSource ?? "bundled";
|
|
75
95
|
const skillConfigPath = resolveSkillSourceConfigPath(governance.skillConfigPath);
|
|
96
|
+
const outputConfigPath = resolveOutputConfigPath(output.configPath);
|
|
97
|
+
const compactionConfigPath = resolveCompactionConfigPath(compaction.configPath);
|
|
98
|
+
const compactionCacheRetention = compaction.cacheRetention
|
|
99
|
+
?? process.env.ODAI_COMPACTION_CACHE_RETENTION
|
|
100
|
+
?? "provider-default";
|
|
76
101
|
const unknownRoles = Object.keys(roles).filter((role) => !ROUTED_ROLES.includes(role));
|
|
102
|
+
const unknownOutputFields = Object.keys(output).filter((field) => field !== "configPath");
|
|
103
|
+
const unknownCompactionFields = Object.keys(compaction).filter((field) => !["cacheRetention", "configPath"].includes(field));
|
|
77
104
|
|
|
78
105
|
if (!ROUTING_MODES.has(mode)) {
|
|
79
106
|
throw new TypeError("config.routing.mode must be off, observe, auto, or execute");
|
|
@@ -87,6 +114,15 @@ export function resolveConfig(rawConfig = {}) {
|
|
|
87
114
|
if (unknownRoles.length > 0) {
|
|
88
115
|
throw new TypeError(`config.routing.roles has unknown roles: ${unknownRoles.join(", ")}`);
|
|
89
116
|
}
|
|
117
|
+
if (unknownOutputFields.length > 0) {
|
|
118
|
+
throw new TypeError(`config.output has unknown fields: ${unknownOutputFields.join(", ")}`);
|
|
119
|
+
}
|
|
120
|
+
if (unknownCompactionFields.length > 0) {
|
|
121
|
+
throw new TypeError(`config.compaction has unknown fields: ${unknownCompactionFields.join(", ")}`);
|
|
122
|
+
}
|
|
123
|
+
if (!COMPACTION_CACHE_RETENTIONS.has(compactionCacheRetention)) {
|
|
124
|
+
throw new TypeError("config.compaction.cacheRetention must be provider-default, short, long, or none");
|
|
125
|
+
}
|
|
90
126
|
if (!Array.isArray(additionalDeniedTools)
|
|
91
127
|
|| additionalDeniedTools.some((tool) => typeof tool !== "string" || tool.trim() === "")) {
|
|
92
128
|
throw new TypeError("config.governance.additionalDeniedTools must be an array of non-empty strings");
|
|
@@ -115,12 +151,17 @@ export function resolveConfig(rawConfig = {}) {
|
|
|
115
151
|
skillSource,
|
|
116
152
|
skillConfigPath,
|
|
117
153
|
}),
|
|
154
|
+
output: Object.freeze({ configPath: outputConfigPath }),
|
|
155
|
+
compaction: Object.freeze({
|
|
156
|
+
cacheRetention: compactionCacheRetention,
|
|
157
|
+
configPath: compactionConfigPath,
|
|
158
|
+
}),
|
|
118
159
|
});
|
|
119
160
|
}
|
|
120
161
|
|
|
121
|
-
|
|
162
|
+
/** Mutate exact-route compaction options; returns true only when an option changed. */
|
|
163
|
+
export function inheritCompactionReasoning(options, sessions, cacheRetention = "provider-default") {
|
|
122
164
|
if (options?.purpose !== "compaction"
|
|
123
|
-
|| options.reasoningEffort !== undefined
|
|
124
165
|
|| options.sessionId === undefined
|
|
125
166
|
|| !Object.isExtensible(options)) {
|
|
126
167
|
return false;
|
|
@@ -134,8 +175,16 @@ export function inheritCompactionReasoning(options, sessions) {
|
|
|
134
175
|
return false;
|
|
135
176
|
}
|
|
136
177
|
|
|
137
|
-
|
|
138
|
-
|
|
178
|
+
let changed = false;
|
|
179
|
+
if (options.reasoningEffort === undefined) {
|
|
180
|
+
options.reasoningEffort = route.reasoningEffort;
|
|
181
|
+
changed = true;
|
|
182
|
+
}
|
|
183
|
+
if (options.cacheRetention === undefined && cacheRetention !== "provider-default") {
|
|
184
|
+
options.cacheRetention = cacheRetention;
|
|
185
|
+
changed = true;
|
|
186
|
+
}
|
|
187
|
+
return changed;
|
|
139
188
|
}
|
|
140
189
|
|
|
141
190
|
export function resolveSkillPath(configuredPath, env = process.env) {
|
|
@@ -315,7 +364,7 @@ function canonicalPrompt(selection) {
|
|
|
315
364
|
`Canonical skill: ${bundle.manifest.skillVersion}; runtime contract: ${bundle.manifest.runtimeContract}; digest: ${bundle.digest}.`,
|
|
316
365
|
...(fallback ? [fallback] : []),
|
|
317
366
|
"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.",
|
|
367
|
+
"Odai governance is already loaded by this runtime; do not call the skill tool or read SKILL.md to load odai again.",
|
|
319
368
|
"",
|
|
320
369
|
bundle.skillText,
|
|
321
370
|
].join("\n");
|
|
@@ -350,8 +399,21 @@ export function apply(ctx, rawConfig) {
|
|
|
350
399
|
}
|
|
351
400
|
};
|
|
352
401
|
const hasSessionEvent = (agent, type, predicate) => evidence.has(agent, type, predicate);
|
|
402
|
+
const selectCompactionTarget = () => {
|
|
403
|
+
try {
|
|
404
|
+
return effectiveCompactionTarget(config.compaction.configPath);
|
|
405
|
+
} catch (error) {
|
|
406
|
+
logger.warn(`Odai compaction model configuration is invalid; inheriting the conversation route: ${error instanceof Error ? error.message : String(error)}`);
|
|
407
|
+
return Object.freeze({ source: "inherit", status: "fallback", reasonCode: "compaction-config-invalid" });
|
|
408
|
+
}
|
|
409
|
+
};
|
|
353
410
|
ctx.on("llm/stream", (options, next) => {
|
|
354
|
-
|
|
411
|
+
if (options?.purpose === "compaction") {
|
|
412
|
+
const selection = selectCompactionTarget();
|
|
413
|
+
applyCompactionTarget(options, selection.target, ctx.sessions);
|
|
414
|
+
applyCompactionStateProtocol(options, selection.target);
|
|
415
|
+
inheritCompactionReasoning(options, ctx.sessions, config.compaction.cacheRetention);
|
|
416
|
+
}
|
|
355
417
|
return next();
|
|
356
418
|
});
|
|
357
419
|
const skillPath = resolveSkillPath(config.skillPath);
|
|
@@ -387,6 +449,16 @@ export function apply(ctx, rawConfig) {
|
|
|
387
449
|
order: -18,
|
|
388
450
|
text: SKILL_SOURCE_CONFIG_PROMPT,
|
|
389
451
|
});
|
|
452
|
+
ctx.systemPrompt.section({
|
|
453
|
+
name: "odai:controller-output-policy",
|
|
454
|
+
order: -17,
|
|
455
|
+
text: "",
|
|
456
|
+
});
|
|
457
|
+
ctx.systemPrompt.section({
|
|
458
|
+
name: "odai:compaction-model-configuration",
|
|
459
|
+
order: -16,
|
|
460
|
+
text: COMPACTION_CONFIG_PROMPT,
|
|
461
|
+
});
|
|
390
462
|
|
|
391
463
|
const optionalSkillRegistry = () => {
|
|
392
464
|
try {
|
|
@@ -422,12 +494,28 @@ export function apply(ctx, rawConfig) {
|
|
|
422
494
|
skills: optionalSkillRegistry(),
|
|
423
495
|
});
|
|
424
496
|
};
|
|
497
|
+
const selectOutputForAgent = () => {
|
|
498
|
+
try {
|
|
499
|
+
return effectiveOutputPolicy(config.output.configPath);
|
|
500
|
+
} catch (error) {
|
|
501
|
+
logger.warn(`Odai output configuration is invalid; using the default policy: ${error instanceof Error ? error.message : String(error)}`);
|
|
502
|
+
return Object.freeze({
|
|
503
|
+
policy: DEFAULT_OUTPUT_POLICY,
|
|
504
|
+
source: "default",
|
|
505
|
+
status: "fallback",
|
|
506
|
+
reasonCode: "output-config-invalid",
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
};
|
|
425
510
|
ctx.on("system-prompt/assemble", async (assembly, context, next) => {
|
|
426
511
|
const downstream = await next();
|
|
427
512
|
const agent = context.agent;
|
|
428
513
|
if (!agent) return downstream;
|
|
429
514
|
const selection = await selectSharedSkillForTurn(agent, () => selectForAgent(agent, context));
|
|
430
515
|
const turn = currentAgentTurn(agent);
|
|
516
|
+
const outputSelection = isSubagentSession(agent)
|
|
517
|
+
? Object.freeze({ policy: DEFAULT_OUTPUT_POLICY, source: "default" })
|
|
518
|
+
: await selectSharedOutputPolicyForTurn(agent, turn, selectOutputForAgent);
|
|
431
519
|
const selectionEvidence = {
|
|
432
520
|
...(turn === undefined ? {} : { turn }),
|
|
433
521
|
requestedMode: selection.mode,
|
|
@@ -445,11 +533,21 @@ export function apply(ctx, rawConfig) {
|
|
|
445
533
|
if (selection.status === "fallback") {
|
|
446
534
|
logger.warn(`Odai skill source ${selection.mode} fell back to bundled governance (${selection.reasonCode})`);
|
|
447
535
|
}
|
|
536
|
+
const outputPrompt = renderOutputPolicyPrompt(outputSelection.policy);
|
|
537
|
+
if (outputPrompt && !hasSessionEvent(agent, "odai/output-policy-selected", (data) => data?.turn === turn)) {
|
|
538
|
+
appendEvent(agent, "odai/output-policy-selected", {
|
|
539
|
+
...(turn === undefined ? {} : { turn }),
|
|
540
|
+
source: outputSelection.source,
|
|
541
|
+
policy: outputSelection.policy,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
448
544
|
return {
|
|
449
545
|
...downstream,
|
|
450
|
-
sections: downstream.sections.map((section) =>
|
|
451
|
-
|
|
452
|
-
: section
|
|
546
|
+
sections: downstream.sections.map((section) => {
|
|
547
|
+
if (section.name === "odai:canonical-governance") return { ...section, text: canonicalPrompt(selection) };
|
|
548
|
+
if (section.name === "odai:controller-output-policy") return { ...section, text: outputPrompt };
|
|
549
|
+
return section;
|
|
550
|
+
}),
|
|
453
551
|
};
|
|
454
552
|
});
|
|
455
553
|
|
|
@@ -499,6 +597,18 @@ export function apply(ctx, rawConfig) {
|
|
|
499
597
|
},
|
|
500
598
|
},
|
|
501
599
|
));
|
|
600
|
+
ctx.tools.register(createOutputConfigTool(config.output.configPath, {
|
|
601
|
+
isChild: isSubagent,
|
|
602
|
+
onConfigured(agent, data) {
|
|
603
|
+
appendEvent(agent, "odai/output-configured", data);
|
|
604
|
+
},
|
|
605
|
+
}));
|
|
606
|
+
ctx.tools.register(createCompactionConfigTool(config.compaction.configPath, {
|
|
607
|
+
isChild: isSubagent,
|
|
608
|
+
onConfigured(agent, data) {
|
|
609
|
+
appendEvent(agent, "odai/compaction-configured", data);
|
|
610
|
+
},
|
|
611
|
+
}));
|
|
502
612
|
ctx.tools.guard((execution) => childGuard(execution) ?? routeProtectionGuard(execution));
|
|
503
613
|
|
|
504
614
|
ctx.on("tools/result", (execution, result) => {
|
|
@@ -508,7 +618,7 @@ export function apply(ctx, rawConfig) {
|
|
|
508
618
|
appendEvent(execution.agent, "odai/tool-observed", summary, logger);
|
|
509
619
|
});
|
|
510
620
|
|
|
511
|
-
ctx.on("agent/request", async ({ agent, turn }, next) => {
|
|
621
|
+
ctx.on("agent/request", async ({ agent, turn, step }, next) => {
|
|
512
622
|
const proposed = await next();
|
|
513
623
|
const childRole = routedRoleOf(agent);
|
|
514
624
|
const childRoleState = childRole ? configuredRole(childRole) : undefined;
|
|
@@ -518,16 +628,37 @@ export function apply(ctx, rawConfig) {
|
|
|
518
628
|
: upgrade && upgrade.turn === turn
|
|
519
629
|
? upgrade.route
|
|
520
630
|
: undefined;
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
631
|
+
let request = proposed;
|
|
632
|
+
if (roleRoute) {
|
|
633
|
+
const { reasoningEffort: _inheritedEffort, ...withoutInheritedEffort } = proposed;
|
|
634
|
+
request = Object.freeze({
|
|
635
|
+
...withoutInheritedEffort,
|
|
636
|
+
provider: roleRoute.provider,
|
|
637
|
+
model: roleRoute.model,
|
|
638
|
+
...(roleRoute.reasoningEffort === undefined ? {} : { reasoningEffort: roleRoute.reasoningEffort }),
|
|
639
|
+
...(childRole && roleRoute.maxTokens !== undefined ? { maxTokens: roleRoute.maxTokens } : {}),
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
if (childRole || isSubagentSession(agent)) return request;
|
|
643
|
+
|
|
644
|
+
const outputSelection = await selectSharedOutputPolicyForTurn(agent, turn, selectOutputForAgent);
|
|
645
|
+
const configuredMaxTokens = outputSelection.policy.maxTokens;
|
|
646
|
+
if (configuredMaxTokens === undefined) return request;
|
|
647
|
+
const priorMaxTokens = request.maxTokens;
|
|
648
|
+
const effectiveMaxTokens = priorMaxTokens === undefined
|
|
649
|
+
? configuredMaxTokens
|
|
650
|
+
: Math.min(priorMaxTokens, configuredMaxTokens);
|
|
651
|
+
if (!hasSessionEvent(agent, "odai/output-budget-applied", (data) => data?.turn === turn && data?.step === step)) {
|
|
652
|
+
appendEvent(agent, "odai/output-budget-applied", {
|
|
653
|
+
turn,
|
|
654
|
+
...(step === undefined ? {} : { step }),
|
|
655
|
+
configuredMaxTokens,
|
|
656
|
+
...(priorMaxTokens === undefined ? {} : { priorMaxTokens }),
|
|
657
|
+
effectiveMaxTokens,
|
|
658
|
+
semantics: "provider-request-ceiling",
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
return Object.freeze({ ...request, maxTokens: effectiveMaxTokens });
|
|
531
662
|
}, { prepend: true });
|
|
532
663
|
|
|
533
664
|
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
|
+
}
|