diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index 4b8b9001107b..056d60fc082d 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -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. diff --git a/packages/codemode/src/interpreter/errors.ts b/packages/codemode/src/interpreter/errors.ts index 14962ce38483..f7ddfbb362cf 100644 --- a/packages/codemode/src/interpreter/errors.ts +++ b/packages/codemode/src/interpreter/errors.ts @@ -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}` } diff --git a/packages/codemode/src/interpreter/methods.ts b/packages/codemode/src/interpreter/methods.ts index cae4951d9a87..debadc8185c7 100644 --- a/packages/codemode/src/interpreter/methods.ts +++ b/packages/codemode/src/interpreter/methods.ts @@ -97,6 +97,17 @@ export const invokeIntrinsic = ( 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) } diff --git a/packages/codemode/src/interpreter/model.ts b/packages/codemode/src/interpreter/model.ts index b3a82e68867f..847a9fb25b95 100644 --- a/packages/codemode/src/interpreter/model.ts +++ b/packages/codemode/src/interpreter/model.ts @@ -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 { diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index 4457ec6add14..86fa7ad78f5b 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -311,6 +311,7 @@ export class Interpreter { 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 { // 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 { 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 { 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) diff --git a/packages/codemode/src/stdlib/value.ts b/packages/codemode/src/stdlib/value.ts index 68817ea7d73b..620cf5e05788 100644 --- a/packages/codemode/src/stdlib/value.ts +++ b/packages/codemode/src/stdlib/value.ts @@ -89,6 +89,12 @@ export const invokeCoercion = (ref: CoercionFunction, args: Array, 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") { diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index 5200c13b26ad..5e0e42a7a020 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -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 } diff --git a/packages/codemode/src/tool-schema.ts b/packages/codemode/src/tool-schema.ts index 52e1cd4033d8..98e76d013263 100644 --- a/packages/codemode/src/tool-schema.ts +++ b/packages/codemode/src/tool-schema.ts @@ -27,6 +27,7 @@ const MAX_RENDER_DEPTH = 8 type RenderContext = { readonly definitions: Readonly> readonly pretty: boolean + readonly bigints?: WeakSet } const hasUnresolvedRef = ( @@ -88,6 +89,7 @@ const renderSchema = ( seen: ReadonlySet = 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): 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> } - return renderSchema(document.schema, { definitions: document.definitions ?? {}, pretty }) + const bigints = new WeakSet() + markBigInts(visible.ast, document.schema, bigints) + return renderSchema(document.schema, { definitions: document.definitions ?? {}, pretty, bigints }) } catch { return "unknown" } diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index 3124db57e1ec..2305525b643c 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/packages/codemode/test/codemode.test.ts @@ -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( diff --git a/packages/codemode/test/parity.test.ts b/packages/codemode/test/parity.test.ts index 1ea2917d0adb..0cd3fee5b0bd 100644 --- a/packages/codemode/test/parity.test.ts +++ b/packages/codemode/test/parity.test.ts @@ -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 }) diff --git a/packages/codemode/test/signature.test.ts b/packages/codemode/test/signature.test.ts index a4dbd8790a04..c36749beec7a 100644 --- a/packages/codemode/test/signature.test.ts +++ b/packages/codemode/test/signature.test.ts @@ -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 }") + 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,\n}): Promise", + ]) + }) + test("nested objects recurse with increasing indent and their own JSDoc", () => { const pretty = jsonSchemaToTypeScript( {