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
20 changes: 18 additions & 2 deletions kora_cli/clients/purelymail_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ async def send_email(
body_html: Optional[str] = None,
in_reply_to: Optional[str] = None,
attachments: Optional[list[Attachment]] = None,
caller_actor_kind: Optional[str] = None,
) -> SendResult:
"""Send one email. Returns a :class:`SendResult` always —
failures are surfaced via ``status="failed"`` + ``error``
Expand Down Expand Up @@ -287,6 +288,7 @@ async def send_email(
subject=subject,
in_reply_to=in_reply_to,
result=result,
caller_actor_kind=caller_actor_kind,
)

return result
Expand Down Expand Up @@ -524,12 +526,19 @@ def _append_outbound_log(
subject: str,
in_reply_to: Optional[str],
result: SendResult,
caller_actor_kind: Optional[str] = None,
) -> None:
"""Append one line to the outbound JSONL. Body NEVER logged.

Fail-soft: a log write error must not crash a successful
send (or mask a failed-send result). Logged at WARN if it
ever happens.

``caller_actor_kind`` (KR-MCP-SEND-TOOLS): when a send is
driven by an MCP tool call, the caller's actor_kind appears
here for audit attribution. ``None`` for internal/runtime-
driven sends (e.g. KR-FEAT-AI-RESPONSE-LOOP email replies).
Backwards-compatible — consumers handle absence.
"""
entry = {
"sent_at": result.sent_at.strftime("%Y-%m-%dT%H:%M:%SZ"),
Expand All @@ -542,6 +551,7 @@ def _append_outbound_log(
"smtp_code": result.smtp_code,
"error": result.error,
"retry_count": result.retry_count,
"caller_actor_kind": caller_actor_kind,
}
try:
log_path = _outbound_log_path()
Expand Down Expand Up @@ -571,6 +581,7 @@ async def send_email_internal(
body_html: Optional[str] = None,
in_reply_to: Optional[str] = None,
attachments: Optional[list[Attachment]] = None,
caller_actor_kind: Optional[str] = None,
) -> SendResult:
"""One-shot send for callers inside Kora's runtime.

