@awak-app/simy-cli 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -5
- package/package.json +14 -4
- package/src/agent.js +209 -24
- package/src/backend-executable.js +92 -6
- package/src/console/app.js +9 -7
- package/src/console/index.js +5 -4
- package/src/desktop-executor.js +137 -0
- package/src/index.js +3 -3
- package/src/local-attachments.js +2 -2
- package/src/orchestrator/audit.js +101 -7
- package/src/orchestrator/budget.js +112 -0
- package/src/orchestrator/contract.js +175 -27
- package/src/orchestrator/independent-audit.js +18 -4
- package/src/orchestrator/index.js +1 -0
- package/src/orchestrator/instruction.js +41 -5
- package/src/orchestrator/loop.js +185 -7
- package/src/orchestrator/presentation.js +2 -2
- package/src/orchestrator/problem-solving.js +129 -0
- package/src/orchestrator/recovery.js +271 -0
- package/src/orchestrator/result.js +2 -0
- package/src/orchestrator/retry.js +140 -0
- package/src/orchestrator/shared.js +87 -5
- package/src/provider-stream.js +17 -0
- package/src/repository-inventory.js +117 -0
- package/src/runner.js +128 -108
package/src/console/app.js
CHANGED
|
@@ -19,7 +19,7 @@ const TERMINAL_STATES = new Set([
|
|
|
19
19
|
]);
|
|
20
20
|
const NEW_TASK = "__new_task__";
|
|
21
21
|
|
|
22
|
-
export function
|
|
22
|
+
export function AgenticLoopConsole({ agent, onQuit = () => {} }) {
|
|
23
23
|
const { exit } = useApp();
|
|
24
24
|
const { stdout } = useStdout();
|
|
25
25
|
const [terminal, setTerminal] = useState(() => terminalSize(stdout));
|
|
@@ -138,7 +138,7 @@ export function CodingLoopConsole({ agent, onQuit = () => {} }) {
|
|
|
138
138
|
}
|
|
139
139
|
if (!selectedRun) {
|
|
140
140
|
if (command.type !== "guidance") {
|
|
141
|
-
setNotice("That command requires a selected
|
|
141
|
+
setNotice("That command requires a selected Agentic Loop run.");
|
|
142
142
|
return;
|
|
143
143
|
}
|
|
144
144
|
setBusy(true);
|
|
@@ -175,12 +175,12 @@ export function CodingLoopConsole({ agent, onQuit = () => {} }) {
|
|
|
175
175
|
setNotice("Guidance queued for the next executor handoff.");
|
|
176
176
|
} else {
|
|
177
177
|
await agent.controls.continue(selectedRun, command.message);
|
|
178
|
-
setNotice("Human guidance accepted. The
|
|
178
|
+
setNotice("Human guidance accepted. The agentic loop is continuing.");
|
|
179
179
|
}
|
|
180
180
|
break;
|
|
181
181
|
case "decision":
|
|
182
182
|
await agent.controls.applyDecision(selectedRun, command);
|
|
183
|
-
setNotice("Human decision recorded. The
|
|
183
|
+
setNotice("Human decision recorded. The agentic loop is continuing.");
|
|
184
184
|
break;
|
|
185
185
|
case "recheck":
|
|
186
186
|
await agent.controls.recheck(selectedRun);
|
|
@@ -259,7 +259,7 @@ export function CodingLoopConsole({ agent, onQuit = () => {} }) {
|
|
|
259
259
|
await agent.controls.selectRepository(selectedRun, match);
|
|
260
260
|
setDetailOpen(true);
|
|
261
261
|
setDetailScrollFromEnd(0);
|
|
262
|
-
setNotice(`Authorized ${match.repository}. The same
|
|
262
|
+
setNotice(`Authorized ${match.repository}. The same Agentic Loop run is continuing.`);
|
|
263
263
|
} else if (selectedRun) {
|
|
264
264
|
setNotice(
|
|
265
265
|
`${selectedRun.request.repository} was not found under ${result.root}. Run /scan <path> for another authorized root.`,
|
|
@@ -290,7 +290,7 @@ export function CodingLoopConsole({ agent, onQuit = () => {} }) {
|
|
|
290
290
|
setRepositoryPicker(null);
|
|
291
291
|
setDetailOpen(true);
|
|
292
292
|
setDetailScrollFromEnd(0);
|
|
293
|
-
setNotice(`Authorized ${selected.repository}. The same
|
|
293
|
+
setNotice(`Authorized ${selected.repository}. The same Agentic Loop run is continuing.`);
|
|
294
294
|
} else {
|
|
295
295
|
const verified = await agent.controls.selectRepository(null, selected);
|
|
296
296
|
setDraft((current) => ({
|
|
@@ -461,7 +461,7 @@ export function CodingLoopConsole({ agent, onQuit = () => {} }) {
|
|
|
461
461
|
if (confirmStop) void execute("/stop");
|
|
462
462
|
else {
|
|
463
463
|
setConfirmStop(true);
|
|
464
|
-
setNotice("Press x again to stop the selected
|
|
464
|
+
setNotice("Press x again to stop the selected Agentic Loop run.");
|
|
465
465
|
}
|
|
466
466
|
return;
|
|
467
467
|
}
|
|
@@ -534,6 +534,8 @@ export function CodingLoopConsole({ agent, onQuit = () => {} }) {
|
|
|
534
534
|
});
|
|
535
535
|
}
|
|
536
536
|
|
|
537
|
+
export const CodingLoopConsole = AgenticLoopConsole;
|
|
538
|
+
|
|
537
539
|
function Header({ agent, runCount }) {
|
|
538
540
|
return jsxs(Box, {
|
|
539
541
|
borderStyle: "single",
|
package/src/console/index.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { jsx } from "react/jsx-runtime";
|
|
2
2
|
import { render } from "ink";
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { AgenticLoopConsole } from "./app.js";
|
|
5
5
|
|
|
6
|
-
export async function
|
|
6
|
+
export async function runAgenticLoopConsole(agent, options = {}) {
|
|
7
7
|
const instance = render(
|
|
8
|
-
jsx(
|
|
8
|
+
jsx(AgenticLoopConsole, {
|
|
9
9
|
agent,
|
|
10
10
|
onQuit: options.onQuit,
|
|
11
11
|
}),
|
|
@@ -21,5 +21,6 @@ export async function runCodingLoopConsole(agent, options = {}) {
|
|
|
21
21
|
await instance.waitUntilExit();
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
export
|
|
24
|
+
export const runCodingLoopConsole = runAgenticLoopConsole;
|
|
25
|
+
export { AgenticLoopConsole, CodingLoopConsole } from "./app.js";
|
|
25
26
|
export { consoleHelpLines, parseConsoleCommand } from "./commands.js";
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
BACKEND_VERSION_POLICIES,
|
|
5
|
+
inspectBackendExecutable,
|
|
6
|
+
} from "./backend-executable.js";
|
|
7
|
+
|
|
8
|
+
export const DESKTOP_EXECUTION_TARGET = "desktop";
|
|
9
|
+
export const DESKTOP_EXECUTION_LABEL = "Your Desktop";
|
|
10
|
+
|
|
11
|
+
export function normalizeDesktopExecutionTarget(value) {
|
|
12
|
+
const target = String(value ?? "").trim().toLowerCase();
|
|
13
|
+
if (!target || target === DESKTOP_EXECUTION_TARGET) return DESKTOP_EXECUTION_TARGET;
|
|
14
|
+
const error = new Error(
|
|
15
|
+
`SIMY CLI runs only on ${DESKTOP_EXECUTION_LABEL}; execution_target must be desktop.`,
|
|
16
|
+
);
|
|
17
|
+
error.code = "unsupported_execution_target";
|
|
18
|
+
error.executionTarget = target;
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function inspectDesktopExecutor(backend, options = {}) {
|
|
23
|
+
const provider = normalizeBackend(backend);
|
|
24
|
+
const inspection = await inspectBackendExecutable(provider, options);
|
|
25
|
+
return {
|
|
26
|
+
execution_target: DESKTOP_EXECUTION_TARGET,
|
|
27
|
+
execution_label: DESKTOP_EXECUTION_LABEL,
|
|
28
|
+
provider_label: BACKEND_VERSION_POLICIES[provider].label,
|
|
29
|
+
...inspection,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function desktopExecutorCapability(inspection) {
|
|
34
|
+
return {
|
|
35
|
+
execution_target: DESKTOP_EXECUTION_TARGET,
|
|
36
|
+
execution_label: DESKTOP_EXECUTION_LABEL,
|
|
37
|
+
backend: inspection.backend,
|
|
38
|
+
provider_label: inspection.provider_label,
|
|
39
|
+
status: inspection.status,
|
|
40
|
+
available: inspection.available,
|
|
41
|
+
compatible: inspection.compatible,
|
|
42
|
+
installed_version: inspection.installed_version,
|
|
43
|
+
minimum_version: inspection.minimum_version,
|
|
44
|
+
update_command: inspection.update_command,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function resolveDesktopExecutorCommand({
|
|
49
|
+
backend,
|
|
50
|
+
instruction,
|
|
51
|
+
repositoryPath,
|
|
52
|
+
environment = process.env,
|
|
53
|
+
spawnImpl = spawn,
|
|
54
|
+
inspectionOptions = {},
|
|
55
|
+
} = {}) {
|
|
56
|
+
const provider = normalizeBackend(backend);
|
|
57
|
+
const target = normalizeDesktopExecutionTarget(DESKTOP_EXECUTION_TARGET);
|
|
58
|
+
const override =
|
|
59
|
+
provider === "claude" ? environment.SIMY_CLAUDE_COMMAND : environment.SIMY_CODEX_COMMAND;
|
|
60
|
+
if (override) {
|
|
61
|
+
return {
|
|
62
|
+
...shellCommand(override, repositoryPath, instruction, spawnImpl),
|
|
63
|
+
backend: provider,
|
|
64
|
+
execution_target: target,
|
|
65
|
+
verification: "explicit_command_override",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const inspection = await inspectDesktopExecutor(provider, {
|
|
70
|
+
...inspectionOptions,
|
|
71
|
+
spawnImpl: inspectionOptions.spawnImpl ?? spawnImpl,
|
|
72
|
+
});
|
|
73
|
+
if (!inspection.compatible || !inspection.executable) {
|
|
74
|
+
throw desktopExecutorUnavailableError(inspection);
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
bin: inspection.executable,
|
|
78
|
+
args:
|
|
79
|
+
provider === "claude"
|
|
80
|
+
? claudeBackendArgs(instruction)
|
|
81
|
+
: ["exec", "--json", instruction],
|
|
82
|
+
env: {},
|
|
83
|
+
spawn: spawnImpl,
|
|
84
|
+
backend: provider,
|
|
85
|
+
execution_target: target,
|
|
86
|
+
verification: "compatible_version",
|
|
87
|
+
installed_version: inspection.installed_version,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function claudeBackendArgs(instruction) {
|
|
92
|
+
return [
|
|
93
|
+
"-p",
|
|
94
|
+
instruction,
|
|
95
|
+
"--output-format",
|
|
96
|
+
"stream-json",
|
|
97
|
+
"--verbose",
|
|
98
|
+
// The Agentic Loop is scoped to a verified checkout and explicitly approved
|
|
99
|
+
// by the user. Print mode cannot display permission prompts, so edits would
|
|
100
|
+
// otherwise be silently unavailable to the desktop executor.
|
|
101
|
+
"--dangerously-skip-permissions",
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function normalizeBackend(backend) {
|
|
106
|
+
if (backend === "codex" || backend === "claude") return backend;
|
|
107
|
+
throw new Error("Desktop executor backend must be codex or claude.");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function desktopExecutorUnavailableError(inspection) {
|
|
111
|
+
const provider = inspection.provider_label;
|
|
112
|
+
let message;
|
|
113
|
+
if (inspection.status === "outdated") {
|
|
114
|
+
message = `${provider} ${inspection.installed_version} is too old for ${DESKTOP_EXECUTION_LABEL}. Update to ${inspection.minimum_version} or newer: ${inspection.update_command}`;
|
|
115
|
+
} else if (inspection.status === "unknown_version") {
|
|
116
|
+
message = `${provider} is installed on ${DESKTOP_EXECUTION_LABEL}, but its version could not be verified. Update it and try again: ${inspection.update_command}`;
|
|
117
|
+
} else {
|
|
118
|
+
message = `${provider} is not installed on ${DESKTOP_EXECUTION_LABEL}. Install or update it before starting this run: ${inspection.update_command}`;
|
|
119
|
+
}
|
|
120
|
+
const error = new Error(message);
|
|
121
|
+
error.code = `desktop_executor_${inspection.status}`;
|
|
122
|
+
error.inspection = desktopExecutorCapability(inspection);
|
|
123
|
+
return error;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function shellCommand(command, cwd, instruction, spawnImpl) {
|
|
127
|
+
return {
|
|
128
|
+
bin: process.platform === "win32" ? "cmd.exe" : "sh",
|
|
129
|
+
args: process.platform === "win32" ? ["/d", "/s", "/c", command] : ["-lc", command],
|
|
130
|
+
cwd,
|
|
131
|
+
env: {
|
|
132
|
+
SIMY_AGENTIC_LOOP_REQUIREMENT: instruction,
|
|
133
|
+
SIMY_CODING_LOOP_REQUIREMENT: instruction,
|
|
134
|
+
},
|
|
135
|
+
spawn: spawnImpl,
|
|
136
|
+
};
|
|
137
|
+
}
|
package/src/index.js
CHANGED
|
@@ -18,7 +18,7 @@ if (args.has("--help") || args.has("-h")) {
|
|
|
18
18
|
|
|
19
19
|
Options:
|
|
20
20
|
--daemon, --deamon Run detached in the background
|
|
21
|
-
--no-tui Disable the interactive
|
|
21
|
+
--no-tui Disable the interactive Agentic Loop console
|
|
22
22
|
--no-open Do not open the browser when authorization is required
|
|
23
23
|
--port <port> Bind a specific localhost port
|
|
24
24
|
--host <url> Connect to a SIMY Web origin (default: ${DEFAULT_WEB_ORIGIN})
|
|
@@ -53,9 +53,9 @@ if (agent.loginUrl && !args.has("--no-open")) {
|
|
|
53
53
|
await openAuthorizationUrl(agent.loginUrl);
|
|
54
54
|
}
|
|
55
55
|
if (interactive) {
|
|
56
|
-
const {
|
|
56
|
+
const { runAgenticLoopConsole } = await import("./console/index.js");
|
|
57
57
|
try {
|
|
58
|
-
await
|
|
58
|
+
await runAgenticLoopConsole(agent);
|
|
59
59
|
} finally {
|
|
60
60
|
await closeServer(agent.server);
|
|
61
61
|
}
|
package/src/local-attachments.js
CHANGED
|
@@ -7,7 +7,7 @@ export const MAX_ATTACHMENT_COUNT = 5;
|
|
|
7
7
|
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
|
|
8
8
|
export const MAX_ATTACHMENTS_TOTAL_BYTES = 50 * 1024 * 1024;
|
|
9
9
|
const RUN_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
10
|
-
const SAFE_RUN_ID = /^
|
|
10
|
+
const SAFE_RUN_ID = /^(?:agentic_loop|coding_loop)_[A-Za-z0-9_-]+$/;
|
|
11
11
|
const ALLOWED_EXACT_MIME_TYPES = new Set([
|
|
12
12
|
"application/json",
|
|
13
13
|
"application/octet-stream",
|
|
@@ -32,7 +32,7 @@ export function runsRoot(override) {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
export async function stageRunAttachments({ runId, attachments, manifest, root }) {
|
|
35
|
-
if (!SAFE_RUN_ID.test(runId)) throw new Error("invalid
|
|
35
|
+
if (!SAFE_RUN_ID.test(runId)) throw new Error("invalid agentic loop run_id");
|
|
36
36
|
if (!Array.isArray(attachments) || attachments.length === 0) return [];
|
|
37
37
|
if (attachments.length > MAX_ATTACHMENT_COUNT) {
|
|
38
38
|
throw new Error(`at most ${MAX_ATTACHMENT_COUNT} attachments are allowed`);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { stat } from "node:fs/promises";
|
|
1
|
+
import { open, readdir, stat } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
4
|
import {
|
|
@@ -7,8 +7,9 @@ import {
|
|
|
7
7
|
hasAllowedPrefix,
|
|
8
8
|
stringArray,
|
|
9
9
|
} from "./shared.js";
|
|
10
|
+
import { evaluateRetryProblemSolving } from "./problem-solving.js";
|
|
10
11
|
|
|
11
|
-
export async function auditAttempt(charter, attempt) {
|
|
12
|
+
export async function auditAttempt(charter, attempt, { previousAttempt = null } = {}) {
|
|
12
13
|
const checks = [];
|
|
13
14
|
const addCheck = (id, label, passed, detail, severity = "blocker", repairability = "auto") => {
|
|
14
15
|
checks.push({ id, label, passed, severity, detail, repairability, evidence_refs: [] });
|
|
@@ -112,6 +113,12 @@ export async function auditAttempt(charter, attempt) {
|
|
|
112
113
|
: "No committed local evidence artifacts reported.",
|
|
113
114
|
);
|
|
114
115
|
|
|
116
|
+
const retryProblemSolving = evaluateRetryProblemSolving(attempt, previousAttempt);
|
|
117
|
+
for (const check of retryProblemSolving.checks) {
|
|
118
|
+
addCheck(check.id, check.label, check.passed, check.detail);
|
|
119
|
+
}
|
|
120
|
+
attempt.retry_problem_solving = retryProblemSolving;
|
|
121
|
+
|
|
115
122
|
const evidenceRequired = charter.expected_evidence.includes("ui_evidence_path");
|
|
116
123
|
const evidenceArtifact = await inspectEvidence(charter, attempt.ui_evidence_path);
|
|
117
124
|
if (evidenceRequired || attempt.ui_evidence_path) {
|
|
@@ -126,6 +133,16 @@ export async function auditAttempt(charter, attempt) {
|
|
|
126
133
|
evidenceArtifact?.summary || "Local evidence path missing.",
|
|
127
134
|
);
|
|
128
135
|
}
|
|
136
|
+
if (charter.evidence_policy?.browser_required) {
|
|
137
|
+
addCheck(
|
|
138
|
+
"browser_visual_evidence_present",
|
|
139
|
+
"Browser screenshot or video evidence is present",
|
|
140
|
+
Boolean(evidenceArtifact?.visual_file_count > 0),
|
|
141
|
+
evidenceArtifact?.visual_file_count > 0
|
|
142
|
+
? `${evidenceArtifact.visual_file_count} screenshot or video artifact(s) found.`
|
|
143
|
+
: "UI and browser-extension work requires a real screenshot or video; a path, text file, or empty directory is not sufficient.",
|
|
144
|
+
);
|
|
145
|
+
}
|
|
129
146
|
|
|
130
147
|
const findings = checks
|
|
131
148
|
.filter((check) => !check.passed)
|
|
@@ -301,17 +318,94 @@ async function inspectEvidence(charter, evidencePath) {
|
|
|
301
318
|
const underRoot = Boolean(
|
|
302
319
|
root && (resolved === root || resolved.startsWith(`${root}${path.sep}`)),
|
|
303
320
|
);
|
|
321
|
+
const directoryEvidence = fileStat?.isDirectory()
|
|
322
|
+
? await scanEvidenceDirectory(resolved)
|
|
323
|
+
: null;
|
|
324
|
+
const directKind = fileStat?.isFile() ? evidenceKind(resolved) : "unknown";
|
|
325
|
+
const visualFileCount = fileStat?.isFile()
|
|
326
|
+
? (await isVisualEvidenceFile(resolved))
|
|
327
|
+
? 1
|
|
328
|
+
: 0
|
|
329
|
+
: directoryEvidence?.visualFileCount ?? 0;
|
|
330
|
+
const sizeBytes = fileStat?.isDirectory()
|
|
331
|
+
? directoryEvidence?.sizeBytes ?? 0
|
|
332
|
+
: fileStat?.size ?? 0;
|
|
304
333
|
return {
|
|
305
334
|
path: resolved,
|
|
306
|
-
kind: fileStat?.isDirectory() ? "directory" :
|
|
335
|
+
kind: fileStat?.isDirectory() ? "directory" : directKind,
|
|
307
336
|
exists: Boolean(fileStat),
|
|
308
337
|
under_required_root: underRoot,
|
|
309
|
-
size_bytes:
|
|
310
|
-
file_count: fileStat?.isFile() ? 1 : 0,
|
|
338
|
+
size_bytes: sizeBytes,
|
|
339
|
+
file_count: fileStat?.isFile() ? 1 : directoryEvidence?.fileCount ?? 0,
|
|
340
|
+
visual_file_count: visualFileCount,
|
|
311
341
|
git_tracked: false,
|
|
312
342
|
summary:
|
|
313
|
-
fileStat && underRoot &&
|
|
314
|
-
?
|
|
343
|
+
fileStat && underRoot && sizeBytes > 0
|
|
344
|
+
? `Evidence exists under the required local root; ${visualFileCount} visual artifact(s) found.`
|
|
315
345
|
: "Evidence is missing, empty, or outside the required local root.",
|
|
316
346
|
};
|
|
317
347
|
}
|
|
348
|
+
|
|
349
|
+
async function scanEvidenceDirectory(directory) {
|
|
350
|
+
const pending = [directory];
|
|
351
|
+
let fileCount = 0;
|
|
352
|
+
let visualFileCount = 0;
|
|
353
|
+
let sizeBytes = 0;
|
|
354
|
+
while (pending.length > 0 && fileCount < 500) {
|
|
355
|
+
const current = pending.shift();
|
|
356
|
+
if (!current) break;
|
|
357
|
+
let entries;
|
|
358
|
+
try {
|
|
359
|
+
entries = await readdir(current, { withFileTypes: true });
|
|
360
|
+
} catch {
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
for (const entry of entries) {
|
|
364
|
+
if (entry.isSymbolicLink()) continue;
|
|
365
|
+
const candidate = path.join(current, entry.name);
|
|
366
|
+
if (entry.isDirectory()) {
|
|
367
|
+
pending.push(candidate);
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
if (!entry.isFile()) continue;
|
|
371
|
+
fileCount += 1;
|
|
372
|
+
if (await isVisualEvidenceFile(candidate)) visualFileCount += 1;
|
|
373
|
+
try {
|
|
374
|
+
sizeBytes += (await stat(candidate)).size;
|
|
375
|
+
} catch {
|
|
376
|
+
// A file disappearing during inspection is simply not counted toward its size.
|
|
377
|
+
}
|
|
378
|
+
if (fileCount >= 500) break;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return { fileCount, visualFileCount, sizeBytes };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function isVisualEvidenceFile(filePath) {
|
|
385
|
+
if (!["image", "video"].includes(evidenceKind(filePath))) return false;
|
|
386
|
+
const header = Buffer.alloc(16);
|
|
387
|
+
let handle;
|
|
388
|
+
try {
|
|
389
|
+
handle = await open(filePath, "r");
|
|
390
|
+
const { bytesRead } = await handle.read(header, 0, header.length, 0);
|
|
391
|
+
if (bytesRead === 0) return false;
|
|
392
|
+
} catch {
|
|
393
|
+
return false;
|
|
394
|
+
} finally {
|
|
395
|
+
try {
|
|
396
|
+
await handle?.close();
|
|
397
|
+
} catch {
|
|
398
|
+
// The evidence file may disappear while it is being inspected.
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const ascii = header.toString("ascii");
|
|
402
|
+
return (
|
|
403
|
+
header.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) ||
|
|
404
|
+
header.subarray(0, 3).equals(Buffer.from([255, 216, 255])) ||
|
|
405
|
+
ascii.startsWith("GIF87a") ||
|
|
406
|
+
ascii.startsWith("GIF89a") ||
|
|
407
|
+
(ascii.startsWith("RIFF") && ascii.slice(8, 12) === "WEBP") ||
|
|
408
|
+
ascii.slice(4, 8) === "ftyp" ||
|
|
409
|
+
header.subarray(0, 4).equals(Buffer.from([26, 69, 223, 163]))
|
|
410
|
+
);
|
|
411
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { clampAttempts } from "./shared.js";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_TOKEN_BUDGET = 250_000;
|
|
4
|
+
export const DEFAULT_RETRY_BUDGET = 2;
|
|
5
|
+
const MAX_TOKEN_BUDGET = 10_000_000;
|
|
6
|
+
|
|
7
|
+
export function resolveRunBudgets(request = {}) {
|
|
8
|
+
const legacyMaxAttempts = clampAttempts(request.max_attempts);
|
|
9
|
+
const explicitRetryBudget = integer(request.retry_budget);
|
|
10
|
+
const retryBudget =
|
|
11
|
+
explicitRetryBudget === null
|
|
12
|
+
? legacyMaxAttempts - 1
|
|
13
|
+
: Math.min(4, Math.max(0, explicitRetryBudget));
|
|
14
|
+
return {
|
|
15
|
+
token_budget: normalizeTokenBudget(request.token_budget),
|
|
16
|
+
retry_budget: retryBudget,
|
|
17
|
+
max_attempts: retryBudget + 1,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function budgetState(snapshot, pendingAttempt = null) {
|
|
22
|
+
const attempts = [
|
|
23
|
+
...(Array.isArray(snapshot?.attempts) ? snapshot.attempts : []),
|
|
24
|
+
...(pendingAttempt ? [pendingAttempt] : []),
|
|
25
|
+
];
|
|
26
|
+
const tokenBudget = normalizeTokenBudget(snapshot?.charter?.token_budget);
|
|
27
|
+
const retryBudget = normalizeRetryBudget(
|
|
28
|
+
snapshot?.charter?.retry_budget,
|
|
29
|
+
snapshot?.charter?.max_attempts,
|
|
30
|
+
);
|
|
31
|
+
const tokensUsed = attempts.reduce(
|
|
32
|
+
(sum, attempt) => sum + tokenUsageTotal(attempt?.token_usage),
|
|
33
|
+
0,
|
|
34
|
+
);
|
|
35
|
+
const attemptsUsed = attempts.length;
|
|
36
|
+
const retriesUsed = Math.max(attemptsUsed - 1, 0);
|
|
37
|
+
const tokenExhausted = tokensUsed >= tokenBudget;
|
|
38
|
+
const retryExhausted = attemptsUsed > 0 && retriesUsed >= retryBudget;
|
|
39
|
+
return {
|
|
40
|
+
schema_version: 1,
|
|
41
|
+
token_budget: tokenBudget,
|
|
42
|
+
tokens_used: tokensUsed,
|
|
43
|
+
tokens_remaining: Math.max(tokenBudget - tokensUsed, 0),
|
|
44
|
+
token_exhausted: tokenExhausted,
|
|
45
|
+
retry_budget: retryBudget,
|
|
46
|
+
retries_used: retriesUsed,
|
|
47
|
+
retries_remaining: Math.max(retryBudget - retriesUsed, 0),
|
|
48
|
+
retry_exhausted: retryExhausted,
|
|
49
|
+
exhausted: tokenExhausted || retryExhausted,
|
|
50
|
+
exhausted_reason: tokenExhausted
|
|
51
|
+
? "token_budget_exhausted"
|
|
52
|
+
: retryExhausted
|
|
53
|
+
? "retry_budget_exhausted"
|
|
54
|
+
: null,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function refreshBudgetState(snapshot, pendingAttempt = null) {
|
|
59
|
+
const state = budgetState(snapshot, pendingAttempt);
|
|
60
|
+
snapshot.budget = state;
|
|
61
|
+
return state;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function canStartProvider(snapshot, { attemptNumber, phase }) {
|
|
65
|
+
const state = refreshBudgetState(snapshot);
|
|
66
|
+
if (state.token_exhausted) {
|
|
67
|
+
return { allowed: false, reason: "token_budget_exhausted", budget: state };
|
|
68
|
+
}
|
|
69
|
+
if (phase === "executor" && attemptNumber > state.retry_budget + 1) {
|
|
70
|
+
return { allowed: false, reason: "retry_budget_exhausted", budget: state };
|
|
71
|
+
}
|
|
72
|
+
return { allowed: true, reason: null, budget: state };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function tokenUsageTotal(value) {
|
|
76
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return 0;
|
|
77
|
+
if (Array.isArray(value.records)) {
|
|
78
|
+
return value.records.reduce((sum, record) => sum + recordTokenTotal(record), 0);
|
|
79
|
+
}
|
|
80
|
+
return recordTokenTotal(value);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function recordTokenTotal(value) {
|
|
84
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return 0;
|
|
85
|
+
const explicit = nonNegativeNumber(value.total_tokens);
|
|
86
|
+
if (explicit !== null) return explicit;
|
|
87
|
+
return (
|
|
88
|
+
(nonNegativeNumber(value.tokens_in ?? value.input_tokens) ?? 0) +
|
|
89
|
+
(nonNegativeNumber(value.tokens_out ?? value.output_tokens) ?? 0)
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function normalizeTokenBudget(value) {
|
|
94
|
+
const parsed = integer(value);
|
|
95
|
+
if (parsed === null || parsed <= 0) return DEFAULT_TOKEN_BUDGET;
|
|
96
|
+
return Math.min(MAX_TOKEN_BUDGET, parsed);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function normalizeRetryBudget(value, maxAttempts) {
|
|
100
|
+
const parsed = integer(value);
|
|
101
|
+
if (parsed !== null) return Math.min(4, Math.max(0, parsed));
|
|
102
|
+
return clampAttempts(maxAttempts) - 1;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function integer(value) {
|
|
106
|
+
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
107
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function nonNegativeNumber(value) {
|
|
111
|
+
return Number.isFinite(value) && value >= 0 ? Math.round(value) : null;
|
|
112
|
+
}
|