spl-lang 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/dist/ast.d.ts +3 -0
- package/dist/builtins.js +6 -1
- package/dist/cli.js +26 -14
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/interpreter.d.ts +2 -0
- package/dist/interpreter.js +19 -5
- package/dist/lexer.js +4 -2
- package/dist/llm.d.ts +1 -1
- package/dist/parser.js +9 -1
- package/dist/recording.d.ts +9 -1
- package/dist/recording.js +90 -18
- package/dist/values.d.ts +1 -0
- package/dist/values.js +5 -0
- package/docs/syntax.ebnf +8 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -42,6 +42,10 @@ under `~/.spl` (override with `SPL_HOME`); `spl logout` removes those saved cred
|
|
|
42
42
|
`{}` remains an empty dict. Functions still require explicit `return`.
|
|
43
43
|
- **Logging:** `log(...)` defaults to info. `log{level=debug}(...)` selects error/info/debug;
|
|
44
44
|
bare level names, strings and expressions are accepted and checked at runtime.
|
|
45
|
+
- **Runtime inputs:** `$env.NAME` / `$env['NAME']` reads process environment strings;
|
|
46
|
+
`$args[i]` reads repeated CLI `--arg` strings in order (empty list by default).
|
|
47
|
+
Missing environment names and out-of-range argument indexes return `None`.
|
|
48
|
+
Both are read-only snapshots, including through aliases, and are not automatically persisted.
|
|
45
49
|
|
|
46
50
|
Common generation modifiers are `len`, `fmt`, `reasoning`, `show`, `schema`, `repairs`,
|
|
47
51
|
and `model`. Values are evaluated once, then validated before calling the model.
|
|
@@ -67,6 +71,7 @@ spl script.spl [--model id] [--mcp file.json] [--cwd dir] [--timeout seconds]
|
|
|
67
71
|
[--store file.json | --store-module module]
|
|
68
72
|
[--record file.jsonl | --replay file.jsonl]
|
|
69
73
|
[--log-level error|info|debug] [--silent] [--check]
|
|
74
|
+
[--arg value ...]
|
|
70
75
|
```
|
|
71
76
|
|
|
72
77
|
Results are printed on stdout, logs on stderr. Full prompts/responses and statement traces
|
|
@@ -87,6 +92,8 @@ The package exports `parse`, `run`, `Logger`, `CopilotBackend`, `RecordingBacken
|
|
|
87
92
|
`ReplayBackend`, variable stores, authentication helpers, and related types.
|
|
88
93
|
Call `close()` on the backend after running; replay also checks for unused entries there.
|
|
89
94
|
Custom backends must honor the request kind: only `agent` may execute tools.
|
|
95
|
+
Embedding callers can pass `args: ['first', 'second']` in `RunOptions`; omitted
|
|
96
|
+
arguments default to `[]`, not the host's `process.argv`. Each run snapshots `process.env`.
|
|
90
97
|
See [embedding](../docs/internals.html#embedding).
|
|
91
98
|
|
|
92
99
|
## Migration
|
package/dist/ast.d.ts
CHANGED
package/dist/builtins.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { compareValues, equals, fromJson, hasKey, isDict, isList, newDict, runtimeError, setKey, str, toJson, truthy, typeName, } from './values.js';
|
|
1
|
+
import { assertMutable, compareValues, equals, fromJson, hasKey, isDict, isList, newDict, runtimeError, setKey, str, toJson, truthy, typeName, } from './values.js';
|
|
2
2
|
function arity(name, args, min, max = min) {
|
|
3
3
|
if (args.length < min || args.length > max) {
|
|
4
4
|
const expected = min === max ? `${min}` : `${min}-${max}`;
|
|
@@ -346,5 +346,10 @@ export function callMethod(self, name, args) {
|
|
|
346
346
|
if (!table || !Object.prototype.hasOwnProperty.call(table, name)) {
|
|
347
347
|
throw runtimeError(`${typeName(self)} has no method '${name}'`);
|
|
348
348
|
}
|
|
349
|
+
const mutating = isList(self)
|
|
350
|
+
? ['append', 'extend', 'insert', 'pop', 'remove', 'reverse', 'sort', 'clear']
|
|
351
|
+
: isDict(self) ? ['pop', 'update', 'clear'] : [];
|
|
352
|
+
if (mutating.includes(name))
|
|
353
|
+
assertMutable(self);
|
|
349
354
|
return table[name](self, args);
|
|
350
355
|
}
|
package/dist/cli.js
CHANGED
|
@@ -12,7 +12,7 @@ import { run } from './interpreter.js';
|
|
|
12
12
|
import { JsonFileStore, MemoryStore, loadStoreModule } from './store.js';
|
|
13
13
|
import { toJson } from './values.js';
|
|
14
14
|
import { loadMcpConfig } from './mcp.js';
|
|
15
|
-
import { RecordingBackend, ReplayBackend } from './recording.js';
|
|
15
|
+
import { RecordingBackend, ReplayBackend, TraceBackend } from './recording.js';
|
|
16
16
|
const HELP = `spl - Semantic Prompt Language interpreter
|
|
17
17
|
|
|
18
18
|
Usage: spl <script.spl> [options]
|
|
@@ -24,12 +24,15 @@ Options:
|
|
|
24
24
|
All levels include LLM transcripts and [deterministic] statement traces.
|
|
25
25
|
--silent suppress all logs; only the result is printed
|
|
26
26
|
--model <id> model override (takes precedence over @{model=...})
|
|
27
|
+
--arg <value> script argument exposed in $args (repeatable; also --arg=value)
|
|
27
28
|
--mcp <file.json> MCP servers in VS Code mcp.json format
|
|
28
29
|
--store <file.json> persist top-level variables in a JSON file
|
|
29
30
|
--store-module <path> custom variable store: module exporting createStore() -> { load(), save(vars) }
|
|
30
31
|
--timeout <seconds> per LLM call timeout (default: 300)
|
|
32
|
+
--trace <file.jsonl> trace all LLM requests, responses, and failures (new file)
|
|
31
33
|
--record <file.jsonl> record generation responses (new file; no agents)
|
|
32
|
-
--replay <file.jsonl>
|
|
34
|
+
--replay <file.jsonl> replay LLM results without Copilot/MCP; explicit script tools still run
|
|
35
|
+
Legacy --record files allow only local reads and no stores.
|
|
33
36
|
--cwd <dir> base directory for relative paths and restrictions (default: current directory)
|
|
34
37
|
--check validate script syntax and any --mcp config without executing
|
|
35
38
|
-v, --version print version
|
|
@@ -79,10 +82,12 @@ async function main() {
|
|
|
79
82
|
'log-level': { type: 'string' },
|
|
80
83
|
silent: { type: 'boolean' },
|
|
81
84
|
model: { type: 'string' },
|
|
85
|
+
arg: { type: 'string', multiple: true },
|
|
82
86
|
mcp: { type: 'string' },
|
|
83
87
|
store: { type: 'string' },
|
|
84
88
|
'store-module': { type: 'string' },
|
|
85
89
|
timeout: { type: 'string' },
|
|
90
|
+
trace: { type: 'string' },
|
|
86
91
|
record: { type: 'string' },
|
|
87
92
|
replay: { type: 'string' },
|
|
88
93
|
cwd: { type: 'string' },
|
|
@@ -120,10 +125,12 @@ async function main() {
|
|
|
120
125
|
usageError('--timeout must be a positive number');
|
|
121
126
|
if (values.store && values['store-module'])
|
|
122
127
|
usageError('--store and --store-module are mutually exclusive');
|
|
123
|
-
if (values.record
|
|
124
|
-
usageError('--record and --replay are mutually exclusive');
|
|
125
|
-
|
|
126
|
-
|
|
128
|
+
if ([values.trace, values.record, values.replay].filter((value) => value !== undefined).length > 1) {
|
|
129
|
+
usageError('--trace, --record, and --replay are mutually exclusive');
|
|
130
|
+
}
|
|
131
|
+
if ([values.trace, values.record, values.replay].some((value) => value !== undefined && value.trim() === '')) {
|
|
132
|
+
usageError('--trace, --record, and --replay require a non-empty file path');
|
|
133
|
+
}
|
|
127
134
|
const cwd = path.resolve(values.cwd ?? process.cwd());
|
|
128
135
|
const file = positionals[0];
|
|
129
136
|
let source;
|
|
@@ -153,13 +160,6 @@ async function main() {
|
|
|
153
160
|
}
|
|
154
161
|
if (values.check)
|
|
155
162
|
return 0;
|
|
156
|
-
let store;
|
|
157
|
-
try {
|
|
158
|
-
store = values.store ? new JsonFileStore(values.store) : values['store-module'] ? await loadStoreModule(values['store-module']) : new MemoryStore();
|
|
159
|
-
}
|
|
160
|
-
catch (e) {
|
|
161
|
-
return usageError(e.message);
|
|
162
|
-
}
|
|
163
163
|
let backend;
|
|
164
164
|
try {
|
|
165
165
|
if (values.replay)
|
|
@@ -168,6 +168,8 @@ async function main() {
|
|
|
168
168
|
backend = new CopilotBackend({ logger, cwd, timeoutMs: timeoutSec * 1000, mcpServers });
|
|
169
169
|
if (values.record)
|
|
170
170
|
backend = new RecordingBackend(backend, values.record);
|
|
171
|
+
if (values.trace)
|
|
172
|
+
backend = new TraceBackend(backend, values.trace);
|
|
171
173
|
}
|
|
172
174
|
}
|
|
173
175
|
catch (e) {
|
|
@@ -175,9 +177,19 @@ async function main() {
|
|
|
175
177
|
throw e;
|
|
176
178
|
return usageError(`cannot open recording: ${e.message}`);
|
|
177
179
|
}
|
|
180
|
+
if (backend.mode === 'replay' && (values.store || values['store-module'])) {
|
|
181
|
+
return usageError('--replay of legacy --record files cannot use a persistent variable store');
|
|
182
|
+
}
|
|
183
|
+
let store;
|
|
184
|
+
try {
|
|
185
|
+
store = values.store ? new JsonFileStore(values.store) : values['store-module'] ? await loadStoreModule(values['store-module']) : new MemoryStore();
|
|
186
|
+
}
|
|
187
|
+
catch (e) {
|
|
188
|
+
return usageError(e.message);
|
|
189
|
+
}
|
|
178
190
|
let result;
|
|
179
191
|
try {
|
|
180
|
-
result = await run(program, { backend, logger, store:
|
|
192
|
+
result = await run(program, { backend, logger, store: backend.mode === 'replay' ? undefined : store, cwd, model: values.model, args: values.arg });
|
|
181
193
|
}
|
|
182
194
|
finally {
|
|
183
195
|
try {
|
package/dist/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export { run, type RunOptions, type RunResult } from './interpreter.js';
|
|
|
3
3
|
export { Failure, SplSyntaxError, type FailurePayload, type StatusCode } from './errors.js';
|
|
4
4
|
export { Logger, type LogLevel } from './logger.js';
|
|
5
5
|
export { CopilotBackend } from './copilot.js';
|
|
6
|
-
export { RecordingBackend, ReplayBackend } from './recording.js';
|
|
6
|
+
export { RecordingBackend, ReplayBackend, TraceBackend } from './recording.js';
|
|
7
7
|
export { loadMcpConfig, type McpServers } from './mcp.js';
|
|
8
8
|
export { login, logout, authStatus, AuthError } from './auth.js';
|
|
9
9
|
export { JsonFileStore, MemoryStore, loadStoreModule, type VariableStore } from './store.js';
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ export { run } from './interpreter.js';
|
|
|
3
3
|
export { Failure, SplSyntaxError } from './errors.js';
|
|
4
4
|
export { Logger } from './logger.js';
|
|
5
5
|
export { CopilotBackend } from './copilot.js';
|
|
6
|
-
export { RecordingBackend, ReplayBackend } from './recording.js';
|
|
6
|
+
export { RecordingBackend, ReplayBackend, TraceBackend } from './recording.js';
|
|
7
7
|
export { loadMcpConfig } from './mcp.js';
|
|
8
8
|
export { login, logout, authStatus, AuthError } from './auth.js';
|
|
9
9
|
export { JsonFileStore, MemoryStore, loadStoreModule } from './store.js';
|
package/dist/interpreter.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ export interface RunOptions {
|
|
|
12
12
|
cwd?: string;
|
|
13
13
|
/** Overrides the `@{model=...}` directive. */
|
|
14
14
|
model?: string;
|
|
15
|
+
/** Script arguments exposed as read-only `$args`; defaults to an empty list. */
|
|
16
|
+
args?: readonly string[];
|
|
15
17
|
maxCallDepth?: number;
|
|
16
18
|
}
|
|
17
19
|
export type RunResult = {
|
package/dist/interpreter.js
CHANGED
|
@@ -5,7 +5,7 @@ import { compileSchema } from './schema.js';
|
|
|
5
5
|
import { Restrictions, makeRestriction } from './restrict.js';
|
|
6
6
|
import { MemoryStore } from './store.js';
|
|
7
7
|
import { ToolHost, TOOL_SPECS } from './tools.js';
|
|
8
|
-
import { compareValues, equals, hasKey, isDict, isFunction, isList, newDict, runtimeError, setKey, str, toJson, truthy, typeName, } from './values.js';
|
|
8
|
+
import { assertMutable, compareValues, equals, hasKey, isDict, isFunction, isList, newDict, runtimeError, setKey, str, toJson, truthy, typeName, } from './values.js';
|
|
9
9
|
class ExpressionSignal {
|
|
10
10
|
signal;
|
|
11
11
|
constructor(signal) {
|
|
@@ -24,6 +24,8 @@ class Interpreter {
|
|
|
24
24
|
cwd;
|
|
25
25
|
model;
|
|
26
26
|
maxDepth;
|
|
27
|
+
runtimeEnv = newDict();
|
|
28
|
+
runtimeArgs;
|
|
27
29
|
constraints = [];
|
|
28
30
|
restrictions;
|
|
29
31
|
depth = 0;
|
|
@@ -34,6 +36,13 @@ class Interpreter {
|
|
|
34
36
|
this.store = opts.store ?? new MemoryStore();
|
|
35
37
|
this.model = opts.model ?? program.directives.model;
|
|
36
38
|
this.maxDepth = opts.maxCallDepth ?? 200;
|
|
39
|
+
for (const [name, value] of Object.entries(process.env)) {
|
|
40
|
+
if (value !== undefined)
|
|
41
|
+
setKey(this.runtimeEnv, name, value);
|
|
42
|
+
}
|
|
43
|
+
this.runtimeArgs = [...(opts.args ?? [])];
|
|
44
|
+
Object.freeze(this.runtimeEnv);
|
|
45
|
+
Object.freeze(this.runtimeArgs);
|
|
37
46
|
this.restrictions = new Restrictions(this.cwd);
|
|
38
47
|
this.builtins = makeBuiltins({ log: (m) => opts.logger.info(`log: ${m}`) });
|
|
39
48
|
for (const tool of Object.keys(TOOL_SPECS)) {
|
|
@@ -263,6 +272,7 @@ class Interpreter {
|
|
|
263
272
|
return;
|
|
264
273
|
}
|
|
265
274
|
const obj = await this.eval(target.obj, env);
|
|
275
|
+
assertMutable(obj);
|
|
266
276
|
if (target.t === 'member') {
|
|
267
277
|
if (!isDict(obj))
|
|
268
278
|
throw runtimeError(`cannot set member '${target.name}' on ${typeName(obj)}`);
|
|
@@ -314,6 +324,7 @@ class Interpreter {
|
|
|
314
324
|
return null;
|
|
315
325
|
}
|
|
316
326
|
case 'name': return this.lookup(e.name, env);
|
|
327
|
+
case 'special': return e.name === 'env' ? this.runtimeEnv : this.runtimeArgs;
|
|
317
328
|
case 'list': {
|
|
318
329
|
const out = [];
|
|
319
330
|
for (const it of e.items)
|
|
@@ -379,8 +390,11 @@ class Interpreter {
|
|
|
379
390
|
}
|
|
380
391
|
}
|
|
381
392
|
getKey(d, k) {
|
|
382
|
-
if (!hasKey(d, k))
|
|
393
|
+
if (!hasKey(d, k)) {
|
|
394
|
+
if (d === this.runtimeEnv)
|
|
395
|
+
return null;
|
|
383
396
|
throw runtimeError(`key '${k}' not found`);
|
|
397
|
+
}
|
|
384
398
|
return d[k];
|
|
385
399
|
}
|
|
386
400
|
async evalCall(callee, argExprs, env) {
|
|
@@ -493,13 +507,13 @@ class Interpreter {
|
|
|
493
507
|
if (typeof index !== 'number' || !Number.isInteger(index))
|
|
494
508
|
throw runtimeError(`indices must be integers, got ${typeName(index)}`);
|
|
495
509
|
const i = index < 0 ? list.length + index : index;
|
|
496
|
-
if (i < 0 || i >= list.length)
|
|
510
|
+
if ((i < 0 || i >= list.length) && list !== this.runtimeArgs)
|
|
497
511
|
throw runtimeError(`index ${index} out of range`);
|
|
498
512
|
return i;
|
|
499
513
|
}
|
|
500
514
|
index(obj, index) {
|
|
501
515
|
if (isList(obj))
|
|
502
|
-
return obj[this.listIndex(obj, index)];
|
|
516
|
+
return obj[this.listIndex(obj, index)] ?? null;
|
|
503
517
|
if (typeof obj === 'string') {
|
|
504
518
|
const chars = Array.from(obj);
|
|
505
519
|
return chars[this.listIndex(chars, index)];
|
|
@@ -557,7 +571,7 @@ class Interpreter {
|
|
|
557
571
|
return new ToolHost(this.restrictions, this.cwd, this.opts.backend.mode === 'replay', true).invoke(tool, params);
|
|
558
572
|
}
|
|
559
573
|
async evalGeneration(kind, mods, promptExpr, env) {
|
|
560
|
-
if (kind === 'agent' && this.opts.backend.mode) {
|
|
574
|
+
if (kind === 'agent' && (this.opts.backend.mode === 'record' || this.opts.backend.mode === 'replay')) {
|
|
561
575
|
throw new Failure('restriction_violation', 'agents cannot run in record/replay mode; record generation-only workflows');
|
|
562
576
|
}
|
|
563
577
|
const options = {};
|
package/dist/lexer.js
CHANGED
|
@@ -71,8 +71,10 @@ export function tokenize(src, opts = {}) {
|
|
|
71
71
|
tokens.push(readString(raw));
|
|
72
72
|
continue;
|
|
73
73
|
}
|
|
74
|
-
if (isNameStart(c)) {
|
|
75
|
-
|
|
74
|
+
if (isNameStart(c) || c === '$') {
|
|
75
|
+
if (c === '$' && (i + 1 >= src.length || !isNameStart(src[i + 1])))
|
|
76
|
+
err("unexpected character '$'");
|
|
77
|
+
let j = i + (c === '$' ? 1 : 0);
|
|
76
78
|
while (j < src.length && isNameChar(src[j]))
|
|
77
79
|
j++;
|
|
78
80
|
const value = src.slice(i, j);
|
package/dist/llm.d.ts
CHANGED
|
@@ -24,7 +24,7 @@ export interface LlmResponse {
|
|
|
24
24
|
effective: Record<string, unknown>;
|
|
25
25
|
}
|
|
26
26
|
export interface LlmBackend {
|
|
27
|
-
readonly mode?: 'record' | 'replay';
|
|
27
|
+
readonly mode?: 'record' | 'replay' | 'trace' | 'workflow-replay';
|
|
28
28
|
call(req: LlmRequest): Promise<LlmResponse>;
|
|
29
29
|
close(): Promise<void>;
|
|
30
30
|
}
|
package/dist/parser.js
CHANGED
|
@@ -69,6 +69,8 @@ class Parser {
|
|
|
69
69
|
if (t.type !== 'name')
|
|
70
70
|
this.fail(`expected ${what} but found ${this.describe(t)}`);
|
|
71
71
|
const name = t.value;
|
|
72
|
+
if (name.startsWith('$'))
|
|
73
|
+
this.fail(`special variable '${name}' cannot be used as ${what}`);
|
|
72
74
|
if (KEYWORDS.has(name))
|
|
73
75
|
this.fail(`'${name}' is a reserved word and cannot be used as ${what}`);
|
|
74
76
|
this.next();
|
|
@@ -476,7 +478,7 @@ class Parser {
|
|
|
476
478
|
else if (this.isOp('.')) {
|
|
477
479
|
this.next();
|
|
478
480
|
const t = this.tok;
|
|
479
|
-
if (t.type !== 'name')
|
|
481
|
+
if (t.type !== 'name' || t.value.startsWith('$'))
|
|
480
482
|
this.fail("expected a member name after '.'");
|
|
481
483
|
this.next();
|
|
482
484
|
e = { t: 'member', obj: e, name: t.value };
|
|
@@ -585,6 +587,12 @@ class Parser {
|
|
|
585
587
|
return this.fail(`unexpected ${this.describe(t)}`);
|
|
586
588
|
}
|
|
587
589
|
parseNamePrimary(t) {
|
|
590
|
+
if (t.value.startsWith('$')) {
|
|
591
|
+
if (t.value !== '$env' && t.value !== '$args')
|
|
592
|
+
this.fail(`unknown special variable '${t.value}'`);
|
|
593
|
+
this.next();
|
|
594
|
+
return { t: 'special', name: t.value === '$env' ? 'env' : 'args' };
|
|
595
|
+
}
|
|
588
596
|
switch (t.value) {
|
|
589
597
|
case 'True':
|
|
590
598
|
this.next();
|
package/dist/recording.d.ts
CHANGED
|
@@ -7,8 +7,16 @@ export declare class RecordingBackend implements LlmBackend {
|
|
|
7
7
|
call(req: LlmRequest): Promise<LlmResponse>;
|
|
8
8
|
close(): Promise<void>;
|
|
9
9
|
}
|
|
10
|
+
export declare class TraceBackend implements LlmBackend {
|
|
11
|
+
private readonly backend;
|
|
12
|
+
private readonly file;
|
|
13
|
+
readonly mode = "trace";
|
|
14
|
+
constructor(backend: LlmBackend, file: string);
|
|
15
|
+
call(req: LlmRequest): Promise<LlmResponse>;
|
|
16
|
+
close(): Promise<void>;
|
|
17
|
+
}
|
|
10
18
|
export declare class ReplayBackend implements LlmBackend {
|
|
11
|
-
readonly mode
|
|
19
|
+
readonly mode: 'replay' | 'workflow-replay';
|
|
12
20
|
private readonly entries;
|
|
13
21
|
private position;
|
|
14
22
|
constructor(file: string);
|
package/dist/recording.js
CHANGED
|
@@ -1,14 +1,36 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
|
+
import { Failure } from './errors.js';
|
|
2
3
|
import { LlmError } from './llm.js';
|
|
3
|
-
function
|
|
4
|
-
return
|
|
4
|
+
function traceRequest(req) {
|
|
5
|
+
return {
|
|
5
6
|
kind: req.kind, prompt: req.prompt, model: req.model ?? null,
|
|
6
7
|
reasoningEffort: req.reasoningEffort ?? null, showReasoning: req.showReasoning,
|
|
7
|
-
}
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
function requestKey(req) {
|
|
11
|
+
return JSON.stringify(traceRequest(req));
|
|
8
12
|
}
|
|
9
13
|
function isRecord(value) {
|
|
10
14
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
11
15
|
}
|
|
16
|
+
function isResponse(value) {
|
|
17
|
+
return isRecord(value) && typeof value.text === 'string' && isRecord(value.effective)
|
|
18
|
+
&& (value.reasoning === undefined || typeof value.reasoning === 'string');
|
|
19
|
+
}
|
|
20
|
+
function isTraceRequest(value) {
|
|
21
|
+
return isRecord(value) && (value.kind === 'ask' || value.kind === 'think' || value.kind === 'agent')
|
|
22
|
+
&& typeof value.prompt === 'string' && (value.model === null || typeof value.model === 'string')
|
|
23
|
+
&& (value.reasoningEffort === null || value.reasoningEffort === 'low' || value.reasoningEffort === 'medium' || value.reasoningEffort === 'high')
|
|
24
|
+
&& typeof value.showReasoning === 'boolean';
|
|
25
|
+
}
|
|
26
|
+
function isTraceError(value) {
|
|
27
|
+
const codes = [
|
|
28
|
+
'assertion_failed', 'explicit_failure', 'invalid_format', 'validation_failed',
|
|
29
|
+
'restriction_violation', 'llm_error', 'tool_error', 'runtime_error',
|
|
30
|
+
];
|
|
31
|
+
return isRecord(value) && typeof value.reason === 'string'
|
|
32
|
+
&& codes.some((code) => code === value.status_code);
|
|
33
|
+
}
|
|
12
34
|
function parseLine(line, number) {
|
|
13
35
|
try {
|
|
14
36
|
return JSON.parse(line);
|
|
@@ -47,35 +69,81 @@ export class RecordingBackend {
|
|
|
47
69
|
return this.backend.close();
|
|
48
70
|
}
|
|
49
71
|
}
|
|
72
|
+
export class TraceBackend {
|
|
73
|
+
backend;
|
|
74
|
+
file;
|
|
75
|
+
mode = 'trace';
|
|
76
|
+
constructor(backend, file) {
|
|
77
|
+
this.backend = backend;
|
|
78
|
+
this.file = file;
|
|
79
|
+
fs.writeFileSync(file, JSON.stringify({ format: 'spl-trace', version: 1 }) + '\n', { flag: 'wx', mode: 0o600 });
|
|
80
|
+
}
|
|
81
|
+
async call(req) {
|
|
82
|
+
const request = traceRequest(req);
|
|
83
|
+
let outcome;
|
|
84
|
+
let failure;
|
|
85
|
+
try {
|
|
86
|
+
outcome = { response: await this.backend.call(req) };
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
failure = e instanceof Error ? e : new LlmError(String(e));
|
|
90
|
+
outcome = { error: {
|
|
91
|
+
status_code: e instanceof Failure ? e.statusCode : 'llm_error',
|
|
92
|
+
reason: e instanceof Failure ? e.reason : e instanceof Error ? e.message : String(e),
|
|
93
|
+
} };
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
fs.appendFileSync(this.file, JSON.stringify({ request, ...outcome }) + '\n');
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
if (!(e instanceof Error))
|
|
100
|
+
throw e;
|
|
101
|
+
throw new LlmError(`could not save trace: ${e.message}`);
|
|
102
|
+
}
|
|
103
|
+
if ('error' in outcome)
|
|
104
|
+
throw failure;
|
|
105
|
+
return outcome.response;
|
|
106
|
+
}
|
|
107
|
+
close() {
|
|
108
|
+
return this.backend.close();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
50
111
|
export class ReplayBackend {
|
|
51
|
-
mode
|
|
112
|
+
mode;
|
|
52
113
|
entries;
|
|
53
114
|
position = 0;
|
|
54
115
|
constructor(file) {
|
|
55
116
|
const lines = fs.readFileSync(file, 'utf8').trimEnd().split(/\r?\n/);
|
|
56
117
|
const header = parseLine(lines.shift() ?? '', 1);
|
|
57
|
-
if (!isRecord(header) || header.format !== 'spl-generation' || header.version !== 1) {
|
|
118
|
+
if (!isRecord(header) || (header.format !== 'spl-generation' && header.format !== 'spl-trace') || header.version !== 1) {
|
|
58
119
|
throw new LlmError('unsupported recording format/version');
|
|
59
120
|
}
|
|
121
|
+
this.mode = header.format === 'spl-trace' ? 'workflow-replay' : 'replay';
|
|
60
122
|
this.entries = lines.map((line, i) => {
|
|
61
123
|
const entry = parseLine(line, i + 2);
|
|
62
|
-
if (!isRecord(entry)
|
|
63
|
-
|
|
64
|
-
|
|
124
|
+
if (!isRecord(entry))
|
|
125
|
+
throw new LlmError(`invalid recording entry at line ${i + 2}`);
|
|
126
|
+
let request;
|
|
127
|
+
if (this.mode === 'workflow-replay' && isTraceRequest(entry.request)) {
|
|
128
|
+
request = JSON.stringify(traceRequest(entry.request));
|
|
129
|
+
}
|
|
130
|
+
else if (this.mode === 'replay' && typeof entry.request === 'string') {
|
|
131
|
+
request = entry.request;
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
65
134
|
throw new LlmError(`invalid recording entry at line ${i + 2}`);
|
|
66
135
|
}
|
|
67
|
-
|
|
68
|
-
request: entry.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
};
|
|
136
|
+
if (isResponse(entry.response) && entry.error === undefined) {
|
|
137
|
+
return { request, outcome: { response: entry.response } };
|
|
138
|
+
}
|
|
139
|
+
if (this.mode === 'workflow-replay' && entry.response === undefined && isTraceError(entry.error)) {
|
|
140
|
+
return { request, outcome: { error: entry.error } };
|
|
141
|
+
}
|
|
142
|
+
throw new LlmError(`invalid recording entry at line ${i + 2}`);
|
|
75
143
|
});
|
|
76
144
|
}
|
|
77
145
|
async call(req) {
|
|
78
|
-
if (req.kind === 'agent')
|
|
146
|
+
if (req.kind === 'agent' && this.mode === 'replay')
|
|
79
147
|
throw new LlmError('agent actions cannot be replayed');
|
|
80
148
|
const entry = this.entries[this.position];
|
|
81
149
|
if (!entry)
|
|
@@ -83,7 +151,11 @@ export class ReplayBackend {
|
|
|
83
151
|
if (entry.request !== requestKey(req))
|
|
84
152
|
throw new LlmError(`replay request mismatch at call ${this.position + 1}`);
|
|
85
153
|
this.position++;
|
|
86
|
-
|
|
154
|
+
if ('error' in entry.outcome) {
|
|
155
|
+
const { status_code, reason } = entry.outcome.error;
|
|
156
|
+
throw new Failure(status_code, reason);
|
|
157
|
+
}
|
|
158
|
+
return structuredClone(entry.outcome.response);
|
|
87
159
|
}
|
|
88
160
|
async close() {
|
|
89
161
|
if (this.position !== this.entries.length) {
|
package/dist/values.d.ts
CHANGED
|
@@ -21,6 +21,7 @@ export interface BuiltinFunction {
|
|
|
21
21
|
}
|
|
22
22
|
export type FunctionValue = UserFunction | BuiltinFunction;
|
|
23
23
|
export declare const runtimeError: (msg: string) => Failure;
|
|
24
|
+
export declare function assertMutable(v: Value): void;
|
|
24
25
|
export declare function isFunction(v: unknown): v is FunctionValue;
|
|
25
26
|
export declare function isList(v: unknown): v is Value[];
|
|
26
27
|
export declare function isDict(v: unknown): v is {
|
package/dist/values.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { Failure } from './errors.js';
|
|
2
2
|
export const runtimeError = (msg) => new Failure('runtime_error', msg);
|
|
3
|
+
export function assertMutable(v) {
|
|
4
|
+
if (typeof v === 'object' && v !== null && Object.isFrozen(v)) {
|
|
5
|
+
throw runtimeError('cannot modify a read-only value');
|
|
6
|
+
}
|
|
7
|
+
}
|
|
3
8
|
export function isFunction(v) {
|
|
4
9
|
return typeof v === 'object' && v !== null && '__fn' in v && (v.__fn === 'user' || v.__fn === 'builtin');
|
|
5
10
|
}
|
package/docs/syntax.ebnf
CHANGED
|
@@ -46,9 +46,10 @@ call = "(" , [ expr , { "," , expr } , [ "," ] ] , ")" ;
|
|
|
46
46
|
index = "[" , expr , "]" ;
|
|
47
47
|
slice = "[" , [ expr ] , ":" , [ expr ] , "]" ;
|
|
48
48
|
member = "." , NAME ;
|
|
49
|
-
primary = NUMBER | string | "True" | "False" | "None" | NAME
|
|
49
|
+
primary = NUMBER | string | "True" | "False" | "None" | NAME | special_var
|
|
50
50
|
| "(" , expr , ")" | list | dict | block | if_stmt | log_expr
|
|
51
51
|
| ( "think" | "agent" ) , [ modifiers ] , postfix ;
|
|
52
|
+
special_var = "$env" | "$args" ;
|
|
52
53
|
list = "[" , [ expr , { "," , expr } , [ "," ] ] , "]" ;
|
|
53
54
|
dict = "{" , [ expr , ":" , expr , { "," , expr , ":" , expr } , [ "," ] ] , "}" ;
|
|
54
55
|
modifiers = "{" , [ modifier , { "," , modifier } , [ "," ] ] , "}" ;
|
|
@@ -84,6 +85,12 @@ NAME = ( letter | "_" ) , { letter | digit | "_" } ;
|
|
|
84
85
|
NL = newline ;
|
|
85
86
|
|
|
86
87
|
(* "{{"/"}}" are literal braces; raw strings have no interpolation or escapes.
|
|
88
|
+
$env is a read-only process-environment dict snapshot; missing keys return None.
|
|
89
|
+
$args is a read-only list of repeated CLI --arg strings (empty by default);
|
|
90
|
+
out-of-range integer indexes return None, and negative indexes count from the end.
|
|
91
|
+
Both are available in every scope and interpolation, cannot be rebound or mutated
|
|
92
|
+
(including through aliases), and are not automatically persisted.
|
|
93
|
+
Other $-prefixed names are syntax errors. Ordinary dict/list missing reads still fail.
|
|
87
94
|
Reserved words: let if elif else for in while def return try except assert fail success
|
|
88
95
|
enforce forbid restrict think agent break continue pass and or not True False None.
|
|
89
96
|
Removed syntax: think{level=...}, tool{param=...} 'instructions',
|