@alisio/core 0.5.2 → 0.6.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/dist/analysis/janitor.d.ts +48 -0
- package/dist/analysis/janitor.js +170 -18
- package/dist/analysis/jobs.d.ts +11 -2
- package/dist/analysis/jobs.js +13 -0
- package/dist/analysis/oci.d.ts +11 -2
- package/dist/analysis/oci.js +20 -4
- package/dist/analysis/python/sources.js +2 -1
- package/dist/analysis/rerun.d.ts +1 -1
- package/dist/analysis/status.d.ts +3 -1
- package/dist/analysis/status.js +3 -0
- package/dist/application.d.ts +28 -2
- package/dist/application.js +189 -4
- package/dist/changelog/data.js +26 -0
- package/dist/config.d.ts +110 -2
- package/dist/config.js +188 -6
- package/dist/core/call-internals.d.ts +22 -0
- package/dist/core/call-internals.js +9 -0
- package/dist/core/contracts.d.ts +2 -2
- package/dist/core/runner.js +111 -38
- package/dist/forge/broker.d.ts +51 -0
- package/dist/forge/broker.js +244 -0
- package/dist/forge/budget.d.ts +27 -0
- package/dist/forge/budget.js +60 -0
- package/dist/forge/catalog-view.d.ts +24 -0
- package/dist/forge/catalog-view.js +43 -0
- package/dist/forge/errors.d.ts +26 -0
- package/dist/forge/errors.js +49 -0
- package/dist/forge/guides/javascript.d.ts +25 -0
- package/dist/forge/guides/javascript.js +90 -0
- package/dist/forge/guides/provider.d.ts +42 -0
- package/dist/forge/guides/provider.js +96 -0
- package/dist/forge/guides/python-environment.d.ts +15 -0
- package/dist/forge/guides/python-environment.js +81 -0
- package/dist/forge/guides/python.d.ts +38 -0
- package/dist/forge/guides/python.js +170 -0
- package/dist/forge/javascript/engine.d.ts +44 -0
- package/dist/forge/javascript/engine.js +229 -0
- package/dist/forge/javascript/hosts/factory.d.ts +25 -0
- package/dist/forge/javascript/hosts/factory.js +21 -0
- package/dist/forge/javascript/hosts/framing.d.ts +17 -0
- package/dist/forge/javascript/hosts/framing.js +49 -0
- package/dist/forge/javascript/hosts/isolate-main.d.ts +9 -0
- package/dist/forge/javascript/hosts/isolate-main.js +63 -0
- package/dist/forge/javascript/hosts/process-entry.d.ts +1 -0
- package/dist/forge/javascript/hosts/process-entry.js +3 -0
- package/dist/forge/javascript/hosts/process-host.d.ts +33 -0
- package/dist/forge/javascript/hosts/process-host.js +160 -0
- package/dist/forge/javascript/hosts/worker-entry.d.ts +1 -0
- package/dist/forge/javascript/hosts/worker-entry.js +3 -0
- package/dist/forge/javascript/hosts/worker-host.d.ts +18 -0
- package/dist/forge/javascript/hosts/worker-host.js +101 -0
- package/dist/forge/javascript/prelude.d.ts +2 -0
- package/dist/forge/javascript/prelude.js +333 -0
- package/dist/forge/javascript/probe.d.ts +2 -0
- package/dist/forge/javascript/probe.js +53 -0
- package/dist/forge/javascript/runtime-manifest.d.ts +14 -0
- package/dist/forge/javascript/runtime-manifest.js +14 -0
- package/dist/forge/javascript/vm-runner.d.ts +21 -0
- package/dist/forge/javascript/vm-runner.js +301 -0
- package/dist/forge/javascript/wasm-asset.d.ts +8 -0
- package/dist/forge/javascript/wasm-asset.js +8 -0
- package/dist/forge/javascript/wasm.d.ts +15 -0
- package/dist/forge/javascript/wasm.js +54 -0
- package/dist/forge/journal.d.ts +105 -0
- package/dist/forge/journal.js +174 -0
- package/dist/forge/kernel.d.ts +76 -0
- package/dist/forge/kernel.js +338 -0
- package/dist/forge/limits.d.ts +38 -0
- package/dist/forge/limits.js +73 -0
- package/dist/forge/nested.d.ts +96 -0
- package/dist/forge/nested.js +214 -0
- package/dist/forge/ports.d.ts +120 -0
- package/dist/forge/ports.js +1 -0
- package/dist/forge/progress.d.ts +24 -0
- package/dist/forge/progress.js +76 -0
- package/dist/forge/protocol.d.ts +110 -0
- package/dist/forge/protocol.js +171 -0
- package/dist/forge/python/backend-selector.d.ts +38 -0
- package/dist/forge/python/backend-selector.js +111 -0
- package/dist/forge/python/backend.d.ts +89 -0
- package/dist/forge/python/backend.js +1 -0
- package/dist/forge/python/engine.d.ts +84 -0
- package/dist/forge/python/engine.js +279 -0
- package/dist/forge/python/frame-parser.d.ts +24 -0
- package/dist/forge/python/frame-parser.js +107 -0
- package/dist/forge/python/frame-transport.d.ts +40 -0
- package/dist/forge/python/frame-transport.js +101 -0
- package/dist/forge/python/import-scan.d.ts +22 -0
- package/dist/forge/python/import-scan.js +133 -0
- package/dist/forge/python/managed-backend.d.ts +30 -0
- package/dist/forge/python/managed-backend.js +111 -0
- package/dist/forge/python/oci-backend.d.ts +27 -0
- package/dist/forge/python/oci-backend.js +84 -0
- package/dist/forge/python/pyodide-backend.d.ts +40 -0
- package/dist/forge/python/pyodide-backend.js +289 -0
- package/dist/forge/python/pyodide-install.d.ts +41 -0
- package/dist/forge/python/pyodide-install.js +167 -0
- package/dist/forge/python/pyodide-manifest.d.ts +42 -0
- package/dist/forge/python/pyodide-manifest.js +376 -0
- package/dist/forge/python/pyodide-runner-source.d.ts +2 -0
- package/dist/forge/python/pyodide-runner-source.js +3 -0
- package/dist/forge/python/rss.d.ts +2 -0
- package/dist/forge/python/rss.js +33 -0
- package/dist/forge/python/scratch-copy.d.ts +6 -0
- package/dist/forge/python/scratch-copy.js +61 -0
- package/dist/forge/python/settle.d.ts +6 -0
- package/dist/forge/python/settle.js +21 -0
- package/dist/forge/python/status.d.ts +8 -0
- package/dist/forge/python/status.js +15 -0
- package/dist/forge/python/wasm-node.d.ts +59 -0
- package/dist/forge/python/wasm-node.js +220 -0
- package/dist/forge/python/wiring.d.ts +39 -0
- package/dist/forge/python/wiring.js +47 -0
- package/dist/forge/registry.d.ts +26 -0
- package/dist/forge/registry.js +83 -0
- package/dist/forge/result.d.ts +58 -0
- package/dist/forge/result.js +86 -0
- package/dist/forge/routing-rule.d.ts +19 -0
- package/dist/forge/routing-rule.js +31 -0
- package/dist/forge/runtime.d.ts +64 -0
- package/dist/forge/runtime.js +88 -0
- package/dist/forge/scratch-files.d.ts +36 -0
- package/dist/forge/scratch-files.js +94 -0
- package/dist/forge/scratch-provider.d.ts +41 -0
- package/dist/forge/scratch-provider.js +79 -0
- package/dist/forge/scratch-quota.d.ts +8 -0
- package/dist/forge/scratch-quota.js +25 -0
- package/dist/forge/selector.d.ts +35 -0
- package/dist/forge/selector.js +51 -0
- package/dist/forge/session-dir.d.ts +59 -0
- package/dist/forge/session-dir.js +239 -0
- package/dist/forge/session-path.d.ts +31 -0
- package/dist/forge/session-path.js +61 -0
- package/dist/forge/slots.d.ts +16 -0
- package/dist/forge/slots.js +47 -0
- package/dist/forge/state-delta.d.ts +54 -0
- package/dist/forge/state-delta.js +99 -0
- package/dist/forge/state-machine.d.ts +20 -0
- package/dist/forge/state-machine.js +40 -0
- package/dist/forge/state-store.d.ts +25 -0
- package/dist/forge/state-store.js +50 -0
- package/dist/forge/task-log.d.ts +9 -0
- package/dist/forge/task-log.js +42 -0
- package/dist/forge/verdict.d.ts +20 -0
- package/dist/forge/verdict.js +28 -0
- package/dist/forge-isolate.d.ts +6 -0
- package/dist/forge-isolate.js +6 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/permissions/decide.d.ts +42 -0
- package/dist/permissions/decide.js +24 -0
- package/dist/resources/context.d.ts +2 -1
- package/dist/resources/context.js +2 -1
- package/dist/runtime/access.d.ts +34 -0
- package/dist/runtime/access.js +80 -0
- package/dist/runtime/process.d.ts +2 -1
- package/dist/runtime/process.js +5 -5
- package/dist/runtime/store.d.ts +11 -0
- package/dist/runtime/store.js +103 -0
- package/dist/tools/analysis.d.ts +25 -2
- package/dist/tools/analysis.js +315 -267
- package/dist/tools/execute.js +6 -7
- package/dist/tools/forge-guide.d.ts +6 -0
- package/dist/tools/forge-guide.js +60 -0
- package/dist/tools/js-run.d.ts +19 -0
- package/dist/tools/js-run.js +126 -0
- package/dist/tools/standard.d.ts +7 -1
- package/dist/tools/standard.js +44 -43
- package/package.json +7 -2
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { EngineId, EngineLanguage, ForgeProgress, PermissionMode } from "@alisio/sdk";
|
|
2
|
+
import type { SqliteExecutionJournal } from "./journal.ts";
|
|
3
|
+
import { type ForgeLimits } from "./limits.ts";
|
|
4
|
+
import type { NestedCallDispatcher } from "./nested.ts";
|
|
5
|
+
import type { EngineRegistry } from "./registry.ts";
|
|
6
|
+
import { type ForgeResult } from "./result.ts";
|
|
7
|
+
import { type EngineSelector, type JavaScriptSelectionContext, type Selection } from "./selector.ts";
|
|
8
|
+
import type { SlotTable } from "./slots.ts";
|
|
9
|
+
import type { SqliteStateStore } from "./state-store.ts";
|
|
10
|
+
export interface KernelConfig {
|
|
11
|
+
/** `forge.javascript.enabled`. */
|
|
12
|
+
enabled: boolean;
|
|
13
|
+
/** The configured limits (the global layer; project config only ever lowered them already). */
|
|
14
|
+
limits: Partial<ForgeLimits>;
|
|
15
|
+
/** `forge.state.enabled`. */
|
|
16
|
+
stateEnabled: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface KernelDeps {
|
|
19
|
+
engines: EngineRegistry;
|
|
20
|
+
selector: EngineSelector<JavaScriptSelectionContext, EngineId>;
|
|
21
|
+
journal: SqliteExecutionJournal;
|
|
22
|
+
state?: SqliteStateStore;
|
|
23
|
+
slots: SlotTable;
|
|
24
|
+
config: () => KernelConfig;
|
|
25
|
+
now?: () => number;
|
|
26
|
+
newId?: () => string;
|
|
27
|
+
/** How long the end of an execution waits for nested calls the script left running. */
|
|
28
|
+
drainMs?: number;
|
|
29
|
+
}
|
|
30
|
+
/** Everything the core knows about the call that asks for the execution; the model controls none of it. */
|
|
31
|
+
export interface KernelCallContext {
|
|
32
|
+
toolCallId: string;
|
|
33
|
+
session: string;
|
|
34
|
+
rootSessionId: string;
|
|
35
|
+
workspace: string;
|
|
36
|
+
signal: AbortSignal;
|
|
37
|
+
permissionMode: PermissionMode;
|
|
38
|
+
allowAnalysis: boolean;
|
|
39
|
+
allowProcess: boolean;
|
|
40
|
+
/** Absent for an engine that cannot call tools (Python): the kernel then offers none. */
|
|
41
|
+
dispatcher?: NestedCallDispatcher;
|
|
42
|
+
/** Delivers `ForgeProgress` to the run (already coalesced to 4 per second). */
|
|
43
|
+
emit: (progress: ForgeProgress) => void;
|
|
44
|
+
/** `<sessionDir>/tasks/<executionId>.log` is written under this folder when given. */
|
|
45
|
+
tasksDir?: string;
|
|
46
|
+
}
|
|
47
|
+
export interface KernelInvocation {
|
|
48
|
+
source: string;
|
|
49
|
+
title?: string;
|
|
50
|
+
requestedLimits?: Partial<ForgeLimits>;
|
|
51
|
+
call: KernelCallContext;
|
|
52
|
+
/** Language of `source`; JavaScript when omitted. */
|
|
53
|
+
language?: EngineLanguage;
|
|
54
|
+
/**
|
|
55
|
+
* A selection the caller already made (Python's backend selector). It replaces the JavaScript
|
|
56
|
+
* chain; the engine must be registered. The model never provides it.
|
|
57
|
+
*/
|
|
58
|
+
selection?: Selection<EngineId>;
|
|
59
|
+
/** Per-engine request extensions, handed to `prepare` next to the state snapshot. */
|
|
60
|
+
payload?: Record<string, unknown>;
|
|
61
|
+
/**
|
|
62
|
+
* Limits set by the core after the merge (Python's own timeout and source size, which the
|
|
63
|
+
* JavaScript maxima do not describe). Never built from model input.
|
|
64
|
+
*/
|
|
65
|
+
limitOverrides?: Partial<ForgeLimits>;
|
|
66
|
+
}
|
|
67
|
+
/** The result plus the bound on what the model sees of it (the effective `maxOutputChars`). */
|
|
68
|
+
export interface KernelOutcome {
|
|
69
|
+
result: ForgeResult;
|
|
70
|
+
maxOutputChars: number;
|
|
71
|
+
}
|
|
72
|
+
export declare class ExecutionKernel {
|
|
73
|
+
#private;
|
|
74
|
+
constructor(deps: KernelDeps);
|
|
75
|
+
execute(invocation: KernelInvocation): Promise<KernelOutcome>;
|
|
76
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ExecutionKernel` (spec D1, §5.2): the fixed lifecycle every Forja execution follows, whatever the
|
|
3
|
+
* engine. Template Method:
|
|
4
|
+
*
|
|
5
|
+
* validate -> admit -> select -> prepare -> run (with deadline) -> collect -> cleanup (always)
|
|
6
|
+
*
|
|
7
|
+
* The kernel owns what must not depend on an engine: limits, the concurrency slots, the journal
|
|
8
|
+
* (every transition and nested call), the cross-execution state (committed only on success), the
|
|
9
|
+
* deadline and cancellation with the host's OWN clock, the output bound and the single terminal
|
|
10
|
+
* state. The engine only knows how to run code in an isolate.
|
|
11
|
+
*/
|
|
12
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
13
|
+
import { CapabilityBroker } from "./broker.js";
|
|
14
|
+
import { BudgetLedger } from "./budget.js";
|
|
15
|
+
import { ForgeError, isForgeError } from "./errors.js";
|
|
16
|
+
import { DEFAULT_FORGE_LIMITS, mergeLimits } from "./limits.js";
|
|
17
|
+
import { ProgressCoalescer } from "./progress.js";
|
|
18
|
+
import { hintFor } from "./result.js";
|
|
19
|
+
import { StateDelta } from "./state-delta.js";
|
|
20
|
+
import { ExecutionStateMachine } from "./state-machine.js";
|
|
21
|
+
import { TaskLog } from "./task-log.js";
|
|
22
|
+
import { verdictOf } from "./verdict.js";
|
|
23
|
+
/** What an engine without tool calls sees as its nested-call port: nothing. */
|
|
24
|
+
const NO_NESTED_CALLS = {
|
|
25
|
+
catalog: () => [],
|
|
26
|
+
knows: () => false,
|
|
27
|
+
call: async () => ({
|
|
28
|
+
ok: false,
|
|
29
|
+
nestedCallId: "",
|
|
30
|
+
code: "TOOL_UNAVAILABLE",
|
|
31
|
+
message: "this engine cannot call tools",
|
|
32
|
+
}),
|
|
33
|
+
};
|
|
34
|
+
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
35
|
+
export class ExecutionKernel {
|
|
36
|
+
#deps;
|
|
37
|
+
constructor(deps) {
|
|
38
|
+
this.#deps = deps;
|
|
39
|
+
}
|
|
40
|
+
async execute(invocation) {
|
|
41
|
+
const deps = this.#deps;
|
|
42
|
+
const now = deps.now ?? Date.now;
|
|
43
|
+
const call = invocation.call;
|
|
44
|
+
const startedAt = now();
|
|
45
|
+
const id = (deps.newId ?? (() => `fx_${randomUUID()}`))();
|
|
46
|
+
const config = deps.config();
|
|
47
|
+
const descriptors = deps.engines.descriptors();
|
|
48
|
+
const language = invocation.language ?? "javascript";
|
|
49
|
+
const dispatcher = call.dispatcher ?? NO_NESTED_CALLS;
|
|
50
|
+
// select (cheap and pure, so it precedes the journal row, which needs the engine and the reason)
|
|
51
|
+
let selection;
|
|
52
|
+
let selectionError;
|
|
53
|
+
try {
|
|
54
|
+
selection =
|
|
55
|
+
invocation.selection ??
|
|
56
|
+
deps.selector.select({
|
|
57
|
+
language,
|
|
58
|
+
javascriptEnabled: config.enabled,
|
|
59
|
+
descriptors,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
selectionError = isForgeError(error)
|
|
64
|
+
? error
|
|
65
|
+
: new ForgeError("ENGINE_UNAVAILABLE", error instanceof Error ? error.message : String(error));
|
|
66
|
+
}
|
|
67
|
+
const engineId = selection?.engine ?? "javascript-quickjs";
|
|
68
|
+
const isolation = descriptors[engineId]?.isolation ?? "none";
|
|
69
|
+
const selectionReason = selection?.reason ?? selectionError?.message ?? "no engine";
|
|
70
|
+
deps.journal.open({
|
|
71
|
+
id,
|
|
72
|
+
rootSessionId: call.rootSessionId,
|
|
73
|
+
sessionId: call.session,
|
|
74
|
+
toolCallId: call.toolCallId,
|
|
75
|
+
engine: engineId,
|
|
76
|
+
isolation,
|
|
77
|
+
selectionReason,
|
|
78
|
+
codeSha256: sha256(invocation.source),
|
|
79
|
+
});
|
|
80
|
+
const machine = new ExecutionStateMachine();
|
|
81
|
+
const advance = (to) => {
|
|
82
|
+
machine.transition(to);
|
|
83
|
+
deps.journal.transition(id, to);
|
|
84
|
+
};
|
|
85
|
+
const log = call.tasksDir ? new TaskLog(`${call.tasksDir}/${id}.log`) : undefined;
|
|
86
|
+
const progress = new ProgressCoalescer(call.emit);
|
|
87
|
+
const base = { kind: "forge", executionId: id, engine: engineId };
|
|
88
|
+
const phase = (name, message) => progress.terminal({ ...base, phase: name, ...(message ? { message } : {}) });
|
|
89
|
+
let limits;
|
|
90
|
+
let release;
|
|
91
|
+
let engine;
|
|
92
|
+
let prepared;
|
|
93
|
+
let broker;
|
|
94
|
+
let verdict = {
|
|
95
|
+
status: "failed",
|
|
96
|
+
error: { code: "ISOLATE_FAILURE", message: "execution did not finish" },
|
|
97
|
+
};
|
|
98
|
+
let report;
|
|
99
|
+
let queueMs = 0;
|
|
100
|
+
const abort = new AbortController();
|
|
101
|
+
let stop;
|
|
102
|
+
let running = false;
|
|
103
|
+
let timer;
|
|
104
|
+
let termination;
|
|
105
|
+
const requestStop = (reason) => {
|
|
106
|
+
if (!running || stop)
|
|
107
|
+
return;
|
|
108
|
+
stop = reason;
|
|
109
|
+
// Nested calls hear it first: a guest waiting on one executes no bytecode and cannot notice.
|
|
110
|
+
abort.abort(new Error(reason === "timeout" ? "deadline" : "cancelled"));
|
|
111
|
+
const why = reason === "timeout" ? "timeout" : "cancelled";
|
|
112
|
+
if (engine && prepared)
|
|
113
|
+
termination = engine.terminate(prepared, why);
|
|
114
|
+
};
|
|
115
|
+
const onUserAbort = () => requestStop("cancelled");
|
|
116
|
+
try {
|
|
117
|
+
// validate
|
|
118
|
+
if (selectionError)
|
|
119
|
+
throw selectionError;
|
|
120
|
+
const layers = {
|
|
121
|
+
global: config.limits,
|
|
122
|
+
...(invocation.requestedLimits ? { request: invocation.requestedLimits } : {}),
|
|
123
|
+
};
|
|
124
|
+
limits = { ...mergeLimits(layers), ...invocation.limitOverrides };
|
|
125
|
+
if (Buffer.byteLength(invocation.source, "utf8") > limits.maxSourceBytes)
|
|
126
|
+
throw new ForgeError("INVALID_INPUT", `the script is larger than ${limits.maxSourceBytes} bytes`);
|
|
127
|
+
if (invocation.source.trim() === "")
|
|
128
|
+
throw new ForgeError("INVALID_INPUT", "the script is empty");
|
|
129
|
+
// admit (the slots are the JavaScript engine's: Python runs are not Forja script hosts, and a
|
|
130
|
+
// `js_run` that calls `python_run` holds its session's slot while the Python run starts)
|
|
131
|
+
if (language === "javascript")
|
|
132
|
+
release = deps.slots.acquire(call.session);
|
|
133
|
+
advance("preparing");
|
|
134
|
+
phase("preparing");
|
|
135
|
+
const snapshot = config.stateEnabled && deps.state
|
|
136
|
+
? deps.state.snapshot(call.rootSessionId)
|
|
137
|
+
: { values: {}, revisions: {} };
|
|
138
|
+
const delta = config.stateEnabled && deps.state ? new StateDelta(snapshot) : undefined;
|
|
139
|
+
// prepare
|
|
140
|
+
engine = deps.engines.get(engineId);
|
|
141
|
+
const request = {
|
|
142
|
+
language,
|
|
143
|
+
source: invocation.source,
|
|
144
|
+
...(invocation.title ? { title: invocation.title } : {}),
|
|
145
|
+
engineHint: engineId,
|
|
146
|
+
payload: {
|
|
147
|
+
...invocation.payload,
|
|
148
|
+
state: snapshot.values,
|
|
149
|
+
catalogSize: dispatcher.catalog().length,
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
prepared = await engine.prepare(request, {
|
|
153
|
+
executionId: id,
|
|
154
|
+
toolCallId: call.toolCallId,
|
|
155
|
+
rootSessionId: call.rootSessionId,
|
|
156
|
+
workspace: call.workspace,
|
|
157
|
+
permissionMode: call.permissionMode,
|
|
158
|
+
allowAnalysis: call.allowAnalysis,
|
|
159
|
+
allowProcess: call.allowProcess,
|
|
160
|
+
signal: call.signal,
|
|
161
|
+
limits,
|
|
162
|
+
});
|
|
163
|
+
if (prepared.analysisExecutionId)
|
|
164
|
+
deps.journal.linkAnalysisExecution(id, prepared.analysisExecutionId);
|
|
165
|
+
broker = new CapabilityBroker({
|
|
166
|
+
dispatcher,
|
|
167
|
+
ledger: new BudgetLedger(limits),
|
|
168
|
+
limits,
|
|
169
|
+
journal: deps.journal,
|
|
170
|
+
executionId: id,
|
|
171
|
+
signal: abort.signal,
|
|
172
|
+
state: delta,
|
|
173
|
+
update: (event) => progress.update(event),
|
|
174
|
+
terminal: (event) => progress.terminal(event),
|
|
175
|
+
engine: engineId,
|
|
176
|
+
log: (line) => log?.write(line),
|
|
177
|
+
now,
|
|
178
|
+
});
|
|
179
|
+
const activeBroker = broker;
|
|
180
|
+
let send = () => { };
|
|
181
|
+
activeBroker.bind((reply) => send(reply));
|
|
182
|
+
// run (the deadline starts here and covers compute and nested calls)
|
|
183
|
+
let outcome;
|
|
184
|
+
if (call.signal.aborted) {
|
|
185
|
+
// Cancelled before it ever ran: preparing -> cancelled.
|
|
186
|
+
outcome = { kind: "failed", code: "CANCELLED", message: "cancelled" };
|
|
187
|
+
verdict = verdictOf(outcome, "cancelled", limits.timeoutMs);
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
advance("running");
|
|
191
|
+
running = true;
|
|
192
|
+
queueMs = now() - startedAt;
|
|
193
|
+
phase("running");
|
|
194
|
+
log?.write(`started ${id} (${selectionReason})`);
|
|
195
|
+
call.signal.addEventListener("abort", onUserAbort, { once: true });
|
|
196
|
+
timer = setTimeout(() => requestStop("timeout"), limits.timeoutMs);
|
|
197
|
+
outcome = await engine.run(prepared, {
|
|
198
|
+
receive: (message) => activeBroker.receive(message),
|
|
199
|
+
bind: (next) => {
|
|
200
|
+
send = next;
|
|
201
|
+
},
|
|
202
|
+
signal: abort.signal,
|
|
203
|
+
});
|
|
204
|
+
running = false;
|
|
205
|
+
// The engine's own collection (artifacts to publish) comes before the state commit, and
|
|
206
|
+
// only for an execution the host did not stop.
|
|
207
|
+
let collecting = false;
|
|
208
|
+
if (!stop && engine.collect) {
|
|
209
|
+
if (outcome.kind === "completed") {
|
|
210
|
+
advance("collecting");
|
|
211
|
+
phase("collecting");
|
|
212
|
+
collecting = true;
|
|
213
|
+
}
|
|
214
|
+
outcome = await engine.collect(prepared, outcome);
|
|
215
|
+
}
|
|
216
|
+
verdict = verdictOf(outcome, stop, limits.timeoutMs);
|
|
217
|
+
if (stop) {
|
|
218
|
+
// The host's own stop decides the terminal state; wait until the guest is confirmed gone.
|
|
219
|
+
report = await (termination ??
|
|
220
|
+
engine.terminate(prepared, stop === "timeout" ? "timeout" : "cancelled"));
|
|
221
|
+
}
|
|
222
|
+
else if (outcome.kind === "failed") {
|
|
223
|
+
report = await engine.terminate(prepared, "limit_exceeded");
|
|
224
|
+
}
|
|
225
|
+
abort.abort(new Error("execution ended"));
|
|
226
|
+
await activeBroker.drain(deps.drainMs ?? 2_000);
|
|
227
|
+
if (verdict.status === "succeeded") {
|
|
228
|
+
if (!collecting) {
|
|
229
|
+
advance("collecting");
|
|
230
|
+
phase("collecting");
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
if (delta && deps.state)
|
|
234
|
+
deps.state.commit(call.rootSessionId, snapshot, delta.ops());
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
if (isForgeError(error))
|
|
238
|
+
verdict = { status: "failed", error: { code: error.code, message: error.message } };
|
|
239
|
+
else
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
running = false;
|
|
247
|
+
const forge = isForgeError(error) ? error : undefined;
|
|
248
|
+
verdict = {
|
|
249
|
+
status: "failed",
|
|
250
|
+
error: {
|
|
251
|
+
code: forge?.code ?? "ISOLATE_FAILURE",
|
|
252
|
+
message: error instanceof Error ? error.message : String(error),
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
clearTimeout(timer);
|
|
258
|
+
call.signal.removeEventListener("abort", onUserAbort);
|
|
259
|
+
abort.abort(new Error("execution ended"));
|
|
260
|
+
if (broker)
|
|
261
|
+
await broker.drain(deps.drainMs ?? 2_000);
|
|
262
|
+
if (engine && prepared) {
|
|
263
|
+
try {
|
|
264
|
+
await engine.dispose(prepared);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
/* The isolate is already stopped or unreachable; nothing more to do from here. */
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
progress.close();
|
|
271
|
+
}
|
|
272
|
+
// The terminal state is reached exactly once, with the state machine's blessing.
|
|
273
|
+
const unconfirmed = report ? !report.confirmed : false;
|
|
274
|
+
const stats = broker?.stats ?? { total: 0, failed: 0, denied: 0 };
|
|
275
|
+
const output = broker?.output ?? [];
|
|
276
|
+
const outputBytes = output.reduce((sum, item) => sum + Buffer.byteLength(JSON.stringify(item)), 0);
|
|
277
|
+
const wallMs = now() - startedAt;
|
|
278
|
+
try {
|
|
279
|
+
if (verdict.status === "cancelled" && machine.state === "running")
|
|
280
|
+
advance("cancelling");
|
|
281
|
+
if (verdict.status === "timed_out" && machine.state === "running")
|
|
282
|
+
advance("timed_out_pending");
|
|
283
|
+
deps.journal.close(id, {
|
|
284
|
+
status: verdict.status,
|
|
285
|
+
...(verdict.error ? { errorCode: verdict.error.code } : {}),
|
|
286
|
+
metrics: {
|
|
287
|
+
wallMs,
|
|
288
|
+
queueMs,
|
|
289
|
+
calls: stats,
|
|
290
|
+
outputBytes,
|
|
291
|
+
...(broker?.progressDropped ? { progressDropped: broker.progressDropped } : {}),
|
|
292
|
+
},
|
|
293
|
+
terminationUnconfirmed: unconfirmed,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
// The row is already terminal (a reconciliation raced us); the result still goes back.
|
|
298
|
+
log?.write(`journal: ${error instanceof Error ? error.message : String(error)}`);
|
|
299
|
+
}
|
|
300
|
+
log?.write(`${verdict.status}${verdict.error ? `: ${verdict.error.code}` : ""} (${wallMs} ms)`);
|
|
301
|
+
await log?.flush();
|
|
302
|
+
// A guest that could not be confirmed gone keeps its slot until it is (or the process ends).
|
|
303
|
+
if (unconfirmed && report?.settled)
|
|
304
|
+
void report.settled.then(() => release?.());
|
|
305
|
+
else
|
|
306
|
+
release?.();
|
|
307
|
+
const result = {
|
|
308
|
+
schemaVersion: 1,
|
|
309
|
+
executionId: id,
|
|
310
|
+
engine: engineId,
|
|
311
|
+
isolation,
|
|
312
|
+
selectionReason,
|
|
313
|
+
status: verdict.status,
|
|
314
|
+
output,
|
|
315
|
+
...(verdict.value !== undefined ? { value: verdict.value } : {}),
|
|
316
|
+
...(verdict.error
|
|
317
|
+
? {
|
|
318
|
+
error: {
|
|
319
|
+
...verdict.error,
|
|
320
|
+
...(() => {
|
|
321
|
+
const hint = language === "javascript"
|
|
322
|
+
? hintFor(verdict.error.code, verdict.error.message, invocation.source)
|
|
323
|
+
: undefined;
|
|
324
|
+
return hint ? { hint } : {};
|
|
325
|
+
})(),
|
|
326
|
+
},
|
|
327
|
+
}
|
|
328
|
+
: {}),
|
|
329
|
+
calls: stats,
|
|
330
|
+
truncated: false,
|
|
331
|
+
metrics: { wallMs, queueMs, outputBytes },
|
|
332
|
+
};
|
|
333
|
+
return {
|
|
334
|
+
result,
|
|
335
|
+
maxOutputChars: limits?.maxOutputChars ?? DEFAULT_FORGE_LIMITS.maxOutputChars,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface ForgeLimits {
|
|
2
|
+
maxSourceBytes: number;
|
|
3
|
+
timeoutMs: number;
|
|
4
|
+
/** QuickJS heap. */
|
|
5
|
+
memoryBytes: number;
|
|
6
|
+
/** QuickJS stack. */
|
|
7
|
+
stackBytes: number;
|
|
8
|
+
/** Nested tool calls per execution. */
|
|
9
|
+
maxToolCalls: number;
|
|
10
|
+
/** Simultaneous nested tool calls. */
|
|
11
|
+
maxConcurrency: number;
|
|
12
|
+
maxCallInputBytes: number;
|
|
13
|
+
maxCallResponseBytes: number;
|
|
14
|
+
maxRetainedOutputBytes: number;
|
|
15
|
+
/** Characters of output returned to the model. */
|
|
16
|
+
maxOutputChars: number;
|
|
17
|
+
}
|
|
18
|
+
export type ForgeLimitKey = keyof ForgeLimits;
|
|
19
|
+
/**
|
|
20
|
+
* The most a tool input may weigh: the protocol message that carries it (envelope, ids, tool name)
|
|
21
|
+
* must fit in `MAX_MESSAGE_BYTES`, so the effective limit stays a little below it whatever the
|
|
22
|
+
* configuration says. Over this a call is refused as `TOOL_INPUT_INVALID` before it is sent.
|
|
23
|
+
*/
|
|
24
|
+
export declare const MAX_CALL_INPUT_BYTES: number;
|
|
25
|
+
/** Defaults of the spec's §6.5 table. */
|
|
26
|
+
export declare const DEFAULT_FORGE_LIMITS: Readonly<ForgeLimits>;
|
|
27
|
+
/** Hard global maxima of the same table. */
|
|
28
|
+
export declare const MAX_FORGE_LIMITS: Readonly<ForgeLimits>;
|
|
29
|
+
/** The limits a project config may lower; the rest is global-only. */
|
|
30
|
+
export declare const PROJECT_LOWERABLE_LIMITS: readonly ForgeLimitKey[];
|
|
31
|
+
export interface LimitLayers {
|
|
32
|
+
global?: Partial<ForgeLimits>;
|
|
33
|
+
project?: Partial<ForgeLimits>;
|
|
34
|
+
request?: Partial<ForgeLimits>;
|
|
35
|
+
}
|
|
36
|
+
export declare function mergeLimits(layers: LimitLayers): ForgeLimits;
|
|
37
|
+
/** The limits the guest is told about in `start`; the host enforces them regardless. */
|
|
38
|
+
export type GuestLimits = Pick<ForgeLimits, "timeoutMs" | "memoryBytes" | "stackBytes" | "maxToolCalls" | "maxConcurrency" | "maxCallInputBytes" | "maxCallResponseBytes" | "maxRetainedOutputBytes">;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forja limit merging: global ∩ project ∩ request. Pure: no I/O.
|
|
3
|
+
*
|
|
4
|
+
* The global layer (user config) picks values up to the hard maxima; project and request layers can
|
|
5
|
+
* only lower them (spec D16). Projects may lower only a subset (§10); a request may lower any limit.
|
|
6
|
+
*/
|
|
7
|
+
import { ForgeError } from "./errors.js";
|
|
8
|
+
import { MAX_MESSAGE_BYTES } from "./protocol.js";
|
|
9
|
+
const KiB = 1024;
|
|
10
|
+
const MiB = 1024 * KiB;
|
|
11
|
+
/**
|
|
12
|
+
* The most a tool input may weigh: the protocol message that carries it (envelope, ids, tool name)
|
|
13
|
+
* must fit in `MAX_MESSAGE_BYTES`, so the effective limit stays a little below it whatever the
|
|
14
|
+
* configuration says. Over this a call is refused as `TOOL_INPUT_INVALID` before it is sent.
|
|
15
|
+
*/
|
|
16
|
+
export const MAX_CALL_INPUT_BYTES = MAX_MESSAGE_BYTES - 4 * KiB;
|
|
17
|
+
/** Defaults of the spec's §6.5 table. */
|
|
18
|
+
export const DEFAULT_FORGE_LIMITS = {
|
|
19
|
+
maxSourceBytes: 64 * KiB,
|
|
20
|
+
timeoutMs: 30_000,
|
|
21
|
+
memoryBytes: 32 * MiB,
|
|
22
|
+
stackBytes: 256 * KiB,
|
|
23
|
+
maxToolCalls: 50,
|
|
24
|
+
maxConcurrency: 4,
|
|
25
|
+
maxCallInputBytes: 256 * KiB,
|
|
26
|
+
maxCallResponseBytes: 512 * KiB,
|
|
27
|
+
maxRetainedOutputBytes: 1 * MiB,
|
|
28
|
+
maxOutputChars: 16_000,
|
|
29
|
+
};
|
|
30
|
+
/** Hard global maxima of the same table. */
|
|
31
|
+
export const MAX_FORGE_LIMITS = {
|
|
32
|
+
maxSourceBytes: 64 * KiB,
|
|
33
|
+
timeoutMs: 300_000,
|
|
34
|
+
memoryBytes: 128 * MiB,
|
|
35
|
+
stackBytes: 512 * KiB,
|
|
36
|
+
maxToolCalls: 500,
|
|
37
|
+
maxConcurrency: 16,
|
|
38
|
+
maxCallInputBytes: 1 * MiB,
|
|
39
|
+
maxCallResponseBytes: 2 * MiB,
|
|
40
|
+
maxRetainedOutputBytes: 4 * MiB,
|
|
41
|
+
maxOutputChars: 48_000,
|
|
42
|
+
};
|
|
43
|
+
/** The limits a project config may lower; the rest is global-only. */
|
|
44
|
+
export const PROJECT_LOWERABLE_LIMITS = [
|
|
45
|
+
"timeoutMs",
|
|
46
|
+
"maxToolCalls",
|
|
47
|
+
"maxConcurrency",
|
|
48
|
+
"maxOutputChars",
|
|
49
|
+
];
|
|
50
|
+
const KEYS = Object.keys(DEFAULT_FORGE_LIMITS);
|
|
51
|
+
function checked(layer, key, value) {
|
|
52
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
53
|
+
throw new ForgeError("INVALID_INPUT", `${layer} limit ${key} must be a positive integer`);
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
export function mergeLimits(layers) {
|
|
58
|
+
const merged = { ...DEFAULT_FORGE_LIMITS };
|
|
59
|
+
for (const key of KEYS) {
|
|
60
|
+
const configured = layers.global?.[key];
|
|
61
|
+
if (configured !== undefined) {
|
|
62
|
+
merged[key] = Math.min(checked("global", key, configured), MAX_FORGE_LIMITS[key]);
|
|
63
|
+
}
|
|
64
|
+
const project = PROJECT_LOWERABLE_LIMITS.includes(key) ? layers.project?.[key] : undefined;
|
|
65
|
+
if (project !== undefined)
|
|
66
|
+
merged[key] = Math.min(merged[key], checked("project", key, project));
|
|
67
|
+
const request = layers.request?.[key];
|
|
68
|
+
if (request !== undefined)
|
|
69
|
+
merged[key] = Math.min(merged[key], checked("request", key, request));
|
|
70
|
+
}
|
|
71
|
+
merged.maxCallInputBytes = Math.min(merged.maxCallInputBytes, MAX_CALL_INPUT_BYTES);
|
|
72
|
+
return merged;
|
|
73
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The nested-call port of Forja (spec D-24, D-29, §6.4): how a script running inside a `js_run`
|
|
3
|
+
* call reaches other tools. A nested call is NOT a second permission system: it passes the same
|
|
4
|
+
* gate a direct call of the run passes (the run's `toolFilter`, opt-in tools, the LIVE run policy
|
|
5
|
+
* and `decideEffect`), with two differences that follow from "never open a new approval":
|
|
6
|
+
* approvals and stored grants do not exist for it, and the answer to "would need to ask" is a
|
|
7
|
+
* structured error instead of a prompt.
|
|
8
|
+
*
|
|
9
|
+
* The runner builds one dispatcher per tool call (`core/call-internals.ts`); the broker of Phase 3b
|
|
10
|
+
* is its only consumer. Nothing here knows QuickJS.
|
|
11
|
+
*/
|
|
12
|
+
import type { ArtifactPublisher, ToolDecisions, ToolDefinition, ToolResult } from "@alisio/sdk";
|
|
13
|
+
import type { Policy } from "../core/contracts.ts";
|
|
14
|
+
import type { ToolRegistry } from "../core/registry.ts";
|
|
15
|
+
import type { PathAccess } from "../runtime/access.ts";
|
|
16
|
+
import { type ForgeErrorCode } from "./errors.ts";
|
|
17
|
+
/**
|
|
18
|
+
* Tools no script can reach in v1: Forja's own tools and `execute` (no recursion), the guide
|
|
19
|
+
* (a model-facing tool), and the subagent tools (`task`, `task_wait` and `send_message` can wait
|
|
20
|
+
* on a child session that is waiting for this very call: D-29), and `ask_user_question` (the only
|
|
21
|
+
* tool that opens an interactive prompt; approvals and installs are already refused). Opt-in tools (`OPT_IN_TOOLS`) are
|
|
22
|
+
* excluded as well.
|
|
23
|
+
*/
|
|
24
|
+
export declare const FORGE_EXCLUDED_TOOLS: ReadonlySet<string>;
|
|
25
|
+
export interface NestedCallStart {
|
|
26
|
+
/** `${parentCallId}:forge:${n}`: the journal id of this call; never a `tool_calls` row. */
|
|
27
|
+
nestedCallId: string;
|
|
28
|
+
name: string;
|
|
29
|
+
effect: string;
|
|
30
|
+
/** Resolved absolute paths the tool declared (journaled for `write` calls, D-31). */
|
|
31
|
+
paths: string[];
|
|
32
|
+
}
|
|
33
|
+
export interface NestedCallRequest {
|
|
34
|
+
name: unknown;
|
|
35
|
+
input?: unknown;
|
|
36
|
+
/** Cancels this call; combined with the signal of the parent call. */
|
|
37
|
+
signal?: AbortSignal;
|
|
38
|
+
/** Runs once the call passed the gate, right before the tool executes. */
|
|
39
|
+
onStart?: (start: NestedCallStart) => void;
|
|
40
|
+
}
|
|
41
|
+
export type NestedCallOutcome = {
|
|
42
|
+
ok: true;
|
|
43
|
+
nestedCallId: string;
|
|
44
|
+
effect: string;
|
|
45
|
+
/** Text parts of the result, joined. */
|
|
46
|
+
text: string;
|
|
47
|
+
/** The text parsed as JSON when it is JSON, else the text. */
|
|
48
|
+
value: unknown;
|
|
49
|
+
result: ToolResult;
|
|
50
|
+
} | {
|
|
51
|
+
ok: false;
|
|
52
|
+
nestedCallId: string;
|
|
53
|
+
code: ForgeErrorCode;
|
|
54
|
+
message: string;
|
|
55
|
+
/** Known once the tool was found; lets the caller journal the failed attempt. */
|
|
56
|
+
effect?: string;
|
|
57
|
+
};
|
|
58
|
+
export interface NestedCallDispatcher {
|
|
59
|
+
/** The tools a nested call can reach right now (what `catalog.search` lists). */
|
|
60
|
+
catalog(): ToolDefinition[];
|
|
61
|
+
/**
|
|
62
|
+
* Whether the registry has a tool of this name, reachable or not. The only names a record or a
|
|
63
|
+
* broadcast may carry: a name a script invented is never stored.
|
|
64
|
+
*/
|
|
65
|
+
knows(name: string): boolean;
|
|
66
|
+
call(request: NestedCallRequest): Promise<NestedCallOutcome>;
|
|
67
|
+
}
|
|
68
|
+
export interface NestedDispatcherDeps {
|
|
69
|
+
registry: ToolRegistry;
|
|
70
|
+
/** The run's live policy object; read at every call (a session approval widens it). */
|
|
71
|
+
policy: Policy;
|
|
72
|
+
/** Whether the run could open an approval for a direct call (only to tell the error apart). */
|
|
73
|
+
approvals: boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Whether a write confined to the scratchpad may run without an approval (default true). It is
|
|
76
|
+
* false only for a run locked down on purpose (a child run with approvals turned off).
|
|
77
|
+
*/
|
|
78
|
+
scratchWrites?: boolean;
|
|
79
|
+
toolFilter?: (tool: ToolDefinition) => boolean;
|
|
80
|
+
optInTools?: readonly string[];
|
|
81
|
+
workspace: string;
|
|
82
|
+
session: string;
|
|
83
|
+
label?: string;
|
|
84
|
+
runId?: string;
|
|
85
|
+
/** The id of the parent tool call; artifacts and decisions are bound to it. */
|
|
86
|
+
parentCallId: string;
|
|
87
|
+
signal: AbortSignal;
|
|
88
|
+
pathAccess?: PathAccess;
|
|
89
|
+
artifacts?: ArtifactPublisher;
|
|
90
|
+
decisions?: ToolDecisions;
|
|
91
|
+
/** The run's guard (context changed, token budget spent): throws to refuse the call. */
|
|
92
|
+
guard?: (effect: string) => void;
|
|
93
|
+
/** Flags the parent call effectful; called when a non-read, non-internal call starts. */
|
|
94
|
+
markEffectful: () => void;
|
|
95
|
+
}
|
|
96
|
+
export declare function createNestedDispatcher(deps: NestedDispatcherDeps): NestedCallDispatcher;
|