Locking it down¶
Starting from the chat/ package Watching the graph left you
with, you will seal capability access, type the llm capability as a Protocol, add
the BatchLoadError rollback recipe to host.py, and add graceful shutdown. This is
the last stage — what you finish with matches
examples/example_host/
byte-for-byte.
What you will change¶
shutdown.py(new) — traps SIGINT/SIGTERM (and asystem.shutdownevent any plugin can emit) and blocksmain()until one arrivesmodel.py/terse_model.py—provides={"llm"}becomesprovides={LLM}, aProtocoltypeagent.py—requires={"llm"}becomesrequires={LLM}; also gains the "agent: ..." print that used to live inhost.pyconductor.py— absorbs the say/reply loop, the roster query, and the closing narration thathost.pyused to drive directlyhost.py— shrinks to an agnostic loader: boot the folder, roll back on a bad batch, resolveshutdown_handling, waitflaky.py(deleted) — it only existed to give tutorial 6's supervisor something to evict; the final graph needs nothing broken to justifysupervisor.py's presence
Running it prints the same conversation and persona swap as tutorial 6, minus the
flaky-eviction sequence, then blocks until Ctrl-C — which now exits cleanly instead of
raising a bare KeyboardInterrupt.
Why the driving logic belongs in a plugin, not the host¶
Every tutorial from 2 onward has quietly carried a tension: host.py decides when to
say each line, correlates replies by cid, and prints the transcript — all "the host
never mentions plugin behavior" work happening outside any plugin. Tutorial 4 named
this honestly as temporary scaffolding, because at that point in the series no plugin
was free to own it yet.
But watcher.py already proved the alternative works: graph control — hot-loading a
changed file through kernel.lifecycle — lives in plugin-land, resolved through
get_capability like anything else. The demo's say/reply loop is no different in kind.
It's built from emit/subscribe/create_background_task, exactly like agent.py's own
reply-handling — which means it belongs in a plugin for the same reason agent.py is a
plugin and not inlined into host.py.
conductor.py — small since tutorial 2, just a persona-swap trigger — absorbs the rest:
the cid-correlated _say/_on_reply loop, the roster.report query, and the closing
line. host.py no longer prints a single line about what any plugin does; its only
plugin-shaped knowledge is host_configs(), which is deployment data, not driving
code.
conductor.py: the whole script, in one plugin¶
class Conductor(Plugin):
def __init__(self) -> None:
super().__init__(
name="conductor",
requires={"kernel.lifecycle"},
events_published={"user.says"},
hooks_consumed={"roster.report"},
config_schema={
"autorun": ConfigField(bool, True, "run the scripted demo conversation on start"),
},
)
self._cids = itertools.count(1)
self._pending: dict[str, asyncio.Future[str]] = {}
async def on_start(self) -> None:
self._lifecycle = await self.get_capability("kernel.lifecycle")
if self.config("autorun"):
await self.create_background_task(self.run_script(), name="conductor-script")
@event("agent.says.*")
async def _on_reply(self, ev: Event) -> None:
future = self._pending.pop(ev.name.rsplit(".", 1)[-1], None)
if future is not None and not future.done():
future.set_result(ev.data["text"])
async def _say(self, text: str) -> str:
cid = f"c{next(self._cids)}"
self._pending[cid] = asyncio.get_running_loop().create_future()
print(f"user: {text}") # noqa: T201
await self.emit("user.says", {"cid": cid, "text": text})
return await asyncio.wait_for(self._pending[cid], timeout=2.0)
async def run_script(self) -> None:
await self._say("hello there")
try:
await self._lifecycle.load_plugin(_GRUMPY.read_text(), origin=str(_GRUMPY))
except (PluginError, CoreError) as exc:
logger.warning("conductor: persona hot-swap failed: %s", exc)
return
print("...[hot-reloaded the persona]...") # noqa: T201
await self._say("what's the weather like?")
report = await self.hook("roster.report", firstresult=True)
print(f"roster: {report}") # noqa: T201
print("conversation done — Ctrl-C to exit (edit grumpy_persona.py meanwhile!)") # noqa: T201
Everything here is a straight relocation of primitives you've already used — emit,
self.hook(..., firstresult=True), create_background_task — pointed at a new
purpose. The one new detail is why run_script runs as a background task instead of
an inline await in on_start:
conductor requires only "kernel.lifecycle", which is always satisfiable, so it
commits in its plain alphabetical slot — before agent.py, which is pushed to the very
end of the batch by its requires={LLM} edge onto both llm providers. If
run_script() ran inline and its first await self._say(...) blocked waiting on a
reply from agent, that would be load_plugins waiting, mid-batch, on a plugin that
hasn't started yet inside the same call that will eventually start it — a deadlock.
create_background_task lets on_start return immediately so the batch keeps
committing; the script's first await then resolves once agent actually starts,
same as agent.py's own _answer() runs as a background task so respond() can
return fast.
Typed capabilities: Protocol instead of a bare string¶
model.py and terse_model.py now each declare their own copy of the contract:
@runtime_checkable
class LLM(Protocol):
async def reply(self, text: str, persona: str) -> str: ...
class Model(Plugin):
def __init__(self) -> None:
super().__init__(name="model", provides={LLM}, tags={"prose"}, ...)
agent.py declares the same shape and does requires={LLM},
get_capability(LLM, tag=self.config("model_tag")). Neither file imports the other's
LLM — the two are structurally identical, and that's the point: the capability name
is derived from the type (LLM → "llm"), checked structurally at admission, not by
identity. It's the same "downloaded, not imported" idea behind every capability in this
series — a string name worked fine when nothing needed to verify shape, and now
something does.
capability_access="sealed"¶
host.py boots the whole graph under Core(capability_access="sealed", ...). Sealing
tightens two things beyond the default "open" mode:
- What a plugin can ask for at all.
get_capability()is gated to the plugin's ownrequiresset (plus anything inresolves=, below) — asking for something outside that grant raisesCapabilityAccessError, whether the name is a string or aProtocoltype. - What a typed lookup gives back.
agent.py'sget_capability(LLM, tag=...)doesn't return the liveModelinstance under"sealed"— it returns aCapabilityFacet: an object that forwards only the methodsLLMdeclares (reply, here — nothing else onModelis reachable through it), re-resolves the live provider on every call (so a hot-swappedterse_model.pyrebinds transparently, and a revoked one raisesStalePluginErroron the next call), and refuses to let any method's return value leak a live plugin orCorehandle back out (the "sealed return guard"). None of this shows up in the transcript —self.llm.reply(...)reads identically to calling the real method — but every call now passes through that guard instead of touchingModeldirectly.
An untyped string lookup under "sealed" still returns the raw provider — there's no
Protocol to attenuate to. Typing the capability is what buys the facet.
resolves=: a grant without a registration promise¶
Nothing in this program's final graph actually needs resolves= — every capability
each plugin touches is already in that plugin's own requires. It's worth knowing
about anyway, because "sealed"'s gate checks requires ∪ resolves, and the two mean
different things: requires is validated at registration — the capability must exist
in the graph already, or registration fails outright. resolves is deliberately
not — a plugin can name a capability there that has no provider yet, to authorize a
lookup it expects to succeed only later:
class Dashboard(Plugin):
def __init__(self) -> None:
super().__init__(name="dashboard", resolves={"metrics"})
async def on_start(self) -> None:
# "metrics" isn't required — dashboard runs fine without it. But if some
# other plugin loads later and starts providing it, sealed mode already
# authorizes this plugin to ask.
self.metrics = None
with contextlib.suppress(CapabilityError):
self.metrics = await self.get_capability("metrics")
Under "open" this distinction doesn't matter — anything resolves. Under "sealed"
it's the difference between "this plugin might reach for X later" being declared
up front, in the manifest, versus silently failing every time until the day X exists.
host.py: the rollback recipe, and waiting for shutdown¶
async def build_host(core: Core) -> tuple[str, ...]:
try:
return await core.load_plugins(_boot_sources())
except BatchLoadError as e:
for name in reversed(e.installed): # () on a plan-phase fault → no-op
await core.unregister_plugin(name)
raise
load_plugins commits the whole folder or nothing — but "nothing" needs qualifying.
BatchLoadError.installed is the tuple of names that did commit before the failure,
in commit order; on a fault caught during planning (a cycle, a missing capability, a
source that fails to compile) it's empty and the loop is a no-op, but a fault raised
by a candidate's own on_start() partway through commit leaves a real prefix live. The
recipe above unwinds that prefix in reverse before re-raising, so a bad batch never
leaves a partial graph running. This isn't demonstrated live in this tutorial — it's
exercised directly in the repository's own acceptance suite
(tests/test_example_host.py), which breaks a required config value on purpose and
asserts the graph ends up empty again.
The other change in main():
async with Core(capability_access="sealed", plugin_configs=host_configs()) as core:
await build_host(core)
shutdown = await core.get_capability("shutdown_handling")
await shutdown.wait_for_shutdown()
Tutorial 5 named the gap plainly: "Ctrl-C raises a bare KeyboardInterrupt — there's no
graceful shutdown yet." shutdown.py closes it. host.py resolves it through the same
capability surface every plugin uses — it holds no special access, just a name in its
own requires... except host.py isn't a plugin, so it has no requires at all, and
under "sealed" that would ordinarily be a problem. It isn't one here because Core
itself is never subject to its own access policy — only plugins are gated by
capability_access. The host is the one place in the whole program still holding the
real Core.
shutdown.py: signals and a bus event, converging on one wait¶
class ShutdownHandler(Plugin):
def __init__(self) -> None:
super().__init__(name="shutdown_handler", provides={"shutdown_handling"})
self._shutdown_event = asyncio.Event()
async def on_start(self) -> None:
loop = asyncio.get_running_loop()
for sig in _SHUTDOWN_SIGNALS:
loop.add_signal_handler(sig, self._on_signal, sig)
def _on_signal(self, sig: signal.Signals) -> None:
asyncio.get_event_loop().create_task(
self.emit("system.shutdown", {"source": "signal", "signal": sig.name})
)
self._shutdown_event.set()
@event("system.shutdown")
async def _on_shutdown_event(self, ev: Event) -> None:
self._shutdown_event.set()
async def wait_for_shutdown(self) -> None:
await self._shutdown_event.wait()
Two paths converge on the same asyncio.Event: an OS signal (Ctrl-C, or SIGTERM from
a process manager) sets it directly and emits "system.shutdown", so any plugin —
not only the OS — can ask for a clean stop by emitting that event itself. on_stop
removes the signal handlers, so the trap is live exactly while this plugin is
registered. Windows has no loop.add_signal_handler; the real file bridges
signal.signal's off-loop callback onto the loop with call_soon_threadsafe instead —
see Shut down gracefully for that path in
full.
Once wait_for_shutdown() returns, async with Core(...) exits and core.stop()
tears the graph down in reverse dependency order — the same teardown roster.py has
been narrating since tutorial 6, now triggered by Ctrl-C instead of the process just
ending.
Run it¶
roster: + roster
roster: + shutdown_handler (provides shutdown_handling)
roster: + supervisor
roster: + terse_model (provides llm)
roster: + watcher
roster: + agent
user: hello there
agent: Cheerfully #1: you said 'hello there'.
...[hot-reloaded the persona]...
user: what's the weather like?
roster: ~ persona hot-swapped
agent: Grumpily #2: you said 'what's the weather like?'.
roster: 9 plugins live; capabilities: llm, shutdown_handling
conversation done — Ctrl-C to exit (edit grumpy_persona.py meanwhile!)
conductor, model, and persona don't appear in that list — they commit
alphabetically before roster, so roster.py's own on_start snapshots them
silently instead of printing their arrival; only registrations after that point get
the + line. Press Ctrl-C and the graph tears down cleanly:
roster: - agent
roster: - watcher
roster: - terse_model
roster: - supervisor
roster: - shutdown_handler
roster: ~ capability shutdown_handling revoked
— then the process exits with no traceback. The folder watcher is still live for as
long as the process runs: edit persona.py (not grumpy_persona.py, already swapped
in) and save, and you'll see the same live hot-reload tutorial 5 demonstrated, right up
until you press Ctrl-C.
The key idea¶
Sealing, typed capabilities, resolves=, and graceful shutdown aren't new kernel
machinery — they're the same primitives this whole series has used, pointed at a
stricter question: not just "what can a plugin do," but "what is a plugin allowed to
ask for, and how honestly does the kernel enforce that boundary." Downloaded policy
goes all the way down, including who's allowed to ask for what — the kernel provides
the gate and the facet; deciding where the gate sits is still policy, not core.
Series closed¶
chat/ now matches
examples/example_host/
byte-for-byte. From here:
- Architecture overview — the capability
system section covers
CapabilityFacet, sealed mode, and the return guard in more depth - Shut down gracefully — the cross-platform
signal-handling path
shutdown.pytakes for granted here - Work with capabilities — the full
requires/resolves/capability_accessreference - Boot a plugin graph in order —
BatchLoadError,try_load_plugins, and the rest of the batch-boot contract