Skip to content
This repository was archived by the owner on May 26, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions agent/identity_spec.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""KR-PLUGIN-IDENTITY (Option C) — IdentitySpec dataclass + provider type.

# The gap

Hermes' reasoning engine reads its system prompt from a HARDCODED
file path at engine construction (``anthropic_engine.py:231``).
This couples the agent's identity to a specific filesystem
location, which works fine when the runtime is a single-tenant
fork but blocks two emerging use cases:

1. **Multi-tenant Hermes deployments** — an operator wants to
run "Kora" + "Marvin" + "Dave" agents off the same Hermes
install; each needs its own identity prompt.
2. **pip-installable agent bundles** — a future
``pip install kora-runtime`` shouldn't require shipping a
specific filesystem layout. The bundle should be able to
declare "here's my identity" via a plugin-provided value
rather than a file-read at a Kora-specific path.

This module exposes the registration surface for that category
so plugin authors can register a single identity provider
without forking the runtime. The fallback file-read path stays
in the engine for bare-Hermes (no Kora plugin loaded) users.

# Shape

:class:`IdentitySpec` carries the resolved identity surface as a
frozen dataclass — the agent's persona prompt (``soul_md_content``;
historically loaded from ``~/.kora/SOUL.md``) AND the reasoning-
engine prompt (``system_prompt_content``; historically loaded
from ``kora_system_prompt.md``). Both fields are required strings;
``identity_metadata`` is an optional dict for free-form metadata
(agent name, version, kora-specific routing hints, etc.).

# Provider callable type

:data:`IdentityProvider` is the callable a plugin registers via
``PluginContext.register_identity_provider``. It receives the
firing kwargs (``engine`` plus any future-added context fields)
and returns either an :class:`IdentitySpec` OR ``None`` (fall
through to other providers / file-read default).

# Out of scope for this module (intentional)

This module is **specification-only**. It carries the dataclass +
provider type. The hook firing site lives in the reasoning
engine; the provider-registration convenience method lives on
``PluginContext`` in ``kora_cli/plugins.py``. Keeping the spec
separate makes it cleanly importable from tests, plugins, and
the engine without circular imports.

# Backward compatibility

This is a NEW module; no existing code paths are affected. The
engine's existing file-read fallback continues to work when no
plugin provides an identity. Plugins that do provide one take
precedence (first-non-None-wins per the existing hook semantic).
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any, Callable, Dict, Optional


@dataclass(frozen=True)
class IdentitySpec:
"""The resolved identity surface for one agent instance.

Frozen so a plugin handler can't mutate the spec after returning
it (the engine receives an immutable snapshot). All fields are
required — a partial spec (only soul_md OR only system_prompt)
should not be expressed via this dataclass; instead return
``None`` to fall through to other providers.

Attributes:
soul_md_content: The agent's persona / identity prompt — the
text historically loaded from ``~/.kora/SOUL.md`` via
``agent/prompt_builder.py:load_soul_md``. Carried here so
downstream consumers (prompt_builder, future skin layers)
can resolve identity via the plugin surface instead of
the filesystem.
system_prompt_content: The reasoning-engine prompt — the
text historically loaded from
``kora_docs/00_canonical_current_state/kora_system_prompt.md``
by ``anthropic_engine.py:231``. Prepended to every
inference request as the Anthropic SDK's ``system`` field.
identity_metadata: Free-form dict for plugin-author-provided
context. Conventional keys: ``"agent_name"`` (str),
``"agent_version"`` (str), ``"plugin_name"`` (str — set by
the registration helper). Engine ignores unknown keys.
"""

soul_md_content: str
system_prompt_content: str
identity_metadata: Dict[str, Any] = field(default_factory=dict)


# Type alias for plugin-author-supplied identity providers.
# Receives the engine instance + any future-added context kwargs;
# returns an IdentitySpec to claim the identity, or None to fall
# through to other providers (first-non-None-wins per the
# pre_agent_identity_set hook semantic).
IdentityProvider = Callable[..., Optional[IdentitySpec]]
51 changes: 48 additions & 3 deletions kora_cli/listeners/alert_notifier_listener.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@
from typing import Any, Optional

