Compare commits

...
11 changed files with 203 additions and 12 deletions
+2 -1
View File
@@ -36,7 +36,8 @@ ultimate source of truth.
- [x] Template literals with interpolation.
- [x] Regular-expression literals.
- [x] `NaN` and `Infinity` globals.
- [ ] BigInt literals and in-interpreter BigInt arithmetic; BigInt remains invalid at JSON-like host boundaries.
- [x] `BigInt(...)`, bigint literals, exact arithmetic, updates, `toString(radix)`, and `valueOf()`; bigint values stay
native across tool calls and become precise decimal strings only in final program results.
- [ ] Arbitrary Symbol primitive values and symbol-keyed properties. The confined `Symbol.iterator` and
`Symbol.asyncIterator` keys are available only for custom iterator protocols.
- [ ] Tagged-template calls.
+2 -2
View File
@@ -45,9 +45,9 @@ export const normalizeError = (error: unknown): Diagnostic => {
message = (value as { message: string }).message
} else {
try {
message = JSON.stringify(copyOut(value, "json")) ?? String(value)
message = JSON.stringify(copyOut(value, "nullify")) ?? coerceToString(value)
} catch {
message = String(value)
message = coerceToString(value)
}
}
return { kind: "ExecutionFailure", message: `Uncaught: ${message}` }
@@ -97,6 +97,17 @@ export const invokeIntrinsic = <R>(
if (typeof ref.receiver === "number") {
return Effect.succeed(invokeNumberMethod(ref.receiver, ref.name, args, node))
}
if (typeof ref.receiver === "bigint") {
if (ref.name === "valueOf") return Effect.succeed(ref.receiver)
const radix = args[0]
if (radix !== undefined && typeof radix !== "number") {
throw new InterpreterRuntimeError("BigInt.toString expects a numeric radix.", node).as("TypeError")
}
if (typeof radix === "number" && (radix < 2 || radix > 36)) {
throw new InterpreterRuntimeError("BigInt.toString radix must be between 2 and 36.", node).as("RangeError")
}
return Effect.succeed(ref.receiver.toString(radix))
}
if (Array.isArray(ref.receiver)) {
return invokeArrayMethod(runner, ref.receiver, ref.name, args, node)
}
+1 -1
View File
@@ -137,7 +137,7 @@ export class JsonMethodReference {
}
export class CoercionFunction {
constructor(readonly name: "Number" | "String" | "Boolean" | "parseInt" | "parseFloat" | "isFinite" | "isNaN") {}
constructor(readonly name: "Number" | "String" | "Boolean" | "BigInt" | "parseInt" | "parseFloat" | "isFinite" | "isNaN") {}
}
export class UriFunction {
+10 -4
View File
@@ -311,6 +311,7 @@ export class Interpreter<R> {
globalScope.set("Math", { mutable: false, value: new GlobalNamespace("Math") })
globalScope.set("JSON", { mutable: false, value: new GlobalNamespace("JSON") })
globalScope.set("Number", { mutable: false, value: new CoercionFunction("Number") })
globalScope.set("BigInt", { mutable: false, value: new CoercionFunction("BigInt") })
globalScope.set("String", { mutable: false, value: new CoercionFunction("String") })
globalScope.set("Boolean", { mutable: false, value: new CoercionFunction("Boolean") })
globalScope.set("Array", { mutable: false, value: new GlobalNamespace("Array") })
@@ -1893,18 +1894,18 @@ export class Interpreter<R> {
// CodeMode numeric coercion, not host Number(): null-prototype data objects would make
// the host throw during ToPrimitive, and opaque runtime references must reject clearly.
const operand = (current: unknown): number => {
const operand = (current: unknown): number | bigint => {
if (containsOpaqueReference(current)) {
throw new InterpreterRuntimeError(`'${operator}' requires a data value.`, argument, "InvalidDataValue")
}
return coerceToNumber(current)
return typeof current === "bigint" ? current : coerceToNumber(current)
}
if (argument.type === "Identifier") {
return Effect.sync(() => {
const name = getString(argument, "name")
const current = operand(this.scopes.get(name, argument))
const next = current + increment
const next = typeof current === "bigint" ? current + BigInt(increment) : current + increment
this.scopes.set(name, next, argument)
return prefix ? next : current
})
@@ -1913,7 +1914,7 @@ export class Interpreter<R> {
if (argument.type === "MemberExpression") {
return this.modifyMember(argument, (current) => {
const value = operand(current)
const next = value + increment
const next = typeof value === "bigint" ? value + BigInt(increment) : value + increment
return Effect.succeed({ write: true, next, result: prefix ? next : value })
})
}
@@ -2581,6 +2582,11 @@ export class Interpreter<R> {
return new ComputedValue(undefined)
}
if (typeof objectValue === "bigint") {
if (key === "toString" || key === "valueOf") return new IntrinsicReference(objectValue, key)
return new ComputedValue(undefined)
}
if (objectValue instanceof CoercionFunction) {
if (typeof key === "string" && isBlockedMember(key)) {
throw new InterpreterRuntimeError(`${objectValue.name}.${key} is not available.`, propertyNode)
+6
View File
@@ -89,6 +89,12 @@ export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node
const value = boundedData(raw, `${ref.name} input`)
if (ref.name === "Number") return coerceToNumber(value)
if (ref.name === "Boolean") return Boolean(value)
if (ref.name === "BigInt") {
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "bigint" && typeof value !== "boolean") {
throw new InterpreterRuntimeError("BigInt expects a string, integer, bigint, or boolean.", node).as("TypeError")
}
return BigInt(value)
}
if (ref.name === "isFinite") return Number.isFinite(coerceToNumber(value))
if (ref.name === "isNaN") return Number.isNaN(coerceToNumber(value))
if (ref.name === "parseInt") {
+4 -2
View File
@@ -149,7 +149,8 @@ const copyBounded = (
value === undefined ||
typeof value === "string" ||
typeof value === "boolean" ||
typeof value === "number"
typeof value === "number" ||
typeof value === "bigint"
) {
return value
}
@@ -256,11 +257,12 @@ const copyBounded = (
// "json" mirrors JSON.stringify (undefined object values drop, undefined array elements become
// null, a bare undefined passes through): use it wherever data leaves as JSON, like tool
// arguments and stringify-style formatting. "nullify" turns every undefined, including a bare
// one, into null: use it for program results, where the consumer must never see undefined.
// one, into null and bigint values into precise decimal strings: use it for final program results.
export type CopyOutMode = "json" | "nullify"
export const copyOut = (value: unknown, mode: CopyOutMode): unknown => {
if (value === undefined && mode === "nullify") return null
if (typeof value === "bigint" && mode === "nullify") return value.toString()
if (typeof value === "number" && !Number.isFinite(value)) {
return null
}
+42 -2
View File
@@ -27,6 +27,7 @@ const MAX_RENDER_DEPTH = 8
type RenderContext = {
readonly definitions: Readonly<Record<string, JsonSchema>>
readonly pretty: boolean
readonly bigints?: WeakSet<JsonSchema>
}
const hasUnresolvedRef = (
@@ -88,6 +89,7 @@ const renderSchema = (
seen: ReadonlySet<string> = new Set(),
): string => {
if (depth > MAX_RENDER_DEPTH) return "unknown"
if (ctx.bigints?.has(schema)) return "bigint"
const nested =
schema.definitions === undefined && schema.$defs === undefined
? ctx
@@ -165,14 +167,52 @@ const renderSchema = (
return "unknown"
}
const markBigInts = (ast: unknown, schema: JsonSchema, bigints: WeakSet<JsonSchema>): void => {
if (ast === null || typeof ast !== "object" || !("_tag" in ast)) return
if (ast._tag === "BigInt") {
bigints.add(schema)
return
}
if (ast._tag === "Objects" && "propertySignatures" in ast && Array.isArray(ast.propertySignatures)) {
for (const field of ast.propertySignatures) {
if (typeof field !== "object" || field === null || !("name" in field) || !("type" in field)) continue
if (typeof field.name !== "string") continue
const property = schema.properties?.[field.name]
if (property !== undefined) markBigInts(field.type, property, bigints)
}
if ("indexSignatures" in ast && Array.isArray(ast.indexSignatures) && typeof schema.additionalProperties === "object") {
for (const index of ast.indexSignatures) {
if (typeof index === "object" && index !== null && "type" in index) {
markBigInts(index.type, schema.additionalProperties, bigints)
}
}
}
return
}
if (ast._tag === "Arrays" && "rest" in ast && Array.isArray(ast.rest) && schema.items !== undefined) {
for (const item of ast.rest) markBigInts(item, schema.items, bigints)
return
}
if (ast._tag === "Union" && "types" in ast && Array.isArray(ast.types)) {
const alternatives = schema.anyOf ?? schema.oneOf
if (alternatives === undefined) return
for (const [index, item] of ast.types.entries()) {
const alternative = alternatives[index]
if (alternative !== undefined) markBigInts(item, alternative, bigints)
}
}
}
export const toTypeScript = (schema: Schema.Top, decoded = false, pretty = false): string => {
try {
const visible = decoded ? Schema.toType(schema) : schema
const visible = decoded ? Schema.toType(schema) : Schema.toEncoded(schema)
const document = Schema.toJsonSchemaDocument(visible) as {
readonly schema: JsonSchema
readonly definitions?: Readonly<Record<string, JsonSchema>>
}
return renderSchema(document.schema, { definitions: document.definitions ?? {}, pretty })
const bigints = new WeakSet<JsonSchema>()
markBigInts(visible.ast, document.schema, bigints)
return renderSchema(document.schema, { definitions: document.definitions ?? {}, pretty, bigints })
} catch {
return "unknown"
}
+91
View File
@@ -437,6 +437,97 @@ describe("CodeMode console capture", () => {
})
})
describe("CodeMode bigint values", () => {
test("preserves exact bigint arithmetic and stringifies nested final results", async () => {
const result = await Effect.runPromise(
CodeMode.execute({
code: `
const value = BigInt("9007199254740993")
return { value, doubled: value * 2n, nested: [value + 1n], hex: value.toString(16) }
`,
}),
)
expect(result).toStrictEqual({
ok: true,
value: {
value: "9007199254740993",
doubled: "18014398509481986",
nested: ["9007199254740994"],
hex: "20000000000001",
},
toolCalls: [],
})
expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result)
})
test("returns a structured diagnostic when an uncaught object contains bigint values", async () => {
const result = await Effect.runPromise(
CodeMode.execute({
code: `throw { value: 9007199254740993n, nested: [1n] }`,
}),
)
expect(result).toStrictEqual({
ok: false,
error: {
kind: "ExecutionFailure",
message: 'Uncaught: {"value":"9007199254740993","nested":["1"]}',
},
toolCalls: [],
})
})
test("preserves native bigint values across chained schema-backed tool calls", async () => {
const lookup = Tool.make({
description: "Look up an exact integer",
input: Schema.Struct({}),
output: Schema.Struct({ value: Schema.BigIntFromString }),
execute: () => Effect.succeed({ value: "9007199254740993" }),
})
const double = Tool.make({
description: "Double an exact integer",
input: Schema.Struct({ value: Schema.BigInt }),
output: Schema.BigInt,
execute: ({ value }) => Effect.succeed(value * 2n),
})
const result = await Effect.runPromise(
CodeMode.make({ tools: { lookup, double } }).execute(`
const result = await tools.lookup({})
return await tools.double({ value: result.value + 1n })
`),
)
expect(result).toStrictEqual({
ok: true,
value: "18014398509481988",
toolCalls: [{ name: "lookup" }, { name: "double" }],
})
})
test("increments bigint bindings and object properties without losing precision", async () => {
const result = await Effect.runPromise(
CodeMode.execute({
code: `
let value = 9007199254740993n
const record = { value }
value++
++record.value
return [value, record.value, typeof value, String(value)]
`,
}),
)
expect(result.ok ? result.value : result.error).toStrictEqual([
"9007199254740994",
"9007199254740994",
"bigint",
"9007199254740994",
])
})
})
describe("CodeMode output budget", () => {
test("absent maxOutputBytes means no truncation at all", async () => {
const result = await Effect.runPromise(
+10
View File
@@ -307,6 +307,16 @@ describe("H1: NaN/Infinity flow as intermediates and normalize to null at the bo
})
})
describe("copyOut bigint handling per boundary mode", () => {
test("preserves native bigint tool arguments but stringifies final result values", () => {
expect(ToolRuntime.copyOut(9007199254740993n, "json")).toBe(9007199254740993n)
expect(ToolRuntime.copyOut(9007199254740993n, "nullify")).toBe("9007199254740993")
expect(ToolRuntime.copyOut({ value: [9007199254740993n] }, "nullify")).toStrictEqual({
value: ["9007199254740993"],
})
})
})
describe("copyOut undefined handling per boundary mode", () => {
test("json mode mirrors JSON.stringify for undefined", () => {
expect(ToolRuntime.copyOut({ q: undefined, keep: 1 }, "json")).toStrictEqual({ keep: 1 })
+24
View File
@@ -69,6 +69,30 @@ describe("pretty signature rendering", () => {
expect(outputTypeScript(lookupOrder)).toBe("{ status: string }")
})
test("renders native bigint schemas accurately in tool inputs and decoded outputs", () => {
const native = Tool.make({
description: "Double an exact integer",
input: Schema.Struct({ value: Schema.BigInt, values: Schema.Array(Schema.BigInt) }),
output: Schema.BigInt,
execute: ({ value }) => Effect.succeed(value * 2n),
})
const decoded = Tool.make({
description: "Look up an exact integer",
input: Schema.Struct({ encoded: Schema.BigIntFromString }),
output: Schema.Struct({ value: Schema.BigIntFromString }),
execute: () => Effect.succeed({ value: "9007199254740993" }),
})
expect(inputTypeScript(native)).toBe("{ value: bigint; values: Array<bigint> }")
expect(outputTypeScript(native)).toBe("bigint")
expect(inputTypeScript(decoded)).toBe("{ encoded: string }")
expect(outputTypeScript(decoded)).toBe("{ value: bigint }")
expect(CodeMode.make({ tools: { native, decoded } }).catalog().map((item) => item.signature)).toStrictEqual([
"tools.decoded(input: {\n encoded: string,\n}): Promise<{\n value: bigint,\n}>",
"tools.native(input: {\n value: bigint,\n values: Array<bigint>,\n}): Promise<bigint>",
])
})
test("nested objects recurse with increasing indent and their own JSDoc", () => {
const pretty = jsonSchemaToTypeScript(
{