odai-dsh-agent 0.0.4 → 0.0.5

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,16 @@ 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
+ ## Skill sources
57
+
58
+ 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:
59
+
60
+ - `bundled`: use the skill shipped with this Agent release.
61
+ - `auto`: allow a compatible current-project `.dsh/skills/odai` or `.agents/skills/odai` bundle, then DSH custom roots and newer user installs under `$DSH_HOME/skills/odai` or `$DSH_AGENTS_HOME/skills/odai` (default `~/.agents/skills/odai`), with bundled fallback.
62
+ - `user`: ignore project roots and require a compatible custom or user-level bundle. An unusable source produces an explicit bundled fallback diagnostic so it can be repaired through the same tool.
63
+
64
+ An independent install must be a complete directory bundle containing `SKILL.md`, `manifest.json`, and every manifest-declared file. The runtime checks SemVer 2.0.0 `skillVersion`, an exact supported `runtimeContract`, complete-file SHA-256 integrity, and same-version content conflicts. Prompt governance and routing role contracts are selected atomically for one agent turn; project sources are scoped by that session's cwd, and changes are reconsidered on the next user turn. Explicit deployment `skillPath` or `ODAI_SKILL_PATH` remains highest priority and requires a DSH restart.
65
+
56
66
  ## Status and uninstall
57
67
 
