codemode

package module
v0.4.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 17, 2026 License: MIT Imports: 14 Imported by: 0

README

codemode-go

Go Reference CI Go Report Card

Programmatic tool calling for Go agents. Not a replacement for tool calling — a second execution strategy for the tools you already have. Direct calls keep working exactly as they do; the model also gets to write one JavaScript program that calls many of them and returns a digest. No new agent loop, no rewriting tools into another API, nothing hidden behind a runtime.

中文文档

Eight steps, three searches overlapping at about 130ms each, five sub-calls, one result back. Everything the program read in between stayed in the process.

The shape of it

If you are writing an agent in Go, your runtime already has a tool registry — read, list, grep, edit, bash, whatever your product needed. Each entry is already a function from arguments to a result. Binding them costs a few lines each:

bindings := []codemode.Binding{
    {Name: "list_files", Invoke: listFiles},
    {Name: "read_file",  Invoke: readFile,  ConflictKeys: fileKey},
    {Name: "grep",       Invoke: grep,      ConflictKeys: fileKey},
    {Name: "write_file", Invoke: writeFile, ConflictKeys: fileKey, Mutating: true},
}
tool, err := codemode.NewTool(codemode.Options{Bindings: bindings})

and the same capabilities the model reaches one call at a time become something it can program against:

const listed = await tools.list_files({path: "src"});
const hits = await Promise.all(
  listed.data.entries.map(p => tools.grep({path: p, query: "TODO|FIXME"}))
);
const worst = hits
  .flatMap(h => h.data.matches)
  .filter(m => m.line < 50)
  .slice(0, 20);
await tools.write_file({path: "debt.md", content: worst.map(m => "- " + m.path).join("\n")});
return {found: worst.length};

Two things follow. Independent calls actually run at the same time, because Promise.all is a real thing and "emit two tool calls and hope the runtime parallelizes them" is not. And the intermediate results stay in the process — the model sees {found: 20}, not the four hundred lines it read to get there.

The registry is the point. There is no protocol boundary in the middle: a Binding is a name and a func(ctx, argsJSON) (string, error), so anything your Go code can call is a tool a program can call.

Mounting it is additive. Your tools stay in the model's tool list and keep working as direct calls; the model gains one more tool, and with it a second way to reach the same capabilities. The generated description says that as a set relation — the tools callable in a program are exactly those in your current tool list, minus this one and minus what you blocked — so nothing re-lists schemas the model already has, and adding a tool later costs nothing here. What that leaves on you: Bindings has to match what you actually mounted, because nothing in this library can see your tool list.

ConflictKeys is on the readers as well as the writer, and it has to be: two calls serialize only when both of them name the same resource, so keys on the writer alone buy nothing and the read would still race. Your runtime is the only thing that can know a write to src/a.go must not overlap a read of it — the engine has no schema for your arguments and cannot infer it. With every file-touching tool declaring, reads of different files still overlap while calls landing on one file serialize in the order the program issued them. See scheduling.

One thing to know before wiring it in: the program runs in-process on goja and sees no filesystem, network or imports, but that is capability omission rather than isolation. This is not a security sandbox and is not meant for untrusted or multi-tenant programs; what a program can see says exactly where the line falls.

Pulled out of an agent that has been running it in production.

What is code mode?

Code mode gives the model one tool that runs a program, and exposes the rest of its tools as an API that program can call. It goes by several names: Anthropic calls it code execution with MCP, Cloudflare calls it Code Mode, the CodeAct paper calls it code-as-action.

Most of what carries the name replaces tool calling with it: the model gets one execute_code tool and the rest disappear behind it. This one does not. It is a library you embed, the tools being projected are your own primitives, MCP is just one of the places they can come from, and the agent loop stays yours — the model keeps every tool it had and gains a second way to reach them. See how it compares.

Why

Context. Intermediate results stay in the process. A sweep over fifty search hits costs one tool result instead of fifty, and the next turn does not carry the forty-five that turned out to be irrelevant. Anthropic's writeup puts one such case at 150,000 tokens down to 2,000.

