Documentation
¶
Overview ¶
Package codemode lets an LLM agent orchestrate its own tools from a JavaScript program instead of one tool call per turn.
The model submits a program as a single tool call. Inside it, `await tools.name(args)` calls the tools the host bound, `Promise.all` fans them out in parallel, and ordinary JavaScript merges, filters and scores the results. Only what the program returns or console.logs goes back into the conversation — the intermediate results stay in the process.
Two things come out of that. Independent calls actually run concurrently instead of waiting for the model to emit another batch, and a fan-out over fifty search hits costs one tool result instead of fifty.
Layers ¶
Run is the engine: a program, a set of [Binding]s, a set of Limits. It knows nothing about agents, prompts or tool schemas.
NewTool wraps the engine into a tool you can hand to a model: a name, a description that states which tools a program may call, a JSON Schema for the arguments, and a Call method taking the raw argument JSON. Framework adapters live in ./adapters and are separate modules.
Mounting it is additive. The model's tool list does not change and its tools keep working as direct calls; it gains one more tool, and with it a second way to reach the same capabilities. The description says so as a set relation rather than by listing anything, so the schemas the model already has are not paid for twice.
Prompt returns the system-prompt section describing how to write a program against the bound tools.
Sandboxing ¶
The program runs in-process on goja. Capability omission is the real boundary: a program sees `tools`, `ToolCallError`, `console`, `sleep` and the JavaScript built-ins, and nothing else — no filesystem, no network, no require, no process. Every external effect goes through a binding the host chose to pass in.
The resource limits are tripwires, not a hard sandbox. goja has no per-VM memory ceiling and no instruction counter, so the wall clock, the compute budget and the memory trip line are sampled every 50ms and enforced with goja's Interrupt. Sampling sets how precisely they fire, not how soon: an allocation bomb crosses the memory line almost at once, while a loop that only computes runs until its compute budget is gone. Both are what a model writes by accident. Neither makes the VM safe against an adversary who controls the program and is willing to spend real effort on it.
Example ¶
A program calls tools, merges what comes back, and returns a digest. The nineteen candidates it looked at and threw away never reach the model.
package main
import (
"context"
"encoding/json"
"fmt"
"github.com/gtoxlili/codemode-go"
)
func main() {
fetch := codemode.Binding{
Name: "fetch_profile",
Invoke: func(_ context.Context, args string) (string, error) {
var a struct {
Handle string `json:"handle"`
}
if err := json.Unmarshal([]byte(args), &a); err != nil {
return "", err
}
return fmt.Sprintf(`{"handle":%q,"followers":%d}`, a.Handle, len(a.Handle)*1000), nil
},
}
res, failure := codemode.Run(context.Background(), `
const handles = ["ada", "grace", "barbara"];
const profiles = await Promise.all(handles.map(h => tools.fetch_profile({handle: h})));
const top = profiles.sort((a, b) => b.followers - a.followers)[0];
console.log("checked " + profiles.length + " profiles");
return {winner: top.handle, followers: top.followers};
`, []codemode.Binding{fetch}, codemode.DefaultLimits())
if failure != nil {
fmt.Println("failed:", failure)
return
}
out, _ := json.Marshal(res.Result)
fmt.Println(res.Logs[0])
fmt.Println(string(out))
}
Output: checked 3 profiles {"followers":7000,"winner":"barbara"}
Example (PartialFailure) ¶
A failed tool call rejects with a ToolCallError the program can catch, so one dead source does not throw away the work already done.
package main
import (
"context"
"encoding/json"
"fmt"
"github.com/gtoxlili/codemode-go"
)
func main() {
ok := codemode.Binding{
Name: "search_docs",
Invoke: func(context.Context, string) (string, error) { return `{"hits":["a","b"]}`, nil },
}
broken := codemode.Binding{
Name: "search_web",
Invoke: func(context.Context, string) (string, error) { return "", fmt.Errorf("rate limited (429)") },
}
res, failure := codemode.Run(context.Background(), `
const out = {hits: [], failed: []};
for (const name of ["search_docs", "search_web"]) {
try {
const r = await tools[name]({query: "changelog"});
out.hits.push(...r.hits);
} catch (e) {
if (!(e instanceof ToolCallError)) throw e;
out.failed.push(e.toolName);
}
}
return out;
`, []codemode.Binding{ok, broken}, codemode.DefaultLimits())
if failure != nil {
fmt.Println("failed:", failure)
return
}
out, _ := json.Marshal(res.Result)
fmt.Println(string(out))
}
Output: {"failed":["search_web"],"hits":["a","b"]}
Index ¶
- Constants
- func Prompt(opts PromptOptions) string
- func ReturnShape[T any]() string
- func ReturnShapeOf(t reflect.Type) string
- func Run(ctx context.Context, code string, bindings []Binding, limits Limits) (Result, *Failure)
- func TailLogs(lines []string, budget int) string
- type Binding
- type Blocked
- type CallEvent
- type Failure
- type FailureKind
- type Limits
- type Options
- type Phase
- type PromptOptions
- type Result
- type Tool
- func (t *Tool) Call(ctx context.Context, argsJSON string) (string, error)
- func (t *Tool) Description() string
- func (t *Tool) Name() string
- func (t *Tool) Parameters() map[string]any
- func (t *Tool) ParametersJSON() json.RawMessage
- func (t *Tool) Run(ctx context.Context, code string) (Result, *Failure)
Examples ¶
Constants ¶
const DefaultToolName = "run_code"
DefaultToolName is the tool's name unless Options.Name overrides it.
Variables ¶
This section is empty.
Functions ¶
func Prompt ¶
func Prompt(opts PromptOptions) string
Prompt returns the system-prompt section for the tool: how to call tools from a program, what a call resolves to, what a failed call rejects with, what runs in parallel, and that only the return value and console output come back.
It is written in the second person and formatted as a "# Heading" block, the usual shape of an agent system prompt section.
func ReturnShape ¶
ReturnShape renders a compact hint at the shape of T, e.g. `{hits: [{path, line: num, snippet}], total: num, next?}`.
Tool-calling protocols ship the input schema and nothing about the output, so a model writing `r.data.hits` is guessing. The hint is meant for the tail of a tool description:
desc += "\n\nReturns `{data: " + codemode.ReturnShape[SearchResult]() + "}`."
PromptOptions.ReturnShapes adds the matching notation guide to the prompt section. Because the hint is derived from the type, it tracks the code.
Notation, kept short to cost few tokens: a bare name is a string; anything else is annotated (`num`, `bool`, `timestamp`, `…` for unknown); `?` marks an omitempty field. Depth is capped at 3 and length at 240 characters, truncated with brackets closed so the hint still parses.
Returns the empty string for a type the notation has nothing to say about: an opaque payload (json.RawMessage, []byte, any), or a struct whose every field is opaque.
Example ¶
Append the shape of what a tool returns to its description, so the model navigates results instead of guessing field names five calls deep.
package main
import (
"fmt"
"github.com/gtoxlili/codemode-go"
)
type searchHit struct {
Path string `json:"path"`
Line int `json:"line"`
Snippet string `json:"snippet"`
}
type searchResults struct {
Query string `json:"query"`
Hits []searchHit `json:"hits"`
Truncated bool `json:"truncated"`
Cursor string `json:"cursor,omitempty"`
}
func main() {
fmt.Println("Returns `{data: " + codemode.ReturnShape[searchResults]() + "}`.")
}
Output: Returns `{data: {query, hits: [{path, line: num, snippet}], truncated: bool, cursor?}}`.
func ReturnShapeOf ¶
ReturnShapeOf is ReturnShape for a type you have at runtime.
func Run ¶
Run executes code as the body of an async function — top-level await and return both work — with bindings reachable as tools.Name(args).
Every run gets a fresh VM and no state survives it. Canceling ctx settles the run as FailureAborted and aborts sub-calls that are still in flight.
Two bindings sharing a name is a caller error this level cannot report: the first wins and the rest are dropped. NewTool rejects that case instead.
Three invariants keep this from deadlocking, and all three are load-bearing:
- vm.Interrupt is called directly from the watchdog, never queued onto the event loop. A queued interrupt never runs, because the loop is exactly what is stuck in the hot loop you are trying to kill.
- loop.Terminate runs in the background. It waits for the loop to go idle, so a binding that ignores context cancellation and spins would otherwise hang the caller's request instead of one cleanup goroutine.
- Every path that ends the run goes through one sync.Once. First one wins, the rest are no-ops.
func TailLogs ¶
TailLogs joins the last lines that fit in budget bytes, keeping the tail whole and dropping from the front. Use it to attach output to a failure message: the model needs to see what the program printed just before it died, and that budget is much tighter than the one a successful run gets.
Types ¶
type Binding ¶
type Binding struct {
Name string
Invoke func(ctx context.Context, args string) (string, error)
// Mutating declares that the tool writes something that outlives the call.
// It only matters together with ConflictKeys: two calls are serialized when
// they share a key and at least one of them mutates.
Mutating bool
// ConflictKeys reports which resources this call touches, derived from its
// own arguments — the scheduler does not know any tool's parameter schema,
// so the tool has to answer. Typical keys: a constant for a shared backend,
// "deck:"+id for a resource id, an absolute path for a file.
//
// Serialization needs a key from BOTH calls. A call with no keys conflicts
// with nothing and always runs in the parallel pool, so declaring them on
// the writers alone buys nothing: a write and a read of the same file will
// still overlap unless the reader names that file too. Every tool that
// touches a resource has to say so, not just the ones that change it.
//
// nil is the default. Panics are absorbed and treated as nil.
ConflictKeys func(args string) []string
}
Binding is one tool a program can call as tools.Name(args).
Invoke receives the run-scoped context — canceled when the run settles, times out or is aborted — and the arguments the program passed, serialized as a JSON object. It returns the tool's result as text. If that text parses as JSON the program sees the parsed value; otherwise it sees the raw string.
A returned error rejects the call's promise with a ToolCallError carrying .toolName, which the program can catch and keep going. A panic is absorbed and turned into the same thing.
func WithCallEvents ¶
WithCallEvents wraps every binding so that on receives a start event before each sub-call and a done or error event after it. Events carry the raw arguments, not a digest.
on is called from the goroutine running the sub-call, so it must be safe for concurrent use, and it runs inline: a slow observer slows the program down.
type Blocked ¶
Blocked names a tool the model can see in its tool list but cannot call from inside a program. It shows up in the tool description's "minus" list, and a program that calls it anyway gets Reason back as the call's error rather than an "unknown tool".
type CallEvent ¶
type CallEvent struct {
Seq int64
Tool string
Args string
Phase Phase
Duration time.Duration
Err error
}
CallEvent is one observation of a sub-call. Seq is the start order within the run, counting from 1. Duration and Err are only set on PhaseDone/PhaseError.
type Failure ¶
type Failure struct {
Kind FailureKind
Message string
}
Failure describes a failed run. Message is written for the model to read and act on, so it says what to do differently rather than what went wrong internally.
type FailureKind ¶
type FailureKind string
FailureKind classifies why a run failed. Each kind maps to one thing the model can do about it, which is the reason a run reports a kind instead of a bare error string: the model is the one reading it.
const ( // FailureException — the program did not compile, threw, or blew the call // stack. Fix the syntax or the logic. FailureException FailureKind = "exception" // FailureTimeout — the wall clock ran out. Narrow the loop, make fewer calls. FailureTimeout FailureKind = "timeout" // FailureComputeLimit — the compute budget ran out. Only time spent actually // running JavaScript counts, so this means the program computed too much, // not that a tool was slow. FailureComputeLimit FailureKind = "compute-limit" // FailureMemoryLimit — heap growth crossed the trip line while the program // was running. Stop building unbounded strings and arrays. FailureMemoryLimit FailureKind = "memory-limit" // FailureResultLimit — accumulated sub-call results crossed their budget. // Select or aggregate in code instead of holding everything. FailureResultLimit FailureKind = "result-limit" // FailureOutputLimit — console output crossed its budget. Print less, return // more. The output collected up to that point is kept. FailureOutputLimit FailureKind = "output-limit" // FailureInvalidReturn — the return value does not survive a JSON round // trip (a function, a cycle). Return plain data. FailureInvalidReturn FailureKind = "invalid-return" // FailureTooManyCalls — the program issued more sub-calls than the limit // allows. Nothing for the model to fix; the run is stopped on purpose. FailureTooManyCalls FailureKind = "too-many-calls" // FailureAborted — the caller canceled. Nothing for the model to fix. FailureAborted FailureKind = "aborted" )
type Limits ¶
type Limits struct {
// WallClock is the hard floor. On expiry the VM is interrupted, which kills
// hot loops and cannot be swallowed by JavaScript try/catch; if the run has
// not settled InterruptGrace later, it is torn down.
WallClock time.Duration
InterruptGrace time.Duration
// ComputeBudget only accumulates while the program is running JavaScript —
// time spent waiting on tool calls or sleep does not count, so this is not
// process CPU time. A fan-out over twenty slow APIs does not burn it; a
// `while (true) {}` burns it in seconds, long before the wall clock.
ComputeBudget time.Duration
// MemoryBudgetBytes trips when process heap growth relative to the run's
// baseline crosses it while the program is running. goja has no per-VM
// memory ceiling (upstream discussion #629), so this is a trip line sampled
// every 50ms, not a hard wall: it is a process-wide number, and concurrent
// allocation elsewhere counts toward it.
MemoryBudgetBytes int64
// ResultBudgetBytes caps the sub-call results a run may accumulate. Unlike
// the memory trip line this is exact — every resolved result is counted as
// it arrives. It is what stops a program from looping a thousand large
// documents into the heap.
ResultBudgetBytes int64
// MaxCallDepth bounds the JavaScript call stack. goja rejects deeper
// recursion with an uncatchable StackOverflowError instead of taking the
// process stack down with it.
MaxCallDepth int
// MaxParallel is the sub-call concurrency pool — the parallelism a
// Promise.all actually gets. MaxSubCalls is the total per run.
MaxParallel int
MaxSubCalls int
// LogBudgetBytes, LogLineRunes and MaxLogLines bound console output.
// Crossing any of them fails the run with FailureOutputLimit and keeps
// what was collected — an explicit failure the model can correct, rather
// than a silent truncation it never learns about.
LogBudgetBytes int
LogLineRunes int
MaxLogLines int
}
Limits is the resource envelope of one run. A zero field falls back to the corresponding DefaultLimits value, so you can override one knob and leave the rest alone.
func DefaultLimits ¶
func DefaultLimits() Limits
DefaultLimits are sized for an agent that calls slow external APIs: ten minutes of wall clock is a realistic ceiling for a fan-out over a few dozen of them, while two minutes of compute is far more than any honest merge or scoring pass needs.
type Options ¶
type Options struct {
// Bindings are the tools a program may call, and the execution side of the
// contract: they map a name a program writes onto the Go function behind
// it. They are not what tells the model those tools exist — it already
// knows them from its own tool list. See [NewTool] for the alignment the
// two sides require.
Bindings []Binding
// Blocked are tools the model has mounted but a program may not call.
Blocked []Blocked
// Name defaults to [DefaultToolName].
Name string
// Description overrides the generated one. The generated text states which
// tools are callable, derived from Bindings and Blocked; an override does
// not, and is not checked against them.
Description string
// Limits defaults to [DefaultLimits].
Limits Limits
// MaxConcurrentRuns bounds how many programs this tool runs at once,
// defaulting to 4. Each run is one VM plus its own pool of MaxParallel
// sub-calls. Waiting for a slot respects context cancellation.
MaxConcurrentRuns int
// OnCall observes each sub-call. See [WithCallEvents].
OnCall func(ctx context.Context, ev CallEvent)
// OnProgram fires once per run, before the program starts, with the code and
// the model's one-line description of what it does.
OnProgram func(ctx context.Context, code, description string)
// LogTailBytes is how much captured output is attached to a failure.
// Defaults to 8000.
LogTailBytes int
}
Options configures NewTool.
type PromptOptions ¶
type PromptOptions struct {
// ToolName defaults to [DefaultToolName]. Pass whatever you passed to
// [NewTool].
ToolName string
// ReturnShapes states whether the tool descriptions in this deployment end
// with a line giving the shape of what the tool returns, in the notation
// [ReturnShape] produces. When true, the section adds how to read those
// lines; when false it says nothing about them.
ReturnShapes bool
}
PromptOptions shapes the section Prompt returns.
type Result ¶
type Result struct {
Logs []string `json:"logs"`
Result any `json:"result,omitempty"`
HasResult bool `json:"-"`
}
Result is a successful run. HasResult is false when the program returned undefined — it only brought logs back.
type Tool ¶
type Tool struct {
// contains filtered or unexported fields
}
Tool is the run_code tool: a name, a description, an argument schema, and a Call that takes the model's raw argument JSON. The adapters under ./adapters map those onto specific frameworks.
func NewTool ¶
NewTool assembles the tool.
The generated description does not enumerate Bindings. It states a set relation — the tools a program can call are the ones already in the model's tool list, minus this tool and minus Blocked — which is why mounting this costs one tool description and not a second copy of every schema the model already has.
That leaves one obligation on the caller: Bindings has to match what the model actually has mounted, minus Blocked. Bind less and the description over-promises, and the model reaches for a tool that answers "unknown tool"; bind more and a program can reach capabilities the model was never shown. Nothing here can check that, because nothing here knows the tool list.
Blocked is the escape hatch for the difference that is intentional: a tool that is mounted and must not be callable from a program.
It fails on a name that appears twice across Bindings and Blocked, since there is no reading of that under which the description and the behavior agree.
Example ¶
The tool form: a name, a description, an argument schema, and a Call that takes the model's raw argument JSON. Hand those to whatever tool-calling loop you already have.
package main
import (
"context"
"fmt"
"github.com/gtoxlili/codemode-go"
)
func main() {
tool, err := codemode.NewTool(codemode.Options{
Bindings: []codemode.Binding{{
Name: "count_words",
Invoke: func(context.Context, string) (string, error) { return `{"count":42}`, nil },
}},
Blocked: []codemode.Blocked{{
Name: "ask_user",
Reason: "ask_user ends the turn, so a program cannot call it — ask before you start the program",
}},
})
if err != nil {
panic(err) // a name in both Bindings and Blocked, or listed twice
}
fmt.Println(tool.Name())
fmt.Println(tool.Description())
out, err := tool.Call(context.Background(),
`{"code":"const r = await tools.count_words({path:'a.md'}); return r.count * 2;","description":"Double a word count"}`)
fmt.Println(out, err)
}
Output: run_code Runs one JavaScript program that orchestrates your tools in batch; only what the program prints or returns comes back to the conversation. The tools callable in a program are exactly those in your current tool list, minus: run_code, ask_user. Reach for it when a batch of calls collapses into one digest — parallel fan-out, chained transforms, filtering bulk results down to what matters; a lone call is cheaper made directly. {"logs":[],"result":84} <nil>
func (*Tool) Call ¶
Call runs a program from the model's raw argument JSON and returns the result as JSON: {"logs": [...], "result": ...}, with result omitted when the program returned nothing.
A failed run comes back as an error whose message carries the failure kind and the tail of whatever the program printed, phrased for the model to read.
func (*Tool) Description ¶
func (*Tool) Parameters ¶
Parameters returns the JSON Schema for the tool's arguments, as a fresh map you may mutate.
func (*Tool) ParametersJSON ¶
func (t *Tool) ParametersJSON() json.RawMessage
ParametersJSON is Tool.Parameters marshaled, for the many APIs that want the schema as bytes.