from kora_cli.alerts.notifier import AlertNotifier
from agent.background_daemon_registry import (
BackgroundDaemonEntry,
PeriodicTaskSpec,
background_daemon_registry,
)
from kora_cli.daemon import DEFAULT_SHUTDOWN_TIMEOUT, register_daemon_listener
from kora_cli.listeners.heartbeat import register_periodic_task

Expand Down Expand Up @@ -221,7 +226,7 @@ async def run_digest_flush() -> None:
class AlertNotifierListener:
"""Holds the live :class:`AlertNotifier` for the daemon's lifetime."""

async def startup(self) -> None:
async def startup(self, coordinator=None) -> None:
"""Construct the notifier bound to live client lazy factories.

Fail-soft on construction errors so daemon boot doesn't
Expand Down Expand Up @@ -290,9 +295,16 @@ def _purelymail_client_factory() -> Optional[Any]:
# ---------------------------------------------------------------------------


# Process-wide singleton — KR-DAEMON-LISTENERS-VIA-GATEWAY Phase 2.
_listener_singleton = AlertNotifierListener()


def _factory():
listener = AlertNotifierListener()
return (listener.startup, listener.shutdown, DEFAULT_SHUTDOWN_TIMEOUT)
return (
_listener_singleton.startup,
_listener_singleton.shutdown,
DEFAULT_SHUTDOWN_TIMEOUT,
)


register_daemon_listener("alert_notifier", _factory)
Expand All @@ -318,3 +330,36 @@ def _factory():
interval_seconds=_read_digest_interval(),
callable=run_digest_flush,
)


# ---------------------------------------------------------------------------
# Hermes-side registration (Phase 2; Path B thin-shim same as snapshot #196)
# ---------------------------------------------------------------------------
#
# Multi-task listener: primary notify cycle goes in periodic_task;
# digest_flush stays on the Kora heartbeat scheduler (per the §4.1
# audit recommendation c). Phase 4 may extend PeriodicTaskSpec to
# a list shape; for now both tasks fire correctly under the existing
# wirings.

_hermes_entry = BackgroundDaemonEntry(
name="alert_notifier",
startup=_listener_singleton.startup,
shutdown=_listener_singleton.shutdown,
periodic_task=PeriodicTaskSpec(
interval_seconds=_read_interval(),
callback=run_notification_cycle,
name="alerts.notify",
),
shutdown_timeout=DEFAULT_SHUTDOWN_TIMEOUT,
plugin_name="kora",
)

try:
background_daemon_registry().register(_hermes_entry)
except ValueError as _exc:
logger.debug(
"[kora.alert_notifier_listener] hermes registry already had "
"'alert_notifier' entry: %s — skipping duplicate registration",
_exc,
)
53 changes: 50 additions & 3 deletions kora_cli/listeners/cost_telemetry_listener.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@
from pathlib import Path
from typing import Optional

from agent.background_daemon_registry import (
BackgroundDaemonEntry,
PeriodicTaskSpec,
background_daemon_registry,
)
from kora_cli.daemon import DEFAULT_SHUTDOWN_TIMEOUT, register_daemon_listener
from kora_cli.listeners.heartbeat import register_periodic_task
from kora_cli.telemetry import (
Expand Down Expand Up @@ -306,7 +311,7 @@ class CostTelemetryListener:
registered at module-import time; this listener just emits
boot logs so operators can confirm wiring."""

async def startup(self) -> None:
async def startup(self, coordinator=None) -> None:
logger.info(
"[kora.cost_telemetry_listener] periodic tasks registered: "
"persist cadence=%ss, reset-tick cadence=%ss",
Expand All @@ -323,9 +328,16 @@ async def shutdown(self) -> None:
# ---------------------------------------------------------------------------


# Process-wide singleton — KR-DAEMON-LISTENERS-VIA-GATEWAY Phase 2.
_listener_singleton = CostTelemetryListener()


def _factory():
listener = CostTelemetryListener()
return (listener.startup, listener.shutdown, DEFAULT_SHUTDOWN_TIMEOUT)
return (
_listener_singleton.startup,
_listener_singleton.shutdown,
DEFAULT_SHUTDOWN_TIMEOUT,
)


register_daemon_listener("cost_telemetry", _factory)
Expand All @@ -348,3 +360,38 @@ def _factory():
interval_seconds=_read_reset_tick_interval(),
callable=run_monthly_reset_check,
)


