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
113 changes: 113 additions & 0 deletions agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -2996,6 +2996,119 @@ def _stop_spinner():
agent._persist_session(messages, conversation_history)
break

# KR-HERMES-LOCAL-EXT-REISSUE — post-LLM can-reissue hook.
# Fires AFTER messages.create returns a valid response and
# BEFORE downstream normalization / observer hooks / tool
# dispatch. A plugin can return ``{"reissue_with": <new
# api_kwargs>}`` to transparently re-call the API with
# modified kwargs; the re-issued response REPLACES the
# original for all downstream processing (normalize,
# post_api_request, post_llm_call, tool dispatch).
#
# Override semantics: matches #172/#181 — iterate plugin
# returns, FIRST non-None ``reissue_with`` wins. Subsequent
# plugin returns are ignored (single re-issue per
# iteration). Plugins returning None or a dict without
# ``reissue_with`` fall through.
#
# Anti-loop safety: the hook is NOT re-fired against the
# re-issued response. If the re-issued response would
# itself satisfy another plugin's escalation condition, the
# loop ignores it. This is intentional — without it, two
# plugins could ping-pong escalations indefinitely.
#
# Fail-safe: any exception in the hook firing OR the re-
# issue API call falls through to the original response.
# ``invoke_hook`` already wraps each callback in try/except;
# this outer guard protects against errors in the re-issue
# call itself (transport raise, validation failure, etc).
#
# Telemetry: when a re-issue fires, we feed cost-ladder
# ``record_inference`` for the re-issued response with
# ``escalated_to_opus=True``. The original Haiku call was
# already telemetered inside the retry-loop chokepoint with
# the default ``escalated_to_opus=False`` — both events
# land in the cost-ladder estimator so $-burn accounting
# stays accurate.
try:
from kora_cli.plugins import invoke_hook as _invoke_hook_reissue
_reissue_results = _invoke_hook_reissue(
"post_llm_call_can_reissue",
response=response,
api_kwargs=api_kwargs,
agent=agent,
iteration=api_call_count,
task_id=effective_task_id,
session_id=agent.session_id or "",
route=getattr(agent, "route", "") or "",
)
for _reissue_result in _reissue_results:
if not isinstance(_reissue_result, dict):
continue
_new_kwargs = _reissue_result.get("reissue_with")
if not isinstance(_new_kwargs, dict):
continue
# First non-None reissue_with wins.
_original_response = response
_original_model = api_kwargs.get("model") if isinstance(api_kwargs, dict) else None
_new_model = _new_kwargs.get("model")
logger.info(
"[kora_hermes] post_llm_call_can_reissue re-issuing "
"API call (iteration=%s, original_model=%s, new_model=%s)",
api_call_count,
_original_model,
_new_model,
)
try:
api_kwargs = _new_kwargs
if _use_streaming:
response = agent._interruptible_streaming_api_call(
api_kwargs, on_first_delta=_stop_spinner
)
else:
response = agent._interruptible_api_call(api_kwargs)
api_duration = time.time() - api_start_time
# Telemetry for the re-issued call. The first
# call's record_inference already fired inside
# the retry-loop chokepoint with the default
# escalated_to_opus=False — this adds the Opus
# call as a second $-burn event with the flag
# set so cockpit panels can compute escalation
# rate per route.
try:
from agent.cost_ladder_wire import (
record_inference_from_response,
)
record_inference_from_response(
response,
model=_new_model or getattr(agent, "model", None),
provider=agent.provider,
base_url=agent.base_url,
api_mode=agent.api_mode,
route=getattr(agent, "route", "") or "unknown",
escalated_to_opus=True,
)
except Exception as _cl_exc:
logger.debug(
"[kora_hermes] re-issue cost-ladder feed "
"failed: %r",
_cl_exc,
)
except Exception as _reissue_call_exc:
logger.warning(
"post_llm_call_can_reissue re-issue API call "
"failed: %s — falling back to original response",
_reissue_call_exc,
)
response = _original_response
break # anti-loop: at most one re-issue per iteration
except Exception as _reissue_exc:
logger.warning(
"post_llm_call_can_reissue hook failed: %s — "
"continuing with original response",
_reissue_exc,
)

