@parcha/agentrun-pi 0.1.0-beta.12 → 0.1.0-beta.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -8
- package/dist/cli.js +10 -1
- package/dist/demo-cli.d.ts +2 -0
- package/dist/demo-cli.js +25 -0
- package/dist/durable/decision-recovery.d.ts +23 -0
- package/dist/durable/decision-recovery.js +57 -0
- package/dist/durable/decisions.d.ts +113 -0
- package/dist/durable/decisions.js +71 -0
- package/dist/durable/index.d.ts +19 -0
- package/dist/durable/index.js +13 -0
- package/dist/durable/node-runner.d.ts +100 -0
- package/dist/durable/node-runner.js +243 -0
- package/dist/durable/node.d.ts +88 -0
- package/dist/durable/node.js +81 -0
- package/dist/durable/pi-versions.d.ts +34 -0
- package/dist/durable/pi-versions.js +66 -0
- package/dist/durable/readers.d.ts +25 -0
- package/dist/durable/readers.js +64 -0
- package/dist/durable/receipts.d.ts +52 -0
- package/dist/durable/receipts.js +91 -0
- package/dist/durable/record-file.d.ts +10 -0
- package/dist/durable/record-file.js +56 -0
- package/dist/durable/record-tool.d.ts +76 -0
- package/dist/durable/record-tool.js +123 -0
- package/dist/durable/record.d.ts +157 -0
- package/dist/durable/record.js +402 -0
- package/dist/durable/recording.d.ts +90 -0
- package/dist/durable/recording.js +292 -0
- package/dist/durable/store.d.ts +23 -0
- package/dist/durable/store.js +192 -0
- package/dist/durable/task.d.ts +129 -0
- package/dist/durable/task.js +395 -0
- package/dist/extension-service.js +14 -1
- package/dist/extension.d.ts +2 -0
- package/dist/extension.js +98 -13
- package/dist/host-session.js +3 -1
- package/dist/run-error.d.ts +6 -0
- package/dist/run-error.js +13 -0
- package/dist/runner.d.ts +1 -6
- package/dist/runner.js +2 -12
- package/dist/sop.d.ts +12 -0
- package/dist/sop.js +29 -0
- package/dist/tool-context.d.ts +21 -0
- package/dist/tool-context.js +13 -0
- package/dist/workflow-session.d.ts +4 -0
- package/dist/workflow-session.js +3 -2
- package/package.json +43 -8
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
// The record core: how one LLM node delivers a typed record through one `submit` call, with no pi import.
|
|
2
|
+
//
|
|
3
|
+
// A submission is repaired as a transport spelling (stringified containers and scalars, a "null" where the
|
|
4
|
+
// field admits null), read from a workspace file when it names one, held to the host's contracts and the
|
|
5
|
+
// schema, and then read a second time by the node's reviewers. Every answer that is not a delivery spends
|
|
6
|
+
// one delivery attempt: a rejection, the second reading's one bounce, a nudge of a yield without a record.
|
|
7
|
+
// The second reading is feedback, once: the first record it disagrees with goes back; the next structurally
|
|
8
|
+
// valid record is delivered with what the reading still disagrees with beside it. A reviewer that cannot
|
|
9
|
+
// answer decides nothing.
|
|
10
|
+
//
|
|
11
|
+
// `deliver` is the whole decision over a `RecordStore`, the two small states a caller keeps durably or in
|
|
12
|
+
// memory. `gate` and `spend` are the rules as pure functions, the ones `spec/lean` proves terminate. `deliver`
|
|
13
|
+
// follows `gate`. It does not hold `spend`'s bound: it decides a submission whatever the count, and a nudge that
|
|
14
|
+
// spends the last attempt still continues the run, so the one submission after it is decided and `attempts` can
|
|
15
|
+
// read one past the limit.
|
|
16
|
+
import { isDeepStrictEqual } from "node:util";
|
|
17
|
+
import { Compile } from "typebox/compile";
|
|
18
|
+
/** Delivery attempts a node gets: rejections, the second reading's one bounce and nudges, each once. */
|
|
19
|
+
export const DELIVERY_ATTEMPTS = 6;
|
|
20
|
+
/** The most schema violations one rejected delivery lists; the submit tool's description tells the model the number. */
|
|
21
|
+
export const VIOLATIONS_SHOWN = 8;
|
|
22
|
+
/** The second reading on one structurally valid submission: it sends the record back only while its round
|
|
23
|
+
* is unspent and a reviewer disagrees. A reviewer with no verdict never sends a record back. */
|
|
24
|
+
export function gate(bounced, readings) {
|
|
25
|
+
return !bounced && readings.includes("disagrees") ? "bounce" : "accept";
|
|
26
|
+
}
|
|
27
|
+
/** Whether a node's delivery has ended: it delivered, or its attempts are spent. */
|
|
28
|
+
export const deliveryEnded = (max, state) => state.delivered || state.attempts >= max;
|
|
29
|
+
/** One answer applied to the count. An ended delivery takes no further answer; every other answer counts once. */
|
|
30
|
+
export function spend(max, state, event) {
|
|
31
|
+
if (deliveryEnded(max, state))
|
|
32
|
+
return state;
|
|
33
|
+
return { attempts: state.attempts + 1, delivered: event === "delivered" };
|
|
34
|
+
}
|
|
35
|
+
// ─── the schema a record is held to ───
|
|
36
|
+
/** What makes a schema one no `submit` call can satisfy: a call's arguments are an object, so the schema must be a
|
|
37
|
+
* JSON Schema object whose root type, when it names one, admits an object. A node runner refuses such a schema
|
|
38
|
+
* before any conversation exists. */
|
|
39
|
+
export function lintRecordRoot(schema) {
|
|
40
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema))
|
|
41
|
+
return ["the record schema must be a JSON Schema object"];
|
|
42
|
+
const { type } = schema;
|
|
43
|
+
const admitsObject = type === undefined || type === "object" || (Array.isArray(type) && type.includes("object"));
|
|
44
|
+
return admitsObject ? [] : [`the record schema's root type must be "object", not ${JSON.stringify(type)}`];
|
|
45
|
+
}
|
|
46
|
+
/** What an author should fix in a record schema, for a host or a validator to report: the root findings above, and
|
|
47
|
+
* a schema that declares its fields and requires one that is not among them, which the tool's shown fields never
|
|
48
|
+
* name. A record of the second kind can still be delivered (the tool's parameters are open, and the delivery
|
|
49
|
+
* contract that ends the node's task carries the schema with its `required`), so a runner does not refuse it. A
|
|
50
|
+
* schema that declares no `properties` is not held to it. */
|
|
51
|
+
export function lintRecordSchema(schema) {
|
|
52
|
+
const problems = lintRecordRoot(schema);
|
|
53
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema))
|
|
54
|
+
return problems;
|
|
55
|
+
const { properties, required } = schema;
|
|
56
|
+
const declared = properties && typeof properties === "object" && !Array.isArray(properties) ? properties : null;
|
|
57
|
+
const undeclared = Array.isArray(required) && declared ? required.filter((name) => typeof name !== "string" || !Object.hasOwn(declared, name)) : [];
|
|
58
|
+
if (undeclared.length)
|
|
59
|
+
problems.push(`the record schema requires ${undeclared.map((name) => JSON.stringify(name)).join(", ")}, which its properties do not declare`);
|
|
60
|
+
return problems;
|
|
61
|
+
}
|
|
62
|
+
const validators = new WeakMap();
|
|
63
|
+
/** The schema's compiled validator, or null for a schema the compiler refuses. */
|
|
64
|
+
function validatorOf(schema) {
|
|
65
|
+
if (!schema || typeof schema !== "object")
|
|
66
|
+
return null;
|
|
67
|
+
let validator = validators.get(schema);
|
|
68
|
+
if (validator === undefined) {
|
|
69
|
+
try {
|
|
70
|
+
validator = Compile(schema);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
validator = null;
|
|
74
|
+
}
|
|
75
|
+
validators.set(schema, validator);
|
|
76
|
+
}
|
|
77
|
+
return validator;
|
|
78
|
+
}
|
|
79
|
+
// ─── transport repair ───
|
|
80
|
+
/** Where the schema expects an object or an array and the submitted value is a string that parses to one, the
|
|
81
|
+
* parsed value; where it expects a number, an integer, a boolean or null and admits no string, the scalar a
|
|
82
|
+
* string spells. Some model families serialise nested tool arguments as strings: the content is valid and
|
|
83
|
+
* every submit fails on its spelling. A string that parses to a container the field also admits is read as that
|
|
84
|
+
* container; only against a scalar does a field that admits a string keep the string. */
|
|
85
|
+
export function parseStringifiedContainers(value, schema, depth = 0, rootDefinitions) {
|
|
86
|
+
if (depth > 6 || !schema || typeof schema !== "object")
|
|
87
|
+
return value;
|
|
88
|
+
// A `$ref` property carries no type of its own: it is read through the root schema's definitions, or the
|
|
89
|
+
// repair skips exactly the fields it exists for.
|
|
90
|
+
const definitions = rootDefinitions ?? schema.definitions;
|
|
91
|
+
const deref = (spec, hops = 0) => {
|
|
92
|
+
if (hops > 4 || !spec || typeof spec !== "object")
|
|
93
|
+
return spec;
|
|
94
|
+
const ref = typeof spec.$ref === "string" ? spec.$ref.match(/^#\/definitions\/(.+)$/) : null;
|
|
95
|
+
if (ref && definitions && definitions[ref[1]] && typeof definitions[ref[1]] === "object")
|
|
96
|
+
return deref(definitions[ref[1]], hops + 1);
|
|
97
|
+
return spec;
|
|
98
|
+
};
|
|
99
|
+
const expected = (spec) => {
|
|
100
|
+
const resolved = deref(spec);
|
|
101
|
+
const types = Array.isArray(resolved?.type) ? resolved.type : resolved?.type ? [resolved.type] : [];
|
|
102
|
+
const anyOf = Array.isArray(resolved?.anyOf) ? resolved.anyOf : [];
|
|
103
|
+
return [...types, ...anyOf.flatMap((variant) => expected(variant))];
|
|
104
|
+
};
|
|
105
|
+
const repair = (input, rawSpec) => {
|
|
106
|
+
const spec = deref(rawSpec);
|
|
107
|
+
const wants = expected(spec);
|
|
108
|
+
let current = input;
|
|
109
|
+
if (typeof current === "string" && (wants.includes("object") || wants.includes("array"))) {
|
|
110
|
+
const trimmed = current.trim();
|
|
111
|
+
if ((trimmed.startsWith("{") && trimmed.endsWith("}")) || (trimmed.startsWith("[") && trimmed.endsWith("]"))) {
|
|
112
|
+
try {
|
|
113
|
+
const parsed = JSON.parse(trimmed);
|
|
114
|
+
const isObject = parsed && typeof parsed === "object" && !Array.isArray(parsed);
|
|
115
|
+
if ((isObject && wants.includes("object")) || (Array.isArray(parsed) && wants.includes("array")))
|
|
116
|
+
current = parsed;
|
|
117
|
+
}
|
|
118
|
+
catch { /* Not JSON: the string stays for the validator to report. */ }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// A scalar is coerced only when the field does not accept a string. Integers must be exact; a decimal
|
|
122
|
+
// becomes the float64 that JSON.parse of the same literal yields, the only value a JSON number can hold.
|
|
123
|
+
if (typeof current === "string" && !wants.includes("string")) {
|
|
124
|
+
const trimmed = current.trim();
|
|
125
|
+
if ((wants.includes("integer") || wants.includes("number")) && /^-?\d+$/.test(trimmed) && Number.isSafeInteger(Number(trimmed)))
|
|
126
|
+
current = Number(trimmed);
|
|
127
|
+
else if (wants.includes("number") && /^-?(?:0|[1-9]\d*)\.\d+$/.test(trimmed) && Number.isFinite(Number(trimmed)))
|
|
128
|
+
current = JSON.parse(trimmed);
|
|
129
|
+
else if (wants.includes("boolean") && (trimmed === "true" || trimmed === "false"))
|
|
130
|
+
current = trimmed === "true";
|
|
131
|
+
else if (wants.includes("null") && trimmed === "null")
|
|
132
|
+
current = null;
|
|
133
|
+
}
|
|
134
|
+
// An `anyOf`-wrapped object's nested fields live under the branch's properties, not the wrapper's.
|
|
135
|
+
const flattenBranches = (candidate) => [candidate, ...(Array.isArray(candidate?.anyOf) ? candidate.anyOf.flatMap(flattenBranches) : [])];
|
|
136
|
+
if (current && typeof current === "object" && !Array.isArray(current)) {
|
|
137
|
+
const objectBranch = flattenBranches(spec).map((branch) => deref(branch)).find((branch) => branch?.properties && typeof branch.properties === "object");
|
|
138
|
+
if (objectBranch)
|
|
139
|
+
return parseStringifiedContainers(current, objectBranch, depth + 1, definitions);
|
|
140
|
+
}
|
|
141
|
+
if (Array.isArray(current)) {
|
|
142
|
+
const arrayBranch = flattenBranches(spec).map((branch) => deref(branch)).find((branch) => branch?.items && typeof branch.items === "object");
|
|
143
|
+
if (arrayBranch)
|
|
144
|
+
return current.map((item) => repair(item, arrayBranch.items));
|
|
145
|
+
}
|
|
146
|
+
return current;
|
|
147
|
+
};
|
|
148
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
149
|
+
return value;
|
|
150
|
+
const properties = schema.properties || {};
|
|
151
|
+
const next = { ...value };
|
|
152
|
+
for (const [key, spec] of Object.entries(properties))
|
|
153
|
+
if (key in next)
|
|
154
|
+
next[key] = repair(next[key], spec);
|
|
155
|
+
return next;
|
|
156
|
+
}
|
|
157
|
+
/** A local `$ref` (`#/definitions/<name>` or `#/$defs/<name>`) resolved against the record's root schema, so
|
|
158
|
+
* the null-spelling walk reads the field schemas the schema check does. One it cannot resolve reads as no schema. */
|
|
159
|
+
function deref(schema, root, hops = 0) {
|
|
160
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema))
|
|
161
|
+
return schema;
|
|
162
|
+
const ref = schema.$ref;
|
|
163
|
+
if (typeof ref !== "string")
|
|
164
|
+
return schema;
|
|
165
|
+
const match = ref.match(/^#\/(definitions|\$defs)\/([^/]+)$/);
|
|
166
|
+
const table = match && root && typeof root === "object" ? root[match[1]] : undefined;
|
|
167
|
+
const target = table && typeof table === "object" ? table[match[2]] : undefined;
|
|
168
|
+
return target && hops < 16 ? deref(target, root, hops + 1) : undefined;
|
|
169
|
+
}
|
|
170
|
+
function admitsNull(schema, root) {
|
|
171
|
+
schema = deref(schema, root);
|
|
172
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema))
|
|
173
|
+
return false;
|
|
174
|
+
const spec = schema;
|
|
175
|
+
if (spec.type === "null" || (Array.isArray(spec.type) && spec.type.includes("null")))
|
|
176
|
+
return true;
|
|
177
|
+
if (spec.const === null || (Array.isArray(spec.enum) && spec.enum.includes(null)))
|
|
178
|
+
return true;
|
|
179
|
+
return [spec.anyOf, spec.oneOf].some((branches) => Array.isArray(branches) && branches.some((branch) => admitsNull(branch, root)));
|
|
180
|
+
}
|
|
181
|
+
function propertiesOf(schema, root) {
|
|
182
|
+
schema = deref(schema, root);
|
|
183
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema))
|
|
184
|
+
return {};
|
|
185
|
+
const spec = schema;
|
|
186
|
+
const fieldMap = !("type" in spec) && !("properties" in spec) && !("anyOf" in spec) && !("oneOf" in spec) ? spec : {};
|
|
187
|
+
const direct = spec.properties && typeof spec.properties === "object" && !Array.isArray(spec.properties) ? spec.properties : fieldMap;
|
|
188
|
+
const branches = [...(Array.isArray(spec.anyOf) ? spec.anyOf : []), ...(Array.isArray(spec.oneOf) ? spec.oneOf : [])];
|
|
189
|
+
return Object.assign({}, ...branches.map((branch) => propertiesOf(branch, root)), direct);
|
|
190
|
+
}
|
|
191
|
+
function itemsOf(schema, root) {
|
|
192
|
+
schema = deref(schema, root);
|
|
193
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema))
|
|
194
|
+
return undefined;
|
|
195
|
+
const spec = schema;
|
|
196
|
+
if (spec.items)
|
|
197
|
+
return spec.items;
|
|
198
|
+
const branches = [...(Array.isArray(spec.anyOf) ? spec.anyOf : []), ...(Array.isArray(spec.oneOf) ? spec.oneOf : [])];
|
|
199
|
+
return branches.map((branch) => itemsOf(branch, root)).find(Boolean);
|
|
200
|
+
}
|
|
201
|
+
// The spellings a model uses when it means "no value": one set, read by the finding and by the repair, so a
|
|
202
|
+
// string one recognises the other heals.
|
|
203
|
+
const NULL_SPELLINGS = new Set(["null", "none", "n/a"]);
|
|
204
|
+
const spellsNull = (value) => typeof value === "string" && NULL_SPELLINGS.has(value.trim().toLowerCase());
|
|
205
|
+
/** The path of every string that spells "no value" in a field whose schema admits JSON null. */
|
|
206
|
+
export function nullSpellings(value, schema, root = schema, at = "") {
|
|
207
|
+
if (spellsNull(value) && admitsNull(schema, root))
|
|
208
|
+
return [at || "$"];
|
|
209
|
+
if (Array.isArray(value)) {
|
|
210
|
+
const items = itemsOf(schema, root);
|
|
211
|
+
return items ? value.flatMap((item, index) => nullSpellings(item, items, root, `${at}[${index}]`)) : [];
|
|
212
|
+
}
|
|
213
|
+
if (!value || typeof value !== "object")
|
|
214
|
+
return [];
|
|
215
|
+
const properties = propertiesOf(schema, root);
|
|
216
|
+
return Object.entries(value).flatMap(([key, child]) => Object.hasOwn(properties, key) ? nullSpellings(child, properties[key], root, at ? `${at}.${key}` : key) : []);
|
|
217
|
+
}
|
|
218
|
+
/** The record with every such string replaced by JSON null, in place. A repair the record as a whole refuses
|
|
219
|
+
* (it satisfied its schema before and does not after) is undone in full, so a spelling stays only where null
|
|
220
|
+
* is inadmissible; `undo: false` leaves that judgement to a caller that repairs more of the record and judges the
|
|
221
|
+
* whole. `""` is a value and stays. */
|
|
222
|
+
export function healNullSpellings(record, schema, options = {}) {
|
|
223
|
+
if (!record || typeof record !== "object" || Array.isArray(record) || Object.getPrototypeOf(record) !== Object.prototype)
|
|
224
|
+
return record;
|
|
225
|
+
if (!nullSpellings(record, schema).length)
|
|
226
|
+
return record;
|
|
227
|
+
const validator = options.undo === false ? null : validatorOf(schema);
|
|
228
|
+
const before = validator?.Check(record) ? structuredClone(record) : null;
|
|
229
|
+
const heal = (value, spec) => {
|
|
230
|
+
if (spellsNull(value) && admitsNull(spec, schema))
|
|
231
|
+
return null;
|
|
232
|
+
if (Array.isArray(value)) {
|
|
233
|
+
const items = itemsOf(spec, schema);
|
|
234
|
+
if (items)
|
|
235
|
+
value.forEach((item, index) => { value[index] = heal(item, items); });
|
|
236
|
+
return value;
|
|
237
|
+
}
|
|
238
|
+
if (!value || typeof value !== "object")
|
|
239
|
+
return value;
|
|
240
|
+
const properties = propertiesOf(spec, schema);
|
|
241
|
+
for (const [key, child] of Object.entries(value)) {
|
|
242
|
+
if (Object.hasOwn(properties, key))
|
|
243
|
+
value[key] = heal(child, properties[key]);
|
|
244
|
+
}
|
|
245
|
+
return value;
|
|
246
|
+
};
|
|
247
|
+
heal(record, schema);
|
|
248
|
+
if (before && validator && !validator.Check(record)) {
|
|
249
|
+
const fields = record;
|
|
250
|
+
for (const key of Object.keys(fields))
|
|
251
|
+
delete fields[key];
|
|
252
|
+
Object.assign(fields, before);
|
|
253
|
+
}
|
|
254
|
+
return record;
|
|
255
|
+
}
|
|
256
|
+
/** A record's transport spellings repaired against its schema, without touching `args`. */
|
|
257
|
+
export function repairRecord(args, schema) {
|
|
258
|
+
if (!args || typeof args !== "object" || Array.isArray(args))
|
|
259
|
+
return args;
|
|
260
|
+
return healNullSpellings(structuredClone(parseStringifiedContainers(args, schema)), schema);
|
|
261
|
+
}
|
|
262
|
+
// ─── the file envelope ───
|
|
263
|
+
/** The file a submission names and nothing else: `{ <key>: "relative/path.json" }`. */
|
|
264
|
+
export function fileSubmission(args, key) {
|
|
265
|
+
if (!args || typeof args !== "object" || Array.isArray(args))
|
|
266
|
+
return null;
|
|
267
|
+
const fields = args;
|
|
268
|
+
return Object.keys(fields).length === 1 && typeof fields[key] === "string" ? fields[key] : null;
|
|
269
|
+
}
|
|
270
|
+
/** A submission with fields beside the file key. Fields that form a whole record on their own are that record;
|
|
271
|
+
* otherwise the file holds the record, and each field beside it may only restate the file's value. A schema
|
|
272
|
+
* that declares the file key as its own field keeps it inline. */
|
|
273
|
+
export function mixedFileSubmission(args, schema, key) {
|
|
274
|
+
if (!args || typeof args !== "object" || Array.isArray(args))
|
|
275
|
+
return null;
|
|
276
|
+
const { [key]: file, ...extras } = args;
|
|
277
|
+
if (typeof file !== "string" || !Object.keys(extras).length)
|
|
278
|
+
return null;
|
|
279
|
+
const properties = schema && typeof schema.properties === "object" && schema.properties ? schema.properties : {};
|
|
280
|
+
return Object.hasOwn(properties, key) ? null : { file, extras };
|
|
281
|
+
}
|
|
282
|
+
/** The fields beside the file key that the file's record does not hold with the same value. */
|
|
283
|
+
export function extrasNotInRecord(record, extras) {
|
|
284
|
+
const held = record && typeof record === "object" && !Array.isArray(record) ? record : {};
|
|
285
|
+
return Object.keys(extras).filter((key) => !Object.hasOwn(held, key) || !isDeepStrictEqual(held[key], extras[key])).sort();
|
|
286
|
+
}
|
|
287
|
+
const SCHEMA_REASON = "submission does not satisfy the schema";
|
|
288
|
+
const messageOf = (error) => error instanceof Error ? error.message : String(error);
|
|
289
|
+
/** Decide one `submit` call. `args` are the call's arguments as the model sent them, or as `repairRecord`
|
|
290
|
+
* already repaired them. */
|
|
291
|
+
export async function deliver(contract, store, args) {
|
|
292
|
+
const max = contract.maxAttempts ?? DELIVERY_ATTEMPTS;
|
|
293
|
+
const key = contract.file?.key;
|
|
294
|
+
const state = await store.read();
|
|
295
|
+
if (state.record.record != null)
|
|
296
|
+
return { status: "already" };
|
|
297
|
+
const reject = async (problems, reason = SCHEMA_REASON) => {
|
|
298
|
+
const attempts = await store.reject();
|
|
299
|
+
return { status: "rejected", attempts, spent: attempts >= max, reason, problems };
|
|
300
|
+
};
|
|
301
|
+
const validator = validatorOf(contract.schema);
|
|
302
|
+
let candidate = args;
|
|
303
|
+
const file = key === undefined ? null : fileSubmission(candidate, key);
|
|
304
|
+
const mixed = key === undefined ? null : mixedFileSubmission(candidate, contract.schema, key);
|
|
305
|
+
// Fields beside the file key that are a whole record on their own are that record, as an inline submission.
|
|
306
|
+
const inlineBeside = mixed ? repairRecord(mixed.extras, contract.schema) : undefined;
|
|
307
|
+
if (mixed && validator?.Check(inlineBeside))
|
|
308
|
+
candidate = inlineBeside;
|
|
309
|
+
else if (mixed) {
|
|
310
|
+
const exact = `call submit with exactly {"${key}": ${JSON.stringify(mixed.file)}} and nothing beside it`;
|
|
311
|
+
let record;
|
|
312
|
+
try {
|
|
313
|
+
record = await contract.file.read(mixed.file);
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
if (error?.code === "ENOENT")
|
|
317
|
+
return reject([`${key} names a file that does not exist in the workspace; write the complete JSON there first, then ${exact}`]);
|
|
318
|
+
return reject([`${messageOf(error)}; then ${exact}`]);
|
|
319
|
+
}
|
|
320
|
+
const stray = extrasNotInRecord(record, mixed.extras);
|
|
321
|
+
if (stray.length) {
|
|
322
|
+
return reject([`the record is read from ${mixed.file} alone, and these fields beside ${key} are missing from that file or differ from it: ${stray.join(", ")}. Write every field into the file, then ${exact}`], "the submission mixes a file with inline fields");
|
|
323
|
+
}
|
|
324
|
+
candidate = repairRecord(record, contract.schema);
|
|
325
|
+
}
|
|
326
|
+
else if (file !== null) {
|
|
327
|
+
// The file's record takes the same transport repair as an inline one.
|
|
328
|
+
try {
|
|
329
|
+
candidate = repairRecord(await contract.file.read(file), contract.schema);
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
if (error?.code === "ENOENT")
|
|
333
|
+
return reject([`${key} names a file that does not exist in the workspace; write the complete JSON there first, then resubmit`]);
|
|
334
|
+
return reject([messageOf(error)]);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
// A "null" the repair could not heal sits where the record's other fields rule null out: the model writes the value.
|
|
338
|
+
const unhealed = (candidate && typeof candidate === "object" && !Array.isArray(candidate) ? nullSpellings(candidate, contract.schema) : []).map((path) => `${path}: violates "this field cannot be null in this record: the schema admits null for the field alone, but the record's other fields rule it out" — write the real value`);
|
|
339
|
+
const owned = [...unhealed, ...(contract.contracts ? await contract.contracts(candidate) : [])];
|
|
340
|
+
if (owned.length)
|
|
341
|
+
return reject(owned, "the record violates host-owned contracts");
|
|
342
|
+
if (!validator || !validator.Check(candidate)) {
|
|
343
|
+
return reject(validator ? [...validator.Errors(candidate)].slice(0, VIOLATIONS_SHOWN).map((error) => `${error.instancePath || error.path || "/"}: ${error.message}`) : ["the record schema does not compile"]);
|
|
344
|
+
}
|
|
345
|
+
for (const check of contract.checks ?? []) {
|
|
346
|
+
let refusal;
|
|
347
|
+
try {
|
|
348
|
+
refusal = await check(candidate);
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
refusal = { problems: [`host validation gate threw: ${messageOf(error)}`] };
|
|
352
|
+
}
|
|
353
|
+
if (refusal?.problems.length)
|
|
354
|
+
return reject(refusal.problems, refusal.reason);
|
|
355
|
+
}
|
|
356
|
+
// The second reading, once.
|
|
357
|
+
const round = !state.gate.bounced;
|
|
358
|
+
const disagreements = [];
|
|
359
|
+
for (const reviewer of contract.reviewers ?? []) {
|
|
360
|
+
try {
|
|
361
|
+
disagreements.push(...await reviewer(candidate, { round }));
|
|
362
|
+
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
if (contract.fatal?.(error))
|
|
365
|
+
throw error;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (gate(state.gate.bounced, [disagreements.length ? "disagrees" : "agrees"]) === "bounce") {
|
|
369
|
+
const attempts = await store.bounce();
|
|
370
|
+
return { status: "bounced", attempts, spent: attempts >= max, disagreements };
|
|
371
|
+
}
|
|
372
|
+
await store.accept(candidate, disagreements);
|
|
373
|
+
return { status: "accepted", record: candidate, disagreements };
|
|
374
|
+
}
|
|
375
|
+
/** The `submit` tool's answer to the model for one delivery. */
|
|
376
|
+
export function deliveryText(delivery, maxAttempts = DELIVERY_ATTEMPTS, say = disagreementText) {
|
|
377
|
+
const spentLine = "The delivery attempts are spent; the run stops here.";
|
|
378
|
+
switch (delivery.status) {
|
|
379
|
+
case "already": return "Already submitted; the first valid record is authoritative.";
|
|
380
|
+
case "accepted": return "Submitted. You are DONE; end your turn.";
|
|
381
|
+
case "rejected": return `REJECTED (${delivery.attempts}/${maxAttempts}) — ${delivery.reason}: ${delivery.problems.join("; ")}.${delivery.spent ? ` ${spentLine}` : " Fix exactly those and resubmit."}`;
|
|
382
|
+
case "bounced": return `${delivery.disagreements.map(say).join("\n")}${delivery.spent ? `\n${spentLine}` : ""}`;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
/** One disagreement as the model reads it. */
|
|
386
|
+
export const disagreementText = (d) => `${d.id} (${d.kind}): ${d.verdict}${d.reasons.length ? ` - ${d.reasons.join("; ")}` : ""}`;
|
|
387
|
+
/** Whether a delivery ends the node's run: a record was delivered, or the attempts are spent. */
|
|
388
|
+
export const deliveryTerminates = (delivery) => delivery.status === "already" || delivery.status === "accepted" || ((delivery.status === "rejected" || delivery.status === "bounced") && delivery.spent);
|
|
389
|
+
/** The delivery contract a node's task ends with: the label, the file envelope when one is offered, the
|
|
390
|
+
* authoritative schema and the host's own delivery instructions. The model sees the exact schema only here. */
|
|
391
|
+
export function submitFooter(record) {
|
|
392
|
+
return [
|
|
393
|
+
`When you are done, call the \`submit\` tool ONCE with ${record.label} as its arguments. The arguments are validated against the required schema; if validation fails you get the problems back and may fix and resubmit.`,
|
|
394
|
+
...(record.reviewed ? ["A schema-valid record is then reviewed against the procedure that governs this run before it is accepted; violations come back to you, with the rule each one breaks, to fix and resubmit."] : []),
|
|
395
|
+
...(record.fileKey ? [`For a large record, write the complete JSON to a fresh file inside the workspace, then call \`submit\` once with {"${record.fileKey}":"relative/path.json"}. The harness reads and validates that file directly; do not paste the record back into the tool call.`] : []),
|
|
396
|
+
`Required JSON Schema (authoritative): ${JSON.stringify(record.schema)}`,
|
|
397
|
+
record.instructions || "",
|
|
398
|
+
"Only a successful `submit` call counts as delivering. Do not answer in plain text.",
|
|
399
|
+
].filter(Boolean).join("\n");
|
|
400
|
+
}
|
|
401
|
+
/** What a yield without a record is answered with, when the delivery has an attempt left for it. */
|
|
402
|
+
export const nudgeText = (label) => `You stopped without submitting. Call submit with ${label}, complete.`;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { Session, Storage } from "@earendil-works/pi-durable";
|
|
2
|
+
type Json = any;
|
|
3
|
+
type Context = Parameters<Session["commit"]>[1];
|
|
4
|
+
/** Who owns a line's record, by its kind's package prefix; a kind with no package prefix is the host's. */
|
|
5
|
+
export type RecordingSource = "workflow" | "pi" | "browser" | "vision" | "transport" | "host";
|
|
6
|
+
/** One durable line: a document revision (`data` is `{ doc, key?, value, version }`, the whole value after the
|
|
7
|
+
* revision), a conversation entry (`data` is the entry as stored), or a task's record (`data` as stored). */
|
|
8
|
+
export type RecordingLine = {
|
|
9
|
+
channel: "durable";
|
|
10
|
+
source: RecordingSource;
|
|
11
|
+
/** The document's kind, the entry's own `kind`, or the task's kind. */
|
|
12
|
+
kind: string;
|
|
13
|
+
/** The generation of the latest `agentrun.driver` revision at or before `commit`; absent when there is none. */
|
|
14
|
+
gen?: number;
|
|
15
|
+
/** The store's commit sequence. */
|
|
16
|
+
commit: number;
|
|
17
|
+
/** The line's place inside its commit: documents by id, then entries by id, then tasks by id. */
|
|
18
|
+
order: number;
|
|
19
|
+
/** The workflow execution path the record is about: the one it names, or, for a record in a node attempt's
|
|
20
|
+
* conversation (an entry, a conversation's document, a task), the node's. */
|
|
21
|
+
path?: string;
|
|
22
|
+
/** The pi conversation the record belongs to, when it belongs to one. */
|
|
23
|
+
conv?: number;
|
|
24
|
+
/** Present on a line `recordCommits` wrote: when the process adopted the commit, by the recorder's clock. */
|
|
25
|
+
t?: number;
|
|
26
|
+
/** Present on `snapshotLines`' lines: a document's state at `commit`, not a change made there. */
|
|
27
|
+
snapshot?: true;
|
|
28
|
+
data: Json;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Every change the store `file` committed after `after` (default: all), as durable recording lines in `(commit, order)`
|
|
32
|
+
* order, and `last`, the highest commit returned (`after` when nothing is). For a store no process holds (a disk run's
|
|
33
|
+
* store admits one connection): read-only, in one read transaction.
|
|
34
|
+
*
|
|
35
|
+
* - A document revision is a line of the document's kind, with the whole value after it (each delta applied over its
|
|
36
|
+
* base); `key` is the family key as stored, absent for a singleton.
|
|
37
|
+
* - A conversation entry is a line of the entry's own kind, source `pi`, `conv` its conversation, the entry as stored
|
|
38
|
+
* (image blocks included: the recorder moves them to blobs).
|
|
39
|
+
* - Every task's current record is a line of the task's kind, at the store's last commit, after that commit's other
|
|
40
|
+
* lines, by task id: the state at export, since the store keeps no commit for a task's change. Its memos ride in the
|
|
41
|
+
* record; there are no memo lines.
|
|
42
|
+
* - `gen` is the generation of the latest surviving `agentrun.driver` revision at or before the line's commit (it
|
|
43
|
+
* counts journal opens, not processes), and absent before the earliest surviving one. No line has `t`: the store
|
|
44
|
+
* keeps no commit time.
|
|
45
|
+
*
|
|
46
|
+
* This is the store's surviving history: pi deletes a current-only document's older revisions when it writes a new
|
|
47
|
+
* base. The full history is `recordCommits`', written inside the run's process as each commit lands.
|
|
48
|
+
*/
|
|
49
|
+
export declare function recordingLines(file: string, options?: {
|
|
50
|
+
after?: number;
|
|
51
|
+
}): {
|
|
52
|
+
lines: RecordingLine[];
|
|
53
|
+
last: number;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Records the run's commits as durable lines inside its process, on pi's commit publications: each commit adopted
|
|
57
|
+
* after this call becomes the lines `recordingLines` would give it, with tasks at their real commit, `t` the adoption
|
|
58
|
+
* time (`clock()`, default `Date.now()`), and `gen` the generation of the latest `agentrun.driver` revision at or
|
|
59
|
+
* before the commit. `from` is the snapshot this recording continues (`snapshotLines`, written before the run
|
|
60
|
+
* resumed): its driver generation and its nodes' conversations carry on until this process writes its own. `gen` is
|
|
61
|
+
* only for a run with no driver revision yet; it never overrides a driver generation.
|
|
62
|
+
*
|
|
63
|
+
* The listener only maps and queues: it never throws, blocks or calls the Session. `write` receives one commit's lines
|
|
64
|
+
* at a time, in commit order, after the listener has returned. A `write` that completes synchronously (an append to a
|
|
65
|
+
* file) runs before pi adopts the next commit, so a process that dies loses at most the commit in flight; one that
|
|
66
|
+
* completes later leaves the commits after it queued in memory, and a death loses those too. The next process's
|
|
67
|
+
* `snapshotLines` restores the documents' state; a lost commit's entries stay missing, a gap the viewer shows. The
|
|
68
|
+
* returned stop ends the subscription, waits for every queued write, and then throws the first write that failed.
|
|
69
|
+
*/
|
|
70
|
+
export declare function recordCommits(session: Pick<Session, "subscribeCommits">, options: {
|
|
71
|
+
gen?: number;
|
|
72
|
+
from?: RecordingLine[];
|
|
73
|
+
write: (lines: RecordingLine[]) => void | Promise<void>;
|
|
74
|
+
clock?: () => number;
|
|
75
|
+
}): () => Promise<void>;
|
|
76
|
+
/**
|
|
77
|
+
* Every document of the run as it stands, as whole values marked `snapshot: true` at commit `at`, in document id order:
|
|
78
|
+
* the state a process records after it opens the run and before it resumes, so the recording is exact after any kill.
|
|
79
|
+
* `storage` is the store the run's Harness was opened over; `at` is the store's latest commit (pi's `Storage` has no
|
|
80
|
+
* head query: a disk run reads it with pi-durable-disk's `storeHead`). The reads are of the current state (pi keeps no
|
|
81
|
+
* past revision of a session or task document), so the snapshot is exact only while nothing commits: take it before
|
|
82
|
+
* the run resumes. Every line's `gen` is the highest `agentrun.driver` generation in it; `gen` is only for a run with no
|
|
83
|
+
* driver revision yet, and never overrides one.
|
|
84
|
+
*/
|
|
85
|
+
export declare function snapshotLines(storage: Pick<Storage, "scanConversations" | "scanTasks" | "scanDocuments" | "document">, options: {
|
|
86
|
+
at: number;
|
|
87
|
+
gen?: number;
|
|
88
|
+
context: Context;
|
|
89
|
+
}): Promise<RecordingLine[]>;
|
|
90
|
+
export {};
|