Documentation
¶
Overview ¶
Package sandbox runs typed TypeScript skills in goja with the gridctl-shaped agent bindings (tool, llm, parallel, handoff, approval) injected as globals. It is a thin layer over the existing pkg/mcp Code Mode infrastructure: transpile via esbuild, execute on a single-shot goja runtime owned by an event loop, deliver async results through Promises that loop.RunOnLoop schedules back onto the event loop thread.
Recursive composability is the design constraint that shapes this package: tool() flows through a gridctl-shaped ToolCaller (which is almost always a *mcp.Gateway adapter), so a TS skill calling a tool goes through the gateway's existing tracing, pricing, replica routing, vault auth, and tool whitelisting paths. handoff() routes to the same skill registry the gateway exposes as MCP tools — the "skill calls another skill" path is the same bytes-on-the-wire path an upstream MCP client would take, just short-circuited in-process.
Phase C scope: bindings work; sandboxing is a single-call event loop; the approval() binding is a stub that auto-approves (the real approval gates land in Phase E). Hot-reload is Phase F. JSONL persistence is Phase E.
Index ¶
- Constants
- func TranspileTS(source string) (string, error)
- type ApprovalDecision
- type Approver
- type Bindings
- type BindingsProvider
- type Dispatcher
- type Result
- type RunSession
- func (s *RunSession) NextNodeID(kind, name string) string
- func (s *RunSession) RecordApprovalRequest(approvalID, prompt string)
- func (s *RunSession) RecordApprovalResponse(approvalID string, approved bool, reason, source string)
- func (s *RunSession) RecordError(nodeID, message string)
- func (s *RunSession) RecordLLMCall(model, provider string, ...)
- func (s *RunSession) RecordNodeEnter(nodeID, nodeName string)
- func (s *RunSession) RecordNodeExit(nodeID string, durationMicros int64, success bool)
- func (s *RunSession) RecordToolCall(nodeID, callID, name string, args map[string]any)
- func (s *RunSession) RecordToolResult(nodeID, callID, output string, isError bool)
- type Sandbox
- type SkillCaller
- type SourceLoader
Constants ¶
const DefaultMaxParallel = 4
DefaultMaxParallel is the soft cap on parallel() concurrency. The hard cap and orchestrator-level coordination land in Phase D; this keeps a runaway parallel() from spawning thousands of goroutines.
const DefaultTimeout = 60 * time.Second
DefaultTimeout bounds a single skill invocation. Skills with their own deadlines should set ctx.WithTimeout before calling Execute.
const MaxSourceSize = 256 * 1024
MaxSourceSize caps the transpiled-source length the sandbox will run. Skills are expected to be small; very large bundles point at a missing import-bundling step that should land before sandbox execution, not be papered over here.
Variables ¶
This section is empty.
Functions ¶
func TranspileTS ¶
TranspileTS is the exported wrapper around the sandbox's TS transpile path. The compiled output is the same CommonJS shape the sandbox itself runs, so a CLI consumer (`gridctl agent build`) can pre-compile a skill and produce an artifact byte-equivalent to what the runtime would produce on first invocation.
Types ¶
type ApprovalDecision ¶
type ApprovalDecision struct {
// Approved reports the gate decision. False rejects the request;
// the JS caller sees `{ approved: false, reason }` and can branch.
Approved bool
// Reason is a free-form note. Authors typically include it in the
// next prompt or log it for audit.
Reason string
}
ApprovalDecision is what an Approver returns. The string is surfaced back to the JS caller verbatim so authors can pattern-match on it.
type Approver ¶
type Approver interface {
Approve(ctx context.Context, prompt string) (ApprovalDecision, error)
}
Approver decides an approval gate. Phase C ships a stub auto-approver when no real Approver is wired; Phase E replaces it with the CLI/web/MCP-backed gate.
type Bindings ¶
type Bindings struct {
// ToolCaller dispatches tool() calls. The runtime gives this an
// adapter over *mcp.Gateway so MCP tracing, pricing, replica
// routing, vault auth, and tool whitelisting all apply.
ToolCaller agent.ToolCaller
// AllowedTools is the ACL the tool() binding consults before
// dispatching. Each tool's Name is the prefixed form
// (server__tool); if the binding receives an unprefixed name and
// only one tool with that suffix is allowed, the binding allows
// it. Empty AllowedTools disables tool() entirely.
AllowedTools []mcp.Tool
// ChatModel is the LLM provider the llm() binding dispatches to.
// nil disables the binding.
ChatModel agent.ChatModel
// SkillCaller is the dispatcher handoff() uses to invoke another
// skill. The runtime wires this to the registry's CallTool path
// so a skill-to-skill handoff and an upstream MCP client calling
// the same skill share one code path. nil disables the binding.
SkillCaller SkillCaller
// Approver is invoked by approval(). nil auto-approves with the
// prompt as the response — Phase E replaces the stub with a real
// gate.
Approver Approver
// MaxParallel caps parallel() concurrency. Zero means
// DefaultMaxParallel.
MaxParallel int
// SkillBody is the post-frontmatter markdown the registry parsed
// from this invocation's SKILL.md. The runtime exposes it to the
// JS sandbox as `skill.body` so TS skills can drive the same
// hybrid pattern Go skills hit through ctx.SkillBody() — feed
// per-skill prose into an llm() call's `system` field without
// hardcoding it in the handler. Empty string for skills with no
// body and for harness paths (tests) that don't plumb one through.
SkillBody string
// SkillName is the registered skill name. Exposed as `skill.name`
// in the JS sandbox for parity with Go's ctx.SkillName().
SkillName string
// Session, when non-nil, is the per-invocation telemetry handle
// the bindings emit through. The runtime wires this to the
// parent run's recorder so external MCP clients firing tools/call
// against a typed skill see the same per-node trace the in-IDE
// Run Launcher produces. A nil Session disables emission — the
// bindings still function, the ledger just records the run's
// terminal boundaries only.
Session *RunSession
}
Bindings are the runtime-supplied collaborators the sandbox injects as JS globals. Every field is optional: bindings that go unset are either omitted from the global scope (tool, llm, handoff) or stubbed (approval auto-approves when ApprovalDecider is nil). Skills that touch a missing binding raise a JS error at call time.
type BindingsProvider ¶
BindingsProvider returns the Bindings the dispatcher uses for one invocation. The closure is called per Dispatch so a long-lived dispatcher can hand each call request-scoped collaborators (a fresh ToolCaller, the current ChatModel, the run's Approver) without caching state across calls.
type Dispatcher ¶
type Dispatcher struct {
// contains filtered or unexported fields
}
Dispatcher implements registry.TSDispatcher: each Dispatch reads the TS skill source from disk and runs it through the sandbox using the per-call Bindings. The struct is constructed once at gateway-build time and registered on the registry server via SetTSDispatcher; all per-call collaborators flow through the BindingsProvider closure.
func NewDispatcher ¶
func NewDispatcher(sb *Sandbox, bp BindingsProvider) (*Dispatcher, error)
NewDispatcher constructs a Dispatcher. Passing a nil sandbox is rejected — the caller is expected to construct a Sandbox with the timeout it wants enforced for skill execution. A nil bindings provider is allowed; calls then run with empty Bindings (no tool(), no llm(), etc.) and any binding access from the skill raises a JS error at call time.
func (*Dispatcher) Dispatch ¶
func (d *Dispatcher) Dispatch(ctx context.Context, name, sourcePath string, arguments map[string]any) (*mcp.ToolCallResult, error)
Dispatch runs `name`'s TS source through the sandbox and wraps the returned value in an mcp.ToolCallResult shaped the way a typed-skill MCP tool reply does. The marshaling matches NewInvoker's so the bytes-on-the-wire are the same whether the gateway calls the skill via the dispatcher (external MCP clients) or via the typed-skill registry (handoff() inside another skill).
type Result ¶
type Result struct {
// Value is the skill's resolved return value, encoded as JSON. An
// empty string means the skill returned undefined or null.
Value string
// Console is one captured line per console.log/warn/error call,
// in invocation order.
Console []string
}
Result is what Execute returns to the caller. Value is the JSON encoding of the skill's resolved return value (a Promise for async skills); Console captures every console.log/warn/error line.
type RunSession ¶
type RunSession struct {
// Recorder is the parent run's ledger writer. Bindings call
// Record* helpers below; those helpers route every write through
// this recorder's existing mutex so events from concurrent
// goroutines don't interleave bytes mid-line.
Recorder *persist.Recorder
// contains filtered or unexported fields
}
RunSession is the per-invocation telemetry handle the sandbox bindings emit through. It carries the parent run's recorder (opened by runner.Run) plus a monotonic node counter so each top-level binding call (tool, llm, handoff, approval) can be addressed as a stable "node" in the run's event timeline.
A nil *RunSession is the no-recording mode every accessor recognises — the in-process compose tests and the registry's NewInvoker harness run without a recorder wired, and the bindings stay quiet rather than panicking. Production dispatches (gateway → runner → sandbox) always carry a session.
func NewRunSession ¶
func NewRunSession(rec *persist.Recorder) *RunSession
NewRunSession constructs a session bound to the given recorder. A nil recorder yields a session whose Record* methods are no-ops — callers don't need to special-case test or no-persistence paths.
func (*RunSession) NextNodeID ¶
func (s *RunSession) NextNodeID(kind, name string) string
NextNodeID returns a "<kind>:<name>#<n>" id for the next top-level binding call. The id pairs the NodeEnter and NodeExit events the caller emits around its work and gives the inspector a stable key for per-call decoration even when the same tool is called twice.
func (*RunSession) RecordApprovalRequest ¶
func (s *RunSession) RecordApprovalRequest(approvalID, prompt string)
RecordApprovalRequest captures the approval() binding's gate-open event. The matching EventApprovalResponse is written from the same binding once the Approver replies; the pair lets the inspector show the suspension and the resume cleanly.
func (*RunSession) RecordApprovalResponse ¶
func (s *RunSession) RecordApprovalResponse(approvalID string, approved bool, reason, source string)
RecordApprovalResponse captures the Approver's reply. Source is "auto" for the stub auto-approver, otherwise whatever the gate surface reports ("cli", "web", "mcp").
func (*RunSession) RecordError ¶
func (s *RunSession) RecordError(nodeID, message string)
RecordError writes a mid-run error (a binding goroutine surfacing a failure that does not abort the run). The terminal error path is the runner's recordFailure helper — bindings only write here.
func (*RunSession) RecordLLMCall ¶
func (s *RunSession) RecordLLMCall(model, provider string, promptTokens, outputTokens, cacheReadTokens, cacheWriteTokens int, costUSD float64)
RecordLLMCall captures the llm() binding's post-dispatch event. Token counts and cost arrive only after the provider replies, so the event is emitted once on completion — not split across a request/response pair like tool calls. EventLLMChunk for streaming fragments is deliberately out of scope for this slice (the ledger would balloon on long generations) and lands in a follow-on.
func (*RunSession) RecordNodeEnter ¶
func (s *RunSession) RecordNodeEnter(nodeID, nodeName string)
RecordNodeEnter writes an EventNodeEnter for the given binding invocation. NodeName carries a human-friendly label ("tool", "llm", etc.); the id is whatever NextNodeID produced. A nil session is a silent no-op so test wiring without a recorder still passes.
func (*RunSession) RecordNodeExit ¶
func (s *RunSession) RecordNodeExit(nodeID string, durationMicros int64, success bool)
RecordNodeExit writes the matching EventNodeExit. DurationMicros is the wall-clock time the binding held — typically computed as time.Since(start).Microseconds() at the boundary. Success=false signals a failure; callers typically also emit EventError for the propagated message.
func (*RunSession) RecordToolCall ¶
func (s *RunSession) RecordToolCall(nodeID, callID, name string, args map[string]any)
RecordToolCall captures the tool() binding's pre-dispatch event. Arguments are size-capped via persist.CapRawJSON so a tool fed a multi-megabyte blob can't bloat the ledger; the cap flag is mirrored into the payload so consumers know the value is a prefix, not the full picture.
func (*RunSession) RecordToolResult ¶
func (s *RunSession) RecordToolResult(nodeID, callID, output string, isError bool)
RecordToolResult captures the tool() binding's post-dispatch event. Output is the raw text the gateway returned (the dispatcher already collapses multi-block content to a single string at the boundary); strings over persist.MaxPayloadBytes are trimmed.
type Sandbox ¶
type Sandbox struct {
// contains filtered or unexported fields
}
Sandbox is a transpiler + goja runtime factory. The struct itself is reusable across calls; each Execute creates a fresh runtime, so scripts cannot leak state across invocations.
func New ¶
New constructs a sandbox. A non-positive timeout is replaced with DefaultTimeout so callers cannot accidentally disable the deadline.
func (*Sandbox) Execute ¶
func (s *Sandbox) Execute(ctx context.Context, source string, input any, b Bindings) (*Result, error)
Execute transpiles the TypeScript source, runs it under a fresh goja runtime with the supplied bindings injected, and returns the resolved value plus captured console output. The skill is expected to assign its handler to module.exports.default — the standard shape after esbuild emits CommonJS for `export default async function ...`.
Execute honors ctx cancellation: a deadline-exceeded ctx terminates the event loop and reports the timeout as an error.
func (*Sandbox) NewInvoker ¶
func (s *Sandbox) NewInvoker(name string, loadSource SourceLoader, bindings func(ctx context.Context) Bindings) skill.Invoker
NewInvoker builds a skill.Invoker that runs `name`'s TS source inside the sandbox using the supplied bindings. The returned Invoker is safe to register on a skill.Registry — it satisfies the signature exactly.
The bindings closure is called per-invocation so a long-lived dispatcher can hand each call a fresh ToolCaller, ChatModel, or SkillCaller that captures call-scoped context (e.g., a request-id or tracing span).
type SkillCaller ¶
type SkillCaller interface {
CallTool(ctx context.Context, name string, arguments map[string]any) (*mcp.ToolCallResult, error)
}
SkillCaller is the dispatch surface handoff() uses. The signature matches mcp.AgentClient.CallTool exactly so callers can hand the sandbox the same registry-backed dispatcher the gateway uses.
type SourceLoader ¶
SourceLoader returns the TypeScript source for a skill by name. The dispatcher calls SourceLoader on every invocation rather than caching so that on-disk edits become visible without a registry reload — the watcher in Phase F will narrow this further, but the uncached read is safe for Phase C.
func FileSourceLoader ¶
func FileSourceLoader(lookup func(name string) (string, bool)) SourceLoader
FileSourceLoader returns a SourceLoader that reads the file at the path the lookup callback resolves for the given skill name. The indirection lets the registry walker hand the dispatcher a closure over the registered skill paths without leaking the registry's internal layout into this package.