try:
_transport = agent._get_transport()
_normalize_kwargs = {}
Expand Down
40 changes: 40 additions & 0 deletions kora_cli/reasoning/kora_hermes_plugin/haiku_router/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Haiku-router sub-plugin — post-call Opus escalation.

See ``escalator.py`` for the pure helpers (text extraction +
re-issue kwargs construction), ``constants.py`` for the model
IDs + env-var names, ``plugin.py`` for the
``post_llm_call_can_reissue`` hook handler + sub-register.

Consumes ``should_escalate_post_call`` from the cost_ladder
sub-plugin's selector — present since #185 but had no caller
until KR-HERMES-LOCAL-EXT-REISSUE added the hook surface that
this plugin registers against.
"""

from kora_cli.reasoning.kora_hermes_plugin.haiku_router.constants import (
ENV_DISABLE_POST_CALL_ESCALATION,
MODEL_HAIKU,
MODEL_OPUS,
REISSUE_REVIEW_PROMPT,
)
from kora_cli.reasoning.kora_hermes_plugin.haiku_router.escalator import (
build_opus_reissue_kwargs,
extract_first_text,
extract_last_user_text,
)
from kora_cli.reasoning.kora_hermes_plugin.haiku_router.plugin import (
haiku_router_post_call_escalation,
register,
)

__all__ = [
"ENV_DISABLE_POST_CALL_ESCALATION",
"MODEL_HAIKU",
"MODEL_OPUS",
"REISSUE_REVIEW_PROMPT",
"build_opus_reissue_kwargs",
"extract_first_text",
"extract_last_user_text",
"haiku_router_post_call_escalation",
"register",
]
38 changes: 38 additions & 0 deletions kora_cli/reasoning/kora_hermes_plugin/haiku_router/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Constants for the haiku_router sub-plugin.

Defaults + env-var names. The model identifiers are re-exported
from the cost_ladder sub-plugin so a single source-of-truth for
the long-form Anthropic IDs (cache-key stability + version-pin
discipline) stays at ``cost_ladder.constants``.
"""

from __future__ import annotations

from kora_cli.reasoning.kora_hermes_plugin.cost_ladder.constants import (
DEFAULT_HAIKU_MODEL as MODEL_HAIKU,
)
from kora_cli.reasoning.kora_hermes_plugin.cost_ladder.constants import (
DEFAULT_OPUS_MODEL as MODEL_OPUS,
)

# Re-issue prompt — short, terse instruction asked of Opus when it
# inherits a Haiku response as assistant context. Parallel-Claude's
# pattern (R3 origin): Opus often just confirms with a one-liner
# instead of redoing the work — ~30% cheaper escalations than a
# cold Opus call.
REISSUE_REVIEW_PROMPT = (
"Please review my last response and improve it if needed. "
"Be terse if confirming."
)

# Disable env — operator escape hatch if post-call escalation
# starts misbehaving. When set to "true" the plugin no-ops and
# the loop continues with the Haiku response unchanged.
ENV_DISABLE_POST_CALL_ESCALATION = "KORA_DISABLE_POST_CALL_ESCALATION"

__all__ = [
"ENV_DISABLE_POST_CALL_ESCALATION",
"MODEL_HAIKU",
"MODEL_OPUS",
"REISSUE_REVIEW_PROMPT",
]
153 changes: 153 additions & 0 deletions kora_cli/reasoning/kora_hermes_plugin/haiku_router/escalator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"""Pure helpers for the post-call Opus escalation re-issue.

The hook handler in ``plugin.py`` is responsible for orchestrating
the re-issue; this module holds the pure functions it composes
with so they're independently testable.

Two responsibilities:

1. :func:`extract_first_text` — pull the first text block out of
an Anthropic ``Messages`` response. Used to feed
:func:`should_escalate_post_call` from the cost_ladder
selector + to build the Haiku-context assistant turn.
2. :func:`build_opus_reissue_kwargs` — mutate a copy of the
original ``api_kwargs`` into the Opus re-issue form: same
conversation prefix + Haiku response as an assistant turn +
a one-liner reviewer prompt + ``model`` swapped to Opus.

Both are pure functions (no I/O, no state). The hook handler is
where activation gating + telemetry side-effects live.
"""