Parallelism you actually get. Promise.all puts calls into a real worker pool. Waiting for the model to emit another batch of tool calls does not.

Logic the model would otherwise narrate. Filtering, scoring, joining two sources on a key — a program does it in four lines instead of a paragraph of reasoning over data that had to be pasted into the context first.

It is built to sit alongside direct tool calls rather than replace them. Most turns are one or two calls, where a program is pure overhead; the tool description tells the model when a program is worth it.

Install

go get github.com/gtoxlili/codemode-go

Go 1.25+. The core is pure Go on the goja stack — goja itself plus goja_nodejs for its event loop, and nothing beyond those two. No cgo, no Node, no Deno, no subprocess, no container.

Use

Three steps. Bind your tools, mount the tool, teach the model.

import "github.com/gtoxlili/codemode-go"

fileKey := func(args string) []string { return []string{"file:" + pathOf(args)} }

bindings := []codemode.Binding{{
    Name:         "read_file",
    Invoke:       readFile, // func(ctx context.Context, argsJSON string) (string, error)
    ConflictKeys: fileKey,
}, {
    Name:         "write_file",
    Invoke:       writeFile,
    ConflictKeys: fileKey,
    Mutating:     true,
}}

tool, err := codemode.NewTool(codemode.Options{
    Bindings: bindings,
    Blocked: []codemode.Blocked{{
        Name:   "ask_user",
        Reason: "ask_user ends the turn — ask before you start the program",
    }},
})

NewTool fails on a name appearing twice across Bindings and Blocked. The engine keeps the first binding under a name and drops the rest, so a duplicate would silently decide which tool a program reaches, and a name in both lists would have the description call it uncallable while the binding underneath still ran.

tool gives you Name(), Description(), Parameters() and Call(ctx, argsJSON), which is what a tool-calling loop needs. Prompt returns the matching system-prompt section:

systemPrompt += "\n\n" + codemode.Prompt(codemode.PromptOptions{})

The description says what the tool is and which tools a program may call. The prompt section covers how to write one: that a call resolves to the same envelope a direct call returns, that a failed call rejects with a catchable error, what runs in parallel, and that only the return value and console output come back.

Blocked tools

Blocked names a tool the model has in its tool list but cannot call from inside a program. Blocked names appear in the generated description's "minus" list, and a program that calls one gets the Reason back as that call's error rather than an "unknown tool".

Watching a run

OnCall fires around every sub-call and OnProgram once per run, before the program starts:

codemode.Options{
    Bindings: bindings,
    OnCall: func(ctx context.Context, ev codemode.CallEvent) {
        // ev.Seq, ev.Tool, ev.Args, ev.Phase, ev.Duration, ev.Err
        ui.Push(ctx, ev)
    },
    OnProgram: func(ctx context.Context, code, description string) {
        ui.Label(ctx, description) // "Vet 20 candidates and score fit"
    },
}

Both run inline on the goroutine making the call, so a slow observer slows the program down. Events carry the raw arguments, not a digest.

Return-shape hints

Tool protocols ship the input schema and say nothing about the output, so a program navigating r.data.hits is working from a guess. ReturnShape derives a compact hint from a Go result type, meant for the tail of a tool description:

desc += "\n\nReturns `{data: " + codemode.ReturnShape[SearchResult]() + "}`."
// Returns `{data: {query, hits: [{path, line: num, snippet}], truncated: bool, cursor?}}`.

A bare name is a string, other types are annotated, ? marks an omitempty field. ReturnShapes: true in PromptOptions adds the matching notation guide to the prompt section; with it false the section says nothing about return shapes.

Adapters

Separate modules, so the core stays at one dependency.

eino
go get github.com/gtoxlili/codemode-go/adapters/eino
bindings, err := einocodemode.Bindings(ctx, myTools)
ct, err := codemode.NewTool(codemode.Options{Bindings: bindings})
myTools = append(myTools, einocodemode.NewTool(ct))

