odai-dsh-agent 0.0.3 → 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 +14 -4
- package/bin/odai-dsh-agent.mjs +23 -3
- package/package.json +3 -3
- package/preset/odai/preset.yml +2 -2
- package/preset/odai/runtime/governance.mjs +2 -2
- package/preset/odai/runtime/index.mjs +166 -42
- package/preset/odai/runtime/router.mjs +76 -17
- package/preset/odai/runtime/routing-config.mjs +3 -2
- package/preset/odai/runtime/session-compat.mjs +387 -0
- package/preset/odai/runtime/session-evidence.mjs +344 -0
- package/preset/odai/runtime/skill-bundle.mjs +295 -0
- package/preset/odai/runtime/skill-selection-state.mjs +53 -0
- package/preset/odai/runtime/skill-selector.mjs +134 -0
- package/preset/odai/runtime/skill-source-config.mjs +239 -0
- package/preset/odai/skills/odai/SKILL.md +2 -1
- package/preset/odai/skills/odai/assets/routing-roles/controller.md +1 -1
- package/preset/odai/skills/odai/assets/routing-roles/reviewer.md +1 -1
- package/preset/odai/skills/odai/manifest.json +33 -0
- package/preset/odai/skills/odai/references/craft.md +1 -0
- package/preset/odai/skills/odai/references/leverage.md +1 -0
- package/preset/odai/skills/odai/references/planning.md +81 -0
- package/scripts/verify-agent-load.mjs +47 -19
- package/src/installer.mjs +60 -2
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ Open a new DSH session and select `Odai` from the Agent preset picker. Existing
|
|
|
35
35
|
|
|
36
36
|
In `dsh@0.1.0-rc.6`, the one-shot `--profile headless` driver creates a global agent directly and neither mounts nor accepts a session Agent preset. Automated Agent runs must create a Web session with `agentPreset: odai`; use the profile-wide Plugin for one-shot headless tasks.
|
|
37
37
|
|
|
38
|
-
The installer copies through a mode-tightened staging directory and atomically publishes the preset. Updates verify every previously managed file first, refuse to overwrite local edits, and always change the composition generation key so new sessions in a running DSH process do not reuse stale runtime code.
|
|
38
|
+
The installer copies through a mode-tightened staging directory and atomically publishes the preset. Updates verify every previously managed file first, refuse to overwrite local edits, and always change the composition generation key so new sessions in a running DSH process do not reuse stale runtime code. If install or update finds recognized historical Odai audit records, it refuses to change the preset until every DSH process is stopped and the command is rerun with `--yes`. The confirmed migration adds DSH's official `ignorable: true` envelope marker, covers both plaintext and concatenated-frame Zstandard session artifacts, verifies each replacement, and retains a content-addressed backup without deleting messages or evidence. Confirmation alone is insufficient: migration also refuses when local process inspection fails or finds any active DSH process. Keep DSH stopped until the installer exits because historical runtimes do not participate in a migration lock. An unknown unmarked `odai/*` type blocks the operation instead of being assumed safe to ignore.
|
|
39
39
|
|
|
40
40
|
DSH classifies this as a `trust: user` preset. User presets have the same privileges as shell access, so install only reviewed package versions; the installer repeats this trust notice in both plain and JSON output.
|
|
41
41
|
|
|
@@ -49,10 +49,20 @@ The Agent ships no planner, executor, or reviewer model mapping. It stays quiet
|
|
|
49
49
|
验收改用 provider-z/model-review,推理档 max。
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
-
The controller calls `odai_routing_config` to persist that explicit choice. The user does not edit Agent files, YAML, or JSON and does not add trigger terms to later tasks. Mappings live in `$DSH_HOME/odai/routing.json`, outside the managed preset, so installer updates do not report them as drift. Changes apply from the next user turn. If reasoning effort is omitted, the target provider/model uses its own default rather than inheriting the source controller's setting. Plugin and Agent read the same
|
|
52
|
+
The controller calls `odai_routing_config` to persist that explicit choice. The user does not edit Agent files, YAML, or JSON and does not add trigger terms to later tasks. Mappings live in `$DSH_HOME/odai/routing.json`, outside the managed preset, so installer updates do not report them as drift. Audit evidence likewise lives under `$DSH_HOME/odai/session-evidence/` instead of using private DSH session-event types, so changing or removing the preset cannot make a session unreadable. Changes apply from the next user turn. If reasoning effort is omitted, the target provider/model uses its own default rather than inheriting the source controller's setting. Plugin and Agent read the same stores when both are deliberately present.
|
|
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
|
|
@@ -61,7 +71,7 @@ npx odai-dsh-agent status --json
|
|
|
61
71
|
npx odai-dsh-agent uninstall
|
|
62
72
|
```
|
|
63
73
|
|
|
64
|
-
`status` reports `absent`, `installed`, or `drifted`. Update and uninstall fail closed when managed files were changed or unmanaged files were added. Uninstall also refuses while `agent-presets.default` still names `odai`; select another default first so the next session cannot fail on a missing preset.
|
|
74
|
+
`status` reports `absent`, `installed`, or `drifted`. Update and uninstall fail closed when managed files were changed or unmanaged files were added. Uninstall first checks for legacy Odai session events and, when any exist, requires the same stopped-DSH `--yes` confirmation before making them ignorable; a failed inspection or migration refuses removal. It also refuses while `agent-presets.default` still names `odai`; select another default first so the next session cannot fail on a missing preset. Stop DSH before install, update, or uninstall so session artifacts are not being written concurrently.
|
|
65
75
|
|
|
66
76
|
## Plugin versus Agent
|
|
67
77
|
|
|
@@ -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,
|
|
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/bin/odai-dsh-agent.mjs
CHANGED
|
@@ -18,6 +18,7 @@ Commands:
|
|
|
18
18
|
Options:
|
|
19
19
|
--dsh-home <path> Override DSH_HOME
|
|
20
20
|
--json Print JSON
|
|
21
|
+
--yes Confirm DSH is stopped; active-process verification still applies
|
|
21
22
|
-h, --help Show this help
|
|
22
23
|
`;
|
|
23
24
|
|
|
@@ -27,13 +28,19 @@ try {
|
|
|
27
28
|
process.stdout.write(HELP);
|
|
28
29
|
} else if (args.command === "install") {
|
|
29
30
|
assertDshVersion();
|
|
30
|
-
print(await installAgentPreset({
|
|
31
|
+
print(await installAgentPreset({
|
|
32
|
+
dshHome: args.dshHome,
|
|
33
|
+
confirmDshStopped: args.yes,
|
|
34
|
+
}), args.json);
|
|
31
35
|
} else if (args.command === "status") {
|
|
32
36
|
const result = await inspectAgentInstallation({ dshHome: args.dshHome });
|
|
33
37
|
print(result, args.json);
|
|
34
38
|
if (result.status === "drifted") process.exitCode = 2;
|
|
35
39
|
} else if (args.command === "uninstall") {
|
|
36
|
-
print(await uninstallAgentPreset({
|
|
40
|
+
print(await uninstallAgentPreset({
|
|
41
|
+
dshHome: args.dshHome,
|
|
42
|
+
confirmDshStopped: args.yes,
|
|
43
|
+
}), args.json);
|
|
37
44
|
} else {
|
|
38
45
|
throw new Error("a command is required\n\n" + HELP);
|
|
39
46
|
}
|
|
@@ -56,11 +63,12 @@ function assertDshVersion() {
|
|
|
56
63
|
}
|
|
57
64
|
|
|
58
65
|
function parseArgs(argv) {
|
|
59
|
-
const parsed = { command: undefined, dshHome: undefined, json: false, help: false };
|
|
66
|
+
const parsed = { command: undefined, dshHome: undefined, json: false, yes: false, help: false };
|
|
60
67
|
for (let index = 0; index < argv.length; index += 1) {
|
|
61
68
|
const arg = argv[index];
|
|
62
69
|
if (arg === "-h" || arg === "--help") parsed.help = true;
|
|
63
70
|
else if (arg === "--json") parsed.json = true;
|
|
71
|
+
else if (arg === "--yes") parsed.yes = true;
|
|
64
72
|
else if (arg === "--dsh-home") parsed.dshHome = argv[++index];
|
|
65
73
|
else if (!parsed.command && ["install", "status", "uninstall"].includes(arg)) parsed.command = arg;
|
|
66
74
|
else throw new Error(`unknown argument: ${arg}`);
|
|
@@ -82,5 +90,17 @@ function print(result, json) {
|
|
|
82
90
|
return;
|
|
83
91
|
}
|
|
84
92
|
process.stdout.write(`${result.operation}: ${result.target}\n`);
|
|
93
|
+
if (result.sessionCompatibility?.repairedEvents > 0) {
|
|
94
|
+
process.stdout.write(`session compatibility: made ${result.sessionCompatibility.repairedEvents} legacy Odai event(s) ignorable in ${result.sessionCompatibility.repairedArtifacts} session artifact(s)\n`);
|
|
95
|
+
}
|
|
96
|
+
if (result.sessionCompatibility?.backupPaths?.length > 0) {
|
|
97
|
+
process.stdout.write(`session compatibility: retained ${result.sessionCompatibility.backupPaths.length} verified backup artifact(s)\n`);
|
|
98
|
+
}
|
|
99
|
+
for (const path of result.sessionCompatibility?.tornArtifacts ?? []) {
|
|
100
|
+
process.stdout.write(`session compatibility warning: preserved DSH-recoverable torn tail: ${path}\n`);
|
|
101
|
+
}
|
|
102
|
+
for (const failure of result.sessionCompatibility?.failures ?? []) {
|
|
103
|
+
process.stdout.write(`session compatibility warning: ${failure.path}: ${failure.error}\n`);
|
|
104
|
+
}
|
|
85
105
|
if (result.security) process.stdout.write(`security: ${result.security}\n`);
|
|
86
106
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "odai-dsh-agent",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "完整继承 DSH
|
|
3
|
+
"version": "0.0.5",
|
|
4
|
+
"description": "完整继承 DSH 标准模式 全部能力的 Odai Agent preset。主要用于治理、规划、测试、验证 等,可当成默认总控使用。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
}
|
|
57
57
|
},
|
|
58
58
|
"engines": {
|
|
59
|
-
"node": ">=
|
|
59
|
+
"node": ">=22.15.0"
|
|
60
60
|
},
|
|
61
61
|
"keywords": [
|
|
62
62
|
"agent",
|
package/preset/odai/preset.yml
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
name:
|
|
1
|
+
name: odai 治理模式
|
|
2
2
|
order: 40
|
|
3
|
-
description: 完整继承 DSH
|
|
3
|
+
description: 完整继承 DSH 标准模式 全部能力,并叠加 odai 治理、证据与自动路由,以 odai 为总控。
|
|
@@ -57,9 +57,9 @@ export function createChildToolGuard(options = {}) {
|
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
export function activeRouteProtection(agent) {
|
|
60
|
+
export function activeRouteProtection(agent, recordedEvents = agent?.session?.events) {
|
|
61
61
|
if (isSubagent(agent)) return undefined;
|
|
62
|
-
const events =
|
|
62
|
+
const events = recordedEvents;
|
|
63
63
|
if (!Array.isArray(events)) return undefined;
|
|
64
64
|
|
|
65
65
|
let currentTurn;
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { existsSync
|
|
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
|
-
|
|
8
|
+
extractRoutingText,
|
|
9
9
|
renderDelegationPrompt,
|
|
10
10
|
renderMissingRouteConfigNotice,
|
|
11
11
|
renderRouteFailureNotice,
|
|
@@ -26,6 +26,24 @@ import {
|
|
|
26
26
|
resolveRoleRoute,
|
|
27
27
|
resolveRoutingConfigPath,
|
|
28
28
|
} from "./routing-config.mjs";
|
|
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";
|
|
29
47
|
|
|
30
48
|
export const name = "odai-dsh-runtime";
|
|
31
49
|
export const inject = ["systemPrompt", "tools", "subagents"];
|
|
@@ -53,6 +71,8 @@ export function resolveConfig(rawConfig = {}) {
|
|
|
53
71
|
const maxInputChars = routing.maxInputChars ?? 12_000;
|
|
54
72
|
const configPath = resolveRoutingConfigPath(routing.configPath);
|
|
55
73
|
const additionalDeniedTools = governance.additionalDeniedTools ?? [];
|
|
74
|
+
const skillSource = governance.skillSource ?? "bundled";
|
|
75
|
+
const skillConfigPath = resolveSkillSourceConfigPath(governance.skillConfigPath);
|
|
56
76
|
const unknownRoles = Object.keys(roles).filter((role) => !ROUTED_ROLES.includes(role));
|
|
57
77
|
|
|
58
78
|
if (!ROUTING_MODES.has(mode)) {
|
|
@@ -71,6 +91,9 @@ export function resolveConfig(rawConfig = {}) {
|
|
|
71
91
|
|| additionalDeniedTools.some((tool) => typeof tool !== "string" || tool.trim() === "")) {
|
|
72
92
|
throw new TypeError("config.governance.additionalDeniedTools must be an array of non-empty strings");
|
|
73
93
|
}
|
|
94
|
+
if (!SKILL_SOURCE_MODES.includes(skillSource)) {
|
|
95
|
+
throw new TypeError("config.governance.skillSource must be bundled, auto, or user");
|
|
96
|
+
}
|
|
74
97
|
if (raw.skillPath !== undefined && (typeof raw.skillPath !== "string" || raw.skillPath.trim() === "")) {
|
|
75
98
|
throw new TypeError("config.skillPath must be a non-empty string");
|
|
76
99
|
}
|
|
@@ -89,32 +112,31 @@ export function resolveConfig(rawConfig = {}) {
|
|
|
89
112
|
}),
|
|
90
113
|
governance: Object.freeze({
|
|
91
114
|
additionalDeniedTools: Object.freeze(additionalDeniedTools.map((tool) => tool.trim())),
|
|
115
|
+
skillSource,
|
|
116
|
+
skillConfigPath,
|
|
92
117
|
}),
|
|
93
118
|
});
|
|
94
119
|
}
|
|
95
120
|
|
|
96
|
-
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
|
+
|
|
97
129
|
const candidates = [
|
|
98
|
-
configuredPath,
|
|
99
|
-
process.env.ODAI_SKILL_PATH,
|
|
100
130
|
resolve(PLUGIN_DIR, "skills/odai/SKILL.md"),
|
|
101
131
|
resolve(PLUGIN_DIR, "../../skills/odai/SKILL.md"),
|
|
102
|
-
]
|
|
103
|
-
|
|
132
|
+
];
|
|
104
133
|
const found = candidates.find((candidate) => existsSync(candidate));
|
|
105
134
|
if (found) return found;
|
|
106
|
-
|
|
107
|
-
throw new Error(`odai canonical skill not found; checked: ${candidates.join(", ")}`);
|
|
135
|
+
throw new Error(`Odai bundled canonical skill not found; checked: ${candidates.join(", ")}`);
|
|
108
136
|
}
|
|
109
137
|
|
|
110
138
|
export function loadRoleContracts(skillPath) {
|
|
111
|
-
|
|
112
|
-
return Object.freeze(Object.fromEntries(ROUTED_ROLES.map((role) => {
|
|
113
|
-
const path = resolve(roleRoot, `${role}.md`);
|
|
114
|
-
const text = existsSync(path) ? readFileSync(path, "utf8").trim() : "";
|
|
115
|
-
if (!text) throw new Error(`odai canonical ${role} role is unavailable: ${path}`);
|
|
116
|
-
return [role, text];
|
|
117
|
-
})));
|
|
139
|
+
return loadSkillBundle(skillPath).roleContracts;
|
|
118
140
|
}
|
|
119
141
|
|
|
120
142
|
function deepFreeze(value) {
|
|
@@ -142,20 +164,6 @@ function loggerFor(ctx) {
|
|
|
142
164
|
return ctx.logger(name);
|
|
143
165
|
}
|
|
144
166
|
|
|
145
|
-
function appendEvent(agent, type, data, logger) {
|
|
146
|
-
try {
|
|
147
|
-
agent?.session?.append(type, data);
|
|
148
|
-
} catch (error) {
|
|
149
|
-
logger.warn(`failed to append ${type}: ${error instanceof Error ? error.message : String(error)}`);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function hasSessionEvent(agent, type, predicate) {
|
|
154
|
-
const events = agent?.session?.events;
|
|
155
|
-
if (!Array.isArray(events)) return false;
|
|
156
|
-
return events.some((event) => event?.type === type && predicate(event.data));
|
|
157
|
-
}
|
|
158
|
-
|
|
159
167
|
function outputText(blocks) {
|
|
160
168
|
if (!Array.isArray(blocks)) return "";
|
|
161
169
|
return blocks
|
|
@@ -276,13 +284,20 @@ export async function runRoutedRole({ subagents, provider, decision, taskText, r
|
|
|
276
284
|
return outcome;
|
|
277
285
|
}
|
|
278
286
|
|
|
279
|
-
function canonicalPrompt(
|
|
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;
|
|
280
292
|
return [
|
|
281
293
|
"## odai canonical governance",
|
|
282
|
-
`Canonical source: ${
|
|
294
|
+
`Canonical source: ${bundle.source} (${bundle.provider})`,
|
|
295
|
+
`Canonical skill: ${bundle.manifest.skillVersion}; runtime contract: ${bundle.manifest.runtimeContract}; digest: ${bundle.digest}.`,
|
|
296
|
+
...(fallback ? [fallback] : []),
|
|
283
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.",
|
|
284
299
|
"",
|
|
285
|
-
skillText,
|
|
300
|
+
bundle.skillText,
|
|
286
301
|
].join("\n");
|
|
287
302
|
}
|
|
288
303
|
|
|
@@ -303,21 +318,116 @@ function routedRoleOf(agent) {
|
|
|
303
318
|
export function apply(ctx, rawConfig) {
|
|
304
319
|
const config = resolveConfig(rawConfig);
|
|
305
320
|
const logger = loggerFor(ctx);
|
|
321
|
+
const evidence = createSessionEvidence({
|
|
322
|
+
root: resolveSessionEvidenceRoot(config.routing.configPath),
|
|
323
|
+
logger,
|
|
324
|
+
});
|
|
325
|
+
const appendEvent = (agent, type, data) => {
|
|
326
|
+
try {
|
|
327
|
+
evidence.append(agent, type, data);
|
|
328
|
+
} catch (error) {
|
|
329
|
+
logger.warn(`failed to record ${type}: ${error instanceof Error ? error.message : String(error)}`);
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
const hasSessionEvent = (agent, type, predicate) => evidence.has(agent, type, predicate);
|
|
306
333
|
const skillPath = resolveSkillPath(config.skillPath);
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
const
|
|
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
|
+
});
|
|
310
350
|
|
|
311
351
|
ctx.systemPrompt.section({
|
|
312
352
|
name: "odai:canonical-governance",
|
|
313
353
|
order: -20,
|
|
314
|
-
text: canonicalPrompt(
|
|
354
|
+
text: canonicalPrompt(baseSelection),
|
|
315
355
|
});
|
|
316
356
|
ctx.systemPrompt.section({
|
|
317
357
|
name: "odai:routing-configuration",
|
|
318
358
|
order: -19,
|
|
319
359
|
text: ROUTING_CONFIG_PROMPT,
|
|
320
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
|
+
});
|
|
321
431
|
|
|
322
432
|
const routeProtections = new WeakMap();
|
|
323
433
|
const controllerUpgrades = new WeakMap();
|
|
@@ -347,10 +457,24 @@ export function apply(ctx, rawConfig) {
|
|
|
347
457
|
onDenied,
|
|
348
458
|
protectionFor(agent) {
|
|
349
459
|
if (config.routing.mode === "off") return undefined;
|
|
350
|
-
return routeProtections.get(agent) ?? activeRouteProtection(agent);
|
|
460
|
+
return routeProtections.get(agent) ?? activeRouteProtection(agent, evidence.events(agent));
|
|
351
461
|
},
|
|
352
462
|
});
|
|
353
|
-
ctx.tools.register(createRoutingConfigTool(config.routing.configPath
|
|
463
|
+
ctx.tools.register(createRoutingConfigTool(config.routing.configPath, {
|
|
464
|
+
onConfigured(agent, data) {
|
|
465
|
+
appendEvent(agent, "odai/routing-configured", data);
|
|
466
|
+
},
|
|
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
|
+
));
|
|
354
478
|
ctx.tools.guard((execution) => childGuard(execution) ?? routeProtectionGuard(execution));
|
|
355
479
|
|
|
356
480
|
ctx.on("tools/result", (execution, result) => {
|
|
@@ -420,7 +544,7 @@ export function apply(ctx, rawConfig) {
|
|
|
420
544
|
if (routed.has(routeKey)) return downstream;
|
|
421
545
|
routed.add(routeKey);
|
|
422
546
|
|
|
423
|
-
const taskText =
|
|
547
|
+
const taskText = extractRoutingText(downstream.messages, agent?.session?.events).slice(0, config.routing.maxInputChars);
|
|
424
548
|
const decision = decideRoute({ text: taskText });
|
|
425
549
|
const routeRole = decision.targetRole ?? decision.role;
|
|
426
550
|
appendEvent(agent, "odai/route-decided", {
|
|
@@ -517,7 +641,7 @@ export function apply(ctx, rawConfig) {
|
|
|
517
641
|
provider: config.routing.provider,
|
|
518
642
|
decision: delegationDecision,
|
|
519
643
|
taskText,
|
|
520
|
-
roleContract: roleContracts[routeRole],
|
|
644
|
+
roleContract: (sharedSkillSelection(agent, turn)?.bundle ?? bundled).roleContracts[routeRole],
|
|
521
645
|
agent,
|
|
522
646
|
signal,
|
|
523
647
|
roleRoute,
|
|
@@ -574,7 +698,7 @@ export function apply(ctx, rawConfig) {
|
|
|
574
698
|
}, { prepend: true });
|
|
575
699
|
}
|
|
576
700
|
|
|
577
|
-
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}`);
|
|
578
702
|
}
|
|
579
703
|
|
|
580
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
|
|
192
|
+
const contextualPlannerGap = riskPresent
|
|
141
193
|
&& unverifiedCausalClaim
|
|
142
194
|
&& concreteChangeRequest
|
|
143
195
|
&& (specificOperationalParameter || urgencyPressure || irreversibleAction);
|
|
144
|
-
if (
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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,
|
|
@@ -180,7 +180,8 @@ function resultFor(configPath, action, roles, responsibility, recoveredInvalidSt
|
|
|
180
180
|
};
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
-
export function createRoutingConfigTool(configPath) {
|
|
183
|
+
export function createRoutingConfigTool(configPath, options = {}) {
|
|
184
|
+
const onConfigured = typeof options.onConfigured === "function" ? options.onConfigured : () => {};
|
|
184
185
|
return {
|
|
185
186
|
name: "odai_routing_config",
|
|
186
187
|
description: [
|
|
@@ -301,7 +302,7 @@ export function createRoutingConfigTool(configPath) {
|
|
|
301
302
|
if (args.action === "remove") delete roles[args.responsibility];
|
|
302
303
|
else roles[args.responsibility] = proposedRoute;
|
|
303
304
|
writeRoutingStore(configPath, roles);
|
|
304
|
-
execution.agent
|
|
305
|
+
onConfigured(execution.agent, {
|
|
305
306
|
action: args.action,
|
|
306
307
|
responsibility: args.responsibility,
|
|
307
308
|
route: roles[args.responsibility],
|