@inhuman.tools/scheme-program 0.0.0-stage → 0.2.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/LICENSE.md +7 -0
- package/dist/emission.d.ts +10 -0
- package/dist/emission.d.ts.map +1 -0
- package/dist/emission.js +74 -0
- package/dist/emission.js.map +1 -0
- package/dist/eval.d.ts +7 -0
- package/dist/eval.d.ts.map +1 -0
- package/dist/eval.js +6 -0
- package/dist/eval.js.map +1 -0
- package/dist/evaluate.d.ts +90 -0
- package/dist/evaluate.d.ts.map +1 -0
- package/dist/evaluate.js +316 -0
- package/dist/evaluate.js.map +1 -0
- package/dist/failure.d.ts +10 -0
- package/dist/failure.d.ts.map +1 -0
- package/dist/failure.js +30 -0
- package/dist/failure.js.map +1 -0
- package/dist/file-path.d.ts +9 -0
- package/dist/file-path.d.ts.map +1 -0
- package/dist/file-path.js +25 -0
- package/dist/file-path.js.map +1 -0
- package/dist/host.d.ts +55 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +2 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/notes.d.ts +12 -0
- package/dist/notes.d.ts.map +1 -0
- package/dist/notes.js +12 -0
- package/dist/notes.js.map +1 -0
- package/dist/open.d.ts +23 -0
- package/dist/open.d.ts.map +1 -0
- package/dist/open.js +53 -0
- package/dist/open.js.map +1 -0
- package/dist/replay-eval.d.ts +5 -0
- package/dist/replay-eval.d.ts.map +1 -0
- package/dist/replay-eval.js +72 -0
- package/dist/replay-eval.js.map +1 -0
- package/dist/run.d.ts +4 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +81 -0
- package/dist/run.js.map +1 -0
- package/dist/runtime.d.ts +8 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +77 -0
- package/dist/runtime.js.map +1 -0
- package/dist/source-fs.d.ts +4 -0
- package/dist/source-fs.d.ts.map +1 -0
- package/dist/source-fs.js +36 -0
- package/dist/source-fs.js.map +1 -0
- package/dist/source.d.ts +22 -0
- package/dist/source.d.ts.map +1 -0
- package/dist/source.js +76 -0
- package/dist/source.js.map +1 -0
- package/package.json +67 -3
- package/src/emission.ts +82 -0
- package/src/eval.ts +20 -0
- package/src/evaluate.ts +398 -0
- package/src/failure.ts +35 -0
- package/src/file-path.ts +27 -0
- package/src/host.ts +57 -0
- package/src/index.ts +8 -0
- package/src/notes.ts +15 -0
- package/src/open.ts +73 -0
- package/src/replay-eval.ts +84 -0
- package/src/run.ts +103 -0
- package/src/runtime.ts +99 -0
- package/src/source-fs.ts +35 -0
- package/src/source.ts +78 -0
- package/README.md +0 -3
package/dist/source.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module `require` jail. Eval with `sourcePath` stays under the entry file's
|
|
3
|
+
* directory. Interactive `scheme_repl` / `scheme_watch` jail at the workspace
|
|
4
|
+
* (`host.root`). Skill inputs stay on licensed `read_file`.
|
|
5
|
+
*/
|
|
6
|
+
import { toJS } from "@here.build/arrival";
|
|
7
|
+
import { makeFsLoader } from "@here.build/arrival-modules";
|
|
8
|
+
export function programSourceOf(raw, allow) {
|
|
9
|
+
if (!allow || raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
10
|
+
return {};
|
|
11
|
+
const rec = raw;
|
|
12
|
+
const source = typeof rec.source === "string" && rec.source !== "" ? rec.source : undefined;
|
|
13
|
+
const sourcePath = typeof rec.sourcePath === "string" && rec.sourcePath.trim() !== "" ? rec.sourcePath.trim() : undefined;
|
|
14
|
+
return { source, sourcePath };
|
|
15
|
+
}
|
|
16
|
+
function parentDir(path) {
|
|
17
|
+
const slash = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
|
|
18
|
+
if (slash <= 0)
|
|
19
|
+
return slash === 0 ? path.slice(0, 1) : ".";
|
|
20
|
+
return path.slice(0, slash);
|
|
21
|
+
}
|
|
22
|
+
export function moduleRootOf(sourcePath, override, workspaceRoot) {
|
|
23
|
+
if (override != null && override !== "")
|
|
24
|
+
return override;
|
|
25
|
+
if (sourcePath != null && sourcePath !== "")
|
|
26
|
+
return parentDir(sourcePath);
|
|
27
|
+
if (workspaceRoot != null && workspaceRoot !== "")
|
|
28
|
+
return workspaceRoot;
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
/** Replay read. The live loader records the same key the first time it touches disk. */
|
|
32
|
+
export function recordedModuleLoader(job) {
|
|
33
|
+
return makeFsLoader({
|
|
34
|
+
readFile(key) {
|
|
35
|
+
const text = job.modules[key];
|
|
36
|
+
if (text === undefined)
|
|
37
|
+
throw new Error(`replay: no recorded module ${key}`);
|
|
38
|
+
return text;
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/** toJS of a dict or list is an egress proxy. structuredClone rejects proxies. */
|
|
43
|
+
function plainData(value, seen = new WeakSet()) {
|
|
44
|
+
if (typeof value === "function")
|
|
45
|
+
throw new Error("not json");
|
|
46
|
+
if (value === null || typeof value !== "object")
|
|
47
|
+
return value;
|
|
48
|
+
if (seen.has(value))
|
|
49
|
+
throw new Error("cycle");
|
|
50
|
+
seen.add(value);
|
|
51
|
+
if (Array.isArray(value))
|
|
52
|
+
return value.map((item) => plainData(item, seen));
|
|
53
|
+
const out = {};
|
|
54
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
55
|
+
if (typeof key !== "string")
|
|
56
|
+
throw new Error("not json");
|
|
57
|
+
out[key] = plainData(value[key], seen);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
/** Objects/arrays become the additive `json` sibling. Scalars stay display-only. */
|
|
62
|
+
export function structuredJson(value) {
|
|
63
|
+
const js = toJS(value);
|
|
64
|
+
if (typeof js === "function")
|
|
65
|
+
return { jsonError: "value is not JSON-compatible" };
|
|
66
|
+
if (js === null || typeof js !== "object")
|
|
67
|
+
return {};
|
|
68
|
+
try {
|
|
69
|
+
const cloned = structuredClone(plainData(js));
|
|
70
|
+
return { json: cloned };
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return { jsonError: "value is not JSON-compatible" };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=source.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"source.js","sourceRoot":"","sources":["../src/source.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,70 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inhuman.tools/scheme-program",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Scheme program tool. Evaluates one ProgrammaticJob. The host passes the EnvCapabilities; this package does not know the catalog or the tool bodies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./eval": {
|
|
15
|
+
"types": "./dist/eval.d.ts",
|
|
16
|
+
"import": "./dist/eval.js",
|
|
17
|
+
"default": "./dist/eval.js"
|
|
18
|
+
},
|
|
19
|
+
"./fs": {
|
|
20
|
+
"types": "./dist/source-fs.d.ts",
|
|
21
|
+
"import": "./dist/source-fs.js",
|
|
22
|
+
"default": "./dist/source-fs.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"src",
|
|
28
|
+
"!**/__tests__"
|
|
29
|
+
],
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@here.build/arrival": "^0.10.2",
|
|
32
|
+
"@here.build/arrival-modules": "^0.10.0",
|
|
33
|
+
"@here.build/arrival-serializer": "^0.10.2",
|
|
34
|
+
"mobx": "6.16.1",
|
|
35
|
+
"tiny-invariant": "^1.3.3",
|
|
36
|
+
"@inhuman.tools/harness-model": "^0.2.0",
|
|
37
|
+
"@inhuman.tools/substrate": "^0.9.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^24.0.0",
|
|
41
|
+
"eslint": "9.39.2",
|
|
42
|
+
"typescript": "6.0.2",
|
|
43
|
+
"vitest": "4.1.0",
|
|
44
|
+
"@-internal-/eslint-configs": "0.0.0"
|
|
45
|
+
},
|
|
46
|
+
"license": "MIT",
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "git+https://github.com/here-build/inhuman-harness.git",
|
|
50
|
+
"directory": "packages-runtime/scheme-program"
|
|
51
|
+
},
|
|
52
|
+
"browserslist": [
|
|
53
|
+
"last 2 chrome versions",
|
|
54
|
+
"last 2 firefox versions",
|
|
55
|
+
"last 2 safari versions",
|
|
56
|
+
"ios_saf >= 16.4",
|
|
57
|
+
"node >= 22"
|
|
58
|
+
],
|
|
59
|
+
"author": "Merkle Bonsai",
|
|
60
|
+
"publishConfig": {
|
|
61
|
+
"access": "public"
|
|
62
|
+
},
|
|
63
|
+
"scripts": {
|
|
64
|
+
"build": "tsc -p tsconfig.build.json",
|
|
65
|
+
"lint": "eslint .",
|
|
66
|
+
"lint:fix": "eslint . --fix",
|
|
67
|
+
"test": "vitest run",
|
|
68
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
69
|
+
}
|
|
6
70
|
}
|
package/src/emission.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Closure windows for one evaluation. The store is discarded: the track is
|
|
3
|
+
* stamped on the child, and that is the record a replay matches.
|
|
4
|
+
*/
|
|
5
|
+
import type { ExecOptions, SchemeValue } from "@here.build/arrival";
|
|
6
|
+
import { buildWireframe } from "@here.build/arrival/provenance";
|
|
7
|
+
|
|
8
|
+
export type ProgramEmission = NonNullable<ExecOptions["emission"]>;
|
|
9
|
+
|
|
10
|
+
const store: ProgramEmission["store"] = {
|
|
11
|
+
async append() {},
|
|
12
|
+
async allocateSeq() {
|
|
13
|
+
return 0;
|
|
14
|
+
},
|
|
15
|
+
async readStream() {
|
|
16
|
+
return [];
|
|
17
|
+
},
|
|
18
|
+
async getHeader() {
|
|
19
|
+
return undefined;
|
|
20
|
+
},
|
|
21
|
+
async putHeader() {},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const payloads: ProgramEmission["payloads"] = {
|
|
25
|
+
async put() {},
|
|
26
|
+
async get() {
|
|
27
|
+
throw new Error("program emission does not read payloads");
|
|
28
|
+
},
|
|
29
|
+
async settle() {},
|
|
30
|
+
async evict() {},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Verbs that start one track per element. Anything else is a source, so a lambda that calls it is a closure. */
|
|
34
|
+
const FANS = new Set([
|
|
35
|
+
"map",
|
|
36
|
+
"for-each",
|
|
37
|
+
"filter",
|
|
38
|
+
"filter-map",
|
|
39
|
+
"partition",
|
|
40
|
+
"append-map",
|
|
41
|
+
"fold",
|
|
42
|
+
"fold-right",
|
|
43
|
+
"vector-map",
|
|
44
|
+
"vector-for-each",
|
|
45
|
+
"string-map",
|
|
46
|
+
"string-for-each",
|
|
47
|
+
"pair-for-each",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const wireframeOptions = {
|
|
51
|
+
classifier: {
|
|
52
|
+
roleOf(op: string): "fan" | "source" {
|
|
53
|
+
return FANS.has(op) ? "fan" : "source";
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
isBaseName: (): boolean => false,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export function harnessEmission(forms: readonly SchemeValue[]): ProgramEmission {
|
|
60
|
+
const closureSites = new Map<object, { readonly templateHash: string; readonly rootOrdinal: number }>();
|
|
61
|
+
const emission: ProgramEmission = {
|
|
62
|
+
store,
|
|
63
|
+
payloads,
|
|
64
|
+
regionId: "harness",
|
|
65
|
+
coordinate: { templateHash: "harness", ordinalPath: [0], regionEpoch: "run" },
|
|
66
|
+
closureSites,
|
|
67
|
+
};
|
|
68
|
+
extendEmission(emission, forms);
|
|
69
|
+
return emission;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Same parsed pairs the following `execState` will run. Sites are keyed by pair identity. */
|
|
73
|
+
export function extendEmission(emission: ProgramEmission, forms: readonly SchemeValue[]): void {
|
|
74
|
+
if (forms.length === 0 || !(emission.closureSites instanceof Map)) return;
|
|
75
|
+
try {
|
|
76
|
+
const built = buildWireframe(forms, wireframeOptions).closureSites;
|
|
77
|
+
if (built === undefined) return;
|
|
78
|
+
for (const [form, site] of built) emission.closureSites.set(form, site);
|
|
79
|
+
} catch {
|
|
80
|
+
// A program the wireframe refuses still runs. Its crossings share the root window.
|
|
81
|
+
}
|
|
82
|
+
}
|
package/src/eval.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { appendNotify } from "./failure.js";
|
|
2
|
+
export {
|
|
3
|
+
cancelLiveInvocations,
|
|
4
|
+
cancelled,
|
|
5
|
+
displayPrinted,
|
|
6
|
+
evaluateForms,
|
|
7
|
+
fail,
|
|
8
|
+
guardProgram,
|
|
9
|
+
hostAbortReason,
|
|
10
|
+
observationText,
|
|
11
|
+
observed,
|
|
12
|
+
parked,
|
|
13
|
+
printedValues,
|
|
14
|
+
sealChunk,
|
|
15
|
+
waitPeriod,
|
|
16
|
+
} from "./evaluate.js";
|
|
17
|
+
export { type ProgramHost } from "./host.js";
|
|
18
|
+
export { openProgram } from "./open.js";
|
|
19
|
+
export { programRuntimeOf } from "./runtime.js";
|
|
20
|
+
export { moduleRootOf } from "./source.js";
|
package/src/evaluate.ts
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One Arrival evaluation of a ProgrammaticJob. Inner tools parent onto
|
|
3
|
+
* `job.children`. Wait is Arrival's trampoline. Not catalog execute.
|
|
4
|
+
*/
|
|
5
|
+
import { execState, type LexicalScope, type SchemeValue } from "@here.build/arrival";
|
|
6
|
+
import { createDisplaySink } from "@here.build/arrival/host-internals";
|
|
7
|
+
import { EvalTrace } from "@here.build/arrival/provenance";
|
|
8
|
+
import { AString, AVoid } from "@here.build/arrival/reflect-internals";
|
|
9
|
+
import { toSExprString } from "@here.build/arrival-serializer";
|
|
10
|
+
import {
|
|
11
|
+
armCrossings,
|
|
12
|
+
crossingEpoch,
|
|
13
|
+
markReplaying,
|
|
14
|
+
CallJob,
|
|
15
|
+
type ApprovableJob,
|
|
16
|
+
cancelOutcomeText,
|
|
17
|
+
isAbortError,
|
|
18
|
+
isTimeoutAbortReason,
|
|
19
|
+
MCPToolJob,
|
|
20
|
+
ProgrammaticJob,
|
|
21
|
+
cancelUnstarted,
|
|
22
|
+
rememberJobResult,
|
|
23
|
+
RESULT_NOTE_CHARS,
|
|
24
|
+
SCHEME_REPL_NAMING,
|
|
25
|
+
ToolCallJob,
|
|
26
|
+
} from "@inhuman.tools/harness-model";
|
|
27
|
+
import { isTopLevelDisplay, stripTopLevelDisplay } from "@inhuman.tools/substrate";
|
|
28
|
+
import { importDoor, unboundInExprDoor } from "@inhuman.tools/substrate/doors";
|
|
29
|
+
import { type TopLevelSplice } from "@inhuman.tools/substrate/unwrap-begin";
|
|
30
|
+
import { action } from "mobx";
|
|
31
|
+
|
|
32
|
+
import { extendEmission, harnessEmission, type ProgramEmission } from "./emission.js";
|
|
33
|
+
import { failureCodeOf } from "./failure.js";
|
|
34
|
+
import { filePathOf } from "./file-path.js";
|
|
35
|
+
import type { ProgramHost, ProgramRuntime } from "./host.js";
|
|
36
|
+
import { SCHEME_REPL_DISPLAY_NOTE, SCHEME_REPL_UNWRAP_NOTES } from "./notes.js";
|
|
37
|
+
import { structuredJson } from "./source.js";
|
|
38
|
+
|
|
39
|
+
const rejectInner = action((leaf: ApprovableJob, reason?: unknown): void => {
|
|
40
|
+
cancelUnstarted(leaf, reason);
|
|
41
|
+
if (leaf instanceof CallJob) return;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const UNBOUND_VARIABLE = /Unbound variable [`']?([^`'\s]+)/;
|
|
45
|
+
|
|
46
|
+
export type FailResult = { readonly ok: false; readonly text: string; readonly code?: string };
|
|
47
|
+
export type FilePrint = {
|
|
48
|
+
readonly text: string;
|
|
49
|
+
readonly face: { readonly kind: "file"; readonly path: string };
|
|
50
|
+
};
|
|
51
|
+
export type PrintedValue = string | FilePrint;
|
|
52
|
+
export type ValueResult = {
|
|
53
|
+
readonly ok: boolean;
|
|
54
|
+
readonly value: PrintedValue[];
|
|
55
|
+
readonly json?: unknown;
|
|
56
|
+
readonly jsonError?: string;
|
|
57
|
+
};
|
|
58
|
+
export type CallResult = FailResult | ValueResult;
|
|
59
|
+
|
|
60
|
+
export type EvalOutcome =
|
|
61
|
+
| {
|
|
62
|
+
readonly kind: "ok";
|
|
63
|
+
readonly values: PrintedValue[];
|
|
64
|
+
readonly json?: unknown;
|
|
65
|
+
readonly jsonError?: string;
|
|
66
|
+
}
|
|
67
|
+
| { readonly kind: "fail"; readonly values: PrintedValue[]; readonly code?: string }
|
|
68
|
+
| { readonly kind: "halt"; readonly result: FailResult }
|
|
69
|
+
| { readonly kind: "park" };
|
|
70
|
+
|
|
71
|
+
export type SharedEval = {
|
|
72
|
+
scope?: LexicalScope;
|
|
73
|
+
readonly signal: AbortSignal;
|
|
74
|
+
emission?: ProgramEmission;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export type EvaluateOpts = {
|
|
78
|
+
readonly timeoutMs: number | undefined;
|
|
79
|
+
readonly maxInternalCalls: number | undefined;
|
|
80
|
+
/** The host's trace for this run. Absent: a period tick mints its own, a one-shot goes untraced. */
|
|
81
|
+
readonly trace?: EvalTrace;
|
|
82
|
+
} & (
|
|
83
|
+
| { readonly kind: "once" }
|
|
84
|
+
| { readonly kind: "period"; readonly tick: { readonly origin: number } }
|
|
85
|
+
| { readonly kind: "source-load"; readonly shared: SharedEval }
|
|
86
|
+
| { readonly kind: "source-body"; readonly shared: SharedEval }
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
export function errorText(error: unknown): string {
|
|
90
|
+
if (isAbortError(error) && !isTimeoutAbortReason(error)) return "Error: Scheme REPL call cancelled";
|
|
91
|
+
if (isTimeoutAbortReason(error)) {
|
|
92
|
+
const text = cancelOutcomeText(error);
|
|
93
|
+
return text.startsWith("Error:") ? text : `Error: ${text}`;
|
|
94
|
+
}
|
|
95
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
96
|
+
return raw.startsWith("Error:") ? raw : `Error: ${raw}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function fail(text: string, code?: string): FailResult {
|
|
100
|
+
return code != null && code !== "" ? { ok: false, text, code } : { ok: false, text };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Strings stay raw (display); path-stamped file strings keep a TUI face. */
|
|
104
|
+
export function observationText(value: SchemeValue): PrintedValue {
|
|
105
|
+
if (typeof value === "string") return value;
|
|
106
|
+
if (value instanceof AString) {
|
|
107
|
+
const text = value.valueOf();
|
|
108
|
+
const path = filePathOf(value);
|
|
109
|
+
if (path !== undefined && path !== "") return { text, face: { kind: "file", path } };
|
|
110
|
+
return text;
|
|
111
|
+
}
|
|
112
|
+
return toSExprString(value);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function displayPrinted(value: PrintedValue): string {
|
|
116
|
+
return typeof value === "string" ? value : value.text;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isParkAbort(signal: AbortSignal): boolean {
|
|
120
|
+
return signal.aborted && signal.reason === "park";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function timedOutText(timeoutMs: number | undefined): string {
|
|
124
|
+
return timeoutMs == null ? "Error: evaluation timed out" : `Error: evaluation timed out after ${timeoutMs}ms`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function haltResult(signal: AbortSignal, timeoutMs: number | undefined, error?: unknown): FailResult | undefined {
|
|
128
|
+
if (isParkAbort(signal)) return undefined;
|
|
129
|
+
if (signal.aborted) {
|
|
130
|
+
const text = cancelOutcomeText(signal.reason);
|
|
131
|
+
if (text === "timed out") return fail(timedOutText(timeoutMs));
|
|
132
|
+
return fail(text === "cancelled" ? "Error: Scheme REPL call cancelled" : `Error: ${text}`);
|
|
133
|
+
}
|
|
134
|
+
if (error instanceof Error && /execution budget exceeded/.test(error.message)) {
|
|
135
|
+
return fail(timedOutText(timeoutMs));
|
|
136
|
+
}
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function resultNoteText(result: CallResult): string {
|
|
141
|
+
if ("text" in result) return result.text;
|
|
142
|
+
return result.value.map((item) => (typeof item === "string" ? item : item.text)).join(" ");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function sealChunk(chunk: ProgrammaticJob, result: CallResult): void {
|
|
146
|
+
if (chunk.completed) return;
|
|
147
|
+
rememberJobResult(chunk, resultNoteText(result), RESULT_NOTE_CHARS);
|
|
148
|
+
chunk.outcomeSnap = result;
|
|
149
|
+
chunk.markComplete();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function cancelLiveInvocations(job: ProgrammaticJob, host: ProgramHost, reason?: unknown): void {
|
|
153
|
+
for (const inner of job.children) {
|
|
154
|
+
if (!(inner instanceof ToolCallJob)) continue;
|
|
155
|
+
if (inner.completed || inner.cancelled) continue;
|
|
156
|
+
if (inner instanceof MCPToolJob && inner.dispatched) continue; // on-wire mutation — do not cancel a dispatched call
|
|
157
|
+
|
|
158
|
+
if (inner.orchestrated) {
|
|
159
|
+
host.abort?.(inner, reason);
|
|
160
|
+
inner.cancelled = true;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
rejectInner(inner, reason);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function hostAbortReason(host: ProgramHost): unknown {
|
|
168
|
+
return host.signal?.aborted === true ? host.signal.reason : undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function parked(host: ProgramHost): boolean {
|
|
172
|
+
return host.signal != null && isParkAbort(host.signal);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function cancelled(host: ProgramHost): boolean {
|
|
176
|
+
return host.signal != null && host.signal.aborted && !isParkAbort(host.signal);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function waitPeriod(ms: number, signal?: AbortSignal): Promise<"elapsed" | "park" | "abort"> {
|
|
180
|
+
if (signal == null) {
|
|
181
|
+
await new Promise((resolve) => {
|
|
182
|
+
setTimeout(resolve, ms);
|
|
183
|
+
});
|
|
184
|
+
return "elapsed";
|
|
185
|
+
}
|
|
186
|
+
const live = signal;
|
|
187
|
+
if (live.aborted) return isParkAbort(live) ? "park" : "abort";
|
|
188
|
+
return await new Promise((resolve) => {
|
|
189
|
+
const timer = setTimeout(() => finish("elapsed"), ms);
|
|
190
|
+
const onAbort = (): void => finish(isParkAbort(live) ? "park" : "abort");
|
|
191
|
+
function finish(result: "elapsed" | "park" | "abort"): void {
|
|
192
|
+
clearTimeout(timer);
|
|
193
|
+
live.removeEventListener("abort", onAbort);
|
|
194
|
+
resolve(result);
|
|
195
|
+
}
|
|
196
|
+
live.addEventListener("abort", onAbort, { once: true });
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function createEvaluationTimeout(
|
|
201
|
+
hostSignal: AbortSignal | undefined,
|
|
202
|
+
timeoutMs: number | undefined,
|
|
203
|
+
): { readonly signal: AbortSignal; readonly dispose: () => void } {
|
|
204
|
+
const controller = new AbortController();
|
|
205
|
+
const timer =
|
|
206
|
+
timeoutMs == null
|
|
207
|
+
? undefined
|
|
208
|
+
: setTimeout(() => controller.abort(new Error(`evaluation timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
209
|
+
const signal =
|
|
210
|
+
timeoutMs == null
|
|
211
|
+
? (hostSignal ?? controller.signal)
|
|
212
|
+
: hostSignal == null
|
|
213
|
+
? controller.signal
|
|
214
|
+
: AbortSignal.any([hostSignal, controller.signal]);
|
|
215
|
+
return { signal, dispose: () => clearTimeout(timer) };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function handleEvaluationStop(
|
|
219
|
+
chunk: ProgrammaticJob,
|
|
220
|
+
host: ProgramHost,
|
|
221
|
+
signal: AbortSignal,
|
|
222
|
+
timeoutMs: number | undefined,
|
|
223
|
+
error: unknown,
|
|
224
|
+
): Extract<EvalOutcome, { kind: "park" | "halt" }> | undefined {
|
|
225
|
+
if (parked(host) || isParkAbort(signal)) return { kind: "park" };
|
|
226
|
+
if (cancelled(host)) {
|
|
227
|
+
cancelLiveInvocations(chunk, host, hostAbortReason(host));
|
|
228
|
+
return { kind: "halt", result: fail("Error: Scheme REPL call cancelled") };
|
|
229
|
+
}
|
|
230
|
+
const halt = haltResult(signal, timeoutMs, error);
|
|
231
|
+
if (halt) {
|
|
232
|
+
cancelLiveInvocations(chunk, host, signal.reason);
|
|
233
|
+
return { kind: "halt", result: halt };
|
|
234
|
+
}
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** One run under the host's trace, if it keeps one. */
|
|
239
|
+
export function observed<T>(host: ProgramHost, run: (trace: EvalTrace | undefined) => Promise<T>): Promise<T> {
|
|
240
|
+
return host.observe === undefined ? run(undefined) : host.observe(run);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export async function evaluateForms(
|
|
244
|
+
chunk: ProgrammaticJob,
|
|
245
|
+
host: ProgramHost,
|
|
246
|
+
forms: readonly SchemeValue[],
|
|
247
|
+
opts: EvaluateOpts,
|
|
248
|
+
runtime: ProgramRuntime,
|
|
249
|
+
): Promise<EvalOutcome> {
|
|
250
|
+
const { timeoutMs, maxInternalCalls } = opts;
|
|
251
|
+
const tick = opts.kind === "period" ? opts.tick : undefined;
|
|
252
|
+
const shared = opts.kind === "source-load" || opts.kind === "source-body" ? opts.shared : undefined;
|
|
253
|
+
const loadSource = opts.kind === "source-load";
|
|
254
|
+
const collectPrintedValues = !loadSource;
|
|
255
|
+
const tools = host.tools();
|
|
256
|
+
const capabilities = runtime.capabilities;
|
|
257
|
+
const config = runtime.config;
|
|
258
|
+
const stopOnFormError = tick != null || loadSource;
|
|
259
|
+
// Per tick, not per watch: remint so numeric provenance ids are path-stable across periods.
|
|
260
|
+
const trace = opts.trace ?? (tick == null ? undefined : new EvalTrace());
|
|
261
|
+
let timeout: { readonly signal: AbortSignal; readonly dispose: () => void } | undefined;
|
|
262
|
+
let signal: AbortSignal | undefined = shared?.signal;
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
// Rooting the prelude is not the program. A short eval limit must not fire during that setup.
|
|
266
|
+
let scope = shared?.scope;
|
|
267
|
+
if (scope === undefined) {
|
|
268
|
+
const setupSignal = shared?.signal ?? host.signal;
|
|
269
|
+
const rooted = await execState("", {
|
|
270
|
+
capabilities,
|
|
271
|
+
config,
|
|
272
|
+
...(setupSignal != null ? { signal: setupSignal } : {}),
|
|
273
|
+
});
|
|
274
|
+
scope = rooted.scope;
|
|
275
|
+
if (shared !== undefined) shared.scope = scope;
|
|
276
|
+
}
|
|
277
|
+
if (shared == null) {
|
|
278
|
+
timeout = createEvaluationTimeout(host.signal, timeoutMs);
|
|
279
|
+
signal = timeout.signal;
|
|
280
|
+
}
|
|
281
|
+
const origin = tick?.origin ?? 0;
|
|
282
|
+
runtime.beginTick({ signal: signal ?? new AbortController().signal, origin, maxInternalCalls });
|
|
283
|
+
const evaluated = forms.map((raw) => ({ raw, form: stripTopLevelDisplay(raw) }));
|
|
284
|
+
let emission = shared?.emission;
|
|
285
|
+
if (emission === undefined) {
|
|
286
|
+
emission = harnessEmission(evaluated.map((item) => item.form));
|
|
287
|
+
if (shared !== undefined) shared.emission = emission;
|
|
288
|
+
} else if (opts.kind === "source-body") {
|
|
289
|
+
extendEmission(
|
|
290
|
+
emission,
|
|
291
|
+
evaluated.map((item) => item.form),
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
const epoch = crossingEpoch(chunk, origin);
|
|
295
|
+
armCrossings(emission, origin, epoch, opts.kind !== "source-body");
|
|
296
|
+
if (host.replayOnly === true) markReplaying(emission);
|
|
297
|
+
const started = Date.now();
|
|
298
|
+
const values: PrintedValue[] = [];
|
|
299
|
+
const displaySink = createDisplaySink();
|
|
300
|
+
let lastRaw: SchemeValue | undefined;
|
|
301
|
+
let failures = 0;
|
|
302
|
+
let failureCode: string | undefined;
|
|
303
|
+
for (const item of evaluated) {
|
|
304
|
+
runtime.clearFailure();
|
|
305
|
+
const remainingMs = timeoutMs == null ? undefined : timeoutMs - (Date.now() - started);
|
|
306
|
+
try {
|
|
307
|
+
const state = await execState(item.form, {
|
|
308
|
+
capabilities,
|
|
309
|
+
config,
|
|
310
|
+
scope,
|
|
311
|
+
signal,
|
|
312
|
+
display: displaySink,
|
|
313
|
+
emission,
|
|
314
|
+
...(remainingMs == null ? {} : { budgetMs: remainingMs }),
|
|
315
|
+
...(trace == null ? {} : { tap: trace }),
|
|
316
|
+
});
|
|
317
|
+
const wasTopLevelDisplay = tick == null && isTopLevelDisplay(item.raw);
|
|
318
|
+
for (const value of state.values) {
|
|
319
|
+
if (value instanceof AVoid) continue;
|
|
320
|
+
lastRaw = value;
|
|
321
|
+
if (!collectPrintedValues) continue;
|
|
322
|
+
const printed = observationText(value);
|
|
323
|
+
values.push(wasTopLevelDisplay ? withDisplayNote(printed) : printed);
|
|
324
|
+
}
|
|
325
|
+
for (const rec of displaySink.drain()) {
|
|
326
|
+
values.push(`#| ${rec.src}: ${toSExprString(rec.value)} |#`);
|
|
327
|
+
}
|
|
328
|
+
} catch (error) {
|
|
329
|
+
const stopped = signal == null ? undefined : handleEvaluationStop(chunk, host, signal, timeoutMs, error);
|
|
330
|
+
if (stopped != null) return stopped;
|
|
331
|
+
const raw = errorText(error).replace(/^Error:\s*/, "");
|
|
332
|
+
// A failed form leaves its defines unbound. Later "Unbound variable" is the cascade, not a second fault.
|
|
333
|
+
const attempted = UNBOUND_VARIABLE.exec(raw)?.[1];
|
|
334
|
+
if (attempted != null && failures > 0) continue;
|
|
335
|
+
let text = raw.startsWith("Error:") ? raw : `Error: ${raw}`;
|
|
336
|
+
if (attempted != null) {
|
|
337
|
+
const door =
|
|
338
|
+
unboundInExprDoor(
|
|
339
|
+
attempted,
|
|
340
|
+
[...tools.keys()],
|
|
341
|
+
tools,
|
|
342
|
+
SCHEME_REPL_NAMING,
|
|
343
|
+
new Map([...tools].map(([name, tool]) => [name, tool.schema])),
|
|
344
|
+
) ?? importDoor(attempted);
|
|
345
|
+
if (door !== undefined) text = `${text}${host.doors.enrichInline(door, attempted)}`;
|
|
346
|
+
}
|
|
347
|
+
values.push(text);
|
|
348
|
+
failures += 1;
|
|
349
|
+
failureCode ??= failureCodeOf(error) ?? runtime.failureCode();
|
|
350
|
+
if (stopOnFormError) break;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (failures > 0) return { kind: "fail", values, code: failureCode };
|
|
354
|
+
if (lastRaw === undefined || host.evalProgram !== true) return { kind: "ok", values };
|
|
355
|
+
return { kind: "ok", values, ...structuredJson(lastRaw) };
|
|
356
|
+
} catch (error) {
|
|
357
|
+
const stopped = signal == null ? undefined : handleEvaluationStop(chunk, host, signal, timeoutMs, error);
|
|
358
|
+
if (stopped != null) return stopped;
|
|
359
|
+
return { kind: "fail", values: [errorText(error)] };
|
|
360
|
+
} finally {
|
|
361
|
+
timeout?.dispose();
|
|
362
|
+
try {
|
|
363
|
+
if (opts.trace === undefined) trace?.clear();
|
|
364
|
+
} catch {
|
|
365
|
+
/* mid-eval abort — next tick remints */
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function withDisplayNote(value: PrintedValue): PrintedValue {
|
|
371
|
+
if (typeof value === "string") return `${SCHEME_REPL_DISPLAY_NOTE}\n${value}`;
|
|
372
|
+
return { ...value, text: `${SCHEME_REPL_DISPLAY_NOTE}\n${value.text}` };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function printedValues(values: readonly PrintedValue[], kinds: readonly TopLevelSplice[]): PrintedValue[] {
|
|
376
|
+
return [...kinds.map((kind) => SCHEME_REPL_UNWRAP_NOTES[kind]), ...values];
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export async function guardProgram(
|
|
380
|
+
chunk: ProgrammaticJob,
|
|
381
|
+
host: ProgramHost,
|
|
382
|
+
body: () => Promise<void>,
|
|
383
|
+
): Promise<void> {
|
|
384
|
+
try {
|
|
385
|
+
await body();
|
|
386
|
+
} catch (error) {
|
|
387
|
+
if (!chunk.completed && !parked(host) && !cancelled(host)) {
|
|
388
|
+
cancelLiveInvocations(chunk, host, error);
|
|
389
|
+
sealChunk(chunk, fail(errorText(error)));
|
|
390
|
+
}
|
|
391
|
+
} finally {
|
|
392
|
+
if (!chunk.completed && !parked(host)) {
|
|
393
|
+
cancelLiveInvocations(chunk, host, hostAbortReason(host));
|
|
394
|
+
if (cancelled(host)) chunk.cancelled = true;
|
|
395
|
+
sealChunk(chunk, fail("Error: Scheme REPL call cancelled"));
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
package/src/failure.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { AgentCallNotification, type ProgrammaticJob } from "@inhuman.tools/harness-model";
|
|
2
|
+
|
|
3
|
+
export class SchemeReplToolFailure extends Error {
|
|
4
|
+
readonly code?: string;
|
|
5
|
+
constructor(message: string, code?: string) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "SchemeReplToolFailure";
|
|
8
|
+
this.code = code;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function failureCodeOf(error: unknown): string | undefined {
|
|
13
|
+
let current = error;
|
|
14
|
+
for (let i = 0; i < 6; i++) {
|
|
15
|
+
if (!(current instanceof Error)) return undefined;
|
|
16
|
+
if (current instanceof SchemeReplToolFailure && current.code != null && current.code !== "") {
|
|
17
|
+
return current.code;
|
|
18
|
+
}
|
|
19
|
+
current = current.cause;
|
|
20
|
+
}
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function appendNotify(
|
|
25
|
+
job: ProgrammaticJob,
|
|
26
|
+
host: { readonly notify?: (note: AgentCallNotification) => void },
|
|
27
|
+
message: string,
|
|
28
|
+
): void {
|
|
29
|
+
const note = new AgentCallNotification({ message });
|
|
30
|
+
if (host.notify != null) {
|
|
31
|
+
host.notify(note);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
job.notifications.push(note);
|
|
35
|
+
}
|