Documentation
¶
Overview ¶
Package agentinstance spawns and owns running ACP agent instances on a server-rooted context, independent of any client connection, and lets multiple viewers attach to a session's event stream. Exactly one attached controller per session answers its permission and terminal requests; a watchDog restarts a dead downstream but the conversation context is lost.
Index ¶
Constants ¶
const ( StateStarting = "starting" StateRunning = "running" StateStopped = "stopped" StateError = "error" StateWarning = "warning" )
Instance lifecycle states — the vocabulary of InstanceStatus.State.
- StateStarting: transient, while a subprocess is (re)spawning.
- StateRunning: a live downstream connection.
- StateStopped: torn down intentionally (Stop/Close); the watchDog never restarts out of this state.
- StateError: the downstream died unexpectedly. Terminal if restart is disabled, else transient (leads back to StateStarting).
- StateWarning: restart was enabled but exhausted its limit, or a re-spawn itself failed.
const ( ChainACPSubcommand = "acp" ChainPathEnvVar = "CONTENOX_ACP_CHAIN_PATH" )
ChainACPSubcommand and ChainPathEnvVar describe this binary's own ACP server for a chain-kind spawn: the subcommand that serves ACP over stdio, and the env var naming which chain file to run. Declared here rather than imported to avoid an import cycle with the packages that own them; kept exported so those packages can assert the definitions still agree.
const AgentModeConfigOptionID = "contenox.agent-mode"
AgentModeConfigOptionID is the reserved SessionConfigOption id under which a session surfaces the downstream agent's session Modes as a single synthetic "select" option (type "select", one value per available mode, currentValue the current mode id). SetConfigOption on this id translates to session/set_mode; a downstream current_mode_update is captured onto it. A reserved dotted namespace so it never collides with a downstream agent's own option ids.
const AgentModelConfigOptionID = "contenox.agent-model"
AgentModelConfigOptionID is the same synthetic-option scheme as AgentModeConfigOptionID, for the downstream agent's unstable model-picker state. SetConfigOption on this id translates to session/set_model; unlike modes, there is no model-update stream kind, so the set_model response alone becomes the new current value.
Variables ¶
var ErrNotFound = errors.New("agentinstance: instance not found")
ErrNotFound is returned for an unknown instance id. It is a sentinel so callers can branch on errors.Is(err, ErrNotFound).
Functions ¶
This section is empty.
Types ¶
type Event ¶
type Event struct {
Kind EventKind `json:"kind"`
InstanceID string `json:"instanceId"`
AgentID string `json:"agentId"`
AgentName string `json:"agentName"`
State string `json:"state,omitempty"` // EventStateChange
SessionID libacp.SessionID `json:"sessionId,omitempty"` // EventAttach / EventDetach / EventUnsupervisedDeny
ViewerID string `json:"viewerId,omitempty"` // EventAttach / EventDetach
Controller bool `json:"controller,omitempty"` // EventAttach
Time time.Time `json:"time"`
}
Event is one instance-lifecycle event, self-contained so a sink can react without calling back into the Manager. Subscribe via WithEventSink.
type EventKind ¶
type EventKind string
EventKind classifies a lifecycle Event.
const ( // EventStateChange fires on every instance state transition (Event.State // carries the new state). EventStateChange EventKind = "state_change" // EventAttach fires when a viewer attaches to a session (Event.Controller // reports whether it became the controller). EventAttach EventKind = "attach" // EventDetach fires when a viewer detaches from a session. EventDetach EventKind = "detach" // EventUnsupervisedDeny fires when a downstream permission request // reaches a session with no controller and is refused — by the built-in // headless deny or an injected PermissionFallback. It does not fire when // a fallback permits the request, so the audit trail never claims a // refusal that didn't happen. EventUnsupervisedDeny EventKind = "unsupervised_permission" )
type EventSink ¶
type EventSink func(Event)
EventSink receives every lifecycle Event, called synchronously on the goroutine that produced it. It must not block or call back into the Manager.
type FleetEntry ¶
type FleetEntry struct {
AgentID string `json:"agentId"`
AgentName string `json:"agentName"`
Kind string `json:"kind"`
Instances []InstanceStatus `json:"instances"`
}
FleetEntry joins one declared agent with its live instances (empty when declared but not running).
func (FleetEntry) Running ¶
func (e FleetEntry) Running() bool
Running reports whether this declared agent has at least one live instance.
type InstanceStatus ¶
type InstanceStatus struct {
ID string `json:"id"`
AgentID string `json:"agentId"`
AgentName string `json:"agentName"`
Kind string `json:"kind"`
State string `json:"state"`
// Sessions is how many downstream sessions are open on the instance —
// always len(SessionIDs), read from the same snapshot.
Sessions int `json:"sessions"`
// Viewers is how many viewers are attached across those sessions,
// independent of Sessions: an open session with nobody watching still
// counts toward Sessions but not Viewers.
Viewers int `json:"viewers"`
StartedAt time.Time `json:"startedAt"`
// SessionIDs lists every session currently open on the instance, sorted
// for a deterministic snapshot. A session nobody is watching, or that
// has emitted no update yet, is still listed.
SessionIDs []string `json:"sessionIds"`
}
InstanceStatus is a point-in-time snapshot of one instance. It is a value copy: mutating it never affects the live instance.
type Manager ¶
type Manager interface {
// Start resolves agentName via the registry and brings up an instance
// bound to the Manager's root context, not ctx, so it outlives the
// request. cwd is the sandbox workspace. Prefer StartResolved if the
// agent is already resolved.
Start(ctx context.Context, agentName, cwd string) (instanceID string, err error)
// StartResolved spawns an instance from an already-resolved agent with
// no registry read of its own, so a caller enforcing a policy decision
// (e.g. Enabled) spawns exactly the record it judged. cwd is the sandbox
// workspace root, overridden by the agent's own declared Cwd if set.
StartResolved(ctx context.Context, agent *runtimetypes.Agent, cwd string) (instanceID string, err error)
// Attach registers viewer against (instanceID, sessionID), replaying the
// journal then joining the live fan-out. The first viewer of a session
// becomes its controller (controllerGranted true).
Attach(ctx context.Context, instanceID string, sessionID libacp.SessionID, viewer Viewer) (controllerGranted bool, err error)
// Detach removes viewerID from (instanceID, sessionID)'s fan-out,
// promoting the earliest-attached survivor if it was the controller.
Detach(instanceID string, sessionID libacp.SessionID, viewerID string) error
// List returns every declared agent joined with its live instances
// (empty = not running).
List(ctx context.Context) ([]FleetEntry, error)
// Get returns the status of one instance.
Get(instanceID string) (InstanceStatus, error)
// OpenSession drives the downstream ACP handshake on instanceID
// (initialize once, then session/new) and returns the downstream
// session id that Attach and the other session methods use.
OpenSession(ctx context.Context, instanceID string, spec SessionSpec) (libacp.SessionID, error)
// Prompt drives one downstream session/prompt turn and returns its stop
// reason. A ctx cancellation or concurrent Cancel resolves as
// StopReasonCancelled with a nil error.
Prompt(ctx context.Context, instanceID string, sessionID libacp.SessionID, prompt []libacp.ContentBlock) (libacp.StopReason, error)
// DeliverToSession injects n into sessionID's fan-out on whichever live
// instance owns that session, as if it were a downstream update; the
// kernel adds nothing to n. ErrNotFound when no instance owns sessionID.
DeliverToSession(ctx context.Context, sessionID libacp.SessionID, n libacp.SessionNotification) error
// Cancel cancels sessionID's in-flight prompt turn. Safe with no turn in flight.
Cancel(instanceID string, sessionID libacp.SessionID) error
// CloseSession ends sessionID and drops its kernel state, without
// stopping the instance. Only the consumer that called OpenSession
// should call this.
CloseSession(instanceID string, sessionID libacp.SessionID) error
// SetConfigOption forwards a config-option change downstream and adopts
// the confirmed value. The synthetic mode/model ids map to
// session/set_mode and session/set_model; every other id forwards to
// session/set_config_option.
SetConfigOption(ctx context.Context, instanceID string, sessionID libacp.SessionID, configID string, value libacp.SessionConfigOptionValue) error
// SessionConfigOptions returns sessionID's captured config-option
// surface (synthetic mode + model selects, then the downstream's own),
// or nil for an unknown session.
SessionConfigOptions(instanceID string, sessionID libacp.SessionID) ([]libacp.SessionConfigOption, error)
// AvailableCommands returns sessionID's captured slash-command menu, or nil for an unknown session.
AvailableCommands(instanceID string, sessionID libacp.SessionID) ([]libacp.AvailableCommand, error)
// Stop tears an instance down and removes it from the registry,
// preventing any watchDog restart. Idempotent.
Stop(instanceID string) error
// Close stops every instance and cancels the Manager's root context.
// After Close, Start returns an error. Idempotent.
Close() error
}
Manager owns the lifecycle of running agent instances. Every method is safe for concurrent use, and every method taking an instanceID returns ErrNotFound when it is unknown.
type Option ¶
type Option func(*manager)
Option configures a Manager.
func WithEventSink ¶
WithEventSink installs sink as the lifecycle event sink (see EventSink).
func WithJournalSize ¶
WithJournalSize overrides the per-session replay journal size (see defaultJournalSize).
func WithKillGrace ¶
WithKillGrace overrides how long an external instance's teardown waits for its subprocess to exit on stdin-close before killing it (see defaultKillGrace).
func WithPermissionFallback ¶
func WithPermissionFallback(fn PermissionFallback) Option
WithPermissionFallback installs fn as the answerer for permission requests that reach a session with no controller viewer (see PermissionFallback). Default: unset, which keeps the built-in headless deny.
func WithRestart ¶
WithRestart enables the watchDog restart policy: an external instance whose subprocess dies unexpectedly is re-spawned up to limit times before parking in StateWarning. Default: disabled (unexpected death is terminal StateError). A restart loses the downstream agent's conversation context.
func WithSelfExecutable ¶
WithSelfExecutable overrides the program a chain-kind agent is spawned from (default os.Executable()). Lets a test point the spawn at a built fixture binary, since the compiled test binary itself has no ACP server.
func WithStderr ¶
WithStderr forwards each spawned external instance's subprocess stderr to w. Defaults to io.Discard.
type PermissionFallback ¶
type PermissionFallback func(ctx context.Context, req UnattendedPermission) (libacp.RequestPermissionResponse, error)
PermissionFallback answers a permission request that arrived at an unattended session — the kernel's only human-in-the-loop concession. It may block (runs on the request's own goroutine); ctx is the downstream request's context. Returning an error falls back to the built-in headless deny.
type SessionSpec ¶
type SessionSpec struct {
// Cwd is the downstream session's working directory; ACP requires one,
// and spec-correct agents expect it absolute.
Cwd string
// AdditionalDirectories are extra absolute workspace roots for the session, on top of
// Cwd. Omitted/empty means none.
AdditionalDirectories []string
// McpServers are the already-resolved MCP servers to forward downstream
// in session/new; the kernel drops any the downstream's advertised
// mcpCapabilities cannot consume. Nil forwards none.
McpServers []libacp.McpServer
// Meta is an opaque session/new `_meta` blob forwarded verbatim; the
// kernel neither reads nor interprets it. Nil forwards none.
Meta json.RawMessage
// Terminal advertises the terminal client capability to the downstream
// at initialize, iff set — negotiated once per connection, at the first
// OpenSession. Even when set, every terminal/* is still gated on the
// session's controller implementing TerminalServer (else
// MethodNotFound); left false, terminals are never advertised and
// terminal/* always refuses.
Terminal bool
}
SessionSpec is the fully-resolved input to Manager.OpenSession: everything the kernel needs to negotiate the downstream connection's capabilities and drive session/new.
type TerminalServer ¶
type TerminalServer interface {
CreateTerminal(ctx context.Context, req libacp.CreateTerminalRequest) (libacp.CreateTerminalResponse, error)
TerminalOutput(ctx context.Context, req libacp.TerminalOutputRequest) (libacp.TerminalOutputResponse, error)
WaitForTerminalExit(ctx context.Context, req libacp.WaitForTerminalExitRequest) (libacp.WaitForTerminalExitResponse, error)
KillTerminal(ctx context.Context, req libacp.KillTerminalRequest) (libacp.KillTerminalResponse, error)
ReleaseTerminal(ctx context.Context, req libacp.ReleaseTerminalRequest) (libacp.ReleaseTerminalResponse, error)
}
TerminalServer is an optional capability a Viewer may implement to service a downstream agent's terminal/* callbacks for the session it controls. It is routed only to the session's controller viewer; a controller that does not implement it, or a session with no controller, answers terminal/* with MethodNotFound. Callbacks run on their own goroutine and may block.
type UnattendedPermission ¶
type UnattendedPermission struct {
InstanceID string
AgentID string
AgentName string
// SessionID is the downstream session the request arrived on.
SessionID libacp.SessionID
Request libacp.RequestPermissionRequest
}
UnattendedPermission is the self-contained input to a PermissionFallback: one downstream permission request that reached a session with no controller viewer, plus the identity of the instance that raised it.
type Viewer ¶
type Viewer interface {
// ID uniquely identifies this viewer within a session; two viewers on the
// same session must not share an ID.
ID() string
// Deliver receives one session update, in order: the replayed journal
// backlog on attach, then every live update.
//
// It must not block — it runs on the fan-out path under the session
// lock, so a blocking call stalls every other viewer and the downstream
// read loop. Enqueue and return. The returned error is advisory only.
Deliver(ctx context.Context, n libacp.SessionNotification) error
// RequestPermission answers the downstream agent's
// session/request_permission. Called only on the session's controller
// viewer (an observer may later be promoted to controller on detach).
// Unlike Deliver it runs on its own goroutine and may block awaiting a
// decision.
RequestPermission(ctx context.Context, req libacp.RequestPermissionRequest) (libacp.RequestPermissionResponse, error)
}
Viewer is a consumer attached to one downstream session: it receives the session's streamed updates via Deliver and, when it is the session's controller, answers the downstream agent's permission requests via RequestPermission.