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
13 changes: 13 additions & 0 deletions kora_cli/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,19 @@ def _install_plugin_debug_handler(force: bool = False) -> None:
# override wins. Enables route-specific tool manifests
# without mutating ``agent.tools`` (which is process-wide).
"pre_tool_list_finalized",
# KR-REASONING-ROUTE-THROUGH-GATEWAY-ST2B — fires inside
# ``model_tools.handle_function_call`` AFTER the
# ``pre_tool_call`` block-check and BEFORE Hermes's default
# ``registry.dispatch``. Plugins return ``{"result":
# "<tool_result_str>"}`` to short-circuit Hermes dispatch
# with a plugin-provided result. First non-None ``result``
# wins. Returning ``None`` (or non-dict / missing
# ``result`` key) falls through to other plugins, then
# Hermes default. Fail-safe: plugin exception → log + fall
# through. Enables fork-specific tool registries (e.g.
# Kora's reasoning tools) to dispatch via plugin code
# without registering them as Hermes-native tools.
"pre_tool_call_can_provide_result",
# Transform LLM output before it's returned to the user.
# Plugins return a string to replace the response text, or None/empty to leave unchanged.
# First non-None string wins. Useful for vocabulary/personality transformation.
Expand Down
35 changes: 27 additions & 8 deletions kora_cli/reasoning/anthropic_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -1161,14 +1161,33 @@ async def _respond_via_gateway(
max_tokens=self._max_output_tokens,
quiet_mode=True, # daemon path; no print() to stdout
)
# Override agent.tools = [] — Kora's reasoning tools are
# NOT bridged into Hermes's toolset model in this ST.
# The bridge is the explicit ST2B follow-on bucket. With
# the toggle OFF in production, the bypass path retains
# full tool capability; toggling ON loses tool-use until
# ST2B lands.
agent.tools = []
agent.valid_tool_names = set()
# KR-REASONING-ROUTE-THROUGH-GATEWAY-ST2B — populate
# agent.tools from Kora's reasoning registry (replaces
# ST2's ``agent.tools = []`` toolless-only posture). The
# kora_hermes plugin's ``pre_tool_call_can_provide_result``
# hook intercepts Hermes's dispatch for these tool names
# and routes them to Kora's existing reasoning dispatch
# (``execute_reasoning_tool``). Empty list — registry
# unavailable / failed import — falls back to toolless
# route-through same as ST2.
try:
from plugins.kora_hermes import get_kora_tools_for_agent

kora_tools = get_kora_tools_for_agent()
agent.tools = kora_tools
agent.valid_tool_names = {
t["function"]["name"]
for t in kora_tools
if isinstance(t, dict) and "function" in t
}
except Exception as exc:
logger.warning(
"[kora.reasoning.gateway] tool-bridge tool population "
"failed: %r — falling back to toolless route-through",
exc,
)
agent.tools = []
agent.valid_tool_names = set()

