@alisio/core 0.5.1 → 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 +38 -2
- package/dist/application.js +213 -4
- package/dist/changelog/data.js +64 -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,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;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { OPT_IN_TOOLS, optInAllows } from "../core/opt-in.js";
|
|
2
|
+
import { decideEffect, effectOf } from "../permissions/decide.js";
|
|
3
|
+
import { isForgeError } from "./errors.js";
|
|
4
|
+
import { forgeCallId } from "./journal.js";
|
|
5
|
+
import { hasScratchEstimator, scratchIncomingBytes } from "./scratch-quota.js";
|
|
6
|
+
/**
|
|
7
|
+
* Tools no script can reach in v1: Forja's own tools and `execute` (no recursion), the guide
|
|
8
|
+
* (a model-facing tool), and the subagent tools (`task`, `task_wait` and `send_message` can wait
|
|
9
|
+
* on a child session that is waiting for this very call: D-29), and `ask_user_question` (the only
|
|
10
|
+
* tool that opens an interactive prompt; approvals and installs are already refused). Opt-in tools (`OPT_IN_TOOLS`) are
|
|
11
|
+
* excluded as well.
|
|
12
|
+
*/
|
|
13
|
+
export const FORGE_EXCLUDED_TOOLS = new Set([
|
|
14
|
+
"js_run",
|
|
15
|
+
"forge_guide",
|
|
16
|
+
"task",
|
|
17
|
+
"task_wait",
|
|
18
|
+
"send_message",
|
|
19
|
+
"execute",
|
|
20
|
+
// Opens an interactive prompt; a script has nobody to ask (D7: never a new approval or UI).
|
|
21
|
+
"ask_user_question",
|
|
22
|
+
]);
|
|
23
|
+
const isPlainObject = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
24
|
+
const messageOf = (error) => error instanceof Error ? error.message : String(error);
|
|
25
|
+
export function createNestedDispatcher(deps) {
|
|
26
|
+
let counter = 0;
|
|
27
|
+
/** The tool when this run lets a script reach it, else undefined (excluded, filtered, absent). */
|
|
28
|
+
const reachable = (name) => {
|
|
29
|
+
if (FORGE_EXCLUDED_TOOLS.has(name) || OPT_IN_TOOLS.has(name))
|
|
30
|
+
return undefined;
|
|
31
|
+
let tool;
|
|
32
|
+
try {
|
|
33
|
+
tool = deps.registry.get(name);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
if (deps.toolFilter && !deps.toolFilter(tool))
|
|
39
|
+
return undefined;
|
|
40
|
+
if (!optInAllows(name, deps.optInTools))
|
|
41
|
+
return undefined;
|
|
42
|
+
return tool;
|
|
43
|
+
};
|
|
44
|
+
const decide = (tool, approvals) => decideEffect(deps.policy, tool, { approvals, capabilityGranted: false });
|
|
45
|
+
const allInScratch = async (targets) => {
|
|
46
|
+
const access = deps.pathAccess;
|
|
47
|
+
if (!access || targets.length === 0)
|
|
48
|
+
return false;
|
|
49
|
+
const inside = await Promise.all(targets.map((t) => access.isScratchPath(t, deps.session)));
|
|
50
|
+
return inside.every(Boolean);
|
|
51
|
+
};
|
|
52
|
+
return {
|
|
53
|
+
knows(name) {
|
|
54
|
+
try {
|
|
55
|
+
deps.registry.get(name);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
catalog() {
|
|
63
|
+
return deps.registry
|
|
64
|
+
.list()
|
|
65
|
+
.filter((tool) => reachable(tool.name) && decide(tool, false).kind === "allow");
|
|
66
|
+
},
|
|
67
|
+
async call(request) {
|
|
68
|
+
const nestedCallId = forgeCallId(deps.parentCallId, ++counter);
|
|
69
|
+
const fail = (code, message, effect) => ({
|
|
70
|
+
ok: false,
|
|
71
|
+
nestedCallId,
|
|
72
|
+
code,
|
|
73
|
+
message,
|
|
74
|
+
...(effect ? { effect } : {}),
|
|
75
|
+
});
|
|
76
|
+
const name = request.name;
|
|
77
|
+
if (typeof name !== "string" || name === "")
|
|
78
|
+
return fail("TOOL_UNAVAILABLE", "tool name must be a non-empty string");
|
|
79
|
+
const tool = reachable(name);
|
|
80
|
+
if (!tool)
|
|
81
|
+
return fail("TOOL_UNAVAILABLE", `Tool "${name}" is not available from js_run`);
|
|
82
|
+
const effect = effectOf(tool);
|
|
83
|
+
const rawInput = request.input === undefined ? {} : request.input;
|
|
84
|
+
if (!isPlainObject(rawInput))
|
|
85
|
+
return fail("TOOL_INPUT_INVALID", "tool input must be a JSON object", effect);
|
|
86
|
+
let input;
|
|
87
|
+
try {
|
|
88
|
+
input = deps.registry.parse(name, JSON.stringify(rawInput));
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
return fail("TOOL_INPUT_INVALID", messageOf(error), effect);
|
|
92
|
+
}
|
|
93
|
+
const signal = request.signal ? AbortSignal.any([deps.signal, request.signal]) : deps.signal;
|
|
94
|
+
// Declared paths are resolved first, like a direct call, but can never ask for a directory.
|
|
95
|
+
const resolved = new Map();
|
|
96
|
+
const targets = [];
|
|
97
|
+
try {
|
|
98
|
+
signal.throwIfAborted();
|
|
99
|
+
if (deps.pathAccess)
|
|
100
|
+
for (const declared of tool.paths?.(input) ?? [])
|
|
101
|
+
if (!resolved.has(declared)) {
|
|
102
|
+
const target = await deps.pathAccess.resolve(declared, {
|
|
103
|
+
workspace: deps.workspace,
|
|
104
|
+
session: deps.session,
|
|
105
|
+
...(deps.label ? { label: deps.label } : {}),
|
|
106
|
+
signal,
|
|
107
|
+
interactive: false,
|
|
108
|
+
});
|
|
109
|
+
resolved.set(declared, target);
|
|
110
|
+
resolved.set(target, target);
|
|
111
|
+
targets.push(target);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (signal.aborted)
|
|
116
|
+
return fail("CANCELLED", "cancelled", effect);
|
|
117
|
+
return fail("PERMISSION_DENIED", messageOf(error), effect);
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
deps.guard?.(effect);
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
return fail("PERMISSION_DENIED", messageOf(error), effect);
|
|
124
|
+
}
|
|
125
|
+
if (decide(tool, false).kind !== "allow") {
|
|
126
|
+
// Same answer as a direct call: "would ask" is APPROVAL_REQUIRED, "never allowed" is denied.
|
|
127
|
+
const direct = decide(tool, deps.approvals);
|
|
128
|
+
// The scratchpad is an authorized write root: a write confined to it needs no approval,
|
|
129
|
+
// also when nobody could be asked (headless).
|
|
130
|
+
const scratchWrite = deps.scratchWrites !== false &&
|
|
131
|
+
effect === "write" &&
|
|
132
|
+
!tool.capability &&
|
|
133
|
+
hasScratchEstimator(name) &&
|
|
134
|
+
decide(tool, true).kind === "ask" &&
|
|
135
|
+
(await allInScratch(targets));
|
|
136
|
+
if (!scratchWrite)
|
|
137
|
+
return direct.kind === "ask"
|
|
138
|
+
? fail("APPROVAL_REQUIRED", `"${name}" needs approval (effect: ${effect}); a script cannot request one. ` +
|
|
139
|
+
"Call it directly or ask the user to allow it for this session.", effect)
|
|
140
|
+
: fail("PERMISSION_DENIED", `Capability denied: ${tool.capability ?? effect}`, effect);
|
|
141
|
+
}
|
|
142
|
+
// A write confined to the scratchpad is bound by its quota, however it was allowed. The check
|
|
143
|
+
// and the write run under one lock, so parallel writes cannot all pass it (L1).
|
|
144
|
+
let releaseScratch;
|
|
145
|
+
try {
|
|
146
|
+
if (effect === "write" && !tool.capability && (await allInScratch(targets))) {
|
|
147
|
+
releaseScratch = await deps.pathAccess?.lockScratch(deps.session);
|
|
148
|
+
const problem = await deps.pathAccess?.scratchQuotaProblem(deps.session, await scratchIncomingBytes(name, input, targets), targets);
|
|
149
|
+
if (problem)
|
|
150
|
+
return fail("BUDGET_EXCEEDED", problem, effect);
|
|
151
|
+
}
|
|
152
|
+
// From here the tool may leave effects, even if the script is then cancelled.
|
|
153
|
+
if (effect !== "read" && effect !== "internal")
|
|
154
|
+
deps.markEffectful();
|
|
155
|
+
request.onStart?.({ nestedCallId, name, effect, paths: targets });
|
|
156
|
+
const context = {
|
|
157
|
+
signal,
|
|
158
|
+
workspace: deps.workspace,
|
|
159
|
+
emit: () => { },
|
|
160
|
+
session: deps.session,
|
|
161
|
+
callId: deps.parentCallId,
|
|
162
|
+
...(deps.runId ? { runId: deps.runId } : {}),
|
|
163
|
+
...(deps.label ? { label: deps.label } : {}),
|
|
164
|
+
...(deps.artifacts ? { artifacts: deps.artifacts } : {}),
|
|
165
|
+
...(deps.decisions ? { decisions: deps.decisions } : {}),
|
|
166
|
+
// A script can never open the installation prompt.
|
|
167
|
+
approveInstall: async () => "deny",
|
|
168
|
+
...(deps.pathAccess
|
|
169
|
+
? {
|
|
170
|
+
resolvePath: (path) => {
|
|
171
|
+
const known = resolved.get(path);
|
|
172
|
+
return known !== undefined
|
|
173
|
+
? Promise.resolve(known)
|
|
174
|
+
: deps.pathAccess.resolve(path, {
|
|
175
|
+
workspace: deps.workspace,
|
|
176
|
+
session: deps.session,
|
|
177
|
+
...(deps.label ? { label: deps.label } : {}),
|
|
178
|
+
signal,
|
|
179
|
+
interactive: false,
|
|
180
|
+
});
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
: {}),
|
|
184
|
+
};
|
|
185
|
+
let result;
|
|
186
|
+
try {
|
|
187
|
+
result = await tool.execute(input, context);
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
if (signal.aborted)
|
|
191
|
+
return fail("CANCELLED", "cancelled", effect);
|
|
192
|
+
return fail(isForgeError(error) ? error.code : "TOOL_FAILED", messageOf(error), effect);
|
|
193
|
+
}
|
|
194
|
+
const text = result.content
|
|
195
|
+
.filter((part) => part.type === "text")
|
|
196
|
+
.map((part) => part.text)
|
|
197
|
+
.join("\n");
|
|
198
|
+
if (result.isError)
|
|
199
|
+
return fail("TOOL_FAILED", text, effect);
|
|
200
|
+
let value = text;
|
|
201
|
+
try {
|
|
202
|
+
value = JSON.parse(text);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
/* Not JSON: the text itself is the value. */
|
|
206
|
+
}
|
|
207
|
+
return { ok: true, nestedCallId, effect, text, value, result };
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
releaseScratch?.();
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ports of the Forja core (hexagonal). Engines, hosts, transports and stores are adapters; the
|
|
3
|
+
* core never depends on QuickJS, Pyodide, Docker or SQLite. Types only: no runtime code.
|
|
4
|
+
*
|
|
5
|
+
* Later phases add the remaining ports (`IsolateHost`, `Transport`, `ExecutionJournal`,
|
|
6
|
+
* `StateStore`, `GuideProvider`) next to these.
|
|
7
|
+
*/
|
|
8
|
+
import type { EngineDescriptor, EngineId, EngineLanguage, IsolationLevel, PermissionMode } from "@alisio/sdk";
|
|
9
|
+
import type { ForgeErrorCode } from "./errors.ts";
|
|
10
|
+
import type { ForgeLimits } from "./limits.ts";
|
|
11
|
+
import type { GuestMessage, HostMessage } from "./protocol.ts";
|
|
12
|
+
export type { EngineDescriptor, EngineId, EngineLanguage, IsolationLevel };
|
|
13
|
+
export interface ExecutionRequest {
|
|
14
|
+
language: EngineLanguage;
|
|
15
|
+
source: string;
|
|
16
|
+
title?: string;
|
|
17
|
+
/** Can only lower the effective limits. */
|
|
18
|
+
requestedLimits?: Partial<ForgeLimits>;
|
|
19
|
+
/** Configured or forced by the user; never chosen by the model. */
|
|
20
|
+
engineHint?: EngineId;
|
|
21
|
+
/** Per-engine extensions (inputs, extras, rerunOf...). */
|
|
22
|
+
payload?: Record<string, unknown>;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Built only by the core from the runner's tool context. No field of the model's input can
|
|
26
|
+
* override it.
|
|
27
|
+
*/
|
|
28
|
+
export interface TrustedExecutionContext {
|
|
29
|
+
executionId: string;
|
|
30
|
+
toolCallId: string;
|
|
31
|
+
rootSessionId: string;
|
|
32
|
+
workspace: string;
|
|
33
|
+
permissionMode: PermissionMode;
|
|
34
|
+
allowAnalysis: boolean;
|
|
35
|
+
allowProcess: boolean;
|
|
36
|
+
signal: AbortSignal;
|
|
37
|
+
limits: ForgeLimits;
|
|
38
|
+
}
|
|
39
|
+
/** An execution ready to run; engines extend it with their own handles. */
|
|
40
|
+
export interface PreparedExecution {
|
|
41
|
+
readonly executionId: string;
|
|
42
|
+
readonly engine: EngineId;
|
|
43
|
+
readonly limits: ForgeLimits;
|
|
44
|
+
/** The `analysis_executions` row this execution created (Python); the journal links to it. */
|
|
45
|
+
readonly analysisExecutionId?: string;
|
|
46
|
+
}
|
|
47
|
+
export type TerminationReason = "cancelled" | "timeout" | "protocol_violation" | "limit_exceeded";
|
|
48
|
+
export interface TerminationReport {
|
|
49
|
+
/** The host checked that the guest no longer runs. */
|
|
50
|
+
confirmed: boolean;
|
|
51
|
+
method: "interrupt" | "terminate" | "kill" | "container-kill";
|
|
52
|
+
elapsedMs: number;
|
|
53
|
+
/** Resolves once the guest is finally gone; lets an unconfirmed termination keep its slot until then. */
|
|
54
|
+
settled?: Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
export type EngineOutcome = {
|
|
57
|
+
kind: "completed";
|
|
58
|
+
value?: unknown;
|
|
59
|
+
} | {
|
|
60
|
+
kind: "failed";
|
|
61
|
+
code: ForgeErrorCode;
|
|
62
|
+
message: string;
|
|
63
|
+
};
|
|
64
|
+
type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
|
|
65
|
+
/** A message for the guest without the envelope fields the transport stamps (`protocol`, `seq`). */
|
|
66
|
+
export type HostReply = DistributiveOmit<HostMessage, "protocol" | "seq">;
|
|
67
|
+
/**
|
|
68
|
+
* The kernel's side of an execution, handed to the engine's `run`. The engine owns the transport
|
|
69
|
+
* and the isolate; the kernel decides what each guest message means.
|
|
70
|
+
*/
|
|
71
|
+
export interface KernelChannel {
|
|
72
|
+
/**
|
|
73
|
+
* Called by the engine for every guest message that passed validation (schema, sequence,
|
|
74
|
+
* correlation, size, capability gate), CONCURRENTLY: a slow `call` never blocks the next
|
|
75
|
+
* message. The optional reply goes to the guest. Throwing a `ForgeError` ends the execution with
|
|
76
|
+
* that code.
|
|
77
|
+
*/
|
|
78
|
+
receive(message: GuestMessage): Promise<HostReply | undefined>;
|
|
79
|
+
/** Called once by the engine with the function that sends a message to the guest (credit grants). */
|
|
80
|
+
bind(send: (reply: HostReply) => void): void;
|
|
81
|
+
/** Aborted when the kernel stops the execution (cancellation or deadline). */
|
|
82
|
+
readonly signal: AbortSignal;
|
|
83
|
+
}
|
|
84
|
+
/** Isolation hosts (spec §6.3, D-20): where the VM runs, and how it is stopped. */
|
|
85
|
+
export interface IsolateExit {
|
|
86
|
+
code: number | null;
|
|
87
|
+
signal: string | null;
|
|
88
|
+
/** Spawn or worker error, when the isolate could not run at all. */
|
|
89
|
+
error?: string;
|
|
90
|
+
}
|
|
91
|
+
export interface IsolateSession {
|
|
92
|
+
readonly kind: "worker" | "process";
|
|
93
|
+
/** Raw guest messages; validation is the engine's job. */
|
|
94
|
+
onMessage(handler: (raw: unknown) => void): void;
|
|
95
|
+
/** The stream broke (oversized or malformed frame): a protocol violation. */
|
|
96
|
+
onStreamError(handler: (reason: string) => void): void;
|
|
97
|
+
send(message: unknown): void;
|
|
98
|
+
readonly exited: Promise<IsolateExit>;
|
|
99
|
+
/** Stops the guest by the host's own means and reports whether it is confirmed gone. */
|
|
100
|
+
stop(reason: TerminationReason): Promise<TerminationReport>;
|
|
101
|
+
}
|
|
102
|
+
export interface IsolateHost {
|
|
103
|
+
readonly kind: "worker" | "process";
|
|
104
|
+
/** Starts an isolate. The guest sends `ready` first; nothing runs until the host sends `start`. */
|
|
105
|
+
start(): IsolateSession;
|
|
106
|
+
}
|
|
107
|
+
export interface ExecutionEngine {
|
|
108
|
+
readonly id: EngineId;
|
|
109
|
+
describe(): Promise<EngineDescriptor>;
|
|
110
|
+
prepare(request: ExecutionRequest, ctx: TrustedExecutionContext): Promise<PreparedExecution>;
|
|
111
|
+
run(prepared: PreparedExecution, channel: KernelChannel): Promise<EngineOutcome>;
|
|
112
|
+
terminate(prepared: PreparedExecution, reason: TerminationReason): Promise<TerminationReport>;
|
|
113
|
+
/**
|
|
114
|
+
* Optional step between `run` and the commit of the shared state, for engines whose results must
|
|
115
|
+
* be published first (Python's artifacts). It runs only when the host did not stop the execution,
|
|
116
|
+
* and may turn a completed outcome into a failed one: a failure discards the state delta.
|
|
117
|
+
*/
|
|
118
|
+
collect?(prepared: PreparedExecution, outcome: EngineOutcome): Promise<EngineOutcome>;
|
|
119
|
+
dispose(prepared: PreparedExecution): Promise<void>;
|
|
120
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progress coalescing (spec §5.4, D14): at most 4 progress events per second reach the UI, but a
|
|
3
|
+
* terminal event (a finished call, the end of a phase) is never dropped. Non-terminal events that
|
|
4
|
+
* arrive too soon are replaced by the newest one of their category (script progress, a call
|
|
5
|
+
* starting, anything else) and delivered when that category's window reopens, so a burst of
|
|
6
|
+
* calls never hides the script's own progress. Pure but for the injected clock and timer.
|
|
7
|
+
*/
|
|
8
|
+
import type { ForgeProgress } from "@alisio/sdk";
|
|
9
|
+
export declare const PROGRESS_INTERVAL_MS = 250;
|
|
10
|
+
export interface ProgressClock {
|
|
11
|
+
now(): number;
|
|
12
|
+
/** Schedules `run` after `ms` and returns how to cancel it. */
|
|
13
|
+
schedule(run: () => void, ms: number): () => void;
|
|
14
|
+
}
|
|
15
|
+
export declare class ProgressCoalescer {
|
|
16
|
+
#private;
|
|
17
|
+
constructor(sink: (progress: ForgeProgress) => void, clock?: ProgressClock);
|
|
18
|
+
/** A progress event that may be replaced by a newer one of its category. */
|
|
19
|
+
update(progress: ForgeProgress): void;
|
|
20
|
+
/** An event that must reach the UI: delivered now, after anything still waiting. */
|
|
21
|
+
terminal(progress: ForgeProgress): void;
|
|
22
|
+
/** Delivers what is waiting and stops the timers. */
|
|
23
|
+
close(): void;
|
|
24
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export const PROGRESS_INTERVAL_MS = 250;
|
|
2
|
+
const realClock = {
|
|
3
|
+
now: () => Date.now(),
|
|
4
|
+
schedule(run, ms) {
|
|
5
|
+
const timer = setTimeout(run, ms);
|
|
6
|
+
timer.unref?.();
|
|
7
|
+
return () => clearTimeout(timer);
|
|
8
|
+
},
|
|
9
|
+
};
|
|
10
|
+
const categoryOf = (progress) => progress.phase === "call"
|
|
11
|
+
? "call"
|
|
12
|
+
: progress.message !== undefined ||
|
|
13
|
+
progress.completed !== undefined ||
|
|
14
|
+
progress.total !== undefined
|
|
15
|
+
? "progress"
|
|
16
|
+
: "other";
|
|
17
|
+
export class ProgressCoalescer {
|
|
18
|
+
#sink;
|
|
19
|
+
#clock;
|
|
20
|
+
#lanes = new Map();
|
|
21
|
+
#order = 0;
|
|
22
|
+
constructor(sink, clock = realClock) {
|
|
23
|
+
this.#sink = sink;
|
|
24
|
+
this.#clock = clock;
|
|
25
|
+
}
|
|
26
|
+
/** A progress event that may be replaced by a newer one of its category. */
|
|
27
|
+
update(progress) {
|
|
28
|
+
const category = categoryOf(progress);
|
|
29
|
+
let lane = this.#lanes.get(category);
|
|
30
|
+
if (!lane) {
|
|
31
|
+
lane = { lastAt: Number.NEGATIVE_INFINITY };
|
|
32
|
+
this.#lanes.set(category, lane);
|
|
33
|
+
}
|
|
34
|
+
const wait = lane.lastAt + PROGRESS_INTERVAL_MS - this.#clock.now();
|
|
35
|
+
if (wait <= 0 && lane.pending === undefined)
|
|
36
|
+
return this.#deliver(lane, progress);
|
|
37
|
+
lane.pending = { progress, order: this.#order++ };
|
|
38
|
+
const owner = lane;
|
|
39
|
+
lane.cancel ??= this.#clock.schedule(() => {
|
|
40
|
+
owner.cancel = undefined;
|
|
41
|
+
this.#flushLane(owner);
|
|
42
|
+
}, Math.max(wait, 0));
|
|
43
|
+
}
|
|
44
|
+
/** An event that must reach the UI: delivered now, after anything still waiting. */
|
|
45
|
+
terminal(progress) {
|
|
46
|
+
this.#flush();
|
|
47
|
+
this.#sink(progress);
|
|
48
|
+
}
|
|
49
|
+
/** Delivers what is waiting and stops the timers. */
|
|
50
|
+
close() {
|
|
51
|
+
this.#flush();
|
|
52
|
+
}
|
|
53
|
+
/** Everything waiting, in arrival order. */
|
|
54
|
+
#flush() {
|
|
55
|
+
const waiting = [];
|
|
56
|
+
for (const lane of this.#lanes.values()) {
|
|
57
|
+
lane.cancel?.();
|
|
58
|
+
lane.cancel = undefined;
|
|
59
|
+
if (lane.pending)
|
|
60
|
+
waiting.push({ lane, ...lane.pending });
|
|
61
|
+
lane.pending = undefined;
|
|
62
|
+
}
|
|
63
|
+
for (const item of waiting.sort((a, b) => a.order - b.order))
|
|
64
|
+
this.#deliver(item.lane, item.progress);
|
|
65
|
+
}
|
|
66
|
+
#flushLane(lane) {
|
|
67
|
+
const pending = lane.pending;
|
|
68
|
+
lane.pending = undefined;
|
|
69
|
+
if (pending)
|
|
70
|
+
this.#deliver(lane, pending.progress);
|
|
71
|
+
}
|
|
72
|
+
#deliver(lane, progress) {
|
|
73
|
+
lane.lastAt = this.#clock.now();
|
|
74
|
+
this.#sink(progress);
|
|
75
|
+
}
|
|
76
|
+
}
|