The Plugin System — A Contract and a Bus, Not Dynamic Loading
Let me be brutally honest first: the "plugin system" in the current code is not dynamic loading. No
go:plugin, no.sohot-loading, no "inject external plugins without changing code". It is a compiled-in Go interface + registry — at startup assembly youRegisterstructs that implement the interface into aPluginBus, which then owns lifecycles and calls the defined extension points.
1. What it actually is: a plugin contract + a bus
internal/runtime's package comment says it plainly:
"Package runtime defines the plugin contract for extending workflow execution. Plugins are registered on a PluginBus which manages their lifecycle and invokes them at defined extension points (BeforeStep, AfterStep)."
Three things: define the contract (interface) → register on the bus → the bus manages lifecycle and calls the extension points.
1.1 The plugin contract: RuntimePlugin is the foundation
type RuntimePlugin interface {
Name() string
Capabilities() []Capability
Start(ctx context.Context, bus EventBus) error
Stop(ctx context.Context) error
}
Name(): unique identifier.Capabilities(): declares which functional areas the plugin provides.Start/Stop: lifecycle.Startmust be non-blocking; it receives theEventBusfor emitting/subscribing events.
Capability is a set of functional-area constants:
CapObserver / CapCheckpoint / CapRouter / CapLoop / CapMemory / CapEvolution / CapTool / CapRecovery / CapInterrupt1.2 Three optional extension interfaces
On top of RuntimePlugin, a plugin may implement these optional interfaces so the bus hands specific scenarios to it:
| Interface | Purpose |
|---|---|
WorkflowHook | BeforeStep / AfterStep — called synchronously before and after each step |
MemoryPlugin | AdviseRoute(ctx, RouteState) → []RouteAdvice — route suggestions based on similar prior executions |
EvolutionPlugin | Recommend(ctx, ExecutionState) → *RuntimeRecommendation + RecordOutcome(ctx, outcome) — feed outcomes into evolutionary learning |
RecoveryPlugin | ShouldRecover(ctx, StepFailure, ExecutionState) bool — decide whether to recover a failed step |
The data structures they depend on live in the same file (plugin.go): RouteAdvice, RouteRecord, ExecutionState, RuntimeRecommendation, ExecutionOutcome, StepFailure, Step, StepResult, etc.
EvolutionPlugin also has a dedicated default implementation in evolution_plugin.go: NewEvolutionPlugin(name, provider, recorder, opts...), with a recommendation cache (default CacheTTL = 30s, tunable via WithCacheTTL). provider/recorder may be nil — "evolution not configured" is a valid disabled state, not an error.
flowchart LR
P[RuntimePlugin interface] --> C[Capabilities]
P --> S[Start / Stop]
P -.optional.-> H[WorkflowHook]
P -.optional.-> M[MemoryPlugin]
P -.optional.-> E[EvolutionPlugin]
P -.optional.-> R[RecoveryPlugin]
H --> B[PluginBus calls before/after each step]
E --> B1.3 The bus: PluginBus
bus.go's PluginBus is the core manager:
bus := ares_runtime.NewPluginBus()
Register(plugin): adds a plugin; a duplicate name returnsErrDuplicatePlugin. A plugin implementingWorkflowHookis auto-registered as a hook. Hot-plug:Registeris valid before OR afterStart— a plugin registered on a running bus is started immediately under the bus's lifetime context (timeout + panic recovery + started/failed events), and a start failure unplugs it again so no dead registration survives.Unregister(ctx, name): the plug-out half of hot-plug — stops the plugin (same timeout/panic contract asStop) and drops it from the plugin list, capability index, and workflow hooks.Start(ctx)/Stop(ctx): starts all plugins (a failing one is logged and iteration continues); stop is in reverse registration order; failures are aggregated witherrors.Join.BeforeStep/AfterStep: call all hooks sequentially, each with a timeout (invokeWithTimeout) and panic recovery. The contract is observational log-and-continue — one failing hook doesn't stop the others.Emit/Subscribe: the event system for plugins.Emitis non-blocking; a saturated subscriber buffer drops events (tracked asdroppedEvents, readable viaStats()), consistent with "never block callers because of a slow consumer".PluginsByCap(cap): fetch plugins by capability.
flowchart LR
Cmd[startup assembly peer_mode] --> SPS[startPluginBus]
SPS --> NB[NewPluginBus]
NB --> REG[Register: LoopPlugin etc.]
REG --> ST[bus.Start]
ST --> QH[sched.WithQuantumHook on quantum boundary]
QH --> EXEC[scheduler step]
EXEC --> BH["bus BeforeStep / AfterStep calls all hooks"]1.4 Production wiring: who actually plugs into the scheduler at startup
cmd/ares/peer_mode.go:
kernel.pluginBus = startPluginBus(ctx, store, sched, kernelLoopCfg)
startPluginBus (cmd/ares/runtime_bridge.go) really registers:
loop := ares_runtime.NewLoopPlugin("kernel-loop", ares_runtime.LoopConfig{
MaxIterations: loopCfg.LoopMaxIterations,
})
if err := bus.Register(loop); err != nil { /* degrade to log + keep scheduling */ }
if err := bus.Start(ctx); err != nil { return nil }
sched.WithQuantumHook(newPluginBusHook(bus, loop, loopCfg))
So: the plugin actually wired into the runtime in production is LoopPlugin (the kernel's round clock), attached to the scheduler's quantum boundary via WithQuantumHook, making the scheduling cadence (quanta/round, max_iterations) an observable, controllable object. That's also why a registration failure is deliberately downgraded to "log + keep scheduling" — a metadata problem must never deadlock the kernel scheduler.
Other built-in plugin implementations (verified to exist): LoopPlugin (loop.go), BasicRecoveryPlugin (recovery.go, an allowlist-based recovery), InterruptPlugin (interrupt.go, implements RuntimePlugin + WorkflowHook), CheckpointPlugin (checkpoint.go, implements RuntimePlugin + WorkflowHook), and a memory-routing plugin (router_memory.go).
2. The honest boundary: what it is not
This is arguably the most important part. Current "plugin system" real boundaries:
| Expectation | Reality |
|---|---|
| Add a new plugin without changing code | ❌ Not possible. Plugins are Go structs compiled into the binary |
.so / go:plugin hot-swap | ❌ Not present (go:plugin is not used in the current code) |
| Discover and register external plugins at runtime | ❌ Plugins are Registered during startup assembly |
| Lifecycle/hook management for a shared bus in one process | ✅ Real, PluginBus |
Why I stress this: "splitting the executor's god object into plugins" and "a ToolExpander that lets an Agent gain new skills without a restart" refer to completely different mechanisms — capability discovery in internal/runtime/protocol/skills (Discovery/Loader/Catalog/Resolver) and the event-callback registry in internal/ares_callbacks/. These do not go through PluginBus, and they are not "dynamic plugin loading". If what you want is "upload a plugin bundle to extend capabilities", the current code does not have that.
2.1 Don't confuse two systems: ares_callbacks ≠ plugins
internal/ares_callbacks/ is an independent event-callback registry (Registry, On(event, handler) / Emit(ctx)) handling lifecycle events like llm.start/end/error/token, agent.start/end/error, and tool.start/end/error. It's consumed via the Emitter interface (e.g. WithCallbacks on the LLM client). It has the shape of callback registration/dispatch, but it is not a RuntimePlugin — it has no Capabilities/Start/Stop and does not hang off the PluginBus.
Memory aid:
ares_callbacksis "broadcast lifecycle events to subscribers"; thePluginBusinares_runtimeis "manage the lifecycle of compiled-in plugins and invoke step extension points". One is event dispatch; the other is a plugin bus.
2.2 The difference from skill capability discovery
internal/runtime/protocol/skills's SkillCatalog / SkillLoader / Resolver handle "skill manifests + the tool-trust gate" (see the TrustLevel discussion in the Security Hardening article). Skill discovery does read SKILL.md / manifest files from disk at runtime, which looks dynamic — but that's the dynamism of capability data, not of plugin code. Declared tools still have to pass the Resolver trust gate to become runnable providers, and they are not brought under PluginBus lifecycle management.
3. The trade-off: why "compiled-in interface + registry" is actually reasonable
Pushing past the imagination the word "plugin" conjures, RuntimePlugin + PluginBus has clear value:
- Splits cross-cutting concerns off one execution path: checkpoint, interrupt, recovery, loop, memory, and evolution all hang on the bus with a uniform interface, so the execution core doesn't have to know the details of each.
- Centralized lifecycle management:
Start/Stop(reverse order), timeouts, panic recovery, and event publishing are all provided by the bus — plugin authors don't reinvent them. - Observe-only hooks that don't block: the log-and-continue contract of
BeforeStep/AfterStepmeans an observational plugin breaking won't take down scheduling. - Decoupled from the kernel: the note in
runtime_bridge.gois key — "the adapter lives in runtime_bridge.go — the kernel stays free of any runtime import" (Section 0.3 dependency rule). The kernel doesn't importruntime; the plugin seam is opened at the assembly layer (cmd/ares).
The cost is equally direct: an extension must be compiled in to take effect, or it goes through the ares_skills capability-data path. If you're building a platform and want third parties to contribute capabilities without recompiling, that path is currently closed (待核实 / to be verified: whether a dynamic-loading evolution exists but isn't merged).
flowchart LR
subgraph kernel[kernelscheduler]
QS[scheduler quantum step]
end
subgraph assembly[cmd/ares runtime_bridge]
BUS[PluginBus]
LOOP[LoopPlugin]
end
QS -. WithQuantumHook .-> BUS
BUS --> LOOP
subgraph concerns[other candidate plugins on the same bus]
CP[CheckpointPlugin]
REC[BasicRecoveryPlugin]
IRQ[InterruptPlugin]
EV[defaultEvolutionPlugin]
end
4. Conclusion
- What really exists as a plugin mechanism = the
RuntimePlugininterface contract ininternal/runtime+ thePluginBus(registration / lifecycle /BeforeStep/AfterStephooks / event system), assembled in production atcmd/ares/peer_mode.go→startPluginBus. - The plugin actually wired into the kernel today is mainly
LoopPlugin(the kernel round clock), attached at the scheduler quantum boundary viaWithQuantumHook. - It is not dynamic plugin loading: no
.so/go:plugin/ hot-loading. To extend capabilities without code changes, you take theares_skillscapability-data path — a completely different mechanism. - Don't confuse
ares_callbacks(event-callback registry) withPluginBus(plugin bus) — one broadcasts events, the other manages plugin lifecycle.
Said honestly, you might think "that doesn't count as a plugin system". But in the current code it is, in fact, a plugin system — just a compiled-in one. Seeing clearly what it is not is more valuable than overselling what it is.
Next in the series: TBD. If something here makes you want a deeper dive on a specific module, tell me.
Verification note: all symbols above come from the actual source; "whether dynamic plugin loading is under development" is flagged to be verified, because I did not see it merged during this read.