eino tools carry no notion of writing or of which resource they touch, so the bindings come back with Mutating false and ConflictKeys nil and every call schedules as conflict-free. The returned slice is plain and can be edited before it is passed on.

MCP
go get github.com/gtoxlili/codemode-go/adapters/mcp

An agent's tools do not all have to be its own. If some of them come from MCP servers your runtime dials, this binds those too:

discovered, err := mcpcodemode.Tools(ctx, mcpClient)
bindings = append(bindings, mcpcodemode.Bindings(discovered)...)

That is the ordinary wiring, and it stays additive: the MCP tools are in the model's tool list, still directly callable, and now also reachable from a program.

There is a second wiring for the case where sixty MCP tools in the tool list cost sixty schemas per request and most of them are cold. Take them out of the tool list, and hand the model Catalog(discovered) in the program tool's description instead — a text listing costs far less than sixty tool definitions, at the price of those tools no longer being directly callable:

ct, err := codemode.NewTool(codemode.Options{
    Bindings:    bindings,
    Description: intro + "\n\n" + mcpcodemode.Catalog(discovered),
})

An overriding description is needed there because the generated one states that a program can call the tools in the model's tool list, which stops being true once they are out of it.

Works with mark3labs/mcp-go clients over any transport: stdio, SSE, streamable HTTP, in-process.

How it compares

Code mode implementations get compared as one group when they sit at different levels. The level decides whether a project is an alternative to this one or a different thing entirely.

A library you embed, projecting the host's own tools. Same abstraction boundary as this: you hand it a name, a schema and a handler, and it hands the model a programmable API. Your agent loop stays yours.

Project Host language Model writes Runs in Tools arrive as
codemode-go Go JavaScript goja, in-process Binding{Name, Invoke}tools.name(args)
tool-sandbox TypeScript JavaScript WASM Tool{name, inputSchema, handler}tool(name, args)

An agent framework or runtime with code mode built in. Same idea, but it owns the loop: adopting one means adopting its agent model.

Project Host language Model writes Runs in
deepseek-harness Code Mode TypeScript TypeScript one fresh Node worker per run
Microsoft Agent Framework CodeAct Python, .NET Python Hyperlight, backend left pluggable
smolagents CodeAgent Python Python restricted interpreter, or E2B / Docker
strands-code-agent Python Python persistent REPL, three backends

The tables are not a survey. They are the projects at the same abstraction boundary, which is the only place a comparison means anything. Standalone servers that sit in front of a pile of MCP servers answer a different question and are not alternatives to this one; see the FAQ for why the shapes do not swap.

What it is good at. Pure Go, one process, no protocol boundary between the program and your tools, and nothing imposed on your agent loop. Of everything above, it is the only one that schedules on declared resource conflicts rather than just letting the calls run: Mutating and ConflictKeys are how a host says this call writes file X, and that is knowledge only the host has. What else comes in the box: the system-prompt section, a failure taxonomy phrased for the model to correct from, and hooks around every sub-call. A run costs one goja VM to start, which is microseconds, so a program that makes two calls is not a losing trade.

What it is not good at. It is not a security boundary — see below, and if you need one, the WASM and container-backed options above are genuinely stronger. The model writes JavaScript, not Go, so your tools are reachable only through the bindings you pass; there is no way to hand a program a Go value directly. There is no typed SDK generation, so the model works from your tool descriptions rather than from TypeScript types with autocomplete — ReturnShape narrows that gap but does not close it. And a program shares the host process's memory, which is why the memory limit is a trip line with a documented margin of error rather than a hard ceiling.

What a program can see

Name What it is
tools tools.name(args) returns a promise resolving to that tool's result, parsed if it is JSON
ToolCallError what a failed call rejects with, carrying .toolName; catch it and continue
console log/info/warn/error/debug, all into one log channel
sleep(ms) the only way to wait; the setTimeout family does not exist

