From fc2499a3eddf024cde89dfad593a621c676ac252 Mon Sep 17 00:00:00 2001 From: CC#3 Kora Runtime Date: Sun, 24 May 2026 01:27:45 -0700 Subject: [PATCH] =?UTF-8?q?feat(kora):=20KR-PLUGIN-IDENTITY-OPTION-C-AND-D?= =?UTF-8?q?AEMON-PHASE2=20=E2=80=94=20full=20identity-as-plugin=20+=208-li?= =?UTF-8?q?stener=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two substantial deliverables in one bucket. After this: 7/7 plugin extractions complete (Lock R3-2 closed); 9/9 periodic-task listeners on the gateway path (Phase 2 of 6). Deliverable A — KR-PLUGIN-IDENTITY (Option C). Full identity-as-plugin architecture so the eventual `pip install kora-runtime` distribution works AND multi-tenant Hermes deployments (Kora + Marvin + Dave all on one install) are possible. New surface: - agent/identity_spec.py — IdentitySpec frozen dataclass (soul_md_content + system_prompt_content + identity_metadata) + IdentityProvider type alias - kora_cli/plugins.py — VALID_HOOKS += "pre_agent_identity_set"; new PluginContext.register_identity_provider convenience method that wraps the {"identity": IdentitySpec} envelope so plugin authors return raw IdentitySpec - kora_cli/reasoning/anthropic_engine.py — AnthropicReasoningEngine.__init__ now invokes the hook BEFORE the file-read fallback; first-non-None-wins; backward-compat fallback to kora_system_prompt.md path preserved for bare-Hermes-no-Kora-plugin users - kora_cli/reasoning/kora_hermes_plugin/identity/ — sub-plugin following the #185 template (constants + loader + plugin + register). Kora claims its canonical identity from kora_system_prompt.md + SOUL.md. Operator escape hatch: KORA_DISABLE_IDENTITY_PROVIDER=true falls back to file-read Upstream-PR drafting deferred per Joshua's amended feedback-local-first-upstream-after — the new hook lives in the Kora fork only; battle-testing happens before any upstream-PR work. Companion kora-docs PR has HOW_TO_BUILD_YOUR_OWN_AGENT.md proving the architecture supports a 3rd-party "Marvin" agent registering its own identity without forking Hermes. Deliverable B — KR-DAEMON-LISTENERS-VIA-GATEWAY Phase 2. 8 remaining periodic-task listeners migrated to dual-registry shape (Path B thin-shim, same pattern as snapshot listener in #196): heartbeat_probes, email_inbound_imap, probe_wake, mcp_consumption, cost_telemetry, alert_notifier, promote_phrasebook, promote_snapshot_expand. Each Listener-class-shape listener (6 of 8): - Process-wide _listener_singleton so both registries point at the same instance's bound methods (prevents double-log-on-startup if both consumers fire) - startup() now accepts optional coordinator kwarg (Hermes BackgroundDaemonEntry.startup is Callable[[Any], Any]; Kora's coordinator passes no arg) - BackgroundDaemonEntry registered with PeriodicTaskSpec carrying the same callback + interval the heartbeat scheduler runs - Defensive duplicate-registration guard for importlib.reload + xdist workers The 2 pure-periodic-task listeners (promote_phrasebook, promote_snapshot_expand) get no-op startup/shutdown wrappers + a BackgroundDaemonEntry that carries only the periodic task (no Kora-side LISTENER_REGISTRY entry to bridge — those never had one). Multi-task listeners (cost_telemetry has 3 periodic tasks; alert_notifier has 2) carry only the PRIMARY task in the BackgroundDaemonEntry per §4.1 audit recommendation c. The other tasks stay on Kora's heartbeat scheduler until Phase 4 (scheduler dissolution) revisits the multi-task shape. Bucket spec said "purelymail_client (IMAP poller)" but the actual IMAP poller is email_inbound_imap_listener.py — purelymail_client_listener.py is the outbound SMTP singleton-holder (not periodic-task). Migrated email_inbound_imap; flagged in PM hand-off. 3 newer promotion listeners (promote_probe_fix_envelopes, promote_router_tuning, promote_tool_trimming) were added after my prior audit. They follow the same shape as promote_phrasebook + promote_snapshot_expand; flagged for a tiny Phase 2.5 follow-up bucket. Tests: 78 new tests across identity (26) + Phase 2 listeners (38) + IdentitySpec dataclass (5) + test_kora_hermes_plugin (1 renamed + 1 new). 563/563 focused regression set green (all listener tests + all kora_hermes_plugin tests + cost_ladder + cost_telemetry). Two existing MockCtx tests updated to handle the new register_identity_provider method. Inventory state post-merge: - 7/7 plugin extractions (closes Lock R3-2): cost_ladder, audit, caching, short_circuit, state_holders, haiku_router, identity - 9/9 periodic-task listeners on the gateway path (Phase 2 complete) - Remaining listener migration phases: Phase 3 (singleton-holders: slack_client, purelymail_client, reasoning_engine), Phase 4 (scheduler dissolution), Phase 5 (HTTP service-mounts stay Kora-local), Phase 6 (DaemonCoordinator shim) Co-Authored-By: Claude Opus 4.7 (1M context) --- agent/identity_spec.py | 104 +++++ kora_cli/listeners/alert_notifier_listener.py | 51 +- kora_cli/listeners/cost_telemetry_listener.py | 53 ++- .../listeners/email_inbound_imap_listener.py | 48 +- .../listeners/heartbeat_probes_listener.py | 49 +- kora_cli/listeners/mcp_consumption.py | 45 +- kora_cli/listeners/probe_wake_listener.py | 45 +- .../listeners/promote_phrasebook_listener.py | 52 +++ .../promote_snapshot_expand_listener.py | 51 ++ kora_cli/plugins.py | 90 ++++ kora_cli/reasoning/anthropic_engine.py | 79 +++- .../kora_hermes_plugin/identity/__init__.py | 53 +++ .../kora_hermes_plugin/identity/constants.py | 61 +++ .../kora_hermes_plugin/identity/loader.py | 146 ++++++ .../kora_hermes_plugin/identity/plugin.py | 124 +++++ .../reasoning/kora_hermes_plugin/plugin.py | 14 +- plugins/kora_hermes/__init__.py | 4 + tests/agent/test_identity_spec.py | 46 ++ .../test_phase2_listener_migrations.py | 267 +++++++++++ tests/plugins/test_kora_hermes_plugin.py | 24 +- .../test_kora_hermes_plugin_haiku_router.py | 5 + .../test_kora_hermes_plugin_identity.py | 435 ++++++++++++++++++ 22 files changed, 1808 insertions(+), 38 deletions(-) create mode 100644 agent/identity_spec.py create mode 100644 kora_cli/reasoning/kora_hermes_plugin/identity/__init__.py create mode 100644 kora_cli/reasoning/kora_hermes_plugin/identity/constants.py create mode 100644 kora_cli/reasoning/kora_hermes_plugin/identity/loader.py create mode 100644 kora_cli/reasoning/kora_hermes_plugin/identity/plugin.py create mode 100644 tests/agent/test_identity_spec.py create mode 100644 tests/kora_cli/test_listeners/test_phase2_listener_migrations.py create mode 100644 tests/plugins/test_kora_hermes_plugin_identity.py diff --git a/agent/identity_spec.py b/agent/identity_spec.py new file mode 100644 index 000000000000..78d47804d9f8 --- /dev/null +++ b/agent/identity_spec.py @@ -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]] diff --git a/kora_cli/listeners/alert_notifier_listener.py b/kora_cli/listeners/alert_notifier_listener.py index b6600df1f8be..30644e9e32d7 100644 --- a/kora_cli/listeners/alert_notifier_listener.py +++ b/kora_cli/listeners/alert_notifier_listener.py @@ -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 @@ -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 @@ -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) @@ -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, + ) diff --git a/kora_cli/listeners/cost_telemetry_listener.py b/kora_cli/listeners/cost_telemetry_listener.py index d7393d623670..d0fabcc92177 100644 --- a/kora_cli/listeners/cost_telemetry_listener.py +++ b/kora_cli/listeners/cost_telemetry_listener.py @@ -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 ( @@ -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", @@ -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) @@ -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, + ) diff --git a/kora_cli/listeners/email_inbound_imap_listener.py b/kora_cli/listeners/email_inbound_imap_listener.py index 18d620fbacc5..4b29fe3ff135 100644 --- a/kora_cli/listeners/email_inbound_imap_listener.py +++ b/kora_cli/listeners/email_inbound_imap_listener.py @@ -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 @@ -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, @@ -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) @@ -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, + ) diff --git a/kora_cli/listeners/heartbeat_probes_listener.py b/kora_cli/listeners/heartbeat_probes_listener.py index 60a9a6c88a77..debbc51990ec 100644 --- a/kora_cli/listeners/heartbeat_probes_listener.py +++ b/kora_cli/listeners/heartbeat_probes_listener.py @@ -26,6 +26,11 @@ import logging import os +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.heartbeat_probes.runner import ( _clear_snapshot_cache, @@ -90,7 +95,7 @@ class HeartbeatProbesListener: restart. """ - async def startup(self) -> None: + async def startup(self, coordinator=None) -> None: interval = _read_probe_interval() logger.info( "[kora.heartbeat_probes] listener active; probe cycle every %ss " @@ -103,14 +108,23 @@ async def shutdown(self) -> None: logger.info("[kora.heartbeat_probes] snapshot cache cleared") +# Process-wide singleton — KR-DAEMON-LISTENERS-VIA-GATEWAY Phase 2 +# (matches the snapshot listener Phase 1 pattern from #196). Both +# registries point at the same instance. +_listener_singleton = HeartbeatProbesListener() + + # --------------------------------------------------------------------------- -# Factory + registration +# Factory + Kora-side registration (back-compat — dissolves in Phase 6) # --------------------------------------------------------------------------- def _factory(): - listener = HeartbeatProbesListener() - return (listener.startup, listener.shutdown, DEFAULT_SHUTDOWN_TIMEOUT) + return ( + _listener_singleton.startup, + _listener_singleton.shutdown, + DEFAULT_SHUTDOWN_TIMEOUT, + ) register_daemon_listener("heartbeat_probes", _factory) @@ -125,3 +139,30 @@ def _factory(): interval_seconds=_read_probe_interval(), callable=run_all_probes_scheduled, ) + + +# --------------------------------------------------------------------------- +# Hermes-side registration (Phase 2; Path B thin-shim same as snapshot #196) +# --------------------------------------------------------------------------- + +_hermes_entry = BackgroundDaemonEntry( + name="heartbeat_probes", + startup=_listener_singleton.startup, + shutdown=_listener_singleton.shutdown, + periodic_task=PeriodicTaskSpec( + interval_seconds=_read_probe_interval(), + callback=run_all_probes_scheduled, + name="heartbeat.service_probes", + ), + shutdown_timeout=DEFAULT_SHUTDOWN_TIMEOUT, + plugin_name="kora", +) + +try: + background_daemon_registry().register(_hermes_entry) +except ValueError as _exc: + logger.debug( + "[kora.heartbeat_probes] hermes registry already had " + "'heartbeat_probes' entry: %s — skipping duplicate registration", + _exc, + ) diff --git a/kora_cli/listeners/mcp_consumption.py b/kora_cli/listeners/mcp_consumption.py index 26755c59d24f..390c1063e41a 100644 --- a/kora_cli/listeners/mcp_consumption.py +++ b/kora_cli/listeners/mcp_consumption.py @@ -49,6 +49,11 @@ from datetime import datetime, timezone 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_mcp.catalog import load_effective_catalog @@ -223,7 +228,7 @@ def __init__(self) -> None: def pool(self) -> Optional[MCPClientPool]: return self._pool - async def startup(self) -> None: + async def startup(self, coordinator=None) -> None: """Build the pool from the effective catalog. Lazy — no transport opens at this point. Any pool construction error propagates so the coordinator can abort the daemon.""" @@ -296,9 +301,16 @@ def current_pool() -> Optional[MCPClientPool]: # --------------------------------------------------------------------------- +# Process-wide singleton — KR-DAEMON-LISTENERS-VIA-GATEWAY Phase 2. +_listener_singleton = MCPConsumptionListener() + + def _factory(): - listener = MCPConsumptionListener() - return (listener.startup, listener.shutdown, DEFAULT_SHUTDOWN_TIMEOUT) + return ( + _listener_singleton.startup, + _listener_singleton.shutdown, + DEFAULT_SHUTDOWN_TIMEOUT, + ) register_daemon_listener("mcp_consumption", _factory) @@ -319,3 +331,30 @@ def _factory(): interval_seconds=_read_health_check_interval(), callable=run_health_check, ) + + +# --------------------------------------------------------------------------- +# Hermes-side registration (Phase 2; Path B thin-shim same as snapshot #196) +# --------------------------------------------------------------------------- + +_hermes_entry = BackgroundDaemonEntry( + name="mcp_consumption", + startup=_listener_singleton.startup, + shutdown=_listener_singleton.shutdown, + periodic_task=PeriodicTaskSpec( + interval_seconds=_read_health_check_interval(), + callback=run_health_check, + name="mcp.health_check", + ), + shutdown_timeout=DEFAULT_SHUTDOWN_TIMEOUT, + plugin_name="kora", +) + +try: + background_daemon_registry().register(_hermes_entry) +except ValueError as _exc: + logger.debug( + "[kora.mcp_consumption] hermes registry already had " + "'mcp_consumption' entry: %s — skipping duplicate registration", + _exc, + ) diff --git a/kora_cli/listeners/probe_wake_listener.py b/kora_cli/listeners/probe_wake_listener.py index 47500295e369..eadacea8152d 100644 --- a/kora_cli/listeners/probe_wake_listener.py +++ b/kora_cli/listeners/probe_wake_listener.py @@ -48,6 +48,11 @@ from datetime import datetime, timezone from typing import Any, 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.probes.wake_consumer import ProbeWakeConsumer @@ -246,7 +251,7 @@ class ProbeWakeListener: tick and hands them to the consumer. """ - async def startup(self) -> None: + async def startup(self, coordinator=None) -> None: """Construct the consumer bound to lazy reasoning + Slack factories. Fail-soft per the AlertNotifier listener pattern (PR #149) — construction errors leave the singleton None + @@ -289,9 +294,16 @@ async def shutdown(self) -> None: # --------------------------------------------------------------------------- +# Process-wide singleton — KR-DAEMON-LISTENERS-VIA-GATEWAY Phase 2. +_listener_singleton = ProbeWakeListener() + + def _factory(): - listener = ProbeWakeListener() - return (listener.startup, listener.shutdown, DEFAULT_SHUTDOWN_TIMEOUT) + return ( + _listener_singleton.startup, + _listener_singleton.shutdown, + DEFAULT_SHUTDOWN_TIMEOUT, + ) register_daemon_listener("probe_wake", _factory) @@ -304,6 +316,33 @@ def _factory(): ) +# --------------------------------------------------------------------------- +# Hermes-side registration (Phase 2; Path B thin-shim same as snapshot #196) +# --------------------------------------------------------------------------- + +_hermes_entry = BackgroundDaemonEntry( + name="probe_wake", + startup=_listener_singleton.startup, + shutdown=_listener_singleton.shutdown, + periodic_task=PeriodicTaskSpec( + interval_seconds=_read_poll_sec(), + callback=run_tail_cycle, + name="probe_wake.tail", + ), + shutdown_timeout=DEFAULT_SHUTDOWN_TIMEOUT, + plugin_name="kora", +) + +try: + background_daemon_registry().register(_hermes_entry) +except ValueError as _exc: + logger.debug( + "[kora.probe_wake_listener] hermes registry already had " + "'probe_wake' entry: %s — skipping duplicate registration", + _exc, + ) + + def _reset_tail_position_for_tests() -> None: """Test-only: clear the tail-position state. Production code MUST NOT call this — it would cause replay of all historical diff --git a/kora_cli/listeners/promote_phrasebook_listener.py b/kora_cli/listeners/promote_phrasebook_listener.py index 527bbcf10f58..a1849b8c8242 100644 --- a/kora_cli/listeners/promote_phrasebook_listener.py +++ b/kora_cli/listeners/promote_phrasebook_listener.py @@ -42,6 +42,12 @@ import logging +from agent.background_daemon_registry import ( + BackgroundDaemonEntry, + PeriodicTaskSpec, + background_daemon_registry, +) +from kora_cli.daemon import DEFAULT_SHUTDOWN_TIMEOUT from kora_cli.listeners.heartbeat import register_periodic_task from kora_cli.promote.phrasebook.cycle import ( get_interval_seconds, @@ -51,6 +57,25 @@ logger = logging.getLogger(__name__) +# KR-DAEMON-LISTENERS-VIA-GATEWAY Phase 2 — no-op lifecycle wrappers +# for the BackgroundDaemonRegistry entry. This listener is pure +# periodic-task (no daemon-state to hold); the Hermes-side +# registration carries the periodic task only — no Kora-side +# ``register_daemon_listener`` call has ever existed for this +# listener so there is no LISTENER_REGISTRY entry to bridge. + + +async def _startup_noop(coordinator=None) -> None: + logger.debug( + "[kora.promote.phrasebook.listener] startup (no-op; periodic " + "task drives the work)" + ) + + +async def _shutdown_noop() -> None: + logger.debug("[kora.promote.phrasebook.listener] shutdown (no-op)") + + async def _periodic_task() -> None: """Thin async wrapper so the heartbeat scheduler's signature (``Callable[[], Awaitable[None]]``) is satisfied. Cycle's @@ -86,3 +111,30 @@ async def _periodic_task() -> None: interval_seconds=float(get_interval_seconds()), callable=_periodic_task, ) + + +# --------------------------------------------------------------------------- +# Hermes-side registration (Phase 2; same Path B thin-shim semantics) +# --------------------------------------------------------------------------- + +_hermes_entry = BackgroundDaemonEntry( + name="promote_phrasebook", + startup=_startup_noop, + shutdown=_shutdown_noop, + periodic_task=PeriodicTaskSpec( + interval_seconds=float(get_interval_seconds()), + callback=_periodic_task, + name="promote_phrasebook_cycle", + ), + shutdown_timeout=DEFAULT_SHUTDOWN_TIMEOUT, + plugin_name="kora", +) + +try: + background_daemon_registry().register(_hermes_entry) +except ValueError as _exc: + logger.debug( + "[kora.promote.phrasebook.listener] hermes registry already had " + "'promote_phrasebook' entry: %s — skipping duplicate registration", + _exc, + ) diff --git a/kora_cli/listeners/promote_snapshot_expand_listener.py b/kora_cli/listeners/promote_snapshot_expand_listener.py index a9c9fee39fef..40297350cdd5 100644 --- a/kora_cli/listeners/promote_snapshot_expand_listener.py +++ b/kora_cli/listeners/promote_snapshot_expand_listener.py @@ -26,6 +26,12 @@ import logging +from agent.background_daemon_registry import ( + BackgroundDaemonEntry, + PeriodicTaskSpec, + background_daemon_registry, +) +from kora_cli.daemon import DEFAULT_SHUTDOWN_TIMEOUT from kora_cli.listeners.heartbeat import register_periodic_task from kora_cli.promote.snapshot_expand.cycle import ( get_interval_seconds, @@ -35,6 +41,24 @@ logger = logging.getLogger(__name__) +# KR-DAEMON-LISTENERS-VIA-GATEWAY Phase 2 — no-op lifecycle wrappers +# for the BackgroundDaemonRegistry entry (same shape as +# promote_phrasebook_listener: pure periodic-task; no daemon-state). + + +async def _startup_noop(coordinator=None) -> None: + logger.debug( + "[kora.promote.snapshot_expand.listener] startup (no-op; " + "periodic task drives the work)" + ) + + +async def _shutdown_noop() -> None: + logger.debug( + "[kora.promote.snapshot_expand.listener] shutdown (no-op)" + ) + + async def _periodic_task() -> None: """Thin async wrapper so the heartbeat scheduler's signature is satisfied. Cycle's summary dict logged at DEBUG so operator can @@ -64,3 +88,30 @@ async def _periodic_task() -> None: interval_seconds=float(get_interval_seconds()), callable=_periodic_task, ) + + +# --------------------------------------------------------------------------- +# Hermes-side registration (Phase 2; same Path B thin-shim semantics) +# --------------------------------------------------------------------------- + +_hermes_entry = BackgroundDaemonEntry( + name="promote_snapshot_expand", + startup=_startup_noop, + shutdown=_shutdown_noop, + periodic_task=PeriodicTaskSpec( + interval_seconds=float(get_interval_seconds()), + callback=_periodic_task, + name="promote_snapshot_expand_cycle", + ), + shutdown_timeout=DEFAULT_SHUTDOWN_TIMEOUT, + plugin_name="kora", +) + +try: + background_daemon_registry().register(_hermes_entry) +except ValueError as _exc: + logger.debug( + "[kora.promote.snapshot_expand.listener] hermes registry already had " + "'promote_snapshot_expand' entry: %s — skipping duplicate registration", + _exc, + ) diff --git a/kora_cli/plugins.py b/kora_cli/plugins.py index b687f2f3a0a7..cffb31573945 100644 --- a/kora_cli/plugins.py +++ b/kora_cli/plugins.py @@ -199,6 +199,20 @@ def _install_plugin_debug_handler(force: bool = False) -> None: # choice: "once" | "session" | "always" | "deny" | "timeout" "pre_approval_request", "post_approval_response", + # KR-PLUGIN-IDENTITY (Option C) — agent identity resolution. + # Fires inside ``AnthropicReasoningEngine.__init__`` after + # credential resolution + before file-read fallback. Plugins + # return ``{"identity": IdentitySpec}`` to claim the agent's + # identity (system prompt + SOUL.md content + metadata) for + # this engine instance. First non-None ``identity`` wins; the + # engine uses ``identity.system_prompt_content`` for inference + # calls. Returning ``None`` (or non-dict / dict-without- + # ``identity``) falls through to file-read default. + # See ``agent/identity_spec.py`` for the IdentitySpec dataclass + + # provider type. Enables multi-tenant Hermes deployments + pip- + # installable agent bundles to declare identity via plugin code + # rather than a Kora-specific filesystem layout. + "pre_agent_identity_set", } ENTRY_POINTS_GROUP = "hermes_agent.plugins" @@ -790,6 +804,82 @@ def register_background_daemon( name, ) + # -- identity-provider registration (KR-PLUGIN-IDENTITY Option C) ------ + + def register_identity_provider(self, provider: Callable) -> None: + """Register an agent-identity provider. + + Convenience method that wires ``provider`` to the + ``pre_agent_identity_set`` hook. The provider receives + ``engine=`` (plus any future- + added context kwargs) and returns either an + :class:`agent.identity_spec.IdentitySpec` to claim the + agent's identity, or ``None`` to fall through to other + providers / the engine's file-read default. + + First-non-None-wins per the hook semantic. When two + plugins both register identity providers, the first to + return a non-None ``IdentitySpec`` wins; subsequent + returns are ignored. Plugins that need deterministic + ordering should coordinate explicitly (e.g. via a + single composite provider rather than two independent + registrations). + + Example:: + + def my_identity_provider(*, engine, **kw): + from agent.identity_spec import IdentitySpec + return IdentitySpec( + soul_md_content="", + system_prompt_content="", + identity_metadata={"agent_name": "Marvin"}, + ) + + ctx.register_identity_provider(my_identity_provider) + + Equivalent to ``ctx.register_hook( + "pre_agent_identity_set", lambda **kw: + {"identity": my_identity_provider(**kw)} if + my_identity_provider(**kw) else None)`` — but the + registration helper wraps the shape conversion so + plugin authors return raw IdentitySpec objects rather + than override-dict envelopes. + """ + def _wrapped(**kw): + try: + spec = provider(**kw) + except Exception as exc: + logger.debug( + "Plugin %s identity provider raised %r — falling " + "through to other providers", + self.manifest.name, + exc, + ) + return None + if spec is None: + return None + # Late-import to keep the hook surface importable without + # pulling identity_spec into modules that don't need it. + from agent.identity_spec import IdentitySpec + if not isinstance(spec, IdentitySpec): + logger.warning( + "Plugin %s identity provider returned %r; expected " + "IdentitySpec instance — ignored", + self.manifest.name, + type(spec).__name__, + ) + return None + return {"identity": spec} + + # Preserve provider __name__ for log readability. + _wrapped.__name__ = getattr(provider, "__name__", "identity_provider") + self.register_hook("pre_agent_identity_set", _wrapped) + logger.debug( + "Plugin %s registered identity provider: %s", + self.manifest.name, + getattr(provider, "__name__", ""), + ) + # -- hook registration -------------------------------------------------- def register_hook(self, hook_name: str, callback: Callable) -> None: diff --git a/kora_cli/reasoning/anthropic_engine.py b/kora_cli/reasoning/anthropic_engine.py index 1bf11c3dba96..a1ec8bbc0a81 100644 --- a/kora_cli/reasoning/anthropic_engine.py +++ b/kora_cli/reasoning/anthropic_engine.py @@ -225,18 +225,77 @@ def __init__( self._api_key = api_key self._oauth_token = oauth_token - # System prompt — fail-CLOSED on missing/unreadable. - prompt_path = system_prompt_path or _resolve_system_prompt_path() + # KR-PLUGIN-IDENTITY (Option C) — pre_agent_identity_set hook. + # Fires BEFORE the file-read fallback so a plugin can claim + # the agent's identity (system prompt + SOUL.md + metadata) + # for THIS engine instance. First-non-None-wins: + # - Plugin returns ``{"identity": IdentitySpec}`` → use + # ``identity.system_prompt_content``. + # - No plugin returns identity → fall back to file-read at + # the canonical kora_system_prompt.md path (existing + # pre-Option-C behavior; preserves bare-Hermes-no-Kora- + # plugin compatibility). + # The hook is fail-safe: any plugin exception is caught at + # the invoke_hook level + falls through to the next provider + # / the file-read default. + self._identity_metadata: Dict[str, Any] = {} + _resolved_identity_spec = None try: - self._system_prompt = prompt_path.read_text(encoding="utf-8") - except OSError as exc: - raise ReasoningSystemPromptError( - f"system prompt unreadable at {prompt_path}: {exc!r}" - ) from exc - if not self._system_prompt.strip(): - raise ReasoningSystemPromptError( - f"system prompt at {prompt_path} is empty" + from kora_cli.plugins import invoke_hook as _invoke_identity_hook + _identity_results = _invoke_identity_hook( + "pre_agent_identity_set", + engine=self, ) + for _identity_result in _identity_results: + if not isinstance(_identity_result, dict): + continue + _spec = _identity_result.get("identity") + if _spec is None: + continue + # Late-import to avoid circulars in modules that + # don't need IdentitySpec. + from agent.identity_spec import IdentitySpec + if not isinstance(_spec, IdentitySpec): + logger.debug( + "[kora.engine] pre_agent_identity_set returned " + "non-IdentitySpec %r — falling through", + type(_spec).__name__, + ) + continue + _resolved_identity_spec = _spec + break # first non-None wins + except Exception as _identity_hook_exc: + logger.debug( + "[kora.engine] pre_agent_identity_set hook failed: %s — " + "falling back to file-read default", + _identity_hook_exc, + ) + + if _resolved_identity_spec is not None: + self._system_prompt = _resolved_identity_spec.system_prompt_content + self._identity_metadata = dict( + _resolved_identity_spec.identity_metadata or {} + ) + if not self._system_prompt.strip(): + raise ReasoningSystemPromptError( + "plugin-provided IdentitySpec.system_prompt_content " + "is empty" + ) + else: + # System prompt — fail-CLOSED on missing/unreadable. Same + # behavior as pre-Option-C; only fires when no plugin + # claimed identity. + prompt_path = system_prompt_path or _resolve_system_prompt_path() + try: + self._system_prompt = prompt_path.read_text(encoding="utf-8") + except OSError as exc: + raise ReasoningSystemPromptError( + f"system prompt unreadable at {prompt_path}: {exc!r}" + ) from exc + if not self._system_prompt.strip(): + raise ReasoningSystemPromptError( + f"system prompt at {prompt_path} is empty" + ) self._timeout = timeout_seconds self._max_output_tokens = max_output_tokens diff --git a/kora_cli/reasoning/kora_hermes_plugin/identity/__init__.py b/kora_cli/reasoning/kora_hermes_plugin/identity/__init__.py new file mode 100644 index 000000000000..d07bb939b70a --- /dev/null +++ b/kora_cli/reasoning/kora_hermes_plugin/identity/__init__.py @@ -0,0 +1,53 @@ +"""Identity sub-plugin — KR-PLUGIN-IDENTITY (Option C). + +Kora's identity provider for the new ``pre_agent_identity_set`` +hook. See ``loader.py`` for the file-read helpers, ``constants.py`` +for env-var names + canonical paths, ``plugin.py`` for the +provider handler + sub-register. + +Closes the 7th-of-7 plugin extraction (per Lock R3-2's original +plan, deferred until Option C was chosen this turn). + +Consumes the Hermes-side surface in ``agent/identity_spec.py`` +(IdentitySpec dataclass + IdentityProvider type) + the +``register_identity_provider`` convenience method on PluginContext +in ``kora_cli/plugins.py``. The hook itself fires inside +``AnthropicReasoningEngine.__init__`` — see that file for the +firing semantics. +""" + +from kora_cli.reasoning.kora_hermes_plugin.identity.constants import ( + DEFAULT_AGENT_NAME, + DEFAULT_AGENT_VERSION, + DEFAULT_PLUGIN_NAME, + DEFAULT_SOUL_MD_PATH, + DEFAULT_SYSTEM_PROMPT_PATH, + ENV_DISABLE_IDENTITY_PROVIDER, + ENV_SOUL_MD_PATH, + ENV_SYSTEM_PROMPT_PATH, +) +from kora_cli.reasoning.kora_hermes_plugin.identity.loader import ( + load_kora_identity, + resolve_soul_md_path, + resolve_system_prompt_path, +) +from kora_cli.reasoning.kora_hermes_plugin.identity.plugin import ( + kora_identity_provider, + register, +) + +__all__ = [ + "DEFAULT_AGENT_NAME", + "DEFAULT_AGENT_VERSION", + "DEFAULT_PLUGIN_NAME", + "DEFAULT_SOUL_MD_PATH", + "DEFAULT_SYSTEM_PROMPT_PATH", + "ENV_DISABLE_IDENTITY_PROVIDER", + "ENV_SOUL_MD_PATH", + "ENV_SYSTEM_PROMPT_PATH", + "kora_identity_provider", + "load_kora_identity", + "register", + "resolve_soul_md_path", + "resolve_system_prompt_path", +] diff --git a/kora_cli/reasoning/kora_hermes_plugin/identity/constants.py b/kora_cli/reasoning/kora_hermes_plugin/identity/constants.py new file mode 100644 index 000000000000..39dd3b833f87 --- /dev/null +++ b/kora_cli/reasoning/kora_hermes_plugin/identity/constants.py @@ -0,0 +1,61 @@ +"""Constants for the identity sub-plugin. + +Default file paths + env-var names for Kora's identity sources. +Two distinct identity surfaces today (both required to fully +populate the IdentitySpec): + + 1. ``kora_system_prompt.md`` — the reasoning-engine system + prompt. Prepended to every Anthropic SDK call as the + ``system`` field. Long-form structural prompt (~10-15 KB). + 2. ``SOUL.md`` — the operator-tunable persona prompt. Loaded + by ``agent/prompt_builder.py:load_soul_md`` for the bypass- + agent's identity slot. Shorter (~1-2 KB). + +Both paths are env-override-able so multi-tenant deployments +can point a Kora-style plugin at custom files without touching +the canonical Kora paths. +""" + +from __future__ import annotations + +from pathlib import Path + +# Env-overrides — operator escape hatches for testing + alt deployments. +ENV_SYSTEM_PROMPT_PATH = "KORA_SYSTEM_PROMPT_PATH" +ENV_SOUL_MD_PATH = "KORA_SOUL_MD_PATH" + +# Disable env — when set "true", the identity sub-plugin no-ops and +# the engine falls back to its file-read default. Operator escape +# hatch for incident response. +ENV_DISABLE_IDENTITY_PROVIDER = "KORA_DISABLE_IDENTITY_PROVIDER" + +# Canonical Kora paths — file locations the plugin reads when no +# env override is set. Resolve relative to the repository root so +# both the bypass-agent and the gateway path see the same files. +_REPO_ROOT = Path(__file__).resolve().parents[4] + +DEFAULT_SYSTEM_PROMPT_PATH = ( + _REPO_ROOT / "kora_docs" / "00_canonical_current_state" + / "kora_system_prompt.md" +) + +DEFAULT_SOUL_MD_PATH = _REPO_ROOT / "SOUL.md" + +# Identity metadata — populated into IdentitySpec.identity_metadata +# when the canonical Kora identity is resolved. Conventional shape +# (see agent/identity_spec.py:IdentitySpec docstring). +DEFAULT_AGENT_NAME = "Kora" +DEFAULT_AGENT_VERSION = "phase2" +DEFAULT_PLUGIN_NAME = "kora_hermes" + + +__all__ = [ + "DEFAULT_AGENT_NAME", + "DEFAULT_AGENT_VERSION", + "DEFAULT_PLUGIN_NAME", + "DEFAULT_SOUL_MD_PATH", + "DEFAULT_SYSTEM_PROMPT_PATH", + "ENV_DISABLE_IDENTITY_PROVIDER", + "ENV_SOUL_MD_PATH", + "ENV_SYSTEM_PROMPT_PATH", +] diff --git a/kora_cli/reasoning/kora_hermes_plugin/identity/loader.py b/kora_cli/reasoning/kora_hermes_plugin/identity/loader.py new file mode 100644 index 000000000000..3a02ebb624ae --- /dev/null +++ b/kora_cli/reasoning/kora_hermes_plugin/identity/loader.py @@ -0,0 +1,146 @@ +"""Pure file-reading helpers for the identity sub-plugin. + +The plugin handler in ``plugin.py`` orchestrates the resolution +(env-override → fallback to default paths → build IdentitySpec); +this module holds the pure functions it composes with so they're +independently testable. + +Two responsibilities: + + 1. :func:`resolve_system_prompt_path` — read env override or + return canonical default. + 2. :func:`resolve_soul_md_path` — read env override or return + canonical default. + 3. :func:`load_kora_identity` — orchestrate the read; build the + full IdentitySpec. + +All functions are fail-soft at the load level — they return +empty strings + log DEBUG when a file is missing. The PLUGIN +handler decides whether empty content should fall through to +the engine's file-read default (yes — emptiness is treated as +"no claim on identity"). +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +from typing import Optional + +from agent.identity_spec import IdentitySpec + +from kora_cli.reasoning.kora_hermes_plugin.identity.constants import ( + DEFAULT_AGENT_NAME, + DEFAULT_AGENT_VERSION, + DEFAULT_PLUGIN_NAME, + DEFAULT_SOUL_MD_PATH, + DEFAULT_SYSTEM_PROMPT_PATH, + ENV_SOUL_MD_PATH, + ENV_SYSTEM_PROMPT_PATH, +) + +logger = logging.getLogger(__name__) + + +def resolve_system_prompt_path() -> Path: + """Return the resolved system-prompt path. + + Honors :data:`ENV_SYSTEM_PROMPT_PATH` (operator override) and + falls back to :data:`DEFAULT_SYSTEM_PROMPT_PATH` when unset. + Empty/whitespace env values are treated as unset. + """ + raw = os.environ.get(ENV_SYSTEM_PROMPT_PATH, "").strip() + if raw: + return Path(raw) + return DEFAULT_SYSTEM_PROMPT_PATH + + +def resolve_soul_md_path() -> Path: + """Return the resolved SOUL.md path. + + Honors :data:`ENV_SOUL_MD_PATH` (operator override) and falls + back to :data:`DEFAULT_SOUL_MD_PATH` when unset. + """ + raw = os.environ.get(ENV_SOUL_MD_PATH, "").strip() + if raw: + return Path(raw) + return DEFAULT_SOUL_MD_PATH + + +def _read_file_fail_soft(path: Path, label: str) -> str: + """Read a file, returning "" on any IO error + logging at DEBUG. + + Used for the SOUL.md side where missing files are non-fatal + (SOUL.md is an OPTIONAL operator-tunable override historically). + The reasoning-engine system prompt is fail-CLOSED at the engine + level — that fail-closed semantic is enforced by the engine, not + by this loader, so the loader stays uniformly soft. + """ + try: + return path.read_text(encoding="utf-8") + except OSError as exc: + logger.debug( + "[kora_hermes.identity] %s unreadable at %s: %r — using " + "empty string", + label, + path, + exc, + ) + return "" + + +def load_kora_identity() -> Optional[IdentitySpec]: + """Load Kora's canonical identity into an :class:`IdentitySpec`. + + Returns ``None`` when the system-prompt file is unreadable OR + empty — that signals "no identity claim from Kora" to the + hook firing site, which then falls through to the engine's + file-read default (preserving the pre-Option-C behavior). + + SOUL.md is OPTIONAL — when missing the spec still resolves, but + ``soul_md_content`` is empty. The engine ignores ``soul_md_content`` + today (it only consumes ``system_prompt_content``); SOUL.md is + carried in the IdentitySpec so future consumers (prompt_builder, + skin layers) can resolve identity via the plugin surface instead + of the filesystem. + + All metadata fields under :data:`DEFAULT_*` constants populate + the ``identity_metadata`` dict for cockpit + telemetry consumers. + """ + system_prompt_path = resolve_system_prompt_path() + system_prompt_content = _read_file_fail_soft( + system_prompt_path, "kora_system_prompt.md" + ) + if not system_prompt_content.strip(): + # Empty system prompt → no claim. Engine will fall back to + # its own file-read default (which will then fail-CLOSED if + # also empty, surfacing the underlying issue at engine init). + logger.debug( + "[kora_hermes.identity] system prompt empty at %s — " + "yielding to engine file-read default", + system_prompt_path, + ) + return None + + soul_md_path = resolve_soul_md_path() + soul_md_content = _read_file_fail_soft(soul_md_path, "SOUL.md") + + return IdentitySpec( + soul_md_content=soul_md_content, + system_prompt_content=system_prompt_content, + identity_metadata={ + "agent_name": DEFAULT_AGENT_NAME, + "agent_version": DEFAULT_AGENT_VERSION, + "plugin_name": DEFAULT_PLUGIN_NAME, + "system_prompt_path": str(system_prompt_path), + "soul_md_path": str(soul_md_path), + }, + ) + + +__all__ = [ + "load_kora_identity", + "resolve_soul_md_path", + "resolve_system_prompt_path", +] diff --git a/kora_cli/reasoning/kora_hermes_plugin/identity/plugin.py b/kora_cli/reasoning/kora_hermes_plugin/identity/plugin.py new file mode 100644 index 000000000000..80ab40a745ed --- /dev/null +++ b/kora_cli/reasoning/kora_hermes_plugin/identity/plugin.py @@ -0,0 +1,124 @@ +"""Identity sub-plugin — Kora's identity provider for the new +``pre_agent_identity_set`` hook (KR-PLUGIN-IDENTITY Option C). + +Registers a single identity provider that returns Kora's canonical +identity (kora_system_prompt.md + SOUL.md content + metadata) as +an :class:`agent.identity_spec.IdentitySpec`. + +# Activation gates + + 1. ``KORA_DISABLE_IDENTITY_PROVIDER`` env not "true" (operator + escape hatch for incident response — falls through to the + engine's file-read default, identical to pre-Option-C + behavior). + 2. Canonical identity files load cleanly (non-empty system + prompt). When either fails, the provider returns ``None``; + other plugins (if any) can still claim identity, and the + engine's file-read default acts as the final fallback. + +# Why this plugin doesn't gate on KORA_ROUTES + +Other Kora sub-plugins (cost_ladder, audit, haiku_router, etc.) +gate on ``_is_kora_call(route)`` so bare-Hermes-fork users who +load the plugin don't get Kora behavior on non-Kora calls. + +Identity is DIFFERENT — it's set ONCE at engine construction, +NOT per-call. The plugin loader's job is to claim Kora's +identity for the engine instance; there is no per-call route +context at that point. Bare-Hermes users who load the plugin +DO want Kora's identity to be the agent's identity (that's the +whole point of installing the plugin). Operators who want a +different identity should write their own plugin (per +HOW_TO_BUILD_YOUR_OWN_AGENT.md). +""" + +from __future__ import annotations + +import logging +import os +from typing import Any, Optional + +from agent.identity_spec import IdentitySpec + +from kora_cli.reasoning.kora_hermes_plugin.identity.constants import ( + ENV_DISABLE_IDENTITY_PROVIDER, +) +from kora_cli.reasoning.kora_hermes_plugin.identity.loader import ( + load_kora_identity, +) + +logger = logging.getLogger(__name__) + + +def _is_disabled() -> bool: + """Operator escape hatch — env-controlled disable.""" + return ( + os.environ.get(ENV_DISABLE_IDENTITY_PROVIDER, "") + .strip() + .lower() + == "true" + ) + + +def kora_identity_provider( + *, + engine: Any = None, + **kw: Any, +) -> Optional[IdentitySpec]: + """Identity provider for the ``pre_agent_identity_set`` hook. + + Returns Kora's :class:`IdentitySpec` (loaded from canonical + filesystem paths via the loader module) when the plugin is + enabled + files load cleanly. ``None`` otherwise — the engine + then falls back to its file-read default OR another plugin's + identity (first-non-None-wins per the hook semantic). + + The ``engine`` kwarg is the firing-engine instance; carried + here for future use (e.g. cost-ladder-aware identity, per- + deployment overrides). Today's implementation doesn't read it + — Kora's identity is fixed across engine instances. + """ + if _is_disabled(): + logger.debug( + "[kora_hermes.identity] disabled via %s — yielding to " + "engine file-read default", + ENV_DISABLE_IDENTITY_PROVIDER, + ) + return None + + try: + spec = load_kora_identity() + except Exception as exc: + logger.warning( + "[kora_hermes.identity] load_kora_identity raised %r — " + "yielding to engine file-read default", + exc, + ) + return None + + if spec is None: + # Loader already logged the reason at DEBUG. + return None + + logger.info( + "[kora_hermes.identity] claiming identity (agent=%s, " + "version=%s, system_prompt_chars=%d, soul_md_chars=%d)", + spec.identity_metadata.get("agent_name"), + spec.identity_metadata.get("agent_version"), + len(spec.system_prompt_content), + len(spec.soul_md_content), + ) + return spec + + +def register(ctx) -> None: + """Sub-plugin register. Wires + :func:`kora_identity_provider` to the new + ``pre_agent_identity_set`` hook via the PluginContext's + ``register_identity_provider`` convenience method (which + unwraps the IdentitySpec → ``{"identity": }`` envelope + that the firing site expects).""" + ctx.register_identity_provider(kora_identity_provider) + logger.debug( + "[kora_hermes.identity] sub-plugin registered" + ) diff --git a/kora_cli/reasoning/kora_hermes_plugin/plugin.py b/kora_cli/reasoning/kora_hermes_plugin/plugin.py index 6ce3ce7dc6f2..251522422a10 100644 --- a/kora_cli/reasoning/kora_hermes_plugin/plugin.py +++ b/kora_cli/reasoning/kora_hermes_plugin/plugin.py @@ -325,6 +325,18 @@ def register(self, ctx) -> None: register_haiku_router(ctx) + # KR-PLUGIN-IDENTITY (Option C) — owns the agent's identity + # for THIS engine instance. Provides IdentitySpec (system + # prompt + SOUL.md + metadata) via the new + # ``pre_agent_identity_set`` hook. Closes 7th-of-7 plugin + # extraction. Bare-Hermes-no-Kora-plugin users still see + # the file-read fallback in the engine. + from kora_cli.reasoning.kora_hermes_plugin.identity import ( + register as register_identity, + ) + + register_identity(ctx) + # --- Handlers still living in the orchestrator (await # their own KR-PLUGIN-* extraction buckets) --- ctx.register_hook( @@ -337,7 +349,7 @@ def register(self, ctx) -> None: ) logger.info( - "[kora_hermes] plugin registered: 6 sub-plugins + 3 " + "[kora_hermes] plugin registered: 7 sub-plugins + 3 " "orchestrator-resident hooks against KORA_ROUTES=%s", sorted(KORA_ROUTES), ) diff --git a/plugins/kora_hermes/__init__.py b/plugins/kora_hermes/__init__.py index 4686632627e4..e70f91f300c0 100644 --- a/plugins/kora_hermes/__init__.py +++ b/plugins/kora_hermes/__init__.py @@ -39,6 +39,9 @@ from kora_cli.reasoning.kora_hermes_plugin.haiku_router.plugin import ( haiku_router_post_call_escalation as _post_llm_call_can_reissue, ) +from kora_cli.reasoning.kora_hermes_plugin.identity.plugin import ( + kora_identity_provider as _pre_agent_identity_set, +) from kora_cli.reasoning.kora_hermes_plugin.plugin import ( KORA_ROUTES, KoraHermesPlugin, @@ -64,6 +67,7 @@ "_post_llm_call", "_post_llm_call_can_reissue", "_post_tool_call", + "_pre_agent_identity_set", "_pre_api_request_mutable", "_pre_tool_call", "_pre_tool_list_finalized", diff --git a/tests/agent/test_identity_spec.py b/tests/agent/test_identity_spec.py new file mode 100644 index 000000000000..b56978d3a046 --- /dev/null +++ b/tests/agent/test_identity_spec.py @@ -0,0 +1,46 @@ +"""Tests for agent.identity_spec — IdentitySpec dataclass.""" + +from __future__ import annotations + +import pytest + +from agent.identity_spec import IdentitySpec, IdentityProvider + + +def test_identity_spec_basic_construction(): + spec = IdentitySpec( + soul_md_content="I am Marvin.", + system_prompt_content="You are paranoid.", + ) + assert spec.soul_md_content == "I am Marvin." + assert spec.system_prompt_content == "You are paranoid." + assert spec.identity_metadata == {} + + +def test_identity_spec_with_metadata(): + spec = IdentitySpec( + soul_md_content="x", + system_prompt_content="y", + identity_metadata={"agent_name": "Marvin", "version": "0.1"}, + ) + assert spec.identity_metadata == {"agent_name": "Marvin", "version": "0.1"} + + +def test_identity_spec_is_frozen(): + spec = IdentitySpec(soul_md_content="x", system_prompt_content="y") + with pytest.raises(Exception): # FrozenInstanceError on dataclasses + spec.soul_md_content = "new" # type: ignore[misc] + + +def test_identity_spec_metadata_defaults_to_empty_dict(): + spec = IdentitySpec(soul_md_content="x", system_prompt_content="y") + assert spec.identity_metadata == {} + # Each instance gets its own dict — no shared-mutable-default trap. + spec_b = IdentitySpec(soul_md_content="a", system_prompt_content="b") + assert spec.identity_metadata is not spec_b.identity_metadata + + +def test_identity_provider_type_alias_exists(): + """IdentityProvider is a Callable type alias — exists for + plugin authors to type-annotate their provider functions.""" + assert IdentityProvider is not None diff --git a/tests/kora_cli/test_listeners/test_phase2_listener_migrations.py b/tests/kora_cli/test_listeners/test_phase2_listener_migrations.py new file mode 100644 index 000000000000..2220dcc9e43a --- /dev/null +++ b/tests/kora_cli/test_listeners/test_phase2_listener_migrations.py @@ -0,0 +1,267 @@ +"""Batched dual-registry tests for KR-DAEMON-LISTENERS-VIA-GATEWAY Phase 2. + +Phase 1 (#196) migrated the snapshot listener as proof-of-pattern; +Phase 2 (this PR) migrates the remaining 8 periodic-task listeners +to dual-registry shape (BOTH Hermes BackgroundDaemonRegistry AND +Kora LISTENER_REGISTRY, both pointing at the same singleton factory). + +Two of the eight (``promote_phrasebook`` + ``promote_snapshot_expand``) +are pure-periodic-task listeners that never had Kora-side daemon +registration — for those, only the Hermes-side surface is verified. + +Coverage per listener: + 1. Listener is in BOTH registries (or only Hermes for the two + pure-periodic ones) + 2. The Hermes BackgroundDaemonEntry carries the expected + periodic_task name + callback identity + interval + 3. Both registrations share the same listener singleton (for + the 6 with Listener-class shape) → behavior parity guarantee +""" + +from __future__ import annotations + +import pytest + +from agent.background_daemon_registry import ( + BackgroundDaemonEntry, + background_daemon_registry, +) +from kora_cli import daemon as daemon_mod + + +# Trigger import-time registrations. +import kora_cli.listeners # noqa: F401 + + +# --------------------------------------------------------------------------- +# Inventory pin — Phase 2 brings the periodic-task count to 9 +# --------------------------------------------------------------------------- + + +PHASE2_LISTENERS = { + "snapshot", # Phase 1 (#196) — included in inventory pin + "heartbeat_probes", + "email_inbound_imap", + "probe_wake", + "mcp_consumption", + "cost_telemetry", + "alert_notifier", + "promote_phrasebook", # pure-periodic; no Kora-side entry + "promote_snapshot_expand", # pure-periodic; no Kora-side entry +} + + +PURE_PERIODIC_NO_KORA_ENTRY = { + "promote_phrasebook", + "promote_snapshot_expand", +} + + +@pytest.mark.parametrize("name", sorted(PHASE2_LISTENERS)) +def test_listener_in_hermes_registry(name): + """Every Phase 2 listener has a BackgroundDaemonEntry in the + Hermes registry.""" + entry = background_daemon_registry().by_name(name) + assert entry is not None, ( + f"{name} missing from BackgroundDaemonRegistry — Phase 2 " + f"migration must add it" + ) + assert isinstance(entry, BackgroundDaemonEntry) + assert entry.plugin_name == "kora" + + +@pytest.mark.parametrize( + "name", + sorted(PHASE2_LISTENERS - PURE_PERIODIC_NO_KORA_ENTRY), +) +def test_listener_in_kora_registry(name): + """Every Listener-class-shape Phase 2 listener stays in Kora's + LISTENER_REGISTRY (Path B thin-shim — back-compat preserved + until Phase 6 dissolution).""" + kora_names = {n for n, _f in daemon_mod.LISTENER_REGISTRY} + assert name in kora_names, ( + f"{name} missing from Kora LISTENER_REGISTRY — Phase 2 " + f"migration must preserve the back-compat entry" + ) + + +@pytest.mark.parametrize("name", sorted(PURE_PERIODIC_NO_KORA_ENTRY)) +def test_pure_periodic_listener_not_in_kora_registry(name): + """The 2 promote_* listeners never had Kora-side daemon + registration (no Listener class, no register_daemon_listener + call). Phase 2 only adds them to the Hermes registry. The + no-op startup/shutdown wrappers exist solely for the Hermes + side.""" + kora_names = {n for n, _f in daemon_mod.LISTENER_REGISTRY} + assert name not in kora_names, ( + f"{name} should NOT be in Kora LISTENER_REGISTRY — it's a " + f"pure-periodic-task listener with no daemon-lifecycle " + f"history. If it appears here, the migration shape drifted." + ) + + +# --------------------------------------------------------------------------- +# Periodic-task fields — Hermes entry carries the expected callback +# --------------------------------------------------------------------------- + + +def test_heartbeat_probes_periodic_task(): + from kora_cli.heartbeat_probes.runner import run_all_probes_scheduled + + entry = background_daemon_registry().by_name("heartbeat_probes") + assert entry is not None and entry.periodic_task is not None + assert entry.periodic_task.name == "heartbeat.service_probes" + assert entry.periodic_task.callback is run_all_probes_scheduled + + +def test_email_inbound_imap_periodic_task(): + from kora_cli.listeners.email_inbound_imap_listener import run_poll_cycle + + entry = background_daemon_registry().by_name("email_inbound_imap") + assert entry is not None and entry.periodic_task is not None + assert entry.periodic_task.name == "email.imap_poll" + assert entry.periodic_task.callback is run_poll_cycle + + +def test_probe_wake_periodic_task(): + from kora_cli.listeners.probe_wake_listener import run_tail_cycle + + entry = background_daemon_registry().by_name("probe_wake") + assert entry is not None and entry.periodic_task is not None + assert entry.periodic_task.name == "probe_wake.tail" + assert entry.periodic_task.callback is run_tail_cycle + + +def test_mcp_consumption_periodic_task(): + from kora_cli.listeners.mcp_consumption import run_health_check + + entry = background_daemon_registry().by_name("mcp_consumption") + assert entry is not None and entry.periodic_task is not None + assert entry.periodic_task.name == "mcp.health_check" + assert entry.periodic_task.callback is run_health_check + + +def test_cost_telemetry_periodic_task(): + """cost_telemetry has 3 periodic tasks; the Hermes entry carries + the PRIMARY persist cycle (per §4.1 audit recommendation c). The + two reset checks stay on Kora's heartbeat scheduler.""" + from kora_cli.listeners.cost_telemetry_listener import run_persist_cycle + + entry = background_daemon_registry().by_name("cost_telemetry") + assert entry is not None and entry.periodic_task is not None + assert entry.periodic_task.name == "cost_telemetry.persist" + assert entry.periodic_task.callback is run_persist_cycle + + +def test_alert_notifier_periodic_task(): + """alert_notifier has 2 periodic tasks; the Hermes entry carries + the PRIMARY notify cycle. The digest_flush task stays on Kora's + heartbeat scheduler.""" + from kora_cli.listeners.alert_notifier_listener import ( + run_notification_cycle, + ) + + entry = background_daemon_registry().by_name("alert_notifier") + assert entry is not None and entry.periodic_task is not None + assert entry.periodic_task.name == "alerts.notify" + assert entry.periodic_task.callback is run_notification_cycle + + +def test_promote_phrasebook_periodic_task(): + """Pure-periodic-task listener: no Listener class, no Kora-side + daemon registration. Phase 2 only adds the Hermes-side entry + (with no-op startup/shutdown wrappers + the existing periodic + task).""" + from kora_cli.listeners.promote_phrasebook_listener import _periodic_task + + entry = background_daemon_registry().by_name("promote_phrasebook") + assert entry is not None and entry.periodic_task is not None + assert entry.periodic_task.name == "promote_phrasebook_cycle" + assert entry.periodic_task.callback is _periodic_task + + +def test_promote_snapshot_expand_periodic_task(): + from kora_cli.listeners.promote_snapshot_expand_listener import ( + _periodic_task, + ) + + entry = background_daemon_registry().by_name("promote_snapshot_expand") + assert entry is not None and entry.periodic_task is not None + assert entry.periodic_task.name == "promote_snapshot_expand_cycle" + assert entry.periodic_task.callback is _periodic_task + + +# --------------------------------------------------------------------------- +# Singleton invariant — both registries point at the same listener +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "name, module_path", + [ + ("heartbeat_probes", + "kora_cli.listeners.heartbeat_probes_listener"), + ("email_inbound_imap", + "kora_cli.listeners.email_inbound_imap_listener"), + ("probe_wake", + "kora_cli.listeners.probe_wake_listener"), + ("mcp_consumption", + "kora_cli.listeners.mcp_consumption"), + ("cost_telemetry", + "kora_cli.listeners.cost_telemetry_listener"), + ("alert_notifier", + "kora_cli.listeners.alert_notifier_listener"), + ], +) +def test_listener_singleton_shared_across_registries(name, module_path): + """For Listener-class-shape listeners: both registries point at + the SAME singleton's bound methods. Prevents double-log-on- + startup if both consumers fire.""" + import importlib + + mod = importlib.import_module(module_path) + singleton = mod._listener_singleton + hermes_entry = background_daemon_registry().by_name(name) + assert hermes_entry is not None + assert hermes_entry.startup == singleton.startup + assert hermes_entry.shutdown == singleton.shutdown + # Kora-side factory tuple shares the same bound methods. + kora_lookup = dict(daemon_mod.LISTENER_REGISTRY) + kora_factory = kora_lookup[name] + kora_startup, kora_shutdown, _ = kora_factory() + assert kora_startup == singleton.startup + assert kora_shutdown == singleton.shutdown + + +# --------------------------------------------------------------------------- +# Startup signature — accepts optional coordinator kwarg +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "module_path", + [ + "kora_cli.listeners.heartbeat_probes_listener", + "kora_cli.listeners.email_inbound_imap_listener", + "kora_cli.listeners.probe_wake_listener", + "kora_cli.listeners.mcp_consumption", + "kora_cli.listeners.cost_telemetry_listener", + "kora_cli.listeners.alert_notifier_listener", + ], +) +def test_startup_signature_accepts_coordinator_kwarg(module_path): + """Every Listener-class startup must accept the optional + coordinator kwarg so both consumer shapes work (Kora's no-arg + + Hermes's positional Callable[[Any], Any]).""" + import importlib + import inspect + + mod = importlib.import_module(module_path) + listener = mod._listener_singleton + sig = inspect.signature(listener.startup) + params = sig.parameters + assert "coordinator" in params, ( + f"{module_path}.startup must accept a 'coordinator' kwarg" + ) + # Default value should be None so no-arg calls don't fail. + assert params["coordinator"].default is None diff --git a/tests/plugins/test_kora_hermes_plugin.py b/tests/plugins/test_kora_hermes_plugin.py index a41de9c4b900..578eb94ab8ce 100644 --- a/tests/plugins/test_kora_hermes_plugin.py +++ b/tests/plugins/test_kora_hermes_plugin.py @@ -74,12 +74,16 @@ def test_plugin_is_discovered_but_opt_in(): assert "not enabled in config" in (loaded.error or "") -def test_register_function_wires_eight_hooks(): - """The plugin's register(ctx) function registers exactly 8 - hooks (KR-HERMES-LOCAL-EXT-REISSUE added - post_llm_call_can_reissue to ST2B's 7). Test directly with - a mock context — bypasses Hermes's opt-in plugins.enabled - gate (operator-policy territory).""" +def test_register_function_wires_nine_hooks(): + """The plugin's register(ctx) function registers exactly 9 + hooks (KR-PLUGIN-IDENTITY Option C added pre_agent_identity_set + to the previous 8). Test directly with a mock context — + bypasses Hermes's opt-in plugins.enabled gate. + + The identity sub-plugin calls ``ctx.register_identity_provider`` + which (in the real PluginContext) internally calls + ``register_hook("pre_agent_identity_set", wrapped)``. The + MockCtx mirrors this delegation so both signals are visible.""" from plugins.kora_hermes import register registered = [] @@ -87,6 +91,11 @@ def test_register_function_wires_eight_hooks(): class _MockCtx: def register_hook(self, name, callback): registered.append((name, callback)) + def register_identity_provider(self, provider): + # Mirror real PluginContext: register_identity_provider + # is a convenience that wires into the + # pre_agent_identity_set hook. + self.register_hook("pre_agent_identity_set", provider) register(_MockCtx()) hook_names = [name for name, _ in registered] @@ -98,7 +107,8 @@ def register_hook(self, name, callback): "post_tool_call", "post_llm_call", "pre_tool_call_can_provide_result", # ST2B added - "post_llm_call_can_reissue", # KR-HERMES-LOCAL-EXT-REISSUE added + "post_llm_call_can_reissue", # KR-HERMES-LOCAL-EXT-REISSUE added + "pre_agent_identity_set", # KR-PLUGIN-IDENTITY Option C added ]) # Each registered callback is callable. for name, callback in registered: diff --git a/tests/plugins/test_kora_hermes_plugin_haiku_router.py b/tests/plugins/test_kora_hermes_plugin_haiku_router.py index d93276bfe732..7e2c9dd8f892 100644 --- a/tests/plugins/test_kora_hermes_plugin_haiku_router.py +++ b/tests/plugins/test_kora_hermes_plugin_haiku_router.py @@ -419,6 +419,11 @@ def test_orchestrator_registers_haiku_router_hook(): class _MockCtx: def register_hook(self, name, callback): registered.append(name) + def register_identity_provider(self, provider): + # KR-PLUGIN-IDENTITY Option C added this method; mirror + # the real PluginContext's delegation so the orchestrator + # walk completes without AttributeError. + self.register_hook("pre_agent_identity_set", provider) register(_MockCtx()) assert "post_llm_call_can_reissue" in registered diff --git a/tests/plugins/test_kora_hermes_plugin_identity.py b/tests/plugins/test_kora_hermes_plugin_identity.py new file mode 100644 index 000000000000..06b1612695c4 --- /dev/null +++ b/tests/plugins/test_kora_hermes_plugin_identity.py @@ -0,0 +1,435 @@ +"""Tests for the identity sub-plugin + the new +``pre_agent_identity_set`` hook surface. + +Coverage: + + - Pure helpers in ``loader.py`` (env override / file read / + IdentitySpec construction) + - ``kora_identity_provider`` handler activation gating (env- + disabled / empty system prompt → None) + - Sub-register wires the provider to the hook via the new + PluginContext.register_identity_provider convenience + - First-non-None-wins semantics through PluginContext + - Backward-compat: engine fallback to file-read when no plugin + claims identity (structural pin on the source) + - Discovery shim exports the new alias +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import pytest + + +# --------------------------------------------------------------------------- +# Pure helpers +# --------------------------------------------------------------------------- + + +def test_resolve_system_prompt_path_env_override(monkeypatch, tmp_path): + from kora_cli.reasoning.kora_hermes_plugin.identity import ( + ENV_SYSTEM_PROMPT_PATH, + resolve_system_prompt_path, + ) + + custom = tmp_path / "custom_prompt.md" + monkeypatch.setenv(ENV_SYSTEM_PROMPT_PATH, str(custom)) + assert resolve_system_prompt_path() == custom + + +def test_resolve_system_prompt_path_default_when_env_unset(monkeypatch): + from kora_cli.reasoning.kora_hermes_plugin.identity import ( + DEFAULT_SYSTEM_PROMPT_PATH, + ENV_SYSTEM_PROMPT_PATH, + resolve_system_prompt_path, + ) + + monkeypatch.delenv(ENV_SYSTEM_PROMPT_PATH, raising=False) + assert resolve_system_prompt_path() == DEFAULT_SYSTEM_PROMPT_PATH + + +def test_resolve_soul_md_path_env_override(monkeypatch, tmp_path): + from kora_cli.reasoning.kora_hermes_plugin.identity import ( + ENV_SOUL_MD_PATH, + resolve_soul_md_path, + ) + + custom = tmp_path / "custom_soul.md" + monkeypatch.setenv(ENV_SOUL_MD_PATH, str(custom)) + assert resolve_soul_md_path() == custom + + +def test_resolve_soul_md_path_default_when_env_unset(monkeypatch): + from kora_cli.reasoning.kora_hermes_plugin.identity import ( + DEFAULT_SOUL_MD_PATH, + ENV_SOUL_MD_PATH, + resolve_soul_md_path, + ) + + monkeypatch.delenv(ENV_SOUL_MD_PATH, raising=False) + assert resolve_soul_md_path() == DEFAULT_SOUL_MD_PATH + + +def test_load_kora_identity_returns_spec_at_canonical_paths(monkeypatch): + """Default-path canonical Kora identity loads cleanly — the + files exist in the repo at the documented locations.""" + from kora_cli.reasoning.kora_hermes_plugin.identity import ( + DEFAULT_AGENT_NAME, + ENV_SOUL_MD_PATH, + ENV_SYSTEM_PROMPT_PATH, + load_kora_identity, + ) + + monkeypatch.delenv(ENV_SYSTEM_PROMPT_PATH, raising=False) + monkeypatch.delenv(ENV_SOUL_MD_PATH, raising=False) + spec = load_kora_identity() + assert spec is not None + assert spec.system_prompt_content.strip() != "" + # SOUL.md may or may not exist depending on dev state, but the + # loader returns the spec regardless (empty soul_md_content is OK). + assert isinstance(spec.soul_md_content, str) + assert spec.identity_metadata["agent_name"] == DEFAULT_AGENT_NAME + assert "system_prompt_path" in spec.identity_metadata + assert "soul_md_path" in spec.identity_metadata + + +def test_load_kora_identity_returns_none_on_empty_system_prompt( + monkeypatch, tmp_path +): + """Empty system-prompt file → loader returns None (yields to + engine file-read default).""" + from kora_cli.reasoning.kora_hermes_plugin.identity import ( + ENV_SYSTEM_PROMPT_PATH, + load_kora_identity, + ) + + empty = tmp_path / "empty.md" + empty.write_text("") + monkeypatch.setenv(ENV_SYSTEM_PROMPT_PATH, str(empty)) + assert load_kora_identity() is None + + +def test_load_kora_identity_returns_none_on_missing_system_prompt( + monkeypatch, tmp_path +): + """Missing system-prompt file → loader returns None.""" + from kora_cli.reasoning.kora_hermes_plugin.identity import ( + ENV_SYSTEM_PROMPT_PATH, + load_kora_identity, + ) + + missing = tmp_path / "does_not_exist.md" + monkeypatch.setenv(ENV_SYSTEM_PROMPT_PATH, str(missing)) + assert load_kora_identity() is None + + +def test_load_kora_identity_handles_missing_soul_md(monkeypatch, tmp_path): + """SOUL.md is OPTIONAL — missing file is non-fatal; spec still + returns with empty soul_md_content.""" + from kora_cli.reasoning.kora_hermes_plugin.identity import ( + ENV_SOUL_MD_PATH, + ENV_SYSTEM_PROMPT_PATH, + load_kora_identity, + ) + + sys_prompt = tmp_path / "sys.md" + sys_prompt.write_text("You are a test agent.") + missing_soul = tmp_path / "missing_soul.md" + monkeypatch.setenv(ENV_SYSTEM_PROMPT_PATH, str(sys_prompt)) + monkeypatch.setenv(ENV_SOUL_MD_PATH, str(missing_soul)) + spec = load_kora_identity() + assert spec is not None + assert spec.system_prompt_content == "You are a test agent." + assert spec.soul_md_content == "" + + +# --------------------------------------------------------------------------- +# Handler activation gating +# --------------------------------------------------------------------------- + + +def test_handler_returns_none_when_disabled_via_env(monkeypatch): + from kora_cli.reasoning.kora_hermes_plugin.identity import ( + ENV_DISABLE_IDENTITY_PROVIDER, + kora_identity_provider, + ) + + monkeypatch.setenv(ENV_DISABLE_IDENTITY_PROVIDER, "true") + assert kora_identity_provider(engine=None) is None + + +def test_handler_returns_spec_when_enabled(monkeypatch): + from kora_cli.reasoning.kora_hermes_plugin.identity import ( + ENV_DISABLE_IDENTITY_PROVIDER, + ENV_SOUL_MD_PATH, + ENV_SYSTEM_PROMPT_PATH, + kora_identity_provider, + ) + + monkeypatch.delenv(ENV_DISABLE_IDENTITY_PROVIDER, raising=False) + monkeypatch.delenv(ENV_SYSTEM_PROMPT_PATH, raising=False) + monkeypatch.delenv(ENV_SOUL_MD_PATH, raising=False) + spec = kora_identity_provider(engine=None) + assert spec is not None + assert spec.system_prompt_content.strip() != "" + + +def test_handler_swallows_loader_exceptions(monkeypatch): + """Loader exception → handler returns None (fail-safe). The + engine then falls back to its file-read default.""" + from kora_cli.reasoning.kora_hermes_plugin.identity import plugin as plugin_mod + + def boom(): + raise RuntimeError("simulated load failure") + + monkeypatch.setattr(plugin_mod, "load_kora_identity", boom) + assert plugin_mod.kora_identity_provider(engine=None) is None + + +# --------------------------------------------------------------------------- +# Sub-register: wires through PluginContext.register_identity_provider +# --------------------------------------------------------------------------- + + +def test_subregister_wires_via_ctx_helper(): + """register() should call ctx.register_identity_provider with the + kora_identity_provider callable, which in turn wraps into a + pre_agent_identity_set hook callback.""" + from kora_cli.reasoning.kora_hermes_plugin.identity import register + from kora_cli.reasoning.kora_hermes_plugin.identity import ( + kora_identity_provider, + ) + + captured = {"identity_provider": None, "hooks": []} + + class _MockCtx: + def register_identity_provider(self, provider): + captured["identity_provider"] = provider + def register_hook(self, name, callback): + captured["hooks"].append((name, callback)) + + register(_MockCtx()) + assert captured["identity_provider"] is kora_identity_provider + + +# --------------------------------------------------------------------------- +# PluginContext.register_identity_provider — wrapping semantics +# --------------------------------------------------------------------------- + + +def test_register_identity_provider_wraps_spec_in_envelope(tmp_path, monkeypatch): + """The convenience method wraps a provider that returns + IdentitySpec into a hook callback that returns + {"identity": }. Plugin authors return raw IdentitySpec; + the firing site sees the envelope.""" + from agent.identity_spec import IdentitySpec + from kora_cli.plugins import PluginManager + + mgr = PluginManager() + + def my_provider(*, engine=None, **kw): + return IdentitySpec( + soul_md_content="x", + system_prompt_content="y", + identity_metadata={"agent_name": "Test"}, + ) + + # Simulate plugin registration via a minimal PluginContext stand-in + # that delegates to the same wrapping logic. + from kora_cli.plugins import PluginContext, PluginManifest + + manifest = PluginManifest(name="test_plugin", version="0.1.0", description="t") + ctx = PluginContext(manager=mgr, manifest=manifest) + ctx.register_identity_provider(my_provider) + + # Now invoke the hook; the wrapped callback should return + # {"identity": }. + results = mgr.invoke_hook("pre_agent_identity_set", engine=None) + assert len(results) == 1 + assert "identity" in results[0] + assert isinstance(results[0]["identity"], IdentitySpec) + assert results[0]["identity"].system_prompt_content == "y" + assert results[0]["identity"].identity_metadata == {"agent_name": "Test"} + + +def test_register_identity_provider_swallows_none_returns(): + """When the provider returns None, the wrapped callback returns + None — invoke_hook excludes None from results.""" + from kora_cli.plugins import PluginContext, PluginManager, PluginManifest + + mgr = PluginManager() + manifest = PluginManifest(name="test_p", version="0.1.0", description="t") + ctx = PluginContext(manager=mgr, manifest=manifest) + + ctx.register_identity_provider(lambda **kw: None) + results = mgr.invoke_hook("pre_agent_identity_set", engine=None) + assert results == [] + + +def test_register_identity_provider_swallows_non_identityspec_returns(): + """Defensive: provider returning a non-IdentitySpec value → wrapped + callback returns None + logs a warning. Don't crash the engine.""" + from kora_cli.plugins import PluginContext, PluginManager, PluginManifest + + mgr = PluginManager() + manifest = PluginManifest(name="bad_p", version="0.1.0", description="t") + ctx = PluginContext(manager=mgr, manifest=manifest) + + ctx.register_identity_provider(lambda **kw: "not an IdentitySpec") + results = mgr.invoke_hook("pre_agent_identity_set", engine=None) + assert results == [] + + +def test_register_identity_provider_swallows_provider_exceptions(): + """Provider exception → wrapped callback returns None (fail-safe). + Engine continues to other providers / file-read default.""" + from kora_cli.plugins import PluginContext, PluginManager, PluginManifest + + mgr = PluginManager() + manifest = PluginManifest(name="exc_p", version="0.1.0", description="t") + ctx = PluginContext(manager=mgr, manifest=manifest) + + def raises(**kw): + raise RuntimeError("simulated provider exception") + + ctx.register_identity_provider(raises) + results = mgr.invoke_hook("pre_agent_identity_set", engine=None) + assert results == [] + + +# --------------------------------------------------------------------------- +# First-non-None-wins semantics +# --------------------------------------------------------------------------- + + +def test_first_non_none_provider_wins(): + """Two plugins both register identity providers. The conversation- + loop side breaks on first non-None; verify the consumer-side + iteration mirrors that.""" + from agent.identity_spec import IdentitySpec + from kora_cli.plugins import PluginContext, PluginManager, PluginManifest + + mgr = PluginManager() + + ctx_a = PluginContext(manager=mgr, manifest=PluginManifest( + name="plugin_a", version="0.1.0", description="t")) + ctx_a.register_identity_provider( + lambda **kw: IdentitySpec( + soul_md_content="a-soul", system_prompt_content="a-sys", + ) + ) + + ctx_b = PluginContext(manager=mgr, manifest=PluginManifest( + name="plugin_b", version="0.1.0", description="t")) + ctx_b.register_identity_provider( + lambda **kw: IdentitySpec( + soul_md_content="b-soul", system_prompt_content="b-sys", + ) + ) + + results = mgr.invoke_hook("pre_agent_identity_set", engine=None) + # Both returned non-None. + assert len(results) == 2 + # Iterate as the engine does; break on first match. + chosen = None + for r in results: + spec = r.get("identity") if isinstance(r, dict) else None + if isinstance(spec, IdentitySpec): + chosen = spec + break + assert chosen is not None + assert chosen.system_prompt_content == "a-sys" + + +# --------------------------------------------------------------------------- +# Engine-side hook firing — structural pin +# --------------------------------------------------------------------------- + + +def test_engine_source_fires_identity_hook_before_file_read(): + """Source-level pin: the hook firing site is BEFORE the file-read + fallback. Slicing the engine source between the hook call and the + file-read call asserts ordering at a structural level.""" + engine_src = ( + Path(__file__).resolve().parents[2] + / "kora_cli" / "reasoning" / "anthropic_engine.py" + ).read_text() + + hook_idx = engine_src.find('"pre_agent_identity_set"') + file_read_idx = engine_src.find("prompt_path.read_text") + assert hook_idx != -1, "pre_agent_identity_set hook must be invoked in engine" + assert file_read_idx != -1, "file-read fallback must remain in engine" + assert hook_idx < file_read_idx, ( + "hook must fire BEFORE the file-read fallback so plugin " + "providers can claim identity; file-read is the fallback path" + ) + + +def test_engine_source_preserves_file_read_fallback(): + """The file-read fallback (pre-Option-C behavior) MUST stay in + the engine for bare-Hermes-no-Kora-plugin users.""" + engine_src = ( + Path(__file__).resolve().parents[2] + / "kora_cli" / "reasoning" / "anthropic_engine.py" + ).read_text() + + # Either resolution helper OR direct path read must still be + # present in the engine source. + assert "prompt_path.read_text" in engine_src + assert "_resolve_system_prompt_path" in engine_src + + +# --------------------------------------------------------------------------- +# Backward-compat: discovery shim exports the new alias +# --------------------------------------------------------------------------- + + +def test_discovery_shim_exports_identity_alias(): + """``plugins.kora_hermes`` re-exports the identity handler under + the canonical alias ``_pre_agent_identity_set`` for downstream + consumer-import stability.""" + from plugins.kora_hermes import _pre_agent_identity_set + from kora_cli.reasoning.kora_hermes_plugin.identity import ( + kora_identity_provider, + ) + + assert _pre_agent_identity_set is kora_identity_provider + + +def test_orchestrator_registers_seven_sub_plugins(): + """Post-Option-C: the orchestrator delegates to 7 sub-plugins + (cost_ladder, audit, caching, short_circuit, state_holders, + haiku_router, identity). Closes Lock R3-2's 7-of-7 target. + + The identity sub-plugin's register() calls + ``ctx.register_identity_provider`` which (in the real + PluginContext) internally calls ``self.register_hook( + "pre_agent_identity_set", wrapped)``. Mirror that delegation + in the MockCtx so both signals are visible.""" + from plugins.kora_hermes import register + + hook_names = [] + identity_providers = [] + + class _MockCtx: + def register_hook(self, name, callback): + hook_names.append(name) + def register_identity_provider(self, provider): + identity_providers.append(provider) + # Mirror real PluginContext: under the hood, + # register_identity_provider wires into the + # pre_agent_identity_set hook. + self.register_hook("pre_agent_identity_set", provider) + + register(_MockCtx()) + assert "pre_agent_identity_set" in hook_names, ( + "orchestrator must wire the identity sub-plugin (via " + "register_identity_provider, which underneath calls " + "register_hook for pre_agent_identity_set)" + ) + assert len(identity_providers) == 1, ( + "exactly one identity provider should be registered " + "(Kora's canonical identity)" + )