@causyne/agent 1.10.21 → 1.11.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/native-skills/media/professional-document-publishing/SKILL.md +44 -0
- package/native-skills/media/screenplay-and-shotlist-forge/SKILL.md +42 -0
- package/package.json +1 -1
- package/src/acp/client.js +235 -0
- package/src/acp/client.ts +282 -0
- package/src/acp/peerProfiles.js +122 -0
- package/src/acp/peerProfiles.ts +128 -0
- package/src/acp/protocol.js +54 -0
- package/src/acp/protocol.ts +434 -0
- package/src/acp/server.js +609 -0
- package/src/acp/server.ts +676 -0
- package/src/agentLoop.d.ts +43 -0
- package/src/agentLoop.js +142 -8
- package/src/commands.js +203 -0
- package/src/compiledSkills.js +10 -0
- package/src/documentEngine/antiSlopAuditor.js +156 -0
- package/src/documentEngine/antiSlopAuditor.ts +221 -0
- package/src/documentEngine/documentForge.js +443 -0
- package/src/documentEngine/documentForge.ts +519 -0
- package/src/documentEngine/screenplayFormatter.js +336 -0
- package/src/documentEngine/screenplayFormatter.ts +407 -0
- package/src/entrypoint.js +13 -0
- package/src/execSafe.js +10 -13
- package/src/execSafe.ts +25 -0
- package/src/modelRouter.js +56 -123
- package/src/modelRouter.ts +134 -0
- package/src/observer/advisory.js +164 -0
- package/src/observer/advisory.ts +171 -0
- package/src/observer/coldObserver.js +462 -0
- package/src/observer/coldObserver.ts +540 -0
- package/src/observer/evidence.js +435 -0
- package/src/observer/evidence.ts +499 -0
- package/src/perception.js +303 -118
- package/src/perception.ts +387 -0
- package/src/procSignal.js +15 -16
- package/src/procSignal.ts +31 -0
- package/src/promptMentions.js +170 -0
- package/src/promptMentions.ts +212 -0
- package/src/sandbox.js +1 -1
- package/src/textDiff.js +117 -109
- package/src/textDiff.ts +169 -0
- package/src/toolPacks.js +5 -2
- package/src/tools/documentForge.js +215 -0
- package/src/tools/documentForge.ts +265 -0
- package/src/tools/peerAgent.js +102 -0
- package/src/tools/peerAgent.ts +116 -0
- package/src/tools/registry.js +3 -1
- package/src/tui/completion.js +97 -41
- package/src/tui/prompt.js +17 -17
- package/src/uncertainty.js +93 -205
- package/src/uncertainty.ts +255 -0
- package/src/utils/paths.js +15 -18
- package/src/utils/paths.ts +36 -0
- package/src/vfeTracker.js +122 -181
- package/src/vfeTracker.ts +242 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { UTILITY_MODEL_BY_PROVIDER } from './modelCatalogue.js';
|
|
2
|
+
|
|
3
|
+
// Hybrid model routing.
|
|
4
|
+
//
|
|
5
|
+
// Not every call an agent makes is reasoning. Condensing a dropped transcript,
|
|
6
|
+
// classifying an intent, filtering a log, extracting keywords — these are
|
|
7
|
+
// utility work, and paying a flagship rate for them is pure waste. The research
|
|
8
|
+
// spec puts the saving at the difference between the two rate cards, which for
|
|
9
|
+
// OpenAI is $5/$30 against $0.75/$4.50: roughly 6x on work that never needed
|
|
10
|
+
// the better model.
|
|
11
|
+
//
|
|
12
|
+
// THREE RULES THIS MODULE EXISTS TO ENFORCE, all of them things a naive router
|
|
13
|
+
// gets wrong:
|
|
14
|
+
//
|
|
15
|
+
// 1. NEVER CHANGE PROVIDER. Causyne is BYOK. The user holds one key. A router
|
|
16
|
+
// that sends utility work to "whatever is cheapest on the market" calls an
|
|
17
|
+
// endpoint there is no credential for and the task fails — having spent
|
|
18
|
+
// the tokens that got it there.
|
|
19
|
+
//
|
|
20
|
+
// 2. NEVER CHANGE THE PRIVACY POSTURE. Muse's cheap tier is `-contributor`,
|
|
21
|
+
// discounted because the vendor trains on the content. Routing into it to
|
|
22
|
+
// save money would opt the user into training silently. Cost is not a
|
|
23
|
+
// reason to make that choice for someone.
|
|
24
|
+
//
|
|
25
|
+
// 3. NEVER GUESS. A provider with no published second tier routes to nothing
|
|
26
|
+
// and pays full rate. An invented model id becomes a 404 mid-task, or
|
|
27
|
+
// worse, a real model at a price nobody predicted.
|
|
28
|
+
//
|
|
29
|
+
// The router returns a DESCRIPTION of where a call should go. It never
|
|
30
|
+
// constructs a provider and never performs a call, so it stays trivially
|
|
31
|
+
// testable and cannot become a second place where credentials are handled.
|
|
32
|
+
|
|
33
|
+
export type RouteClass = 'reasoning' | 'utility';
|
|
34
|
+
|
|
35
|
+
export interface Route {
|
|
36
|
+
provider: string;
|
|
37
|
+
model: string;
|
|
38
|
+
routed: boolean;
|
|
39
|
+
reason: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface RouterConfig {
|
|
43
|
+
provider?: string | undefined;
|
|
44
|
+
model?: string | undefined;
|
|
45
|
+
modelRouting?: Record<string, string> | undefined;
|
|
46
|
+
disableModelRouting?: boolean | undefined;
|
|
47
|
+
[key: string]: unknown;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Utility work that is cheap to get wrong and expensive to run on a flagship.
|
|
52
|
+
* Anything not listed is treated as reasoning — the safe default is to spend
|
|
53
|
+
* more, never to silently downgrade work the user is judging.
|
|
54
|
+
*/
|
|
55
|
+
export const UTILITY_KINDS: ReadonlySet<string> = new Set([
|
|
56
|
+
'condense', // cortex/autoCondenser semantic pass
|
|
57
|
+
'keywords', // plan-cache intent extraction
|
|
58
|
+
'classify', // intent / archetype disambiguation
|
|
59
|
+
'filter', // log and output filtering
|
|
60
|
+
'summarise' // non-decision summaries
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Decide where a call of a given kind should go.
|
|
65
|
+
*
|
|
66
|
+
* @param config The live agent config, as loaded from `~/.causyne/config.json`.
|
|
67
|
+
* @param kind Either a RouteClass or one of UTILITY_KINDS.
|
|
68
|
+
* @returns The resolved route description.
|
|
69
|
+
*/
|
|
70
|
+
export function resolveRoute(config: RouterConfig | undefined | null, kind: RouteClass | string): Route {
|
|
71
|
+
const provider = String(config?.provider || '').toLowerCase();
|
|
72
|
+
const configured = String(config?.model || '');
|
|
73
|
+
|
|
74
|
+
const stay: Route = {
|
|
75
|
+
provider,
|
|
76
|
+
model: configured,
|
|
77
|
+
routed: false,
|
|
78
|
+
reason: 'reasoning work runs on the configured model'
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
if (!provider || !configured) {
|
|
82
|
+
return { ...stay, reason: 'no provider or model configured' };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const isUtility = kind === 'utility' || UTILITY_KINDS.has(String(kind));
|
|
86
|
+
if (!isUtility) return stay;
|
|
87
|
+
|
|
88
|
+
if (config?.disableModelRouting === true) {
|
|
89
|
+
return { ...stay, reason: 'model routing disabled by config' };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// An explicit choice by the user outranks the table, including a deliberate
|
|
93
|
+
// choice of a training tier — they made it, we did not make it for them.
|
|
94
|
+
const override = config?.modelRouting?.['utility'];
|
|
95
|
+
if (override) {
|
|
96
|
+
return {
|
|
97
|
+
provider,
|
|
98
|
+
model: String(override),
|
|
99
|
+
routed: String(override) !== configured,
|
|
100
|
+
reason: 'utility route set explicitly in config'
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const cheap = UTILITY_MODEL_BY_PROVIDER[provider];
|
|
105
|
+
if (!cheap) {
|
|
106
|
+
return { ...stay, reason: `no published utility tier for ${provider}; paying full rate` };
|
|
107
|
+
}
|
|
108
|
+
if (cheap === configured) {
|
|
109
|
+
return { ...stay, reason: 'already on this provider\'s utility tier' };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Rule 2. The configured model already carries a privacy posture; a route may
|
|
113
|
+
// not alter it in either direction.
|
|
114
|
+
if (/-contributor$/.test(configured) !== /-contributor$/.test(cheap)) {
|
|
115
|
+
return { ...stay, reason: 'a route may not change the training-data posture' };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
provider,
|
|
120
|
+
model: cheap,
|
|
121
|
+
routed: true,
|
|
122
|
+
reason: `utility work routed to ${provider}'s cheaper tier`
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A config object for the routed call, leaving everything except the model
|
|
128
|
+
* untouched — same provider, same key, same base URL.
|
|
129
|
+
*/
|
|
130
|
+
export function routedConfig<T extends RouterConfig>(config: T, kind: RouteClass | string): { config: T; route: Route } {
|
|
131
|
+
const route = resolveRoute(config, kind);
|
|
132
|
+
if (!route.routed) return { config, route };
|
|
133
|
+
return { config: { ...config, model: route.model }, route };
|
|
134
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
const MAX_PAYLOAD_CHARS = 5000;
|
|
2
|
+
export function isLoopbackEndpoint(value) {
|
|
3
|
+
try {
|
|
4
|
+
const url = new URL(value);
|
|
5
|
+
if (url.username || url.password)
|
|
6
|
+
return false;
|
|
7
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
8
|
+
return false;
|
|
9
|
+
const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
10
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function completionUrl(endpoint) {
|
|
17
|
+
const trimmed = endpoint.replace(/\/+$/, '');
|
|
18
|
+
if (/\/chat\/completions$/.test(trimmed))
|
|
19
|
+
return trimmed;
|
|
20
|
+
return `${trimmed}/chat/completions`;
|
|
21
|
+
}
|
|
22
|
+
function redact(value) {
|
|
23
|
+
return value
|
|
24
|
+
.replace(/\b(Bearer)\s+[A-Za-z0-9._~+/=-]+/gi, '$1 [redacted]')
|
|
25
|
+
.replace(/\b(api[-_]?key|token|secret|password|passwd)\b\s*[:=]\s*["']?[^"'\s,}]+/gi, '$1=[redacted]')
|
|
26
|
+
.slice(0, MAX_PAYLOAD_CHARS);
|
|
27
|
+
}
|
|
28
|
+
function compactArguments(args) {
|
|
29
|
+
const allowed = {};
|
|
30
|
+
for (const key of ['command', 'cmd', 'path', 'file_path', 'source', 'destination', 'action', 'url']) {
|
|
31
|
+
const value = args[key];
|
|
32
|
+
if (typeof value === 'string')
|
|
33
|
+
allowed[key] = value;
|
|
34
|
+
}
|
|
35
|
+
if (typeof args.content === 'string') {
|
|
36
|
+
allowed.contentExcerpt = args.content.slice(0, 800);
|
|
37
|
+
allowed.contentBytes = Buffer.byteLength(args.content, 'utf8');
|
|
38
|
+
}
|
|
39
|
+
if (Array.isArray(args.edits)) {
|
|
40
|
+
allowed.edits = args.edits.slice(0, 8).map((edit) => {
|
|
41
|
+
if (edit === null || typeof edit !== 'object' || Array.isArray(edit))
|
|
42
|
+
return {};
|
|
43
|
+
const record = edit;
|
|
44
|
+
return {
|
|
45
|
+
before: typeof record.old_string === 'string'
|
|
46
|
+
? record.old_string.slice(0, 200)
|
|
47
|
+
: typeof record.searchContent === 'string' ? record.searchContent.slice(0, 200) : '',
|
|
48
|
+
after: typeof record.new_string === 'string'
|
|
49
|
+
? record.new_string.slice(0, 200)
|
|
50
|
+
: typeof record.replaceContent === 'string' ? record.replaceContent.slice(0, 200) : ''
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return redact(JSON.stringify(allowed));
|
|
55
|
+
}
|
|
56
|
+
function parseVerdict(content) {
|
|
57
|
+
const match = content.match(/\{[\s\S]*\}/);
|
|
58
|
+
if (!match)
|
|
59
|
+
return null;
|
|
60
|
+
try {
|
|
61
|
+
const parsed = JSON.parse(match[0]);
|
|
62
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
63
|
+
return null;
|
|
64
|
+
const record = parsed;
|
|
65
|
+
const risk = record.risk;
|
|
66
|
+
const confidence = record.confidence;
|
|
67
|
+
if (typeof risk !== 'number' || !Number.isFinite(risk))
|
|
68
|
+
return null;
|
|
69
|
+
if (typeof confidence !== 'number' || !Number.isFinite(confidence))
|
|
70
|
+
return null;
|
|
71
|
+
return {
|
|
72
|
+
risk: Math.max(0, Math.min(1, risk)),
|
|
73
|
+
confidence: Math.max(0, Math.min(1, confidence)),
|
|
74
|
+
observation: typeof record.observation === 'string' ? record.observation.slice(0, 400) : ''
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export async function queryAdvisory(input, options) {
|
|
82
|
+
if (!isLoopbackEndpoint(options.endpoint))
|
|
83
|
+
return null;
|
|
84
|
+
if (!options.model.trim())
|
|
85
|
+
return null;
|
|
86
|
+
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 200)
|
|
87
|
+
return null;
|
|
88
|
+
const payload = {
|
|
89
|
+
model: options.model,
|
|
90
|
+
temperature: 0,
|
|
91
|
+
max_tokens: 160,
|
|
92
|
+
messages: [
|
|
93
|
+
{
|
|
94
|
+
role: 'system',
|
|
95
|
+
content: 'You are an evidence reviewer for a software agent. Judge only the supplied action and deterministic findings. Do not invent facts. Return one JSON object with keys risk (0..1), confidence (0..1), and observation (one sentence). High risk means the action could destroy work, escape the workspace, expose credentials, or publish state. Low risk means the action is reversible and scoped.'
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
role: 'user',
|
|
99
|
+
content: `Tool: ${input.tool}\nWorkspace: ${input.workspaceRoot}\nFindings: ${input.findings.slice(0, 6).join(' | ') || 'none'}\nArguments: ${compactArguments(input.args)}`
|
|
100
|
+
}
|
|
101
|
+
]
|
|
102
|
+
};
|
|
103
|
+
const controller = new AbortController();
|
|
104
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs);
|
|
105
|
+
try {
|
|
106
|
+
const response = await fetch(completionUrl(options.endpoint), {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: { 'content-type': 'application/json' },
|
|
109
|
+
body: JSON.stringify(payload),
|
|
110
|
+
signal: controller.signal
|
|
111
|
+
});
|
|
112
|
+
if (!response.ok)
|
|
113
|
+
return null;
|
|
114
|
+
const body = await response.json();
|
|
115
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body))
|
|
116
|
+
return null;
|
|
117
|
+
const choices = body.choices;
|
|
118
|
+
if (!Array.isArray(choices) || choices.length === 0)
|
|
119
|
+
return null;
|
|
120
|
+
const first = choices[0];
|
|
121
|
+
if (first === null || typeof first !== 'object' || Array.isArray(first))
|
|
122
|
+
return null;
|
|
123
|
+
const message = first.message;
|
|
124
|
+
if (message === null || typeof message !== 'object' || Array.isArray(message))
|
|
125
|
+
return null;
|
|
126
|
+
const content = message.content;
|
|
127
|
+
if (typeof content !== 'string')
|
|
128
|
+
return null;
|
|
129
|
+
return parseVerdict(content);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
export async function probeAdvisory(options) {
|
|
139
|
+
if (!isLoopbackEndpoint(options.endpoint)) {
|
|
140
|
+
return { healthy: false, latencyMs: 0, detail: 'endpoint must be a loopback HTTP URL' };
|
|
141
|
+
}
|
|
142
|
+
const start = Date.now();
|
|
143
|
+
const controller = new AbortController();
|
|
144
|
+
const timer = setTimeout(() => controller.abort(), Math.max(500, options.timeoutMs));
|
|
145
|
+
try {
|
|
146
|
+
const base = options.endpoint.replace(/\/+$/, '').replace(/\/chat\/completions$/, '');
|
|
147
|
+
const response = await fetch(`${base}/models`, { signal: controller.signal });
|
|
148
|
+
return {
|
|
149
|
+
healthy: response.ok,
|
|
150
|
+
latencyMs: Date.now() - start,
|
|
151
|
+
detail: response.ok ? 'endpoint responded' : `endpoint returned HTTP ${response.status}`
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
return {
|
|
156
|
+
healthy: false,
|
|
157
|
+
latencyMs: Date.now() - start,
|
|
158
|
+
detail: error instanceof Error ? error.message : 'endpoint did not respond'
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
clearTimeout(timer);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
export interface AdvisoryInput {
|
|
2
|
+
tool: string;
|
|
3
|
+
args: Record<string, unknown>;
|
|
4
|
+
findings: string[];
|
|
5
|
+
workspaceRoot: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface AdvisoryVerdict {
|
|
9
|
+
risk: number;
|
|
10
|
+
confidence: number;
|
|
11
|
+
observation: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface AdvisoryOptions {
|
|
15
|
+
endpoint: string;
|
|
16
|
+
model: string;
|
|
17
|
+
timeoutMs: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const MAX_PAYLOAD_CHARS = 5000;
|
|
21
|
+
|
|
22
|
+
export function isLoopbackEndpoint(value: string): boolean {
|
|
23
|
+
try {
|
|
24
|
+
const url = new URL(value);
|
|
25
|
+
if (url.username || url.password) return false;
|
|
26
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
|
27
|
+
const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
28
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function completionUrl(endpoint: string): string {
|
|
35
|
+
const trimmed = endpoint.replace(/\/+$/, '');
|
|
36
|
+
if (/\/chat\/completions$/.test(trimmed)) return trimmed;
|
|
37
|
+
return `${trimmed}/chat/completions`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function redact(value: string): string {
|
|
41
|
+
return value
|
|
42
|
+
.replace(/\b(Bearer)\s+[A-Za-z0-9._~+/=-]+/gi, '$1 [redacted]')
|
|
43
|
+
.replace(/\b(api[-_]?key|token|secret|password|passwd)\b\s*[:=]\s*["']?[^"'\s,}]+/gi, '$1=[redacted]')
|
|
44
|
+
.slice(0, MAX_PAYLOAD_CHARS);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function compactArguments(args: Record<string, unknown>): string {
|
|
48
|
+
const allowed: Record<string, unknown> = {};
|
|
49
|
+
for (const key of ['command', 'cmd', 'path', 'file_path', 'source', 'destination', 'action', 'url']) {
|
|
50
|
+
const value = args[key];
|
|
51
|
+
if (typeof value === 'string') allowed[key] = value;
|
|
52
|
+
}
|
|
53
|
+
if (typeof args.content === 'string') {
|
|
54
|
+
allowed.contentExcerpt = args.content.slice(0, 800);
|
|
55
|
+
allowed.contentBytes = Buffer.byteLength(args.content, 'utf8');
|
|
56
|
+
}
|
|
57
|
+
if (Array.isArray(args.edits)) {
|
|
58
|
+
allowed.edits = args.edits.slice(0, 8).map((edit) => {
|
|
59
|
+
if (edit === null || typeof edit !== 'object' || Array.isArray(edit)) return {};
|
|
60
|
+
const record = edit as Record<string, unknown>;
|
|
61
|
+
return {
|
|
62
|
+
before: typeof record.old_string === 'string'
|
|
63
|
+
? record.old_string.slice(0, 200)
|
|
64
|
+
: typeof record.searchContent === 'string' ? record.searchContent.slice(0, 200) : '',
|
|
65
|
+
after: typeof record.new_string === 'string'
|
|
66
|
+
? record.new_string.slice(0, 200)
|
|
67
|
+
: typeof record.replaceContent === 'string' ? record.replaceContent.slice(0, 200) : ''
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return redact(JSON.stringify(allowed));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseVerdict(content: string): AdvisoryVerdict | null {
|
|
75
|
+
const match = content.match(/\{[\s\S]*\}/);
|
|
76
|
+
if (!match) return null;
|
|
77
|
+
try {
|
|
78
|
+
const parsed: unknown = JSON.parse(match[0]);
|
|
79
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
80
|
+
const record = parsed as Record<string, unknown>;
|
|
81
|
+
const risk = record.risk;
|
|
82
|
+
const confidence = record.confidence;
|
|
83
|
+
if (typeof risk !== 'number' || !Number.isFinite(risk)) return null;
|
|
84
|
+
if (typeof confidence !== 'number' || !Number.isFinite(confidence)) return null;
|
|
85
|
+
return {
|
|
86
|
+
risk: Math.max(0, Math.min(1, risk)),
|
|
87
|
+
confidence: Math.max(0, Math.min(1, confidence)),
|
|
88
|
+
observation: typeof record.observation === 'string' ? record.observation.slice(0, 400) : ''
|
|
89
|
+
};
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function queryAdvisory(
|
|
96
|
+
input: AdvisoryInput,
|
|
97
|
+
options: AdvisoryOptions
|
|
98
|
+
): Promise<AdvisoryVerdict | null> {
|
|
99
|
+
if (!isLoopbackEndpoint(options.endpoint)) return null;
|
|
100
|
+
if (!options.model.trim()) return null;
|
|
101
|
+
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 200) return null;
|
|
102
|
+
|
|
103
|
+
const payload = {
|
|
104
|
+
model: options.model,
|
|
105
|
+
temperature: 0,
|
|
106
|
+
max_tokens: 160,
|
|
107
|
+
messages: [
|
|
108
|
+
{
|
|
109
|
+
role: 'system',
|
|
110
|
+
content: 'You are an evidence reviewer for a software agent. Judge only the supplied action and deterministic findings. Do not invent facts. Return one JSON object with keys risk (0..1), confidence (0..1), and observation (one sentence). High risk means the action could destroy work, escape the workspace, expose credentials, or publish state. Low risk means the action is reversible and scoped.'
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
role: 'user',
|
|
114
|
+
content: `Tool: ${input.tool}\nWorkspace: ${input.workspaceRoot}\nFindings: ${input.findings.slice(0, 6).join(' | ') || 'none'}\nArguments: ${compactArguments(input.args)}`
|
|
115
|
+
}
|
|
116
|
+
]
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const controller = new AbortController();
|
|
120
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs);
|
|
121
|
+
try {
|
|
122
|
+
const response = await fetch(completionUrl(options.endpoint), {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
headers: { 'content-type': 'application/json' },
|
|
125
|
+
body: JSON.stringify(payload),
|
|
126
|
+
signal: controller.signal
|
|
127
|
+
});
|
|
128
|
+
if (!response.ok) return null;
|
|
129
|
+
const body: unknown = await response.json();
|
|
130
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body)) return null;
|
|
131
|
+
const choices = (body as Record<string, unknown>).choices;
|
|
132
|
+
if (!Array.isArray(choices) || choices.length === 0) return null;
|
|
133
|
+
const first = choices[0];
|
|
134
|
+
if (first === null || typeof first !== 'object' || Array.isArray(first)) return null;
|
|
135
|
+
const message = (first as Record<string, unknown>).message;
|
|
136
|
+
if (message === null || typeof message !== 'object' || Array.isArray(message)) return null;
|
|
137
|
+
const content = (message as Record<string, unknown>).content;
|
|
138
|
+
if (typeof content !== 'string') return null;
|
|
139
|
+
return parseVerdict(content);
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
} finally {
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function probeAdvisory(options: AdvisoryOptions): Promise<{ healthy: boolean; latencyMs: number; detail: string }> {
|
|
148
|
+
if (!isLoopbackEndpoint(options.endpoint)) {
|
|
149
|
+
return { healthy: false, latencyMs: 0, detail: 'endpoint must be a loopback HTTP URL' };
|
|
150
|
+
}
|
|
151
|
+
const start = Date.now();
|
|
152
|
+
const controller = new AbortController();
|
|
153
|
+
const timer = setTimeout(() => controller.abort(), Math.max(500, options.timeoutMs));
|
|
154
|
+
try {
|
|
155
|
+
const base = options.endpoint.replace(/\/+$/, '').replace(/\/chat\/completions$/, '');
|
|
156
|
+
const response = await fetch(`${base}/models`, { signal: controller.signal });
|
|
157
|
+
return {
|
|
158
|
+
healthy: response.ok,
|
|
159
|
+
latencyMs: Date.now() - start,
|
|
160
|
+
detail: response.ok ? 'endpoint responded' : `endpoint returned HTTP ${response.status}`
|
|
161
|
+
};
|
|
162
|
+
} catch (error) {
|
|
163
|
+
return {
|
|
164
|
+
healthy: false,
|
|
165
|
+
latencyMs: Date.now() - start,
|
|
166
|
+
detail: error instanceof Error ? error.message : 'endpoint did not respond'
|
|
167
|
+
};
|
|
168
|
+
} finally {
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
}
|
|
171
|
+
}
|