jev-layer 0.2.0 → 0.3.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/CHANGELOG.md +47 -19
- package/CONTRIBUTING.md +1 -1
- package/README.md +2 -0
- package/README.ru.md +2 -0
- package/README.zh-CN.md +2 -0
- package/SECURITY.md +8 -0
- package/bin/jev.mjs +7 -5
- package/config/jev.example.json +1 -0
- package/config/providers.env.example +2 -2
- package/docs/AGENT-IMPLEMENTATION.md +56 -0
- package/docs/COMPATIBILITY.md +9 -0
- package/docs/HERMES-LOCAL-AGENT-HANDOFF.ru.md +12 -2
- package/docs/PROVIDERS.md +45 -3
- package/docs/SCHEMA-VERSIONING.md +5 -0
- package/integrations/hermes/__init__.py +8 -2
- package/integrations/hermes/plugin.yaml +3 -2
- package/integrations/hermes/schemas.py +31 -0
- package/integrations/omp/extension.js +1 -0
- package/package.json +1 -1
- package/scripts/clean-install-smoke.mjs +24 -10
- package/scripts/mcp-receipt-smoke.mjs +4 -2
- package/scripts/openrouter-choice.mjs +1 -1
- package/src/browser.mjs +15 -3
- package/src/computer.mjs +196 -0
- package/src/config.mjs +1 -0
- package/src/context-filter.mjs +1 -1
- package/src/contract.mjs +2 -0
- package/src/hermes-adapter.mjs +14 -3
- package/src/images.mjs +98 -0
- package/src/mcp-server.mjs +56 -10
- package/src/providers/typesafe.mjs +94 -14
- package/src/receipts.mjs +1 -0
- package/src/route.mjs +11 -2
- package/test/browser.test.mjs +23 -0
- package/test/computer.test.mjs +195 -0
- package/test/hermes-adapter.test.mjs +2 -1
- package/test/image-fixture.mjs +2 -0
- package/test/images.test.mjs +85 -0
- package/test/providers.test.mjs +5 -6
- package/test/route.test.mjs +12 -0
package/src/mcp-server.mjs
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createInterface } from "node:readline";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
3
4
|
import { appendExecutionReceipt, appendRoutingCase, buildExecutionReceipt, replayCasePath } from "./receipts.mjs";
|
|
4
5
|
import { configuredProvider, configuredReplayPath, loadConfig } from "./config.mjs";
|
|
5
6
|
import { decideBrowserStep } from "./browser.mjs";
|
|
7
|
+
import { decideComputerStep } from "./computer.mjs";
|
|
6
8
|
import { superviseWork } from "./supervision.mjs";
|
|
7
9
|
import { buildShadowCompactionReport } from "./shadow-compaction.mjs";
|
|
8
10
|
import { recommendModelRoute } from "./model-routing.mjs";
|
|
9
11
|
import { routeRequest } from "./route.mjs";
|
|
10
12
|
|
|
11
13
|
const { config } = await loadConfig();
|
|
12
|
-
const
|
|
14
|
+
const { version } = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
|
|
15
|
+
const serverInfo = { name: "jev-layer", version };
|
|
13
16
|
const CASES_PATH = replayCasePath(configuredReplayPath(config));
|
|
14
17
|
const pendingDecisions = new Map();
|
|
15
18
|
const tools = [
|
|
@@ -23,6 +26,7 @@ const tools = [
|
|
|
23
26
|
intent: { type: "string" },
|
|
24
27
|
harness: { type: "string" },
|
|
25
28
|
context: { type: "object" },
|
|
29
|
+
assets: { type: "object", description: "Inline PNG/JPEG assets keyed by reference; images are passed outside the text projection." },
|
|
26
30
|
actor_permissions: { type: "array", items: { type: "string" } },
|
|
27
31
|
capabilities: { type: "array", items: { type: "object" } },
|
|
28
32
|
policy: { type: "object" },
|
|
@@ -41,7 +45,41 @@ const tools = [
|
|
|
41
45
|
goal: { type: "string" },
|
|
42
46
|
harness: { type: "string" },
|
|
43
47
|
start_url: { type: ["string", "null"] },
|
|
44
|
-
observation: { type: "object" },
|
|
48
|
+
observation: { type: "object", description: "Host snapshot: url, visible_text, targets, tabs, scroll, and optional screenshot {format,data_base64,width,height}." },
|
|
49
|
+
progress: { type: "object" },
|
|
50
|
+
actor_permissions: { type: "array", items: { type: "string" } },
|
|
51
|
+
policy: { type: "object" },
|
|
52
|
+
provider: { type: "string", enum: ["demo", "typesafe", "openrouter"] },
|
|
53
|
+
enabled: { type: "boolean" },
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: "jev_computer_step",
|
|
59
|
+
description: "Recommend one host-declared desktop action, optionally grounding a click in a screenshot. Advisory only: host validates freshness, permissions, approval and execution.",
|
|
60
|
+
inputSchema: {
|
|
61
|
+
type: "object",
|
|
62
|
+
required: ["goal", "observation", "actions", "actor_permissions"],
|
|
63
|
+
properties: {
|
|
64
|
+
goal: { type: "string" },
|
|
65
|
+
harness: { type: "string" },
|
|
66
|
+
observation: { type: "object", description: "Host screenshot {format,data_base64,width,height}, optional application and visible_text." },
|
|
67
|
+
actions: { type: "array", maxItems: 32, items: {
|
|
68
|
+
type: "object", required: ["id", "operation"],
|
|
69
|
+
properties: {
|
|
70
|
+
id: { type: "string" },
|
|
71
|
+
operation: { type: "string", enum: ["click", "type", "key", "scroll", "handoff"] },
|
|
72
|
+
args: { type: "object", description: "Exact host-declared arguments; Jev does not generate typing or key input." },
|
|
73
|
+
target: { type: "string", description: "Unique visible click target to locate when no x/y are supplied." },
|
|
74
|
+
description: { type: "string" },
|
|
75
|
+
risk: { type: "string", enum: ["low", "medium", "high", "critical"] },
|
|
76
|
+
available: { type: "boolean" },
|
|
77
|
+
availability: { type: "object" },
|
|
78
|
+
permissions: { type: "array", items: { type: "string" } },
|
|
79
|
+
policy: { type: "object" },
|
|
80
|
+
},
|
|
81
|
+
} },
|
|
82
|
+
actor_permissions: { type: "array", items: { type: "string" }, description: "Must explicitly include computer; never grants host permission." },
|
|
45
83
|
policy: { type: "object" },
|
|
46
84
|
provider: { type: "string", enum: ["demo", "typesafe", "openrouter"] },
|
|
47
85
|
enabled: { type: "boolean" },
|
|
@@ -148,6 +186,7 @@ async function handle(message) {
|
|
|
148
186
|
const args = params.arguments && typeof params.arguments === "object" ? params.arguments : {};
|
|
149
187
|
if (params.name === "jev_route") return route(args, id);
|
|
150
188
|
if (params.name === "jev_browser_step") return browserStep(args, id);
|
|
189
|
+
if (params.name === "jev_computer_step") return computerStep(args, id);
|
|
151
190
|
if (params.name === "jev_supervise") return supervise(args, id);
|
|
152
191
|
if (params.name === "jev_model_route") return modelRoute(args, id);
|
|
153
192
|
if (params.name === "jev_shadow_compaction") return shadowCompaction(args, id);
|
|
@@ -175,17 +214,22 @@ async function browserStep(args, id) {
|
|
|
175
214
|
provider: configuredProvider(config, requestedProvider),
|
|
176
215
|
});
|
|
177
216
|
routed.decision.browser_action = routed.action
|
|
178
|
-
? {
|
|
179
|
-
id: routed.action.id,
|
|
180
|
-
operation: routed.action.operation,
|
|
181
|
-
target_id: routed.action.target_id ?? null,
|
|
182
|
-
option_id: routed.action.option_id ?? null,
|
|
183
|
-
consequential: routed.action.consequential,
|
|
184
|
-
}
|
|
217
|
+
? { ...routed.action, target_id: routed.action.target_id ?? null, option_id: routed.action.option_id ?? null }
|
|
185
218
|
: null;
|
|
186
219
|
return persistDecision(routed.request, routed.decision, id);
|
|
187
220
|
}
|
|
188
221
|
|
|
222
|
+
async function computerStep(args, id) {
|
|
223
|
+
const { provider: requestedProvider, enabled, ...input } = args;
|
|
224
|
+
const routed = await decideComputerStep(input, {
|
|
225
|
+
config,
|
|
226
|
+
enabled: enabled ?? (process.env.JEV_COMPUTER_USE === "1" || config.features?.computer_use),
|
|
227
|
+
provider: configuredProvider(config, requestedProvider),
|
|
228
|
+
});
|
|
229
|
+
routed.decision.computer_action = routed.action;
|
|
230
|
+
return persistDecision(routed.request ?? { harness: input.harness ?? "computer", intent: input.goal }, routed.decision, id);
|
|
231
|
+
}
|
|
232
|
+
|
|
189
233
|
async function supervise(args, id) {
|
|
190
234
|
const { provider: requestedProvider, enabled, ...input } = args;
|
|
191
235
|
const result = await superviseWork({
|
|
@@ -217,7 +261,9 @@ async function shadowCompaction(args, id) {
|
|
|
217
261
|
}
|
|
218
262
|
|
|
219
263
|
async function persistDecision(request, decision, id) {
|
|
220
|
-
|
|
264
|
+
const { assets, ...retainedRequest } = request;
|
|
265
|
+
if (Object.keys(assets ?? {}).length) retainedRequest.assets_omitted = true;
|
|
266
|
+
pendingDecisions.set(decision.correlation_id, { request: retainedRequest, decision });
|
|
221
267
|
try {
|
|
222
268
|
await appendRoutingCase({ path: CASES_PATH, request, decision });
|
|
223
269
|
decision.receipt = { ...decision.receipt, replay_case_id: decision.correlation_id, replay_persisted: true };
|
|
@@ -1,7 +1,57 @@
|
|
|
1
|
+
import { normalizeImageAssets } from "../images.mjs";
|
|
2
|
+
|
|
3
|
+
function containsReference(value, reference) {
|
|
4
|
+
if (typeof value === "string") return value.includes(reference);
|
|
5
|
+
return value !== null && typeof value === "object"
|
|
6
|
+
&& Object.values(value).some((child) => containsReference(child, reference));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function openRouterImageInput(state, questions, images) {
|
|
10
|
+
const parts = Object.fromEntries(Object.entries(images).map(([reference, image]) => [reference, {
|
|
11
|
+
type: "image_url",
|
|
12
|
+
image_url: { url: `data:image/${image.format === "png" ? "png" : "jpeg"};base64,${image.data_base64}` },
|
|
13
|
+
}]));
|
|
14
|
+
const pattern = new RegExp(Object.keys(parts).sort((a, b) => b.length - a.length).map((key) => key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"), "g");
|
|
15
|
+
const used = new Set();
|
|
16
|
+
function expand(value) {
|
|
17
|
+
if (typeof value === "string") {
|
|
18
|
+
if (Object.hasOwn(parts, value)) {
|
|
19
|
+
used.add(value);
|
|
20
|
+
return parts[value];
|
|
21
|
+
}
|
|
22
|
+
const content = [];
|
|
23
|
+
let offset = 0;
|
|
24
|
+
for (const match of value.matchAll(pattern)) {
|
|
25
|
+
if (match.index > offset) content.push(value.slice(offset, match.index));
|
|
26
|
+
content.push(parts[match[0]]);
|
|
27
|
+
used.add(match[0]);
|
|
28
|
+
offset = match.index + match[0].length;
|
|
29
|
+
}
|
|
30
|
+
if (!content.length) return value;
|
|
31
|
+
if (offset < value.length) content.push(value.slice(offset));
|
|
32
|
+
return content;
|
|
33
|
+
}
|
|
34
|
+
if (Array.isArray(value)) return value.map(expand);
|
|
35
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, expand(child)]));
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
const input = {
|
|
39
|
+
state: expand(state),
|
|
40
|
+
questions: Object.fromEntries(Object.entries(questions).map(([key, question]) => [key, {
|
|
41
|
+
...question,
|
|
42
|
+
instructions: expand(question.instructions),
|
|
43
|
+
...(question.criteria !== undefined ? { criteria: expand(question.criteria) } : {}),
|
|
44
|
+
}])),
|
|
45
|
+
};
|
|
46
|
+
if (used.size !== Object.keys(parts).length) throw new Error("every image asset must be referenced in state, instructions, or criteria");
|
|
47
|
+
return input;
|
|
48
|
+
}
|
|
49
|
+
|
|
1
50
|
export class TypeSafeProvider {
|
|
2
51
|
name = "typesafe";
|
|
52
|
+
supportsImages = true;
|
|
3
53
|
|
|
4
|
-
constructor({ apiKey = process.env.TYPESAFE_API_KEY, endpoint = process.env.TYPESAFE_ENDPOINT ?? "https://api.typesafe.ai/v1/systemone", model = process.env.TYPESAFE_MODEL ?? "jev-
|
|
54
|
+
constructor({ apiKey = process.env.TYPESAFE_API_KEY, endpoint = process.env.TYPESAFE_ENDPOINT ?? "https://api.typesafe.ai/v1/systemone", model = process.env.TYPESAFE_MODEL ?? "jev-1.14.0", timeoutMs = 2_000, fetchImpl = globalThis.fetch } = {}) {
|
|
5
55
|
this.apiKey = apiKey;
|
|
6
56
|
this.endpoint = endpoint;
|
|
7
57
|
this.model = model;
|
|
@@ -9,7 +59,7 @@ export class TypeSafeProvider {
|
|
|
9
59
|
this.fetchImpl = fetchImpl;
|
|
10
60
|
}
|
|
11
61
|
|
|
12
|
-
async decide({ state, candidates }) {
|
|
62
|
+
async decide({ state, candidates, assets }) {
|
|
13
63
|
const criteria = Object.fromEntries(candidates.map((candidate) => [candidate.id, {
|
|
14
64
|
name: candidate.name,
|
|
15
65
|
kind: candidate.kind,
|
|
@@ -18,6 +68,7 @@ export class TypeSafeProvider {
|
|
|
18
68
|
}]));
|
|
19
69
|
return this.evaluate({
|
|
20
70
|
state,
|
|
71
|
+
assets,
|
|
21
72
|
questions: {
|
|
22
73
|
tool: {
|
|
23
74
|
type: "choice",
|
|
@@ -28,27 +79,44 @@ export class TypeSafeProvider {
|
|
|
28
79
|
});
|
|
29
80
|
}
|
|
30
81
|
|
|
31
|
-
async evaluate({ state, questions }) {
|
|
82
|
+
async evaluate({ state, questions, assets }) {
|
|
32
83
|
if (!this.apiKey) throw new Error("TYPESAFE_API_KEY is not configured");
|
|
33
84
|
if (typeof this.fetchImpl !== "function") throw new Error("fetch is unavailable");
|
|
85
|
+
const images = normalizeImageAssets(assets);
|
|
86
|
+
const hasImages = Object.keys(images).length > 0;
|
|
87
|
+
const hasBox = Object.values(questions).some((question) => question?.type === "box");
|
|
88
|
+
for (const reference of Object.keys(images)) {
|
|
89
|
+
if (!containsReference(state, reference)
|
|
90
|
+
&& !Object.values(questions).some((question) => containsReference(question?.instructions, reference) || containsReference(question?.criteria, reference))) {
|
|
91
|
+
throw new Error("every image asset must be referenced in state, instructions, or criteria");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const body = JSON.stringify({ state, model: this.model, questions, ...(hasImages ? { assets: images } : {}) });
|
|
95
|
+
if (Buffer.byteLength(body) > 32 * 1024 * 1024) throw new Error("TypeSafe request exceeds the 32 MiB body limit");
|
|
34
96
|
const response = await this.fetchImpl(this.endpoint, {
|
|
35
97
|
method: "POST",
|
|
36
98
|
headers: {
|
|
37
99
|
Authorization: `Bearer ${this.apiKey}`,
|
|
38
100
|
"Content-Type": "application/json",
|
|
101
|
+
...(hasImages || hasBox ? { "TypeSafe-Danger-Zone-Images": "true" } : {}),
|
|
39
102
|
},
|
|
40
|
-
body
|
|
103
|
+
body,
|
|
41
104
|
signal: AbortSignal.timeout(this.timeoutMs),
|
|
42
105
|
}).catch((error) => {
|
|
43
106
|
if (error?.name === "TimeoutError") throw new Error(`TypeSafe request timed out after ${this.timeoutMs}ms`);
|
|
107
|
+
if (hasImages) throw new Error("TypeSafe image request failed");
|
|
44
108
|
throw error;
|
|
45
109
|
});
|
|
46
110
|
|
|
47
111
|
if (!response.ok) {
|
|
48
|
-
|
|
49
|
-
|
|
112
|
+
// Image requests must not copy an upstream echo of image data into receipts.
|
|
113
|
+
const detail = hasImages ? "" : `: ${(await response.text()).slice(0, 240)}`;
|
|
114
|
+
throw new Error(`TypeSafe returned HTTP ${response.status}${detail}`);
|
|
50
115
|
}
|
|
51
|
-
const raw = await response.json()
|
|
116
|
+
const raw = await response.json().catch((error) => {
|
|
117
|
+
if (hasImages) throw new Error("TypeSafe image response is not valid JSON");
|
|
118
|
+
throw error;
|
|
119
|
+
});
|
|
52
120
|
if (!raw || typeof raw !== "object" || !raw.answers || typeof raw.answers !== "object") {
|
|
53
121
|
throw new Error("TypeSafe response has no answers object");
|
|
54
122
|
}
|
|
@@ -57,10 +125,12 @@ export class TypeSafeProvider {
|
|
|
57
125
|
}
|
|
58
126
|
|
|
59
127
|
export class OpenRouterDecisionsProvider {
|
|
128
|
+
supportsImages = true;
|
|
129
|
+
|
|
60
130
|
constructor({
|
|
61
131
|
apiKey = process.env.OPENROUTER_API_KEY,
|
|
62
132
|
endpoint = process.env.OPENROUTER_DECISIONS_ENDPOINT ?? "https://openrouter.ai/api/alpha/decisions",
|
|
63
|
-
model = process.env.OPENROUTER_DECISIONS_MODEL ?? "typesafe/jev-1.
|
|
133
|
+
model = process.env.OPENROUTER_DECISIONS_MODEL ?? "typesafe/jev-1.14",
|
|
64
134
|
timeoutMs = 5_000,
|
|
65
135
|
fetchImpl = globalThis.fetch,
|
|
66
136
|
httpReferer = process.env.OPENROUTER_HTTP_REFERER,
|
|
@@ -75,7 +145,7 @@ export class OpenRouterDecisionsProvider {
|
|
|
75
145
|
this.httpReferer = httpReferer;
|
|
76
146
|
this.appTitle = appTitle;
|
|
77
147
|
}
|
|
78
|
-
async decide({ state, candidates }) {
|
|
148
|
+
async decide({ state, candidates, assets }) {
|
|
79
149
|
const criteria = Object.fromEntries(candidates.map((candidate) => [candidate.id, {
|
|
80
150
|
name: candidate.name,
|
|
81
151
|
kind: candidate.kind,
|
|
@@ -84,6 +154,7 @@ export class OpenRouterDecisionsProvider {
|
|
|
84
154
|
}]));
|
|
85
155
|
return this.evaluate({
|
|
86
156
|
state,
|
|
157
|
+
assets,
|
|
87
158
|
questions: {
|
|
88
159
|
tool: {
|
|
89
160
|
type: "choice",
|
|
@@ -94,9 +165,14 @@ export class OpenRouterDecisionsProvider {
|
|
|
94
165
|
});
|
|
95
166
|
}
|
|
96
167
|
|
|
97
|
-
async evaluate({ state, questions }) {
|
|
168
|
+
async evaluate({ state, questions, assets }) {
|
|
98
169
|
if (!this.apiKey) throw new Error("OPENROUTER_API_KEY is not configured");
|
|
99
170
|
if (typeof this.fetchImpl !== "function") throw new Error("fetch is unavailable");
|
|
171
|
+
const images = normalizeImageAssets(assets);
|
|
172
|
+
const hasImages = Object.keys(images).length > 0;
|
|
173
|
+
const input = hasImages ? openRouterImageInput(state, questions, images) : { state, questions };
|
|
174
|
+
const body = JSON.stringify({ model: this.model, ...input });
|
|
175
|
+
if (Buffer.byteLength(body) > 32 * 1024 * 1024) throw new Error("OpenRouter Decisions request exceeds the 32 MiB body limit");
|
|
100
176
|
|
|
101
177
|
const response = await this.fetchImpl(this.endpoint, {
|
|
102
178
|
method: "POST",
|
|
@@ -106,18 +182,22 @@ export class OpenRouterDecisionsProvider {
|
|
|
106
182
|
...(this.httpReferer ? { "HTTP-Referer": this.httpReferer } : {}),
|
|
107
183
|
...(this.appTitle ? { "X-Title": this.appTitle } : {}),
|
|
108
184
|
},
|
|
109
|
-
body
|
|
185
|
+
body,
|
|
110
186
|
signal: AbortSignal.timeout(this.timeoutMs),
|
|
111
187
|
}).catch((error) => {
|
|
112
188
|
if (error?.name === "TimeoutError") throw new Error(`OpenRouter Decisions request timed out after ${this.timeoutMs}ms`);
|
|
189
|
+
if (hasImages) throw new Error("OpenRouter Decisions image request failed");
|
|
113
190
|
throw error;
|
|
114
191
|
});
|
|
115
192
|
|
|
116
193
|
if (!response.ok) {
|
|
117
|
-
const
|
|
118
|
-
throw new Error(`OpenRouter Decisions returned HTTP ${response.status}
|
|
194
|
+
const detail = hasImages ? "" : `: ${(await response.text()).slice(0, 240)}`;
|
|
195
|
+
throw new Error(`OpenRouter Decisions returned HTTP ${response.status}${detail}`);
|
|
119
196
|
}
|
|
120
|
-
const raw = await response.json()
|
|
197
|
+
const raw = await response.json().catch((error) => {
|
|
198
|
+
if (hasImages) throw new Error("OpenRouter Decisions image response is not valid JSON");
|
|
199
|
+
throw error;
|
|
200
|
+
});
|
|
121
201
|
if (!raw || typeof raw !== "object" || !raw.answers || typeof raw.answers !== "object") {
|
|
122
202
|
throw new Error("OpenRouter Decisions response has no answers object");
|
|
123
203
|
}
|
package/src/receipts.mjs
CHANGED
|
@@ -167,6 +167,7 @@ function replayRequest(request) {
|
|
|
167
167
|
actor_permissions: Array.isArray(request.actor_permissions) ? request.actor_permissions.filter((value) => typeof value === "string") : undefined,
|
|
168
168
|
policy: request.policy && typeof request.policy === "object" ? request.policy : {},
|
|
169
169
|
capabilities: Array.isArray(request.capabilities) ? request.capabilities.map(replayCapability) : [],
|
|
170
|
+
...(request.assets_omitted || Object.keys(request.assets ?? {}).length ? { assets_omitted: true } : {}),
|
|
170
171
|
};
|
|
171
172
|
}
|
|
172
173
|
|
package/src/route.mjs
CHANGED
|
@@ -3,11 +3,12 @@ import { projectState } from "./context-filter.mjs";
|
|
|
3
3
|
import { discoverCapabilities } from "./discovery.mjs";
|
|
4
4
|
import { decisionCandidates, DEFAULT_POLICY, deterministicCandidate, filterCapabilities, normalizeCapabilities } from "./registry.mjs";
|
|
5
5
|
import { resolveProvider } from "./providers/index.mjs";
|
|
6
|
+
import { normalizeImageAssets } from "./images.mjs";
|
|
6
7
|
|
|
7
8
|
export async function routeRequest(input, options = {}) {
|
|
8
9
|
const discovered = options.discovery ? discoverCapabilities(options.discovery) : [];
|
|
9
10
|
const request = normalizeRequest(discovered.length ? { ...input, capabilities: [...(Array.isArray(input?.capabilities) ? input.capabilities : []), ...discovered] } : input);
|
|
10
|
-
if (options.engine === "jevrouter") return routeViaJevRouter(request, options);
|
|
11
|
+
if (options.engine === "jevrouter" && !input.assets && !input.assets_omitted) return routeViaJevRouter(request, options);
|
|
11
12
|
|
|
12
13
|
const started = performance.now();
|
|
13
14
|
const effectivePolicy = { ...DEFAULT_POLICY, ...request.policy, ...(options.policy ?? {}) };
|
|
@@ -28,6 +29,14 @@ export async function routeRequest(input, options = {}) {
|
|
|
28
29
|
try {
|
|
29
30
|
provider = resolveProvider(options.provider ?? "demo", options);
|
|
30
31
|
base.provider = provider.name;
|
|
32
|
+
if (request.assets_omitted) throw new Error("visual replay requires the original images; stored screenshots are intentionally omitted");
|
|
33
|
+
const assets = normalizeImageAssets(request.assets);
|
|
34
|
+
if (Object.keys(assets).length) {
|
|
35
|
+
if (options.engine === "jevrouter" || provider.supportsImages !== true) throw new Error("provider does not support image decisions");
|
|
36
|
+
projection.state.visual_assets = Object.keys(assets);
|
|
37
|
+
projection.context_bytes = byteLength(projection.state);
|
|
38
|
+
}
|
|
39
|
+
request.assets = assets;
|
|
31
40
|
} catch (error) {
|
|
32
41
|
return fallback(base, assessments, "provider_error", error instanceof Error ? error.message : String(error), projection.state);
|
|
33
42
|
}
|
|
@@ -66,7 +75,7 @@ export async function routeRequest(input, options = {}) {
|
|
|
66
75
|
|
|
67
76
|
let raw;
|
|
68
77
|
try {
|
|
69
|
-
raw = await provider.decide({ state: projection.state, candidates: eligible });
|
|
78
|
+
raw = await provider.decide({ state: projection.state, candidates: eligible, assets: request.assets });
|
|
70
79
|
} catch (error) {
|
|
71
80
|
return fallback(base, assessments, "provider_error", error instanceof Error ? error.message : String(error), projection.state);
|
|
72
81
|
}
|
package/test/browser.test.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { test } from "node:test";
|
|
|
6
6
|
import { buildBrowserRequest, browserActions, decideBrowserStep, runBrowserFastPath } from "../src/browser.mjs";
|
|
7
7
|
import { readRoutingCases } from "../src/receipts.mjs";
|
|
8
8
|
import { routeRequest } from "../src/route.mjs";
|
|
9
|
+
import { buildExecutionReceipt, buildRoutingCase } from "../src/receipts.mjs";
|
|
9
10
|
function providerFor(choices) {
|
|
10
11
|
let call = 0;
|
|
11
12
|
return {
|
|
@@ -197,3 +198,25 @@ test("native select uses the optional host select executor", async () => {
|
|
|
197
198
|
assert.equal(selectCalls, 1);
|
|
198
199
|
assert.equal(executeCalls, 0);
|
|
199
200
|
});
|
|
201
|
+
|
|
202
|
+
test("screenshots stay out of browser text context and durable replay", async () => {
|
|
203
|
+
const imageData = "private-screen-pixels";
|
|
204
|
+
const request = buildBrowserRequest({
|
|
205
|
+
goal: "inspect the screen",
|
|
206
|
+
observation: {
|
|
207
|
+
screenshot: { format: "png", data_base64: imageData, width: 280, height: 280 },
|
|
208
|
+
targets: [{ id: "inspect", role: "button", name: "Inspect" }],
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
assert.equal(JSON.stringify(request.context).includes(imageData), false);
|
|
212
|
+
const decision = await routeRequest(request, { provider: "demo" });
|
|
213
|
+
assert.equal(decision.status, "fallback");
|
|
214
|
+
const recorded = buildRoutingCase({ request, decision });
|
|
215
|
+
assert.equal(JSON.stringify(recorded).includes(imageData), false);
|
|
216
|
+
assert.equal(recorded.request.assets_omitted, true);
|
|
217
|
+
assert.equal(typeof recorded.request.context.browser.observation.screenshot.sha256, "string");
|
|
218
|
+
const execution = buildExecutionReceipt({ request, decision, host: {
|
|
219
|
+
status: "completed", result: { observation: { screenshot: { format: "png", data_base64: imageData } } },
|
|
220
|
+
} });
|
|
221
|
+
assert.equal(JSON.stringify(execution).includes(imageData), false);
|
|
222
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import { decideComputerStep } from "../src/computer.mjs";
|
|
5
|
+
import { PNG } from "./image-fixture.mjs";
|
|
6
|
+
|
|
7
|
+
const SCREENSHOT = { format: "png", data_base64: PNG, width: 400, height: 300 };
|
|
8
|
+
const SCROLL = { id: "scroll-down", operation: "scroll", args: { direction: "down", amount: 120 } };
|
|
9
|
+
const CLICK = { id: "save", operation: "click", target: "the Save button", args: { button: "left" }, risk: "low" };
|
|
10
|
+
const BOX = { left: 0.2, right: 0.6, top: 0.1, bottom: 0.5 };
|
|
11
|
+
|
|
12
|
+
function input(overrides = {}) {
|
|
13
|
+
return { goal: "Inspect the application", observation: { screenshot: SCREENSHOT, visible_text: "Save", application: "Editor" }, actions: [SCROLL], actor_permissions: ["computer"], ...overrides };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function grounding(visibility = 0.99, confidence = 0.95, box = BOX) {
|
|
17
|
+
return { answers: { target_visible: { type: "noul", noul: visibility }, target_box: { type: "box", box, confidence } } };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function provider({ choice, confidence = 1, result = grounding(), error = false } = {}) {
|
|
21
|
+
return {
|
|
22
|
+
name: "computer-test", supportsImages: true, choices: [], groundings: [],
|
|
23
|
+
async decide(request) {
|
|
24
|
+
this.choices.push(request);
|
|
25
|
+
const selected = choice ?? request.candidates[0].id;
|
|
26
|
+
return { answers: { tool: { type: "choice", choice: selected, confidence, probabilities: { [selected]: confidence } } } };
|
|
27
|
+
},
|
|
28
|
+
async evaluate(request) {
|
|
29
|
+
this.groundings.push(request);
|
|
30
|
+
if (error) throw new Error("offline");
|
|
31
|
+
return result;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function assertFallback(result, type) {
|
|
37
|
+
assert.equal(result.action, null);
|
|
38
|
+
assert.equal(result.decision.status, "fallback");
|
|
39
|
+
if (type) assert.equal(result.decision.fallback.type, type);
|
|
40
|
+
assert.equal(result.decision.execution.enabled, false);
|
|
41
|
+
assert.equal(result.decision.correlation_id, result.decision.receipt.correlation_id);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
test("computer advice is opt-in and an explicit disable overrides the environment", async () => {
|
|
45
|
+
const previous = process.env.JEV_COMPUTER_USE;
|
|
46
|
+
const engine = provider();
|
|
47
|
+
try {
|
|
48
|
+
delete process.env.JEV_COMPUTER_USE;
|
|
49
|
+
assertFallback(await decideComputerStep(input(), { provider: engine }), "computer_use_disabled");
|
|
50
|
+
process.env.JEV_COMPUTER_USE = "1";
|
|
51
|
+
assertFallback(await decideComputerStep(input(), { enabled: false, provider: engine }), "computer_use_disabled");
|
|
52
|
+
assert.equal(engine.choices.length, 0);
|
|
53
|
+
const enabled = await decideComputerStep(input(), { provider: engine });
|
|
54
|
+
assert.equal(enabled.decision.status, "selected");
|
|
55
|
+
assert.deepEqual(enabled.action.args, SCROLL.args);
|
|
56
|
+
assert.deepEqual(enabled.decision.execution, { enabled: false, status: "not_started" });
|
|
57
|
+
} finally {
|
|
58
|
+
if (previous === undefined) delete process.env.JEV_COMPUTER_USE;
|
|
59
|
+
else process.env.JEV_COMPUTER_USE = previous;
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("malformed screenshots and non-closed action arguments fail open before provider calls", async () => {
|
|
64
|
+
const engine = provider();
|
|
65
|
+
for (const candidate of [
|
|
66
|
+
input({ observation: {} }),
|
|
67
|
+
input({ observation: { screenshot: { ...SCREENSHOT, data_base64: "file:///screen.png" } } }),
|
|
68
|
+
input({ observation: { screenshot: { ...SCREENSHOT, data_base64: Buffer.from("not an image").toString("base64") } } }),
|
|
69
|
+
input({ observation: { screenshot: { ...SCREENSHOT, width: 800 } } }),
|
|
70
|
+
input({ actions: [{ ...SCROLL, operation: "shell", args: { command: "anything" } }] }),
|
|
71
|
+
input({ actions: [{ ...SCROLL, args: { direction: "down", command: "anything" } }] }),
|
|
72
|
+
input({ actions: [{ ...CLICK, target: undefined }] }),
|
|
73
|
+
input({ actions: [{ ...CLICK, args: { x: 400, y: 20 } }] }),
|
|
74
|
+
input({ actions: [{ ...CLICK, args: { x: 20 } }] }),
|
|
75
|
+
input({ actions: [SCROLL, SCROLL] }),
|
|
76
|
+
input({ actions: Array.from({ length: 33 }, (_, index) => ({ ...SCROLL, id: `scroll-${index}` })) }),
|
|
77
|
+
input({ policy: { min_target_visibility: Number.NaN } }),
|
|
78
|
+
input({ policy: { min_box_confidence: 0 } }),
|
|
79
|
+
]) assertFallback(await decideComputerStep(candidate, { enabled: true, provider: engine }), "invalid_input");
|
|
80
|
+
assert.equal(engine.choices.length, 0);
|
|
81
|
+
assert.equal(engine.groundings.length, 0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("registry filters enforce explicit computer permission, availability, extra permissions, and risk", async () => {
|
|
85
|
+
const engine = provider();
|
|
86
|
+
for (const candidate of [
|
|
87
|
+
input({ actor_permissions: undefined }),
|
|
88
|
+
input({ actor_permissions: [] }),
|
|
89
|
+
input({ actions: [{ ...SCROLL, permissions: ["administrator"] }] }),
|
|
90
|
+
input({ actions: [{ ...SCROLL, available: false }] }),
|
|
91
|
+
input({ actions: [{ ...SCROLL, availability: { available: false, reason: "offline" } }] }),
|
|
92
|
+
input({ actions: [{ ...SCROLL, risk: "high" }], policy: { max_risk: "low" } }),
|
|
93
|
+
]) {
|
|
94
|
+
const result = await decideComputerStep(candidate, { enabled: true, provider: engine });
|
|
95
|
+
assert.equal(result.decision.status, "no_decision");
|
|
96
|
+
assert.equal(result.action, null);
|
|
97
|
+
assert.equal(result.decision.execution.enabled, false);
|
|
98
|
+
assert.ok(result.decision.candidates.every((item) => item.filtered));
|
|
99
|
+
}
|
|
100
|
+
assert.equal(engine.choices.length, 0);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("consequential actions require confirmation even at low risk", async () => {
|
|
104
|
+
for (const action of [
|
|
105
|
+
{ id: "click", operation: "click", args: { x: 17, y: 29, button: "right" }, risk: "low" },
|
|
106
|
+
{ id: "type", operation: "type", args: { text: "host supplied text" }, risk: "low" },
|
|
107
|
+
{ id: "key", operation: "key", args: { keys: ["Control", "S"] }, risk: "low" },
|
|
108
|
+
{ ...SCROLL, consequential: true },
|
|
109
|
+
{ ...SCROLL, requires_confirmation: true },
|
|
110
|
+
{ ...SCROLL, policy: { requires_confirmation: true } },
|
|
111
|
+
]) {
|
|
112
|
+
const engine = provider();
|
|
113
|
+
const result = await decideComputerStep(input({ actions: [action], policy: { confirmation_risk_levels: [] } }), { enabled: true, provider: engine });
|
|
114
|
+
assert.equal(result.decision.status, "needs_confirmation");
|
|
115
|
+
assert.equal(result.decision.candidates[0].requires_confirmation, true);
|
|
116
|
+
assert.equal(result.decision.execution.enabled, false);
|
|
117
|
+
assert.equal(engine.groundings.length, 0);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("only the chosen described click is grounded, once, and mapped to bound screenshot pixels", async () => {
|
|
122
|
+
const engine = provider({ choice: "save" });
|
|
123
|
+
const result = await decideComputerStep(input({ actions: [SCROLL, CLICK, { ...CLICK, id: "other", target: "Cancel button" }] }), { enabled: true, provider: engine });
|
|
124
|
+
assert.equal(result.decision.status, "needs_confirmation");
|
|
125
|
+
assert.deepEqual(result.action.args, { button: "left", x: 160, y: 90 });
|
|
126
|
+
assert.equal(result.action.target, CLICK.target);
|
|
127
|
+
assert.deepEqual(result.action.grounding, { box: BOX, visibility: 0.99, confidence: 0.95, coordinate_space: "screenshot_pixels" });
|
|
128
|
+
assert.equal(result.action.screenshot.sha256, `sha256:${createHash("sha256").update(Buffer.from(PNG, "base64")).digest("hex")}`);
|
|
129
|
+
assert.equal(result.action.screenshot.requires_current, true);
|
|
130
|
+
assert.equal(result.action.screenshot.width, 400);
|
|
131
|
+
assert.equal(result.action.screenshot.height, 300);
|
|
132
|
+
assert.equal(engine.groundings.length, 1);
|
|
133
|
+
assert.equal(JSON.stringify(result.request.context).includes(PNG), false);
|
|
134
|
+
assert.equal(JSON.stringify(result.decision).includes(PNG), false);
|
|
135
|
+
assert.deepEqual(CLICK.args, { button: "left" });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("absent, malformed, and uncertain grounding yields no action while retaining routing correlation", async () => {
|
|
139
|
+
const badResults = [
|
|
140
|
+
[grounding(0), "target_not_visible"],
|
|
141
|
+
[grounding(0.899), "target_not_visible"],
|
|
142
|
+
[grounding(0.99, 0.799), "low_grounding_confidence"],
|
|
143
|
+
[grounding(Number.NaN), "malformed_grounding"],
|
|
144
|
+
[grounding(Infinity), "malformed_grounding"],
|
|
145
|
+
[grounding("0.99"), "malformed_grounding"],
|
|
146
|
+
[grounding(0.99, Infinity), "malformed_grounding"],
|
|
147
|
+
[{ answers: { target_box: grounding().answers.target_box } }, "malformed_grounding"],
|
|
148
|
+
[grounding(0.99, 0.95, { ...BOX, left: -0.1 }), "malformed_grounding"],
|
|
149
|
+
[grounding(0.99, 0.95, { ...BOX, right: 1.1 }), "malformed_grounding"],
|
|
150
|
+
[grounding(0.99, 0.95, { ...BOX, left: BOX.right }), "malformed_grounding"],
|
|
151
|
+
[grounding(0.99, 0.95, { ...BOX, top: BOX.bottom + 0.1 }), "malformed_grounding"],
|
|
152
|
+
[grounding(0.99, 0.95, { ...BOX, bottom: Number.NaN }), "malformed_grounding"],
|
|
153
|
+
];
|
|
154
|
+
for (const [raw, type] of badResults) {
|
|
155
|
+
const result = await decideComputerStep(input({ actions: [CLICK] }), { enabled: true, provider: provider({ result: raw }) });
|
|
156
|
+
assertFallback(result, type);
|
|
157
|
+
assert.equal(result.decision.selected, CLICK.id);
|
|
158
|
+
assert.equal(result.decision.receipt.provider, "computer-test");
|
|
159
|
+
assert.equal(result.decision.receipt.candidate_count, 1);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("host grounding thresholds are inclusive and edge pixels remain inside the screenshot", async () => {
|
|
164
|
+
const engine = provider({ result: grounding(0.95, 0.9, { left: 0.999, right: 1, top: 0.999, bottom: 1 }) });
|
|
165
|
+
const result = await decideComputerStep(input({ actions: [CLICK], policy: { min_target_visibility: 0.95, min_box_confidence: 0.9 } }), { enabled: true, provider: engine });
|
|
166
|
+
assert.deepEqual(result.action.args, { button: "left", x: 399, y: 299 });
|
|
167
|
+
const stricter = await decideComputerStep(input({ actions: [CLICK] }), { enabled: true, provider: engine, policy: { min_target_visibility: 0.96 } });
|
|
168
|
+
assertFallback(stricter, "target_not_visible");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("route fallback, provider failures, unsupported images, and unknown selections never leak actions", async () => {
|
|
172
|
+
assertFallback(await decideComputerStep(input(), { enabled: true, provider: provider({ confidence: 0.2 }) }), "low_confidence");
|
|
173
|
+
assertFallback(await decideComputerStep(input(), { enabled: true, provider: provider({ choice: "not-host-supplied" }) }), "invalid_selection");
|
|
174
|
+
assertFallback(await decideComputerStep(input({ actions: [CLICK] }), { enabled: true, provider: provider({ error: true }) }), "provider_error");
|
|
175
|
+
const unavailable = provider();
|
|
176
|
+
unavailable.supportsImages = false;
|
|
177
|
+
assertFallback(await decideComputerStep(input(), { enabled: true, provider: unavailable }), "unsupported_images");
|
|
178
|
+
assert.equal(unavailable.choices.length, 0);
|
|
179
|
+
const noGrounding = provider();
|
|
180
|
+
delete noGrounding.evaluate;
|
|
181
|
+
assertFallback(await decideComputerStep(input({ actions: [CLICK] }), { enabled: true, provider: noGrounding }), "grounding_unavailable");
|
|
182
|
+
const broken = provider();
|
|
183
|
+
broken.decide = async () => { throw new Error("offline"); };
|
|
184
|
+
assertFallback(await decideComputerStep(input(), { enabled: true, provider: broken }), "provider_error");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("handoff is advisory and nonselected described targets are never grounded", async () => {
|
|
188
|
+
const engine = provider({ choice: "handoff" });
|
|
189
|
+
const action = { id: "handoff", operation: "handoff", args: { reason: "Host planning needed" }, available: true, risk: "low" };
|
|
190
|
+
const result = await decideComputerStep(input({ actions: [CLICK, action] }), { enabled: true, provider: engine });
|
|
191
|
+
assert.equal(result.decision.status, "selected");
|
|
192
|
+
assert.equal(result.action.operation, "handoff");
|
|
193
|
+
assert.equal(engine.groundings.length, 0);
|
|
194
|
+
assert.equal(result.decision.execution.enabled, false);
|
|
195
|
+
});
|
|
@@ -3,9 +3,10 @@ import { mkdtemp, readFile } from "node:fs/promises";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
6
7
|
import test from "node:test";
|
|
7
8
|
|
|
8
|
-
const adapter = new URL("../src/hermes-adapter.mjs", import.meta.url)
|
|
9
|
+
const adapter = fileURLToPath(new URL("../src/hermes-adapter.mjs", import.meta.url));
|
|
9
10
|
|
|
10
11
|
function run(lines, replayPath) {
|
|
11
12
|
return new Promise((resolve, reject) => {
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
// Complete 400x300 still RGB PNG, generated with zlib and valid chunk CRCs.
|
|
2
|
+
export const PNG = "iVBORw0KGgoAAAANSUhEUgAAAZAAAAEsCAIAAABi1XKVAAABdElEQVR4nO3BMQEAAADCoPVPbQwfoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD+Bn+3AAEkkD9cAAAAAElFTkSuQmCC";
|