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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
349 changes: 312 additions & 37 deletions kora_cli/handlers/slack_dm_handler.py

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions kora_cli/listeners/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,9 @@
# construction happens per cycle (stateless across cycles), so
# startup is a clean no-op + LOG line.
from kora_cli.listeners import heartbeat_probes_listener # noqa: F401
# KR-FEAT-AI-RESPONSE-LOOP ST2 — reasoning engine listener.
# Constructs the AnthropicReasoningEngine at daemon startup;
# fail-CLOSED on missing creds / missing system prompt (coordinator
# aborts boot). Module-level `current_reasoning_engine()` accessor
# mirrors `current_pool()` so SlackDMHandler reads cross-cuttingly.
from kora_cli.listeners import reasoning_engine_listener # noqa: F401
152 changes: 152 additions & 0 deletions kora_cli/listeners/reasoning_engine_listener.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"""Reasoning engine daemon listener — KR-FEAT-AI-RESPONSE-LOOP ST2.

Wraps :class:`AnthropicReasoningEngine` in the
:class:`DaemonCoordinator` lifecycle:

- Startup: construct the engine (loads system prompt from
``kora_docs/00_canonical_current_state/kora_system_prompt.md``;
resolves credential cascade OAuth-first → API key → fail-CLOSED).
Construction failure → daemon aborts boot (matches the bucket
spec's "engine startup failure → daemon fails-CLOSED" since
a daemon that can't reason is one that can't fulfill its
primary purpose).
- Hold: module-level ``_engine_singleton`` set via
``_set_singleton``; cleared on shutdown.
- Shutdown: close the engine's underlying HTTP client. Best-
effort; the coordinator's per-listener timeout (default 10s)
caps the wait.

Mirrors ``kora_cli/listeners/mcp_consumption.py`` shape — singleton
pattern + ``current_reasoning_engine()`` accessor for cross-cutting
read from any code path (notably ``SlackDMHandler`` in ST2).

# Why startup failure should be FATAL

The bucket spec is explicit: "Engine startup failure during daemon
boot → daemon fails-CLOSED (no echoes since previous behavior is
replaced by reasoning that can't run)." After ST2 wires the
handler to the engine, the prior echo path is gone — if the engine
can't construct, the daemon has no useful response path. Better
to abort boot loudly than to ship a daemon that drops Joshua's
DMs into a canned-fallback loop.

The coordinator's :class:`DaemonCoordinator` handles this
naturally: any exception from ``startup()`` aborts the boot +
unwinds already-started listeners (KR-D-DAEMON ST1's lifecycle).
"""

from __future__ import annotations

import logging
from typing import Optional

from kora_cli.daemon import DEFAULT_SHUTDOWN_TIMEOUT, register_daemon_listener
from kora_cli.reasoning.anthropic_engine import (
AnthropicReasoningEngine,
ReasoningEngineError,
)
from kora_cli.reasoning.engine import ReasoningEngine

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Module-level singleton + accessor (mirrors current_pool pattern)
# ---------------------------------------------------------------------------


_engine_singleton: Optional[ReasoningEngine] = None


def _set_singleton(engine: ReasoningEngine) -> None:
global _engine_singleton
_engine_singleton = engine


def _clear_singleton() -> None:
global _engine_singleton
_engine_singleton = None


def current_reasoning_engine() -> Optional[ReasoningEngine]:
"""Return the live :class:`ReasoningEngine`, or ``None``.

``None`` cases:
- Daemon not running
- Listener not yet started
- Listener stopped (post-shutdown)
- Listener startup failed AND the daemon proceeded anyway
(shouldn't happen — fatal-CLOSED — but defensive)

Mirrors :func:`kora_cli.listeners.mcp_consumption.current_pool`.
"""
return _engine_singleton


# ---------------------------------------------------------------------------
# Listener lifecycle wrapper
# ---------------------------------------------------------------------------


class ReasoningEngineListener:
"""Owns the engine instance + sets the module-level singleton.

Tests inject a pre-built engine via the constructor arg; the
factory leaves it ``None`` so production startup creates a
real ``AnthropicReasoningEngine``.
"""

