Adding a Task Mode¶
A "task mode" controls how an AgentRun's spec.task is dispatched. The
AgentRun CR exposes a polymorphic task field (string OR object).
Object form is dispatched by the controller's taskmodes
registry to a registered TaskModeHandler.
This page walks through extending Sympozium with a new mode. See sidecar-driven.md and harness.md for in-depth walkthroughs of the two modes shipped today.
Built-in modes¶
| Mode | Doc | Since |
|---|---|---|
sidecar-driven |
sidecar-driven.md | v0.10.44 |
harness |
harness.md | v0.10.48 |
The controller ships sidecar-driven and harness by default. External contributions can register more from their own repos without forking sympozium — see Downstream registration.
Five-step extension guide¶
Each step is a small, self-contained change. You should not need to touch the controller's reconcile loop.
1. Implement TaskModeHandler¶
Create a new file in internal/controller/taskmodes/. The interface (taskmodes.go):
type TaskModeHandler interface {
Mode() string // unique identifier, e.g. "my-mode"
Capabilities() Capabilities // what the mode can honour
Validate(task *v1alpha1.TaskSpec) error // per-mode required fields
ConfigureAgentContainer(task *v1alpha1.TaskSpec, agentEnv *[]corev1.EnvVar) error
AdjustSidecars(task *v1alpha1.TaskSpec, sidecars []SidecarContext) ([]SidecarAdjustment, error)
}
Capabilitiesdeclares what your mode honours from the AgentRun spec:OutputSchema,ToolFilter,Persona,Subagents,Resume. Be honest — a run asking for something you declare is admitted, and if you then ignore it the field is silently dropped, which is exactly what this descriptor exists to prevent. DeclareResume: falseunless your mode can genuinely be parked mid-run.Validateruns beforeConfigure*. Return a clear error so the reconcile loop can surface it onAgentRun.status.error.ConfigureAgentContainermutates the agent env slice in place. Useappend, don't replace — the central loop has already setTASK,MODEL_*,SYSTEM_PROMPT, etc.AdjustSidecarsreturns per-sidecar mutations the controller applies during container build. Returnnil, nilif your mode doesn't touch sidecars.
Two optional interfaces cover cases the four-method contract can't:
TaskCapabilityReporter— implementTaskCapabilities(task)when your mode's support varies from task to task rather than being fixed.Capabilities()then reports the ceiling (what the mode can do at best) and callers holding aTaskSpecuseCapabilitiesFor, which routes through your per-task answer.harnessuses it to read a declaration off the task, because the image an operator names is the only thing that knows what it honours — see harness.md.AgentContainerOverrider— implementOverrideAgentContainer(task)when something other thanagent-runnershould be the pod's primary process. It replaces the image, argv and env (SetEnvreplaces by name, so it can displaceTASK) and can attach its own volumes. The controller applies it last, after every central assignment.
Capability claims gate admission
internal/webhook/policy_enforcer.go denies an AgentRun that asks for a capability your descriptor lacks, and resolveTaskModeAdjustments repeats the check for clusters running without the webhook. Getting a claim wrong in either direction is a user-visible bug: too generous and the field is silently ignored, too stingy and working runs are rejected.
The CRD schema (task.tool, task.parameters) is intentionally loose — the mode owns its field semantics. If you need new fields, document them in your mode's docs page; the controller won't enforce them.
2. Register the handler¶
In internal/controller/taskmodes/register.go, append the new handler to the init():
func init() {
Register(NewSidecarDrivenHandler())
Register(NewHarnessHandler())
Register(NewMyModeHandler())
}
For downstream repos that want to add their own modes without forking sympozium, import internal/controller/taskmodes and call Register from that repo's main.go init. See Downstream registration below.
Duplicate registration panics
The registry panics on duplicate Mode() values at startup. Two handlers competing for the same mode is a programming error and must be caught in CI, not at runtime.
3. Update the CRD's task schema (only if you need new top-level fields)¶
The current oneOf: [string, object] schema constrains only mode (required) and allows tool / parameters (optional, open shape via x-kubernetes-preserve-unknown-fields).
If your mode needs additional fields (e.g. sidecars: [name] to bind to a specific sidecar), update all three:
config/crd/bases/sympozium.ai_agentruns.yaml(source of truth)charts/sympozium-crds/templates/sympozium.ai_agentruns.yaml(Helm-synced copy)charts/sympozium/crds/sympozium.ai_agentruns.yaml(bundled CRDs copy)
Then run make generate && make helm-sync to keep them consistent. The CI gate make helm-sync-check will fail if the copies drift.
4. Update the agent-runner's mode list¶
The agent-runner hard-codes its supported AGENT_MODE values in cmd/agent-runner/main.go:
Add your mode's AGENT_MODE identifier here. The startup log line and the unknown-mode error message are generated from this slice, so a contributor who sets AGENT_MODE=my-agent-mode will see a clear error listing the supported modes.
When is this step required?
Only if your mode changes how the agent-runner behaves. If your mode only configures sidecars (and the agent-runner still runs as prompt-server), you can skip this step.
5. Tests + docs¶
- Write unit tests for your handler against a fake
SidecarContextslice — seeinternal/controller/taskmodes/taskmodes_test.gofor theSidecarDrivenHandlertest patterns. - Pin your capability descriptor against what the mode actually does, one assertion per claim, so the two cannot drift apart silently.
- Write controller-level tests in
internal/controller/agentrun_*_test.gofor the dispatch wiring (unknown mode rejected, validation error propagated, JSON round-trips for both shapes). - If your mode denies anything at admission, add a webhook test asserting the denial names the mode and the reason — see
internal/webhook/task_mode_capabilities_test.go. - Add a
docs/modes/<mode-name>.mdpage modelled on sidecar-driven.md or harness.md: lifecycle, tool resolution, parameter passing, examples, links to your handler's source.
Downstream registration¶
A downstream repo (a sidecar orchestrator framework, an enterprise fork) can register its own modes without forking sympozium. Import the registry and call Register from the consumer's init() or main():
import (
"github.com/sympozium-ai/sympozium/internal/controller/taskmodes"
)
func init() {
taskmodes.Register(NewMyModeHandler())
}
The handler's Mode() must not collide with any built-in. To avoid collisions, namespace your mode identifier (e.g. acme-batch-runner rather than batch-runner).
Reconciler visibility
The sympozium controller will dispatch any registered mode. Make sure your handler's Validate is strict — an under-validated handler can ship pods with the wrong env vars.
What stays central in the controller¶
The controller's reconcile loop is stable across modes. It only does:
taskmodes.Get(task.Mode)— registry lookup.- If not found: surface
"unknown task.mode \"<x>\"; supported modes: [<sorted>]"onAgentRun.status.error, markphase: Failed. - Otherwise:
handler.Validate→ capability check →handler.ConfigureAgentContainer→handler.AdjustSidecars→ optionalOverrideAgentContainer→ render containers with the adjustments.
You should not need to touch this path. If you find yourself wanting to add a new branch to it, that's a signal that your mode would be better expressed as a handler.
Reference¶
internal/controller/taskmodes/taskmodes.go— interface definitioninternal/controller/taskmodes/register.go— built-in registrationinternal/controller/taskmodes/capabilities.go— the capability descriptor and the admission checkinternal/controller/taskmodes/override.go— replacing the agent containerinternal/controller/taskmodes/sidecar_driven.go— reference implementationinternal/controller/taskmodes/harness.go— reference implementation for a mode that replaces the agent containerinternal/controller/taskmodes/taskmodes_test.go— test patternsapi/v1alpha1/taskspec.go— polymorphicTaskSpectypecmd/agent-runner/main.go—supportedAgentModesslice- sidecar-driven.md — reference mode write-up