Plus the JavaScript built-ins. No filesystem, no network, no require, no process, no fetch. Every external effect goes through a binding you passed in.

Capability omission is the real boundary here. The rest, stated plainly: this is not a security sandbox. The program runs in-process on goja, which 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 those fire, not how soon. An allocation bomb crosses the memory line almost at once — a doubling string dies in about 200ms under the default 256MB — while a loop that only computes runs until its compute budget is gone, two minutes by default. Both of those are what a model writes by accident. Neither stops someone who controls the program and is trying.

Limits

Limit Default Notes
Wall clock 10 min Interrupt, then a 5s grace period, then torn down
Compute budget 2 min only counts time actually running JS — waiting on tools does not burn it
Memory 256 MB heap growth over the run's baseline, re-checked after a forced GC before killing anything
Accumulated results 64 MB exact, counted as each result arrives
Call depth 8192
Parallel sub-calls 8 the pool a Promise.all gets
Sub-calls per run 200
Console output 200KB / 2000 lines crossing it fails the run and keeps what was collected

All overridable through Options.Limits. Zero means "use the default", which is right for a partially filled struct and wrong for values that came out of a config file, where a wall_clock of zero silently becomes ten minutes; Validate() is there to report those.

The compute budget and the wall clock differ in what they count. A fan-out over twenty slow APIs can run for minutes without burning any compute budget, because none of that time is spent running JavaScript. A while (true) {} burns it in two minutes flat, long before the wall clock. Slow tools and runaway programs are different problems and get different limits.

Failures

A failed run reports a kind, and each kind maps to one thing the model can do about it. The message is written to be read by the model, with the tail of whatever the program printed attached.

Kind What to do
exception fix the syntax or the logic
timeout narrow the loop, make fewer calls
compute-limit compute less — this is not about slow tools
memory-limit stop building unbounded strings and arrays
result-limit select or aggregate in code instead of holding everything
output-limit print less, return more
invalid-return return plain JSON-serializable data
too-many-calls nothing; the run was stopped on purpose
aborted nothing; the caller canceled

A sub-call that fails does not fail the run: it rejects with a ToolCallError the program can catch, so one dead source does not throw away the work already done. A call fired without await still runs, and if it fails its rejection shows up as an [unhandled rejection] log line rather than evaporating.

Scheduling

Calls start in the order the program made them. Ordinary calls go into a pool of MaxParallel. A call that shares a ConflictKey with an outstanding one, where at least one of the two mutates, waits for the pool to drain and runs alone.

Keys come from the tool, not the runtime — the scheduler does not know any tool's parameter schema, so the tool is the only thing that can say what a given call touches. A constant works for a shared backend, "deck:" + id for a resource id, an absolute path for a file. Keys are compared as strings, so out/a.jpg and ./out/a.jpg are two keys and the collision between them goes unseen.

Conflicts are pruned as soon as a call finishes, since two calls can only conflict while they overlap. Without that, writing a digest and then reading a batch of files back — including the one just written — would serialize the whole read fan-out behind a write that is already done.

FAQ

Is this an MCP server I can add to Claude Code?

No. It is a library for the agent you are building, not a server you register with someone else's client.

That form does exist elsewhere, and it is structurally limited in a way worth knowing about: MCP is asymmetric. A server exposes tools; a client exposes roots, sampling and elicitation. Nothing in the protocol lets a server call back into the client, so a gateway can never touch Claude Code's Read, Edit or Bash — only the upstream servers it dials itself. Getting value from one means moving your MCP servers behind it and giving up calling them directly. That is a reasonable trade when a pile of MCP tools is your whole problem, and the wrong shape for making a runtime's own primitives programmable.

Do my tools leave the model's tool list?

No. Mounting this adds one tool and changes nothing else; every tool the model had stays mounted and directly callable, and the program tool simply says that those same tools are also reachable from a program. Taking tools out of the tool list is a separate thing you can choose to do with the MCP adapter's Catalog, and it is a trade — cheaper per turn, no longer directly callable.