Expand All @@ -580,8 +591,12 @@ async def send_email_internal(
instantiate the client once and call ``send_email`` per
message to avoid repeated env reads.

NOT exposed via ``/mcp`` — that's a separate bucket
(``kora__send_email`` MCP tool, follow-on).
KR-MCP-SEND-TOOLS update: ``caller_actor_kind`` propagates to
the JSONL audit log when the send is driven by an MCP tool
call. Daemon-coordinator-managed paths prefer the listener
accessor (``current_purelymail_client``) over this one-shot
helper to share a single client instance + reduce env-read
overhead.
"""
client = PurelymailClient()
return await client.send_email(
Expand All @@ -592,4 +607,5 @@ async def send_email_internal(
body_html=body_html,
in_reply_to=in_reply_to,
attachments=attachments,
caller_actor_kind=caller_actor_kind,
)
69 changes: 58 additions & 11 deletions kora_cli/handlers/slack_dm_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -664,27 +664,60 @@ def _record_inference_to_cost_ladder(
)

def _get_or_create_slack_client(self) -> Optional[Any]:
"""Lazy SlackClient construction.

Returns the cached client if one is set (test injection or
prior successful construction). Otherwise tries to construct
one; on ``SlackClientNotConfigured`` returns ``None`` so the
caller can record a failed-outbound entry without crashing.
"""Get the SlackClient — daemon-coordinator-managed first;
lazy-create as fallback.

Resolution order:

1. ``self._slack_client`` (test injection or prior cache)
2. ``current_slack_client()`` from the daemon listener
(KR-MCP-SEND-TOOLS) — present once the daemon boots
with ``slack_client`` listener registered
3. Lazy-construct a fresh SlackClient (legacy path —
keeps standalone-handler tests passing without daemon
listeners running)

The lazy-construct fallback preserves the pre-KR-MCP-SEND-
TOOLS contract: a handler instantiated outside the daemon
(test fixtures, ad-hoc scripts) still acquires a client.
Production daemon paths get the shared listener instance.

Returns ``None`` if every path fails (KORA_SLACK_BOT_TOKEN
unset).
"""
if self._slack_client is not None:
return self._slack_client

# Listener-managed singleton (KR-MCP-SEND-TOOLS).
try:
from kora_cli.clients.slack_client import (
SlackClient,
SlackClientNotConfigured,
from kora_cli.listeners.slack_client_listener import (
current_slack_client,
)

shared = current_slack_client()
if shared is not None:
self._slack_client = shared
return self._slack_client
except Exception as exc:
# Import error / accessor blow-up — fall through to
# lazy-construct. Don't swallow silently; log so the
# operator can correlate.
logger.debug(
"[kora.slack_dm] current_slack_client lookup failed: %r — "
"falling back to lazy construct",
exc,
)

# Lazy-construct fallback (legacy / standalone-handler path).
try:
from kora_cli.clients.slack_client import SlackClient

self._slack_client = SlackClient()
return self._slack_client
except Exception as exc:
# SlackClientNotConfigured is the expected failure when
# KORA_SLACK_BOT_TOKEN is unset. Log once + cache None so
# subsequent inbound events don't re-attempt.
# KORA_SLACK_BOT_TOKEN is unset. Log once + cache None
# so subsequent inbound events don't re-attempt.
logger.warning(
"[kora.slack_dm] SlackClient unavailable: %r — "
"outbound replies disabled",
Expand All @@ -710,6 +743,10 @@ def _append_outbound_log_entry(
output_tokens: Optional[int] = None,
reasoning_duration_ms: Optional[int] = None,
reasoning_error: Optional[str] = None,
# KR-MCP-SEND-TOOLS — when a send is driven by an MCP tool
# call, the caller's actor_kind appears here for audit
# attribution. None (omitted) on handler-driven sends.
caller_actor_kind: Optional[str] = None,
) -> None:
"""Outbound-side JSONL entry. Distinct schema from inbound
entries (``sent_at`` instead of ``received_at``) so operator
Expand All @@ -726,6 +763,14 @@ def _append_outbound_log_entry(
``ResponseResult.error`` (``cost_ladder_halted`` /
``sdk_5xx`` / ``engine_unavailable`` / etc.) — None on
successful reasoning calls

``caller_actor_kind`` (KR-MCP-SEND-TOOLS): when a send is
driven by an MCP tool call, the caller's actor_kind appears
here for audit attribution. ``None`` (omitted) for
handler-driven sends (echo replies + reasoning-engine
replies from CC#3's KR-FEAT-AI-RESPONSE-LOOP). Backwards-
compatible — consumers handle absence; existing entries
without the field keep parsing.
"""
entry: Dict[str, Any] = {
"sent_at": _now_iso(),
Expand All @@ -751,6 +796,8 @@ def _append_outbound_log_entry(
entry["reasoning_duration_ms"] = int(reasoning_duration_ms)
if reasoning_error is not None:
entry["reasoning_error"] = reasoning_error
if caller_actor_kind is not None:
entry["caller_actor_kind"] = caller_actor_kind

try:
self._log_path.parent.mkdir(parents=True, exist_ok=True)
Expand Down
8 changes: 8 additions & 0 deletions kora_cli/listeners/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,11 @@
# 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
# KR-MCP-SEND-TOOLS — promote SlackClient + PurelymailClient from
# per-handler lazy construction to daemon-coordinator-managed
# singletons. Both fail-soft on missing auth env (Slack outbound /
# email outbound are capabilities, not gates — daemon boots
# without them). Imported AFTER mcp_consumption so the same lazy-
# constructed-fallback pattern is established.
from kora_cli.listeners import slack_client_listener # noqa: F401
from kora_cli.listeners import purelymail_client_listener # noqa: F401
Loading