Skip to content
Open
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: 20 additions & 0 deletions agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,18 @@ def run_conversation(
model=agent.model,
platform=getattr(agent, "platform", None) or "",
sender_id=getattr(agent, "_user_id", None) or "",
sender_id_alt=getattr(agent, "_user_id_alt", None) or "",
sender_name=getattr(agent, "_user_name", None) or "",
chat_id=getattr(agent, "_chat_id", None) or "",
chat_name=getattr(agent, "_chat_name", None) or "",
chat_type=getattr(agent, "_chat_type", None) or "",
# Back-compat alias for channel-aware hooks. Many platforms call
# this a chat/room/guild channel; triggers should not need to parse
# gateway session keys or user text to infer it.
channel_id=getattr(agent, "_chat_id", None) or "",
channel_name=getattr(agent, "_chat_name", None) or "",
thread_id=getattr(agent, "_thread_id", None) or "",
gateway_session_key=getattr(agent, "_gateway_session_key", None) or "",
)
_ctx_parts: list[str] = []
for r in _pre_results:
Expand Down Expand Up @@ -4411,6 +4423,14 @@ def _stop_spinner():
session_id=agent.session_id or "",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current main moved this live transform_llm_output dispatch to agent/turn_finalizer.py:380-391 in 55b83c3d9; port these kwargs there during salvage, otherwise this hunk does not affect the runtime final-response path.

model=agent.model,
platform=getattr(agent, "platform", None) or "",
user_message=persist_user_message or user_message or "",
channel_id=getattr(agent, "_chat_id", None) or "",
channel_name=getattr(agent, "_chat_name", None) or "",
chat_id=getattr(agent, "_chat_id", None) or "",
chat_name=getattr(agent, "_chat_name", None) or "",
chat_type=getattr(agent, "_chat_type", None) or "",
thread_id=getattr(agent, "_thread_id", None) or "",
gateway_session_key=getattr(agent, "_gateway_session_key", None) or "",
)
for _hook_result in _transform_results:
if isinstance(_hook_result, str) and _hook_result:
Expand Down
22 changes: 18 additions & 4 deletions agent/shell_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@
# Inject context for pre_llm_call:
{"context": "Today is Friday"}

# Replace a final response from transform_llm_output:
{"response_text": "Updated assistant text"}

# Silent no-op:
<empty or any non-matching JSON object>
"""
Expand Down Expand Up @@ -419,10 +422,10 @@ def _spawn(spec: ShellHookSpec, stdin_json: str) -> Dict[str, Any]:
return result


def _make_callback(spec: ShellHookSpec) -> Callable[..., Optional[Dict[str, Any]]]:
def _make_callback(spec: ShellHookSpec) -> Callable[..., Optional[Any]]:
"""Build the closure that ``invoke_hook()`` will call per firing."""

def _callback(**kwargs: Any) -> Optional[Dict[str, Any]]:
def _callback(**kwargs: Any) -> Optional[Any]:
# Matcher gate — only meaningful for tool-scoped events.
if spec.event in {"pre_tool_call", "post_tool_call"}:
if not spec.matches_tool(kwargs.get("tool_name")):
Expand Down Expand Up @@ -493,8 +496,8 @@ def _block_message(primary: Any, secondary: Any) -> str:
return raw if isinstance(raw, str) and raw else _DEFAULT_BLOCK_MESSAGE


def _parse_response(event: str, stdout: str) -> Optional[Dict[str, Any]]:
"""Translate stdout JSON into a Hermes wire-shape dict.
def _parse_response(event: str, stdout: str) -> Optional[Any]:
"""Translate stdout JSON into a Hermes wire-shape value.

For ``pre_tool_call`` the Claude-Code-style ``{"decision": "block",
"reason": "..."}`` payload is translated into the canonical Hermes
Expand All @@ -507,6 +510,10 @@ def _parse_response(event: str, stdout: str) -> Optional[Dict[str, Any]]:
For ``pre_llm_call``, ``{"context": "..."}`` is passed through
unchanged to match the existing plugin-hook contract.

For ``transform_llm_output``, a non-empty ``response_text``/``text``
string is returned directly because the core hook contract expects a
string replacement, not a dict.

Anything else returns ``None``.
"""
stdout = (stdout or "").strip()
Expand All @@ -532,6 +539,13 @@ def _parse_response(event: str, stdout: str) -> Optional[Dict[str, Any]]:
return {"action": "block", "message": _block_message(data.get("reason"), data.get("message"))}
return None

if event == "transform_llm_output":
for key in ("response_text", "text", "message"):
value = data.get(key)
if isinstance(value, str) and value:
return value
return None

context = data.get("context")
if isinstance(context, str) and context.strip():
return {"context": context}
Expand Down
12 changes: 12 additions & 0 deletions tests/agent/test_shell_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,18 @@ def test_pre_llm_call_context_passthrough(self):
)
assert r == {"context": "today is Friday"}

def test_transform_llm_output_response_text_passthrough(self):
r = shell_hooks._parse_response(
"transform_llm_output", '{"response_text": "updated final"}',
)
assert r == "updated final"

def test_transform_llm_output_empty_text_ignored(self):
r = shell_hooks._parse_response(
"transform_llm_output", '{"response_text": ""}',
)
assert r is None

def test_subagent_stop_context_passthrough(self):
r = shell_hooks._parse_response(
"subagent_stop", '{"context": "child role=leaf"}',
Expand Down