tjs-lang 0.8.1 → 0.8.3
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 +13 -4
- package/demo/autocomplete.test.ts +37 -0
- package/demo/docs.json +698 -8
- package/demo/examples.test.ts +6 -2
- package/demo/src/autocomplete.ts +40 -2
- package/demo/src/introspection-bridge.ts +140 -0
- package/demo/src/introspection-doc.test.ts +63 -0
- package/demo/src/playground-shared.ts +101 -16
- package/demo/src/playground-test-results.test.ts +112 -0
- package/demo/src/tjs-playground.ts +32 -5
- package/dist/examples/modules/dist/main.d.ts +34 -0
- package/dist/examples/modules/dist/math.d.ts +120 -0
- package/dist/index.js +115 -112
- 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.d.ts +12 -0
- package/dist/src/lang/index.d.ts +2 -1
- package/dist/src/lang/parser-types.d.ts +17 -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/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 -43
- package/dist/tjs-eval.js.map +3 -3
- package/dist/tjs-from-ts.js +1 -1
- package/dist/tjs-from-ts.js.map +2 -2
- package/dist/tjs-lang.js +76 -73
- package/dist/tjs-lang.js.map +4 -4
- package/dist/tjs-vm.js +49 -49
- package/dist/tjs-vm.js.map +3 -3
- package/editors/codemirror/ajs-language.ts +130 -44
- package/editors/codemirror/completion-source.test.ts +114 -0
- package/editors/introspect-value.test.ts +61 -0
- package/editors/introspect-value.ts +86 -0
- package/editors/scope-symbols.test.ts +113 -0
- package/editors/scope-symbols.ts +173 -0
- package/llms.txt +1 -0
- package/package.json +21 -1
- package/src/batteries/audit.ts +3 -2
- 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/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/emitters/ast.ts +145 -2
- package/src/lang/emitters/js-tests.ts +46 -37
- package/src/lang/emitters/js.ts +19 -2
- package/src/lang/features.test.ts +6 -5
- package/src/lang/index.ts +40 -5
- 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/predicate-schema.test.ts +97 -0
- package/src/lang/predicate-schema.ts +168 -0
- package/src/lang/predicate.test.ts +184 -0
- package/src/lang/predicate.ts +550 -0
- package/src/lang/subset-invariant.test.ts +90 -0
- package/src/lang/suggest.test.ts +84 -0
- package/src/lang/transpiler.ts +34 -0
- package/src/lang/types.ts +18 -0
- package/src/lang/wasm.test.ts +8 -2
- package/src/linalg/linalg.test.ts +8 -2
- package/src/use-cases/batteries.test.ts +4 -0
- package/src/use-cases/local-helpers.test.ts +219 -0
- package/src/use-cases/timeout-overrides.test.ts +169 -0
- package/src/vm/atom-effects.test.ts +72 -0
- package/src/vm/atoms/batteries.ts +29 -3
- package/src/vm/runtime.ts +140 -4
- package/src/vm/vm.ts +47 -9
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, it, expect } from 'bun:test'
|
|
2
|
+
import { suggest } from './predicate'
|
|
3
|
+
|
|
4
|
+
const vals = (s: ReturnType<typeof suggest>) =>
|
|
5
|
+
s.filter((x) => x.kind === 'value').map((x) => x.value)
|
|
6
|
+
const stubs = (s: ReturnType<typeof suggest>) =>
|
|
7
|
+
s.filter((x) => x.kind === 'stub').map((x) => x.value)
|
|
8
|
+
|
|
9
|
+
describe('suggest — autocomplete from predicate clusters', () => {
|
|
10
|
+
// The keyword set is a plain array literal + an equality check; the open-ended
|
|
11
|
+
// values ride in `startsWith` guards. TS could enumerate the union but not the
|
|
12
|
+
// open-ended `var(--`; a TS `string` fallback offers neither.
|
|
13
|
+
const ANIMATION = String.raw`
|
|
14
|
+
var TIMING = ['linear','ease','ease-in','ease-out','ease-in-out']
|
|
15
|
+
function isTime(t){ return /^-?[0-9.]+m?s$/.test(t) }
|
|
16
|
+
function isIter(t){ return t == 'infinite' || /^[0-9.]+$/.test(t) }
|
|
17
|
+
function isVar(t){ return typeof t == 'string' && t.startsWith('var(--') }
|
|
18
|
+
function isTok(t){ return isTime(t) || isIter(t) || TIMING.includes(t) || isVar(t) }
|
|
19
|
+
function isAnimationToken(v){ return typeof v == 'string' && isTok(v.trim()) }
|
|
20
|
+
`
|
|
21
|
+
|
|
22
|
+
it('mines the keyword set (array literal + equality literal)', () => {
|
|
23
|
+
const v = vals(suggest(ANIMATION))
|
|
24
|
+
expect(v).toContain('linear')
|
|
25
|
+
expect(v).toContain('ease-in-out')
|
|
26
|
+
expect(v).toContain('infinite') // from `t == 'infinite'`
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('mines open-ended `startsWith` guards as stubs (TS string can offer none)', () => {
|
|
30
|
+
expect(stubs(suggest(ANIMATION))).toContain('var(--')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('filters by the prefix typed so far', () => {
|
|
34
|
+
const v = vals(suggest(ANIMATION, { prefix: 'ease-' }))
|
|
35
|
+
expect(v).toEqual(['ease-in', 'ease-in-out', 'ease-out'])
|
|
36
|
+
expect(v).not.toContain('linear')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('a stub matches when the user is mid-typing into it', () => {
|
|
40
|
+
// user has typed `var(`; the `var(--` stub is still the relevant completion
|
|
41
|
+
expect(stubs(suggest(ANIMATION, { prefix: 'var(' }))).toContain('var(--')
|
|
42
|
+
// and once they pass it, it still matches by startsWith
|
|
43
|
+
expect(stubs(suggest(ANIMATION, { prefix: 'var(--' }))).toContain('var(--')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('validated suggestions are exactly what the predicate accepts', () => {
|
|
47
|
+
// `bad` is in the keyword array but the entry predicate excludes it — so the
|
|
48
|
+
// mined candidate is filtered out by running it through the predicate.
|
|
49
|
+
const src = String.raw`
|
|
50
|
+
var ALL = ['yes','maybe','bad']
|
|
51
|
+
function check(v){ return ALL.includes(v) && v != 'bad' }
|
|
52
|
+
`
|
|
53
|
+
const v = vals(suggest(src))
|
|
54
|
+
expect(v).toContain('yes')
|
|
55
|
+
expect(v).toContain('maybe')
|
|
56
|
+
expect(v).not.toContain('bad') // mined, but predicate rejects it
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('validate:false returns raw mined candidates (no predicate run)', () => {
|
|
60
|
+
const src = String.raw`
|
|
61
|
+
var ALL = ['yes','maybe','bad']
|
|
62
|
+
function check(v){ return ALL.includes(v) && v != 'bad' }
|
|
63
|
+
`
|
|
64
|
+
const v = vals(suggest(src, { validate: false }))
|
|
65
|
+
expect(v).toContain('yes')
|
|
66
|
+
expect(v).toContain('bad') // un-validated → the excluded literal leaks in
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('respects limit', () => {
|
|
70
|
+
expect(suggest(ANIMATION, { limit: 2 }).length).toBe(2)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('values sort before stubs', () => {
|
|
74
|
+
const s = suggest(ANIMATION)
|
|
75
|
+
const firstStub = s.findIndex((x) => x.kind === 'stub')
|
|
76
|
+
const lastValue = s.map((x) => x.kind).lastIndexOf('value')
|
|
77
|
+
expect(lastValue).toBeLessThan(firstStub)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('empty / unparseable source yields nothing', () => {
|
|
81
|
+
expect(suggest('')).toEqual([])
|
|
82
|
+
expect(suggest('function (')).toEqual([])
|
|
83
|
+
})
|
|
84
|
+
})
|
package/src/lang/transpiler.ts
CHANGED
|
@@ -16,6 +16,40 @@ export { transpile, ajs, tjs, createAgent, getToolDefinitions } from './core'
|
|
|
16
16
|
// Parser
|
|
17
17
|
export { parse, preprocess, extractTDoc } from './parser'
|
|
18
18
|
|
|
19
|
+
// Dialect resolution for file-based tooling (extension → dialect)
|
|
20
|
+
export {
|
|
21
|
+
dialectForFilename,
|
|
22
|
+
sourceKindForFilename,
|
|
23
|
+
type Dialect,
|
|
24
|
+
type SourceKind,
|
|
25
|
+
} from './dialect'
|
|
26
|
+
|
|
27
|
+
// Predicate-safety: verify a cluster of pure, synchronous, composable predicates
|
|
28
|
+
export {
|
|
29
|
+
verifyPredicate,
|
|
30
|
+
compilePredicate,
|
|
31
|
+
suggest,
|
|
32
|
+
effectfulFromAtoms,
|
|
33
|
+
formatPredicateDiagnostics,
|
|
34
|
+
PredicateFuelExhausted,
|
|
35
|
+
type PredicateDiagnostic,
|
|
36
|
+
type PredicateVerifyResult,
|
|
37
|
+
type VerifyPredicateOptions,
|
|
38
|
+
type CompilePredicateOptions,
|
|
39
|
+
type Suggestion,
|
|
40
|
+
type SuggestOptions,
|
|
41
|
+
} from './predicate'
|
|
42
|
+
|
|
43
|
+
// Predicate-aware JSON-Schema: the `$predicate` keyword (computational types)
|
|
44
|
+
export {
|
|
45
|
+
compilePredicateSchema,
|
|
46
|
+
validatePredicateSchema,
|
|
47
|
+
type PredicateSchema,
|
|
48
|
+
type SchemaError,
|
|
49
|
+
type SchemaValidationResult,
|
|
50
|
+
type PredicateSchemaOptions,
|
|
51
|
+
} from './predicate-schema'
|
|
52
|
+
|
|
19
53
|
// AST emitter
|
|
20
54
|
export { transformFunction } from './emitters/ast'
|
|
21
55
|
|
package/src/lang/types.ts
CHANGED
|
@@ -225,6 +225,21 @@ export interface TransformContext {
|
|
|
225
225
|
filename: string
|
|
226
226
|
/** Options */
|
|
227
227
|
options: TranspileOptions
|
|
228
|
+
/**
|
|
229
|
+
* Helper functions (top-level functions declared before the entry function).
|
|
230
|
+
* Calls to these names emit `callLocal` instead of treating them as atom calls.
|
|
231
|
+
*/
|
|
232
|
+
helpers?: Map<string, any /* FunctionDeclaration */>
|
|
233
|
+
/**
|
|
234
|
+
* Cache of transformed helper bodies. Lazily populated as helpers are
|
|
235
|
+
* referenced from the entry function (or from other helpers).
|
|
236
|
+
*/
|
|
237
|
+
helperSteps?: Map<string, { steps: any[]; paramNames: string[] }>
|
|
238
|
+
/**
|
|
239
|
+
* Names of helpers currently being transformed. Used to detect direct or
|
|
240
|
+
* transitive recursion.
|
|
241
|
+
*/
|
|
242
|
+
helperTransforming?: Set<string>
|
|
228
243
|
}
|
|
229
244
|
|
|
230
245
|
/** Create a child context for nested scopes */
|
|
@@ -239,6 +254,9 @@ export function createChildContext(parent: TransformContext): TransformContext {
|
|
|
239
254
|
source: parent.source,
|
|
240
255
|
filename: parent.filename,
|
|
241
256
|
options: parent.options,
|
|
257
|
+
helpers: parent.helpers,
|
|
258
|
+
helperSteps: parent.helperSteps,
|
|
259
|
+
helperTransforming: parent.helperTransforming,
|
|
242
260
|
}
|
|
243
261
|
}
|
|
244
262
|
|
package/src/lang/wasm.test.ts
CHANGED
|
@@ -2317,14 +2317,20 @@ describe('boundary distribution form (Phase 4)', () => {
|
|
|
2317
2317
|
async function dynamicImportLibrary(transpiled: string): Promise<any> {
|
|
2318
2318
|
const { tmpdir } = await import('node:os')
|
|
2319
2319
|
const { join } = await import('node:path')
|
|
2320
|
-
const { writeFileSync, unlinkSync } = await import('node:fs')
|
|
2320
|
+
const { writeFileSync, unlinkSync, realpathSync } = await import('node:fs')
|
|
2321
|
+
const { pathToFileURL } = await import('node:url')
|
|
2321
2322
|
const path = join(
|
|
2322
2323
|
tmpdir(),
|
|
2323
2324
|
`tjs-lib-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mjs`
|
|
2324
2325
|
)
|
|
2325
2326
|
writeFileSync(path, transpiled)
|
|
2327
|
+
// Resolve macOS /var → /private/var symlink and use file:// URL — bun's
|
|
2328
|
+
// resolver in subsequent test cases (after a `new Function(emittedCode)`
|
|
2329
|
+
// execution earlier in the same file) fails on bare absolute paths with
|
|
2330
|
+
// "Cannot find module ... from ''". This form is robust to that state.
|
|
2331
|
+
const url = pathToFileURL(realpathSync(path)).href
|
|
2326
2332
|
try {
|
|
2327
|
-
const mod = await import(
|
|
2333
|
+
const mod = await import(url)
|
|
2328
2334
|
// Wait for the async wasm bootstrap inside the module to finish.
|
|
2329
2335
|
// The bootstrap runs as a top-level IIFE; instantiation is async.
|
|
2330
2336
|
await new Promise((r) => setTimeout(r, 100))
|
|
@@ -14,9 +14,10 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { describe, it, expect } from 'bun:test'
|
|
17
|
-
import { readFileSync } from 'node:fs'
|
|
17
|
+
import { readFileSync, realpathSync } from 'node:fs'
|
|
18
18
|
import { join } from 'node:path'
|
|
19
19
|
import { tmpdir } from 'node:os'
|
|
20
|
+
import { pathToFileURL } from 'node:url'
|
|
20
21
|
import { writeFileSync, unlinkSync } from 'node:fs'
|
|
21
22
|
|
|
22
23
|
const LINALG_PATH = join(import.meta.dir, 'index.tjs')
|
|
@@ -41,8 +42,13 @@ async function dynamicImportLibrary(transpiled: string): Promise<any> {
|
|
|
41
42
|
`linalg-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.mjs`
|
|
42
43
|
)
|
|
43
44
|
writeFileSync(path, transpiled)
|
|
45
|
+
// Resolve macOS /var → /private/var symlink and use file:// URL — bun's
|
|
46
|
+
// resolver in subsequent test cases (after a `new Function(emittedCode)`
|
|
47
|
+
// execution earlier in the same file) fails on bare absolute paths with
|
|
48
|
+
// "Cannot find module ... from ''". This form is robust to that state.
|
|
49
|
+
const url = pathToFileURL(realpathSync(path)).href
|
|
44
50
|
try {
|
|
45
|
-
const mod = await import(
|
|
51
|
+
const mod = await import(url)
|
|
46
52
|
// Wait for the async wasm bootstrap inside the module to finish
|
|
47
53
|
await new Promise((r) => setTimeout(r, 100))
|
|
48
54
|
return mod
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, it, expect } from 'bun:test'
|
|
2
|
+
import { coreAtoms, EFFECTFUL_CORE_OPS, type AtomDef } from './runtime'
|
|
3
|
+
import { batteryAtoms } from './atoms/index'
|
|
4
|
+
|
|
5
|
+
const allAtoms = { ...coreAtoms, ...batteryAtoms } as Record<string, AtomDef>
|
|
6
|
+
|
|
7
|
+
describe('atom effects classification (predicate-safety keystone)', () => {
|
|
8
|
+
it('every atom is classified pure | io', () => {
|
|
9
|
+
for (const [op, atom] of Object.entries(allAtoms)) {
|
|
10
|
+
expect(atom.effects, `${op} has no effects tag`).toMatch(/^(pure|io)$/)
|
|
11
|
+
}
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('the declared effectful core ops are all tagged io', () => {
|
|
15
|
+
for (const op of EFFECTFUL_CORE_OPS) {
|
|
16
|
+
expect(coreAtoms[op as keyof typeof coreAtoms]?.effects, op).toBe('io')
|
|
17
|
+
}
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('every battery atom is io (network calls)', () => {
|
|
21
|
+
for (const atom of Object.values(batteryAtoms) as AtomDef[]) {
|
|
22
|
+
expect(atom.effects, atom.op).toBe('io')
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('the pure substrate a predicate relies on is tagged pure', () => {
|
|
27
|
+
// data / control-flow atoms predicates compose from
|
|
28
|
+
const pure = [
|
|
29
|
+
'map',
|
|
30
|
+
'filter',
|
|
31
|
+
'reduce',
|
|
32
|
+
'find',
|
|
33
|
+
'len',
|
|
34
|
+
'split',
|
|
35
|
+
'join',
|
|
36
|
+
'keys',
|
|
37
|
+
'pick',
|
|
38
|
+
'omit',
|
|
39
|
+
'merge',
|
|
40
|
+
'regexMatch',
|
|
41
|
+
'jsonParse',
|
|
42
|
+
'callLocal',
|
|
43
|
+
'if',
|
|
44
|
+
'while',
|
|
45
|
+
'return',
|
|
46
|
+
'scope',
|
|
47
|
+
]
|
|
48
|
+
for (const op of pure) {
|
|
49
|
+
expect(coreAtoms[op as keyof typeof coreAtoms]?.effects, op).toBe('pure')
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('the IO atoms are exactly the ones that may not appear in a predicate', () => {
|
|
54
|
+
const io = Object.values(allAtoms)
|
|
55
|
+
.filter((a) => a.effects === 'io')
|
|
56
|
+
.map((a) => a.op)
|
|
57
|
+
// sanity: the capability-touching atoms are all present
|
|
58
|
+
expect(io).toEqual(
|
|
59
|
+
expect.arrayContaining([
|
|
60
|
+
'httpFetch',
|
|
61
|
+
'storeGet',
|
|
62
|
+
'llmPredict',
|
|
63
|
+
'agentRun',
|
|
64
|
+
'runCode',
|
|
65
|
+
'llmVision',
|
|
66
|
+
])
|
|
67
|
+
)
|
|
68
|
+
// and none of the pure data ops leaked in
|
|
69
|
+
expect(io).not.toContain('map')
|
|
70
|
+
expect(io).not.toContain('jsonParse')
|
|
71
|
+
})
|
|
72
|
+
})
|