spl-lang 0.2.0 → 0.3.1
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 +73 -170
- package/dist/ast.d.ts +6 -15
- package/dist/cli.js +46 -12
- package/dist/copilot.d.ts +1 -0
- package/dist/copilot.js +10 -6
- package/dist/errors.d.ts +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/interpreter.js +99 -69
- package/dist/llm.d.ts +4 -9
- package/dist/llm.js +17 -26
- package/dist/parser.d.ts +2 -12
- package/dist/parser.js +21 -97
- package/dist/recording.d.ts +17 -0
- package/dist/recording.js +93 -0
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +40 -0
- package/dist/tools.d.ts +3 -1
- package/dist/tools.js +29 -9
- package/dist/values.d.ts +1 -1
- package/docs/syntax.ebnf +54 -70
- package/package.json +3 -2
package/dist/interpreter.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { callMethod, iterate, makeBuiltins } from './builtins.js';
|
|
2
2
|
import { Failure, SuccessSignal } from './errors.js';
|
|
3
|
-
import { LlmError, buildPrompt,
|
|
3
|
+
import { LlmError, buildPrompt, parseJsonResponse } from './llm.js';
|
|
4
|
+
import { compileSchema } from './schema.js';
|
|
4
5
|
import { Restrictions, makeRestriction } from './restrict.js';
|
|
5
6
|
import { MemoryStore } from './store.js';
|
|
6
7
|
import { ToolHost, TOOL_SPECS } from './tools.js';
|
|
7
|
-
import { compareValues, equals, hasKey, isDict, isFunction, isList, newDict, runtimeError, setKey, str, truthy, typeName, } from './values.js';
|
|
8
|
+
import { compareValues, equals, hasKey, isDict, isFunction, isList, newDict, runtimeError, setKey, str, toJson, truthy, typeName, } from './values.js';
|
|
8
9
|
export async function run(program, opts) {
|
|
9
10
|
return new Interpreter(program, opts).run();
|
|
10
11
|
}
|
|
@@ -29,10 +30,16 @@ class Interpreter {
|
|
|
29
30
|
this.maxDepth = opts.maxCallDepth ?? 200;
|
|
30
31
|
this.restrictions = new Restrictions(this.cwd);
|
|
31
32
|
this.builtins = makeBuiltins({ log: (m) => opts.logger.info(`log: ${m}`) });
|
|
33
|
+
for (const tool of Object.keys(TOOL_SPECS)) {
|
|
34
|
+
this.builtins.set(tool, { __fn: 'builtin', name: tool, call: (args) => this.callTool(tool, args) });
|
|
35
|
+
}
|
|
32
36
|
}
|
|
33
37
|
async run() {
|
|
34
38
|
const { logger } = this.opts;
|
|
35
39
|
try {
|
|
40
|
+
if (this.opts.backend.mode === 'replay' && this.opts.store) {
|
|
41
|
+
throw new Failure('restriction_violation', 'replay does not allow a persistent variable store');
|
|
42
|
+
}
|
|
36
43
|
for (const [k, v] of Object.entries(this.store.load()))
|
|
37
44
|
this.globals.vars.set(k, v);
|
|
38
45
|
const signal = await this.execBlock(this.program.body, this.globals);
|
|
@@ -53,11 +60,13 @@ class Interpreter {
|
|
|
53
60
|
}
|
|
54
61
|
finally {
|
|
55
62
|
try {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
63
|
+
if (this.opts.backend.mode !== 'replay') {
|
|
64
|
+
const snapshot = {};
|
|
65
|
+
for (const [k, v] of this.globals.vars)
|
|
66
|
+
if (!isFunction(v))
|
|
67
|
+
snapshot[k] = v;
|
|
68
|
+
this.store.save(snapshot);
|
|
69
|
+
}
|
|
61
70
|
}
|
|
62
71
|
catch (e) {
|
|
63
72
|
logger.error(`could not save variable store: ${e.message}`);
|
|
@@ -101,10 +110,13 @@ class Interpreter {
|
|
|
101
110
|
async execStmtInner(s, env) {
|
|
102
111
|
switch (s.t) {
|
|
103
112
|
case 'let':
|
|
104
|
-
|
|
113
|
+
env.vars.set(s.name, await this.eval(s.value, env));
|
|
114
|
+
return;
|
|
115
|
+
case 'assign':
|
|
116
|
+
await this.execAssign(s.target, s.op, s.value, env);
|
|
105
117
|
return;
|
|
106
118
|
case 'block':
|
|
107
|
-
return this.execBlock(s.body,
|
|
119
|
+
return this.execBlock(s.body, env);
|
|
108
120
|
case 'expr':
|
|
109
121
|
await this.eval(s.e, env);
|
|
110
122
|
return;
|
|
@@ -215,13 +227,14 @@ class Interpreter {
|
|
|
215
227
|
}
|
|
216
228
|
vars.forEach((v, i) => env.vars.set(v, item[i]));
|
|
217
229
|
}
|
|
218
|
-
async
|
|
230
|
+
async execAssign(target, op, valueExpr, env) {
|
|
219
231
|
const compute = async (current) => {
|
|
220
232
|
const rhs = await this.eval(valueExpr, env);
|
|
221
233
|
return op === '=' ? rhs : this.binary(op.slice(0, -1), current(), rhs);
|
|
222
234
|
};
|
|
223
235
|
if (target.t === 'name') {
|
|
224
|
-
|
|
236
|
+
if (!env.vars.has(target.name))
|
|
237
|
+
throw runtimeError(`cannot assign '${target.name}': declare a local with let first`);
|
|
225
238
|
env.vars.set(target.name, await compute(() => this.lookup(target.name, env)));
|
|
226
239
|
return;
|
|
227
240
|
}
|
|
@@ -319,21 +332,10 @@ class Interpreter {
|
|
|
319
332
|
const hi = e.hi ? await this.eval(e.hi, env) : undefined;
|
|
320
333
|
return this.slice(obj, lo, hi);
|
|
321
334
|
}
|
|
322
|
-
case 'ask':
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
return e.mods.
|
|
326
|
-
}
|
|
327
|
-
case 'think': {
|
|
328
|
-
const prompt = str(await this.eval(e.prompt, env));
|
|
329
|
-
return this.callLlm({
|
|
330
|
-
kind: 'think',
|
|
331
|
-
prompt,
|
|
332
|
-
effort: e.mods.level ?? 'medium',
|
|
333
|
-
show: e.mods.show ?? false,
|
|
334
|
-
});
|
|
335
|
-
}
|
|
336
|
-
case 'tool': return this.evalTool(e.tool, e.params, e.instructions, env);
|
|
335
|
+
case 'ask':
|
|
336
|
+
case 'think':
|
|
337
|
+
case 'agent':
|
|
338
|
+
return this.evalGeneration(e.t, e.mods, e.prompt, env);
|
|
337
339
|
}
|
|
338
340
|
}
|
|
339
341
|
getKey(d, k) {
|
|
@@ -484,29 +486,13 @@ class Interpreter {
|
|
|
484
486
|
return typeof obj === 'string' ? part.join('') : part;
|
|
485
487
|
}
|
|
486
488
|
// ---- LLM and tools -------------------------------------------------
|
|
487
|
-
async callLlm(
|
|
489
|
+
async callLlm(req) {
|
|
488
490
|
const { logger, backend } = this.opts;
|
|
489
|
-
|
|
490
|
-
kind: c.kind,
|
|
491
|
-
prompt: buildPrompt({
|
|
492
|
-
prompt: c.prompt,
|
|
493
|
-
mods: c.mods,
|
|
494
|
-
thinking: c.kind === 'think',
|
|
495
|
-
constraints: this.constraints,
|
|
496
|
-
restrictions: this.restrictions,
|
|
497
|
-
}),
|
|
498
|
-
model: this.model,
|
|
499
|
-
reasoningEffort: c.effort ?? (c.kind === 'tool' ? 'medium' : undefined),
|
|
500
|
-
showReasoning: c.show ?? false,
|
|
501
|
-
restrictions: this.restrictions,
|
|
502
|
-
tools: new ToolHost(this.restrictions, this.cwd),
|
|
503
|
-
pinned: c.pinned,
|
|
504
|
-
};
|
|
505
|
-
logger.llm(`llm request (${c.kind}):\n${req.prompt}`);
|
|
491
|
+
logger.llm(`llm request (${req.kind}):\n${req.prompt}`);
|
|
506
492
|
const started = Date.now();
|
|
507
493
|
try {
|
|
508
494
|
const res = await backend.call(req);
|
|
509
|
-
logger.info(`llm ${
|
|
495
|
+
logger.info(`llm ${req.kind} call: ${JSON.stringify(res.effective)} (${Date.now() - started} ms)`);
|
|
510
496
|
logger.llm(`llm response:\n${res.text}`);
|
|
511
497
|
return res.text.trim();
|
|
512
498
|
}
|
|
@@ -518,32 +504,76 @@ class Interpreter {
|
|
|
518
504
|
throw new Failure('llm_error', e.message ?? String(e));
|
|
519
505
|
}
|
|
520
506
|
}
|
|
521
|
-
async
|
|
507
|
+
async callTool(tool, args) {
|
|
508
|
+
const names = TOOL_SPECS[tool].required;
|
|
509
|
+
if (args.length !== names.length)
|
|
510
|
+
throw runtimeError(`${tool}() takes ${names.length} argument(s) but ${args.length} were given`);
|
|
522
511
|
const params = {};
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
512
|
+
names.forEach((name, i) => {
|
|
513
|
+
if (typeof args[i] !== 'string')
|
|
514
|
+
throw runtimeError(`${tool}(): ${name} must be a string`);
|
|
515
|
+
params[name] = args[i];
|
|
516
|
+
});
|
|
517
|
+
return new ToolHost(this.restrictions, this.cwd, this.opts.backend.mode === 'replay', true).invoke(tool, params);
|
|
518
|
+
}
|
|
519
|
+
async evalGeneration(kind, mods, promptExpr, env) {
|
|
520
|
+
if (kind === 'agent' && this.opts.backend.mode) {
|
|
521
|
+
throw new Failure('restriction_violation', 'agents cannot run in record/replay mode; record generation-only workflows');
|
|
522
|
+
}
|
|
523
|
+
const options = {};
|
|
524
|
+
for (const [name, expr] of Object.entries(mods))
|
|
525
|
+
setKey(options, name, await this.eval(expr, env));
|
|
526
|
+
const { len, reasoning = kind === 'ask' ? undefined : 'medium', show = false, repairs = 0, model } = options;
|
|
527
|
+
const fmt = options.fmt ?? ('schema' in options ? 'json' : 'text');
|
|
528
|
+
if (len !== undefined && len !== 'word' && len !== 'sentence' && len !== 'paragraph' && len !== 'long')
|
|
529
|
+
throw runtimeError('len must be word, sentence, paragraph, or long');
|
|
530
|
+
if (fmt !== 'text' && fmt !== 'json' && fmt !== 'object')
|
|
531
|
+
throw runtimeError('fmt must be text, json, or object');
|
|
532
|
+
if (reasoning !== undefined && reasoning !== 'low' && reasoning !== 'medium' && reasoning !== 'high')
|
|
533
|
+
throw runtimeError('reasoning must be low, medium, or high');
|
|
534
|
+
if (typeof show !== 'boolean')
|
|
535
|
+
throw runtimeError('show must be True or False');
|
|
536
|
+
if (model !== undefined && (typeof model !== 'string' || !model.trim()))
|
|
537
|
+
throw runtimeError('model must be a non-empty string');
|
|
538
|
+
if (typeof repairs !== 'number' || !Number.isInteger(repairs) || repairs < 0 || repairs > 5)
|
|
539
|
+
throw runtimeError('repairs must be an integer from 0 to 5');
|
|
540
|
+
if (kind === 'agent' && repairs !== 0)
|
|
541
|
+
throw runtimeError('agents cannot use repairs: external actions must not be retried automatically');
|
|
542
|
+
if (fmt === 'text' && ('schema' in options || repairs !== 0))
|
|
543
|
+
throw runtimeError('schema and repairs require JSON output');
|
|
544
|
+
const validate = 'schema' in options ? compileSchema(options.schema) : undefined;
|
|
545
|
+
let prompt = buildPrompt({
|
|
546
|
+
prompt: str(await this.eval(promptExpr, env)),
|
|
547
|
+
mods: { len, fmt: fmt === 'object' ? 'object' : undefined },
|
|
548
|
+
thinking: reasoning !== undefined,
|
|
549
|
+
constraints: this.constraints,
|
|
550
|
+
restrictions: this.restrictions,
|
|
551
|
+
});
|
|
552
|
+
if (fmt === 'json')
|
|
553
|
+
prompt += '\n\nReturn ONLY valid JSON, without prose or code fences.';
|
|
554
|
+
if (validate)
|
|
555
|
+
prompt += `\n\nThe result must satisfy this JSON Schema:\n${JSON.stringify(toJson(options.schema))}`;
|
|
556
|
+
const basePrompt = prompt;
|
|
557
|
+
for (let attempt = 0;; attempt++) {
|
|
558
|
+
const text = await this.callLlm({
|
|
559
|
+
kind, prompt,
|
|
560
|
+
model: this.opts.model ?? model ?? this.model,
|
|
561
|
+
reasoningEffort: reasoning,
|
|
562
|
+
showReasoning: show,
|
|
563
|
+
restrictions: this.restrictions,
|
|
564
|
+
tools: new ToolHost(this.restrictions, this.cwd, this.opts.backend.mode === 'replay'),
|
|
540
565
|
});
|
|
566
|
+
try {
|
|
567
|
+
const value = fmt === 'text' ? text : parseJsonResponse(text, fmt === 'object');
|
|
568
|
+
validate?.(value);
|
|
569
|
+
return value;
|
|
570
|
+
}
|
|
571
|
+
catch (e) {
|
|
572
|
+
if (!(e instanceof Failure) || (e.statusCode !== 'invalid_format' && e.statusCode !== 'validation_failed') || attempt >= repairs)
|
|
573
|
+
throw e;
|
|
574
|
+
this.opts.logger.info(`repair ${attempt + 1}/${repairs}: ${e.reason}`);
|
|
575
|
+
prompt = `${basePrompt}\n\nPrevious response:\n${text}\n\nValidation failed: ${e.reason}\nReturn a corrected JSON value.`;
|
|
576
|
+
}
|
|
541
577
|
}
|
|
542
|
-
const output = await host.invoke(tool, params);
|
|
543
|
-
this.opts.logger.debug(`tool ${tool} ${JSON.stringify(params)} -> ${output.length} chars`);
|
|
544
|
-
return this.callLlm({
|
|
545
|
-
kind: 'tool',
|
|
546
|
-
prompt: `The tool \`${tool}\` was run with parameters ${JSON.stringify(params)}. Its output is between the markers.\n<tool_output>\n${output}\n</tool_output>\n\nInstructions: ${instructions}`,
|
|
547
|
-
});
|
|
548
578
|
}
|
|
549
579
|
}
|
package/dist/llm.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AskMods
|
|
1
|
+
import type { AskMods } from './ast.js';
|
|
2
2
|
import type { Restrictions } from './restrict.js';
|
|
3
3
|
import type { ToolHost } from './tools.js';
|
|
4
4
|
import { type Value } from './values.js';
|
|
@@ -8,7 +8,7 @@ export interface Constraint {
|
|
|
8
8
|
text: string;
|
|
9
9
|
}
|
|
10
10
|
export interface LlmRequest {
|
|
11
|
-
kind: 'ask' | 'think' | '
|
|
11
|
+
kind: 'ask' | 'think' | 'agent';
|
|
12
12
|
/** The fully assembled query: constraints, restriction guidance and hints are already prepended. */
|
|
13
13
|
prompt: string;
|
|
14
14
|
model?: string;
|
|
@@ -16,11 +16,6 @@ export interface LlmRequest {
|
|
|
16
16
|
showReasoning: boolean;
|
|
17
17
|
restrictions: Restrictions;
|
|
18
18
|
tools: ToolHost;
|
|
19
|
-
/** For tools the script left parameters out of: the LLM calls the tool itself, these params are fixed by the script. */
|
|
20
|
-
pinned?: {
|
|
21
|
-
tool: ToolName;
|
|
22
|
-
params: Record<string, string>;
|
|
23
|
-
};
|
|
24
19
|
}
|
|
25
20
|
export interface LlmResponse {
|
|
26
21
|
text: string;
|
|
@@ -29,6 +24,7 @@ export interface LlmResponse {
|
|
|
29
24
|
effective: Record<string, unknown>;
|
|
30
25
|
}
|
|
31
26
|
export interface LlmBackend {
|
|
27
|
+
readonly mode?: 'record' | 'replay';
|
|
32
28
|
call(req: LlmRequest): Promise<LlmResponse>;
|
|
33
29
|
close(): Promise<void>;
|
|
34
30
|
}
|
|
@@ -42,5 +38,4 @@ export declare function buildPrompt(opts: {
|
|
|
42
38
|
constraints: readonly Constraint[];
|
|
43
39
|
restrictions: Restrictions;
|
|
44
40
|
}): string;
|
|
45
|
-
|
|
46
|
-
export declare function parseObjectResponse(text: string): Value;
|
|
41
|
+
export declare function parseJsonResponse(text: string, objectOnly?: boolean): Value;
|
package/dist/llm.js
CHANGED
|
@@ -9,12 +9,12 @@ const LEN_HINTS = {
|
|
|
9
9
|
paragraph: 'Answer in a single paragraph.',
|
|
10
10
|
long: 'Give a long, detailed answer.',
|
|
11
11
|
};
|
|
12
|
-
const
|
|
12
|
+
const JSON_HINT = 'Respond with ONLY valid JSON. No prose, no markdown, no code fences.';
|
|
13
13
|
export function buildPrompt(opts) {
|
|
14
14
|
const sections = [];
|
|
15
15
|
if (opts.constraints.length) {
|
|
16
|
-
sections.push('
|
|
17
|
-
opts.constraints.map((c) => `-
|
|
16
|
+
sections.push('Response guidance (not runtime validation):\n' +
|
|
17
|
+
opts.constraints.map((c) => `- ${c.kind === 'enforce' ? 'Follow' : 'Avoid'}: ${c.text}`).join('\n'));
|
|
18
18
|
}
|
|
19
19
|
const guidance = opts.restrictions.guidance();
|
|
20
20
|
if (guidance.length)
|
|
@@ -24,31 +24,22 @@ export function buildPrompt(opts) {
|
|
|
24
24
|
if (opts.mods?.len)
|
|
25
25
|
sections.push(LEN_HINTS[opts.mods.len]);
|
|
26
26
|
if (opts.mods?.fmt === 'object')
|
|
27
|
-
sections.push(
|
|
27
|
+
sections.push(`${JSON_HINT} Return an object or array.`);
|
|
28
28
|
sections.push(opts.prompt);
|
|
29
29
|
return sections.join('\n\n');
|
|
30
30
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (fenced)
|
|
36
|
-
candidates.push(fenced[1].trim());
|
|
37
|
-
const open = text.search(/[{[]/);
|
|
38
|
-
const close = Math.max(text.lastIndexOf('}'), text.lastIndexOf(']'));
|
|
39
|
-
if (open >= 0 && close > open)
|
|
40
|
-
candidates.push(text.slice(open, close + 1));
|
|
41
|
-
let lastError = 'empty response';
|
|
42
|
-
for (const c of candidates) {
|
|
43
|
-
try {
|
|
44
|
-
const v = fromJson(JSON.parse(c));
|
|
45
|
-
if (isDict(v) || isList(v))
|
|
46
|
-
return v;
|
|
47
|
-
lastError = 'response is JSON but not an object or array';
|
|
48
|
-
}
|
|
49
|
-
catch (e) {
|
|
50
|
-
lastError = e.message;
|
|
51
|
-
}
|
|
31
|
+
export function parseJsonResponse(text, objectOnly = false) {
|
|
32
|
+
let value;
|
|
33
|
+
try {
|
|
34
|
+
value = fromJson(JSON.parse(text));
|
|
52
35
|
}
|
|
53
|
-
|
|
36
|
+
catch (e) {
|
|
37
|
+
if (!(e instanceof SyntaxError))
|
|
38
|
+
throw e;
|
|
39
|
+
throw new Failure('invalid_format', `LLM response is not valid JSON: ${e.message}`);
|
|
40
|
+
}
|
|
41
|
+
if (objectOnly && !isDict(value) && !isList(value)) {
|
|
42
|
+
throw new Failure('invalid_format', 'LLM response must be a JSON object or array');
|
|
43
|
+
}
|
|
44
|
+
return value;
|
|
54
45
|
}
|
package/dist/parser.d.ts
CHANGED
|
@@ -1,13 +1,3 @@
|
|
|
1
|
-
import type { Program
|
|
2
|
-
export declare const
|
|
3
|
-
/** Fixed modifier schema per command; anything else is a syntax error. */
|
|
4
|
-
export declare const MODIFIER_SCHEMA: Record<'?' | 'think', Record<string, readonly (string | boolean)[]>>;
|
|
5
|
-
/**
|
|
6
|
-
* Tool parameter schema: the allowed names, and the `required` ones that let the runtime run the tool itself.
|
|
7
|
-
* Every parameter is optional in a script; the LLM supplies the missing required ones when it calls the tool.
|
|
8
|
-
*/
|
|
9
|
-
export declare const TOOL_SCHEMA: Record<ToolName, {
|
|
10
|
-
params: string[];
|
|
11
|
-
required: string[];
|
|
12
|
-
}>;
|
|
1
|
+
import type { Program } from './ast.js';
|
|
2
|
+
export declare const MODIFIER_NAMES: readonly ['len', 'fmt', 'reasoning', 'show', 'schema', 'repairs', 'model'];
|
|
13
3
|
export declare function parse(source: string): Program;
|
package/dist/parser.js
CHANGED
|
@@ -1,33 +1,11 @@
|
|
|
1
1
|
import { SplSyntaxError } from './errors.js';
|
|
2
2
|
import { tokenize } from './lexer.js';
|
|
3
|
-
export const TOOL_NAMES = ['read_file', 'write_file', 'web_fetch', 'web_search', 'shell'];
|
|
4
3
|
const KEYWORDS = new Set([
|
|
5
4
|
'let', 'if', 'elif', 'else', 'for', 'in', 'while', 'def', 'return', 'try', 'except', 'assert', 'fail',
|
|
6
|
-
'success', 'enforce', 'forbid', 'restrict', 'think', 'break', 'continue', 'pass', 'and', 'or', 'not',
|
|
7
|
-
'True', 'False', 'None',
|
|
5
|
+
'success', 'enforce', 'forbid', 'restrict', 'think', 'agent', 'break', 'continue', 'pass', 'and', 'or', 'not',
|
|
6
|
+
'True', 'False', 'None',
|
|
8
7
|
]);
|
|
9
|
-
|
|
10
|
-
export const MODIFIER_SCHEMA = {
|
|
11
|
-
'?': {
|
|
12
|
-
len: ['word', 'sentence', 'paragraph', 'long'],
|
|
13
|
-
fmt: ['text', 'object'],
|
|
14
|
-
},
|
|
15
|
-
think: {
|
|
16
|
-
level: ['low', 'medium', 'high'],
|
|
17
|
-
show: [true, false],
|
|
18
|
-
},
|
|
19
|
-
};
|
|
20
|
-
/**
|
|
21
|
-
* Tool parameter schema: the allowed names, and the `required` ones that let the runtime run the tool itself.
|
|
22
|
-
* Every parameter is optional in a script; the LLM supplies the missing required ones when it calls the tool.
|
|
23
|
-
*/
|
|
24
|
-
export const TOOL_SCHEMA = {
|
|
25
|
-
read_file: { params: ['path'], required: ['path'] },
|
|
26
|
-
write_file: { params: ['path', 'content'], required: ['path', 'content'] },
|
|
27
|
-
web_fetch: { params: ['url'], required: ['url'] },
|
|
28
|
-
web_search: { params: ['query'], required: ['query'] },
|
|
29
|
-
shell: { params: ['command'], required: ['command'] },
|
|
30
|
-
};
|
|
8
|
+
export const MODIFIER_NAMES = ['len', 'fmt', 'reasoning', 'show', 'schema', 'repairs', 'model'];
|
|
31
9
|
const DIRECTIVE_SCHEMA = { model: 'string' };
|
|
32
10
|
const ASSIGN_OPS = new Set(['=', '+=', '-=', '*=', '/=', '//=', '%=']);
|
|
33
11
|
export function parse(source) {
|
|
@@ -272,6 +250,9 @@ class Parser {
|
|
|
272
250
|
return { t: 'constraint', line, kind, text, body: this.parseBlock() };
|
|
273
251
|
}
|
|
274
252
|
case 'restrict': return this.parseRestrict();
|
|
253
|
+
case 'instruct':
|
|
254
|
+
case 'avoid':
|
|
255
|
+
this.fail(`use '${t.value === 'instruct' ? 'enforce' : 'forbid'}' instead of '${t.value}' for scoped prompt guidance`);
|
|
275
256
|
default:
|
|
276
257
|
break;
|
|
277
258
|
}
|
|
@@ -283,18 +264,9 @@ class Parser {
|
|
|
283
264
|
parseLet() {
|
|
284
265
|
const line = this.tok.line;
|
|
285
266
|
this.next();
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
}
|
|
290
|
-
if (startTok.type !== 'name')
|
|
291
|
-
this.fail(`expected a variable name but found ${this.describe(startTok)}`);
|
|
292
|
-
const target = this.toTarget(this.parsePostfix(), startTok);
|
|
293
|
-
const opTok = this.tok;
|
|
294
|
-
if (opTok.type !== 'op' || !ASSIGN_OPS.has(opTok.value))
|
|
295
|
-
this.fail(`expected '=' after the target of 'let' but found ${this.describe(opTok)}`);
|
|
296
|
-
this.next();
|
|
297
|
-
return { t: 'let', line, target, op: opTok.value, value: this.parseExpr() };
|
|
267
|
+
const name = this.expectIdent('a variable name');
|
|
268
|
+
this.expectOp('=');
|
|
269
|
+
return { t: 'let', line, name, value: this.parseExpr() };
|
|
298
270
|
}
|
|
299
271
|
parseIf() {
|
|
300
272
|
const line = this.tok.line;
|
|
@@ -372,11 +344,14 @@ class Parser {
|
|
|
372
344
|
return { t: 'restrict', line, clauses, body: this.parseBlock() };
|
|
373
345
|
}
|
|
374
346
|
parseSimpleStatement() {
|
|
375
|
-
const
|
|
347
|
+
const start = this.tok;
|
|
348
|
+
const line = start.line;
|
|
376
349
|
const e = this.parseExpr();
|
|
377
350
|
const t = this.tok;
|
|
378
351
|
if (t.type === 'op' && ASSIGN_OPS.has(t.value)) {
|
|
379
|
-
|
|
352
|
+
const target = this.toTarget(e, start);
|
|
353
|
+
this.next();
|
|
354
|
+
return { t: 'assign', line, target, op: t.value, value: this.parseExpr() };
|
|
380
355
|
}
|
|
381
356
|
return { t: 'expr', line, e };
|
|
382
357
|
}
|
|
@@ -604,17 +579,12 @@ class Parser {
|
|
|
604
579
|
case 'None':
|
|
605
580
|
this.next();
|
|
606
581
|
return { t: 'none' };
|
|
607
|
-
case 'think':
|
|
582
|
+
case 'think':
|
|
583
|
+
case 'agent': {
|
|
608
584
|
this.next();
|
|
609
|
-
const mods = this.isOp('{') ? this.parseMods(
|
|
610
|
-
return { t:
|
|
585
|
+
const mods = this.isOp('{') ? this.parseMods(t.value) : {};
|
|
586
|
+
return { t: t.value, mods, prompt: this.parsePrompt(t.value) };
|
|
611
587
|
}
|
|
612
|
-
case 'read_file':
|
|
613
|
-
case 'write_file':
|
|
614
|
-
case 'web_fetch':
|
|
615
|
-
case 'web_search':
|
|
616
|
-
case 'shell':
|
|
617
|
-
return this.parseToolCall(t.value);
|
|
618
588
|
default:
|
|
619
589
|
if (KEYWORDS.has(t.value))
|
|
620
590
|
this.fail(`unexpected keyword '${t.value}'`);
|
|
@@ -628,7 +598,6 @@ class Parser {
|
|
|
628
598
|
return this.parsePostfix();
|
|
629
599
|
}
|
|
630
600
|
parseMods(cmd) {
|
|
631
|
-
const schema = MODIFIER_SCHEMA[cmd];
|
|
632
601
|
this.expectOp('{');
|
|
633
602
|
this.skipNl();
|
|
634
603
|
const out = {};
|
|
@@ -638,25 +607,13 @@ class Parser {
|
|
|
638
607
|
this.fail('expected a modifier name');
|
|
639
608
|
const name = nameTok.value;
|
|
640
609
|
this.next();
|
|
641
|
-
if (!(
|
|
642
|
-
this.fail(`unknown modifier '${name}' for '${cmd}' (allowed: ${
|
|
610
|
+
if (!MODIFIER_NAMES.some((allowed) => allowed === name)) {
|
|
611
|
+
this.fail(`unknown modifier '${name}' for '${cmd}' (allowed: ${MODIFIER_NAMES.join(', ')})`, nameTok);
|
|
643
612
|
}
|
|
644
613
|
if (name in out)
|
|
645
614
|
this.fail(`duplicate modifier '${name}'`, nameTok);
|
|
646
615
|
this.expectOp('=');
|
|
647
|
-
|
|
648
|
-
let value;
|
|
649
|
-
if (valTok.type === 'str')
|
|
650
|
-
value = this.parseLiteralString(`modifier '${name}'`);
|
|
651
|
-
else if (this.isName('True') || this.isName('False'))
|
|
652
|
-
value = this.next().value === 'True';
|
|
653
|
-
else
|
|
654
|
-
return this.fail(`modifier '${name}' requires a quoted string or True/False`);
|
|
655
|
-
if (!schema[name].includes(value)) {
|
|
656
|
-
const allowed = schema[name].map((v) => (typeof v === 'string' ? `'${v}'` : v ? 'True' : 'False'));
|
|
657
|
-
this.fail(`invalid value ${typeof value === 'string' ? `'${value}'` : value ? 'True' : 'False'} for modifier '${name}' (allowed: ${allowed.join(', ')})`, valTok);
|
|
658
|
-
}
|
|
659
|
-
out[name] = value;
|
|
616
|
+
out[name] = this.parseExpr();
|
|
660
617
|
this.skipNl();
|
|
661
618
|
if (this.isOp(',')) {
|
|
662
619
|
this.next();
|
|
@@ -669,39 +626,6 @@ class Parser {
|
|
|
669
626
|
this.next();
|
|
670
627
|
return out;
|
|
671
628
|
}
|
|
672
|
-
parseToolCall(tool) {
|
|
673
|
-
this.next();
|
|
674
|
-
const schema = TOOL_SCHEMA[tool];
|
|
675
|
-
const params = {};
|
|
676
|
-
if (this.isOp('{')) {
|
|
677
|
-
this.next();
|
|
678
|
-
this.skipNl();
|
|
679
|
-
while (!this.isOp('}')) {
|
|
680
|
-
const nameTok = this.tok;
|
|
681
|
-
if (nameTok.type !== 'name')
|
|
682
|
-
this.fail('expected a parameter name');
|
|
683
|
-
const name = nameTok.value;
|
|
684
|
-
this.next();
|
|
685
|
-
if (!schema.params.includes(name)) {
|
|
686
|
-
this.fail(`unknown parameter '${name}' for '${tool}' (allowed: ${schema.params.join(', ')})`, nameTok);
|
|
687
|
-
}
|
|
688
|
-
if (name in params)
|
|
689
|
-
this.fail(`duplicate parameter '${name}'`, nameTok);
|
|
690
|
-
this.expectOp('=');
|
|
691
|
-
params[name] = this.parseExpr();
|
|
692
|
-
this.skipNl();
|
|
693
|
-
if (this.isOp(',')) {
|
|
694
|
-
this.next();
|
|
695
|
-
this.skipNl();
|
|
696
|
-
}
|
|
697
|
-
else if (!this.isOp('}')) {
|
|
698
|
-
this.fail("expected ',' or '}' in tool parameters");
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
this.next();
|
|
702
|
-
}
|
|
703
|
-
return { t: 'tool', tool, params, instructions: this.parsePrompt(tool) };
|
|
704
|
-
}
|
|
705
629
|
convertParts(parts) {
|
|
706
630
|
return parts.map((p) => {
|
|
707
631
|
if ('text' in p)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type LlmBackend, type LlmRequest, type LlmResponse } from './llm.js';
|
|
2
|
+
export declare class RecordingBackend implements LlmBackend {
|
|
3
|
+
private readonly backend;
|
|
4
|
+
private readonly file;
|
|
5
|
+
readonly mode = "record";
|
|
6
|
+
constructor(backend: LlmBackend, file: string);
|
|
7
|
+
call(req: LlmRequest): Promise<LlmResponse>;
|
|
8
|
+
close(): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
export declare class ReplayBackend implements LlmBackend {
|
|
11
|
+
readonly mode = "replay";
|
|
12
|
+
private readonly entries;
|
|
13
|
+
private position;
|
|
14
|
+
constructor(file: string);
|
|
15
|
+
call(req: LlmRequest): Promise<LlmResponse>;
|
|
16
|
+
close(): Promise<void>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import { LlmError } from './llm.js';
|
|
3
|
+
function requestKey(req) {
|
|
4
|
+
return JSON.stringify({
|
|
5
|
+
kind: req.kind, prompt: req.prompt, model: req.model ?? null,
|
|
6
|
+
reasoningEffort: req.reasoningEffort ?? null, showReasoning: req.showReasoning,
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
function isRecord(value) {
|
|
10
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
11
|
+
}
|
|
12
|
+
function parseLine(line, number) {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(line);
|
|
15
|
+
}
|
|
16
|
+
catch (e) {
|
|
17
|
+
if (!(e instanceof SyntaxError))
|
|
18
|
+
throw e;
|
|
19
|
+
throw new LlmError(`invalid recording JSON at line ${number}: ${e.message}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export class RecordingBackend {
|
|
23
|
+
backend;
|
|
24
|
+
file;
|
|
25
|
+
mode = 'record';
|
|
26
|
+
constructor(backend, file) {
|
|
27
|
+
this.backend = backend;
|
|
28
|
+
this.file = file;
|
|
29
|
+
fs.writeFileSync(file, JSON.stringify({ format: 'spl-generation', version: 1 }) + '\n', { flag: 'wx', mode: 0o600 });
|
|
30
|
+
}
|
|
31
|
+
async call(req) {
|
|
32
|
+
if (req.kind === 'agent')
|
|
33
|
+
throw new LlmError('agent actions cannot be recorded; record generation-only workflows');
|
|
34
|
+
const request = requestKey(req);
|
|
35
|
+
const response = await this.backend.call(req);
|
|
36
|
+
try {
|
|
37
|
+
fs.appendFileSync(this.file, JSON.stringify({ request, response }) + '\n');
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
if (!(e instanceof Error))
|
|
41
|
+
throw e;
|
|
42
|
+
throw new LlmError(`could not save recording: ${e.message}`);
|
|
43
|
+
}
|
|
44
|
+
return response;
|
|
45
|
+
}
|
|
46
|
+
close() {
|
|
47
|
+
return this.backend.close();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export class ReplayBackend {
|
|
51
|
+
mode = 'replay';
|
|
52
|
+
entries;
|
|
53
|
+
position = 0;
|
|
54
|
+
constructor(file) {
|
|
55
|
+
const lines = fs.readFileSync(file, 'utf8').trimEnd().split(/\r?\n/);
|
|
56
|
+
const header = parseLine(lines.shift() ?? '', 1);
|
|
57
|
+
if (!isRecord(header) || header.format !== 'spl-generation' || header.version !== 1) {
|
|
58
|
+
throw new LlmError('unsupported recording format/version');
|
|
59
|
+
}
|
|
60
|
+
this.entries = lines.map((line, i) => {
|
|
61
|
+
const entry = parseLine(line, i + 2);
|
|
62
|
+
if (!isRecord(entry) || typeof entry.request !== 'string' || !isRecord(entry.response)
|
|
63
|
+
|| typeof entry.response.text !== 'string' || !isRecord(entry.response.effective)
|
|
64
|
+
|| (entry.response.reasoning !== undefined && typeof entry.response.reasoning !== 'string')) {
|
|
65
|
+
throw new LlmError(`invalid recording entry at line ${i + 2}`);
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
request: entry.request,
|
|
69
|
+
response: {
|
|
70
|
+
text: entry.response.text,
|
|
71
|
+
effective: entry.response.effective,
|
|
72
|
+
reasoning: entry.response.reasoning,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
async call(req) {
|
|
78
|
+
if (req.kind === 'agent')
|
|
79
|
+
throw new LlmError('agent actions cannot be replayed');
|
|
80
|
+
const entry = this.entries[this.position];
|
|
81
|
+
if (!entry)
|
|
82
|
+
throw new LlmError(`recording exhausted at call ${this.position + 1}`);
|
|
83
|
+
if (entry.request !== requestKey(req))
|
|
84
|
+
throw new LlmError(`replay request mismatch at call ${this.position + 1}`);
|
|
85
|
+
this.position++;
|
|
86
|
+
return structuredClone(entry.response);
|
|
87
|
+
}
|
|
88
|
+
async close() {
|
|
89
|
+
if (this.position !== this.entries.length) {
|
|
90
|
+
throw new LlmError(`replay ended with ${this.entries.length - this.position} unused recorded response(s)`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
package/dist/schema.d.ts
ADDED