tjs-lang 0.8.0 → 0.8.2
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/CLAUDE.md +15 -5
- package/CONTEXT.md +4 -0
- package/demo/docs.json +12 -690
- package/demo/examples.test.ts +6 -2
- package/demo/src/playground-shared.ts +48 -16
- package/demo/src/playground-test-results.test.ts +112 -0
- package/demo/src/tjs-playground.ts +11 -5
- package/dist/eslint.config.d.ts +2 -0
- package/dist/index.js +146 -141
- package/dist/index.js.map +4 -4
- package/dist/src/lang/core.d.ts +1 -1
- package/dist/src/lang/dialect.d.ts +35 -0
- package/dist/src/lang/emitters/ast.d.ts +1 -1
- package/dist/src/lang/emitters/js-tests.d.ts +7 -0
- package/dist/src/lang/emitters/js-wasm.d.ts +5 -1
- package/dist/src/lang/emitters/js.d.ts +21 -0
- package/dist/src/lang/index.d.ts +3 -1
- package/dist/src/lang/module-loader.d.ts +125 -0
- package/dist/src/lang/parser-transforms.d.ts +79 -0
- package/dist/src/lang/parser-types.d.ts +50 -0
- package/dist/src/lang/parser.d.ts +18 -0
- package/dist/src/lang/transpiler.d.ts +1 -0
- package/dist/src/lang/types.d.ts +18 -0
- package/dist/src/lang/wasm.d.ts +67 -1
- package/dist/src/vm/runtime.d.ts +18 -0
- package/dist/src/vm/vm.d.ts +15 -1
- package/dist/tjs-batteries.js +2 -2
- package/dist/tjs-batteries.js.map +2 -2
- package/dist/tjs-eval.js +43 -41
- package/dist/tjs-eval.js.map +3 -3
- package/dist/tjs-from-ts.js +2 -2
- package/dist/tjs-from-ts.js.map +2 -2
- package/dist/tjs-lang.js +107 -104
- package/dist/tjs-lang.js.map +4 -4
- package/dist/tjs-vm.js +53 -51
- package/dist/tjs-vm.js.map +3 -3
- package/docs/README.md +2 -0
- package/docs/lm-studio-setup.md +143 -0
- package/docs/universal-endpoint.md +122 -0
- package/llms.txt +9 -2
- package/package.json +24 -4
- package/src/batteries/audit.ts +6 -5
- package/src/batteries/llm.ts +8 -3
- package/src/builder.ts +0 -3
- package/src/cli/commands/check.ts +3 -2
- package/src/cli/commands/emit.ts +4 -2
- package/src/cli/commands/run.ts +6 -2
- package/src/cli/commands/test.ts +1 -1
- package/src/cli/commands/types.ts +2 -2
- package/src/lang/codegen.test.ts +4 -1
- package/src/lang/core.ts +6 -4
- package/src/lang/dialect.test.ts +63 -0
- package/src/lang/dialect.ts +50 -0
- package/src/lang/docs.test.ts +0 -1
- package/src/lang/emitters/ast.ts +145 -2
- package/src/lang/emitters/from-ts.ts +1 -1
- package/src/lang/emitters/js-tests.ts +46 -37
- package/src/lang/emitters/js.ts +19 -3
- package/src/lang/features.test.ts +6 -5
- package/src/lang/index.ts +18 -5
- package/src/lang/linter.ts +1 -1
- package/src/lang/module-loader.test.ts +9 -5
- package/src/lang/module-loader.ts +4 -5
- package/src/lang/parser-params.ts +1 -1
- package/src/lang/parser-transforms.ts +12 -18
- package/src/lang/parser-types.ts +17 -0
- package/src/lang/parser.test.ts +12 -6
- package/src/lang/parser.ts +113 -3
- package/src/lang/perf.test.ts +10 -4
- package/src/lang/runtime.ts +0 -1
- package/src/lang/subset-invariant.test.ts +90 -0
- package/src/lang/transpiler.ts +8 -0
- package/src/lang/types.ts +18 -0
- package/src/lang/wasm.test.ts +22 -16
- package/src/linalg/linalg.test.ts +16 -4
- package/src/linalg/vector-search.bench.test.ts +31 -10
- package/src/types/Type.ts +6 -6
- package/src/use-cases/asymmetric-client-server.test.ts +0 -2
- package/src/use-cases/batteries.test.ts +4 -0
- package/src/use-cases/client-server.test.ts +1 -1
- package/src/use-cases/local-helpers.test.ts +219 -0
- package/src/use-cases/timeout-overrides.test.ts +169 -0
- package/src/use-cases/unbundled-imports.test.ts +0 -1
- package/src/vm/atoms/batteries.ts +17 -3
- package/src/vm/runtime.ts +92 -7
- package/src/vm/vm.ts +50 -10
package/src/types/Type.ts
CHANGED
|
@@ -686,7 +686,7 @@ export interface FunctionPredicateSpec {
|
|
|
686
686
|
}
|
|
687
687
|
|
|
688
688
|
/** A runtime type that validates function signatures */
|
|
689
|
-
// eslint-disable-next-line @typescript-eslint/
|
|
689
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
|
690
690
|
export interface FunctionPredicateType extends RuntimeType<Function> {
|
|
691
691
|
/** Parameter specification */
|
|
692
692
|
readonly params: Record<string, any>
|
|
@@ -755,7 +755,7 @@ function kindOfExample(example: unknown): string | null {
|
|
|
755
755
|
*/
|
|
756
756
|
export function FunctionPredicate(
|
|
757
757
|
name: string,
|
|
758
|
-
// eslint-disable-next-line @typescript-eslint/
|
|
758
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
|
759
759
|
specOrFn: FunctionPredicateSpec | Function | (string | [string, TypeParam])[],
|
|
760
760
|
specBuilder?: (...typeArgs: any[]) => FunctionPredicateSpec
|
|
761
761
|
): FunctionPredicateType | GenericFunctionPredicateType {
|
|
@@ -794,18 +794,18 @@ export function FunctionPredicate(
|
|
|
794
794
|
return factory
|
|
795
795
|
}
|
|
796
796
|
|
|
797
|
-
/* eslint-disable @typescript-eslint/
|
|
797
|
+
/* eslint-disable @typescript-eslint/no-unsafe-function-type */
|
|
798
798
|
return _createFunctionPredicate(
|
|
799
799
|
name,
|
|
800
800
|
specOrFn as FunctionPredicateSpec | Function
|
|
801
801
|
)
|
|
802
|
-
/* eslint-enable @typescript-eslint/
|
|
802
|
+
/* eslint-enable @typescript-eslint/no-unsafe-function-type */
|
|
803
803
|
}
|
|
804
804
|
|
|
805
805
|
/** Internal: create a non-generic FunctionPredicateType */
|
|
806
806
|
function _createFunctionPredicate(
|
|
807
807
|
name: string,
|
|
808
|
-
// eslint-disable-next-line @typescript-eslint/
|
|
808
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
|
809
809
|
specOrFn: FunctionPredicateSpec | Function
|
|
810
810
|
): FunctionPredicateType {
|
|
811
811
|
let params: Record<string, any> = {}
|
|
@@ -853,7 +853,7 @@ function _createFunctionPredicate(
|
|
|
853
853
|
// Structural validation: check arity and __tjs metadata
|
|
854
854
|
const expectedArity = Object.keys(params).length
|
|
855
855
|
if (expectedArity > 0) {
|
|
856
|
-
// eslint-disable-next-line @typescript-eslint/
|
|
856
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
|
857
857
|
const fn = value as Function
|
|
858
858
|
const meta = (fn as any).__tjs
|
|
859
859
|
if (meta?.params) {
|
|
@@ -82,7 +82,6 @@ describe('Use Case: Asymmetric Client-Server', () => {
|
|
|
82
82
|
|
|
83
83
|
// Note: In a real scenario, client imports definitions (types/schema) but not heavy deps.
|
|
84
84
|
// Here we reuse the atom definitions from 'batteries.ts' for the builder schema.
|
|
85
|
-
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
86
85
|
const { coreAtoms } = require('../runtime')
|
|
87
86
|
const clientBuilder = Agent.custom({
|
|
88
87
|
...coreAtoms,
|
|
@@ -154,7 +153,6 @@ describe('Use Case: Asymmetric Client-Server', () => {
|
|
|
154
153
|
llmPredictBattery,
|
|
155
154
|
})
|
|
156
155
|
|
|
157
|
-
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
158
156
|
const { coreAtoms } = require('../runtime')
|
|
159
157
|
const logic = Agent.custom({ ...coreAtoms, storeVectorize })
|
|
160
158
|
.step({ op: 'storeVectorize', text: 'fail' })
|
|
@@ -252,4 +252,8 @@ describe('Batteries Included', () => {
|
|
|
252
252
|
expect(msg.tool_calls[0].function.name).toBe('get_weather')
|
|
253
253
|
expect(msg.tool_calls[0].function.arguments).toBe('{"city":"Paris"}')
|
|
254
254
|
})
|
|
255
|
+
|
|
256
|
+
it('LLM battery atoms have IO-friendly timeouts', () => {
|
|
257
|
+
expect(llmPredictBattery.timeoutMs).toBeGreaterThanOrEqual(60000)
|
|
258
|
+
})
|
|
255
259
|
})
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { describe, it, expect } from 'bun:test'
|
|
2
|
+
import { ajs } from '../transpiler'
|
|
3
|
+
import { transpile } from '../lang'
|
|
4
|
+
import { AgentVM } from '../vm'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Local helper functions: an agent source file may declare multiple top-level
|
|
8
|
+
* functions. The LAST is the entry point; the preceding ones are helpers,
|
|
9
|
+
* invoked via the `callLocal` atom. Helpers are top-level siblings — isolated
|
|
10
|
+
* scopes that see only their parameters, like ordinary functions.
|
|
11
|
+
*/
|
|
12
|
+
describe('Local helper functions', () => {
|
|
13
|
+
const VM = new AgentVM()
|
|
14
|
+
const run = async (src: string, args: Record<string, any>) =>
|
|
15
|
+
(await VM.run(ajs(src), args)).result
|
|
16
|
+
|
|
17
|
+
it('invokes a scalar-returning helper from the entry function', async () => {
|
|
18
|
+
const result = await run(
|
|
19
|
+
`
|
|
20
|
+
function double(x) { return x * 2 }
|
|
21
|
+
function main(n) {
|
|
22
|
+
const d = double(n)
|
|
23
|
+
return { d }
|
|
24
|
+
}`,
|
|
25
|
+
{ n: 5 }
|
|
26
|
+
)
|
|
27
|
+
expect(result.d).toBe(10)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('lets a helper call another helper', async () => {
|
|
31
|
+
const result = await run(
|
|
32
|
+
`
|
|
33
|
+
function double(x) { return x * 2 }
|
|
34
|
+
function addOne(x) {
|
|
35
|
+
const d = double(x)
|
|
36
|
+
return d + 1
|
|
37
|
+
}
|
|
38
|
+
function main(n) {
|
|
39
|
+
const a = double(n)
|
|
40
|
+
const b = addOne(n)
|
|
41
|
+
return { a, b }
|
|
42
|
+
}`,
|
|
43
|
+
{ n: 5 }
|
|
44
|
+
)
|
|
45
|
+
expect(result).toEqual({ a: 10, b: 11 })
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('allows helpers to return arrays and objects', async () => {
|
|
49
|
+
const result = await run(
|
|
50
|
+
`
|
|
51
|
+
function pair(x) { return [x, x] }
|
|
52
|
+
function wrap(x) { return { value: x } }
|
|
53
|
+
function main(n) {
|
|
54
|
+
const p = pair(n)
|
|
55
|
+
const w = wrap(n)
|
|
56
|
+
return { p, w }
|
|
57
|
+
}`,
|
|
58
|
+
{ n: 3 }
|
|
59
|
+
)
|
|
60
|
+
expect(result).toEqual({ p: [3, 3], w: { value: 3 } })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('supports idiomatic TJS typed/example params (colon shorthand)', async () => {
|
|
64
|
+
// Examples are representative values: floats for a fractional-scaling fn.
|
|
65
|
+
const result = await run(
|
|
66
|
+
`
|
|
67
|
+
function scale(x: 1.5, factor: 0.5): 0.75 { return x * factor }
|
|
68
|
+
function main(n: 1.5): 0.75 {
|
|
69
|
+
const s = scale(n, 0.5)
|
|
70
|
+
return { s }
|
|
71
|
+
}`,
|
|
72
|
+
{ n: 3 }
|
|
73
|
+
)
|
|
74
|
+
expect(result.s).toBe(1.5)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('supports multiple parameters', async () => {
|
|
78
|
+
const result = await run(
|
|
79
|
+
`
|
|
80
|
+
function add(a, b) { return a + b }
|
|
81
|
+
function main(x, y) {
|
|
82
|
+
const sum = add(x, y)
|
|
83
|
+
return { sum }
|
|
84
|
+
}`,
|
|
85
|
+
{ x: 7, y: 8 }
|
|
86
|
+
)
|
|
87
|
+
expect(result.sum).toBe(15)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('isolates helper scope — helpers cannot see the caller locals', async () => {
|
|
91
|
+
// `secret` is a local in main (= 42). The helper references a bare
|
|
92
|
+
// `secret`; under isolation it is NOT in scope, so AJS falls back to the
|
|
93
|
+
// bare-string literal "secret". A scope leak would instead expose main's
|
|
94
|
+
// 42 — so the guarantee is simply: the helper never sees the caller value.
|
|
95
|
+
const result = await run(
|
|
96
|
+
`
|
|
97
|
+
function leak(x) { return { x, secret } }
|
|
98
|
+
function main(n) {
|
|
99
|
+
const secret = 42
|
|
100
|
+
const r = leak(n)
|
|
101
|
+
return r
|
|
102
|
+
}`,
|
|
103
|
+
{ n: 1 }
|
|
104
|
+
)
|
|
105
|
+
expect(result.x).toBe(1)
|
|
106
|
+
expect(result.secret).not.toBe(42) // no leak of caller's local
|
|
107
|
+
expect(result.secret).toBe('secret') // AJS bare-string fallback
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('does not let helper return exit the caller agent', async () => {
|
|
111
|
+
// The helper returns, then main keeps running and returns its own object.
|
|
112
|
+
const result = await run(
|
|
113
|
+
`
|
|
114
|
+
function step(x) { return x + 100 }
|
|
115
|
+
function main(n) {
|
|
116
|
+
const a = step(n)
|
|
117
|
+
const b = step(a)
|
|
118
|
+
return { a, b }
|
|
119
|
+
}`,
|
|
120
|
+
{ n: 1 }
|
|
121
|
+
)
|
|
122
|
+
expect(result).toEqual({ a: 101, b: 201 })
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// --- transpile-time guards -------------------------------------------------
|
|
126
|
+
|
|
127
|
+
const expectTranspileError = (src: string, match: RegExp) =>
|
|
128
|
+
expect(() => transpile(src, { vmTarget: true })).toThrow(match)
|
|
129
|
+
|
|
130
|
+
it('rejects helper calls nested inside expressions', () => {
|
|
131
|
+
expectTranspileError(
|
|
132
|
+
`
|
|
133
|
+
function double(x) { return x * 2 }
|
|
134
|
+
function main(n) { return { v: double(n) + 1 } }`,
|
|
135
|
+
/cannot be called inside an expression/
|
|
136
|
+
)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('supports recursion (bounded by fuel/timeout, not rejected)', async () => {
|
|
140
|
+
// Recursive factorial — by-reference dispatch makes this a runtime loop.
|
|
141
|
+
const result = await run(
|
|
142
|
+
`
|
|
143
|
+
function fact(n) {
|
|
144
|
+
if (n <= 1) { return 1 }
|
|
145
|
+
const prev = fact(n - 1)
|
|
146
|
+
return n * prev
|
|
147
|
+
}
|
|
148
|
+
function main(n) {
|
|
149
|
+
const f = fact(n)
|
|
150
|
+
return { f }
|
|
151
|
+
}`,
|
|
152
|
+
{ n: 5 }
|
|
153
|
+
)
|
|
154
|
+
expect(result.f).toBe(120)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('supports mutual recursion', async () => {
|
|
158
|
+
const result = await run(
|
|
159
|
+
`
|
|
160
|
+
function isEven(n) {
|
|
161
|
+
if (n == 0) { return true }
|
|
162
|
+
const r = isOdd(n - 1)
|
|
163
|
+
return r
|
|
164
|
+
}
|
|
165
|
+
function isOdd(n) {
|
|
166
|
+
if (n == 0) { return false }
|
|
167
|
+
const r = isEven(n - 1)
|
|
168
|
+
return r
|
|
169
|
+
}
|
|
170
|
+
function main(n) {
|
|
171
|
+
const even = isEven(n)
|
|
172
|
+
return { even }
|
|
173
|
+
}`,
|
|
174
|
+
{ n: 10 }
|
|
175
|
+
)
|
|
176
|
+
expect(result.even).toBe(true)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('turns runaway recursion into a clean error, not a host crash', async () => {
|
|
180
|
+
// Unbounded recursion: must surface as a monadic error (depth cap or fuel),
|
|
181
|
+
// never a thrown RangeError that crashes the host VM.
|
|
182
|
+
const out = await VM.run(
|
|
183
|
+
ajs(`
|
|
184
|
+
function loop(n) { const r = loop(n); return r }
|
|
185
|
+
function main(n) { const v = loop(n); return { v } }`),
|
|
186
|
+
{ n: 1 },
|
|
187
|
+
{ fuel: 100000 }
|
|
188
|
+
)
|
|
189
|
+
expect(out.error).toBeDefined()
|
|
190
|
+
expect(out.error?.message).toMatch(/depth|fuel/i)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('rejects arity mismatches', () => {
|
|
194
|
+
expectTranspileError(
|
|
195
|
+
`
|
|
196
|
+
function add(a, b) { return a + b }
|
|
197
|
+
function main(n) { const v = add(n); return { v } }`,
|
|
198
|
+
/expects 2 argument\(s\), got 1/
|
|
199
|
+
)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('rejects same-name same-arity functions (collision)', () => {
|
|
203
|
+
// Two identical-signature `dup` declarations collide. The polymorphic-merge
|
|
204
|
+
// pass (which runs during parse, before helper extraction) catches this
|
|
205
|
+
// first — either way it's a clear, deterministic rejection.
|
|
206
|
+
expectTranspileError(
|
|
207
|
+
`
|
|
208
|
+
function dup(x) { return x }
|
|
209
|
+
function dup(x) { return x }
|
|
210
|
+
function main(n) { const v = dup(n); return { v } }`,
|
|
211
|
+
/ambiguous signatures|Duplicate helper/
|
|
212
|
+
)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('still transpiles a single-function agent (no helpers)', async () => {
|
|
216
|
+
const result = await run(`function main(n) { return { n } }`, { n: 9 })
|
|
217
|
+
expect(result.n).toBe(9)
|
|
218
|
+
})
|
|
219
|
+
})
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { AgentVM, isAgentError, defineAtom } from '../index'
|
|
3
|
+
import { s } from 'tosijs-schema'
|
|
4
|
+
|
|
5
|
+
// A deliberately slow atom whose builtin timeout would normally trip.
|
|
6
|
+
const slowAtom = defineAtom(
|
|
7
|
+
'slow',
|
|
8
|
+
s.object({ ms: s.number }),
|
|
9
|
+
undefined,
|
|
10
|
+
async ({ ms }) => {
|
|
11
|
+
await new Promise((resolve) => setTimeout(resolve, ms))
|
|
12
|
+
},
|
|
13
|
+
{ docs: 'Slow IO atom for testing', cost: 0.01, timeoutMs: 100 }
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
describe('Per-atom Timeout Overrides', () => {
|
|
17
|
+
it('uses the atom default when no override is provided', async () => {
|
|
18
|
+
const vm = new AgentVM({ slow: slowAtom })
|
|
19
|
+
|
|
20
|
+
const ast = vm.Agent.step({ op: 'slow', ms: 200 }).toJSON()
|
|
21
|
+
|
|
22
|
+
const result = await vm.run(ast, {}, { fuel: 100 })
|
|
23
|
+
|
|
24
|
+
expect(result.error).toBeDefined()
|
|
25
|
+
expect(result.error?.message).toContain("Atom 'slow' timed out")
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('static override raises an atom timeout above its default', async () => {
|
|
29
|
+
const vm = new AgentVM({ slow: slowAtom })
|
|
30
|
+
|
|
31
|
+
const ast = vm.Agent.step({ op: 'slow', ms: 200 }).toJSON()
|
|
32
|
+
|
|
33
|
+
const result = await vm.run(
|
|
34
|
+
ast,
|
|
35
|
+
{},
|
|
36
|
+
{
|
|
37
|
+
fuel: 100,
|
|
38
|
+
timeoutOverrides: { slow: 5000 }, // raise from 100 → 5s
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
expect(result.error).toBeUndefined()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('static override lowers an atom timeout below its default', async () => {
|
|
46
|
+
const vm = new AgentVM({ slow: slowAtom })
|
|
47
|
+
|
|
48
|
+
const ast = vm.Agent.step({ op: 'slow', ms: 100 }).toJSON()
|
|
49
|
+
|
|
50
|
+
const result = await vm.run(
|
|
51
|
+
ast,
|
|
52
|
+
{},
|
|
53
|
+
{
|
|
54
|
+
fuel: 100,
|
|
55
|
+
timeoutOverrides: { slow: 10 }, // lower from 100ms → 10ms
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
expect(result.error).toBeDefined()
|
|
60
|
+
expect(result.error?.message).toContain("Atom 'slow' timed out")
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('dynamic override receives input and ctx', async () => {
|
|
64
|
+
const vm = new AgentVM({ slow: slowAtom })
|
|
65
|
+
|
|
66
|
+
const ast = vm.Agent.step({ op: 'slow', ms: 200 }).toJSON()
|
|
67
|
+
|
|
68
|
+
const result = await vm.run(
|
|
69
|
+
ast,
|
|
70
|
+
{},
|
|
71
|
+
{
|
|
72
|
+
fuel: 100,
|
|
73
|
+
timeoutOverrides: {
|
|
74
|
+
// Allow 10x the requested delay
|
|
75
|
+
slow: (input: any) => input.ms * 10,
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
expect(result.error).toBeUndefined()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('override of 0 disables the per-atom timeout', async () => {
|
|
84
|
+
const vm = new AgentVM({ slow: slowAtom })
|
|
85
|
+
|
|
86
|
+
// ms > atom default (100), would normally trip
|
|
87
|
+
const ast = vm.Agent.step({ op: 'slow', ms: 300 }).toJSON()
|
|
88
|
+
|
|
89
|
+
const result = await vm.run(
|
|
90
|
+
ast,
|
|
91
|
+
{},
|
|
92
|
+
{
|
|
93
|
+
fuel: 100,
|
|
94
|
+
timeoutOverrides: { slow: 0 },
|
|
95
|
+
}
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
expect(result.error).toBeUndefined()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('atom timeout still fires when no override matches that op', async () => {
|
|
102
|
+
const vm = new AgentVM({ slow: slowAtom })
|
|
103
|
+
|
|
104
|
+
const ast = vm.Agent.step({ op: 'slow', ms: 200 }).toJSON()
|
|
105
|
+
|
|
106
|
+
const result = await vm.run(
|
|
107
|
+
ast,
|
|
108
|
+
{},
|
|
109
|
+
{
|
|
110
|
+
fuel: 100,
|
|
111
|
+
timeoutOverrides: { somethingElse: 5000 },
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
expect(result.error).toBeDefined()
|
|
116
|
+
expect(isAgentError(result.error)).toBe(true)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('Default vm.run timeout', () => {
|
|
121
|
+
it('does not derive run timeout from fuel (formula removed)', async () => {
|
|
122
|
+
// Under the old `fuel * 10ms` formula, fuel=1 would timeout in 10ms —
|
|
123
|
+
// far less than the IO needed below. Under the atom-derived default
|
|
124
|
+
// (slowest atom × 2), a 200ms IO atom completes fine with tiny fuel.
|
|
125
|
+
const vm = new AgentVM({ slow: slowAtom })
|
|
126
|
+
|
|
127
|
+
const ast = vm.Agent.step({ op: 'slow', ms: 200 }).toJSON()
|
|
128
|
+
|
|
129
|
+
const result = await vm.run(
|
|
130
|
+
ast,
|
|
131
|
+
{},
|
|
132
|
+
{
|
|
133
|
+
fuel: 1000, // headroom for cost; the run-level timeout is what matters
|
|
134
|
+
timeoutOverrides: { slow: 0 }, // disable per-atom timeout
|
|
135
|
+
// no timeoutMs option — use the default
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
expect(result.error).toBeUndefined()
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('derives the default from the slowest atom × 2 (never below the slowest atom budget)', () => {
|
|
143
|
+
// coreAtoms include 120s atoms (llm/runCode); the run-level default must
|
|
144
|
+
// cover them, else those per-atom budgets are dead config.
|
|
145
|
+
const core = new AgentVM()
|
|
146
|
+
expect(core.defaultRunTimeout).toBe(240_000) // 120s × 2
|
|
147
|
+
|
|
148
|
+
// A slower custom atom raises the default (self-adjusting).
|
|
149
|
+
const slow = defineAtom('slow5m', s.object({}), undefined, async () => {}, {
|
|
150
|
+
timeoutMs: 300_000,
|
|
151
|
+
})
|
|
152
|
+
const vm = new AgentVM({ slow5m: slow })
|
|
153
|
+
expect(vm.defaultRunTimeout).toBe(600_000) // 300s × 2
|
|
154
|
+
// and it always covers the slowest atom's own budget
|
|
155
|
+
expect(vm.defaultRunTimeout).toBeGreaterThanOrEqual(300_000)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('floors the default at 60s for a VM whose atoms are all fast', () => {
|
|
159
|
+
// `seq` etc. have timeoutMs: 0 (no timeout) and are excluded; if the only
|
|
160
|
+
// finite budgets were tiny, the floor keeps a sane backstop.
|
|
161
|
+
const fast = defineAtom('fast', s.object({}), undefined, async () => {}, {
|
|
162
|
+
timeoutMs: 50,
|
|
163
|
+
})
|
|
164
|
+
const vm = new AgentVM({ fast })
|
|
165
|
+
// coreAtoms still contribute their 120s atoms, so this VM is 240s; the floor
|
|
166
|
+
// is exercised conceptually — assert it's at least the 60s minimum.
|
|
167
|
+
expect(vm.defaultRunTimeout).toBeGreaterThanOrEqual(60_000)
|
|
168
|
+
})
|
|
169
|
+
})
|
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
import { describe, it, expect, beforeAll, afterAll } from 'bun:test'
|
|
15
15
|
import { fromTS } from '../lang/emitters/from-ts'
|
|
16
16
|
import { tjs } from '../lang'
|
|
17
|
-
import { createRuntime } from '../lang/runtime'
|
|
18
17
|
import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'
|
|
19
18
|
import { join } from 'path'
|
|
20
19
|
|
|
@@ -51,7 +51,12 @@ export const storeVectorize = defineAtom(
|
|
|
51
51
|
const resolvedText = resolveValue(text, ctx)
|
|
52
52
|
return vectorCap.embed(resolvedText)
|
|
53
53
|
},
|
|
54
|
-
|
|
54
|
+
// Network embedding call; a cold model can exceed the 1s atom default.
|
|
55
|
+
{
|
|
56
|
+
docs: 'Generate embeddings using vector battery',
|
|
57
|
+
cost: 20,
|
|
58
|
+
timeoutMs: 60000,
|
|
59
|
+
}
|
|
55
60
|
)
|
|
56
61
|
|
|
57
62
|
// store.createCollection
|
|
@@ -97,7 +102,12 @@ export const storeVectorAdd = defineAtom(
|
|
|
97
102
|
|
|
98
103
|
return storeCap.vectorAdd(resolvedColl, resolvedDoc)
|
|
99
104
|
},
|
|
100
|
-
|
|
105
|
+
// May embed the doc via the store (network IO); allow for a cold model.
|
|
106
|
+
{
|
|
107
|
+
docs: 'Add a document to a vector store collection',
|
|
108
|
+
cost: 5,
|
|
109
|
+
timeoutMs: 60000,
|
|
110
|
+
}
|
|
101
111
|
)
|
|
102
112
|
|
|
103
113
|
// store.search (Vector Search)
|
|
@@ -167,7 +177,11 @@ export const llmPredictBattery = defineAtom(
|
|
|
167
177
|
resolvedFormat
|
|
168
178
|
)
|
|
169
179
|
},
|
|
170
|
-
{
|
|
180
|
+
{
|
|
181
|
+
docs: 'Generate completion using LLM battery',
|
|
182
|
+
cost: 100,
|
|
183
|
+
timeoutMs: 120000,
|
|
184
|
+
}
|
|
171
185
|
)
|
|
172
186
|
|
|
173
187
|
// Vision battery interface (multimodal)
|
package/src/vm/runtime.ts
CHANGED
|
@@ -151,6 +151,11 @@ export type CostOverride =
|
|
|
151
151
|
| number
|
|
152
152
|
| ((input: any, ctx: RuntimeContext) => number)
|
|
153
153
|
|
|
154
|
+
/** Timeout override: static number (ms) or dynamic function. 0 disables. */
|
|
155
|
+
export type TimeoutOverride =
|
|
156
|
+
| number
|
|
157
|
+
| ((input: any, ctx: RuntimeContext) => number)
|
|
158
|
+
|
|
154
159
|
export interface RuntimeContext {
|
|
155
160
|
fuel: { current: number }
|
|
156
161
|
args: Record<string, any>
|
|
@@ -165,8 +170,12 @@ export interface RuntimeContext {
|
|
|
165
170
|
warnings?: string[] // Non-fatal warnings (e.g., console.warn)
|
|
166
171
|
signal?: AbortSignal // External abort signal for timeout enforcement
|
|
167
172
|
costOverrides?: Record<string, CostOverride> // Per-atom cost overrides
|
|
173
|
+
timeoutOverrides?: Record<string, TimeoutOverride> // Per-atom timeout overrides (ms, 0 disables)
|
|
168
174
|
context?: Record<string, any> // Immutable request-scoped metadata (auth, permissions, etc.)
|
|
169
175
|
runCodeDepth?: number // Track nested runCode calls to prevent infinite recursion
|
|
176
|
+
localCall?: boolean // Inside a callLocal helper body — return may be a non-object scalar
|
|
177
|
+
helpers?: Record<string, { steps: any[]; paramNames: string[] }> // Local helper bodies, called by name
|
|
178
|
+
callDepth?: number // Helper call nesting depth — guards against host-stack overflow on deep recursion
|
|
170
179
|
}
|
|
171
180
|
|
|
172
181
|
export type AtomExec = (step: any, ctx: RuntimeContext) => Promise<void>
|
|
@@ -1363,18 +1372,25 @@ export function defineAtom<I extends Record<string, any>, O = any>(
|
|
|
1363
1372
|
return
|
|
1364
1373
|
}
|
|
1365
1374
|
|
|
1366
|
-
// 3. Execution with Timeout
|
|
1375
|
+
// 3. Execution with Timeout (per-atom override > atom default)
|
|
1376
|
+
const overrideTimeout = ctx.timeoutOverrides?.[op]
|
|
1377
|
+
const baseTimeout =
|
|
1378
|
+
overrideTimeout !== undefined ? overrideTimeout : timeoutMs
|
|
1379
|
+
const effectiveTimeout =
|
|
1380
|
+
typeof baseTimeout === 'function'
|
|
1381
|
+
? baseTimeout(inputData, ctx)
|
|
1382
|
+
: baseTimeout
|
|
1367
1383
|
let timer: any
|
|
1368
1384
|
const execute = async () => fn(step as I, ctx)
|
|
1369
1385
|
|
|
1370
1386
|
result =
|
|
1371
|
-
|
|
1387
|
+
effectiveTimeout > 0
|
|
1372
1388
|
? await Promise.race([
|
|
1373
1389
|
execute(),
|
|
1374
1390
|
new Promise<never>((_, reject) => {
|
|
1375
1391
|
timer = setTimeout(
|
|
1376
1392
|
() => reject(new Error(`Atom '${op}' timed out`)),
|
|
1377
|
-
|
|
1393
|
+
effectiveTimeout
|
|
1378
1394
|
)
|
|
1379
1395
|
}),
|
|
1380
1396
|
]).finally(() => clearTimeout(timer))
|
|
@@ -1562,8 +1578,11 @@ export const ret = defineAtom(
|
|
|
1562
1578
|
if ('value' in step) {
|
|
1563
1579
|
const res = resolveValue(step.value, ctx)
|
|
1564
1580
|
|
|
1565
|
-
// Enforce object returns — agents must return objects for composability
|
|
1581
|
+
// Enforce object returns — agents must return objects for composability.
|
|
1582
|
+
// Helper bodies (callLocal) are exempt: they're internal calls and may
|
|
1583
|
+
// return scalars, arrays, etc. like ordinary functions.
|
|
1566
1584
|
if (
|
|
1585
|
+
!ctx.localCall &&
|
|
1567
1586
|
res !== undefined &&
|
|
1568
1587
|
res !== null &&
|
|
1569
1588
|
!isAgentError(res) &&
|
|
@@ -1786,6 +1805,71 @@ export const scope = defineAtom(
|
|
|
1786
1805
|
{ docs: 'Create new scope', timeoutMs: 0, cost: 0.1 }
|
|
1787
1806
|
)
|
|
1788
1807
|
|
|
1808
|
+
/**
|
|
1809
|
+
* Maximum helper-call nesting depth. Fuel + timeout bound the total *work* a
|
|
1810
|
+
* recursive helper can do, but deeply-nested `await seq.exec` would overflow
|
|
1811
|
+
* the host JS stack (an uncatchable RangeError) before fuel runs out. This cap
|
|
1812
|
+
* converts runaway recursion into a clean monadic error instead.
|
|
1813
|
+
*/
|
|
1814
|
+
export const MAX_CALL_DEPTH = 256
|
|
1815
|
+
|
|
1816
|
+
export const callLocal = defineAtom(
|
|
1817
|
+
'callLocal',
|
|
1818
|
+
s.object({
|
|
1819
|
+
name: s.string,
|
|
1820
|
+
args: s.array(s.any),
|
|
1821
|
+
}),
|
|
1822
|
+
undefined,
|
|
1823
|
+
async ({ name, args }, ctx) => {
|
|
1824
|
+
const helper = ctx.helpers?.[name as string]
|
|
1825
|
+
if (!helper) {
|
|
1826
|
+
ctx.error = new AgentError(`Unknown helper: ${name}`, 'callLocal')
|
|
1827
|
+
return ctx.error
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
const depth = (ctx.callDepth ?? 0) + 1
|
|
1831
|
+
if (depth > MAX_CALL_DEPTH) {
|
|
1832
|
+
ctx.error = new AgentError(
|
|
1833
|
+
`Maximum helper call depth (${MAX_CALL_DEPTH}) exceeded — likely infinite recursion in '${name}'`,
|
|
1834
|
+
'callLocal'
|
|
1835
|
+
)
|
|
1836
|
+
return ctx.error
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
// Resolve each argument expression in the caller's scope
|
|
1840
|
+
const resolvedArgs = (args as any[]).map((arg) => resolveValue(arg, ctx))
|
|
1841
|
+
|
|
1842
|
+
// Isolated scope: helpers are top-level sibling functions, not nested
|
|
1843
|
+
// closures, so they see ONLY their params — never the caller's locals.
|
|
1844
|
+
// Capabilities/fuel/resolver/etc. are shared via the spread; state and
|
|
1845
|
+
// consts start fresh. localCall exempts the helper's `return` from the
|
|
1846
|
+
// agent object-return contract (helpers may return scalars/arrays like
|
|
1847
|
+
// ordinary functions). callDepth guards against host-stack overflow.
|
|
1848
|
+
const scopedCtx: RuntimeContext = {
|
|
1849
|
+
...ctx,
|
|
1850
|
+
state: {},
|
|
1851
|
+
consts: new Set(),
|
|
1852
|
+
output: undefined,
|
|
1853
|
+
error: undefined,
|
|
1854
|
+
localCall: true,
|
|
1855
|
+
callDepth: depth,
|
|
1856
|
+
}
|
|
1857
|
+
for (let i = 0; i < helper.paramNames.length; i++) {
|
|
1858
|
+
scopedCtx.state[helper.paramNames[i]] = resolvedArgs[i]
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
await seq.exec({ op: 'seq', steps: helper.steps } as any, scopedCtx)
|
|
1862
|
+
|
|
1863
|
+
// Propagate errors but NOT output — the helper's return becomes this
|
|
1864
|
+
// atom's result, captured into the caller's named result variable by
|
|
1865
|
+
// the standard exec wrapper. Unlike `scope`, it does not bubble up
|
|
1866
|
+
// to ctx.output (so a helper return doesn't exit the caller agent).
|
|
1867
|
+
if (scopedCtx.error) ctx.error = scopedCtx.error
|
|
1868
|
+
return scopedCtx.output
|
|
1869
|
+
},
|
|
1870
|
+
{ docs: 'Invoke a local helper function by name', timeoutMs: 0, cost: 0.1 }
|
|
1871
|
+
)
|
|
1872
|
+
|
|
1789
1873
|
// 3. List (Cost 1)
|
|
1790
1874
|
|
|
1791
1875
|
/*#
|
|
@@ -2202,7 +2286,7 @@ export const fetch = defineAtom(
|
|
|
2202
2286
|
}
|
|
2203
2287
|
} catch (e: any) {
|
|
2204
2288
|
if (e.message.includes('allowedFetchDomains')) throw e
|
|
2205
|
-
throw new Error(`Invalid URL: ${url}
|
|
2289
|
+
throw new Error(`Invalid URL: ${url}`, { cause: e })
|
|
2206
2290
|
}
|
|
2207
2291
|
}
|
|
2208
2292
|
|
|
@@ -2476,7 +2560,7 @@ export const transpileCode = defineAtom(
|
|
|
2476
2560
|
try {
|
|
2477
2561
|
return ctx.capabilities.code.transpile(resolvedCode)
|
|
2478
2562
|
} catch (e: any) {
|
|
2479
|
-
throw new Error(`Code transpilation failed: ${e.message}
|
|
2563
|
+
throw new Error(`Code transpilation failed: ${e.message}`, { cause: e })
|
|
2480
2564
|
}
|
|
2481
2565
|
},
|
|
2482
2566
|
{ docs: 'Transpile AsyncJS code to AST', cost: 1 }
|
|
@@ -2538,7 +2622,7 @@ export const runCode = defineAtom(
|
|
|
2538
2622
|
try {
|
|
2539
2623
|
ast = ctx.capabilities.code.transpile(resolvedCode)
|
|
2540
2624
|
} catch (e: any) {
|
|
2541
|
-
throw new Error(`Code transpilation failed: ${e.message}
|
|
2625
|
+
throw new Error(`Code transpilation failed: ${e.message}`, { cause: e })
|
|
2542
2626
|
}
|
|
2543
2627
|
|
|
2544
2628
|
if (ast.op !== 'seq') {
|
|
@@ -2991,6 +3075,7 @@ export const coreAtoms = {
|
|
|
2991
3075
|
varsLet,
|
|
2992
3076
|
varsExport,
|
|
2993
3077
|
scope,
|
|
3078
|
+
callLocal,
|
|
2994
3079
|
map,
|
|
2995
3080
|
filter,
|
|
2996
3081
|
reduce,
|