Is this a security sandbox?

No. Capability omission is real — a program has no filesystem, network, or imports, and every effect goes through a binding that was passed in. The resource limits are a different story: they are sampled trip lines that catch what a model writes by accident, not a boundary against an adversary.

Why JavaScript instead of Go?

Because the model writes it and goja runs it in-process with no cgo, no subprocess, and no container. Compiling and running model-written Go means a toolchain, a build step, and real isolation work before anything runs at all. Models also write far more JavaScript than they write Go, which shows up directly in how often the program is correct on the first attempt.

Does this replace normal tool calling?

No. It is designed to coexist with direct tool calls: most turns are one or two calls, where a program is pure overhead, and the tool description describes when a program is worth it so the model can choose.

How much context does it actually save?

It depends entirely on how much data your tools return and how much of it the answer needs. The saving is the part the program throws away — fan out over fifty results and return five, and the other forty-five never enter the conversation. Anthropic's writeup measures one such case at 150,000 tokens down to 2,000. A program that fetches one thing and returns it saves nothing.

Do I need MCP?

No. MCP is one source of tools, and there is an adapter for it. A Binding is just a name and a func(ctx, argsJSON) (string, error), so anything you can call from Go can be a tool.

Which Go agent frameworks does this work with?

Any of them, and none of them. The core produces a name, a description, a JSON Schema and a Call — wire those into whatever loop you have. There is a ready-made adapter for eino and one for MCP clients. A hand-written loop over the OpenAI or Anthropic SDK takes about five lines.

Do I need Node, Deno, or Docker?

No. goja is a JavaScript engine written in pure Go. The program runs inside your process.

What happens if the model writes an infinite loop?

It dies in about two minutes, on the compute budget, well before the wall clock — and the model gets back a compute-limit failure telling it to compute less, along with whatever the program printed before it hung. try/catch cannot swallow the interrupt; there is a test for that.

Can a program call another program?

No. The tool excludes itself from its own bindings, and its name is the first entry in the description's "minus" list, so the model is told as much.

Further reading

Status

v0.x. The engine is stable and in production; the surface around it may still move. Issues and PRs welcome.

MIT.

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

Examples

Constants

View Source
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

func ReturnShape[T any]() string

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

func ReturnShapeOf(t reflect.Type) string

ReturnShapeOf is ReturnShape for a type you have at runtime.

func Run

func Run(ctx context.Context, code string, bindings []Binding, limits Limits) (Result, *Failure)

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

func TailLogs(lines []string, budget int) string

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

func WithCallEvents(bindings []Binding, on func(ctx context.Context, ev CallEvent)) []Binding

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

type Blocked struct {
	Name   string
	Reason string
}

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.

func (*Failure) Error

func (f *Failure) Error() string

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.

func (Limits) Validate

func (l Limits) Validate() error

Validate reports the first non-positive field. Run treats those as "use the default", which is right for a partially filled struct and wrong for values that came out of a config file, where a wall_clock of "0s" silently becomes ten minutes.

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 Phase

type Phase string

Phase is the stage of a sub-call an observer is being told about.

const (
	PhaseStart Phase = "start"
	PhaseDone  Phase = "done"
	PhaseError Phase = "error"
)

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

func NewTool(opts Options) (*Tool, error)

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

func (t *Tool) Call(ctx context.Context, argsJSON string) (string, error)

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 (t *Tool) Description() string

func (*Tool) Name

func (t *Tool) Name() string

func (*Tool) Parameters

func (t *Tool) Parameters() map[string]any

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.

func (*Tool) Run

func (t *Tool) Run(ctx context.Context, code string) (Result, *Failure)

Run executes a program against this tool's bindings and limits, waiting for a concurrency slot first, and returns the structured Result and Failure rather than the wire form Tool.Call produces.

Directories

Path Synopsis
adapters
mcp module

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL