externagent

package
v0.1.2 Latest Latest
Warning

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

Go to latest
Published: Jul 7, 2026 License: MIT Imports: 26 Imported by: 0

Documentation

Overview

Package externagent runs another agent's command-line harness as a backend, so a run can be driven by an external agent CLI (installed on the host, authenticated on its own subscription) instead of a single model call. The external tool owns its own inner loop: handed one turn, it runs a whole agentic episode (many inner model calls, its own tool calls and retries) before it yields. That is the opposite of a model port, which is one request in and one response out, so an external agent is a loop strategy, not a model.

The external tool must never be the effector. Its native execution surface is locked down (a read-only sandbox, native writes and shell denied), and the agent's own tools are offered to it through a loopback Model Context Protocol bridge the run hosts. Every tool call the external agent makes comes back through that bridge and is admitted, contained, braked, and recorded at the same dispatch waist as a native loop. So swapping in another harness never widens what a run may do or escapes a halt; the waist is outside the loop and applies to every action, whichever harness took it.

What an external harness cannot make observable is recorded as a gap rather than hidden: its inner model calls, the context it compacts, and its direct channel to its own provider are outside the run's tracing. Each projected action carries a provenance Tier saying how strongly the record can vouch for it, so a run driven by an external agent never claims the integrity of a native run.

One Adapter describes one external CLI; the codex adapter is the first. The runner, the bridge, and the governance are shared, so a second CLI is a new Adapter, not a new subsystem.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Adapter

type Adapter interface {
	// Name is the CLI's stable identifier (for example "codex"), used in the model
	// spec that selects it and recorded on the run.
	Name() string
	// Detect probes whether the CLI is installed, logged in, and new enough to be
	// constrained. It runs the CLI's own version and auth probes and never starts an
	// episode.
	Detect(ctx context.Context) (Readiness, error)
	// Command builds the subprocess invocation for one episode: the argv that locks
	// the CLI's native execution down (read-only, native effects denied) and points
	// its MCP client at the bridge, plus how the turn and the final message are
	// carried. It does not run anything.
	Command(ep Episode) (Invocation, error)
	// Parse turns one line of the CLI's stdout into zero or more typed events. A line
	// it does not recognize yields an attested progress event rather than an error, so
	// an unfamiliar line is recorded, not dropped.
	Parse(line []byte) ([]Event, error)
}

Adapter describes how to drive one external agent CLI as a subprocess. The codex adapter is the first; the same port fits any CLI that runs an episode as a child process and reports it as a stream of lines. An implementation must be safe for concurrent use.

type Bridge

type Bridge struct {
	// Name is the MCP server name the CLI registers the bridge under. Tool names the
	// CLI reports may be namespaced by it, which the driver maps back to real tool
	// identities for capability matching.
	Name string
	// URL is the streamable-HTTP endpoint the CLI connects to, on the loopback
	// interface.
	URL string
	// Token is the bearer token the CLI must present, so another local process cannot
	// drive the bridge. The adapter passes it to the CLI through an environment
	// variable rather than an argument, keeping it out of the process table.
	Token string
	// TokenEnv is the environment variable the CLI reads the bearer token from.
	TokenEnv string
}

Bridge is the loopback endpoint an episode points the external CLI at, so the CLI's tool calls come back through the dispatch waist. It is a streamable-HTTP MCP server the run hosts on the local host for the life of the episode.

type Codex

type Codex struct {
	// contains filtered or unexported fields
}

Codex drives the codex CLI as an external agent backend over its non-interactive exec mode, which streams an episode as JSON lines and writes the final message to a file. It is constrained to a read-only native sandbox with native approvals denied, so the model cannot write or run a command directly; its effects reach the workspace only through the loopback MCP bridge, where they are governed.

func NewCodex

func NewCodex(bin string, spawner Spawner) *Codex

NewCodex builds the codex adapter. bin overrides the executable to run (empty uses "codex" resolved on PATH); spawner runs the detection probes through the sandbox boundary and may be nil when the adapter is used only for Command and Parse.

func (*Codex) Command

func (c *Codex) Command(ep Episode) (Invocation, error)

Command builds the codex exec invocation for one episode. It pins the read-only sandbox and denies native approvals so codex cannot write or run a command itself, points codex's MCP client at the bridge over streamable HTTP with the bearer token carried in the environment (not on the command line), and reads the final message from a file rather than the event stream. The turn is written on stdin; a standing instruction is prepended as a lower-authority preamble, since codex's own harness prompt outranks anything injected.

func (*Codex) Detect

func (c *Codex) Detect(ctx context.Context) (Readiness, error)

Detect probes that codex is installed, logged in, and new enough to be constrained to the bridge. It runs codex's own version, auth, and help probes and never starts an episode. A missing binary or a build without the lockdown knobs (the read-only sandbox, the JSON event stream, the streamable-HTTP MCP client) is a hard refusal, so the driver stops rather than running codex with unattested effects; a healthy but logged-out CLI is a recoverable onboarding prompt.

func (*Codex) Name

func (*Codex) Name() string

Name identifies the adapter in the model spec and on the run.

func (*Codex) Parse

func (c *Codex) Parse(line []byte) ([]Event, error)

Parse projects one codex exec --json line to typed events. Recognized boundaries (thread and turn start) become attested progress; a reported error or a failed turn becomes an error event, terminal when the message names a permanent condition; a completed turn becomes a done event carrying any usage; an assistant message item becomes attested text. A line it cannot decode becomes an attested progress event carrying the raw line, so nothing is dropped and noise does not end the episode.

type Driver

type Driver struct {
	// contains filtered or unexported fields
}

Driver adapts an external agent CLI to the driver.Driver port, so an external harness is a selectable run loop like any other: an additive registry entry, no edit to the default loop. Its loop shape is one CLI episode per goal step. It composes with governance rather than replacing it: the same grant, containment, brake, and event spine that bound a native loop bound every tool call the external harness makes, because those sit at the dispatch waist outside the loop.

Construct it with the adapter for the CLI and the spawner that runs the CLI under the sandbox's confinement. The workspace is where the CLI reads and where its bridged effects land.

func NewDriver

func NewDriver(adapter Adapter, spawner Spawner, workdir string) *Driver

NewDriver builds the driver for one external CLI. spawner runs the CLI confined; workdir is the directory the episode operates in (the CLI reads it and its bridged tools act in it).

func (*Driver) Build

Build assembles the episode loop from the Spec. It captures the governance ingredients (tools, grant, sandbox gate, brake, event sink, reporter) and defers the wiring to each step, so a step's bridge is scoped and attributed to the goal it runs under. The Spec's Model (an llm.Model) is intentionally unused: an external harness drives its own model, selected by the goal's model string, not a model port.

func (*Driver) Name

func (d *Driver) Name() string

Name is the adapter's identifier, the name this loop is selected by and recorded under on the run.

type Episode

type Episode struct {
	Input   string
	Workdir string
	Model   string
	System  string
	Bridge  Bridge
}

Episode is one turn handed to an external CLI: the user input to act on, where to act, which model to drive, the standing instruction to layer in, and the bridge to route effects through. The system instruction lands as a lower-authority layer because the CLI's own harness prompt outranks anything injected, so a behavioral contract is a request to an external harness, not a guarantee.

type Event

type Event struct {
	Kind     EventKind
	Text     string
	Usage    Usage
	Err      string
	Terminal bool // for EventError: the failure is terminal, not worth retrying
	Tier     Tier
	Raw      json.RawMessage
}

Event is one typed projection of an episode's output line. The runner forwards it to the reporter and, with its Tier, to the record. Raw preserves the original CLI line for the attested record, so the CLI's own account is kept verbatim alongside the typed projection.

type EventKind

type EventKind int

EventKind is the sort of thing an episode's output line projects to.

const (
	// EventProgress is an intermediate signal from the CLI with no effect of its own
	// (a thread or turn boundary, a status line). It is attested.
	EventProgress EventKind = iota
	// EventText is assistant-visible text the CLI produced. It is attested: the run
	// did not generate it and cannot attest the context that produced it.
	EventText
	// EventUsage carries token accounting the CLI reported for the episode.
	EventUsage
	// EventError is a failure the CLI reported (a provider error, a turn failure). Its
	// Class distinguishes a terminal failure from a transient one for retry.
	EventError
	// EventDone marks the episode's own completion (the turn finished), carrying any
	// final usage. It is distinct from EventError, which ends an episode abnormally.
	EventDone
)

type Invocation

type Invocation struct {
	Path            string
	Args            []string
	Env             []string
	Stdin           string
	LastMessageFile string
}

Invocation is the subprocess to run for one episode: the program to exec, its arguments, environment additions (KEY=VALUE, merged onto a minimal base by the runner), and the input to write on stdin when the CLI reads the turn from stdin rather than an argument. LastMessageFile, when set, is the path the CLI writes its final assistant message to, which the runner reads as the episode's result rather than reconstructing it from the event stream.

type Process

type Process interface {
	Stdout() io.Reader
	Wait() error
}

Process is a running episode subprocess: its stdout stream and a wait handle. Cancellation is the context's job (Start binds the process to it), so there is no separate kill; Wait returns once the process ends, including after a context-driven kill.

type Readiness

type Readiness struct {
	// Available is true when the CLI is installed and answered a version probe.
	Available bool
	// LoggedIn is true when the CLI holds usable credentials (a subscription session
	// or an API key), so an episode would not stall on an auth prompt.
	LoggedIn bool
	// Version is the CLI's reported version, recorded on the run and used to refuse a
	// build too old to constrain.
	Version string
	// Reason is a one-line, actionable message when the CLI is not ready to run (for
	// example, an instruction to log in). It is empty when Ready is true.
	Reason string
	// Refuse marks a hard refusal the driver must not start on (a too-old CLI, a
	// missing lockdown knob) as distinct from a recoverable onboarding prompt (not yet
	// logged in). A refusal is terminal; an onboarding prompt is not.
	Refuse bool
}

Readiness reports whether an external agent CLI can start an episode, and if not, why. The driver consults it before every session: a CLI that is not installed or not logged in yields an actionable Reason, and a CLI that cannot be constrained to route its effects through the bridge (too old to offer the sandbox, approval, or MCP knobs the lockdown needs) sets Refuse, so the driver stops rather than running an external harness with unattested effects.

func (Readiness) Ready

func (r Readiness) Ready() bool

Ready reports whether an episode can start: the CLI is installed, logged in, and not under a hard refusal.

type Result

type Result struct {
	Text     string
	Usage    Usage
	Failed   bool
	Err      string
	Terminal bool
	Tiers    map[Tier]int
}

Result is the outcome of one episode: the final assistant message, the token usage the CLI reported, whether it failed and why, and the provenance-tier tally of the projected events. Reasoning is not in the tally because it is unobservable with an external harness in the loop; the record names that gap separately.

type Runner

type Runner struct {
	// contains filtered or unexported fields
}

Runner drives one external-agent episode end to end: it hosts the loopback MCP bridge over a local streamable-HTTP port, spawns the CLI constrained to route its effects through that bridge, projects the CLI's output stream to typed events, and tears everything down. The bridge is served on the governed context passed to Run, so every tool call the CLI makes is admitted, contained, braked, and recorded at the dispatch waist; cancelling that context (a halt or shutdown) both refuses further tool calls and kills the subprocess.

func NewRunner

func NewRunner(adapter Adapter, server *mcp.Server, spawner Spawner, report func(Event)) *Runner

NewRunner builds a runner that drives adapter, hosting server as the bridge, spawning the episode subprocess through spawner (the sandbox-confined process host), and forwarding each projected event to report (nil drops them).

func (*Runner) Run

func (r *Runner) Run(ctx context.Context, ep Episode) (Result, error)

Run hosts the bridge, runs one episode of ep, and returns its Result. ctx must carry the run's governance bindings (the grant and the brake) so bridged calls are governed; cancelling ctx halts the episode and kills the subprocess. Run returns a non-nil error only on a failure to start or host the episode (a bad invocation, a bridge that will not bind); an episode that runs and the CLI reports as failed is a completed Run with Failed set on the Result.

type SandboxConfig

type SandboxConfig struct {
	// AllowedHosts is the destination-name allowlist for an episode's egress: the
	// external provider's API and auth endpoints, the only names the confined child may
	// reach out to. It is a name gate on the egress proxy ("deny all egress except these
	// providers"): a name not on the list is denied, and a listed name that resolves to a
	// private or rebinding address is denied too (the address gate still applies). An
	// entry beginning with a dot (".example.com") matches any subdomain. The loopback MCP
	// bridge is not listed here: the child reaches it directly, outside the proxy, so the
	// bridge stays reachable while the internet does not. An empty list permits no egress
	// at all beyond the bridge (deny-all), enough for an offline detection run but not a
	// live episode. These are supplied by the adapter, so no provider name is baked into
	// this generic host.
	AllowedHosts []string
	// AuthDir is the external CLI's credential and config home (its OAuth token lives
	// there), which sits outside the episode workspace. The confined child is granted
	// read (and traverse) on it for the life of the episode and the grant is revoked on
	// teardown, so the credential stays in its home directory and is never copied into the
	// workspace, the vault, or the record. Empty grants no extra read. On Linux and macOS
	// a read-only host already permits the read, so this takes effect only where the
	// confinement denies reads by default (a Windows AppContainer).
	AuthDir string
	// MinContainment is the floor the host must actually enforce or an episode is refused
	// rather than run less contained (refuse-rather-than-downgrade: an untrusted harness
	// never silently drops to a weaker boundary). The zero value is treated as
	// sandbox.ContainmentKernel, the boundary for semi-trusted, model-authored code over a
	// shared kernel; a caller can raise it (a microVM tier) but not lower it below the
	// kernel floor by leaving it zero.
	MinContainment sandbox.Containment
	// ProbeTimeout caps how long a detection probe may run before it is killed, so a
	// hung CLI cannot stall detection. Zero applies no cap beyond the caller's context.
	ProbeTimeout time.Duration
}

SandboxConfig is the confinement envelope a SandboxSpawner runs an external CLI under. The external harness is untrusted: its own sandbox is bypassed and is not the boundary, so this profile is the only one. A zero value refuses a live episode (no provider egress, kernel containment required), which is the safe default.

type SandboxSpawner

type SandboxSpawner struct {
	// contains filtered or unexported fields
}

SandboxSpawner is the production Spawner: it runs an external agent CLI as an untrusted subprocess inside the sandbox's containment envelope, the single security boundary around a harness whose own code the run does not control. It backs the Spawner port with the sandbox's streaming and capture launch primitives (no direct os/exec, which the repo confines to the sandbox package), composing a read-only host, the syscall filter, a deny-all-except-provider egress gate, and a read grant for the CLI's own auth home into one launch. Detection runs through Probe; an episode runs through Start, whose process is bound to the context so a halt kills the CLI.

func NewSandboxSpawner

func NewSandboxSpawner(cfg SandboxConfig) *SandboxSpawner

NewSandboxSpawner builds the production Spawner for the given confinement envelope. A MinContainment left at the zero value is raised to sandbox.ContainmentKernel, so the default is the kernel-confined floor rather than an unconfined process jail.

func (*SandboxSpawner) Probe

func (s *SandboxSpawner) Probe(ctx context.Context, path string, args ...string) (string, error)

Probe runs path with args to completion under best-effort confinement and returns its combined output, for detection (a version or auth-status probe). Detection must work wherever the CLI is installed, so the probe uses the always-on baseline confinement that degrades to the process-jail floor rather than refusing on a host that cannot set up the kernel tier; the episode path (Start) is the one that refuses. The CLI's auth home is granted read so an auth-status probe can see whether it is logged in. A non-zero exit is returned as an error with the output preserved, so detection reads it as "not present" or "not ready" while the adapter can still parse the reason.

func (*SandboxSpawner) Start

func (s *SandboxSpawner) Start(ctx context.Context, ep Episode, inv Invocation) (Process, error)

Start launches one episode's subprocess inside the containment envelope and returns its live stdout and a wait handle. The child runs with a read-only host and the syscall filter (so its bypassed native writes and dangerous syscalls are refused by the OS, not by trusting the CLI), a deny-all-except-provider egress gate (so its only way out is the allowlisted provider, and its direct provider channel stays unobserved-but-contained), and a read grant for its auth home. Before it launches, the host's actual containment is checked against the configured floor and the launch is refused if the host cannot meet it, so an untrusted harness never runs less contained than required. The process is bound to ctx: cancelling it (a halt or shutdown) kills the CLI, and the per-episode sandbox (its egress proxy and read grant) is released when Wait returns.

type Spawner

type Spawner interface {
	// Probe runs path with args to completion and returns its combined output. A
	// non-nil error means the command could not be run or exited non-zero, which
	// detection reads as "not present" or "not ready".
	Probe(ctx context.Context, path string, args ...string) (string, error)
	// Start launches one episode's subprocess and returns its live stdout and a wait
	// handle. The process is bound to ctx: a cancellation (a halt or shutdown) kills it.
	Start(ctx context.Context, ep Episode, inv Invocation) (Process, error)
}

Spawner runs the external CLI's processes. Process spawning is confined to the sandbox boundary in this project, so this package never spawns directly: the caller provides a Spawner backed by that boundary (a semi-trusted external-agent containment profile), and the adapter and runner drive it. Probe runs a short command to completion for detection; Start launches an episode subprocess bound to the context, so cancelling the context kills it.

type Tier

type Tier int

Tier is the provenance tier of a recorded action: how strongly the sealed record can vouch for it. A run driven by an external harness mixes tiers, and verify reports the mix so an external-harness run never claims the integrity of a native one.

const (
	// TierEnforced is an action that ran through the dispatch waist: a bridged tool
	// call, admitted against the grant and contained, exactly like a native action.
	TierEnforced Tier = iota
	// TierAttested is an action the external CLI reported that the run did not
	// independently enforce, for example the CLI's own progress and context events.
	// The record carries the CLI's claim, marked as its claim.
	TierAttested
	// TierUnobserved is a declared gap: work the external harness does that is
	// structurally outside the run's tracing (its inner model calls, its direct egress
	// to its own provider). The record names the gap rather than pretending to cover it.
	TierUnobserved
)

func (Tier) String

func (t Tier) String() string

String names the tier for the record and verify output.

type Usage

type Usage struct {
	InputTokens  int
	OutputTokens int
}

Usage is the token accounting an episode reports. It mirrors the shape a native run records, so an external episode's cost lands on the same accounting.

Jump to

Keyboard shortcuts

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