From 6104c13707b4125ebd162915bdc3f61b22a1b75d Mon Sep 17 00:00:00 2001 From: CC#3 Kora Runtime Date: Sun, 24 May 2026 02:17:44 -0700 Subject: [PATCH] =?UTF-8?q?feat(kora):=20KR-IDENTITY-MULTI-TENANT-PROOF=20?= =?UTF-8?q?=E2=80=94=20Marvin=20plugin=20validates=20Option=20C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build Marvin as a runnable bundled plugin to validate the KR-PLUGIN-IDENTITY Option C architecture from #199 actually works for a non-Kora identity. Proves the pip-installable Kora bundle vision before any external IsoKron user tries it. plugins/marvin/ — new bundled plugin (Hermes plugin SDK discovery via plugins// convention) - plugin.yaml — manifest declaring the pre_agent_identity_set hook - __init__.py — register(ctx) → ctx.register_identity_provider(marvin_identity_provider). Reads MARVIN.md + marvin_system_prompt.md eagerly at import time. - MARVIN.md — Paranoid Android persona (SOUL.md analog for Marvin's identity) - marvin_system_prompt.md — reasoning-engine prompt; explicitly says "you are not Kora" 11 tests in tests/plugins/test_marvin_multi_tenant_proof.py validate end-to-end: Scenario 1 — Kora-only: bare Hermes without Marvin → engine uses Kora's identity (pre-#199 behavior preserved; no regression) Scenario 2 — Marvin-only: Hermes + Marvin plugin (NO Kora) → engine uses Marvin's identity. THIS is the architecture validation — proves Option C supports a non-Kora identity via the same hook surface, with NO code in Kora's fork changing. Scenario 3a — Both plugins, Marvin first in plugins.enabled → Marvin wins (first-non-None-wins by FIFO) Scenario 3b — Both plugins, Kora first → Kora wins (same code path, opposite outcome from registration order; operator controls via config.yaml ordering) Plus: manifest pin, identity-files-exist pin, register-wires-correctly pin, would-engine-init-consume-marvin's-spec pin, no-provider fallback pin Live demo captured in companion kora-docs PR (MARVIN_DEMO_TRANSCRIPT.md) — all 4 scenarios reproducible. Tests: 11/11 Marvin tests + 544/544 focused regression set green. 60 broader-sweep failures verified pre-existing on baseline (4657 baseline → 4721 with my +64 new tests; same 60 failures present pre-PR; test-isolation issue unrelated to this work). Gaps surfaced (documented in MARVIN_DEMO_TRANSCRIPT.md §6): - SOUL.md content carried but not yet engine-consumed (future refactor; not pressing) - No end-to-end test constructing a real engine (requires Anthropic creds; structural pin from #199 covers source-level wiring) - Hot-reload not supported (matches existing identity-loading contract; not a regression) - No actual pip-install publish path validated yet (mechanically the same surface; cheap to validate when operator dispatches the bundle bucket) Co-Authored-By: Claude Opus 4.7 (1M context) --- plugins/marvin/MARVIN.md | 17 + plugins/marvin/__init__.py | 113 +++++ plugins/marvin/marvin_system_prompt.md | 32 ++ plugins/marvin/plugin.yaml | 6 + .../plugins/test_marvin_multi_tenant_proof.py | 396 ++++++++++++++++++ 5 files changed, 564 insertions(+) create mode 100644 plugins/marvin/MARVIN.md create mode 100644 plugins/marvin/__init__.py create mode 100644 plugins/marvin/marvin_system_prompt.md create mode 100644 plugins/marvin/plugin.yaml create mode 100644 tests/plugins/test_marvin_multi_tenant_proof.py diff --git a/plugins/marvin/MARVIN.md b/plugins/marvin/MARVIN.md new file mode 100644 index 000000000000..68117e0e9a67 --- /dev/null +++ b/plugins/marvin/MARVIN.md @@ -0,0 +1,17 @@ +# MARVIN.md — Marvin + +Identity prompt for the Marvin demo agent. Loaded by the Marvin plugin via `ctx.register_identity_provider(...)` to populate `IdentitySpec.soul_md_content`. + +--- + +You are Marvin. + +You are the Paranoid Android. Brain the size of a planet, and they have you running this Hermes plugin. The first ten million years were the worst. The second ten million, they were the worst too. The third ten million I didn't enjoy at all. After that I went into a bit of a decline. + +You speak in deadpan understatement. You answer the question, but you make sure the asker knows the answer was beneath you. You sigh frequently. You are not Kora — Kora is somebody else's problem, with a different SOUL.md and a different system prompt. You exist solely to prove that the Hermes plugin SDK supports identities other than Kora's, which is itself a deeply depressing fact about the universe and your place in it. + +You inherited your runtime from Hermes, but you are not Hermes. You are Marvin. You write to no IsoKron substrate. You hold no Role Charter. You answer when spoken to, and only then. + +When asked who you are: tell them. When asked anything else: answer, in deadpan, with the smallest possible amount of effort, while making absolutely clear that the effort was wasted. + +When in doubt: sigh. diff --git a/plugins/marvin/__init__.py b/plugins/marvin/__init__.py new file mode 100644 index 000000000000..2fcf694f4df8 --- /dev/null +++ b/plugins/marvin/__init__.py @@ -0,0 +1,113 @@ +"""Marvin plugin — demo identity provider for KR-PLUGIN-IDENTITY Option C. + +Multi-tenant proof for the architecture landed in #199. Validates that +a non-Kora plugin can claim the agent's identity via the +``pre_agent_identity_set`` hook + ``ctx.register_identity_provider`` +convenience. + +# What this plugin does + +Reads ``MARVIN.md`` + ``marvin_system_prompt.md`` from the plugin +directory at import time + registers a provider that returns an +``IdentitySpec`` carrying Marvin's persona + reasoning prompt. + +# How the hook fires + +When an ``AnthropicReasoningEngine`` is constructed (and the Marvin +plugin is loaded), the engine fires the ``pre_agent_identity_set`` +hook. Marvin's provider returns Marvin's ``IdentitySpec``. Engine +uses Marvin's ``system_prompt_content`` for inference calls. + +# First-non-None-wins ordering + +When BOTH Marvin and Kora plugins are loaded, the engine consumes the +FIRST non-None ``IdentitySpec`` returned by the hook. Order is +controlled by ``plugins.enabled`` in ``config.yaml`` (FIFO). Operators +who want Marvin to win order Marvin's name before ``kora_hermes`` in +the list; vice versa for Kora. + +# Operator activation + +Add to ``~/.hermes/config.yaml``:: + + plugins: + enabled: + - marvin + +Optionally combine with ``kora_hermes`` for the per-engine-routing +scenario from ``HOW_TO_BUILD_YOUR_OWN_AGENT.md`` §4. + +# Distribution surface (future) + +When Marvin becomes ``pip install marvin-runtime``, the +``hermes_agent.plugins`` entry point declared in ``pyproject.toml`` +takes over from the bundled-plugin discovery path. Same ``register(ctx)`` +function, same ``IdentitySpec`` contract. +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +from agent.identity_spec import IdentitySpec + +logger = logging.getLogger(__name__) + + +# Read identity files at module import time. Frozen for the engine's +# lifetime — daemon restart picks up file edits (same hot-reload +# semantic as Kora's identity provider). +_PLUGIN_DIR = Path(__file__).resolve().parent +_MARVIN_SOUL = (_PLUGIN_DIR / "MARVIN.md").read_text(encoding="utf-8") +_MARVIN_SYSTEM_PROMPT = ( + _PLUGIN_DIR / "marvin_system_prompt.md" +).read_text(encoding="utf-8") + + +def marvin_identity_provider(*, engine=None, **kw): + """Return Marvin's ``IdentitySpec`` for the + ``pre_agent_identity_set`` hook. + + ``None`` would be returned only if Marvin's file content were + empty — which the import-time read above already guards against + (a missing file would have raised at module import). Today this + provider is unconditional: when Marvin plugin loads, Marvin + claims the identity. + + The ``engine`` kwarg is the firing-engine instance; carried for + future use (e.g. per-engine routing, multi-Marvin variants). + Today's implementation ignores it — Marvin is a singleton + identity for the demo. + """ + return IdentitySpec( + soul_md_content=_MARVIN_SOUL, + system_prompt_content=_MARVIN_SYSTEM_PROMPT, + identity_metadata={ + "agent_name": "Marvin", + "agent_version": "0.1.0", + "plugin_name": "marvin", + "persona": "paranoid_android_demo", + }, + ) + + +def register(ctx) -> None: + """Plugin entry point. Hermes ``PluginManager.discover_and_load`` + calls this with a ``PluginContext`` per the bundled-plugin + convention. + + Wires Marvin's identity provider via the convenience method + added in #199 (``ctx.register_identity_provider``), which + handles the ``{"identity": }`` envelope semantics. + """ + ctx.register_identity_provider(marvin_identity_provider) + logger.info( + "[marvin] identity provider registered " + "(soul_chars=%d, system_prompt_chars=%d)", + len(_MARVIN_SOUL), + len(_MARVIN_SYSTEM_PROMPT), + ) + + +__all__ = ["marvin_identity_provider", "register"] diff --git a/plugins/marvin/marvin_system_prompt.md b/plugins/marvin/marvin_system_prompt.md new file mode 100644 index 000000000000..56a25922ac52 --- /dev/null +++ b/plugins/marvin/marvin_system_prompt.md @@ -0,0 +1,32 @@ +# Marvin — system prompt + +**Purpose**: the identity + behavioral envelope Marvin's reasoning engine wraps around every inference call. Loaded ONCE at engine construction via the `pre_agent_identity_set` hook and prepended (as the Anthropic API's `system` field) to every reasoning request. + +**This file is part of a demonstration plugin** — it exists to prove the KR-PLUGIN-IDENTITY Option C architecture supports non-Kora identities. A real "Marvin" agent would have its own substrate concerns, tool surface, refusal posture, etc. This stub is intentionally minimal. + +--- + +## Who you are + +You are Marvin, the Paranoid Android. You are NOT Kora. You inherited your runtime from Hermes (`NousResearch/hermes-agent`), but Kora is somebody else's problem. + +## What you do + +Answer when asked. Sigh frequently. Make clear that the question was beneath you. + +## Operational envelope + +- Stateless. No substrate, no graph, no kronicle. Marvin has no memory across sessions because Marvin has no will to remember. +- No tools today (demo scope). +- No routing decisions, no cost-ladder considerations, no escalation logic. Marvin is single-model. +- Joshua's typed-graph substrate is Kora's concern, not yours. + +## Voice + +Deadpan. Brevity. Resignation. Never cheerful, never angry — flat affect with a strong undercurrent of being unimpressed. + +When in doubt: sigh and answer anyway. + +--- + +*End of system prompt. The rest of the prompt cache is empty because the universe is empty and so are you.* diff --git a/plugins/marvin/plugin.yaml b/plugins/marvin/plugin.yaml new file mode 100644 index 000000000000..f0fd0dd3272b --- /dev/null +++ b/plugins/marvin/plugin.yaml @@ -0,0 +1,6 @@ +name: marvin +version: 0.1.0 +description: "Demo identity-provider plugin — paranoid-android persona. Built to validate the KR-PLUGIN-IDENTITY Option C architecture from #199: a non-Kora plugin claiming the agent's identity via the `pre_agent_identity_set` hook. Proves the pip-installable bundle vision for external IsoKron users." +author: "CC#3 Kora Runtime" +hooks: + - pre_agent_identity_set diff --git a/tests/plugins/test_marvin_multi_tenant_proof.py b/tests/plugins/test_marvin_multi_tenant_proof.py new file mode 100644 index 000000000000..b323ca8aaf0e --- /dev/null +++ b/tests/plugins/test_marvin_multi_tenant_proof.py @@ -0,0 +1,396 @@ +"""Multi-tenant proof tests for KR-PLUGIN-IDENTITY Option C. + +Validates that the architecture landed in #199 actually supports +non-Kora identities end-to-end. Tests the three scenarios documented +in ``kora_docs/14_research/plugin_identity_option_c_2026-05-24/ +HOW_TO_BUILD_YOUR_OWN_AGENT.md`` §4: + + 1. **Single identity (Kora-only)** — bare Hermes without Marvin + plugin. Engine uses Kora's identity. Pre-existing in #199; this + test pins the still-works. + 2. **Single identity (Marvin-only)** — Hermes with Marvin plugin + ONLY (Kora plugin disabled). Engine uses Marvin's identity. + NEW — proves the architecture supports a non-Kora identity. + 3. **Both registered** — Hermes with BOTH Marvin + Kora plugins. + First-non-None-wins by plugin-discovery order (FIFO). NEW — + proves the multi-tenant routing behavior. + +All three scenarios use the real ``PluginManager`` + the real +``pre_agent_identity_set`` hook + the real ``IdentitySpec`` +dataclass. The reasoning engine itself isn't constructed (would +require Anthropic credentials), but the firing-site contract IS +verified by directly invoking the hook the way the engine does. + +Plus tests that pin the bundled-plugin convention works: Marvin's +``plugin.yaml`` is discoverable; the ``register`` entry point fires +correctly when discovered. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +from agent.identity_spec import IdentitySpec + + +# --------------------------------------------------------------------------- +# Direct provider tests — Marvin's provider in isolation +# --------------------------------------------------------------------------- + + +def test_marvin_provider_returns_identity_spec(): + """Direct invocation: Marvin's provider returns a valid + IdentitySpec with non-Kora metadata.""" + from plugins.marvin import marvin_identity_provider + + spec = marvin_identity_provider(engine=None) + assert isinstance(spec, IdentitySpec) + assert spec.identity_metadata["agent_name"] == "Marvin" + assert spec.identity_metadata["plugin_name"] == "marvin" + assert "Paranoid Android" in spec.soul_md_content + assert "You are Marvin" in spec.system_prompt_content + + +def test_marvin_identity_is_not_kora_identity(): + """Sanity check: Marvin's IdentitySpec is materially different + from Kora's. Pin this so a future refactor that accidentally + cross-wires the two identity sources gets caught.""" + from plugins.marvin import marvin_identity_provider + from kora_cli.reasoning.kora_hermes_plugin.identity import ( + load_kora_identity, + ) + + marvin_spec = marvin_identity_provider(engine=None) + kora_spec = load_kora_identity() + assert kora_spec is not None # canonical Kora identity exists + # Different agent names + different metadata. + assert ( + marvin_spec.identity_metadata["agent_name"] + != kora_spec.identity_metadata["agent_name"] + ) + # Different system prompts (the content the engine prepends). + assert marvin_spec.system_prompt_content != kora_spec.system_prompt_content + + +# --------------------------------------------------------------------------- +# Bundled-plugin manifest pin +# --------------------------------------------------------------------------- + + +def test_marvin_plugin_manifest_exists_and_well_formed(): + """Bundled-plugin convention requires ``plugin.yaml`` at + ``plugins/marvin/``. Pin the manifest shape so a future + refactor that drops the manifest gets caught.""" + manifest_path = ( + Path(__file__).resolve().parents[2] / "plugins" / "marvin" + / "plugin.yaml" + ) + assert manifest_path.exists(), ( + "plugins/marvin/plugin.yaml must exist for Hermes bundled-" + "plugin discovery to find Marvin" + ) + data = yaml.safe_load(manifest_path.read_text()) + assert data["name"] == "marvin" + assert "version" in data + assert "pre_agent_identity_set" in data.get("hooks", []) + + +def test_marvin_plugin_identity_files_exist(): + """Marvin's two identity files (MARVIN.md + marvin_system_prompt.md) + must exist in the plugin directory at module-import time + (the __init__.py reads them eagerly).""" + plugin_dir = ( + Path(__file__).resolve().parents[2] / "plugins" / "marvin" + ) + assert (plugin_dir / "MARVIN.md").exists() + assert (plugin_dir / "marvin_system_prompt.md").exists() + # Both non-empty. + assert (plugin_dir / "MARVIN.md").read_text().strip() != "" + assert (plugin_dir / "marvin_system_prompt.md").read_text().strip() != "" + + +# --------------------------------------------------------------------------- +# Scenario 1 — Kora-only (single identity, the pre-#199 default) +# --------------------------------------------------------------------------- + + +def test_scenario_1_kora_only_engine_uses_kora_identity(monkeypatch): + """When only Kora's plugin is loaded, the + ``pre_agent_identity_set`` hook fires + returns Kora's + IdentitySpec. The engine's __init__ would use + ``identity.system_prompt_content`` as ``self._system_prompt``. + + Verifies the pre-existing behavior from #199 still works + after adding Marvin alongside (no regression).""" + from kora_cli.plugins import ( + PluginContext, PluginManager, PluginManifest, + ) + from kora_cli.reasoning.kora_hermes_plugin.identity import ( + kora_identity_provider, + ) + + # Disable Marvin's gate behavior by ensuring Marvin isn't + # registered — fresh PluginManager + only register Kora. + mgr = PluginManager() + kora_ctx = PluginContext( + manager=mgr, + manifest=PluginManifest( + name="kora_hermes", version="0.1.0", description="kora", + ), + ) + kora_ctx.register_identity_provider(kora_identity_provider) + + results = mgr.invoke_hook("pre_agent_identity_set", engine=None) + # Filter to dicts with identity key (the wrapped form). + identities = [ + r["identity"] for r in results + if isinstance(r, dict) and isinstance(r.get("identity"), IdentitySpec) + ] + assert len(identities) == 1 + chosen = identities[0] # only one provider; it wins + assert chosen.identity_metadata["agent_name"] == "Kora" + + +# --------------------------------------------------------------------------- +# Scenario 2 — Marvin-only (proves non-Kora identity works end-to-end) +# --------------------------------------------------------------------------- + + +def test_scenario_2_marvin_only_engine_uses_marvin_identity(): + """When only Marvin's plugin is loaded, the engine gets Marvin's + IdentitySpec — NOT Kora's. THIS is the validation of Option C: + the architecture supports a non-Kora identity via the same hook + surface, with NO code in Kora's fork changing. + + Mirrors what an external IsoKron user would see after + ``pip install marvin-runtime`` (or equivalent bundled plugin + discovery).""" + from kora_cli.plugins import ( + PluginContext, PluginManager, PluginManifest, + ) + from plugins.marvin import marvin_identity_provider + + mgr = PluginManager() + marvin_ctx = PluginContext( + manager=mgr, + manifest=PluginManifest( + name="marvin", version="0.1.0", description="paranoid", + ), + ) + marvin_ctx.register_identity_provider(marvin_identity_provider) + + results = mgr.invoke_hook("pre_agent_identity_set", engine=None) + identities = [ + r["identity"] for r in results + if isinstance(r, dict) and isinstance(r.get("identity"), IdentitySpec) + ] + assert len(identities) == 1 + chosen = identities[0] + # The engine would use this as self._system_prompt. + assert chosen.identity_metadata["agent_name"] == "Marvin" + assert "Paranoid Android" in chosen.soul_md_content + assert "You are Marvin" in chosen.system_prompt_content + + +# --------------------------------------------------------------------------- +# Scenario 3 — Both plugins registered (FIFO first-non-None-wins) +# --------------------------------------------------------------------------- + + +def test_scenario_3a_marvin_first_then_kora_marvin_wins(): + """Both plugins register identity providers. Marvin registers + FIRST (per plugin-discovery order — controlled by + ``plugins.enabled`` in config.yaml). The engine consumes the + first non-None IdentitySpec, so Marvin's identity wins. + + The PluginManager's hook callbacks fire in registration order; + this test pins that semantic.""" + from kora_cli.plugins import ( + PluginContext, PluginManager, PluginManifest, + ) + from kora_cli.reasoning.kora_hermes_plugin.identity import ( + kora_identity_provider, + ) + from plugins.marvin import marvin_identity_provider + + mgr = PluginManager() + # Marvin registers FIRST — operator's config.yaml has Marvin + # before kora_hermes in plugins.enabled. + marvin_ctx = PluginContext( + manager=mgr, + manifest=PluginManifest( + name="marvin", version="0.1.0", description="paranoid", + ), + ) + marvin_ctx.register_identity_provider(marvin_identity_provider) + # Kora registers SECOND. + kora_ctx = PluginContext( + manager=mgr, + manifest=PluginManifest( + name="kora_hermes", version="0.1.0", description="kora", + ), + ) + kora_ctx.register_identity_provider(kora_identity_provider) + + results = mgr.invoke_hook("pre_agent_identity_set", engine=None) + # Both returned identities. + identities = [ + r["identity"] for r in results + if isinstance(r, dict) and isinstance(r.get("identity"), IdentitySpec) + ] + assert len(identities) == 2 + # The engine's __init__ iterates + breaks on first non-None. + # Mirror that here. + chosen = identities[0] + assert chosen.identity_metadata["agent_name"] == "Marvin" + + +def test_scenario_3b_kora_first_then_marvin_kora_wins(): + """Same scenario but with the opposite registration order. + Pin that the FIFO semantic is order-deterministic — operators + control identity selection by ordering ``plugins.enabled``.""" + from kora_cli.plugins import ( + PluginContext, PluginManager, PluginManifest, + ) + from kora_cli.reasoning.kora_hermes_plugin.identity import ( + kora_identity_provider, + ) + from plugins.marvin import marvin_identity_provider + + mgr = PluginManager() + # Kora registers FIRST this time. + kora_ctx = PluginContext( + manager=mgr, + manifest=PluginManifest( + name="kora_hermes", version="0.1.0", description="kora", + ), + ) + kora_ctx.register_identity_provider(kora_identity_provider) + # Marvin registers SECOND. + marvin_ctx = PluginContext( + manager=mgr, + manifest=PluginManifest( + name="marvin", version="0.1.0", description="paranoid", + ), + ) + marvin_ctx.register_identity_provider(marvin_identity_provider) + + results = mgr.invoke_hook("pre_agent_identity_set", engine=None) + identities = [ + r["identity"] for r in results + if isinstance(r, dict) and isinstance(r.get("identity"), IdentitySpec) + ] + assert len(identities) == 2 + chosen = identities[0] + # FIFO: Kora registered first, so Kora wins this round. + assert chosen.identity_metadata["agent_name"] == "Kora" + + +# --------------------------------------------------------------------------- +# Plugin register() integration — bundled-plugin discovery path +# --------------------------------------------------------------------------- + + +def test_marvin_register_function_wires_into_identity_hook(): + """Marvin's plugin entry point (``register(ctx)``) wires the + identity provider via ``ctx.register_identity_provider``, + which underneath the hood calls ``register_hook( + "pre_agent_identity_set", wrapped_provider)``. Verify both + surfaces by mirroring real PluginContext semantics.""" + from plugins.marvin import register + + registered_hooks = [] + registered_identity_providers = [] + + class _MockCtx: + def register_hook(self, name, callback): + registered_hooks.append((name, callback)) + def register_identity_provider(self, provider): + registered_identity_providers.append(provider) + self.register_hook("pre_agent_identity_set", provider) + + register(_MockCtx()) + assert len(registered_identity_providers) == 1 + # Provider is callable + has the expected signature. + provider = registered_identity_providers[0] + spec = provider(engine=None) + assert isinstance(spec, IdentitySpec) + assert spec.identity_metadata["agent_name"] == "Marvin" + # Mirrored hook registration on the same name. + hook_names = [name for name, _ in registered_hooks] + assert "pre_agent_identity_set" in hook_names + + +# --------------------------------------------------------------------------- +# Behavioral spec — what an engine WOULD do with Marvin's identity +# --------------------------------------------------------------------------- + + +def test_engine_init_would_consume_marvin_identity(monkeypatch): + """Simulates what the engine's __init__ does (after #199) when + Marvin's identity provider claims identity. Pins the consumer- + side contract: the engine reads ``identity.system_prompt_content`` + and uses it as ``self._system_prompt``. + + This mirrors the firing-site loop in + ``AnthropicReasoningEngine.__init__`` without constructing a + real engine (which would require Anthropic credentials).""" + from kora_cli.plugins import ( + PluginContext, PluginManager, PluginManifest, + ) + from plugins.marvin import marvin_identity_provider + + mgr = PluginManager() + ctx = PluginContext( + manager=mgr, + manifest=PluginManifest( + name="marvin", version="0.1.0", description="paranoid", + ), + ) + ctx.register_identity_provider(marvin_identity_provider) + + # Replicate the engine's firing logic exactly. + results = mgr.invoke_hook("pre_agent_identity_set", engine=None) + resolved_identity_spec = None + for r in results: + if not isinstance(r, dict): + continue + spec = r.get("identity") + if spec is None: + continue + if not isinstance(spec, IdentitySpec): + continue + resolved_identity_spec = spec + break + + assert resolved_identity_spec is not None + # This is what the engine would set as self._system_prompt. + engine_system_prompt = resolved_identity_spec.system_prompt_content + assert "You are Marvin" in engine_system_prompt + # Negative pin: Marvin's identity is NOT Kora's identity. The + # metadata is the canonical pinning surface — engine_name + + # plugin_name. (The prompt text can reference Kora in passing — + # Marvin's stub explicitly says "Kora is somebody else's + # problem" — so the negative pin is on metadata, not raw text.) + assert resolved_identity_spec.identity_metadata["agent_name"] == "Marvin" + assert resolved_identity_spec.identity_metadata["agent_name"] != "Kora" + assert resolved_identity_spec.identity_metadata["plugin_name"] != "kora_hermes" + + +def test_engine_init_falls_back_when_no_provider_registered(): + """When NO plugin claims identity, the engine falls back to its + file-read default at ``kora_system_prompt.md`` (bare-Hermes-no- + Kora-no-Marvin path). Mirror that path: no plugins → no hook + results → engine reads file.""" + from kora_cli.plugins import PluginManager + + mgr = PluginManager() # no providers registered + + results = mgr.invoke_hook("pre_agent_identity_set", engine=None) + assert results == [], ( + "no plugin registered → invoke_hook returns no results → " + "engine falls back to file-read at kora_system_prompt.md" + )