Skip to content
Merged
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
2 changes: 2 additions & 0 deletions packages/client/src/generated/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2609,6 +2609,8 @@ export type SkillsListOutput = {
readonly name: string
readonly description?: string
readonly slash?: boolean
readonly icon?: string
readonly autoInject?: { readonly keywords?: ReadonlyArray<string>; readonly url?: ReadonlyArray<string> }
readonly location: string
readonly content: string
}>
Expand Down
15 changes: 13 additions & 2 deletions packages/codemode/src/codemode.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Effect, Schema } from "effect"
import { executeWithLimits } from "./interpreter/runtime.js"
import { assertValidGlobals, executeWithLimits } from "./interpreter/runtime.js"
import { type HostTools, type Services, type ToolDescription, ToolRuntime } from "./tool-runtime.js"
import type { Definition } from "./tool.js"

Expand Down Expand Up @@ -38,6 +38,14 @@ export type ExecuteOptions<Tools extends Record<string, unknown> = {}> = {
code: string
/** Explicit tool tree exposed to the program as `tools`. */
tools?: Tools & ToolTree<Services<Tools>>
/**
* Top-level tool namespaces ALSO bound as bare globals, so a skill document can be
* written as `await page.text()` rather than `await tools.page.text()`. Each name must
* be a namespace at the top level of `tools` and must not shadow a builtin global.
* A global is an alias for the same tool path: one implementation, one authorization
* point. Code Mode stays host-neutral - it never knows what `page` means.
*/
globals?: ReadonlyArray<string>
/** Per-execution overrides for the default resource limits. */
limits?: ExecutionLimits
/** Observes decoded tool input immediately before tool execution. */
Expand Down Expand Up @@ -139,6 +147,7 @@ export const execute = <const Tools extends Record<string, unknown>>(
): Effect.Effect<Result, never, Services<Tools>> => {
const tools = (options.tools ?? {}) as HostTools<Services<Tools>>
ToolRuntime.assertValidTools(tools)
assertValidGlobals(tools, options.globals ?? [])
return executeWithLimits(options, resolveExecutionLimits(options.limits), ToolRuntime.searchIndex(tools))
}

Expand All @@ -148,8 +157,10 @@ export const make = <const Tools extends Record<string, unknown> = {}>(
): Runtime<Services<Tools>> => {
const tools = (options.tools ?? {}) as HostTools<Services<Tools>>
ToolRuntime.assertValidTools(tools)
const globals = options.globals ?? []
assertValidGlobals(tools, globals)
const limits = resolveExecutionLimits(options.limits)
const prepared = ToolRuntime.prepare(tools, options.discovery?.catalogBudget)
const prepared = ToolRuntime.prepare(tools, options.discovery?.catalogBudget, globals)

return {
catalog: () => prepared.catalog,
Expand Down
131 changes: 97 additions & 34 deletions packages/codemode/src/interpreter/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
type Services,
} from "../tool-runtime.js"
import { ToolError } from "../tool-error.js"
import { isDefinition as isToolDefinition } from "../tool.js"
import { identifierSegment } from "../tool-schema.js"
import type {
DataValue,
Diagnostic,
Expand Down Expand Up @@ -599,6 +601,85 @@ const collectPatternNames = (pattern: AstNode, out: Array<string> = []): Array<s
return out
}

/**
* Builds the builtin global scope for one interpreter. Extracted from the Interpreter
* constructor so `BUILTIN_GLOBAL_NAMES` is derived from the same code that seeds the
* bindings: a builtin added here cannot silently become available as a host global name.
*/
const seedBuiltinGlobals = (): Map<string, Binding> => {
const globalScope = new Map<string, Binding>()
globalScope.set("tools", { mutable: false, value: new ToolReference([]) })
globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() })
globalScope.set("undefined", { mutable: false, value: undefined })
globalScope.set("Object", { mutable: false, value: new GlobalNamespace("Object") })
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("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") })
globalScope.set("console", { mutable: false, value: new GlobalNamespace("console") })
globalScope.set("parseInt", { mutable: false, value: new CoercionFunction("parseInt") })
globalScope.set("parseFloat", { mutable: false, value: new CoercionFunction("parseFloat") })
globalScope.set("Date", { mutable: false, value: new GlobalNamespace("Date") })
globalScope.set("RegExp", { mutable: false, value: new GlobalNamespace("RegExp") })
globalScope.set("Map", { mutable: false, value: new GlobalNamespace("Map") })
globalScope.set("Set", { mutable: false, value: new GlobalNamespace("Set") })
globalScope.set("URL", { mutable: false, value: new GlobalNamespace("URL") })
globalScope.set("URLSearchParams", { mutable: false, value: new GlobalNamespace("URLSearchParams") })
globalScope.set("encodeURI", { mutable: false, value: new UriFunction("encodeURI") })
globalScope.set("encodeURIComponent", { mutable: false, value: new UriFunction("encodeURIComponent") })
globalScope.set("decodeURI", { mutable: false, value: new UriFunction("decodeURI") })
globalScope.set("decodeURIComponent", { mutable: false, value: new UriFunction("decodeURIComponent") })
// Error constructors are real values, so `x instanceof Error` works and `Error("msg")`
// (with or without `new`) constructs a branded { name, message } error object.
for (const name of errorConstructors) {
globalScope.set(name, { mutable: false, value: new ErrorConstructorReference(name) })
}
// NaN/Infinity flow as ordinary in-sandbox values (normalized to null only at the data
// boundary - see copyOut), so their global bindings must exist too, e.g. `reduce(max, -Infinity)`.
globalScope.set("NaN", { mutable: false, value: NaN })
globalScope.set("Infinity", { mutable: false, value: Infinity })
return globalScope
}

