Code Exec Tool (Programmatic Tool Calling)
Lets the LLM orchestrate multiple tool calls in a single turn by writing a short Go script. The script runs in an embedded Yaegi interpreter and dispatches inner tool calls through the real event bus, so every existing gate still fires.
Details
| ID | nexus.tool.code_exec |
| Tool Name | run_code |
| Dependencies | None (uses current tool registry at invocation time) |
Configuration
| Key | Type | Default | Description |
|---|---|---|---|
timeout_seconds | int | 30 | Wall-clock limit for a script, propagated via context.Context |
max_output_bytes | int | 65536 | Stdout/stderr cap per script; excess is silently dropped and truncated=true is returned |
max_workers | int | runtime.NumCPU() | Concurrency ceiling for the parallel.* primitives — shared across Map/ForEach/All within a single script invocation |
allowed_packages | string[] | see below | Go stdlib whitelist. Default covers pure compute: fmt, strings, strconv, bytes, regexp, unicode*, encoding/* (json/base64/hex/csv/xml/pem/binary), crypto/* (sha/md5/hmac/rand/subtle), math, math/big, math/rand, math/rand/v2, math/bits, sort, container/* (heap/list/ring), hash/* (crc32/crc64/fnv/adler32), sync, sync/atomic, errors, time, context, io, bufio. Omitted: anything touching filesystem, network, OS processes, reflection, unsafe memory. Also omitted: slices/maps (Yaegi lacks full generics support). |
persist_scripts | bool | true | Write script.go, stdout.txt, result.json, error.txt to the session workspace |
reject_goroutines | bool | true | Reject scripts containing go statements at the AST layer |
Script Contract
The LLM passes a script argument containing a complete Go source file:
package main
import (
"context"
"fmt"
"tools"
)
func Run(ctx context.Context) (any, error) {
r, err := tools.Shell(tools.ShellArgs{Command: "ls"})
if err != nil {
return nil, err
}
fmt.Println("found:", r.Output)
return map[string]string{"listing": r.Output}, nil
}
Hard rules enforced before Yaegi ever sees the source:
- Package must be
main. - Must declare
func Run(ctx context.Context) (any, error)exactly. - No
gostatements (phase 1). - Imports restricted to
allowed_packagesplustools,parallel, andskills/<name>for each currently-active skill.
Violations surface as a structured error in the tool result. The script never executes.
Typed Tool Bindings
At every run_code invocation the plugin snapshots the current tool registry and builds a fresh tools package for Yaegi:
- JSON Schema types map to Go:
string→string,integer→int64,number→float64,boolean→bool,array→[]T,object→struct. - Each tool
foo_barbecomestools.FooBar(args tools.FooBarArgs) (<Result>, error). - Return type depends on whether the tool declared
OutputSchema:- With schema →
tools.FooBarResult, a struct generated from the schema. Fields are populated fromToolResult.OutputStructured(preferred) or parsed from JSON inOutputas a fallback. - Without schema → the fixed
tools.Resultstruct ({Output, Error, OutputFile string}). Scripts parseOutputthemselves.
- With schema →
tools.Resultis always exported so helper functions can handle both shapes.- Gate vetoes (
before:tool.invoke) surface as a Goerroron thetools.*call. - Outer
run_codecall is excluded from the binding — scripts cannot recursively invoke themselves.
Declaring an OutputSchema
Tool plugins opt in by adding OutputSchema to their ToolDef and populating ToolResult.OutputStructured:
_ = p.bus.Emit("tool.register", events.ToolDef{
Name: "shell",
// ...
OutputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"stdout": map[string]any{"type": "string"},
"stderr": map[string]any{"type": "string"},
"exit_code": map[string]any{"type": "integer"},
},
"required": []string{"stdout", "stderr", "exit_code"},
},
})
// ...then in the tool's handler:
result := events.ToolResult{
ID: tc.ID,
Name: tc.Name,
Output: humanReadableSummary,
OutputStructured: map[string]any{
"stdout": stdoutStr,
"stderr": stderrStr,
"exit_code": exitCode,
},
// ...
}
Scripts then see the typed shape:
r, err := tools.Shell(tools.ShellArgs{Command: "ls"})
if err != nil {
return nil, err
}
fmt.Println(r.Stdout, r.Stderr, r.ExitCode)
The existing Output string still flows through the bus unchanged — non-script consumers (LLM conversation history, logging, etc.) see the same human-readable text as before. Tools without schemas continue to work as they always did.
Skill Helpers
Skills may ship .go files alongside SKILL.md. On skill.loaded the plugin reads every non-test .go in the skill dir, rewrites the package declaration to a sanitised name, and stages the result into a per-invocation GOPATH. Scripts import the package as skills/<skill_name>:
import helpers "skills/math-helpers"
func Run(ctx context.Context) (any, error) {
return helpers.Double(21), nil
}
Skills are loaded on skill.loaded and removed on skill.deactivate. Cross-skill imports are not supported in phase 1.
Parallel Primitives
Scripts can parallelize work (tool fan-out or pure compute) via the parallel package. A host-side worker pool bounded by max_workers backs all three primitives; they share the same pool within a single run_code call.
import (
"context"
"parallel"
"tools"
)
func Run(ctx context.Context) (any, error) {
urls := []string{"https://a.example", "https://b.example", "https://c.example"}
// Map: ordered results, first-error-cancels-the-rest.
results, err := parallel.Map(ctx, urls, func(ctx context.Context, u string) (string, error) {
r, err := tools.Fetch(tools.FetchArgs{URL: u})
if err != nil { return "", err }
return r.Body, nil
})
if err != nil { return nil, err }
// results is `any` (Yaegi has no generics); cast to the element slice type.
bodies := results.([]string)
return bodies, nil
}
Semantics (all three):
- First non-nil error wins, cancels the derived
context.Context, waits for in-flight callbacks to observe cancellation, then returns the wrapped error. Mappreserves input order in the output slice;ForEachdiscards results but otherwise identical;Allruns N heterogeneousfunc(ctx context.Context) errorvalues.- Worker pool size =
max_workers(defaultruntime.NumCPU()). Scripts never spawn goroutines directly — thegokeyword is still rejected at the AST layer. - Callback panics are recovered and surface as an error on the outer call.
Expected signatures:
| Primitive | Callback shape |
|---|---|
parallel.Map(ctx, items, fn) | func(ctx context.Context, item T) (R, error) |
parallel.ForEach(ctx, items, fn) | func(ctx context.Context, item T) error |
parallel.All(ctx, fns...) | func(ctx context.Context) error |
Caveat: tools invoked from inside parallel callbacks execute concurrently on the bus. Host tool plugins must tolerate concurrent tool.invoke delivery. The built-in Nexus tools (shell, file, etc.) already do since they rely on OS-level isolation or hold no shared mutable state.
Events
Subscribes To
| Event | Priority | Purpose |
|---|---|---|
tool.invoke | 50 | Handles the outer run_code call |
tool.result | 50 | Routes inner tool results back to the waiting script |
tool.register | 50 | Builds the type catalogue used to generate tools.* bindings |
skill.loaded | 50 | Scans and stages skill helper source files |
skill.deactivate | 50 | Removes skill helpers from the active set |
Emits
| Event | When |
|---|---|
tool.register | Registers the run_code tool at boot |
before:tool.invoke / tool.invoke | For every inner tool call dispatched by a script |
before:tool.result / tool.result | For the outer run_code call |
code.exec.request | Just before script execution (carries the raw script + imports + active skills) |
code.exec.stdout | For every flushed stdout chunk while the script runs; the final chunk has Final=true and carries the truncation flag if applicable |
code.exec.result | When the script has finished, errored out, or timed out — always arrives after the final code.exec.stdout chunk for the same CallID |
Stdout Streaming
The interpreter’s stdout and stderr are wired to a chunking writer that emits code.exec.stdout events while the script is still running. Flush triggers:
- Any newline in the pending buffer — everything up to the newline flushes.
- Pending buffer crosses a 512-byte threshold — forces out long lines that would otherwise wait for a newline.
- Script finish — any residual tail is flushed as the
Finalchunk.
The aggregated Output field on the terminal code.exec.result still contains the full stdout (capped at max_output_bytes), so non-streaming consumers keep working without changes. IO plugins that want live output subscribe to code.exec.stdout instead.
Sandboxing Layers
Defense in depth; each layer enforces a different concern:
- Import allowlist — AST-level rejection of any import not on
allowed_packages∪{tools}∪ activeskills/<name>. - AST
go-stmt rejection — scripts cannot spawn goroutines. - Wall-clock timeout — script runs under
context.WithTimeout;tools.*shims observe cancellation. - Stdout byte cap — capped writer drops excess and flags truncation.
- No heap/CPU cap — documented limitation; operators rely on OS-level process limits if a stronger guarantee is required.
Session Persistence
When persist_scripts is enabled each call writes to plugins/nexus.tool.code_exec/<callID>/:
plugins/nexus.tool.code_exec/<callID>/
script.go # exact source the LLM submitted
stdout.txt # captured stdout (capped)
result.json # JSON-marshaled Run() return value
error.txt # only present if the script failed
Example Config
plugins:
active:
- nexus.tool.shell
- nexus.tool.file
- nexus.tool.code_exec
nexus.tool.code_exec:
timeout_seconds: 30
max_output_bytes: 65536
max_workers: 8
persist_scripts: true
reject_goroutines: true
Non-Goals (phase 1)
- Goroutines /
gokeyword - Provider-native programmatic tool calling (Anthropic
allowed_callers) - Cross-skill imports
- CPU / memory resource limits beyond the wall-clock timeout
- Script REPL or debugger