odai-dsh-agent 0.0.7 → 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 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; explicit and cross-model settings are not changed.
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
 
@@ -67,7 +67,13 @@ Natural requests include `use normal output`, `use soft concise output`, `enable
67
67
 
68
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
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.
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.
71
77
 
72
78
  ## Skill sources
73
79
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "odai-dsh-agent",
3
- "version": "0.0.7",
3
+ "version": "0.0.8",
4
4
  "description": "完整继承 DSH 标准模式 全部能力的 Odai Agent preset。主要用于治理、规划、测试、验证 等,可当成默认总控使用。",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
+ }
@@ -35,6 +35,14 @@ import {
35
35
  resolveOutputConfigPath,
36
36
  } from "./output-config.mjs";
37
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";
38
46
  import { createSessionEvidence, resolveSessionEvidenceRoot } from "./session-evidence.mjs";
39
47
  import {
40
48
  SKILL_SOURCE_CONFIG_PROMPT,
@@ -86,12 +94,13 @@ export function resolveConfig(rawConfig = {}) {
86
94
  const skillSource = governance.skillSource ?? "bundled";
87
95
  const skillConfigPath = resolveSkillSourceConfigPath(governance.skillConfigPath);
88
96
  const outputConfigPath = resolveOutputConfigPath(output.configPath);
97
+ const compactionConfigPath = resolveCompactionConfigPath(compaction.configPath);
89
98
  const compactionCacheRetention = compaction.cacheRetention
90
99
  ?? process.env.ODAI_COMPACTION_CACHE_RETENTION
91
- ?? "long";
100
+ ?? "provider-default";
92
101
  const unknownRoles = Object.keys(roles).filter((role) => !ROUTED_ROLES.includes(role));
93
102
  const unknownOutputFields = Object.keys(output).filter((field) => field !== "configPath");
94
- const unknownCompactionFields = Object.keys(compaction).filter((field) => field !== "cacheRetention");
103
+ const unknownCompactionFields = Object.keys(compaction).filter((field) => !["cacheRetention", "configPath"].includes(field));
95
104
 
96
105
  if (!ROUTING_MODES.has(mode)) {
97
106
  throw new TypeError("config.routing.mode must be off, observe, auto, or execute");
@@ -143,13 +152,16 @@ export function resolveConfig(rawConfig = {}) {
143
152
  skillConfigPath,
144
153
  }),
145
154
  output: Object.freeze({ configPath: outputConfigPath }),
146
- compaction: Object.freeze({ cacheRetention: compactionCacheRetention }),
155
+ compaction: Object.freeze({
156
+ cacheRetention: compactionCacheRetention,
157
+ configPath: compactionConfigPath,
158
+ }),
147
159
  });
148
160
  }
149
161
 
150
- export function inheritCompactionReasoning(options, sessions, cacheRetention = "long") {
162
+ /** Mutate exact-route compaction options; returns true only when an option changed. */
163
+ export function inheritCompactionReasoning(options, sessions, cacheRetention = "provider-default") {
151
164
  if (options?.purpose !== "compaction"
152
- || options.reasoningEffort !== undefined
153
165
  || options.sessionId === undefined
154
166
  || !Object.isExtensible(options)) {
155
167
  return false;
@@ -163,11 +175,16 @@ export function inheritCompactionReasoning(options, sessions, cacheRetention = "
163
175
  return false;
164
176
  }
165
177
 
166
- options.reasoningEffort = route.reasoningEffort;
178
+ let changed = false;
179
+ if (options.reasoningEffort === undefined) {
180
+ options.reasoningEffort = route.reasoningEffort;
181
+ changed = true;
182
+ }
167
183
  if (options.cacheRetention === undefined && cacheRetention !== "provider-default") {
168
184
  options.cacheRetention = cacheRetention;
185
+ changed = true;
169
186
  }
170
- return true;
187
+ return changed;
171
188
  }
172
189
 
173
190
  export function resolveSkillPath(configuredPath, env = process.env) {
@@ -382,8 +399,21 @@ export function apply(ctx, rawConfig) {
382
399
  }
383
400
  };
384
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
+ };
385
410
  ctx.on("llm/stream", (options, next) => {
386
- inheritCompactionReasoning(options, ctx.sessions, config.compaction.cacheRetention);
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
+ }
387
417
  return next();
388
418
  });
389
419
  const skillPath = resolveSkillPath(config.skillPath);
@@ -424,6 +454,11 @@ export function apply(ctx, rawConfig) {
424
454
  order: -17,
425
455
  text: "",
426
456
  });
457
+ ctx.systemPrompt.section({
458
+ name: "odai:compaction-model-configuration",
459
+ order: -16,
460
+ text: COMPACTION_CONFIG_PROMPT,
461
+ });
427
462
 
428
463
  const optionalSkillRegistry = () => {
429
464
  try {
@@ -568,6 +603,12 @@ export function apply(ctx, rawConfig) {
568
603
  appendEvent(agent, "odai/output-configured", data);
569
604
  },
570
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
+ }));
571
612
  ctx.tools.guard((execution) => childGuard(execution) ?? routeProtectionGuard(execution));
572
613
 
573
614
  ctx.on("tools/result", (execution, result) => {