/**
* Every name the interpreter seeds into the builtin global scope. A host global
* (see `ExecuteOptions.globals`) may not shadow one of these.
*/
export const BUILTIN_GLOBAL_NAMES: ReadonlySet<string> = new Set(seedBuiltinGlobals().keys())

/**
* Validates the names a host asks to bind as bare globals. A global is an alias for a
* top-level namespace of the tool tree, so it must name one, must not shadow a builtin
* global, and must be a plain identifier (a bare binding cannot be written in bracket
* notation). A duplicate is refused rather than deduplicated: a repeated name in a host's
* list is a configuration mistake worth surfacing.
*
* Lives here, beside `seedBuiltinGlobals`, so the reserved-name check reads the same set
* the interpreter actually seeds.
*/
export const assertValidGlobals = <R>(tools: HostTools<R>, globals: ReadonlyArray<string>): void => {
const seen = new Set<string>()
for (const name of globals) {
if (!identifierSegment.test(name)) {
throw new Error(`Global '${name}' is not a plain identifier and cannot be bound as a global.`)
}
if (BUILTIN_GLOBAL_NAMES.has(name)) {
throw new Error(`Global '${name}' collides with a builtin global.`)
}
if (seen.has(name)) throw new Error(`Global '${name}' is listed more than once.`)
seen.add(name)
if (!Object.hasOwn(tools, name)) {
throw new Error(`Global '${name}' is not a top-level namespace of the tool tree.`)
}
const namespace = tools[name]
if (typeof namespace === "function" || isToolDefinition(namespace)) {
throw new Error(`Global '${name}' must be a namespace of tools, not a tool itself.`)
}
}
}

