@aragon-agent/cli 0.6.37 → 0.6.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2523 -2326
- package/README.md +3614 -3173
- package/dist/agent/controller.d.ts +142 -1
- package/dist/agent/controller.js +171 -3
- package/dist/agent/reducer.d.ts +41 -1
- package/dist/agent/reducer.js +25 -0
- package/dist/agent/system-prompt.d.ts +14 -0
- package/dist/agent/system-prompt.js +5 -0
- package/dist/cli.js +85 -27
- package/dist/commands/builtins.js +13 -1
- package/dist/commands/keys-command.d.ts +23 -0
- package/dist/commands/keys-command.js +38 -0
- package/dist/commands/perf.d.ts +7 -1
- package/dist/commands/perf.js +2 -2
- package/dist/commands/registry.d.ts +39 -0
- package/dist/commands/task-command.d.ts +39 -0
- package/dist/commands/task-command.js +102 -0
- package/dist/config/cli-commands.d.ts +23 -0
- package/dist/config/cli-commands.js +36 -0
- package/dist/config/env.js +17 -4
- package/dist/config/load.js +6 -1
- package/dist/config/schema.d.ts +43 -4
- package/dist/config/schema.js +31 -5
- package/dist/config/store.js +17 -2
- package/dist/diagnostics/doctor.d.ts +9 -0
- package/dist/diagnostics/doctor.js +34 -0
- package/dist/diagnostics/info.d.ts +71 -0
- package/dist/diagnostics/info.js +91 -5
- package/dist/exec/bridge/caller-tools.d.ts +96 -0
- package/dist/exec/bridge/caller-tools.js +213 -0
- package/dist/exec/bridge/decisions.d.ts +78 -0
- package/dist/exec/bridge/decisions.js +116 -0
- package/dist/exec/bridge/human-answers.d.ts +38 -0
- package/dist/exec/bridge/human-answers.js +164 -0
- package/dist/exec/bridge/limits.d.ts +68 -0
- package/dist/exec/bridge/limits.js +68 -0
- package/dist/exec/bridge/ndjson.d.ts +17 -0
- package/dist/exec/bridge/ndjson.js +83 -0
- package/dist/exec/bridge/pending.d.ts +70 -0
- package/dist/exec/bridge/pending.js +132 -0
- package/dist/exec/bridge/protocol.d.ts +346 -0
- package/dist/exec/bridge/protocol.js +256 -0
- package/dist/exec/bridge/server.d.ts +142 -0
- package/dist/exec/bridge/server.js +502 -0
- package/dist/exec/bridge/source.d.ts +58 -0
- package/dist/exec/bridge/source.js +145 -0
- package/dist/exec/bridge/writer.d.ts +26 -0
- package/dist/exec/bridge/writer.js +156 -0
- package/dist/exec/cli-commands.js +30 -2
- package/dist/exec/emitter.d.ts +16 -2
- package/dist/exec/emitter.js +26 -6
- package/dist/exec/events.d.ts +465 -10
- package/dist/exec/events.js +47 -1
- package/dist/exec/hooks/dispatch.d.ts +76 -0
- package/dist/exec/hooks/dispatch.js +169 -0
- package/dist/exec/hooks/index.d.ts +83 -0
- package/dist/exec/hooks/index.js +205 -0
- package/dist/exec/hooks/limits.d.ts +56 -0
- package/dist/exec/hooks/limits.js +49 -0
- package/dist/exec/hooks/registry.d.ts +49 -0
- package/dist/exec/hooks/registry.js +91 -0
- package/dist/exec/hooks/tool-wrapper.d.ts +94 -0
- package/dist/exec/hooks/tool-wrapper.js +154 -0
- package/dist/exec/hooks/turn.d.ts +61 -0
- package/dist/exec/hooks/turn.js +59 -0
- package/dist/exec/hooks/validate.d.ts +85 -0
- package/dist/exec/hooks/validate.js +310 -0
- package/dist/exec/index.d.ts +130 -4
- package/dist/exec/index.js +501 -10
- package/dist/exec/options.d.ts +124 -2
- package/dist/exec/options.js +413 -6
- package/dist/exec/permission.d.ts +9 -1
- package/dist/exec/permission.js +7 -1
- package/dist/exec/request/limits.d.ts +51 -0
- package/dist/exec/request/limits.js +49 -0
- package/dist/exec/request/scope.d.ts +106 -0
- package/dist/exec/request/scope.js +149 -0
- package/dist/exec/result/limits.d.ts +74 -0
- package/dist/exec/result/limits.js +72 -0
- package/dist/exec/result/prompt.d.ts +28 -0
- package/dist/exec/result/prompt.js +74 -0
- package/dist/exec/result/schema.d.ts +99 -0
- package/dist/exec/result/schema.js +464 -0
- package/dist/exec/result/sink.d.ts +56 -0
- package/dist/exec/result/sink.js +50 -0
- package/dist/exec/result/tool.d.ts +37 -0
- package/dist/exec/result/tool.js +118 -0
- package/dist/exec/runner.d.ts +395 -1
- package/dist/exec/runner.js +860 -8
- package/dist/exec/threads/aggregate.d.ts +34 -0
- package/dist/exec/threads/aggregate.js +30 -0
- package/dist/exec/threads/capacity.d.ts +23 -0
- package/dist/exec/threads/capacity.js +38 -0
- package/dist/exec/threads/dispatch-source.d.ts +60 -0
- package/dist/exec/threads/dispatch-source.js +106 -0
- package/dist/exec/threads/execute-threads.d.ts +53 -0
- package/dist/exec/threads/execute-threads.js +234 -0
- package/dist/exec/threads/gate.d.ts +42 -0
- package/dist/exec/threads/gate.js +81 -0
- package/dist/exec/threads/identity.d.ts +36 -0
- package/dist/exec/threads/identity.js +67 -0
- package/dist/exec/threads/interrupt.d.ts +29 -0
- package/dist/exec/threads/interrupt.js +79 -0
- package/dist/exec/threads/limits.d.ts +58 -0
- package/dist/exec/threads/limits.js +35 -0
- package/dist/exec/threads/outcome.d.ts +47 -0
- package/dist/exec/threads/outcome.js +26 -0
- package/dist/exec/threads/registry.d.ts +81 -0
- package/dist/exec/threads/registry.js +427 -0
- package/dist/exec/threads/runtime.d.ts +107 -0
- package/dist/exec/threads/runtime.js +523 -0
- package/dist/exec/threads/thread-source.d.ts +20 -0
- package/dist/exec/threads/thread-source.js +134 -0
- package/dist/fast/limits.d.ts +54 -0
- package/dist/fast/limits.js +54 -0
- package/dist/fast/summary-call.d.ts +72 -0
- package/dist/fast/summary-call.js +133 -0
- package/dist/fast/summary-prompt.d.ts +106 -0
- package/dist/fast/summary-prompt.js +320 -0
- package/dist/fast/task-summary-types.d.ts +66 -0
- package/dist/fast/task-summary-types.js +17 -0
- package/dist/logging/logger.d.ts +1 -1
- package/dist/proc/limits.d.ts +7 -0
- package/dist/proc/limits.js +7 -0
- package/dist/proc/output-streams.d.ts +50 -0
- package/dist/proc/output-streams.js +54 -0
- package/dist/team/runtime.d.ts +9 -0
- package/dist/team/runtime.js +8 -0
- package/dist/team/subagent.d.ts +15 -0
- package/dist/team/subagent.js +45 -1
- package/dist/tools/bash-tool.d.ts +10 -0
- package/dist/tools/bash-tool.js +71 -6
- package/dist/tools/index.d.ts +93 -1
- package/dist/tools/index.js +112 -3
- package/dist/tools/tool-gate.d.ts +100 -0
- package/dist/tools/tool-gate.js +87 -0
- package/dist/ui/App.js +224 -11
- package/dist/ui/Header.d.ts +28 -16
- package/dist/ui/Header.js +21 -19
- package/dist/ui/interaction-hints.js +6 -0
- package/dist/ui/layout/OverlayFrame.d.ts +18 -0
- package/dist/ui/layout/OverlayFrame.js +19 -4
- package/dist/ui/layout/header-hints.d.ts +55 -62
- package/dist/ui/layout/header-hints.js +59 -166
- package/dist/ui/layout/header-left.d.ts +115 -0
- package/dist/ui/layout/header-left.js +206 -0
- package/dist/ui/layout/shortcut-panel-layout.d.ts +191 -0
- package/dist/ui/layout/shortcut-panel-layout.js +321 -0
- package/dist/ui/layout/status-detail-layout.js +5 -1
- package/dist/ui/layout/task-summary-cell.d.ts +61 -0
- package/dist/ui/layout/task-summary-cell.js +65 -0
- package/dist/ui/overlays/HelpOverlay.d.ts +12 -0
- package/dist/ui/overlays/HelpOverlay.js +29 -2
- package/dist/ui/overlays/ShortcutPanel.d.ts +39 -0
- package/dist/ui/overlays/ShortcutPanel.js +106 -0
- package/dist/ui/scrollback/canvas.d.ts +3 -2
- package/dist/ui/scrollback/canvas.js +3 -2
- package/dist/ui/scrollback/scrollback-writer.d.ts +13 -1
- package/dist/ui/scrollback/scrollback-writer.js +109 -10
- package/dist/ui/shortcuts.d.ts +81 -0
- package/dist/ui/shortcuts.js +221 -0
- package/package.json +2 -2
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stable `code` values preflight can return (section 2.3.3).
|
|
3
|
+
*
|
|
4
|
+
* STABLE BECAUSE A CALLER SWITCHES ON THEM. Renaming one is a breaking change for
|
|
5
|
+
* every wrapper that gave a different remedy per cause, which is the whole reason
|
|
6
|
+
* the design split the two that used to share a code.
|
|
7
|
+
*/
|
|
8
|
+
export declare const RESULT_SCHEMA_CODES: {
|
|
9
|
+
readonly tooLarge: "result_schema_too_large";
|
|
10
|
+
readonly invalidJson: "result_schema_invalid_json";
|
|
11
|
+
readonly notObject: "result_schema_not_object";
|
|
12
|
+
readonly tooDeep: "result_schema_too_deep";
|
|
13
|
+
readonly unsupportedRef: "result_schema_unsupported_ref";
|
|
14
|
+
readonly wrapperConflict: "result_schema_wrapper_conflict";
|
|
15
|
+
readonly compositeRoot: "result_schema_composite_root";
|
|
16
|
+
};
|
|
17
|
+
export type ResultSchemaCode = (typeof RESULT_SCHEMA_CODES)[keyof typeof RESULT_SCHEMA_CODES];
|
|
18
|
+
/** A schema that passed preflight, plus the wrapper it is injected into. */
|
|
19
|
+
export interface ResultSchemaSpec {
|
|
20
|
+
/** The caller's own schema. Treated as immutable after this point. */
|
|
21
|
+
schema: Record<string, unknown>;
|
|
22
|
+
/** `{ type:'object', properties:{ result: schema }, ... }` - what the executor sees. */
|
|
23
|
+
wrapper: Record<string, unknown>;
|
|
24
|
+
/** The file name, for echoing back in errors and in `result.structured`. */
|
|
25
|
+
name: string;
|
|
26
|
+
bytes: number;
|
|
27
|
+
/** `true` when the root carried `definitions` / `$defs` that were lifted. */
|
|
28
|
+
hasDefinitions: boolean;
|
|
29
|
+
}
|
|
30
|
+
export type ResultSchemaParse = {
|
|
31
|
+
ok: true;
|
|
32
|
+
spec: ResultSchemaSpec;
|
|
33
|
+
} | {
|
|
34
|
+
ok: false;
|
|
35
|
+
code: ResultSchemaCode;
|
|
36
|
+
message: string;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Preflight one schema file.
|
|
40
|
+
*
|
|
41
|
+
* SIX USAGE ERRORS, ALL OF THEM BEFORE ANY WORK HAPPENS, because a schema that
|
|
42
|
+
* cannot be honoured must not produce a run that half-honours it. Returns rather
|
|
43
|
+
* than throws, for the reason `exec/options.ts` states about flags: the caller is a
|
|
44
|
+
* process that must exit 2 with nothing on stdout.
|
|
45
|
+
*/
|
|
46
|
+
export declare function parseResultSchema(text: string, name: string): ResultSchemaParse;
|
|
47
|
+
/**
|
|
48
|
+
* The one place the caller's schema becomes a tool parameter schema.
|
|
49
|
+
*
|
|
50
|
+
* `definitions` / `$defs` ARE LIFTED TO THE WRAPPER ROOT (section 2.3.2). A
|
|
51
|
+
* `$ref: "#/definitions/Item"` resolves from the DOCUMENT root, and injecting the
|
|
52
|
+
* schema under `properties.result` makes the document root the wrapper. Copying the
|
|
53
|
+
* two containers up leaves every existing `$ref` string resolving to the same
|
|
54
|
+
* definition - the only option that does not rewrite the caller's text.
|
|
55
|
+
*/
|
|
56
|
+
export declare function buildResultWrapper(schema: Record<string, unknown>): Record<string, unknown>;
|
|
57
|
+
/** One validation run's bounds. Defaults come from `RESULT_LIMITS`, never a literal. */
|
|
58
|
+
export interface CheckLimits {
|
|
59
|
+
maxNodes: number;
|
|
60
|
+
maxViolations: number;
|
|
61
|
+
maxPathChars: number;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Validate a submitted value against the caller's schema.
|
|
65
|
+
*
|
|
66
|
+
* RETURNS VIOLATIONS; AN EMPTY ARRAY MEANS "ACCEPTED". Each string has the stable
|
|
67
|
+
* shape `result.<path>: <what is wrong>`, because that is what the model has to act
|
|
68
|
+
* on and what a test can assert without pinning prose.
|
|
69
|
+
*/
|
|
70
|
+
export declare function checkResultValue(schema: Record<string, unknown>, value: unknown, limits?: CheckLimits): string[];
|
|
71
|
+
/**
|
|
72
|
+
* The one place a violation list becomes model-visible text: one per line.
|
|
73
|
+
*
|
|
74
|
+
* ONE PER LINE BECAUSE THAT IS HOW A MODEL READS A LIST OF FIXES, and one function
|
|
75
|
+
* because the tool's error message and the repair prompt must quote the same shape -
|
|
76
|
+
* two formatters is how a model ends up being told two different paths for one field.
|
|
77
|
+
* The list is already bounded by `maxViolations` and each path by `maxPathChars`.
|
|
78
|
+
*/
|
|
79
|
+
export declare function formatResultViolations(violations: string[]): string;
|
|
80
|
+
/**
|
|
81
|
+
* Round-trip through JSON so the accepted value is exactly what a consumer will
|
|
82
|
+
* read back (`result.structured.value`) and exactly what gets size-checked.
|
|
83
|
+
*
|
|
84
|
+
* `undefined` cannot survive that trip; it is returned as `undefined` rather than
|
|
85
|
+
* silently becoming `null`, so the caller's own `required` check reports it.
|
|
86
|
+
*/
|
|
87
|
+
export declare function canonicalizeResult(value: unknown): unknown;
|
|
88
|
+
/** Canonicalized byte size, or `Infinity` for a value JSON cannot serialize. */
|
|
89
|
+
export declare function resultValueBytes(value: unknown): number;
|
|
90
|
+
/**
|
|
91
|
+
* Accept a stringified object when the schema asks for an object (section 9.1).
|
|
92
|
+
*
|
|
93
|
+
* A MODEL COMMONLY WRITES THE JSON AS A STRING, and refusing it teaches nothing that
|
|
94
|
+
* the retry could not teach faster: the alternative is a round trip whose only
|
|
95
|
+
* content is "unquote it". The tolerance is bounded to a single parse of a value no
|
|
96
|
+
* larger than the accepted-value ceiling, and it is one-way - a string is only
|
|
97
|
+
* reinterpreted when the schema says `object` and the parse yields an object.
|
|
98
|
+
*/
|
|
99
|
+
export declare function coerceStringifiedObject(schema: Record<string, unknown>, value: unknown): unknown;
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--result-schema`: the preflight, the wrapper, and the belt validator
|
|
3
|
+
* (headless-request-scope-and-typed-result section 2.3).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
|
|
6
|
+
*
|
|
7
|
+
* PURE. No I/O, no timers, no streams: text in, a discriminated result or a list of
|
|
8
|
+
* readable violations out. Everything that needs the filesystem lives in
|
|
9
|
+
* `exec/index.ts`, and everything that needs the model lives in `exec/result/tool.ts`.
|
|
10
|
+
*
|
|
11
|
+
* ## Why there are two validators, and why this one is not redundant
|
|
12
|
+
*
|
|
13
|
+
* `ajv` is an OPTIONAL dependency of Core. `ToolParamValidator` uses it when it can
|
|
14
|
+
* be imported and otherwise falls back to a strategy that checks `type` at the top
|
|
15
|
+
* level, checks top-level `required`, and NEVER looks at `properties`, `items`,
|
|
16
|
+
* `enum`, `const`, the numeric/length bounds, or `additionalProperties:false`
|
|
17
|
+
* (`packages/core/src/tools/validator.ts`). So on a machine without `ajv`, a schema
|
|
18
|
+
* pushed into a tool's `parameters` is close to decoration - which is exactly the
|
|
19
|
+
* machine an embedder is least likely to inspect.
|
|
20
|
+
*
|
|
21
|
+
* `checkResultValue` below is therefore the BELT, and the four keywords Core stops
|
|
22
|
+
* checking are MANDATORY here rather than best-effort: `required`, recursive
|
|
23
|
+
* `properties`, `items`, and `additionalProperties:false`. The rest it covers where
|
|
24
|
+
* covering them is cheap. What it does not cover (`pattern`, `format`,
|
|
25
|
+
* `multipleOf`, ...) is honestly documented as "checked only when `ajv` is present",
|
|
26
|
+
* and `aragon doctor` reports which machine you are on.
|
|
27
|
+
*
|
|
28
|
+
* IT IS ALSO BOUNDED (section 2.3.4): a node budget, a violation cap and a path
|
|
29
|
+
* width. An unbounded validator inside a headless process is a denial of service
|
|
30
|
+
* the model can trigger by accident.
|
|
31
|
+
*/
|
|
32
|
+
import { RESULT_LIMITS, RESULT_MAX_VALUE_DEPTH } from './limits.js';
|
|
33
|
+
/**
|
|
34
|
+
* The stable `code` values preflight can return (section 2.3.3).
|
|
35
|
+
*
|
|
36
|
+
* STABLE BECAUSE A CALLER SWITCHES ON THEM. Renaming one is a breaking change for
|
|
37
|
+
* every wrapper that gave a different remedy per cause, which is the whole reason
|
|
38
|
+
* the design split the two that used to share a code.
|
|
39
|
+
*/
|
|
40
|
+
export const RESULT_SCHEMA_CODES = {
|
|
41
|
+
tooLarge: 'result_schema_too_large',
|
|
42
|
+
invalidJson: 'result_schema_invalid_json',
|
|
43
|
+
notObject: 'result_schema_not_object',
|
|
44
|
+
tooDeep: 'result_schema_too_deep',
|
|
45
|
+
unsupportedRef: 'result_schema_unsupported_ref',
|
|
46
|
+
wrapperConflict: 'result_schema_wrapper_conflict',
|
|
47
|
+
compositeRoot: 'result_schema_composite_root',
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Preflight one schema file.
|
|
51
|
+
*
|
|
52
|
+
* SIX USAGE ERRORS, ALL OF THEM BEFORE ANY WORK HAPPENS, because a schema that
|
|
53
|
+
* cannot be honoured must not produce a run that half-honours it. Returns rather
|
|
54
|
+
* than throws, for the reason `exec/options.ts` states about flags: the caller is a
|
|
55
|
+
* process that must exit 2 with nothing on stdout.
|
|
56
|
+
*/
|
|
57
|
+
export function parseResultSchema(text, name) {
|
|
58
|
+
const bytes = Buffer.byteLength(text, 'utf-8');
|
|
59
|
+
if (bytes > RESULT_LIMITS.maxSchemaBytes) {
|
|
60
|
+
return fail(RESULT_SCHEMA_CODES.tooLarge, `${name} is ${bytes} bytes; a result schema may be at most ${RESULT_LIMITS.maxSchemaBytes}.`);
|
|
61
|
+
}
|
|
62
|
+
let parsed;
|
|
63
|
+
try {
|
|
64
|
+
parsed = JSON.parse(text);
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
return fail(RESULT_SCHEMA_CODES.invalidJson, `${name} is not JSON: ${firstLine(err.message)}`);
|
|
68
|
+
}
|
|
69
|
+
if (!isPlainObject(parsed)) {
|
|
70
|
+
return fail(RESULT_SCHEMA_CODES.notObject, `${name} must contain a JSON object at the root; found ${describeType(parsed)}.`);
|
|
71
|
+
}
|
|
72
|
+
const schema = parsed;
|
|
73
|
+
const depth = schemaDepth(schema);
|
|
74
|
+
if (depth > RESULT_LIMITS.maxSchemaDepth) {
|
|
75
|
+
return fail(RESULT_SCHEMA_CODES.tooDeep, `${name} nests ${depth} levels deep; the limit is ${RESULT_LIMITS.maxSchemaDepth}.`);
|
|
76
|
+
}
|
|
77
|
+
const ref = findUnsupportedRef(schema);
|
|
78
|
+
if (ref !== null)
|
|
79
|
+
return fail(RESULT_SCHEMA_CODES.unsupportedRef, `${name}: ${ref}`);
|
|
80
|
+
const properties = isPlainObject(schema.properties) ? schema.properties : {};
|
|
81
|
+
if (Object.prototype.hasOwnProperty.call(properties, 'result')) {
|
|
82
|
+
// THE WRAPPER AND THE CALLER WANT THE SAME KEY. Our wrapper puts the whole
|
|
83
|
+
// schema under `properties.result`, so a root that already declares a `result`
|
|
84
|
+
// property would be silently nested one level deeper than the caller wrote it.
|
|
85
|
+
// Renaming it for them would change their contract; refusing is the honest
|
|
86
|
+
// answer, and the remedy is one edit away (`items`, or another key name).
|
|
87
|
+
return fail(RESULT_SCHEMA_CODES.wrapperConflict, `${name}: the root already declares a "result" property (at /properties/result). ` +
|
|
88
|
+
'Rename it, or wrap the object in an "items" array.');
|
|
89
|
+
}
|
|
90
|
+
const composite = ['oneOf', 'anyOf', 'allOf'].find((key) => key in schema);
|
|
91
|
+
if (composite !== undefined) {
|
|
92
|
+
// INLINING A COMPOSED ROOT IS NOT SAFE HERE: `properties.result` would hold a
|
|
93
|
+
// schema whose own combinators no longer mean what they meant at the document
|
|
94
|
+
// root (sibling keywords interact differently). A caller that needs a union can
|
|
95
|
+
// put it under a property, where it is preserved verbatim.
|
|
96
|
+
return fail(RESULT_SCHEMA_CODES.compositeRoot, `${name}: the root uses "${composite}", which cannot be inlined safely. ` +
|
|
97
|
+
'Move the composition under a property.');
|
|
98
|
+
}
|
|
99
|
+
const hasDefinitions = 'definitions' in schema || '$defs' in schema;
|
|
100
|
+
return {
|
|
101
|
+
ok: true,
|
|
102
|
+
spec: { schema, wrapper: buildResultWrapper(schema), name, bytes, hasDefinitions },
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The one place the caller's schema becomes a tool parameter schema.
|
|
107
|
+
*
|
|
108
|
+
* `definitions` / `$defs` ARE LIFTED TO THE WRAPPER ROOT (section 2.3.2). A
|
|
109
|
+
* `$ref: "#/definitions/Item"` resolves from the DOCUMENT root, and injecting the
|
|
110
|
+
* schema under `properties.result` makes the document root the wrapper. Copying the
|
|
111
|
+
* two containers up leaves every existing `$ref` string resolving to the same
|
|
112
|
+
* definition - the only option that does not rewrite the caller's text.
|
|
113
|
+
*/
|
|
114
|
+
export function buildResultWrapper(schema) {
|
|
115
|
+
const wrapper = {
|
|
116
|
+
type: 'object',
|
|
117
|
+
properties: { result: schema },
|
|
118
|
+
required: ['result'],
|
|
119
|
+
// THE WRAPPER LAYER ONLY. "The model sent an extra key" should be refused rather
|
|
120
|
+
// than ignored, and our own belt enforces this even where Core's fallback does
|
|
121
|
+
// not (see the file header).
|
|
122
|
+
additionalProperties: false,
|
|
123
|
+
};
|
|
124
|
+
if (isPlainObject(schema.definitions))
|
|
125
|
+
wrapper.definitions = schema.definitions;
|
|
126
|
+
if (isPlainObject(schema.$defs))
|
|
127
|
+
wrapper.$defs = schema.$defs;
|
|
128
|
+
return wrapper;
|
|
129
|
+
}
|
|
130
|
+
const DEFAULT_CHECK_LIMITS = {
|
|
131
|
+
maxNodes: RESULT_LIMITS.maxValidationNodes,
|
|
132
|
+
maxViolations: RESULT_LIMITS.maxViolations,
|
|
133
|
+
maxPathChars: RESULT_LIMITS.maxViolationPathChars,
|
|
134
|
+
};
|
|
135
|
+
/**
|
|
136
|
+
* Validate a submitted value against the caller's schema.
|
|
137
|
+
*
|
|
138
|
+
* RETURNS VIOLATIONS; AN EMPTY ARRAY MEANS "ACCEPTED". Each string has the stable
|
|
139
|
+
* shape `result.<path>: <what is wrong>`, because that is what the model has to act
|
|
140
|
+
* on and what a test can assert without pinning prose.
|
|
141
|
+
*/
|
|
142
|
+
export function checkResultValue(schema, value, limits = DEFAULT_CHECK_LIMITS) {
|
|
143
|
+
const ctx = { ...limits, nodes: 0, violations: [], overflowed: false };
|
|
144
|
+
checkNode(schema, value, 'result', ctx, 0);
|
|
145
|
+
return ctx.violations;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The one place a violation list becomes model-visible text: one per line.
|
|
149
|
+
*
|
|
150
|
+
* ONE PER LINE BECAUSE THAT IS HOW A MODEL READS A LIST OF FIXES, and one function
|
|
151
|
+
* because the tool's error message and the repair prompt must quote the same shape -
|
|
152
|
+
* two formatters is how a model ends up being told two different paths for one field.
|
|
153
|
+
* The list is already bounded by `maxViolations` and each path by `maxPathChars`.
|
|
154
|
+
*/
|
|
155
|
+
export function formatResultViolations(violations) {
|
|
156
|
+
return violations.join('\n');
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Round-trip through JSON so the accepted value is exactly what a consumer will
|
|
160
|
+
* read back (`result.structured.value`) and exactly what gets size-checked.
|
|
161
|
+
*
|
|
162
|
+
* `undefined` cannot survive that trip; it is returned as `undefined` rather than
|
|
163
|
+
* silently becoming `null`, so the caller's own `required` check reports it.
|
|
164
|
+
*/
|
|
165
|
+
export function canonicalizeResult(value) {
|
|
166
|
+
if (value === undefined)
|
|
167
|
+
return undefined;
|
|
168
|
+
try {
|
|
169
|
+
return JSON.parse(JSON.stringify(value));
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return value;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/** Canonicalized byte size, or `Infinity` for a value JSON cannot serialize. */
|
|
176
|
+
export function resultValueBytes(value) {
|
|
177
|
+
try {
|
|
178
|
+
return Buffer.byteLength(JSON.stringify(value) ?? '', 'utf-8');
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return Number.POSITIVE_INFINITY;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Accept a stringified object when the schema asks for an object (section 9.1).
|
|
186
|
+
*
|
|
187
|
+
* A MODEL COMMONLY WRITES THE JSON AS A STRING, and refusing it teaches nothing that
|
|
188
|
+
* the retry could not teach faster: the alternative is a round trip whose only
|
|
189
|
+
* content is "unquote it". The tolerance is bounded to a single parse of a value no
|
|
190
|
+
* larger than the accepted-value ceiling, and it is one-way - a string is only
|
|
191
|
+
* reinterpreted when the schema says `object` and the parse yields an object.
|
|
192
|
+
*/
|
|
193
|
+
export function coerceStringifiedObject(schema, value) {
|
|
194
|
+
if (typeof value !== 'string' || schemaWants(schema, 'object') !== true)
|
|
195
|
+
return value;
|
|
196
|
+
if (Buffer.byteLength(value, 'utf-8') > RESULT_LIMITS.maxResultValueBytes)
|
|
197
|
+
return value;
|
|
198
|
+
const trimmed = value.trim();
|
|
199
|
+
if (!trimmed.startsWith('{'))
|
|
200
|
+
return value;
|
|
201
|
+
try {
|
|
202
|
+
const parsed = JSON.parse(trimmed);
|
|
203
|
+
return isPlainObject(parsed) ? parsed : value;
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return value;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// Internals
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
function fail(code, message) {
|
|
213
|
+
return { ok: false, code, message };
|
|
214
|
+
}
|
|
215
|
+
function firstLine(message) {
|
|
216
|
+
const line = message.split('\n')[0] ?? message;
|
|
217
|
+
return line.length <= 160 ? line : `${line.slice(0, 157)}...`;
|
|
218
|
+
}
|
|
219
|
+
function describeType(value) {
|
|
220
|
+
if (value === null)
|
|
221
|
+
return 'null';
|
|
222
|
+
if (Array.isArray(value))
|
|
223
|
+
return 'array';
|
|
224
|
+
return typeof value;
|
|
225
|
+
}
|
|
226
|
+
function isPlainObject(value) {
|
|
227
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
228
|
+
}
|
|
229
|
+
/** Nesting depth of a schema document; a scalar is depth 1. */
|
|
230
|
+
function schemaDepth(value, depth = 1) {
|
|
231
|
+
if (depth > RESULT_LIMITS.maxSchemaDepth + 1)
|
|
232
|
+
return depth;
|
|
233
|
+
if (Array.isArray(value)) {
|
|
234
|
+
let deepest = depth;
|
|
235
|
+
for (const item of value)
|
|
236
|
+
deepest = Math.max(deepest, schemaDepth(item, depth + 1));
|
|
237
|
+
return deepest;
|
|
238
|
+
}
|
|
239
|
+
if (!isPlainObject(value))
|
|
240
|
+
return depth;
|
|
241
|
+
let deepest = depth;
|
|
242
|
+
for (const key of Object.keys(value)) {
|
|
243
|
+
deepest = Math.max(deepest, schemaDepth(value[key], depth + 1));
|
|
244
|
+
}
|
|
245
|
+
return deepest;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Walk every string in the document looking for `$ref` values (and counting them).
|
|
249
|
+
*
|
|
250
|
+
* Returns a readable complaint, or `null`. Only local pointer prefixes are allowed,
|
|
251
|
+
* because a remote `$ref` would make a schema silently depend on the network - and a
|
|
252
|
+
* headless run must never fetch a contract it was handed.
|
|
253
|
+
*/
|
|
254
|
+
function findUnsupportedRef(schema) {
|
|
255
|
+
const refs = [];
|
|
256
|
+
collectRefs(schema, refs);
|
|
257
|
+
if (refs.length > RESULT_LIMITS.maxSchemaRefs) {
|
|
258
|
+
return `${refs.length} "$ref" values; the limit is ${RESULT_LIMITS.maxSchemaRefs}.`;
|
|
259
|
+
}
|
|
260
|
+
for (const ref of refs) {
|
|
261
|
+
const local = ref.startsWith('#/definitions/') || ref.startsWith('#/$defs/');
|
|
262
|
+
if (!local) {
|
|
263
|
+
return `"$ref": "${clipText(ref, 120)}" must start with "#/definitions/" or "#/$defs/".`;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
function collectRefs(value, out, depth = 0) {
|
|
269
|
+
if (depth > RESULT_LIMITS.maxSchemaDepth + 1)
|
|
270
|
+
return;
|
|
271
|
+
if (Array.isArray(value)) {
|
|
272
|
+
for (const item of value)
|
|
273
|
+
collectRefs(item, out, depth + 1);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (!isPlainObject(value))
|
|
277
|
+
return;
|
|
278
|
+
const ref = value.$ref;
|
|
279
|
+
if (typeof ref === 'string')
|
|
280
|
+
out.push(ref);
|
|
281
|
+
for (const key of Object.keys(value))
|
|
282
|
+
collectRefs(value[key], out, depth + 1);
|
|
283
|
+
}
|
|
284
|
+
function clipText(text, max) {
|
|
285
|
+
return text.length <= max ? text : `${text.slice(0, Math.max(0, max - 3))}...`;
|
|
286
|
+
}
|
|
287
|
+
/** Does a `type` keyword (string or array) name `wanted`? */
|
|
288
|
+
function schemaWants(schema, wanted) {
|
|
289
|
+
const type = schema.type;
|
|
290
|
+
if (typeof type === 'string')
|
|
291
|
+
return type === wanted;
|
|
292
|
+
if (Array.isArray(type))
|
|
293
|
+
return type.includes(wanted);
|
|
294
|
+
return undefined;
|
|
295
|
+
}
|
|
296
|
+
function checkNode(schema, value, path, ctx, depth) {
|
|
297
|
+
if (!isPlainObject(schema))
|
|
298
|
+
return;
|
|
299
|
+
ctx.nodes += 1;
|
|
300
|
+
if (ctx.nodes > ctx.maxNodes) {
|
|
301
|
+
// ONE VIOLATION, THEN SILENCE. The point is a readable refusal, not a list of
|
|
302
|
+
// five thousand copies of it.
|
|
303
|
+
if (!ctx.overflowed) {
|
|
304
|
+
ctx.overflowed = true;
|
|
305
|
+
push(ctx, `${clipPath('result', ctx)}: the submitted value is too complex to validate`);
|
|
306
|
+
}
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
// A STACK GUARD, NOT A RULE.
|
|
310
|
+
// It is unreachable for any schema that passes preflight - the walk descends only
|
|
311
|
+
// through `properties` and `items`, so value depth is bounded by schema depth (12)
|
|
312
|
+
// long before 64 - and it exists so that a future keyword which recurses WITHOUT
|
|
313
|
+
// a matching schema level cannot turn one hostile value into a stack overflow.
|
|
314
|
+
if (depth > RESULT_MAX_VALUE_DEPTH)
|
|
315
|
+
return;
|
|
316
|
+
if (ctx.violations.length >= ctx.maxViolations)
|
|
317
|
+
return;
|
|
318
|
+
if (!matchesType(schema, value)) {
|
|
319
|
+
push(ctx, `${clipPath(path, ctx)}: expected ${describeSchemaTypes(schema)}, got ${describeType(value)}`);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (Array.isArray(schema.enum) && !schema.enum.some((allowed) => deepEqual(allowed, value))) {
|
|
323
|
+
push(ctx, `${clipPath(path, ctx)}: must be one of ${describeEnum(schema.enum)}`);
|
|
324
|
+
}
|
|
325
|
+
if ('const' in schema && !deepEqual(schema.const, value)) {
|
|
326
|
+
push(ctx, `${clipPath(path, ctx)}: must equal ${describeEnum([schema.const])}`);
|
|
327
|
+
}
|
|
328
|
+
checkStringBounds(schema, value, path, ctx);
|
|
329
|
+
checkNumberBounds(schema, value, path, ctx);
|
|
330
|
+
checkObjectShape(schema, value, path, ctx, depth);
|
|
331
|
+
checkArrayShape(schema, value, path, ctx, depth);
|
|
332
|
+
}
|
|
333
|
+
function matchesType(schema, value) {
|
|
334
|
+
const type = schema.type;
|
|
335
|
+
if (type === undefined)
|
|
336
|
+
return true;
|
|
337
|
+
const names = Array.isArray(type) ? type : [type];
|
|
338
|
+
// `some`, never `every`: `type: ['string', 'null']` is a union, which is how every
|
|
339
|
+
// schema author reads it. An unknown type name matches nothing, so a future
|
|
340
|
+
// keyword this build does not know cannot silently accept everything.
|
|
341
|
+
return names.some((name) => matchesOneType(name, value));
|
|
342
|
+
}
|
|
343
|
+
function matchesOneType(name, value) {
|
|
344
|
+
if (name === 'object')
|
|
345
|
+
return isPlainObject(value);
|
|
346
|
+
if (name === 'array')
|
|
347
|
+
return Array.isArray(value);
|
|
348
|
+
if (name === 'null')
|
|
349
|
+
return value === null;
|
|
350
|
+
if (name === 'integer')
|
|
351
|
+
return typeof value === 'number' && Number.isInteger(value);
|
|
352
|
+
if (name === 'number')
|
|
353
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
354
|
+
if (name === 'string')
|
|
355
|
+
return typeof value === 'string';
|
|
356
|
+
if (name === 'boolean')
|
|
357
|
+
return typeof value === 'boolean';
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
function describeSchemaTypes(schema) {
|
|
361
|
+
const type = schema.type;
|
|
362
|
+
if (typeof type === 'string')
|
|
363
|
+
return type;
|
|
364
|
+
if (Array.isArray(type))
|
|
365
|
+
return type.map((item) => String(item)).join(' | ');
|
|
366
|
+
return 'any';
|
|
367
|
+
}
|
|
368
|
+
function describeEnum(values) {
|
|
369
|
+
const shown = values.slice(0, 6).map((item) => clipText(JSON.stringify(item) ?? 'null', 40));
|
|
370
|
+
return shown.join(', ') + (values.length > 6 ? ', ...' : '');
|
|
371
|
+
}
|
|
372
|
+
function checkStringBounds(schema, value, path, ctx) {
|
|
373
|
+
if (typeof value !== 'string')
|
|
374
|
+
return;
|
|
375
|
+
if (typeof schema.minLength === 'number' && value.length < schema.minLength) {
|
|
376
|
+
push(ctx, `${clipPath(path, ctx)}: length ${value.length} is below minLength ${schema.minLength}`);
|
|
377
|
+
}
|
|
378
|
+
if (typeof schema.maxLength === 'number' && value.length > schema.maxLength) {
|
|
379
|
+
push(ctx, `${clipPath(path, ctx)}: length ${value.length} is above maxLength ${schema.maxLength}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
function checkNumberBounds(schema, value, path, ctx) {
|
|
383
|
+
if (typeof value !== 'number')
|
|
384
|
+
return;
|
|
385
|
+
if (typeof schema.minimum === 'number' && value < schema.minimum) {
|
|
386
|
+
push(ctx, `${clipPath(path, ctx)}: ${value} is below minimum ${schema.minimum}`);
|
|
387
|
+
}
|
|
388
|
+
if (typeof schema.maximum === 'number' && value > schema.maximum) {
|
|
389
|
+
push(ctx, `${clipPath(path, ctx)}: ${value} is above maximum ${schema.maximum}`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
function checkObjectShape(schema, value, path, ctx, depth) {
|
|
393
|
+
if (!isPlainObject(value))
|
|
394
|
+
return;
|
|
395
|
+
const properties = isPlainObject(schema.properties) ? schema.properties : {};
|
|
396
|
+
if (Array.isArray(schema.required)) {
|
|
397
|
+
for (const key of schema.required) {
|
|
398
|
+
if (typeof key !== 'string')
|
|
399
|
+
continue;
|
|
400
|
+
if (value[key] === undefined)
|
|
401
|
+
push(ctx, `${clipPath(`${path}.${key}`, ctx)}: required`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
for (const key of Object.keys(properties)) {
|
|
405
|
+
if (value[key] === undefined)
|
|
406
|
+
continue;
|
|
407
|
+
checkNode(properties[key], value[key], `${path}.${key}`, ctx, depth + 1);
|
|
408
|
+
}
|
|
409
|
+
if (schema.additionalProperties === false) {
|
|
410
|
+
for (const key of Object.keys(value)) {
|
|
411
|
+
if (Object.prototype.hasOwnProperty.call(properties, key))
|
|
412
|
+
continue;
|
|
413
|
+
push(ctx, `${clipPath(`${path}.${key}`, ctx)}: not allowed (additionalProperties is false)`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
function checkArrayShape(schema, value, path, ctx, depth) {
|
|
418
|
+
if (!Array.isArray(value))
|
|
419
|
+
return;
|
|
420
|
+
if (typeof schema.minItems === 'number' && value.length < schema.minItems) {
|
|
421
|
+
push(ctx, `${clipPath(path, ctx)}: ${value.length} items is below minItems ${schema.minItems}`);
|
|
422
|
+
}
|
|
423
|
+
if (typeof schema.maxItems === 'number' && value.length > schema.maxItems) {
|
|
424
|
+
push(ctx, `${clipPath(path, ctx)}: ${value.length} items is above maxItems ${schema.maxItems}`);
|
|
425
|
+
}
|
|
426
|
+
// The single-schema form only. A tuple (`items: [...]`) is checked by `ajv` when it
|
|
427
|
+
// is present; implementing it here would be a second dialect, not a safety net.
|
|
428
|
+
if (!isPlainObject(schema.items))
|
|
429
|
+
return;
|
|
430
|
+
const itemsSchema = schema.items;
|
|
431
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
432
|
+
if (ctx.violations.length >= ctx.maxViolations)
|
|
433
|
+
return;
|
|
434
|
+
checkNode(itemsSchema, value[index], `${path}[${index}]`, ctx, depth + 1);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
function push(ctx, violation) {
|
|
438
|
+
if (ctx.violations.length >= ctx.maxViolations)
|
|
439
|
+
return;
|
|
440
|
+
ctx.violations.push(violation);
|
|
441
|
+
}
|
|
442
|
+
function clipPath(path, ctx) {
|
|
443
|
+
return clipText(path, ctx.maxPathChars);
|
|
444
|
+
}
|
|
445
|
+
/** Structural equality for `enum` / `const`, ordered keys included. */
|
|
446
|
+
function deepEqual(a, b) {
|
|
447
|
+
if (a === b)
|
|
448
|
+
return true;
|
|
449
|
+
if (typeof a !== typeof b)
|
|
450
|
+
return false;
|
|
451
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
452
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
|
|
453
|
+
return false;
|
|
454
|
+
return a.every((item, index) => deepEqual(item, b[index]));
|
|
455
|
+
}
|
|
456
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
457
|
+
const left = Object.keys(a);
|
|
458
|
+
const right = Object.keys(b);
|
|
459
|
+
if (left.length !== right.length)
|
|
460
|
+
return false;
|
|
461
|
+
return left.every((key) => deepEqual(a[key], b[key]));
|
|
462
|
+
}
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The result box: one value, written by the tool, read by the runner
|
|
3
|
+
* (headless-request-scope-and-typed-result section 2.3.5 / 5.1).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
|
|
6
|
+
*
|
|
7
|
+
* ONE WRITER, ONE READER, ONE RESET POINT. `submit_result.execute` is the only
|
|
8
|
+
* writer, `ExecRunner.checkResultComplete` and the `result` event are the readers,
|
|
9
|
+
* and `runner.run()`'s request loop calls `reset()` between `scope.begin()` and
|
|
10
|
+
* `controller.prompt()`. That last sentence is the important one: resetting at the
|
|
11
|
+
* END of a request instead would let a tool call that settles one tick late land its
|
|
12
|
+
* value in the NEXT request, which is a defect that costs a day to find and two
|
|
13
|
+
* lines to prevent.
|
|
14
|
+
*
|
|
15
|
+
* SINGLE ASSIGNMENT (I-S3). A later `accept` is IGNORED, never an overwrite: the
|
|
16
|
+
* model calling `submit_result` twice is doing what the prompt told it not to, and
|
|
17
|
+
* the accepted answer must be the one the caller is entitled to - the first.
|
|
18
|
+
*/
|
|
19
|
+
/** What was accepted, and enough provenance to explain it afterwards. */
|
|
20
|
+
export interface ResultAcceptance {
|
|
21
|
+
value: unknown;
|
|
22
|
+
/** `submit_result` calls that preceded acceptance, including the accepted one. */
|
|
23
|
+
attempts: number;
|
|
24
|
+
/** Core's tool-call id, so a transcript can be joined to the event stream. */
|
|
25
|
+
callId: string;
|
|
26
|
+
acceptedAt: number;
|
|
27
|
+
}
|
|
28
|
+
export interface ResultSink {
|
|
29
|
+
/**
|
|
30
|
+
* Record the accepted value. Ignored when one is already accepted, and the caller
|
|
31
|
+
* must NOT treat that as a failure: a second call is answered with a success
|
|
32
|
+
* result precisely so the model stops instead of retrying (I-S3).
|
|
33
|
+
*/
|
|
34
|
+
accept(value: unknown, meta: {
|
|
35
|
+
attempts: number;
|
|
36
|
+
callId: string;
|
|
37
|
+
}): void;
|
|
38
|
+
/** The accepted value, or `null` while nothing has been accepted. */
|
|
39
|
+
accepted(): ResultAcceptance | null;
|
|
40
|
+
/** Count one attempt and return the running total. */
|
|
41
|
+
noteAttempt(): number;
|
|
42
|
+
/** Remember the last rejection so the repair message can quote it. */
|
|
43
|
+
noteViolations(violations: string[]): void;
|
|
44
|
+
lastViolations(): string[];
|
|
45
|
+
/** Attempts in the current request. */
|
|
46
|
+
attempts(): number;
|
|
47
|
+
/** THE ONLY RESET POINT - see the file header. */
|
|
48
|
+
reset(): void;
|
|
49
|
+
}
|
|
50
|
+
export interface ResultSinkOptions {
|
|
51
|
+
/** Injected so a test never touches the real clock. */
|
|
52
|
+
now: () => number;
|
|
53
|
+
}
|
|
54
|
+
export declare function createResultSink(options: ResultSinkOptions): ResultSink;
|
|
55
|
+
/** The members the runner depends on; a narrower type keeps the port honest. */
|
|
56
|
+
export type ResultSinkReader = Pick<ResultSink, 'accepted' | 'lastViolations' | 'attempts' | 'reset'>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The result box: one value, written by the tool, read by the runner
|
|
3
|
+
* (headless-request-scope-and-typed-result section 2.3.5 / 5.1).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
|
|
6
|
+
*
|
|
7
|
+
* ONE WRITER, ONE READER, ONE RESET POINT. `submit_result.execute` is the only
|
|
8
|
+
* writer, `ExecRunner.checkResultComplete` and the `result` event are the readers,
|
|
9
|
+
* and `runner.run()`'s request loop calls `reset()` between `scope.begin()` and
|
|
10
|
+
* `controller.prompt()`. That last sentence is the important one: resetting at the
|
|
11
|
+
* END of a request instead would let a tool call that settles one tick late land its
|
|
12
|
+
* value in the NEXT request, which is a defect that costs a day to find and two
|
|
13
|
+
* lines to prevent.
|
|
14
|
+
*
|
|
15
|
+
* SINGLE ASSIGNMENT (I-S3). A later `accept` is IGNORED, never an overwrite: the
|
|
16
|
+
* model calling `submit_result` twice is doing what the prompt told it not to, and
|
|
17
|
+
* the accepted answer must be the one the caller is entitled to - the first.
|
|
18
|
+
*/
|
|
19
|
+
export function createResultSink(options) {
|
|
20
|
+
let accepted = null;
|
|
21
|
+
let attempts = 0;
|
|
22
|
+
let violations = [];
|
|
23
|
+
return {
|
|
24
|
+
accept(value, meta) {
|
|
25
|
+
if (accepted)
|
|
26
|
+
return;
|
|
27
|
+
accepted = {
|
|
28
|
+
value,
|
|
29
|
+
attempts: meta.attempts,
|
|
30
|
+
callId: meta.callId,
|
|
31
|
+
acceptedAt: options.now(),
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
accepted: () => accepted,
|
|
35
|
+
noteAttempt() {
|
|
36
|
+
attempts += 1;
|
|
37
|
+
return attempts;
|
|
38
|
+
},
|
|
39
|
+
noteViolations(next) {
|
|
40
|
+
violations = [...next];
|
|
41
|
+
},
|
|
42
|
+
lastViolations: () => [...violations],
|
|
43
|
+
attempts: () => attempts,
|
|
44
|
+
reset() {
|
|
45
|
+
accepted = null;
|
|
46
|
+
attempts = 0;
|
|
47
|
+
violations = [];
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|