def __init__(
self, engine: Optional[ReasoningEngine] = None
) -> None:
self._engine: Optional[ReasoningEngine] = engine

async def startup(self) -> None:
if self._engine is None:
# Construction can raise ReasoningEngineNotConfigured /
# ReasoningSystemPromptError. We do NOT catch — the
# coordinator's startup-failure path unwinds the daemon,
# which is the spec-mandated fail-CLOSED behavior.
try:
self._engine = AnthropicReasoningEngine()
except ReasoningEngineError as exc:
logger.error(
"[kora.reasoning] engine construction failed: %r "
"— daemon will abort boot (fail-CLOSED). Operator "
"must configure credentials + system prompt before "
"the daemon can reply to DMs.",
exc,
)
raise
_set_singleton(self._engine)
logger.info("[kora.reasoning] engine listener active")

async def shutdown(self) -> None:
engine = self._engine
_clear_singleton()
if engine is None:
return
try:
close_method = getattr(engine, "close", None)
if close_method is not None:
result = close_method()
if hasattr(result, "__await__"):
await result
except Exception as exc:
logger.warning(
"[kora.reasoning] engine shutdown raised %r — continuing",
exc,
)


# ---------------------------------------------------------------------------
# Factory + registration (import-time side effect)
# ---------------------------------------------------------------------------


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


register_daemon_listener("reasoning_engine", _factory)
60 changes: 32 additions & 28 deletions kora_cli/reasoning/anthropic_engine.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
"""AnthropicReasoningEngine — KR-FEAT-AI-RESPONSE-LOOP ST1.
"""AnthropicReasoningEngine — KR-FEAT-AI-RESPONSE-LOOP ST1+ST2.

Implements :class:`kora_cli.reasoning.engine.ReasoningEngine` against
Anthropic's Python SDK (``anthropic==0.86.0``, declared in the
``[web]`` extra alongside fastapi/uvicorn/slowapi so daemon installs
auto-pick it up).
Anthropic's Python SDK (``anthropic==0.86.0``, runtime dep — promoted
from extra in ST2 per PM ruling 2026-05-22 since the reasoning_engine
listener imports it unconditionally at boot).

# Credential cascade
# Credential cascade — OAuth FIRST (PM ruling 2026-05-22 ST2)

Two supported credential sources (K-DG drift surfaced in the ST1
PR body — bucket spec said ``KORA_ANTHROPIC_API_KEY`` only; the
existing env mapping doc plus the gate-2 anti-secret block + the
"Max plan via Agent SDK billing" framing in §1 imply
``CLAUDE_CODE_OAUTH_TOKEN`` is the canonical credential):
Two supported credential sources. **OAuth-first** because Joshua's
Max 20x plan + the post-May-15 SDK billing split route the $200/mo
Agent SDK pool via the OAuth token path. OAuth = production;
API key = fallback for testing / dev / local-without-Max-setup.

1. ``KORA_ANTHROPIC_API_KEY`` (if set) → SDK constructed with
``api_key=...``. Billing: Anthropic Console (operator must
provision an API key separately).
2. ``CLAUDE_CODE_OAUTH_TOKEN`` (fallback) → SDK constructed with
1. ``CLAUDE_CODE_OAUTH_TOKEN`` (if set) → SDK constructed with
``auth_token=...``. Billing: Max plan ($200/mo Agent SDK
pool). Existing Doppler ``kora-runtime-anthropic`` secret.
**Production path.**
2. ``KORA_ANTHROPIC_API_KEY`` (fallback) → SDK constructed with
``api_key=...``. Billing: Anthropic Console (operator must
provision an API key separately). Test / dev escape hatch.

