@openvulcan/dsh-vulcancode 0.0.0-stage → 0.4.0
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/LICENSE +21 -0
- package/README.md +86 -2
- package/assets/openvulcan-icon.svg +33 -0
- package/cordis.patch.yml +17 -0
- package/node_modules/@openvulcan/dsh-decision-guard/LICENSE +21 -0
- package/node_modules/@openvulcan/dsh-decision-guard/README.md +14 -0
- package/node_modules/@openvulcan/dsh-decision-guard/assets/decision-guard-icon.png +0 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/calls.d.ts +40 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/calls.js +150 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/client.js +2193 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/decide.d.ts +90 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/decide.js +108 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/index.d.ts +129 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/index.js +572 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/intent.d.ts +22 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/intent.js +77 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/panel.d.ts +158 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/panel.js +340 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/records.d.ts +133 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/records.js +186 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/review.d.ts +55 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/review.js +202 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/session-state.d.ts +62 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/session-state.js +155 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/version.d.ts +2 -0
- package/node_modules/@openvulcan/dsh-decision-guard/lib/version.js +4 -0
- package/node_modules/@openvulcan/dsh-decision-guard/package.json +68 -0
- package/node_modules/@openvulcan/dsh-vulcan-account/LICENSE +21 -0
- package/node_modules/@openvulcan/dsh-vulcan-account/assets/account-icon.png +0 -0
- package/node_modules/@openvulcan/dsh-vulcan-account/lib/client.js +565 -0
- package/node_modules/@openvulcan/dsh-vulcan-account/lib/index.d.ts +92 -0
- package/node_modules/@openvulcan/dsh-vulcan-account/lib/index.js +194 -0
- package/node_modules/@openvulcan/dsh-vulcan-account/package.json +57 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/LICENSE +21 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/assets/answers-icon.png +0 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/assets/prompts/answer-mode-interactive.md +79 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/assets/prompts/answer-mode-standard.md +1 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/lib/answer-model.d.ts +258 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/lib/answer-model.js +698 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/lib/client.js +671 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/lib/fences.d.ts +29 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/lib/fences.js +70 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/lib/index.d.ts +19 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/lib/index.js +52 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/lib/limits.d.ts +9 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/lib/limits.js +9 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/lib/prompt.d.ts +22 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/lib/prompt.js +39 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/lib/reply-format.d.ts +20 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/lib/reply-format.js +17 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/lib/session-state.d.ts +66 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/lib/session-state.js +118 -0
- package/node_modules/@openvulcan/dsh-vulcan-answers/package.json +59 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/LICENSE +21 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/assets/luaskills-icon.svg +1 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/client.js +940 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/constants.d.ts +46 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/constants.js +51 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/engine.d.ts +55 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/engine.js +149 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/hub.d.ts +49 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/hub.js +116 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/index.d.ts +26 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/index.js +276 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/protocol.d.ts +65 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/protocol.js +2 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/runtime.d.ts +111 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/runtime.js +281 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/sdk-install.d.ts +26 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/sdk-install.js +87 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/settings.d.ts +79 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/settings.js +165 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/skills.d.ts +34 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/skills.js +61 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/tools.d.ts +59 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/tools.js +107 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/worker.d.ts +10 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/lib/worker.js +140 -0
- package/node_modules/@openvulcan/dsh-vulcan-luaskills/package.json +57 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/LICENSE +21 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/assets/markdown-memory-guide.md +21 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/assets/memory-curator/SKILL.md +31 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/assets/memory-icon.png +0 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/client.js +1090 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/index.d.ts +30 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/index.js +240 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/inject.d.ts +30 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/inject.js +46 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/constants.d.ts +23 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/constants.js +25 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/document.d.ts +108 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/document.js +373 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/errors.d.ts +14 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/errors.js +31 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/index.d.ts +196 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/index.js +204 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/operations.d.ts +92 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/operations.js +472 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/package.d.ts +126 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/package.js +553 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/registry.d.ts +56 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/registry.js +182 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/scope.d.ts +13 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/scope.js +24 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/search.d.ts +65 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/search.js +227 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/snapshot.d.ts +34 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/snapshot.js +171 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/store.d.ts +72 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/store.js +250 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/timestamp.d.ts +6 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/timestamp.js +12 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/tokens.d.ts +8 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/markdown/tokens.js +38 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/page.d.ts +132 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/page.js +235 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/project.d.ts +14 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/project.js +27 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/settings.d.ts +29 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/lib/settings.js +62 -0
- package/node_modules/@openvulcan/dsh-vulcan-memory/package.json +63 -0
- package/node_modules/@openvulcan/dsh-vulcan-style/LICENSE +21 -0
- package/node_modules/@openvulcan/dsh-vulcan-style/assets/style-icon.png +0 -0
- package/node_modules/@openvulcan/dsh-vulcan-style/lib/client.js +1061 -0
- package/node_modules/@openvulcan/dsh-vulcan-style/lib/index.d.ts +34 -0
- package/node_modules/@openvulcan/dsh-vulcan-style/lib/index.js +42 -0
- package/node_modules/@openvulcan/dsh-vulcan-style/package.json +51 -0
- package/node_modules/@openvulcan/openvulcan-account/LICENSE +21 -0
- package/node_modules/@openvulcan/openvulcan-account/lib/account.d.ts +140 -0
- package/node_modules/@openvulcan/openvulcan-account/lib/account.js +347 -0
- package/node_modules/@openvulcan/openvulcan-account/lib/index.d.ts +1 -0
- package/node_modules/@openvulcan/openvulcan-account/lib/index.js +1 -0
- package/node_modules/@openvulcan/openvulcan-account/package.json +29 -0
- package/node_modules/@openvulcan/vulcan-decision/LICENSE +21 -0
- package/node_modules/@openvulcan/vulcan-decision/README.md +23 -0
- package/node_modules/@openvulcan/vulcan-decision/bin/vulcan-decision.js +4 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/cli.d.ts +5 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/cli.js +267 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/cloud/edge.d.ts +211 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/cloud/edge.js +594 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/download.d.ts +36 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/download.js +99 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/engine.d.ts +37 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/engine.js +39 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/hardware.d.ts +105 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/hardware.js +207 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/home.d.ts +26 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/home.js +54 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/index.d.ts +17 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/index.js +17 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/local/engine.d.ts +54 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/local/engine.js +157 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/local/models.d.ts +126 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/local/models.js +261 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/local/packs.d.ts +102 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/local/packs.js +195 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/local/worker.d.ts +61 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/local/worker.js +123 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/protocol.d.ts +74 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/protocol.js +31 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/render.d.ts +15 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/render.js +33 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/version.d.ts +2 -0
- package/node_modules/@openvulcan/vulcan-decision/lib/version.js +4 -0
- package/node_modules/@openvulcan/vulcan-decision/package.json +40 -0
- package/node_modules/@openvulcan/vulcan-decision/python/vulcan_decision_worker.py +363 -0
- package/node_modules/@openvulcan/vulcan-decision/runtime-packs/onnx-cpu.json +627 -0
- package/node_modules/@openvulcan/vulcan-decision/runtime-packs/onnx-cuda.json +423 -0
- package/node_modules/@openvulcan/vulcan-decision/runtime-packs/torch-cpu.json +1047 -0
- package/node_modules/@openvulcan/vulcan-decision/runtime-packs/torch-cu128.json +647 -0
- package/package.json +53 -4
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import Schema from '@deepseek-ai/schemastery';
|
|
3
|
+
import { AUTO_PRESET } from '@deepseek-ai/dsh-permission-presets';
|
|
4
|
+
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths';
|
|
5
|
+
import { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools';
|
|
6
|
+
import { DecisionEngine, DecisionHome, DOWNLOAD_SOURCES, PACK_IDS, POLICY_VERSION, renderCall, } from '@openvulcan/vulcan-decision';
|
|
7
|
+
import { translateCall } from './calls.js';
|
|
8
|
+
import { decide, stageOf, wantsReview } from './decide.js';
|
|
9
|
+
import { IntentStore, intentTool } from './intent.js';
|
|
10
|
+
import { reviewCall } from './review.js';
|
|
11
|
+
import { handle, Panel, ROUTE_PREFIX, ROUTES, ServiceAccount } from './panel.js';
|
|
12
|
+
import { RecordStore } from './records.js';
|
|
13
|
+
import { installSessionState, MAX_PROMPT_CHARS } from './session-state.js';
|
|
14
|
+
export { PROJECTION_KEY, REVIEW_PROMPT_COMMAND, REVIEW_PROMPT_RECORD, UNATTENDED_COMMAND, UNATTENDED_RECORD, } from './session-state.js';
|
|
15
|
+
export const name = 'vulcan-decision-guard';
|
|
16
|
+
export const inject = ['llm', 'permissionPresets', 'sessionProjections', 'sessions', 'tools', 'workingDirectory'];
|
|
17
|
+
/** Longest approval countdown the settings accept, in seconds. */
|
|
18
|
+
const MAX_ASK_TIMEOUT = 3600;
|
|
19
|
+
export const Config = Schema.object({
|
|
20
|
+
mode: Schema.union(['auto-preset', 'standalone'])
|
|
21
|
+
.default('auto-preset')
|
|
22
|
+
.description('auto-preset: appears as the Auto permission preset. standalone: judges every session on top of its preset.')
|
|
23
|
+
.volatile(),
|
|
24
|
+
backend: Schema.union(['cloud', 'local'])
|
|
25
|
+
.default('cloud')
|
|
26
|
+
.description('cloud: OpenVulcan judges calls after you sign in. local: an installed model runs on this machine.')
|
|
27
|
+
.volatile(),
|
|
28
|
+
cloudModel: Schema.string()
|
|
29
|
+
.default('')
|
|
30
|
+
.description('Cloud decision model id; empty uses the first model OpenVulcan lists for the account.')
|
|
31
|
+
.volatile(),
|
|
32
|
+
cloudEndpoint: Schema.string()
|
|
33
|
+
.description('Cloud endpoint id to always use; omitted uses the fastest measured one.')
|
|
34
|
+
.volatile(),
|
|
35
|
+
localPack: Schema.union(PACK_IDS)
|
|
36
|
+
.description('Runtime pack that runs the local model.')
|
|
37
|
+
.volatile(),
|
|
38
|
+
localModel: Schema.string()
|
|
39
|
+
.description('Installed local model id.')
|
|
40
|
+
.volatile(),
|
|
41
|
+
downloadSource: Schema.union(DOWNLOAD_SOURCES)
|
|
42
|
+
.default('official')
|
|
43
|
+
.description('Where runtime packs download from: official hosts, or cn mirrors.')
|
|
44
|
+
.volatile(),
|
|
45
|
+
review: Schema.union(['session', 'custom', 'off'])
|
|
46
|
+
.default('session')
|
|
47
|
+
.description('LLM review of calls the decision model did not allow. session: the session model. custom: reviewProvider and reviewModel. off: no review.')
|
|
48
|
+
.volatile(),
|
|
49
|
+
reviewProvider: Schema.string()
|
|
50
|
+
.description('Provider of the review model when review is custom.')
|
|
51
|
+
.volatile(),
|
|
52
|
+
reviewModel: Schema.string()
|
|
53
|
+
.description('Review model when review is custom.')
|
|
54
|
+
.volatile(),
|
|
55
|
+
allowRules: Schema.string().role('textarea').default('')
|
|
56
|
+
.description('Natural-language rules for calls the LLM review should allow.')
|
|
57
|
+
.volatile(),
|
|
58
|
+
denyRules: Schema.string().role('textarea').default('')
|
|
59
|
+
.description('Natural-language rules for calls the LLM review should deny; the user is still asked when present.')
|
|
60
|
+
.volatile(),
|
|
61
|
+
forbidRules: Schema.string().role('textarea').default('')
|
|
62
|
+
.description('Natural-language rules for calls the LLM review refuses without asking anyone.')
|
|
63
|
+
.volatile(),
|
|
64
|
+
llmOverridesBlock: Schema.boolean()
|
|
65
|
+
.default(false)
|
|
66
|
+
.description('Whether calls the decision model blocks also go to the LLM review, which may then allow them.')
|
|
67
|
+
.volatile(),
|
|
68
|
+
reviewPrompt: Schema.string().role('textarea').max(MAX_PROMPT_CHARS).default('')
|
|
69
|
+
.description('Default additional guidance the LLM review follows while the user is present; a session may replace it.')
|
|
70
|
+
.volatile(),
|
|
71
|
+
unattendedPolicy: Schema.string().role('textarea').max(MAX_PROMPT_CHARS).default('')
|
|
72
|
+
.description('Deny policy offered when a session turns unattended mode on: what must never run while the user is away.')
|
|
73
|
+
.volatile(),
|
|
74
|
+
recordDecisions: Schema.boolean()
|
|
75
|
+
.default(false)
|
|
76
|
+
.description('Keep a local decision log with the original call arguments, in the plugin data directory.')
|
|
77
|
+
.volatile(),
|
|
78
|
+
askTimeout: Schema.natural().max(MAX_ASK_TIMEOUT)
|
|
79
|
+
.default(0)
|
|
80
|
+
.description('Seconds an approval prompt waits before the call is refused; 0 waits for the user.')
|
|
81
|
+
.volatile(),
|
|
82
|
+
crossProjectWrites: Schema.union(['allow', 'review'])
|
|
83
|
+
.default('allow')
|
|
84
|
+
.description('Ordinary writes outside the project: allow them, or ask first.')
|
|
85
|
+
.volatile(),
|
|
86
|
+
databaseReset: Schema.union(['confirm', 'allow'])
|
|
87
|
+
.default('confirm')
|
|
88
|
+
.description('Database drops, truncations and unrestricted data writes: confirm first, or allow.')
|
|
89
|
+
.volatile(),
|
|
90
|
+
projectReset: Schema.union(['confirm', 'allow'])
|
|
91
|
+
.default('confirm')
|
|
92
|
+
.description('Deleting or resetting the whole current project or its history: confirm first, or allow.')
|
|
93
|
+
.volatile(),
|
|
94
|
+
});
|
|
95
|
+
/** Settings namespace of dsh's locale plugin, whose `preference` is the chosen interface language. */
|
|
96
|
+
const LOCALE_SETTINGS = 'locale';
|
|
97
|
+
/** Preset that sessions still on `auto` move to when the guard is removed. */
|
|
98
|
+
const FALLBACK_PRESET = 'workspace-write';
|
|
99
|
+
/** Structured error identity of a final denial. */
|
|
100
|
+
const DENIED_INFO = { name: 'VulcanDecisionDeniedError', code: 'VULCAN_DECISION_DENIED' };
|
|
101
|
+
/**
|
|
102
|
+
* Key of the approval prompt's `displayReason` entry that carries {@link PromptPayload} as JSON.
|
|
103
|
+
* dsh resolves the prompt text by locale and never reads this key; this plugin's composer card
|
|
104
|
+
* does. Also restated in `client/index.tsx`.
|
|
105
|
+
*/
|
|
106
|
+
export const PROMPT_KEY = 'x-vulcan-decision';
|
|
107
|
+
/** Install the Vulcan Decision gate and, in `auto-preset` mode, the Auto preset. */
|
|
108
|
+
export async function apply(ctx, config) {
|
|
109
|
+
// Retain the injected services while this context drains on disposal.
|
|
110
|
+
const permissionPresets = ctx.permissionPresets;
|
|
111
|
+
const sessions = ctx.sessions;
|
|
112
|
+
const llm = ctx.llm;
|
|
113
|
+
const tools = ctx.tools;
|
|
114
|
+
const account = new ServiceAccount(() => ctx.get('openvulcanAccount'));
|
|
115
|
+
const engine = new DecisionEngine({ home: new DecisionHome(path.join(resolveDshHome(), 'vulcan-decision')), account });
|
|
116
|
+
const intents = new IntentStore();
|
|
117
|
+
const lifecycle = new AbortController();
|
|
118
|
+
let accepting = true;
|
|
119
|
+
let releasing = false;
|
|
120
|
+
let ownsAuto = false;
|
|
121
|
+
const sessionState = await installSessionState(ctx);
|
|
122
|
+
const records = new RecordStore(engine.home);
|
|
123
|
+
/** Append one finished call off the gate's path; a failure is only logged. */
|
|
124
|
+
const keep = (record) => {
|
|
125
|
+
setImmediate(() => {
|
|
126
|
+
try {
|
|
127
|
+
records.append(record);
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
ctx.logger.warn(`cannot keep a decision record: ${messageOf(error)}`);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
};
|
|
134
|
+
const governs = (exec) => {
|
|
135
|
+
const agent = exec.agent;
|
|
136
|
+
if (agent === undefined)
|
|
137
|
+
return false;
|
|
138
|
+
// The outer run_code transport is not an action; its inner tool calls are judged.
|
|
139
|
+
if (exec.parent === undefined && exec.name === RUN_CODE_NAME)
|
|
140
|
+
return false;
|
|
141
|
+
if (config.mode.get() === 'standalone')
|
|
142
|
+
return true;
|
|
143
|
+
return ownsAuto && permissionPresets.current(agent.session) === AUTO_PRESET;
|
|
144
|
+
};
|
|
145
|
+
/** Nobody can be asked: the session is marked unattended, or its approval policy never asks. */
|
|
146
|
+
const unattended = (agent) => {
|
|
147
|
+
if (sessionState(agent.session).unattended)
|
|
148
|
+
return true;
|
|
149
|
+
const approval = ctx.get('approval');
|
|
150
|
+
return approval !== undefined && (approval.overrideOf(agent.session) ?? approval.config.policy ?? 'ask') === 'never';
|
|
151
|
+
};
|
|
152
|
+
const options = () => ({
|
|
153
|
+
cross_project_writes: config.crossProjectWrites.get(),
|
|
154
|
+
database_reset: config.databaseReset.get(),
|
|
155
|
+
project_reset: config.projectReset.get(),
|
|
156
|
+
});
|
|
157
|
+
const engineSettings = () => {
|
|
158
|
+
const pack = config.localPack.get();
|
|
159
|
+
const model = config.localModel.get();
|
|
160
|
+
const endpoint = config.cloudEndpoint.get();
|
|
161
|
+
return {
|
|
162
|
+
backend: config.backend.get(),
|
|
163
|
+
cloudModel: config.cloudModel.get(),
|
|
164
|
+
...endpoint === undefined || endpoint === '' ? {} : { cloudEndpoint: endpoint },
|
|
165
|
+
...pack === undefined || model === undefined || model === '' ? {} : { local: { pack, model } },
|
|
166
|
+
options: options(),
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
const panel = new Panel(engine, {
|
|
170
|
+
engine: engineSettings, downloadSource: () => config.downloadSource.get(), accountPlugin: () => account.available(),
|
|
171
|
+
}, records);
|
|
172
|
+
/**
|
|
173
|
+
* The user's own text the review reads: the session's review prompt, else the system default,
|
|
174
|
+
* while attended; the deny policy the session confirmed while unattended. A session that never
|
|
175
|
+
* asks because of its approval policy, not the unattended switch, reads the default deny policy.
|
|
176
|
+
*/
|
|
177
|
+
const reviewText = (agent, attended) => {
|
|
178
|
+
const state = sessionState(agent.session);
|
|
179
|
+
if (attended)
|
|
180
|
+
return state.reviewPrompt ?? config.reviewPrompt.get();
|
|
181
|
+
return state.unattended ? state.denyPolicy : config.unattendedPolicy.get();
|
|
182
|
+
};
|
|
183
|
+
/**
|
|
184
|
+
* The interface language the user chose in dsh's settings, or undefined when it follows the
|
|
185
|
+
* browser, which the Host cannot see.
|
|
186
|
+
*/
|
|
187
|
+
const interfaceLanguage = () => {
|
|
188
|
+
const descriptor = ctx.get('settings')?.describe().find(item => item.ns === LOCALE_SETTINGS);
|
|
189
|
+
const value = descriptor?.value;
|
|
190
|
+
const preference = typeof value === 'object' && value !== null ? value['preference'] : undefined;
|
|
191
|
+
return typeof preference === 'string' && preference !== '' ? preference : undefined;
|
|
192
|
+
};
|
|
193
|
+
/** The provider and model that review this agent's calls. */
|
|
194
|
+
const reviewRoute = (agent) => {
|
|
195
|
+
if (config.review.get() === 'custom') {
|
|
196
|
+
const provider = config.reviewProvider.get();
|
|
197
|
+
const model = config.reviewModel.get();
|
|
198
|
+
return provider === undefined || model === undefined || provider === '' || model === ''
|
|
199
|
+
? 'no review model is chosen in the settings'
|
|
200
|
+
: { provider, model };
|
|
201
|
+
}
|
|
202
|
+
const route = agent.session.requestHeader()?.config;
|
|
203
|
+
return route === undefined ? 'the session has no model route to review with' : { provider: route.provider, model: route.model };
|
|
204
|
+
};
|
|
205
|
+
const gate = async (exec, signal) => {
|
|
206
|
+
// `governs` established that the call has an agent.
|
|
207
|
+
const agent = exec.agent;
|
|
208
|
+
const judgedAtMs = Date.now();
|
|
209
|
+
const project = await ctx.workingDirectory.ensure(agent, signal).catch(() => undefined);
|
|
210
|
+
const translation = translateCall(exec.name, exec.arguments, project);
|
|
211
|
+
if (translation.kind === 'allow')
|
|
212
|
+
return 'run';
|
|
213
|
+
// The statement belongs to this call whatever happens to it.
|
|
214
|
+
const intent = intents.take(agent.session.id, exec.name);
|
|
215
|
+
const settings = engineSettings();
|
|
216
|
+
const decisionOptions = settings.options;
|
|
217
|
+
const call = { ...project === undefined ? {} : { project }, tool: translation.tool, arguments: translation.arguments };
|
|
218
|
+
const judgeStarted = performance.now();
|
|
219
|
+
const first = await abortable(engine.judge(call, settings), signal).then(verdict => ({ kind: 'verdict', verdict }), (error) => ({ kind: 'error', message: messageOf(error) }));
|
|
220
|
+
if (signal.aborted)
|
|
221
|
+
return 'cancel';
|
|
222
|
+
const judgeMs = performance.now() - judgeStarted;
|
|
223
|
+
const situation = {
|
|
224
|
+
attended: !unattended(agent),
|
|
225
|
+
llm: config.review.get() !== 'off',
|
|
226
|
+
llmOverridesBlock: config.llmOverridesBlock.get(),
|
|
227
|
+
};
|
|
228
|
+
let review;
|
|
229
|
+
// The record is built only when the log is on, since it renders the call again.
|
|
230
|
+
const log = (ending) => {
|
|
231
|
+
if (!config.recordDecisions.get())
|
|
232
|
+
return;
|
|
233
|
+
keep({
|
|
234
|
+
judgedAtMs,
|
|
235
|
+
sessionId: agent.session.id,
|
|
236
|
+
...project === undefined ? {} : { workspaceRoot: project },
|
|
237
|
+
toolName: exec.name,
|
|
238
|
+
arguments: JSON.stringify(exec.arguments) ?? 'null',
|
|
239
|
+
attended: situation.attended,
|
|
240
|
+
judge: judgeOf(first, judgeMs, settings, call),
|
|
241
|
+
...review === undefined ? {} : { review },
|
|
242
|
+
outcome: ending,
|
|
243
|
+
});
|
|
244
|
+
};
|
|
245
|
+
const stage = stageOf(first);
|
|
246
|
+
if (stage === undefined)
|
|
247
|
+
return { outcome: { kind: 'run' }, intent, log: () => { log('judge_allowed'); } };
|
|
248
|
+
let second = { kind: 'skipped' };
|
|
249
|
+
if (wantsReview(stage, situation)) {
|
|
250
|
+
const route = reviewRoute(agent);
|
|
251
|
+
const reviewStarted = performance.now();
|
|
252
|
+
second = typeof route === 'string'
|
|
253
|
+
? { kind: 'error', message: route }
|
|
254
|
+
: await reviewCall(llm, {
|
|
255
|
+
provider: route.provider,
|
|
256
|
+
model: route.model,
|
|
257
|
+
attended: situation.attended,
|
|
258
|
+
language: interfaceLanguage(),
|
|
259
|
+
project,
|
|
260
|
+
options: decisionOptions,
|
|
261
|
+
rules: { allow: config.allowRules.get(), deny: config.denyRules.get(), forbid: config.forbidRules.get() },
|
|
262
|
+
prompt: reviewText(agent, situation.attended),
|
|
263
|
+
stage,
|
|
264
|
+
tool: exec.name,
|
|
265
|
+
arguments: exec.arguments,
|
|
266
|
+
intent,
|
|
267
|
+
history: agent.session.deriveMessages(),
|
|
268
|
+
}, signal);
|
|
269
|
+
if (signal.aborted)
|
|
270
|
+
return 'cancel';
|
|
271
|
+
review = reviewOf(second, performance.now() - reviewStarted);
|
|
272
|
+
}
|
|
273
|
+
const outcome = decide(exec.name, first, second, situation);
|
|
274
|
+
// A run or a refusal was settled here; only a question's ending is known later.
|
|
275
|
+
const settled = outcome.kind === 'run'
|
|
276
|
+
? 'review_allowed'
|
|
277
|
+
: outcome.kind === 'deny'
|
|
278
|
+
? second.kind === 'forbid' || second.kind === 'deny' || second.kind === 'review' ? 'review_denied' : 'unattended_denied'
|
|
279
|
+
: undefined;
|
|
280
|
+
return {
|
|
281
|
+
outcome,
|
|
282
|
+
intent,
|
|
283
|
+
log: (ending) => {
|
|
284
|
+
const kept = settled ?? ending;
|
|
285
|
+
if (kept !== undefined)
|
|
286
|
+
log(kept);
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
};
|
|
290
|
+
/** Ask the user through dsh's approval service, refusing the call when the countdown runs out. */
|
|
291
|
+
const askUser = async (exec, ask, intent, signal) => {
|
|
292
|
+
const approval = ctx.get('approval');
|
|
293
|
+
if (approval === undefined) {
|
|
294
|
+
return { decision: denied(`${ask.reason}. No approval channel is available, so the call was not executed.`), ending: 'not_asked' };
|
|
295
|
+
}
|
|
296
|
+
const seconds = config.askTimeout.get();
|
|
297
|
+
const countdown = seconds > 0 ? AbortSignal.timeout(seconds * 1000) : undefined;
|
|
298
|
+
const payload = {
|
|
299
|
+
v: 1,
|
|
300
|
+
...ask.detail,
|
|
301
|
+
...intent === undefined ? {} : { intent },
|
|
302
|
+
preview: previewOf(exec.arguments),
|
|
303
|
+
...countdown === undefined ? {} : { timeoutMs: seconds * 1000 },
|
|
304
|
+
};
|
|
305
|
+
let result;
|
|
306
|
+
try {
|
|
307
|
+
result = await approval.request({
|
|
308
|
+
agent: exec.agent,
|
|
309
|
+
toolName: exec.name,
|
|
310
|
+
callId: exec.callId,
|
|
311
|
+
reason: ask.reason,
|
|
312
|
+
displayReason: { ...ask.display, [PROMPT_KEY]: JSON.stringify(payload) },
|
|
313
|
+
signal: countdown === undefined ? signal : AbortSignal.any([signal, countdown]),
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
return { decision: denied(`${ask.reason}. The user could not be asked (${messageOf(error)}), so the call was not executed.`), ending: 'not_asked' };
|
|
318
|
+
}
|
|
319
|
+
if (signal.aborted)
|
|
320
|
+
return { decision: { kind: 'cancel' } };
|
|
321
|
+
switch (result) {
|
|
322
|
+
case 'allowed-once':
|
|
323
|
+
return { decision: { kind: 'allow' }, ending: 'user_allowed' };
|
|
324
|
+
case 'rejected':
|
|
325
|
+
return { decision: denied(`The user rejected tool "${exec.name}". The call was not executed.`), ending: 'user_denied' };
|
|
326
|
+
case 'cancelled':
|
|
327
|
+
return countdown?.aborted === true
|
|
328
|
+
? { decision: denied(`${ask.reason}. Nobody answered the approval prompt within ${seconds} seconds, so the call was not executed.`), ending: 'approval_expired' }
|
|
329
|
+
: { decision: denied(`${ask.reason}. The approval prompt was withdrawn, so the call was not executed.`), ending: 'not_asked' };
|
|
330
|
+
case 'unavailable':
|
|
331
|
+
return { decision: denied(`${ask.reason}. No approval channel answered, so the call was not executed.`), ending: 'not_asked' };
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
let stopIntentTool;
|
|
335
|
+
/** The intent tool is offered while the LLM review is on. */
|
|
336
|
+
const syncIntentTool = () => {
|
|
337
|
+
const wanted = accepting && config.review.get() !== 'off';
|
|
338
|
+
if (wanted && stopIntentTool === undefined) {
|
|
339
|
+
stopIntentTool = tools.register(intentTool(intents, exec => governs(exec) && config.review.get() !== 'off'));
|
|
340
|
+
}
|
|
341
|
+
else if (!wanted && stopIntentTool !== undefined) {
|
|
342
|
+
stopIntentTool();
|
|
343
|
+
stopIntentTool = undefined;
|
|
344
|
+
intents.clear();
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
let stopAuto;
|
|
348
|
+
let claiming = false;
|
|
349
|
+
let reportedHeld = false;
|
|
350
|
+
/**
|
|
351
|
+
* Register `auto` unless another integration (usually Auto review) already holds it. Called at
|
|
352
|
+
* start, whenever the catalog changes and when the mode switches to `auto-preset`, so the slot is
|
|
353
|
+
* taken once the other integration leaves.
|
|
354
|
+
*/
|
|
355
|
+
const claimAuto = () => {
|
|
356
|
+
if (config.mode.get() !== 'auto-preset' || !accepting || releasing || stopAuto !== undefined || claiming)
|
|
357
|
+
return;
|
|
358
|
+
if (permissionPresets.catalog().options.some(option => option.value === AUTO_PRESET)) {
|
|
359
|
+
if (!reportedHeld) {
|
|
360
|
+
reportedHeld = true;
|
|
361
|
+
ctx.logger.warn('the Auto permission preset is held by another integration, usually Auto review; Vulcan Decision takes it once that integration is disabled');
|
|
362
|
+
}
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
claiming = true;
|
|
366
|
+
try {
|
|
367
|
+
const stop = permissionPresets.registerAuto(() => {
|
|
368
|
+
if (!accepting || releasing)
|
|
369
|
+
throw new Error('vulcan-decision-guard: the Auto preset is being withdrawn');
|
|
370
|
+
});
|
|
371
|
+
ownsAuto = true;
|
|
372
|
+
reportedHeld = false;
|
|
373
|
+
stopAuto = async () => {
|
|
374
|
+
ownsAuto = false;
|
|
375
|
+
await stop();
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
catch (error) {
|
|
379
|
+
// A concurrent registration won the slot; the plugin keeps loading.
|
|
380
|
+
ctx.logger.warn(`cannot register the Auto permission preset: ${messageOf(error)}`);
|
|
381
|
+
}
|
|
382
|
+
finally {
|
|
383
|
+
claiming = false;
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
/** Move sessions still on `auto` to the fallback preset, then withdraw the registration. */
|
|
387
|
+
const releaseAuto = async () => {
|
|
388
|
+
const stop = stopAuto;
|
|
389
|
+
if (stop === undefined || releasing)
|
|
390
|
+
return;
|
|
391
|
+
releasing = true;
|
|
392
|
+
try {
|
|
393
|
+
for (const session of sessions.list()) {
|
|
394
|
+
if (permissionPresets.current(session) === AUTO_PRESET)
|
|
395
|
+
permissionPresets.set(session, FALLBACK_PRESET);
|
|
396
|
+
}
|
|
397
|
+
stopAuto = undefined;
|
|
398
|
+
await stop();
|
|
399
|
+
}
|
|
400
|
+
finally {
|
|
401
|
+
releasing = false;
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
/** Apply a settings change that affects what is registered. */
|
|
405
|
+
const syncSettings = () => {
|
|
406
|
+
syncIntentTool();
|
|
407
|
+
if (config.mode.get() === 'auto-preset') {
|
|
408
|
+
claimAuto();
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
releaseAuto().catch((error) => {
|
|
412
|
+
ctx.logger.warn(`cannot withdraw the Auto permission preset: ${messageOf(error)}`);
|
|
413
|
+
});
|
|
414
|
+
};
|
|
415
|
+
// A new sign-in or a sign-out invalidates the endpoints measured for the previous account.
|
|
416
|
+
ctx.inject(['openvulcanAccount'], (child) => {
|
|
417
|
+
child.effect(() => child.openvulcanAccount.onChange(() => { engine.cloud.reset(); }));
|
|
418
|
+
});
|
|
419
|
+
// The settings panel's routes live inside dsh's authenticated connection, when there is one.
|
|
420
|
+
ctx.inject(['connection'], (child) => {
|
|
421
|
+
for (const [method, route, handler] of ROUTES) {
|
|
422
|
+
child.effect(() => child.connection.fetch.register({
|
|
423
|
+
path: `${ROUTE_PREFIX}${route}`,
|
|
424
|
+
methods: [method],
|
|
425
|
+
requestBody: 'buffered',
|
|
426
|
+
fetch: request => handle(panel, handler, request),
|
|
427
|
+
}));
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
// This plugin ships its own page under Settings › Auto approval, so schema-generated pages are turned off.
|
|
431
|
+
ctx.inject(['settings'], (child) => {
|
|
432
|
+
child.effect(() => child.settings.configure({ auto: false }, ctx.fiber));
|
|
433
|
+
});
|
|
434
|
+
ctx.effect(function* () {
|
|
435
|
+
const stopListener = ctx.on('tools/pre-execute', async (exec, next) => {
|
|
436
|
+
if (!governs(exec))
|
|
437
|
+
return next();
|
|
438
|
+
if (!accepting || lifecycle.signal.aborted)
|
|
439
|
+
return { kind: 'cancel' };
|
|
440
|
+
const signal = AbortSignal.any([exec.signal, lifecycle.signal]);
|
|
441
|
+
const gated = await gate(exec, signal);
|
|
442
|
+
if (gated === 'cancel')
|
|
443
|
+
return { kind: 'cancel' };
|
|
444
|
+
if (gated === 'run')
|
|
445
|
+
return next();
|
|
446
|
+
const { outcome, intent, log } = gated;
|
|
447
|
+
switch (outcome.kind) {
|
|
448
|
+
case 'run':
|
|
449
|
+
log();
|
|
450
|
+
return next();
|
|
451
|
+
case 'deny':
|
|
452
|
+
log();
|
|
453
|
+
return denied(outcome.reason);
|
|
454
|
+
case 'ask': {
|
|
455
|
+
// A later listener's denial, cancellation or own question takes precedence over asking.
|
|
456
|
+
const downstream = await next();
|
|
457
|
+
if (downstream.kind !== 'allow') {
|
|
458
|
+
if (downstream.kind === 'deny')
|
|
459
|
+
log('host_denied');
|
|
460
|
+
return downstream;
|
|
461
|
+
}
|
|
462
|
+
const settled = await askUser(exec, outcome, intent, signal);
|
|
463
|
+
if (settled.ending !== undefined)
|
|
464
|
+
log(settled.ending);
|
|
465
|
+
return settled.decision;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}, { prepend: true });
|
|
469
|
+
yield stopListener;
|
|
470
|
+
yield ctx.on('permission-presets/catalog-changed', claimAuto);
|
|
471
|
+
yield ctx.on('loader/volatile-update', syncSettings);
|
|
472
|
+
claimAuto();
|
|
473
|
+
syncIntentTool();
|
|
474
|
+
// Measure the cloud endpoints at start and again once a measurement is older than its interval.
|
|
475
|
+
const measure = () => {
|
|
476
|
+
if (config.backend.get() === 'cloud')
|
|
477
|
+
panel.endpoints().catch(() => { });
|
|
478
|
+
};
|
|
479
|
+
measure();
|
|
480
|
+
const measuring = setInterval(measure, 60_000);
|
|
481
|
+
yield () => clearInterval(measuring);
|
|
482
|
+
// Load the local model now so the first tool call does not wait for it.
|
|
483
|
+
const warm = engineSettings();
|
|
484
|
+
if (warm.backend === 'local' && warm.local !== undefined) {
|
|
485
|
+
engine.local.warm(warm.local).catch((error) => {
|
|
486
|
+
ctx.logger.warn(`local decision model did not load: ${messageOf(error)}`);
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
// Disposers start in reverse order, so this runs first.
|
|
490
|
+
yield async () => {
|
|
491
|
+
accepting = false;
|
|
492
|
+
syncIntentTool();
|
|
493
|
+
try {
|
|
494
|
+
await releaseAuto();
|
|
495
|
+
}
|
|
496
|
+
finally {
|
|
497
|
+
lifecycle.abort(new Error('vulcan-decision-guard integration disposed'));
|
|
498
|
+
panel.close();
|
|
499
|
+
engine.stop();
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
}, 'vulcan-decision-guard lifecycle');
|
|
503
|
+
}
|
|
504
|
+
/** Longest call preview the approval card shows. */
|
|
505
|
+
const MAX_PREVIEW_CHARS = 1200;
|
|
506
|
+
/** A shell command as it was written, otherwise the arguments as JSON. */
|
|
507
|
+
function previewOf(args) {
|
|
508
|
+
const command = typeof args === 'object' && args !== null ? args['command'] : undefined;
|
|
509
|
+
const text = typeof command === 'string' ? command : JSON.stringify(args, null, 2) ?? '';
|
|
510
|
+
return text.length <= MAX_PREVIEW_CHARS ? text : `${text.slice(0, MAX_PREVIEW_CHARS)}…`;
|
|
511
|
+
}
|
|
512
|
+
/** Settle with `promise`, or reject as soon as `signal` aborts; the work itself is not stopped. */
|
|
513
|
+
function abortable(promise, signal) {
|
|
514
|
+
if (signal.aborted)
|
|
515
|
+
return Promise.reject(signal.reason);
|
|
516
|
+
return new Promise((resolve, reject) => {
|
|
517
|
+
const abort = () => reject(signal.reason);
|
|
518
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
519
|
+
promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
/** The decision model's part of a record; the trace is kept whenever the call renders. */
|
|
523
|
+
function judgeOf(first, elapsedMs, settings, call) {
|
|
524
|
+
let rendered;
|
|
525
|
+
try {
|
|
526
|
+
rendered = renderCall(call);
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
529
|
+
rendered = undefined;
|
|
530
|
+
}
|
|
531
|
+
const verdict = first.kind === 'verdict' ? first.verdict : undefined;
|
|
532
|
+
const model = verdict?.model ?? (settings.backend === 'cloud' ? settings.cloudModel : settings.local?.model ?? '');
|
|
533
|
+
return {
|
|
534
|
+
...verdict === undefined ? { error: first.kind === 'error' ? first.message : '' } : { label: verdict.decision },
|
|
535
|
+
...verdict?.reason_code === undefined ? {} : { reasonCode: verdict.reason_code },
|
|
536
|
+
elapsedMs: Math.round(elapsedMs),
|
|
537
|
+
...rendered === undefined
|
|
538
|
+
? {}
|
|
539
|
+
: {
|
|
540
|
+
trace: {
|
|
541
|
+
backend: settings.backend,
|
|
542
|
+
model,
|
|
543
|
+
...verdict === undefined ? {} : { via: verdict.via },
|
|
544
|
+
policyVersion: POLICY_VERSION,
|
|
545
|
+
settings: settings.options,
|
|
546
|
+
rendered,
|
|
547
|
+
...verdict?.logits === undefined ? {} : { logits: verdict.logits },
|
|
548
|
+
...verdict?.input_tokens === undefined ? {} : { inputTokens: verdict.input_tokens },
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
/** The review LLM's part of a record. */
|
|
554
|
+
function reviewOf(second, elapsedMs) {
|
|
555
|
+
const elapsed = Math.round(elapsedMs);
|
|
556
|
+
switch (second.kind) {
|
|
557
|
+
case 'skipped':
|
|
558
|
+
return undefined;
|
|
559
|
+
case 'error':
|
|
560
|
+
return { error: second.message, elapsedMs: elapsed };
|
|
561
|
+
case 'allow':
|
|
562
|
+
return { verdict: 'allow', elapsedMs: elapsed };
|
|
563
|
+
default:
|
|
564
|
+
return { verdict: second.kind, ...second.reason === undefined ? {} : { reason: second.reason }, elapsedMs: elapsed };
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
function denied(reason) {
|
|
568
|
+
return { kind: 'deny', reason, info: DENIED_INFO };
|
|
569
|
+
}
|
|
570
|
+
function messageOf(error) {
|
|
571
|
+
return error instanceof Error ? error.message : String(error);
|
|
572
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The intent tool: the agent states the purpose of its next call of a tool, and that statement
|
|
3
|
+
* goes to the LLM review and the approval prompt with the call. It does not go to the decision
|
|
4
|
+
* model, and it never authorizes anything by itself; the reviewer is told so.
|
|
5
|
+
*
|
|
6
|
+
* Statements are kept in memory per session until the next judged call of the named tool takes
|
|
7
|
+
* them. One per tool: a newer statement replaces an older one.
|
|
8
|
+
*/
|
|
9
|
+
import type { SessionId } from '@deepseek-ai/dsh-session';
|
|
10
|
+
import { type ToolExecution } from '@deepseek-ai/dsh-tools';
|
|
11
|
+
/** Pending statements by session, then by tool name. */
|
|
12
|
+
export declare class IntentStore {
|
|
13
|
+
#private;
|
|
14
|
+
put(session: SessionId, tool: string, purpose: string): void;
|
|
15
|
+
/** Remove and return the statement for this session's next call of `tool`. */
|
|
16
|
+
take(session: SessionId, tool: string): string | undefined;
|
|
17
|
+
clear(): void;
|
|
18
|
+
}
|
|
19
|
+
/** Whether the guard reviews this execution's calls with an LLM right now. */
|
|
20
|
+
export type ReviewsWithLlm = (exec: ToolExecution) => boolean;
|
|
21
|
+
/** The tool definition; `reviews` decides whether a statement is kept or not needed. */
|
|
22
|
+
export declare function intentTool(store: IntentStore, reviews: ReviewsWithLlm): import("@deepseek-ai/dsh-tools").ToolDefinition;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The intent tool: the agent states the purpose of its next call of a tool, and that statement
|
|
3
|
+
* goes to the LLM review and the approval prompt with the call. It does not go to the decision
|
|
4
|
+
* model, and it never authorizes anything by itself; the reviewer is told so.
|
|
5
|
+
*
|
|
6
|
+
* Statements are kept in memory per session until the next judged call of the named tool takes
|
|
7
|
+
* them. One per tool: a newer statement replaces an older one.
|
|
8
|
+
*/
|
|
9
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
10
|
+
import { INTENT_TOOL } from './decide.js';
|
|
11
|
+
const MAX_PURPOSE_CHARS = 2000;
|
|
12
|
+
/** Pending statements by session, then by tool name. */
|
|
13
|
+
export class IntentStore {
|
|
14
|
+
#pending = new Map();
|
|
15
|
+
put(session, tool, purpose) {
|
|
16
|
+
let tools = this.#pending.get(session);
|
|
17
|
+
if (tools === undefined) {
|
|
18
|
+
tools = new Map();
|
|
19
|
+
this.#pending.set(session, tools);
|
|
20
|
+
}
|
|
21
|
+
tools.set(tool, purpose);
|
|
22
|
+
}
|
|
23
|
+
/** Remove and return the statement for this session's next call of `tool`. */
|
|
24
|
+
take(session, tool) {
|
|
25
|
+
const tools = this.#pending.get(session);
|
|
26
|
+
const purpose = tools?.get(tool);
|
|
27
|
+
if (purpose === undefined)
|
|
28
|
+
return undefined;
|
|
29
|
+
tools.delete(tool);
|
|
30
|
+
if (tools.size === 0)
|
|
31
|
+
this.#pending.delete(session);
|
|
32
|
+
return purpose;
|
|
33
|
+
}
|
|
34
|
+
clear() {
|
|
35
|
+
this.#pending.clear();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** The tool definition; `reviews` decides whether a statement is kept or not needed. */
|
|
39
|
+
export function intentTool(store, reviews) {
|
|
40
|
+
return defineTool({
|
|
41
|
+
name: INTENT_TOOL,
|
|
42
|
+
description: 'Explain to the permission reviewer (Vulcan Decision) why your next call of a tool is needed and what it will do. '
|
|
43
|
+
+ 'Use it before a call the reviewer may question, or after the review denied a call you still need, then make the call. '
|
|
44
|
+
+ 'The explanation goes to the reviewer and is shown to the user with that call; it does not grant permission by itself, '
|
|
45
|
+
+ 'so describe the real purpose and effect.',
|
|
46
|
+
parameters: {
|
|
47
|
+
tool: { type: 'string', required: true, description: 'Exact name of the tool you will call next, for example "bash".' },
|
|
48
|
+
purpose: { type: 'string', required: true, description: 'Why the call is needed and what it will change, in one or two sentences.' },
|
|
49
|
+
},
|
|
50
|
+
output: {
|
|
51
|
+
schema: {
|
|
52
|
+
type: 'object',
|
|
53
|
+
additionalProperties: false,
|
|
54
|
+
properties: {
|
|
55
|
+
recorded: { type: 'boolean', required: true },
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
render: (args, value) => [{
|
|
59
|
+
type: 'text',
|
|
60
|
+
text: value.recorded
|
|
61
|
+
? `Recorded. The reviewer will see this purpose with your next "${args.tool}" call; make that call now.`
|
|
62
|
+
: 'This session\'s calls are not reviewed by an LLM, so no explanation is needed; make the call directly.',
|
|
63
|
+
}],
|
|
64
|
+
},
|
|
65
|
+
execute(args, exec) {
|
|
66
|
+
const agent = exec.agent;
|
|
67
|
+
if (agent === undefined || !reviews(exec))
|
|
68
|
+
return Promise.resolve({ recorded: false });
|
|
69
|
+
const purpose = args.purpose.trim();
|
|
70
|
+
if (purpose === '')
|
|
71
|
+
throw new Error('purpose must not be empty');
|
|
72
|
+
store.put(agent.session.id, args.tool.trim(), purpose.length <= MAX_PURPOSE_CHARS ? purpose : purpose.slice(0, MAX_PURPOSE_CHARS));
|
|
73
|
+
return Promise.resolve({ recorded: true });
|
|
74
|
+
},
|
|
75
|
+
presentCall: args => ({ card: 'generic', title: `Explain the next ${args.tool} call`, kind: 'other', rawInput: args.purpose }),
|
|
76
|
+
});
|
|
77
|
+
}
|