RUNTIME(7) NPCAgent Manual RUNTIME(7)

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:

npcagent/runtime.pylines 196–200
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.

Module topology and permitted import directions Five layers stacked from contracts at the bottom through kernel, planner and catalog, to plugins, with runtime.py above them all as the composition root. A forbidden edge from kernel to plugins is marked with a cross. npcagent/runtime.py composition root · imports every layer below npcagent/plugins/ may import contracts · kernel · planner clis/ claude_code · codex gemini_cli · opencode cao_adapter model_catalog npc_mcp_server triggers/ cron webhook file_watch tools/ messaging · review workflow_signals · tasks persistence · external memory_tools 36 builtin tools mcp/ local_mcp tool_adapter surfaces/ cli · acp · api dashboard · codeman npcagent/planner/ may import contracts · kernel stage1 stage2 stage2_heuristic validator capabilities ledger eval npcagent/catalog/ workflows/ — 4 JSON definitions skills/ — 20 SKILL.md folders prompts/ — 7 role templates data only; imports nothing npcagent/kernel/ may import contracts only — 37 modules run state runs · lifecycle · retry events · event_log · bus deadline_watcher needs_you · tasks primitives · spawn db · repo_init workflow workflow_state workflow_def workflow_executor signals plan_parser worktrees memory & skills memory_scope · memory_local memory_facts · memory_inject memory_search · compaction skill_format · skill_provider skill_index infrastructure registry · manifest tools (ToolRegistry) config · log · context redaction cli_lifecycle npcagent/contracts/ pure Protocol definitions — imports nothing else in the package base.py (PluginKind: memory · skill · tool · cli · trigger · mcp · workflow · surface) cli.py · mcp.py · memory.py · skill.py · surface.py · tool.py · trigger.py · workflow.py kernel ↛ plugins The forbidden edge is enforced by tests/test_layering.py — three tests grep every .py file for import statements that cross a layer.
Figure 3 — module topology. Arrows show permitted import direction. 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/:

tests/test_layering.pytest_runtime_module_lives_outside_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.

Runtime construction order and the attributes it populates Eleven construction steps on the left, connected to the Runtime object's attributes on the right, showing which step populates which attribute and where the ordering constraints lie. CONSTRUCTION ORDER (synchronous) RUNTIME ATTRIBUTES Runtime repo_root: Path db_path: Path settings: NPCAgentSettings logger: BoundLogger bus: EventBus plugin_registry: Registry context: Context tool_registry: ToolRegistry tools_loaded: int skill_provider: LocalSkillProvider skill_index: SkillIndex memory_provider: LocalMemoryProvider mcp_provider: LocalMCPProvider mcp_tools_loaded: int capability_catalog: CapabilityCatalog shutdown() · db_context() refresh_mcp_tools() rebind_workspace_tools(dir) 1 · resolve repo_root · repo_db_path() · db.bind_db_path() 2 · get_settings() · get_logger("npcagent.runtime") 3 · bus = EventBus(persist=self._persist_event) bus first — Context hands the same instance to every plugin 4 · plugin_registry = Registry(repo_root) build_context(bus, registry) → registry.bind_context(ctx) bootstrap cycle: the registry needs a Context, plugins want ctx.registry 5 · tool_registry = ToolRegistry(bus=bus) 6 · skill_provider.setup(ctx) · SkillIndex(provider) walks catalog/skills → ~/.npcagent/skills → ./.npcagent/skills 7 · MemoryScope(repo_path) → LocalMemoryProvider(scope) 8 · LocalMCPProvider().setup(ctx) — reads ~/.npcagent/mcp.json 9 · register_builtin_tools(registry, memory_provider, working_dir) → 36 tools. Not crash-isolated: a failure here is a programming error. must follow step 7 — the memory tools close over the provider 10 · build_catalog_from_registries(...) [sparse] 11 · if discover: plugin_registry.discover() entry points → ~/.npcagent/plugins/ → <repo>/plugins/ (first wins) then adapt every kind=TOOL plugin · rebuild the catalog catalog is rebuilt because it was first built against empty registries async · await runtime.refresh_mcp_tools() — surfaces call this before planning; registers mcp.<server>.<tool>, rebuilds the catalog again
Figure 4 — construction order. Two constraints are non-negotiable. The bus is built first so every plugin receives the same fan-out instance through 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 and resolve()-d, so a relative path, a ~ path and a symlinked path collapse to one cache entry. Construction is serialised under a module-level threading.Lock. The discover keyword 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's finally.
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_artifact and 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.
Which nine

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 the events table 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 named npcagent.bus[<name>]. On overflow the bus discards the oldest queued item and logs event_bus.subscriber_queue_full with dropped_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 as event_bus.predicate_raised. Neither takes the bus down. CancelledError is 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:

npcagent/runtime.pylines 401–405
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:

npcagent/kernel/workflow_executor.pylines 610–618
  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.

See also

npcagent 0.1.0 Apache-2.0 RUNTIME(7)