Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/codemode/interpreter-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions packages/codemode/src/interpreter/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}` }
Expand Down
11 changes: 11 additions & 0 deletions packages/codemode/src/interpreter/methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion packages/codemode/src/interpreter/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
14 changes: 10 additions & 4 deletions packages/codemode/src/interpreter/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") })
Expand Down Expand Up @@ -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
})
Expand All @@ -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 })
})
}
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions packages/codemode/src/stdlib/value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
6 changes: 4 additions & 2 deletions packages/codemode/src/tool-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
44 changes: 42 additions & 2 deletions packages/codemode/src/tool-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
}
Expand Down
91 changes: 91 additions & 0 deletions packages/codemode/test/codemode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
10 changes: 10 additions & 0 deletions packages/codemode/test/parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
24 changes: 24 additions & 0 deletions packages/codemode/test/signature.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down
Loading