Both unset → ``ReasoningEngineNotConfigured`` raised at
construction. **Fail-CLOSED** per
Expand Down Expand Up @@ -153,18 +153,22 @@ def __init__(
# stand-in (anything with an async ``messages.create``).
client: Optional[Any] = None,
) -> None:
# Credential cascade. Read once at construction (production
# rotation pattern: redeploy, not hot-reload — same as
# SlackClient's bot-token model).
api_key = os.environ.get(API_KEY_ENV, "").strip() or None
# Credential cascade — OAuth FIRST (PM ruling 2026-05-22).
# OAuth = production path (Max plan billing); API key =
# fallback for dev/testing. Read once at construction
# (production rotation pattern: redeploy, not hot-reload —
# same as SlackClient's bot-token model).
oauth_token = os.environ.get(OAUTH_TOKEN_ENV, "").strip() or None
if not api_key and not oauth_token:
api_key = os.environ.get(API_KEY_ENV, "").strip() or None
if not oauth_token and not api_key:
raise ReasoningEngineNotConfigured(
f"both {API_KEY_ENV} and {OAUTH_TOKEN_ENV} are unset — "
"daemon cannot reason. Set one in Doppler "
"(kora-runtime-anthropic project). See env-mapping doc."
f"both {OAUTH_TOKEN_ENV} and {API_KEY_ENV} are unset — "
"daemon cannot reason. Set CLAUDE_CODE_OAUTH_TOKEN in "
"Doppler (kora-runtime-anthropic project) — the "
"production path via Joshua's Max plan."
)
self._auth_mode: str = "api_key" if api_key else "oauth_token"
# OAuth wins when both are set.
self._auth_mode: str = "oauth_token" if oauth_token else "api_key"
# Stored under underscore-prefixed attrs to discourage casual
# serialization. NEVER logged.
self._api_key = api_key
Expand Down Expand Up @@ -286,15 +290,15 @@ async def _ensure_client(self) -> Any:
# never see the real client created.
from anthropic import AsyncAnthropic

if self._api_key:
# OAuth-first per PM ruling: prefer Max-plan billing path.
# Falls back to API key only when OAuth is absent.
if self._oauth_token:
self._client = AsyncAnthropic(
api_key=self._api_key, timeout=self._timeout
auth_token=self._oauth_token, timeout=self._timeout
)
else:
# OAuth token via the SDK's ``auth_token`` constructor
# arg (Max plan billing path).
self._client = AsyncAnthropic(
auth_token=self._oauth_token, timeout=self._timeout
api_key=self._api_key, timeout=self._timeout
)
return self._client

Expand Down
11 changes: 7 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@ dependencies = [
# over on import (task #269). PR #125 traced the cascade. Same
# placement discipline as aiosmtplib above.
"slowapi==0.1.9",
# Anthropic SDK — required by the reasoning engine listener (KR-FEAT-
# AI-RESPONSE-LOOP). Promoted to runtime per the same rule that moved
# aiosmtplib + the slowapi-fix lesson: the daemon's reasoning_engine
# listener imports it unconditionally at boot. Previously under
# [anthropic] + [web] extras; consolidated here.
"anthropic==0.86.0",
]

[project.urls]
Expand All @@ -91,9 +97,6 @@ Repository = "https://github.com/rafe-walker/kora"
Upstream = "https://github.com/NousResearch/hermes-agent"

[project.optional-dependencies]
# Native Anthropic provider — only needed when provider=anthropic (not via
# OpenRouter or other aggregators).
anthropic = ["anthropic==0.86.0"]
# Web search backends — each only loaded when the user picks it as their
# search provider (configured via `hermes tools` or config.yaml).
exa = ["exa-py==2.10.2"]
Expand Down Expand Up @@ -198,7 +201,7 @@ youtube = [
"youtube-transcript-api==1.2.4",
]
# `hermes dashboard` (localhost SPA + API). Not in core to keep the default install lean.
web = ["fastapi==0.133.1", "uvicorn[standard]==0.41.0", "anthropic==0.86.0"]
web = ["fastapi==0.133.1", "uvicorn[standard]==0.41.0"]
all = [
# Policy (2026-05-12): `[all]` includes only extras that genuinely
# CAN'T be lazy-installed via `tools/lazy_deps.py` — i.e. things every
Expand Down
Loading