# Route field threading — kora_hermes plugin's hooks gate
# on this. Setting it to "" (when source isn't mapped)
Expand Down
38 changes: 38 additions & 0 deletions kora_docs/14_research/hermes_local_extensions_2026-05-23.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,34 @@ ctx.register_background_daemon(

**Thread safety**: registry methods are RLock-wrapped. Plugin discovery happens at import time on the main thread; consumers may iterate from any thread.

### Extension 5: `pre_tool_call_can_provide_result` hook (added in KR-REASONING-ROUTE-THROUGH-GATEWAY-ST2B)

**File**: `model_tools.py` (insertion in `handle_function_call` after the existing `pre_tool_call` block-check and before `registry.dispatch`).
**VALID_HOOKS entry**: added between `pre_tool_list_finalized` and the `transform_llm_output` block in `kora_cli/plugins.py:155-167`.

**Signature**:
```python
invoke_hook(
"pre_tool_call_can_provide_result",
tool_name=...,
args=...,
task_id=...,
session_id=...,
tool_call_id=...,
)
```

**Return contract**:
- `None` / non-dict / missing `"result"` key → no-op, fall through to other plugins, then Hermes default `registry.dispatch`
- `{"result": "<tool_result_str>"}` → short-circuits Hermes dispatch; the plugin-provided string becomes the tool result
- First non-None `result` wins (matches existing override-shape semantics for `pre_api_request_mutable` and `pre_tool_list_finalized`)

**Failure handling**: any hook exception is caught + DEBUG-logged + fall-through to Hermes default. Fail-safe.

**Backward compat**: no existing plugin registers it; non-Kora-route plugins gate themselves on tool-name or route checks (the kora_hermes plugin returns None when the tool isn't a Kora reasoning tool — confirms safety for Hermes-fork users loading the plugin).

**Backward-compat for the dispatch site**: Hermes's `pre_tool_call` block-check + `post_tool_call` audit hook + `transform_tool_result` hook ALL still fire on the same code path; the new hook slots between the block-check and Hermes's default dispatch without altering observer ordering.

### Extension DEFERRED: post-LLM re-issue hook

**Why deferred**: re-issuing `messages.create` inside `conversation_loop` requires re-running portions of the loop (streaming consumption, tool-result processing, conversation-history append). Each of those has substantial state machinery. Doing this safely needs a coordinated control-flow refactor — beyond the scope of this bucket per §4.
Expand Down Expand Up @@ -227,6 +255,16 @@ Each extension is designed to package cleanly into a future Hermes-upstream PR.
- **Upstream framing**: "Add `route: str` context field to all conversation-loop hooks. Threading purely from `getattr(agent, 'route', '')` so legacy callers (no route set) see no change. Use case: per-route observability (cost telemetry, latency tracking, debug logging that groups by use-case)."
- **Upstream prep work needed**: extract Hermes-friendly `KNOWN_ROUTES` taxonomy + RFC the vocabulary before the PR.

### Extension 5: `pre_tool_call_can_provide_result` (KR-REASONING-ROUTE-THROUGH-GATEWAY-ST2B) — **PR-ready after route-through battle-tests it**

- [x] New hook added cleanly between existing block-check and Hermes default dispatch
- [x] First-non-None-result wins; non-dict / missing-key returns no-op
- [x] Fail-safe: hook exception → caught + logged → Hermes default dispatch
- [x] Backward compat: existing pre_tool_call (block-check) + post_tool_call + transform_tool_result fire unchanged on the same code path
- [x] Tests in `tests/plugins/test_kora_hermes_plugin_st2b.py` (Hermes-side wiring + Kora plugin consumer + bridge handler + Kora-tool-via-bridge sample trace + non-Kora-tool-fall-through-to-Hermes sample trace)
- [ ] **Battle-test gap**: ST3 (default-flip) + a 24-48h burn-in is the natural integration test. After ST3 lands, this hook is upstreamable.
- **Upstream framing**: "Allow plugins to short-circuit Hermes's tool dispatch with a plugin-computed result. Use case: a fork that maintains its own tool registry (parallel to Hermes's) wants those tools dispatched via fork code without registering them as Hermes tools. Fork-specific tool dispatch keeps the fork's code organization clean + enables behaviors (async tool dispatch, custom error envelopes, plugin-mediated security checks) that don't fit the Hermes tool registry's signature."

### Extension 4: `register_background_daemon` + registry — **PR-ready in isolation; consumer wiring is a separate PR**

- [x] New module is import-side-effect-free
Expand Down
61 changes: 48 additions & 13 deletions model_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -829,21 +829,56 @@ def handle_function_call(
# to wrap every tool manually. We use monotonic() so the value is
# unaffected by wall-clock adjustments during the call.
_dispatch_start = time.monotonic()
if function_name == "execute_code":
# Prefer the caller-provided list so subagents can't overwrite
# the parent's tool set via the process-global.
sandbox_enabled = enabled_tools if enabled_tools is not None else _last_resolved_tool_names
result = registry.dispatch(
function_name, function_args,
task_id=task_id,
enabled_tools=sandbox_enabled,

# KR-REASONING-ROUTE-THROUGH-GATEWAY-ST2B — give plugins a
# chance to provide the result themselves (e.g. Kora's
# reasoning tools are dispatched via the kora_hermes
# plugin, NOT via Hermes's tool registry). First non-None
# ``{"result": <str>}`` return wins; on no plugin result,
# fall through to Hermes's default ``registry.dispatch``.
# Fail-safe: any hook exception → caught + logged → fall
# through. Non-Kora-route plugins return None on their own
# gate; this is purely additive on the Hermes flow.
result: Optional[str] = None
try:
from kora_cli.plugins import invoke_hook as _invoke_hook
_provide_results = _invoke_hook(
"pre_tool_call_can_provide_result",
tool_name=function_name,
args=function_args,
task_id=task_id or "",
session_id=session_id or "",
tool_call_id=tool_call_id or "",
)
else:
result = registry.dispatch(
function_name, function_args,
task_id=task_id,
user_task=user_task,
for _provide in _provide_results:
if isinstance(_provide, dict):
_r = _provide.get("result")
if isinstance(_r, str):
result = _r
break
except Exception as _hook_err:
logger.debug(
"pre_tool_call_can_provide_result hook error: %s",
_hook_err,
)
result = None

if result is None:
if function_name == "execute_code":
# Prefer the caller-provided list so subagents can't overwrite
# the parent's tool set via the process-global.
sandbox_enabled = enabled_tools if enabled_tools is not None else _last_resolved_tool_names
result = registry.dispatch(
function_name, function_args,
task_id=task_id,
enabled_tools=sandbox_enabled,
)
else:
result = registry.dispatch(
function_name, function_args,
task_id=task_id,
user_task=user_task,
)
duration_ms = int((time.monotonic() - _dispatch_start) * 1000)

try:
Expand Down
168 changes: 165 additions & 3 deletions plugins/kora_hermes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,22 +285,184 @@ def _post_llm_call(
)


# ---------------------------------------------------------------------------
# KR-REASONING-ROUTE-THROUGH-GATEWAY-ST2B — tool bridge
# ---------------------------------------------------------------------------


def _is_kora_reasoning_tool(tool_name: str) -> bool:
"""True iff ``tool_name`` is one of Kora's reasoning-allowlist
tools (``kora__*``). Import is lazy so plugin discovery
doesn't fault when the registry isn't importable in CI."""
try:
from kora_cli.reasoning.tool_registry import REASONING_TOOL_ALLOWLIST
except Exception:
return False
return tool_name in REASONING_TOOL_ALLOWLIST


def _tool_bridge_provide_result(
*,
tool_name: str = "",
args: Optional[dict] = None,
**kw,
) -> Optional[dict]:
"""Bridge handler for ``pre_tool_call_can_provide_result``.

Intercepts dispatch for Kora's reasoning tools and returns
the result the kora_cli reasoning code would have produced
in the bypass loop. Hermes's default ``registry.dispatch``
would otherwise fail (Kora's tools aren't registered as
Hermes tools).

Returns:
* ``{"result": <json_str>}`` when the tool IS in Kora's
allowlist + dispatch succeeded → short-circuits Hermes
* ``{"result": <json_str_with_error>}`` when the tool IS
in Kora's allowlist BUT dispatch raised → short-circuits
Hermes with an is_error result so the reasoning loop
sees the error rather than getting Hermes's
"tool not found" envelope.
* ``None`` when the tool isn't a Kora tool OR the call
isn't a Kora-route call → falls through to other plugins
or Hermes default. Non-Kora-route safety: Hermes-fork
users with this plugin loaded see no behavior change on
their own (non-Kora) sessions.

Implementation note: ``handle_function_call`` is sync;
``execute_reasoning_tool`` is async. We bridge via
``asyncio.run`` when no loop is running, OR via
``asyncio.new_event_loop`` + ``run_until_complete`` when
nested under an existing loop (the daemon path runs
``handle_function_call`` inside an ``asyncio.to_thread``
call from ``_respond_via_gateway`` — the thread has no
running loop, so ``asyncio.run`` is the right primitive).
"""
import asyncio
import json

route = kw.get("route", "") or ""
# ``handle_function_call`` doesn't currently forward
# ``route`` to the hook (the kwargs it passes are tool_name,
# args, task_id, session_id, tool_call_id). For ST2B v1 we
# gate on the tool name's belonging to Kora's allowlist
# alone — this is the cleaner check anyway since the tool
# name uniquely identifies whether Kora can serve it.
# Future ST2C: thread ``route`` through the hook kwargs so
# we can also restrict to Kora routes (defense-in-depth).

if not _is_kora_reasoning_tool(tool_name):
return None

# Dispatch via Kora's reasoning tool registry.
try:
from kora_cli.reasoning.tool_registry import execute_reasoning_tool

result_model = asyncio.run(
execute_reasoning_tool(tool_name, args or {})
)

# Project the Pydantic model into a JSON string. Models
# have ``model_dump_json`` per the existing registry's
# contract; fall back to ``str()`` if not Pydantic.
if hasattr(result_model, "model_dump_json"):
result_str = result_model.model_dump_json()
else:
result_str = json.dumps(result_model, default=str)

logger.debug(
"[kora_hermes.tool_bridge] dispatched %s via Kora registry "
"(result %d chars)",
tool_name,
len(result_str),
)
return {"result": result_str}
except Exception as exc:
# Convert dispatch failure to an is_error tool_result so
# the reasoning loop can see the error rather than crash.
# Match Kora's existing bypass-loop error envelope shape
# (the JSON-serializable dict with ``error`` key, matching
# what Hermes's _sanitize_tool_error produces on its own
# dispatch errors).
error_msg = (
f"kora_tool_dispatch_error: {type(exc).__name__}: {exc!s}"
)
logger.exception(
"[kora_hermes.tool_bridge] dispatch raised for %s",
tool_name,
)
return {"result": json.dumps({"error": error_msg})}


def get_kora_tools_for_agent() -> list:
"""Return Kora's reasoning tools in Hermes/OpenAI tool shape
for ``agent.tools`` population. The kora_cli registry stores
Anthropic-shaped descriptors (``{"name", "description",
"input_schema"}``); Hermes's ``agent.tools`` reads
``tool["function"]["name"]`` (OpenAI shape) at multiple
sites. We convert here so consumers (e.g.
``_respond_via_gateway``) get the right shape.

Returns ``[]`` on any error (registry unavailable, schema
drift) — engine falls back to toolless route-through.
"""
try:
from kora_cli.reasoning.tool_registry import (
get_reasoning_available_tools,
)

anthropic_tools = get_reasoning_available_tools() or []
except Exception as exc:
logger.warning(
"[kora_hermes.tool_bridge] tool registry unavailable: %r "
"— agent.tools stays empty",
exc,
)
return []

hermes_tools: list = []
for tool in anthropic_tools:
try:
hermes_tools.append(
{
"type": "function",
"function": {
"name": tool["name"],
"description": tool.get("description", ""),
"parameters": tool.get("input_schema", {}),
},
}
)
except Exception as exc:
logger.warning(
"[kora_hermes.tool_bridge] tool %r conversion raised "
"%r — skipping",
tool.get("name", "<unknown>"),
exc,
)
return hermes_tools


# ---------------------------------------------------------------------------
# Plugin entry point — called once at plugin discovery
# ---------------------------------------------------------------------------


def register(ctx) -> None:
"""Plugin entry. Called by ``PluginManager.discover_and_load``
once at process startup. Registers Kora behaviors against the 7
Hermes hooks Kora's reasoning path needs."""
once at process startup. Registers Kora behaviors against the
7 Hermes hooks Kora's reasoning path needs (post-ST2B)."""
ctx.register_hook("on_session_start", _on_session_start)
ctx.register_hook("pre_api_request_mutable", _pre_api_request_mutable)
ctx.register_hook("pre_tool_list_finalized", _pre_tool_list_finalized)
ctx.register_hook("pre_tool_call", _pre_tool_call)
ctx.register_hook("post_tool_call", _post_tool_call)
ctx.register_hook("post_llm_call", _post_llm_call)
# KR-REASONING-ROUTE-THROUGH-GATEWAY-ST2B — tool-bridge hook.
ctx.register_hook(
"pre_tool_call_can_provide_result", _tool_bridge_provide_result
)
logger.info(
"[kora_hermes] plugin registered: 6 hooks against KORA_ROUTES=%s",
"[kora_hermes] plugin registered: 7 hooks against KORA_ROUTES=%s",
sorted(KORA_ROUTES),
)
11 changes: 6 additions & 5 deletions tests/plugins/test_kora_hermes_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,11 @@ def test_plugin_is_discovered_but_opt_in():
assert "not enabled in config" in (loaded.error or "")


def test_register_function_wires_six_hooks():
"""The plugin's register(ctx) function registers exactly 6
hooks. Test directly with a mock context — bypasses Hermes's
opt-in plugins.enabled gate (which is operator-policy
territory, not the plugin's responsibility)."""
def test_register_function_wires_seven_hooks():
"""The plugin's register(ctx) function registers exactly 7
hooks (ST2B added pre_tool_call_can_provide_result to ST1's
6). Test directly with a mock context — bypasses Hermes's
opt-in plugins.enabled gate (operator-policy territory)."""
from plugins.kora_hermes import register

registered = []
Expand All @@ -96,6 +96,7 @@ def register_hook(self, name, callback):
"pre_tool_call",
"post_tool_call",
"post_llm_call",
"pre_tool_call_can_provide_result", # ST2B added
])
# Each registered callback is callable.
for name, callback in registered:
Expand Down
Loading