from __future__ import annotations

from typing import Any, Dict, List, Optional

from kora_cli.reasoning.kora_hermes_plugin.haiku_router.constants import (
MODEL_OPUS,
REISSUE_REVIEW_PROMPT,
)


def extract_first_text(response: Any) -> str:
"""Return the first text block from an Anthropic ``Messages``
response. Empty string on any extraction failure — caller
treats that as "no Haiku text to escalate from" and bails.

The Anthropic SDK shape is ``response.content`` →
``list[ContentBlock]`` where each block has ``type`` and
(for text blocks) ``text``. Models can return content lists
interleaving text + tool_use; we only want the text. We
concatenate ALL text blocks because some models emit the
user-visible answer across multiple text blocks (e.g. when
extended thinking is enabled the answer can split).
"""
if response is None:
return ""

content = getattr(response, "content", None)
if not isinstance(content, list):
return ""

parts: List[str] = []
for block in content:
# SDK content block (pydantic model).
block_type = getattr(block, "type", None)
if block_type == "text":
text = getattr(block, "text", "")
if isinstance(text, str) and text:
parts.append(text)
continue
# Dict fallback (Mock-friendly + non-SDK callers).
if isinstance(block, dict) and block.get("type") == "text":
text = block.get("text", "")
if isinstance(text, str) and text:
parts.append(text)

return "\n".join(parts).strip()


def extract_last_user_text(api_kwargs: Dict[str, Any]) -> str:
"""Return the most recent user-turn text from
``api_kwargs["messages"]``. Used as ``original_message_text``
when calling :func:`should_escalate_post_call`.

The Anthropic ``messages`` shape is a list of
``{"role": "user"|"assistant", "content": str | list[block]}``.
We walk backwards looking for the first user turn + flatten
its content to text. Returns "" on any extraction failure.
"""
if not isinstance(api_kwargs, dict):
return ""

messages = api_kwargs.get("messages")
if not isinstance(messages, list):
return ""

for msg in reversed(messages):
if not isinstance(msg, dict):
continue
if msg.get("role") != "user":
continue
content = msg.get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
# Content-block list — flatten text parts.
parts: List[str] = []
for block in content:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict) and block.get("type") == "text":
text = block.get("text", "")
if isinstance(text, str):
parts.append(text)
return "\n".join(parts).strip()
return ""

return ""


def build_opus_reissue_kwargs(
*,
api_kwargs: Dict[str, Any],
haiku_response_text: str,
opus_model: Optional[str] = None,
) -> Dict[str, Any]:
"""Build the new api_kwargs for the Opus re-issue.

Strategy (parallel-Claude's pattern, R3 origin): instead of
a cold Opus call that redoes Haiku's work, include Haiku's
response as an assistant turn and ask Opus to confirm-or-
improve. Opus often returns a one-liner confirmation — ~30%
cheaper escalations.

Returns a NEW dict; the input ``api_kwargs`` is not mutated.
The returned kwargs share top-level non-message refs with
the input (system prompt, tools, max_tokens, etc.) — only
``model`` + ``messages`` are replaced.
"""
new_kwargs = dict(api_kwargs)
new_kwargs["model"] = opus_model or MODEL_OPUS

original_messages = api_kwargs.get("messages")
if not isinstance(original_messages, list):
original_messages = []

new_messages = list(original_messages)
new_messages.append(
{"role": "assistant", "content": haiku_response_text}
)
new_messages.append(
{"role": "user", "content": REISSUE_REVIEW_PROMPT}
)
new_kwargs["messages"] = new_messages

return new_kwargs


__all__ = [
"build_opus_reissue_kwargs",
"extract_first_text",
"extract_last_user_text",
]
Loading