Documentation
¶
Overview ¶
Package bindings assembles host capabilities into a script.Env for any script.Runtime implementation (jsrt, luart, etc.).
Purpose ¶
bindings does two things: (1) expose Go-side interfaces as script-callable globals/tables; (2) provide common presets to reduce boilerplate. Business policy (who may call what, quotas, auth) belongs to the caller: pass pre-trimmed dependencies or tighten capabilities via Options. VM-agnostic: bindings never parses syntax; Lua/JS are handled by script sub-packages.
Dependency constraint ¶
bindings depends on llm, model, tool, engine. It must never depend on graph: graph composes bindings, not the other way around. The Board surface bindings consume is the structural bindings.Board interface, which *engine.Board satisfies directly.
Layering model ¶
- core: BindingFunc, BuildEnv — language-agnostic assembly primitives.
- atomic bridges: one file per host capability, named New<Domain>Bridge; the injected global is typically the lowercase domain name (board, fs, …).
- expr subsystem: compiled-program LRU cache (see bridge_expr.go), transparent to scripts.
- presets: common combinations as convenience functions; presets express "default assembly" and do not replace caller-level policy.
- LLM bridge (bridge_llm.go + bridge_llm_round.go + llm_marshal.go): drives an LLM round directly via llm.LLMResolver / llm.LLM / tool.Registry — fully self-contained, no host runtime dependency. Returns multimodal-aware structured results to scripts; does NOT write to the board (scripts control data flow explicitly via the board bridge). Supports blocking llm.run() and iterator-based llm.stream() modes; the iterator exposes per-chunk model.Part projections so scripts can branch on text / image / tool_call.
Directory layout ¶
doc.go package-level design notes (this file) core.go BindingFunc, BuildEnv bridge_board.go board variables + typed message channels (engine.Board) bridge_expr.go expr-lang expressions (eval) + LRU program cache bridge_shell.go sandboxed subprocesses (allowlist via ShellOption) bridge_fs.go workspace files bridge_runtime.go sub-script execScript (inherits parent bindings) bridge_llm.go NewLLMBridge facade + LLMRunOptions (script-facing options schema) bridge_llm_round.go in-bridge round driver (resolver + GenerateStream + tool.Registry) llm_marshal.go model.* ⇄ map[string]any projections (multimodal-aware) bridge_tools.go tool.Registry (deny-by-default, explicit allowlist or AllowAll) bridge_run.go run metadata exposed from agent.RunInfo (run/task/agent/context ids) *_test.go table-driven / jsrt integration tests
Global naming convention ¶
Script global names match the name returned by BindingFunc: board, stream, expr, shell, fs, runtime (injected by scriptnode), run, tools, llm. Methods exposed by each bridge use lowercase_underscore style, consistent with existing script conventions.
Integration with hosts ¶
- Graph ScriptNode: composes board+stream+expr+runtime in ExecuteBoard, then appends shell/fs per node type (see graph/node/scriptnode).
- Engine / Agent: build the env directly with BuildEnv and the bridges you need; add NewLLMBridge when LLM access is required, and use NewRunInfoBridge(runInfo) to surface run/task/agent/context ids. There is no global preset — the four lines of BuildEnv are the preset.
Checklist for adding a new bridge ¶
- Are closure-captured dependencies thread-safe and context-cancellable?
- Are defaults least-privilege (e.g. tools deny-by-default, shell recommends allowlist)?
- Are return values stable on the script side (map field names, multi-return mapping in luart/jsrt)?
- Should it be included in a preset? Document which execution paths it is compatible with.
Testing conventions ¶
Integration tests use jsrt to execute small script snippets that verify bindings; pure Go logic (e.g. LRU) is covered by *_test.go unit tests.
Index ¶
- func BuildEnv(ctx context.Context, config map[string]any, fns ...BindingFunc) *script.Env
- func RuntimeBinding(ctx context.Context, rt script.Runtime, parentBindings map[string]any) map[string]any
- type BindingFunc
- func NewBoardBridge(board Board) BindingFunc
- func NewExprBridge() BindingFunc
- func NewFSBridge(ws workspace.Workspace) BindingFunc
- func NewHostBridge(host engine.Host, source string, emitter StreamEmitter) BindingFunc
- func NewLLMBridge(opts LLMBridgeOptions) BindingFunc
- func NewRunInfoBridge(info agent.RunInfo) BindingFunc
- func NewShellBridge(runner sandbox.Runner, opts ...ShellOption) BindingFunc
- func NewToolBridge(reg *tool.Registry, opts ...ToolBridgeOption) BindingFunc
- type Board
- type LLMBridgeOptions
- type LLMRunOptions
- type ShellOption
- type StreamEmitter
- type ToolBridgeOption
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
Types ¶
type BindingFunc ¶
BindingFunc creates a named binding for script execution. The returned name becomes the global variable name in the script scope, and the value is typically a map[string]any of callable Go functions.
func NewBoardBridge ¶
func NewBoardBridge(board Board) BindingFunc
NewBoardBridge exposes board state as the global "board".
Vars (control variables, untyped):
- getVar(key) → any
- setVar(key, val)
- getVars() → map[string]any
- hasVar(key) → bool
Channels (typed conversation history; multimodal-aware via the model.Message projection in bridge_llm_marshal.go):
- channel(name) → []messageMap (read; never returns null)
- setChannel(name, msgs) → throws on validation errors
- appendChannel(name, msg) → throws on validation errors
Constants:
- MAIN_CHANNEL — the engine's reserved default channel name; scripts should reference this rather than hard-coding the literal string so future renames do not break existing scripts.
All channel APIs require an explicit name — scripts must opt into MainChannel by passing board.MAIN_CHANNEL themselves. This avoids accidentally stitching unrelated conversations together via an implicit default.
func NewExprBridge ¶
func NewExprBridge() BindingFunc
NewExprBridge exposes expr-lang as global "expr" (eval).
func NewFSBridge ¶
func NewFSBridge(ws workspace.Workspace) BindingFunc
NewFSBridge exposes workspace file ops as global "fs".
func NewHostBridge ¶ added in v0.2.3
func NewHostBridge(host engine.Host, source string, emitter StreamEmitter) BindingFunc
NewHostBridge exposes the engine.Host control plane to scripts as the global "host". It is the script-side mirror of the small interfaces composed in engine.Host (Publisher, Interrupter, UserPrompter, Checkpointer, UsageReporter), with one extra method (host.emit) surfacing the per-node stream publisher the executor pre-baked with the right run / node identity.
Script-facing API (every method returns nil / "" on a NoopHost so scripts can call it unconditionally):
host.publish(subject, payload) -> nil | error
host.emit(type, payload) -> void (per-node stream
delta; subject is composed
by the executor from the
runID and nodeID it
installed at wire time)
host.checkInterrupt() -> {cause, detail} | null
host.askUser({ parts, schema, source, metadata })
-> { parts, metadata }
host.reportUsage({ input, output, total })
-> nil
Identity (which run / which node) is intentionally NOT exposed on host: it lives on the canonical NewRunInfoBridge "run" global (run.get_run_id() / run.get_node_id() / ...). There is one source of truth for "where am I", separate from the host control plane.
host.publish vs host.emit:
- host.publish is the low-level escape hatch. Scripts that need a specific Subject (kanban callbacks, custom analytics subjects, cross-run signalling) construct the subject themselves and pass a full envelope payload.
- host.emit is the high-level node-stream channel. The executor installed a per-node publisher that already knows the runID and nodeID, so scripts only supply (type, payload) and the envelope fans out under the canonical engine.run.<runID>.stream.<nodeID>.delta subject. This is the same channel Go-side nodes write to via ctx.Publisher.Emit.
Payload conventions for host.emit mirror the executor's normalisePayload behaviour: passing an object literal merges in the {type} field so a {content: "..."} payload for type "token" decodes cleanly into engine.StreamDeltaPayload; passing a bare value is wrapped as {type, payload: value} for legacy consumers.
Checkpointing is intentionally NOT exposed: scripts have no access to the executing engine's ExecID / Board snapshot / Step marker, so a "host.checkpoint(payload)" call would be ambiguous at best and accidentally destructive at worst. If a future use case needs script initiated checkpoints, it should land as its own typed API rather than a generic bag.
Source labels diagnostic strings (errors carry it, future tracing may too) and supplies the default UserPrompt.Source for askUser when the script does not override it. It mirrors the bridge_llm.LLMBridgeOptions.Source convention. scriptnode passes the node id as source so an askUser interruption is naturally attributed to the calling node; scripts that want to read the node id should use run.get_node_id() from NewRunInfoBridge, not infer it from this label.
Interrupt latching: the first interrupt the bridge observes is cached inside the closure so subsequent host.checkInterrupt() calls keep returning the same {cause, detail} for the lifetime of the bridge instance. This matches how scripts naturally consume the signal — "have I been told to stop?" — instead of forcing them to either save the value at first sight or risk losing it on a re-poll.
The bridge does NOT own the engine.Host nor the StreamEmitter; the caller (typically scriptnode.ScriptNode) feeds it whatever ctx.Host / ctx.Publisher the executor installed and reuses those instances for all bindings. When emitter is nil host.emit silently drops the call, matching graph.NoopPublisher on the Go side.
func NewLLMBridge ¶
func NewLLMBridge(opts LLMBridgeOptions) BindingFunc
NewLLMBridge exposes LLM calls to scripts as the global "llm":
llm.run() // blocking, returns full result map
llm.run({ model, temperature, ... }) // with per-call overrides
llm.stream() // returns iterator { next, part, text, finish, close }
llm.stream({ model, ... }) // streamed, with per-call overrides
Iterator usage (multimodal-friendly):
var s = llm.stream({ model: "..." });
while (s.next()) {
var p = s.part(); // map projection of model.Part
if (p.type === "text") write(p.text);
if (p.type === "image") show(p.image.url);
}
var r = s.finish(); // round result map
board.setVar("answer", r.content);
board.setChannel(board.MAIN_CHANNEL, r.messages);
Neither mode writes to the board; the script controls what to do with results (typically via the board bridge: board.setVar / board.setChannel). See LLMRunOptions for the per-call schema.
func NewRunInfoBridge ¶ added in v0.2.3
func NewRunInfoBridge(info agent.RunInfo) BindingFunc
NewRunInfoBridge exposes read-only run metadata to scripts as global "run", sourcing every field from a single agent.RunInfo value.
Script-facing API:
run.get_run_id() string // agent.RunInfo.RunID run.get_task_id() string // agent.RunInfo.TaskID run.get_agent_id() string // agent.RunInfo.AgentID run.get_context_id() string // agent.RunInfo.ContextID
All getters return the empty string when the corresponding field on agent.RunInfo is unset; scripts can do `if (!run.get_task_id()) { … }` to branch on absence without needing a separate "has_*" probe.
Naming: the constructor advertises its data source (RunInfo) rather than using the bare "Run" prefix. The previous workflow-coupled NewRunBridge was retired in v0.3.0; this is now the only run-metadata bridge in the package.
Design choices:
Pulls metadata from agent.RunInfo directly. No board lookup, no VarRunID convention key. The information lives on the call stack where it was minted, not in a side-channel string map.
No board reference. Run identity and board state are independent concerns; the bridge has no reason to know about the board.
Exposes AgentID and ContextID in addition to RunID/TaskID — scripts that route on multi-agent or multi-conversation identity need them.
Takes the value (not a pointer). RunInfo is a small immutable descriptor; copying it into the closure is cheaper and safer than carrying a pointer into script-controlled territory.
Typical wiring at the agent.Run boundary:
env := bindings.BuildEnv(ctx, scriptCfg,
bindings.NewBoardBridge(eng.Board()),
bindings.NewRunInfoBridge(runInfo),
bindings.NewExprBridge(),
)
func NewShellBridge ¶
func NewShellBridge(runner sandbox.Runner, opts ...ShellOption) BindingFunc
NewShellBridge exposes shell execution as global "shell".
func NewToolBridge ¶
func NewToolBridge(reg *tool.Registry, opts ...ToolBridgeOption) BindingFunc
NewToolBridge exposes tool execution to scripts as global "tools":
- call(name, argumentsJSON) -> { content, is_error, tool_call_id }
- list() -> []string (names the script is allowed to call)
Security: by default no tool is callable until WithAllowedToolNames or WithToolAllowAll is set.
type Board ¶ added in v0.2.3
type Board interface {
GetVar(key string) (any, bool)
SetVar(key string, value any)
Vars() map[string]any
Channel(name string) []model.Message
SetChannel(name string, msgs []model.Message)
AppendChannelMessage(name string, msg model.Message)
}
Board is the structural contract NewBoardBridge requires of any blackboard-shaped object. *engine.Board satisfies it directly; the legacy *workflow.Board also fits the same shape, which is what lets existing callers keep compiling during the v0.3.0 transition without the bridge taking on a host dependency.
Method set rationale:
- GetVar / SetVar / Vars: plain control-variable access.
- Channel / SetChannel / AppendChannelMessage: typed message channels — needed because LLM rounds keep multimodal conversation history in channels, not vars. Scripts need the ability to read the current history, replace it after a round (setChannel with r.messages), and append individual user / assistant turns (appendChannel).
type LLMBridgeOptions ¶
type LLMBridgeOptions struct {
Resolver llm.LLMResolver
Registry *tool.Registry
// Defaults are merged with the script-supplied overrides on every
// llm.run() / llm.stream() call. Any field the script omits is
// inherited from here; explicit script values win.
Defaults LLMRunOptions
// Source labels diagnostics (errors, future tracing). It is the
// bridge equivalent of "node id" / "event id" in legacy code.
Source string
// ReadMessages returns the conversation history to send to the
// LLM. Called once per script invocation so the script can mutate
// the board between rounds and have the next call see the update.
// When nil, an empty slice is used.
ReadMessages func(ctx context.Context) []model.Message
}
LLMBridgeOptions configures NewLLMBridge.
The bridge is the only consumer of these options at the moment, so the surface stays minimal: a resolver to materialize the LLM, an optional tool registry for function-calling, the per-call defaults the script can override, a source label for diagnostics, and a hook for the bridge to read the conversation history at call time.
Notably absent — these were intentional decisions during the "B-track" refactor:
- No llm.RoundConfig: scripts and the bridge interior speak the bridge's own LLMRunOptions / roundOptions types end-to-end.
- No Go-side OnEvent hook: scripts pull chunks via the iterator returned by stream(); a host-side event subscription will be added only when a real consumer needs it.
type LLMRunOptions ¶ added in v0.2.3
type LLMRunOptions struct {
Model string `json:"model,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens int64 `json:"max_tokens,omitempty"`
JSONMode *bool `json:"json_mode,omitempty"`
Thinking *bool `json:"thinking,omitempty"`
Tools []string `json:"tools,omitempty"`
}
LLMRunOptions is the strongly-typed object that scripts pass to llm.run() / llm.stream().
All fields are optional; any unset field inherits the bridge's LLMBridgeOptions.Defaults value. Pointer fields (Temperature, JSONMode, Thinking) distinguish "script omitted" from "script explicitly set to zero/false". Unknown JSON keys are rejected at parse time so script typos surface immediately instead of silently falling back to defaults.
Script-side schema (JS / Lua):
llm.run({
model: "openai/gpt-4o-mini", // optional, string
temperature: 0.2, // optional, number
max_tokens: 1024, // optional, integer
json_mode: true, // optional, bool
thinking: false, // optional, bool
tools: ["web_search"], // optional, []string
})
type ShellOption ¶
type ShellOption func(*shellConfig)
ShellOption configures a shell bridge.
func WithAllowedCommands ¶
func WithAllowedCommands(cmds ...string) ShellOption
WithAllowedCommands restricts the shell bridge to only execute the specified commands. When set, any command not in the list is rejected.
type StreamEmitter ¶ added in v0.3.2
StreamEmitter is the structural contract NewHostBridge accepts for per-node delta emission, exposed to scripts via host.emit. The executor's per-node publisher (built by executor.newNodePublisher) satisfies it; the bridge takes the smaller shape so the bindings package does not depend on sdk/graph.
The Emit signature mirrors graph.StreamPublisher exactly so callers can hand the bridge their existing publisher without an adapter.
type ToolBridgeOption ¶
type ToolBridgeOption func(*toolBridgeConfig)
ToolBridgeOption configures NewToolBridge.
func WithAllowedToolNames ¶
func WithAllowedToolNames(names ...string) ToolBridgeOption
WithAllowedToolNames restricts script-visible tools; names must match registry entries.
func WithToolAllowAll ¶
func WithToolAllowAll() ToolBridgeOption
WithToolAllowAll allows calling any tool registered in the registry. Use only when scripts are fully trusted.