bindings

package
v0.2.9 Latest Latest
Warning

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

Go to latest
Published: May 8, 2026 License: MIT Imports: 21 Imported by: 0

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, and (for the bridges in deprecated.go) workflow. 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 both *engine.Board and *workflow.Board satisfy — so callers can pass either without conversion while we migrate fully off workflow.

Layering model

  1. core: BindingFunc, BuildEnv — language-agnostic assembly primitives.
  2. atomic bridges: one file per host capability, named New<Domain>Bridge; the injected global is typically the lowercase domain name (board, fs, …).
  3. expr subsystem: compiled-program LRU cache (see bridge_expr.go), transparent to scripts.
  4. presets: common combinations as convenience functions; presets express "default assembly" and do not replace caller-level policy.
  5. 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)
deprecated.go     v0.3.0 removal queue: NewStreamBridge, NewRunBridge, AgentStepBindings
*_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.
  • Legacy workflow agent step: see deprecated.go (NewRunBridge, AgentStepBindings — both slated for v0.3.0 removal).

Checklist for adding a new bridge

  1. Are closure-captured dependencies thread-safe and context-cancellable?
  2. Are defaults least-privilege (e.g. tools deny-by-default, shell recommends allowlist)?
  3. Are return values stable on the script side (map field names, multi-return mapping in luart/jsrt)?
  4. 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

Constants

This section is empty.

Variables

This section is empty.

Functions

func BuildEnv

func BuildEnv(ctx context.Context, config map[string]any, fns ...BindingFunc) *script.Env

BuildEnv creates a script.Env from binding funcs evaluated against ctx.

func RuntimeBinding

func RuntimeBinding(ctx context.Context, rt script.Runtime, parentBindings map[string]any) map[string]any

RuntimeBinding returns the host object for global "runtime" (e.g. execScript). Parent bindings are inherited by sub-scripts.

Types

type AgentStepOptions deprecated

type AgentStepOptions struct {
	Board *workflow.Board
	// TaskID / RunID mirror workflow.Request fields; RunID overrides board when non-empty.
	TaskID string
	RunID  string

	ToolRegistry *tool.Registry
	// AllowedTools is passed to WithAllowedToolNames when non-empty (ignored if ToolRegistry is nil).
	AllowedTools []string
}

AgentStepOptions selects common bindings for one workflow agent step (Lua/JS), without pulling in LLM streaming (add NewStreamBridge at the call site if needed).

Deprecated: scheduled for removal in v0.3.0 together with AgentStepBindings.

type BindingFunc

type BindingFunc func(ctx context.Context) (name string, value any)

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 AgentStepBindings deprecated

func AgentStepBindings(o AgentStepOptions) []BindingFunc

AgentStepBindings returns a typical binding set: board, run, expr, optional tools. Caller still supplies config + script.Runtime-specific globals (signal, etc.).

Deprecated: scheduled for removal in v0.3.0. The agent/engine stack does not need this preset — callers compose BuildEnv with the bridges they want directly (typically four lines), and binding combinations vary too much per agent for one preset to be worth maintaining.

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

All channel APIs require an explicit name — scripts must opt into MainChannel by passing "" 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) 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).

Script-facing API (every method returns nil / "" on a NoopHost so scripts can call it unconditionally):

host.publish(subject, payload)         -> nil | error
host.checkInterrupt()                  -> {cause, detail} | null
host.askUser({ parts, schema, source, metadata })
                                       -> { parts, metadata }
host.reportUsage({ input, output, total })
                                       -> nil

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); it mirrors the bridge_llm.LLMBridgeOptions.Source convention.

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; the caller (typically scriptnode.ScriptNode) feeds it whatever ctx.Host the executor installed and reuses the same Host instance for all bindings.

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("main", 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 NewRunBridge deprecated

func NewRunBridge(opts RunBridgeOptions) BindingFunc

NewRunBridge exposes read-only run metadata to scripts as global "run":

  • get_run_id() string
  • get_task_id() string

Deprecated: scheduled for removal in v0.3.0. Hard-wired to *workflow.Board and the workflow.VarRunID convention. The engine/agent stack carries the same metadata as agent.RunInfo (RunID/TaskID/AgentID/ContextID) — pass it in directly without going through a board key. The replacement bridge will land alongside the rest of the agent-runtime cleanup.

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 legacy NewRunBridge in deprecated.go is kept under its original name to avoid breaking existing callers; the new constructor names its data source (RunInfo) instead. Once the legacy bridge is removed in v0.3.0, this will be renamed to NewRunBridge.

Design choices vs. the deprecated NewRunBridge in deprecated.go:

  • Pulls metadata from agent.RunInfo directly. No board lookup, no workflow.VarRunID convention key. The information lives on the call stack where it was minted, not in a side-channel string map.

  • No board reference. The previous bridge accepted a *workflow.Board so it could fall back to reading "__run_id" — pure tech debt from when run state was scattered across the blackboard. The agent runtime hands you the full RunInfo, so the bridge has no reason to know about a board at all.

  • Exposes AgentID and ContextID in addition to RunID/TaskID. These two fields exist on agent.RunInfo and were not surfaced by the old bridge; 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 workspace.CommandRunner, opts ...ShellOption) BindingFunc

NewShellBridge exposes shell execution as global "shell".

func NewStreamBridge deprecated

func NewStreamBridge(stream workflow.StreamCallback, nodeID string) BindingFunc

NewStreamBridge exposes streaming as global "stream" (emit).

Deprecated: scheduled for removal in v0.3.0. Tied to workflow.StreamCallback and currently only consumed by graph/node/scriptnode (which itself rides on workflow's streaming model). Will be replaced once graph migrates off workflow.StreamCallback.

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 RunBridgeOptions deprecated

type RunBridgeOptions struct {
	Board *workflow.Board
	// TaskID is optional (e.g. workflow.Request.TaskID).
	TaskID string
	// RunID, if non-empty, is returned by get_run_id instead of reading the board.
	RunID string
}

RunBridgeOptions configures NewRunBridge (workflow / agent metadata on the board).

Deprecated: scheduled for removal in v0.3.0 together with NewRunBridge. See NewRunBridge for the engine/agent-era replacement plan.

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 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.

Jump to

Keyboard shortcuts

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