Skip to content
Closed
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
28 changes: 27 additions & 1 deletion code_puppy/agents/_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
)
from code_puppy.agents.event_stream_handler import event_stream_handler
from code_puppy.callbacks import (
PromptBlocked,
on_agent_exception,
on_agent_run_cancel,
on_agent_run_context,
Expand All @@ -99,6 +100,7 @@
)
from code_puppy.keymap import sigint_fallback_cancels
from code_puppy.messaging import emit_error, emit_info, emit_warning
from code_puppy.session_context import get_session_id, set_session_id
from code_puppy.tools.command_runner import is_awaiting_user_input

# ---- Streaming retry helpers ------------------------------------------------
Expand Down Expand Up @@ -642,6 +644,10 @@ async def run_with_mcp(
global _active_run_depth
is_nested_run = _active_run_depth > 0
_active_run_depth += 1
# Restored on the way out so a nested run hands its parent's id back, and
# the outermost run leaves no stale id behind for code that runs between
# turns. The impl publishes this run's own id once it mints one.
previous_session_id = get_session_id()
try:
return await _run_with_mcp_impl(
agent,
Expand All @@ -654,6 +660,7 @@ async def run_with_mcp(
)
finally:
_active_run_depth -= 1
set_session_id(previous_session_id)


async def _run_with_mcp_impl(
Expand Down Expand Up @@ -683,14 +690,33 @@ async def _run_with_mcp_impl(
prompt = _sanitize_prompt(prompt)
group_id = str(uuid.uuid4())

# Publish the run id before the agent task is created below, so the task
# inherits it and callbacks with no run-scoped argument — notably
# pre_tool_call / post_tool_call — can correlate their events with this run.
set_session_id(group_id)

# Fire user_prompt_submit hooks BEFORE prompt is sent. Plugins (e.g. the
# claude_code_hooks bridge) may return a string to replace the prompt —
# this is how Claude Code-style ``UserPromptSubmit`` hooks inject
# additional context (project constitutions, domain nudges, etc.)
try:
submit_results = await on_user_prompt_submit(prompt, group_id)
for r in submit_results:
if isinstance(r, str) and r:
if isinstance(r, PromptBlocked):
if not is_nested_run:
# Cancel the turn outright: return before the agent is
# built, so the prompt never reaches the model and no LLM
# call is made. Mirrors the None a cancelled run returns —
# callers already handle that. on_agent_run_start has not
# fired yet, so there is no run-end to pair with either.
emit_warning(f"🚫 Prompt blocked by hook: {r.reason}")
return None
# A nested run's caller dereferences the result (e.g. an
# internal assessment call passing output_type), so None would
# break it. Substitute instead — the prompt text is still
# withheld from the model.
prompt = r.replacement
elif isinstance(r, str) and r:
prompt = r
except Exception:
# Hook failures must never block the run.
Expand Down
37 changes: 35 additions & 2 deletions code_puppy/callbacks.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import asyncio
import logging
import traceback
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Literal, Optional, Set

PhaseType = Literal[
Expand Down Expand Up @@ -673,6 +674,12 @@ async def on_post_tool_call(
This allows plugins to inspect tool results, log execution times,
or perform post-processing.

A callback may return ``{"blocked": True, "reason": ...}`` to withhold the
tool's OUTPUT: the model and the message history receive a notice naming the
reason instead of the real result. The tool has already run by this point, so
this controls what the output reaches — not whether the side effect happened.
Use ``pre_tool_call`` to stop the call itself.

Args:
tool_name: Name of the tool that was called
tool_args: Arguments that were passed to the tool
Expand All @@ -681,7 +688,7 @@ async def on_post_tool_call(
context: Optional context data for the tool call

Returns:
List of results from registered callbacks.
List of results from registered callbacks (dict | None).
"""
return await _trigger_callbacks(
"post_tool_call", tool_name, tool_args, result, duration_ms, context
Expand Down Expand Up @@ -1451,6 +1458,29 @@ async def on_interactive_turn_cancel(
)


@dataclass(frozen=True)
class PromptBlocked:
"""Returned by a ``user_prompt_submit`` callback to stop a prompt entirely.

A top-level run handed one of these is **cancelled**: the prompt is never
sent to the model, no LLM call is made, and ``run_with_mcp`` returns
``None`` — the same shape it already returns for a cancelled run.

Nested runs cannot be cancelled that way. A plugin making its own internal
``run_with_mcp`` call (the shell-safety assessment, anything passing
``output_type``) dereferences the result, so returning ``None`` there would
break the caller. Those fall back to ``replacement``, which is substituted
for the prompt instead.

Attributes:
reason: Why the prompt was blocked. Shown to the user.
replacement: Prompt text used instead when the run cannot be cancelled.
"""

reason: str
replacement: str


async def on_user_prompt_submit(
prompt: str, session_id: str | None = None
) -> List[Any]:
Expand All @@ -1462,12 +1492,15 @@ async def on_user_prompt_submit(
returns a non-None, non-empty string wins; all others are merged in order
via concatenation. Returning None means "don't touch the prompt".

A callback may instead return a :class:`PromptBlocked` to stop the prompt
outright; see that class for how top-level and nested runs differ.

Args:
prompt: The raw user prompt about to be sent.
session_id: Optional run/session identifier.

Returns:
List of results from registered callbacks (str | None).
List of results from registered callbacks (str | PromptBlocked | None).
"""
return await _trigger_callbacks("user_prompt_submit", prompt, session_id)

Expand Down
8 changes: 8 additions & 0 deletions code_puppy/hook_engine/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ def _make_serializable(obj: Any) -> Any:
payload["tool_result"] = _make_serializable(event_data.context["result"])
if "duration_ms" in event_data.context:
payload["tool_duration_ms"] = event_data.context["duration_ms"]
# Stop / SubagentStop: the agent's final text. Claude Code hands end-of-turn
# hooks a transcript_path to read; code puppy has no transcript file, so the
# response itself is the equivalent. Without it a Stop hook fires with
# nothing to inspect and cannot do end-of-turn review at all.
if event_data.context.get("response_text") is not None:
payload["response_text"] = _make_serializable(
event_data.context["response_text"]
)

return json.dumps(payload, ensure_ascii=False).encode("utf-8")

Expand Down
Loading