[hooks] stop continuation & stop_hook_active mechanics (#14532)

Stop hooks now receive `stop_hook_active` and enable stop hooks to loop
forever if they'd like to. In the initial hooks PR, we implemented a
simpler mechanic that the stop-blocking could only happen once in a row

- support stop hook adding a continuation prompt to add a further task
- if multiple stop-blocks happen that have continuation prompts, they
are concatenated

example run:
```
› hey :)


• Running SessionStart hook: lighting the observatory

SessionStart hook (completed)
  warning: Hi, I'm a session start hook for wizard-tower (startup).
  hook context: Startup context: A wimboltine stonpet is an exotic cuisine from hyperspace

• Aloha :) Happy to jam with you. What are we building today?

• Running Stop hook: updating the guards

Stop hook (blocked)
  warning: Wizard Tower Stop hook continuing conversation
  feedback: cook the stonpet

• Aloha, here’s the hyperspace move for cooking a wimboltine stonpet:

  1. Sear the stonpet in a hot pan with moon-oil until the edges shimmer.
  2. Add star-lime, black salt, and a little fermented nebula paste.
  3. Lower the heat and let it braise for 8 cosmic minutes with a splash of comet broth.
  4. Finish with sky herbs and serve over warm asteroid rice.

  The vibe: crispy outside, tender center, deep interdimensional savor.

• Running Stop hook: updating the guards

Stop hook (completed)
  warning: Wizard Tower Stop hook saw a second pass and stayed calm to avoid a loop.
```

.codex/config.toml
```
[features]
codex_hooks = true
```

.codex/hooks.json
```
{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup|resume",
        "hooks": [
          {
            "type": "command",
            "command": "/usr/bin/python3 .codex/hooks/session_start_demo.py",
            "timeoutSec": 10,
            "statusMessage": "lighting the observatory"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "/usr/bin/python3 .codex/hooks/stop_demo_block.py",
            "timeoutSec": 10,
            "statusMessage": "updating the guards"
          }
        ]
      }
    ]
  }
}
```

.codex/hooks/session_start_demo.py
```
#!/usr/bin/env python3

import json
import sys
from pathlib import Path


def main() -> int:
    payload = json.load(sys.stdin)
    cwd = Path(payload.get("cwd", ".")).name or "wizard-tower"
    source = payload.get("source", "startup")
    source_label = "resume" if source == "resume" else "startup"
    source_prefix = (
        "Resume context:"
        if source == "resume"
        else "Startup context:"
    )

    output = {
        "systemMessage": (
            f"Hi, I'm a session start hook for {cwd} ({source_label})."
        ),
        "hookSpecificOutput": {
            "hookEventName": "SessionStart",
            "additionalContext": (
                f"{source_prefix} A wimboltine stonpet is an exotic cuisine from hyperspace"
            ),
        },
    }
    print(json.dumps(output))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
```

.codex/hooks/stop_demo_block.py
```
#!/usr/bin/env python3

import json
import sys


def main() -> int:
    payload = json.load(sys.stdin)
    stop_hook_active = payload.get("stop_hook_active", False)
    last_assistant_message = payload.get("last_assistant_message") or ""
    char_count = len(last_assistant_message.strip())

    if stop_hook_active:
        system_message = (
            "Wizard Tower Stop hook saw a second pass and stayed calm to avoid a loop."
        )
        print(json.dumps({"systemMessage": system_message}))
    else:
        system_message = (
            f"Wizard Tower Stop hook continuing conversation"
        )
        print(json.dumps({"systemMessage": system_message, "decision": "block", "reason": "cook the stonpet"}))

    return 0


if __name__ == "__main__":
    raise SystemExit(main())
```
This commit is contained in:
Andrei Eternal
2026-03-13 15:51:19 -07:00
committed by GitHub
parent 467e6216bb
commit 9a44a7e499
7 changed files with 467 additions and 117 deletions
+14 -11
View File
@@ -5652,7 +5652,6 @@ pub(crate) async fn run_turn(
.await;
let mut last_agent_message: Option<String> = None;
let mut stop_hook_active = false;
let mut pending_stop_hook_message: Option<String> = None;
// Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains
// many turns, from the perspective of the user, it is a single turn.
let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
@@ -5744,14 +5743,11 @@ pub(crate) async fn run_turn(
}
// Construct the input that we will send to the model.
let mut sampling_request_input: Vec<ResponseItem> = {
let sampling_request_input: Vec<ResponseItem> = {
sess.clone_history()
.await
.for_prompt(&turn_context.model_info.input_modalities)
};
if let Some(stop_hook_message) = pending_stop_hook_message.take() {
sampling_request_input.push(DeveloperInstructions::new(stop_hook_message).into());
}
let sampling_request_input_messages = sampling_request_input
.iter()
@@ -5848,18 +5844,25 @@ pub(crate) async fn run_turn(
.await;
}
if stop_outcome.should_block {
if stop_hook_active {
if let Some(continuation_prompt) = stop_outcome.continuation_prompt.clone()
{
let developer_message: ResponseItem =
DeveloperInstructions::new(continuation_prompt).into();
sess.record_conversation_items(
&turn_context,
std::slice::from_ref(&developer_message),
)
.await;
stop_hook_active = true;
continue;
} else {
sess.send_event(
&turn_context,
EventMsg::Warning(WarningEvent {
message: "Stop hook blocked twice in the same turn; ignoring the second block to avoid an infinite loop.".to_string(),
message: "Stop hook requested continuation without a prompt; ignoring the block.".to_string(),
}),
)
.await;
} else {
stop_hook_active = true;
pending_stop_hook_message = stop_outcome.block_message_for_model;
continue;
}
}
if stop_outcome.should_stop {