codemode

package module
v0.1.0 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

Code mode for Go agents. The model writes one JavaScript program that calls your tools, instead of one tool call per turn.

中文文档

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.

What is code mode?

Code mode gives the model one tool that runs a program, and exposes the rest of your tools as an API that program can call. The model writes await tools.search({query}) instead of emitting a tool call and waiting a turn for the answer.

Two things follow. Independent calls 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 intermediate results stay in the process — the model sees what the program returns, not the fifty search hits it looked at to produce it.

The idea goes by several names. Anthropic calls it code execution with MCP, Cloudflare calls it Code Mode, the CodeAct paper calls it code-as-action. Same shape: replace JSON tool calls with a program.

This library is the Go implementation. Pulled out of an agent that has been running it in production.

// one tool call, twenty candidates, one digest back
const handles = await tools.list_candidates({campaign: "spring"});
const profiles = await Promise.all(handles.data.map(h => tools.fetch_profile({handle: h})));
const fit = profiles
  .filter(p => p.data.followers > 10000)
  .map(p => ({handle: p.data.handle, ratio: p.data.engagement / p.data.followers}))
  .sort((a, b) => b.ratio - a.ratio)
  .slice(0, 5);
return fit;

The same work through direct tool calls is twenty-one calls across at least three turns, and every intermediate profile lands in the conversation and stays there for the rest of the session.

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 module depends on goja and nothing else. No cgo, no Node, no Deno, no container.

Use

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

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

bindings := []codemode.Binding{{
    Name:   "search_files",
    Invoke: searchFiles, // func(ctx context.Context, argsJSON string) (string, error)
}, {
    Name:         "write_file",
    Invoke:       writeFile,
    Mutating:     true,
    ConflictKeys: func(args string) []string { return []string{"file:" + pathOf(args)} },
}}

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

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 := 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
discovered, err := mcpcodemode.Tools(ctx, mcpClient)
ct := codemode.NewTool(codemode.Options{Bindings: mcpcodemode.Bindings(discovered)})

That form assumes the server's tools are already in the model's tool list, where sixty of them cost sixty schemas per request. The second form keeps them out of it and puts the catalog in the program tool's description instead, for a per-turn cost of one description:

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

It supplies its own description because the generated one states that a program can call the tools in the model's tool list, which no longer holds 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 is an idea with implementations in several ecosystems. Where they differ is what language the model writes, what runs it, and how much of the surrounding machinery you have to build yourself.

Project Model writes Runs in Your host is Isolation
codemode-go JavaScript goja, in-process Go capability omission + sampled limits
Cloudflare Code Mode TypeScript V8 isolate on Workers TypeScript / Workers V8 isolate, no network
Anthropic code execution with MCP TypeScript your choice — it is a pattern, not a library anything yours to build
UTCP code-mode TypeScript / Python Node vm TypeScript, Python Node vm context
Edison-Watch/mcp-code-mode TypeScript Deno Python Deno permissions + AST allowlist
smolagents CodeAgent Python restricted interpreter, or E2B / Docker Python depends on the executor you pick
langchain-sandbox Python Pyodide in Deno Python Deno permissions
pydantic/mcp-run-python Python Pyodide in Deno, as an MCP server anything Deno permissions
Protocol-Lattice/go-agent Go its UTCP runtime Go (that framework) requires opting into unsafe tools

What this one is good at. It is a library, not a framework and not a runtime. There is no Node, Deno, Docker, or cloud platform underneath it — one Go dependency, one process, and the same wiring whether the agent loop is hand-rolled, eino, or an MCP client. What comes in the box beyond the engine: the system-prompt section, a scheduler that serializes calls touching the same resource, 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 Deno and V8-isolate options above are genuinely stronger. The model writes JavaScript, not Go, so your Go 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. The wall clock, the compute budget and the memory trip line are enforced by a 50ms sampler plus goja's Interrupt. They catch hot loops and allocation bombs in well under a second, which is what a model produces by accident. They do not stop 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", so Validate() exists for values that came out of a config file — a wall_clock accidentally set to zero would otherwise silently become ten minutes.

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 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 because that is where the savings are largest. 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.

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 enforced by a 50ms sampler and goja's Interrupt. They catch hot loops and allocation bombs in well under a second. They do not make 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.

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.
	//
	// nil means the call never conflicts with anything and always runs in the
	// parallel pool. 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

	// CPUBudget only accumulates while the program is running JavaScript — time
	// spent waiting on tool calls or sleep does not count. A fan-out over
	// twenty slow APIs does not burn it; a `while (true) {}` burns it in
	// seconds, long before the wall clock.
	CPUBudget 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. Required.
	Bindings []Binding

	// Blocked are tools the model has 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

NewTool assembles the tool. The generated description enumerates Bindings and Blocked as they are at this moment, so it is a snapshot: a tool added to the model's tool list afterwards is not in it.

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 := 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",
		}},
	})

	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