runtime — the per-repo composition object
Name
npcagent.runtime.Runtime — a single object, one per repository
path, that owns the event bus, the plugin registry, the tool registry, the memory
provider, the skill index, the MCP client and the capability catalog. Every surface — the
CLI, the REST API, the ACP server, npc serve — obtains the same instance
through Runtime.for_repo(repo_root) and therefore observes the same event
stream and the same tool table.
Direct construction is refused:
196 if not _from_for_repo: 197 raise RuntimeError( 198 "use Runtime.for_repo(repo_root) instead of constructing " 199 "Runtime directly so per-repo state is shared correctly", 200 )
The _from_for_repo flag is the factory's private
bypass. Anything else that instantiates Runtime would get a second event
bus for the same repository, and events published on one would be invisible to
subscribers of the other.
Layers
The package has a one-way dependency direction, and it is not merely a convention — it is asserted by a test that greps every source file.
catalog/ is data, not code, and is read through
LocalWorkflowProvider and LocalSkillProvider. The dashed edge
is the one relationship that must not exist: the kernel hosts plugins, so a kernel
module importing npcagent.plugins would invert the relationship and make
crash isolation impossible.The enforcement is worth quoting, because it explains why runtime.py lives
at the package root rather than in kernel/:
kernel_runtime = PKG_ROOT / "kernel" / "runtime.py" assert not kernel_runtime.exists(), ( "npcagent/kernel/runtime.py must not exist; the runtime composition " "object belongs at npcagent/runtime.py to keep the kernel pure" )
Runtime has to wire kernel primitives to plugin implementations. If
it lived inside the kernel, the kernel would import plugins. Moving it out is what
makes the rule satisfiable. The other two tests assert that no kernel file imports
npcagent.plugins and that no contracts file imports
kernel, plugins, catalog or
planner.
Composition
Runtime.__init__ runs synchronously and in a fixed order. The order is
load-bearing in two places, and both are commented in the source.
Context. The memory provider is built before the builtin tools because
memory_get, memory_put, memory_list_keys,
memory_search and memory_compact_now close over it. The
capability catalog is built three times — sparse, after discovery, and again after MCP
refresh — because the planner must never see a stale view of what exists.Crash isolation is layered. Registry.discover() already wraps each plugin's
import and setup() in its own try/except, so one bad plugin is dropped rather
than fatal. Runtime wraps the whole discover() call again, with the
stated reasoning that "a runtime that came up with no plugins is still preferable to a
hard import error in CLI startup". Tool adaptation is isolated a third time, per plugin —
a provider whose schema() raises, or whose tool name collides with a builtin,
is logged and skipped.
Cache and shutdown
- Runtime.for_repo(repo_root, *, discover=True)
- Paths are
expanduser()-ed andresolve()-d, so a relative path, a~path and a symlinked path collapse to one cache entry. Construction is serialised under a module-levelthreading.Lock. Thediscoverkeyword only takes effect on first construction — a later call returns the cached instance regardless of what you pass. - Runtime.reset_cache()
- Drops every cached reference. It deliberately does not call
shutdown()on the instances it drops; the caller is responsible, typically from a test fixture'sfinally. - await runtime.shutdown()
- Idempotent. Disconnects MCP sessions, tears down every plugin in reverse load order, closes the bus (cancelling subscriber tasks and awaiting them), and disposes the SQLAlchemy engine for that DB path. A second call is a silent no-op so signal handlers and test fixtures need not coordinate.
- runtime.rebind_workspace_tools(working_dir)
- Re-registers nine filesystem and environment tools against a different directory.
This exists for one specific reason: when an agent runs in a per-run worktree, its
save_artifactand environment tools must operate in the worktree, while its workflow-signal tools must still write to the parent repo's database. The two working directories are threaded separately all the way into the CAO profile.
save_artifact, read_artifact, web_search,
fetch_url, notify, current_time,
set_environment_variable, get_environment_variable,
list_environment_variables — the frozenset
_WORKSPACE_TOOL_NAMES at the top of runtime.py.
The bus
EventBus has three properties that matter for correctness, and all three
are unusual enough to be worth stating plainly.
- Persist before fan-out
publish()writes the event to theeventstable first, then delivers it to subscribers. If persistence raises, no subscriber sees the event at all. The consequence is that the durable log is never behind the live view, so a client can disconnect and replay from SQLite without a gap.- Per-subscriber queues, drop-oldest
- Each subscriber gets its own
asyncio.Queue(maxsize=1000)and its own consumer task namednpcagent.bus[<name>]. On overflow the bus discards the oldest queued item and logsevent_bus.subscriber_queue_fullwithdropped_oldest=True. A slow subscriber degrades itself and nothing else. - Handler and predicate crash isolation
- A handler that raises is logged as
event_bus.handler_raised; a predicate that raises is logged asevent_bus.predicate_raised. Neither takes the bus down.CancelledErroris re-raised so shutdown still works.
Runtime supplies its own persistence callback rather than relying on the module default, because the kernel's DB helpers resolve their path from a ContextVar that may be bound to a different repo:
401 async def _persist_event(self, event: BaseEvent) -> None: 402 """Persist bus events into this runtime's DB, independent of ambient context.""" 404 with self.db_context(): 405 await append_event_log(event)
Without this, an event published while another repo's DB path was
bound would be written to the wrong database. The DB path resolution order is:
ContextVar binding → configured path → NPCAGENT_DB_PATH →
~/.npcagent/global.db.
Design tensions
Three places where the architecture pays a visible cost for its own rules.
The kernel cannot name a planner exception
WorkflowExecutor must tolerate a Stage 2 expansion failure, but the kernel
is not allowed to import npcagent.planner. It therefore catches
Exception and discriminates by class name string:
except Exception as exc: # Crash-isolate ``PlanExpansionError`` (and any other expander # failure) ... We match by class name so the kernel # need not import from the planner package. if type(exc).__name__ == "PlanExpansionError":
Honest but fragile: renaming the exception in
planner/stage2.py would silently change the executor's logging branch
without any type checker noticing.
Only one MCP provider shape is actually usable
Third-party plugins declaring kind = "mcp" are discovered and appear in
npc plugin list, but Runtime will not adapt their tools. The reason is
stated in _select_registry_mcp_provider: the base MCPProvider
protocol can connect, list and call a named server, but it does not define how
Runtime is supposed to discover those names. Only LocalMCPProvider
exposes server_names(), so only it is refreshed. Anything else logs
runtime.mcp_provider_unsupported.
The heuristic planners live on different sides of the line
HeuristicStage2Planner is in planner/stage2_heuristic.py where
you would expect it. HeuristicStage1Planner is not — it is defined at line
419 of plugins/surfaces/cli/goals.py, inside the CLI surface. Nothing breaks,
but the asymmetry means the offline Stage 1 plan is owned by a surface module rather than
by the planner package.