class Interpreter<R> {
private scopes: Array<Map<string, Binding>>
private readonly invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
Expand All @@ -618,46 +699,28 @@ class Interpreter<R> {
invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>,
logs: Array<string> = [],
/**
* Host tool namespaces additionally bound at the top level, so a program writes
* `page.text()` instead of `tools.page.text()`. Each name resolves to the same tool
* path, so there is one implementation and one authorization point, not two.
*/
hostGlobals: ReadonlyArray<string> = [],
) {
const globalScope = new Map<string, Binding>()
const globalScope = seedBuiltinGlobals()
this.scopes = [globalScope]
this.invokeTool = invokeTool
this.toolKeys = toolKeys
this.logs = logs
this.lastValue = undefined
this.callPermits = Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY)
globalScope.set("tools", { mutable: false, value: new ToolReference([]) })
globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() })
globalScope.set("undefined", { mutable: false, value: undefined })
globalScope.set("Object", { mutable: false, value: new GlobalNamespace("Object") })
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("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") })
globalScope.set("console", { mutable: false, value: new GlobalNamespace("console") })
globalScope.set("parseInt", { mutable: false, value: new CoercionFunction("parseInt") })
globalScope.set("parseFloat", { mutable: false, value: new CoercionFunction("parseFloat") })
globalScope.set("Date", { mutable: false, value: new GlobalNamespace("Date") })
globalScope.set("RegExp", { mutable: false, value: new GlobalNamespace("RegExp") })
globalScope.set("Map", { mutable: false, value: new GlobalNamespace("Map") })
globalScope.set("Set", { mutable: false, value: new GlobalNamespace("Set") })
globalScope.set("URL", { mutable: false, value: new GlobalNamespace("URL") })
globalScope.set("URLSearchParams", { mutable: false, value: new GlobalNamespace("URLSearchParams") })
globalScope.set("encodeURI", { mutable: false, value: new UriFunction("encodeURI") })
globalScope.set("encodeURIComponent", { mutable: false, value: new UriFunction("encodeURIComponent") })
globalScope.set("decodeURI", { mutable: false, value: new UriFunction("decodeURI") })
globalScope.set("decodeURIComponent", { mutable: false, value: new UriFunction("decodeURIComponent") })
// Error constructors are real values, so `x instanceof Error` works and `Error("msg")`
// (with or without `new`) constructs a branded { name, message } error object.
for (const name of errorConstructors) {
globalScope.set(name, { mutable: false, value: new ErrorConstructorReference(name) })
}
// NaN/Infinity flow as ordinary in-sandbox values (normalized to null only at the data
// boundary - see copyOut), so their global bindings must exist too, e.g. `reduce(max, -Infinity)`.
globalScope.set("NaN", { mutable: false, value: NaN })
globalScope.set("Infinity", { mutable: false, value: Infinity })
for (const name of hostGlobals) {
// Validated by ToolRuntime.assertValidGlobals before construction; a collision
// here would silently replace a builtin, so refuse rather than overwrite.
if (BUILTIN_GLOBAL_NAMES.has(name)) {
throw new Error(`Host global '${name}' collides with a builtin global.`)
}
globalScope.set(name, { mutable: false, value: new ToolReference([name]) })
}
}