# ---------------------------------------------------------------------------
# Hermes-side registration (Phase 2; Path B thin-shim same as snapshot #196)
# ---------------------------------------------------------------------------
#
# Multi-task listener: the audit's §4.1 recommendation (option c) says
# "let startup spawn its own asyncio loops" for daemons with more than
# one periodic task. We register the PRIMARY persist task here in the
# BackgroundDaemonEntry's periodic_task field; the two reset-check
# tasks stay on Kora's heartbeat scheduler via the register_periodic_
# task calls above. Phase 4 (scheduler dissolution) will revisit
# whether to extend PeriodicTaskSpec to a list-of-specs.

_hermes_entry = BackgroundDaemonEntry(
name="cost_telemetry",
startup=_listener_singleton.startup,
shutdown=_listener_singleton.shutdown,
periodic_task=PeriodicTaskSpec(
interval_seconds=_read_persist_interval(),
callback=run_persist_cycle,
name="cost_telemetry.persist",
),
shutdown_timeout=DEFAULT_SHUTDOWN_TIMEOUT,
plugin_name="kora",
)

try:
background_daemon_registry().register(_hermes_entry)
except ValueError as _exc:
logger.debug(
"[kora.cost_telemetry_listener] hermes registry already had "
"'cost_telemetry' entry: %s — skipping duplicate registration",
_exc,
)
48 changes: 44 additions & 4 deletions kora_cli/listeners/email_inbound_imap_listener.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@
import os
from typing import Optional

from agent.background_daemon_registry import (
BackgroundDaemonEntry,
PeriodicTaskSpec,
background_daemon_registry,
)
from kora_cli.daemon import DEFAULT_SHUTDOWN_TIMEOUT, register_daemon_listener
from kora_cli.listeners.heartbeat import register_periodic_task

Expand Down Expand Up @@ -226,7 +231,7 @@ class EmailInboundIMAPListener:
``None`` rather than aborting daemon boot.
"""

async def startup(self) -> None:
async def startup(self, coordinator=None) -> None:
try:
from kora_cli.clients.purelymail_imap_client import (
PurelymailIMAPClient,
Expand Down Expand Up @@ -279,14 +284,22 @@ async def shutdown(self) -> None:
)


# Process-wide singleton — KR-DAEMON-LISTENERS-VIA-GATEWAY Phase 2
# (matches the snapshot listener Phase 1 pattern from #196).
_listener_singleton = EmailInboundIMAPListener()


# ---------------------------------------------------------------------------
# Factory + registration (import-time side effect)
# Factory + Kora-side registration (back-compat — dissolves in Phase 6)
# ---------------------------------------------------------------------------


def _factory():
listener = EmailInboundIMAPListener()
return (listener.startup, listener.shutdown, DEFAULT_SHUTDOWN_TIMEOUT)
return (
_listener_singleton.startup,
_listener_singleton.shutdown,
DEFAULT_SHUTDOWN_TIMEOUT,
)


register_daemon_listener("email_inbound_imap", _factory)
Expand All @@ -307,3 +320,30 @@ def _factory():
interval_seconds=_read_poll_interval(),
callable=run_poll_cycle,
)


# ---------------------------------------------------------------------------
# Hermes-side registration (Phase 2; Path B thin-shim same as snapshot #196)
# ---------------------------------------------------------------------------

_hermes_entry = BackgroundDaemonEntry(
name="email_inbound_imap",
startup=_listener_singleton.startup,
shutdown=_listener_singleton.shutdown,
periodic_task=PeriodicTaskSpec(
interval_seconds=_read_poll_interval(),
callback=run_poll_cycle,
name="email.imap_poll",
),
shutdown_timeout=DEFAULT_SHUTDOWN_TIMEOUT,
plugin_name="kora",
)

try:
background_daemon_registry().register(_hermes_entry)
except ValueError as _exc:
logger.debug(
"[kora.email_inbound_imap_listener] hermes registry already had "
"'email_inbound_imap' entry: %s — skipping duplicate registration",
_exc,
)
Loading
Loading