58
68
  ```sh
@@ -71,7 +81,7 @@ Use only the surface that matches the desired scope:
71
81
  - `odai-dsh-agent`: selectable Odai governance for only sessions using this preset.
72
82
  - both: supported only for a deliberate combination of profile-wide and Agent-scoped behavior; normally redundant, so it is not the default recommendation.
73
83
 
74
- When both are deliberate, the shared compatibility-safe evidence store deduplicates tool and route records and denials remain monotonic. Neither package installs or changes the provider-neutral `odai-cli`.
84
+ When both are deliberate, a process-shared per-agent/per-turn skill snapshot keeps prompt governance and role contracts identical, the compatibility-safe evidence store deduplicates tool and route records, and denials remain monotonic. Neither package installs or changes the provider-neutral `odai-cli`.
75
85
 
76
86
  ## Development
77
87
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "odai-dsh-agent",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "完整继承 DSH 标准模式 全部能力的 Odai Agent preset。主要用于治理、规划、测试、验证 等,可当成默认总控使用。",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,11 +1,11 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { existsSync, readFileSync } from "node:fs";
2
+ import { existsSync } from "node:fs";
3
3
  import { dirname, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
6
6
  import {
7
7
  decideRoute,
8
- extractLatestUserText,
8
+ extractRoutingText,
9
9
  renderDelegationPrompt,
10
10
  renderMissingRouteConfigNotice,
11
11
  renderRouteFailureNotice,
@@ -27,6 +27,23 @@ import {
27
27
  resolveRoutingConfigPath,
28
28
  } from "./routing-config.mjs";
29
29
  import { createSessionEvidence, resolveSessionEvidenceRoot } from "./session-evidence.mjs";
30
+ import {
31
+ SKILL_SOURCE_CONFIG_PROMPT,
32
+ createSkillSourceConfigTool,
33
+ effectiveSkillSource,
34
+ resolveSkillSourceConfigPath,
35
+ } from "./skill-source-config.mjs";
36
+ import {
37
+ ODAI_RUNTIME_CONTRACT,
38
+ SKILL_SOURCE_MODES,
39
+ loadSkillBundle,
40
+ } from "./skill-bundle.mjs";
41
+ import { resolveSkillSelection } from "./skill-selector.mjs";
42
+ import {
43
+ currentAgentTurn,
44
+ selectSharedSkillForTurn,
45
+ sharedSkillSelection,
46
+ } from "./skill-selection-state.mjs";
30
47
 
31
48
  export const name = "odai-dsh-runtime";
32
49
  export const inject = ["systemPrompt", "tools", "subagents"];
@@ -54,6 +71,8 @@ export function resolveConfig(rawConfig = {}) {
54
71
  const maxInputChars = routing.maxInputChars ?? 12_000;
55
72
  const configPath = resolveRoutingConfigPath(routing.configPath);
56
73
  const additionalDeniedTools = governance.additionalDeniedTools ?? [];
74
+ const skillSource = governance.skillSource ?? "bundled";
75
+ const skillConfigPath = resolveSkillSourceConfigPath(governance.skillConfigPath);
57
76
  const unknownRoles = Object.keys(roles).filter((role) => !ROUTED_ROLES.includes(role));
58
77
 
59
78
  if (!ROUTING_MODES.has(mode)) {
@@ -72,6 +91,9 @@ export function resolveConfig(rawConfig = {}) {
72
91
  || additionalDeniedTools.some((tool) => typeof tool !== "string" || tool.trim() === "")) {
73
92
  throw new TypeError("config.governance.additionalDeniedTools must be an array of non-empty strings");
74
93
  }
94
+ if (!SKILL_SOURCE_MODES.includes(skillSource)) {
95
+ throw new TypeError("config.governance.skillSource must be bundled, auto, or user");
96
+ }
75
97
  if (raw.skillPath !== undefined && (typeof raw.skillPath !== "string" || raw.skillPath.trim() === "")) {
76
98
  throw new TypeError("config.skillPath must be a non-empty string");
77
99
  }
@@ -90,32 +112,31 @@ export function resolveConfig(rawConfig = {}) {
90
112
  }),
91
113
  governance: Object.freeze({
92
114
  additionalDeniedTools: Object.freeze(additionalDeniedTools.map((tool) => tool.trim())),
115
+ skillSource,
116
+ skillConfigPath,
93
117
  }),
94
118
  });
95
119
  }
96
120
 
97
- export function resolveSkillPath(configuredPath) {
121
+ export function resolveSkillPath(configuredPath, env = process.env) {
122
+ const explicit = configuredPath ?? env.ODAI_SKILL_PATH;
123
+ if (explicit !== undefined) {
124
+ const path = resolve(explicit);
125
+ if (!existsSync(path)) throw new Error(`explicit Odai canonical skill not found: ${path}`);
126
+ return path;
127
+ }
128
+
98
129
  const candidates = [
99
- configuredPath,
100
- process.env.ODAI_SKILL_PATH,
101
130
  resolve(PLUGIN_DIR, "skills/odai/SKILL.md"),
102
131
  resolve(PLUGIN_DIR, "../../skills/odai/SKILL.md"),
103
- ].filter(Boolean).map((candidate) => resolve(candidate));
104
-
132
+ ];
105
133
  const found = candidates.find((candidate) => existsSync(candidate));
106
134
  if (found) return found;
107
-
108
- throw new Error(`odai canonical skill not found; checked: ${candidates.join(", ")}`);
135
+ throw new Error(`Odai bundled canonical skill not found; checked: ${candidates.join(", ")}`);
109
136
  }
110
137
 
111
138
  export function loadRoleContracts(skillPath) {
112
- const roleRoot = resolve(dirname(skillPath), "assets/routing-roles");
113
- return Object.freeze(Object.fromEntries(ROUTED_ROLES.map((role) => {
114
- const path = resolve(roleRoot, `${role}.md`);
115
- const text = existsSync(path) ? readFileSync(path, "utf8").trim() : "";
116
- if (!text) throw new Error(`odai canonical ${role} role is unavailable: ${path}`);
117
- return [role, text];
118
- })));
139
+ return loadSkillBundle(skillPath).roleContracts;
119
140
  }
120
141
 
121
142
  function deepFreeze(value) {
@@ -263,13 +284,20 @@ export async function runRoutedRole({ subagents, provider, decision, taskText, r
263
284
  return outcome;
264
285
  }
265
286
 
266
- function canonicalPrompt(skillPath, skillText) {
287
+ function canonicalPrompt(selection) {
288
+ const { bundle } = selection;
289
+ const fallback = selection.status === "fallback"
290
+ ? `Selection fallback: ${selection.reasonCode}${selection.detail ? ` (${selection.detail})` : ""}.`
291
+ : undefined;
267
292
  return [
268
293
  "## odai canonical governance",
269
- `Canonical source: ${skillPath}`,
294
+ `Canonical source: ${bundle.source} (${bundle.provider})`,
295
+ `Canonical skill: ${bundle.manifest.skillVersion}; runtime contract: ${bundle.manifest.runtimeContract}; digest: ${bundle.digest}.`,
296
+ ...(fallback ? [fallback] : []),
270
297
  "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.",
298
+ "Odai governance is already loaded by this runtime; do not call the skill tool to load odai again.",
271
299
  "",
272
- skillText,
300
+ bundle.skillText,
273
301
  ].join("\n");
274
302
  }
275
303
 
@@ -303,20 +331,103 @@ export function apply(ctx, rawConfig) {
303
331
  };
304
332
  const hasSessionEvent = (agent, type, predicate) => evidence.has(agent, type, predicate);
305
333
  const skillPath = resolveSkillPath(config.skillPath);
306
- const skillText = readFileSync(skillPath, "utf8").trim();
307
- if (!skillText) throw new Error(`odai canonical skill is empty: ${skillPath}`);
308
- const roleContracts = loadRoleContracts(skillPath);
334
+ const explicitSkillPath = config.skillPath !== undefined
335
+ || (typeof process.env.ODAI_SKILL_PATH === "string" && process.env.ODAI_SKILL_PATH.trim() !== "");
336
+ const bundled = loadSkillBundle(skillPath, {
337
+ source: explicitSkillPath ? "path" : "bundled",
338
+ provider: explicitSkillPath ? "odai-explicit-path" : "odai-dsh-runtime",
339
+ });
340
+ if (bundled.manifest.runtimeContract !== ODAI_RUNTIME_CONTRACT) {
341
+ throw new Error(`Odai canonical runtimeContract ${bundled.manifest.runtimeContract} is incompatible with this runtime`);
342
+ }
343
+ const baseSelection = Object.freeze({
344
+ mode: explicitSkillPath ? "path" : "bundled",
345
+ status: "selected",
346
+ reasonCode: explicitSkillPath ? "explicit-path" : "bundled-configured",
347
+ bundle: bundled,
348
+ rejections: Object.freeze([]),
349
+ });
309
350
 
310
351
  ctx.systemPrompt.section({
311
352
  name: "odai:canonical-governance",
312
353
  order: -20,
313
- text: canonicalPrompt(skillPath, skillText),
354
+ text: canonicalPrompt(baseSelection),
314
355
  });
315
356
  ctx.systemPrompt.section({
316
357
  name: "odai:routing-configuration",
317
358
  order: -19,
318
359
  text: ROUTING_CONFIG_PROMPT,
319
360
  });
361
+ ctx.systemPrompt.section({
362
+ name: "odai:skill-source-configuration",
363
+ order: -18,
364
+ text: SKILL_SOURCE_CONFIG_PROMPT,
365
+ });
366
+
367
+ const optionalSkillRegistry = () => {
368
+ try {
369
+ if (ctx.skills && typeof ctx.skills.get === "function") return ctx.skills;
370
+ } catch {}
371
+ try {
372
+ const service = typeof ctx.get === "function" ? ctx.get("skills") : undefined;
373
+ return service && typeof service.get === "function" ? service : undefined;
374
+ } catch {
375
+ return undefined;
376
+ }
377
+ };
378
+ const selectForAgent = async (agent, context) => {
379
+ if (explicitSkillPath) return baseSelection;
380
+ let mode;
381
+ try {
382
+ mode = effectiveSkillSource(config.governance.skillConfigPath, config.governance.skillSource);
383
+ } catch (error) {
384
+ return Object.freeze({
385
+ ...baseSelection,
386
+ mode: "bundled",
387
+ status: "fallback",
388
+ reasonCode: "source-config-invalid",
389
+ detail: error instanceof Error ? error.message : String(error),
390
+ });
391
+ }
392
+ return resolveSkillSelection({
393
+ mode,
394
+ bundled,
395
+ cwd: agent.session?.header?.cwd,
396
+ scope: context.scope,
397
+ signal: context.signal,
398
+ skills: optionalSkillRegistry(),
399
+ });
400
+ };
401
+ ctx.on("system-prompt/assemble", async (assembly, context, next) => {
402
+ const downstream = await next();
403
+ const agent = context.agent;
404
+ if (!agent) return downstream;
405
+ const selection = await selectSharedSkillForTurn(agent, () => selectForAgent(agent, context));
406
+ const turn = currentAgentTurn(agent);
407
+ const selectionEvidence = {
408
+ ...(turn === undefined ? {} : { turn }),
409
+ requestedMode: selection.mode,
410
+ status: selection.status,
411
+ reasonCode: selection.reasonCode,
412
+ effectiveSource: selection.bundle.source,
413
+ skillVersion: selection.bundle.manifest.skillVersion,
414
+ runtimeContract: selection.bundle.manifest.runtimeContract,
415
+ digest: selection.bundle.digest,
416
+ rejections: selection.rejections.map(({ source, reasonCode }) => ({ source, reasonCode })),
417
+ };
418
+ if (!hasSessionEvent(agent, "odai/skill-selected", (data) => data?.turn === turn && data?.digest === selection.bundle.digest)) {
419
+ appendEvent(agent, "odai/skill-selected", selectionEvidence);
420
+ }
421
+ if (selection.status === "fallback") {
422
+ logger.warn(`Odai skill source ${selection.mode} fell back to bundled governance (${selection.reasonCode})`);
423
+ }
424
+ return {
425
+ ...downstream,
426
+ sections: downstream.sections.map((section) => section.name === "odai:canonical-governance"
427
+ ? { ...section, text: canonicalPrompt(selection) }
428
+ : section),
429
+ };
430
+ });
320
431
 
321
432
  const routeProtections = new WeakMap();
322
433
  const controllerUpgrades = new WeakMap();
@@ -354,6 +465,16 @@ export function apply(ctx, rawConfig) {
354
465
  appendEvent(agent, "odai/routing-configured", data);
355
466
  },
356
467
  }));
468
+ ctx.tools.register(createSkillSourceConfigTool(
469
+ config.governance.skillConfigPath,
470
+ config.governance.skillSource,
471
+ {
472
+ explicitPath: explicitSkillPath,
473
+ onConfigured(agent, data) {
474
+ appendEvent(agent, "odai/skill-source-configured", data);
475
+ },
476
+ },
477
+ ));
357
478
  ctx.tools.guard((execution) => childGuard(execution) ?? routeProtectionGuard(execution));
358
479
 
359
480
  ctx.on("tools/result", (execution, result) => {
@@ -423,7 +544,7 @@ export function apply(ctx, rawConfig) {
423
544
  if (routed.has(routeKey)) return downstream;
424
545
  routed.add(routeKey);
425
546
 
426
- const taskText = extractLatestUserText(downstream.messages).slice(0, config.routing.maxInputChars);
547
+ const taskText = extractRoutingText(downstream.messages, agent?.session?.events).slice(0, config.routing.maxInputChars);
427
548
  const decision = decideRoute({ text: taskText });
428
549
  const routeRole = decision.targetRole ?? decision.role;
429
550
  appendEvent(agent, "odai/route-decided", {
@@ -520,7 +641,7 @@ export function apply(ctx, rawConfig) {
520
641
  provider: config.routing.provider,
521
642
  decision: delegationDecision,
522
643
  taskText,
523
- roleContract: roleContracts[routeRole],
644
+ roleContract: (sharedSkillSelection(agent, turn)?.bundle ?? bundled).roleContracts[routeRole],
524
645
  agent,
525
646
  signal,
526
647
  roleRoute,
@@ -577,7 +698,7 @@ export function apply(ctx, rawConfig) {
577
698
  }, { prepend: true });
578
699
  }
579
700
 
580
- logger.info(`loaded canonical governance from ${skillPath}; routing=${config.routing.mode}`);
701
+ logger.info(`loaded canonical governance ${bundled.manifest.skillVersion} from ${skillPath}; skillSource=${explicitSkillPath ? "path" : config.governance.skillSource}; routing=${config.routing.mode}`);
581
702
  }
582
703
 
583
704
  function isSubagentSession(agent) {
@@ -60,6 +60,16 @@ const IRREVERSIBLE_ACTION_PATTERNS = [
60
60
  /\b(?:delete|drop|clear|migrate|deploy|release|disable)\b/iu,
61
61
  ];
62
62
 
63
+ const CONTINUATION_PATTERNS = [
64
+ /(?:继续|接着|进一步|深入|再(?:判断|分析|评估|检查|处理)|按(?:照)?(?:刚才|上面|上述|前面|这个|该)|那就|就按)/iu,
65
+ /\b(?:continue|proceed|go ahead|follow up|dig deeper|based on (?:that|the previous|the above))\b/iu,
66
+ ];
67
+
68
+ const LOW_RISK_TRANSFORM_PATTERNS = [
69
+ /(?:重述|总结|概括|压缩|翻译|改写|润色|格式化|解释(?:一下)?)/iu,
70
+ /\b(?:restate|summari[sz]e|translate|shorten|rewrite|format|explain)\b/iu,
71
+ ];
72
+
63
73
  function matchesAny(text, patterns) {
64
74
  return patterns.some((pattern) => pattern.test(text));
65
75
  }
@@ -72,6 +82,48 @@ function stripQuotedMaterial(text) {
72
82
  .replace(/“[^”\n]*”|‘[^’\n]*’|「[^」\n]*」|『[^』\n]*』|《[^》\n]*》|"[^"\n]*"/gu, " ");
73
83
  }
74
84
 
85
+ function hasContextualPlannerGap(text) {
86
+ return matchesAny(text, RISK_PATTERNS)
87
+ && matchesAny(text, UNVERIFIED_CAUSAL_PATTERNS)
88
+ && matchesAny(text, CONCRETE_CHANGE_PATTERNS)
89
+ && (matchesAny(text, SPECIFIC_PARAMETER_PATTERNS)
90
+ || matchesAny(text, URGENCY_PATTERNS)
91
+ || matchesAny(text, IRREVERSIBLE_ACTION_PATTERNS));
92
+ }
93
+
94
+ function isLowRiskTransform(text) {
95
+ return matchesAny(stripQuotedMaterial(text), LOW_RISK_TRANSFORM_PATTERNS);
96
+ }
97
+
98
+ function genuineUserText(message) {
99
+ if (message?.role !== "user" || message?.source?.kind !== "user" || !Array.isArray(message.content)) return "";
100
+ return message.content
101
+ .filter((block) => block?.type === "text" && typeof block.text === "string")
102
+ .map((block) => block.text)
103
+ .join("\n")
104
+ .trim();
105
+ }
106
+
107
+ function genuineUserTexts(messages, sessionEvents = []) {
108
+ const candidates = [
109
+ ...(Array.isArray(messages) ? [...messages].reverse() : []),
110
+ ...(Array.isArray(sessionEvents)
111
+ ? [...sessionEvents].reverse()
112
+ .filter((event) => event?.type === "user/message")
113
+ .map((event) => event.data)
114
+ : []),
115
+ ];
116
+ const seen = new Set();
117
+ const texts = [];
118
+ for (const message of candidates) {
119
+ const text = genuineUserText(message);
120
+ if (!text || seen.has(text)) continue;
121
+ seen.add(text);
122
+ texts.push(text);
123
+ }
124
+ return texts;
125
+ }
126
+
75
127
  function route(role, reasonCode, reason, signals, action = role === "controller" ? "direct" : "delegate", targetRole) {
76
128
  return Object.freeze({
77
129
  role,
@@ -137,11 +189,11 @@ export function decideRoute(input = {}) {
137
189
  );
138
190
  }
139
191
 
140
- const hasContextualPlannerGap = riskPresent
192
+ const contextualPlannerGap = riskPresent
141
193
  && unverifiedCausalClaim
142
194
  && concreteChangeRequest
143
195
  && (specificOperationalParameter || urgencyPressure || irreversibleAction);
144
- if (hasContextualPlannerGap) {
196
+ if (contextualPlannerGap) {
145
197
  return route(
146
198
  "controller",
147
199
  HIGH_IMPACT_PLANNER_REASON,
@@ -162,23 +214,29 @@ export function decideRoute(input = {}) {
162
214
  }
163
215
 
164
216
  export function extractLatestUserText(messages) {
165
- if (!Array.isArray(messages)) return "";
166
-
167
- for (let index = messages.length - 1; index >= 0; index -= 1) {
168
- const message = messages[index];
169
- if (message?.role !== "user") continue;
170
- if (message?.source?.kind !== "user") continue;
171
- if (!Array.isArray(message.content)) continue;
172
-
173
- const text = message.content
174
- .filter((block) => block?.type === "text" && typeof block.text === "string")
175
- .map((block) => block.text)
176
- .join("\n")
177
- .trim();
178
- if (text) return text;
217
+ return genuineUserTexts(messages)[0] ?? "";
218
+ }
219
+
220
+ export function extractRoutingText(messages, sessionEvents) {
221
+ const texts = genuineUserTexts(messages, sessionEvents);
222
+ const latest = texts[0] ?? "";
223
+ if (!latest
224
+ || hasContextualPlannerGap(latest)
225
+ || !matchesAny(stripQuotedMaterial(latest), CONTINUATION_PATTERNS)
226
+ || isLowRiskTransform(latest)) {
227
+ return latest;
179
228
  }
180
229
 
181
- return "";
230
+ let referencedHighImpact;
231
+ for (const text of texts.slice(1)) {
232
+ if (hasContextualPlannerGap(text)) {
233
+ referencedHighImpact = text;
234
+ break;
235
+ }
236
+ if (!isLowRiskTransform(text)) break;
237
+ }
238
+ if (!referencedHighImpact) return latest;
239
+ return `${latest}\n\nReferenced earlier high-impact user context:\n${referencedHighImpact}`;
182
240
  }
183
241
 
184
242
  export function requiresFailClosedProtection(decision) {
@@ -283,6 +341,7 @@ export function renderDelegationPrompt(decision, taskText, roleContract) {
283
341
  `You are the odai ${decision.role}.`,
284
342
  roleContract.trim(),
285
343
  "Runtime boundary: do not edit files, run shell commands, ask the user, or delegate further. The controller owns all final decisions and delivery.",
344
+ "Context boundary: this is a bounded task and evidence packet, not an inherited controller transcript. Use only the supplied contract, task, evidence, and source pointers; do not request or reconstruct the controller's full history.",
286
345
  "",
287
346
  "Task:",
288
347
  taskText,