run(program: ProgramNode): Effect.Effect<unknown, unknown, R> {
Expand Down Expand Up @@ -3365,7 +3428,7 @@ export const executeWithLimits = <const Tools extends Record<string, unknown>>(

const operation = Effect.gen(function* () {
const program = parseProgram(options.code)
const interpreter = new Interpreter<Services<Tools>>(tools.invoke, tools.keys, logs)
const interpreter = new Interpreter<Services<Tools>>(tools.invoke, tools.keys, logs, options.globals ?? [])
const value = yield* interpreter.run(program)
const result = copyOut(copyIn(value, "Execution result"), true) as DataValue
return {
Expand Down
27 changes: 25 additions & 2 deletions packages/codemode/src/tool-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,24 @@ export const assertValidTools = <R>(tools: HostTools<R>): void => {
}
}

/**
* Renders the host-global aliases. Each listed namespace is reachable both as a bare
* identifier and under `tools`, so a skill document can open with `await page.text()`
* without the model having to be told that `page` is "really" a tool namespace.
*/
const globalsSection = (globals: ReadonlyArray<string>): Array<string> => {
if (globals.length === 0) return []
const ordered = [...globals].sort((left, right) => left.localeCompare(right))
return [
"",
"## Domain globals",
"",
`These namespaces are also bound as bare globals: ${ordered.map((name) => `\`${name}\``).join(", ")}.`,
"`<global>.<tool>(input)` and `tools.<global>.<tool>(input)` are the same call; prefer the bare form.",
"A global's tools are listed under its namespace in the catalog below, like any other tool.",
]
}

/**
* Budgeted catalog: every namespace is always listed with its tool count; full call
* signatures are inlined against the `catalogBudget` (estimated tokens,
Expand All @@ -492,7 +510,12 @@ export const assertValidTools = <R>(tools: HostTools<R>): void => {
* namespace. Namespace stub lines are never budgeted: every namespace appears with its
* tool count even at budget 0.
*/
export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBudget): DiscoveryPlan => {
export const prepare = <R>(
tools: HostTools<R>,
catalogBudget = defaultCatalogBudget,
/** Namespaces also bound as bare globals; rendered so the model knows it may write `page.text()`. */
globals: ReadonlyArray<string> = [],
): DiscoveryPlan => {
if (!Number.isSafeInteger(catalogBudget) || catalogBudget < 0) {
throw new RangeError("discovery.catalogBudget must be a non-negative safe integer")
}
Expand Down Expand Up @@ -639,7 +662,7 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
}
}

const lines = [...intro, ...workflow, ...rules, ...language, ...toolSection]
const lines = [...intro, ...workflow, ...rules, ...globalsSection(globals), ...language, ...toolSection]
return {
catalog: described,
instructions: lines.join("\n"),
Expand Down
83 changes: 83 additions & 0 deletions packages/codemode/test/globals.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { CodeMode, Tool } from "../src/index.js"

const text = Tool.make({
description: "Visible text of the page",
input: Schema.Struct({}),
output: Schema.String,
run: () => Effect.succeed("hello from the page"),
})

const click = Tool.make({
description: "Click the first node matching a selector",
input: Schema.Struct({ selector: Schema.String }),
output: Schema.Boolean,
run: (input) => Effect.succeed(input.selector === "button"),
})

const page = { text, click }

const run = (code: string, globals: ReadonlyArray<string> = ["page"]) =>
Effect.runPromise(CodeMode.make({ tools: { page }, globals }).execute(code))

describe("CodeMode host globals", () => {
test("a bare global resolves to its tool namespace", async () => {
const result = await run("return await page.text({})")
expect(result).toMatchObject({ ok: true, value: "hello from the page" })
})

test("the bare form and the tools form are the same call", async () => {
const result = await run(
'return [await page.click({ selector: "button" }), await tools.page.click({ selector: "a" })]',
)
expect(result).toMatchObject({ ok: true, value: [true, false] })
expect(result.toolCalls.map((call) => call.name)).toStrictEqual(["page.click", "page.click"])
})

test("a global is enumerable like any namespace", async () => {
const result = await run("return Object.keys(page).toSorted()")
expect(result).toMatchObject({ ok: true, value: ["click", "text"] })
})

test("a top-level declaration still shadows a global, as in a JS module", async () => {
const result = await run('const page = "shadowed"; return page')
expect(result).toMatchObject({ ok: true, value: "shadowed" })
})

test("an unlisted namespace is not bound as a global", async () => {
const result = await Effect.runPromise(CodeMode.make({ tools: { page } }).execute("return await page.text({})"))
expect(result.ok).toBe(false)
expect(result.ok ? "" : result.error.message).toContain("page")
})

test("the instructions name the globals and the equivalence", async () => {
const instructions = CodeMode.make({ tools: { page }, globals: ["page"] }).instructions()
expect(instructions).toContain("## Domain globals")
expect(instructions).toContain("`page`")
expect(instructions).toContain("`tools.<global>.<tool>(input)` are the same call")
})

test("no globals section is rendered when no globals are declared", () => {
expect(CodeMode.make({ tools: { page } }).instructions()).not.toContain("## Domain globals")
})

describe("refuses a global a host cannot honestly bind", () => {
const cases: ReadonlyArray<readonly [string, ReadonlyArray<string>, string]> = [
["a name that is not in the tool tree", ["screen"], "not a top-level namespace"],
["a builtin global", ["Math"], "collides with a builtin global"],
["the tools binding itself", ["tools"], "collides with a builtin global"],
["a duplicate", ["page", "page"], "listed more than once"],
["a non-identifier", ["page-object"], "not a plain identifier"],
]
for (const [name, globals, message] of cases) {
test(name, () => {
expect(() => CodeMode.make({ tools: { page }, globals })).toThrow(message)
})
}

test("a tool rather than a namespace", () => {
expect(() => CodeMode.make({ tools: { text }, globals: ["text"] })).toThrow("not a tool itself")
})
})
})
Loading
Loading