Documentation
¶
Overview ¶
Package managedagent renders and reconciles the Brain workload of a ManagedAgent.
Rendering is a pure function so the whole object graph a ManagedAgent produces can be diffed against a known-good baseline in a unit test. That matters more than it sounds: the Brain's behaviour is configured almost entirely through environment variables, and a variable that silently fails to render does not crash anything — it turns a capability off. A missing sandbox image leaves the agent with sandboxes whose command endpoint never answers; a missing credentials file variable leaves its CLI unauthenticated; a missing telemetry variable simply stops the traces. None of those surface as an error.
Index ¶
- Constants
- func BrainName(agent string) string
- func Endpoint(agent, namespace string, port int32, proxyService string) string
- func GatewayPort(ma *agentsv1alpha1.ManagedAgent) int32
- func HandsEnvName(agent string) string
- func OpenCodeConfigSecretName(agent string) string
- func PublicURL(ma *agentsv1alpha1.ManagedAgent, base string) string
- func RenderOpenCodeConfig(ma *agentsv1alpha1.ManagedAgent, apiKey string) ([]byte, error)
- func ScenarioNames(ma *agentsv1alpha1.ManagedAgent) []string
- func WithDefaults(ma *agentsv1alpha1.ManagedAgent, defaults RenderDefaults) *agentsv1alpha1.ManagedAgent
- func WorkspaceFSPort(ma *agentsv1alpha1.ManagedAgent) int32
- type ClusterEndpoint
- type ClusterResolver
- type DerivedEnv
- type DerivedMember
- type HandsProvisioner
- type RESTHandsProvisioner
- type Reconciler
- type RenderDefaults
- type Rendered
Constants ¶
const ( LabelManagedBy = "agentbox.navix.sh/managed-by" LabelAgent = "agentbox.navix.sh/managed-agent" // AnnotationOwnerAgent records the agent an env was derived for. Cross- // cluster ownerReferences do not exist, so this is the only link back, and // it is also how an orphan is found: an env carrying LabelAgent whose named // agent no longer exists has outlived its owner. // // A derived env is never deleted with its agent, and nothing marks it at // deletion time. Marking would need a finalizer, which would block deleting // an agent whenever its worker cluster is unreachable — trading a tidy // annotation for an object that cannot be removed during an outage. The env // also holds warm pods that someone may still be using. Reclaiming one is // therefore a human decision, made against this label. AnnotationOwnerAgent = "agentbox.navix.sh/owner-managed-agent" )
Labels and annotations stamped on a derived env, so an operator looking at a worker cluster can tell where the object came from and what happens to it.
const ( // NamePrefix prefixes every object a ManagedAgent owns, so a Brain is // never mistaken for a hand-rolled Deployment of the same agent name. NamePrefix = "agentbox-brain-" // DefaultGatewayPort is the agent gateway: the one port callers use. DefaultGatewayPort int32 = 4099 // DefaultWorkspaceFSPort serves attachment staging and the workspace file // browser. It is a separate process from the gateway, and the file panel // and attachment upload both stop working if it is not exposed. DefaultWorkspaceFSPort int32 = 8766 // DefaultOpenCodePort is the loopback port for `opencode serve`. DefaultOpenCodePort int32 = 4096 // RuntimeHome is the Brain image's HOME. Every path below is derived from it // rather than written out, because the image's runtime user owns these // directories and a path that disagrees with HOME is not a crash — it is an // unwritable directory the process falls back from silently. RuntimeHome = "/home/agents" // StateRoot is where the Brain keeps everything a restart must not lose: // the thread map, the Claude Code transcripts and the OpenCode session DB. // // All three live UNDER it, and it is mounted at exactly one path with no // subPath. That is the whole point of the layout: the volume used to be // mounted on the OpenCode data directory, which left the thread map and the // transcripts on the container filesystem — so a restart kept OpenCode's // sessions and lost the map naming them, and every conversation came back // empty while the transcripts sat there intact. StateRoot = RuntimeHome + "/state" // OpenCodeDBPath is OpenCode's SQLite file: sessions, messages, parts. Only // the database is placed on the volume, not the whole data directory — the // rest of it is an unused credential file and a log directory. OpenCodeDBPath = StateRoot + "/opencode/opencode.db" // ClaudeConfigDir holds one JSONL transcript per session. ClaudeConfigDir = StateRoot + "/claude" // ThreadStorePath is the gateway's thread map. Every harness builds its // history list from this file, so losing it loses the history even when the // transcripts survive. ThreadStorePath = StateRoot + "/gateway/threads.json" // OpenCodeConfigPath is where the OpenCode harness reads its runtime // config. The filename is fixed by the harness, and the directory by HOME. OpenCodeConfigPath = RuntimeHome + "/.config/opencode/opencode.json" // ManagedByValue marks every object this controller owns. The Hands // reconciler refuses to update a SandboxEnv that does not carry it, so a // user who takes manual ownership of an object keeps it. ManagedByValue = "agentbox-managedagent" // ConfigChecksumAnnotation carries a hash of everything that is NOT part of // the pod spec but still changes the Brain's behaviour — the referenced // Secrets and ConfigMaps. Without it a credential rotation leaves the old // value live in a running pod with nothing to indicate it. ConfigChecksumAnnotation = "agentbox.navix.sh/config-checksum" )
const DefaultProviderID = "platform"
DefaultProviderID is used when the agent does not name one. The provider id is part of every model's address ("<provider>/<model>"), so it is a spec field rather than a constant here.
Variables ¶
This section is empty.
Functions ¶
func Endpoint ¶
Endpoint is the in-cluster URL callers are given.
It points at the control-plane proxy, not at the Brain's own Service. Both are in-cluster, but only one of them checks anything: the Brain takes the caller's word for which end user is asking, so handing out its address makes every pod in the cluster able to read any tenant's threads. Reporting the proxied address instead means the published route and the in-cluster route differ only in hostname — same path, same key, same authorization.
proxyService is "<service>.<namespace>:<port>"; empty falls back to the Brain's own address, which is the only thing available on a deployment that has no proxy.
func GatewayPort ¶
func GatewayPort(ma *agentsv1alpha1.ManagedAgent) int32
GatewayPort is the port the Brain serves its agent API on. Exported because anything proxying to an agent has to address it by the same number the Deployment and Service were rendered with.
func HandsEnvName ¶
HandsEnvName is the env derived for an agent.
The worker caps env names at 24 characters because it appends the instance type to form each pool name. Deriving a name that the worker will reject would surface as a create failure on every reconcile, so the suffix is budgeted for here rather than discovered at the remote.
func OpenCodeConfigSecretName ¶
OpenCodeConfigSecretName is the Secret holding the generated opencode.json.
It is generated rather than asked for because the harness must be pinned to a single provider: left to its own defaults OpenCode also loads every provider reachable without credentials, including its vendor's hosted free models, and those become both pickable and reachable by model id.
func PublicURL ¶
func PublicURL(ma *agentsv1alpha1.ManagedAgent, base string) string
PublicURL is the address external callers use, or "" when the agent is not published. base is the shared route this deployment serves agents on.
func RenderOpenCodeConfig ¶
func RenderOpenCodeConfig(ma *agentsv1alpha1.ManagedAgent, apiKey string) ([]byte, error)
RenderOpenCodeConfig produces the opencode.json for one agent.
The result is the agent's overlay with the platform-owned keys stamped on top. apiKey is read from the agent's credential Secret by the caller; it is embedded here because the harness reads its provider credential from this file rather than from the environment.
func ScenarioNames ¶
func ScenarioNames(ma *agentsv1alpha1.ManagedAgent) []string
ScenarioNames lists the scenarios a Brain serves, sorted for a stable status.
func WithDefaults ¶ added in v0.0.10
func WithDefaults( ma *agentsv1alpha1.ManagedAgent, defaults RenderDefaults, ) *agentsv1alpha1.ManagedAgent
WithDefaults returns the agent as the deployment actually runs it: its own fields where it sets them, the deployment's defaults where it does not.
The result is a copy. Nothing here may write to the caller's object — a reconcile that mutated the spec it was reconciling would be observed as a change by the next one, and the defaults would leak into etcd where a later deployment could no longer re-point them.
Every consumer of an agent's effective configuration must go through this: rendering the pod from the defaults while hashing or reporting status from the raw spec is how a default credential ends up live in a pod that nothing says is using it.
func WorkspaceFSPort ¶
func WorkspaceFSPort(ma *agentsv1alpha1.ManagedAgent) int32
WorkspaceFSPort serves attachment staging and the workspace file browser. It is exported for the same reason as GatewayPort: a proxy in front of an agent must address it by the number the Service was rendered with.
Types ¶
type ClusterEndpoint ¶
type ClusterEndpoint struct {
BaseURL string
// HostHeader carries the virtual host when the base URL is an IP. The
// worker's ingress routes on Host, so omitting it lands on the default
// backend and every call 404s while the address looks correct.
HostHeader string
}
ClusterEndpoint is one worker cluster's native API, as the control plane addresses it.
type ClusterResolver ¶
type ClusterResolver func(clusterID string) (ClusterEndpoint, bool)
ClusterResolver hands out the endpoint for a cluster id.
type DerivedEnv ¶
type DerivedEnv struct {
Name string
TemplateRef string
Image string
Members []DerivedMember
Labels map[string]string
Annotations map[string]string
}
DerivedEnv is the env one agent needs, already named and sized.
func DeriveEnv ¶
func DeriveEnv(ma *agentsv1alpha1.ManagedAgent) (DerivedEnv, error)
DeriveEnv turns the agent's auto spec into the env to create.
type DerivedMember ¶
type DerivedMember struct {
InstanceType string
Resources *corev1.ResourceRequirements
Replicas int32
MinReplicas int32
MaxReplicas int32
}
DerivedMember is one member pool of a derived env.
Resources and InstanceType are alternatives, not a pair: a cluster with an instance-type catalog sizes members by catalog entry, one without it needs the size spelled out, and sending both is rejected.
type HandsProvisioner ¶
type HandsProvisioner interface {
// EnsureEnv makes the env and its member pools exist, and reports whether
// the env is serving. It must be safe to call on every reconcile.
EnsureEnv(ctx context.Context, clusterID string, spec DerivedEnv) (ready bool, detail string, err error)
// CreateEnv creates an env from a request body the caller supplies verbatim,
// plus its member pools, and returns the env's name.
//
// Distinct from EnsureEnv in two ways that matter. It is a ONE-SHOT: the
// caller is a console request, not a reconcile loop, so it reports a
// conflict as an error instead of treating an existing env as success —
// silently adopting somebody else's env would attach an agent to a sandbox
// supply configured for another purpose. And the body is opaque here: it is
// the worker's own create-env request, forwarded unchanged, so the console
// can offer every field that API has (credential injection included) without
// this type growing a mirror of it that drifts.
CreateEnv(ctx context.Context, clusterID string, env json.RawMessage, members []json.RawMessage) (string, error)
}
HandsProvisioner derives an agent's sandbox supply on a worker cluster.
It is an interface rather than a concrete client because the object it creates does not live in the control plane's API server: SandboxEnv is a worker-cluster CRD, so the control plane reaches it over the worker's REST API. Keeping that behind an interface is also what lets the reconciler be tested without a worker.
type RESTHandsProvisioner ¶
type RESTHandsProvisioner struct {
Resolve ClusterResolver
// APIKey authenticates to every worker. The control plane acts as itself
// here, not on behalf of the agent's owner: the env is platform
// infrastructure and outlives any one caller's credential.
APIKey string
Client *http.Client
}
RESTHandsProvisioner provisions envs over a worker cluster's native REST API.
func NewRESTHandsProvisioner ¶
func NewRESTHandsProvisioner(resolve ClusterResolver, apiKey string) *RESTHandsProvisioner
NewRESTHandsProvisioner builds a provisioner with a bounded HTTP client.
func (*RESTHandsProvisioner) CreateEnv ¶ added in v0.0.10
func (p *RESTHandsProvisioner) CreateEnv( ctx context.Context, clusterID string, env json.RawMessage, members []json.RawMessage, ) (string, error)
CreateEnv implements HandsProvisioner.
The env body is forwarded to the worker unchanged; only its `name` is read here, because the member endpoint is addressed by it and the caller needs it to point the agent at the env afterwards. Members are posted one by one so a rejected member names itself.
func (*RESTHandsProvisioner) EnsureEnv ¶
func (p *RESTHandsProvisioner) EnsureEnv( ctx context.Context, clusterID string, spec DerivedEnv, ) (bool, string, error)
EnsureEnv creates the env and any missing member pool, then reports whether the remote considers it serving.
type Reconciler ¶
type Reconciler struct {
client.Client
Scheme *runtime.Scheme
// ProxyService is this process reached from inside the cluster, as
// "<service>.<namespace>:<port>". It is what status.endpoint points at so
// in-cluster callers go through authentication too.
ProxyService string
// PublicBaseURL is the shared route this deployment serves published agents
// on, e.g. "https://console.example.com/agentbox/api/managed-agents". Empty
// leaves status.publicURL unset: the agent still works in-cluster, it just
// has no address to hand out.
PublicBaseURL string
// Hands derives an agent's SandboxEnv on a worker cluster. It is nil on a
// control plane with no worker clusters registered, which is why
// spec.hands.auto reports "unavailable" instead of failing: the other two
// hands modes stay usable.
Hands HandsProvisioner
// DefaultBrainImage is the image an agent gets when it names none, letting a
// caller create one from a prompt alone. Unset keeps spec.image required.
DefaultBrainImage agentsv1alpha1.ManagedAgentImage
// DefaultHands is the sandbox supply an agent gets when it declares no branch.
// Nil means a deployment that publishes none, and an agent without a branch
// then reports HandsReady=False rather than coming up with no hands.
DefaultHands *agentsv1alpha1.ManagedAgentHands
// DefaultRuntime is the harness configuration — endpoint, models and
// credential reference — an agent gets when it declares none of its own. Nil
// leaves every agent to bring its own model credential.
DefaultRuntime *agentsv1alpha1.ManagedAgentRuntime
}
Reconciler drives a ManagedAgent's Brain workload.
It runs on the control plane only. The worker binary installs on every cluster, so a control-plane object reconciled there would get one reconciler per cluster all competing for the same resource.
func (*Reconciler) Reconcile ¶
Reconcile brings the Brain in line with the spec and reports what it found.
func (*Reconciler) SetupWithManager ¶
func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error
SetupWithManager registers the controller.
type RenderDefaults ¶ added in v0.0.10
type RenderDefaults struct {
// BrainImage is used when the agent names no image of its own. Lets a caller
// create an agent from a prompt alone, which is the point — requiring an image
// reference means knowing which one carries a compatible gateway, and that is
// the platform's business, not the tenant's.
//
// Empty keeps the image required, which is the correct behaviour for a
// deployment that has not published one: inventing a reference would fail later
// as an ImagePullBackOff, a long way from the cause.
BrainImage agentsv1alpha1.ManagedAgentImage
// Hands is the sandbox supply an agent gets when it declares no branch of its
// own. The whole block is supplied, so a deployment can default to an external
// service, a local SandboxEnv or a derived one without a second mechanism.
//
// It is applied on every render rather than stamped onto the object at creation:
// the sandbox image travels with this default, and that tag rolls with its own
// build, so a copy taken at creation time silently ages. Nil leaves an agent
// without a branch with no supply at all, which the HandsReady condition
// reports.
Hands *agentsv1alpha1.ManagedAgentHands
// Runtime is the harness configuration — endpoint, model list and credential
// reference — an agent gets when it declares none of its own.
//
// This is the last thing standing between "create an agent from a prompt" and
// an agent that cannot answer: a Brain with no model credential comes up
// healthy, serves its API, and reports every harness unavailable. The failure
// is legible but it is still an agent that does nothing.
//
// Whether a deployment SHOULD default this is its own call, and the two
// answers are both reasonable. A shared internal endpoint makes one key
// everyone's: one quota, one revocation, one audit trail. A deployment that
// needs those separate leaves this unset and each agent brings its own. The
// credential is a reference either way — this process renders a pointer and
// never holds the key.
Runtime *agentsv1alpha1.ManagedAgentRuntime
}
RenderDefaults supplies values the deployment owns rather than the agent.
Passed in rather than read from a package variable so Render stays pure: the golden test and every unit test depend on the same spec producing the same bytes, and a default that could be mutated at process scope would make that conditional on whatever ran first.
type Rendered ¶
type Rendered struct {
Deployment *appsv1.Deployment
Service *corev1.Service
// PVC is nil when session persistence is disabled or supplied by an
// existing claim.
PVC *corev1.PersistentVolumeClaim
}
Rendered is the full object graph one ManagedAgent produces.
func Render ¶
func Render(ma *agentsv1alpha1.ManagedAgent, checksum string) (*Rendered, error)
Render turns a ManagedAgent into the objects that implement it.
It is pure: same spec in, same bytes out. `checksum` is the caller's hash of the referenced Secrets and ConfigMaps; passing "" omits the annotation.
func RenderWithDefaults ¶ added in v0.0.10
func RenderWithDefaults( ma *agentsv1alpha1.ManagedAgent, checksum string, defaults RenderDefaults, ) (*Rendered, error)
RenderWithDefaults renders an agent, filling anything it left unset from deployment-level defaults.