Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
bd8ee83
Add SubagentStart hook
abhinav-oai May 15, 2026
48110f9
Add SubagentStop hook
abhinav-oai May 15, 2026
b2e51b8
Add subagent identity to hook inputs
abhinav-oai May 15, 2026
991bc30
Wait for subagent hook logs in test
abhinav-oai May 15, 2026
8dfeb97
Address subagent start hook review feedback
abhinav-oai May 15, 2026
11f3142
Merge subagent hooks base into stop stack
abhinav-oai May 15, 2026
579bb3b
Simplify stop hook parsing
abhinav-oai May 15, 2026
ce0d7c4
Remove stop hook kind helper
abhinav-oai May 15, 2026
94cbf56
Clarify stop hook target resolution
abhinav-oai May 15, 2026
a2c6e1b
Merge main into abhinav/subagent-stop-stack
abhinav-oai May 19, 2026
15fed46
Merge remote-tracking branch 'origin/abhinav/subagent-stop-stack' int…
abhinav-oai May 20, 2026
fd3a862
Merge remote-tracking branch 'origin/main' into abhinav/subagent-hook…
abhinav-oai May 20, 2026
08f7c92
Fix subagent hook fixture arg comments
abhinav-oai May 20, 2026
0ed016d
Fix hooks schema arg comments
abhinav-oai May 20, 2026
26c2104
Default hooks for function tools
abhinav-oai May 20, 2026
f6f6721
Update plan hook coverage
abhinav-oai May 20, 2026
754ce75
Remove stale plan hook exclusions
abhinav-oai May 20, 2026
579781d
Cover local function tool hooks
abhinav-oai May 20, 2026
2f0db69
Cover function hook opt outs
abhinav-oai May 20, 2026
c16afce
Inline function tool hook defaults
abhinav-oai May 20, 2026
d9a7b56
Add Agent matcher alias for spawn_agent
abhinav-oai May 21, 2026
9fcee0a
Merge origin/main into abhinav/spawn-agent-hook-alias
abhinav-oai May 21, 2026
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
6 changes: 5 additions & 1 deletion codex-rs/core/src/tools/code_mode/wait_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,4 +110,8 @@ impl ToolExecutor<ToolInvocation> for CodeModeWaitHandler {
}
}

impl CoreToolRuntime for CodeModeWaitHandler {}
impl CoreToolRuntime for CodeModeWaitHandler {
fn supports_default_function_tool_hooks(&self) -> bool {
false
}
}
43 changes: 3 additions & 40 deletions codex-rs/core/src/tools/handlers/extension_tools.rs
Original file line number Diff line number Diff line change
@@ -1,20 +1,14 @@
use std::sync::Arc;

use codex_tools::ToolCall as ExtensionToolCall;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
use serde_json::Value;

use crate::function_tool::FunctionCallError;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::flat_tool_name;
use crate::tools::hook_names::HookToolName;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::PostToolUsePayload;
use crate::tools::registry::PreToolUsePayload;
use crate::tools::registry::ToolExecutor;
use codex_tools::ToolCall as ExtensionToolCall;
use codex_tools::ToolName;
use codex_tools::ToolSpec;

pub(crate) struct ExtensionToolAdapter(Arc<dyn codex_tools::ToolExecutor<ExtensionToolCall>>);

Expand Down Expand Up @@ -61,29 +55,6 @@ impl CoreToolRuntime for ExtensionToolAdapter {
fn matches_kind(&self, payload: &ToolPayload) -> bool {
self.arguments_from_payload(payload).is_some()
}

fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
let arguments = self.arguments_from_payload(&invocation.payload)?;
Some(PreToolUsePayload {
tool_name: HookToolName::new(flat_tool_name(&self.tool_name()).into_owned()),
tool_input: extension_tool_hook_input(arguments),
})
}

fn post_tool_use_payload(
&self,
invocation: &ToolInvocation,
result: &dyn ToolOutput,
) -> Option<PostToolUsePayload> {
let arguments = self.arguments_from_payload(&invocation.payload)?;
Some(PostToolUsePayload {
tool_name: HookToolName::new(flat_tool_name(&self.tool_name()).into_owned()),
tool_use_id: invocation.call_id.clone(),
tool_input: extension_tool_hook_input(arguments),
tool_response: result
.post_tool_use_response(&invocation.call_id, &invocation.payload)?,
})
}
}

fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall {
Expand All @@ -96,14 +67,6 @@ fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall {
}
}

fn extension_tool_hook_input(arguments: &str) -> Value {
if arguments.trim().is_empty() {
return Value::Object(serde_json::Map::new());
}

serde_json::from_str(arguments).unwrap_or_else(|_| Value::String(arguments.to_string()))
}

#[cfg(test)]
mod tests {
use std::sync::Arc;
Expand Down
9 changes: 3 additions & 6 deletions codex-rs/core/src/tools/handlers/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,6 @@ impl CoreToolRuntime for McpHandler {
tags
})
}

fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
let ToolPayload::Function { arguments } = &invocation.payload else {
return None;
Expand Down Expand Up @@ -279,6 +278,9 @@ mod tests {
use super::*;
use crate::session::tests::make_session_and_context;
use crate::tools::context::ToolCallSource;
use crate::tools::hook_names::HookToolName;
use crate::tools::registry::PostToolUsePayload;
use crate::tools::registry::PreToolUsePayload;
use crate::turn_diff_tracker::TurnDiffTracker;
use pretty_assertions::assert_eq;
use serde_json::json;
Expand Down Expand Up @@ -438,11 +440,6 @@ mod tests {
);
}

#[test]
fn mcp_hook_tool_input_defaults_empty_args_to_object() {
assert_eq!(mcp_hook_tool_input(" "), json!({}));
}

fn tool_info(server_name: &str, callable_namespace: &str, tool_name: &str) -> ToolInfo {
ToolInfo {
server_name: server_name.to_string(),
Expand Down
6 changes: 5 additions & 1 deletion codex-rs/core/src/tools/handlers/request_permissions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,8 @@ impl ToolExecutor<ToolInvocation> for RequestPermissionsHandler {
}
}

impl CoreToolRuntime for RequestPermissionsHandler {}
impl CoreToolRuntime for RequestPermissionsHandler {
fn supports_default_function_tool_hooks(&self) -> bool {
false
}
}
4 changes: 4 additions & 0 deletions codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ impl CoreToolRuntime for WriteStdinHandler {
matches!(payload, ToolPayload::Function { .. })
}

fn supports_default_function_tool_hooks(&self) -> bool {
false
}

fn post_tool_use_payload(
&self,
invocation: &ToolInvocation,
Expand Down
12 changes: 12 additions & 0 deletions codex-rs/core/src/tools/hook_names.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,18 @@ impl HookToolName {
}
}

/// Returns the hook identity for spawning sub-agents.
///
/// The serialized name remains `spawn_agent`, while `Agent` is accepted as
/// a matcher alias for compatibility with hook configurations that describe
/// sub-agent creation using Claude Code-style names.
pub(crate) fn spawn_agent() -> Self {
Self {
name: "spawn_agent".to_string(),
matcher_aliases: vec!["Agent".to_string()],
}
}

/// Returns the hook identity historically used for shell-like tools.
pub(crate) fn bash() -> Self {
Self::new("Bash")
Expand Down
1 change: 1 addition & 0 deletions codex-rs/core/src/tools/parallel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ impl ToolCallRuntime {
result: Box::new(AbortedToolOutput {
message: Self::abort_message(call, secs),
}),
model_visible_override: None,
post_tool_use_payload: None,
}
}
Expand Down
121 changes: 112 additions & 9 deletions codex-rs/core/src/tools/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@ use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::flat_tool_name;
use crate::tools::handlers::multi_agents_spec::MULTI_AGENT_V1_NAMESPACE;
use crate::tools::hook_names::HookToolName;
use crate::tools::lifecycle::notify_tool_finish;
use crate::tools::lifecycle::notify_tool_start;
use crate::tools::tool_dispatch_trace::ToolDispatchTrace;
use crate::tools::tool_search_entry::ToolSearchInfo;
use crate::util::error_or_panic;
use codex_extension_api::ToolCallOutcome;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::protocol::EventMsg;
use codex_tools::ToolName;
Expand Down Expand Up @@ -64,14 +66,55 @@ pub(crate) trait CoreToolRuntime: ToolExecutor<ToolInvocation> {

fn post_tool_use_payload(
&self,
_invocation: &ToolInvocation,
_result: &dyn ToolOutput,
invocation: &ToolInvocation,
result: &dyn ToolOutput,
) -> Option<PostToolUsePayload> {
None
if !self.supports_default_function_tool_hooks() {
return None;
}

let ToolPayload::Function { arguments } = &invocation.payload else {
return None;
};

Some(PostToolUsePayload {
tool_name: function_hook_tool_name(invocation),
tool_use_id: result.post_tool_use_id(&invocation.call_id),
tool_input: result
.post_tool_use_input(&invocation.payload)
.unwrap_or_else(|| function_hook_tool_input(arguments)),
tool_response: result
.post_tool_use_response(&invocation.call_id, &invocation.payload)
.or_else(|| {
// Most function tools can expose their model-facing output
// as the hook response. Outputs with a more stable hook
// contract should override post_tool_use_response above.
let ResponseInputItem::FunctionCallOutput {
output: FunctionCallOutputPayload { body, .. },
..
} = result.to_response_item(&invocation.call_id, &invocation.payload)
else {
return None;
};

serde_json::to_value(body).ok()
})?,
})
}

fn pre_tool_use_payload(&self, _invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
None
fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
if !self.supports_default_function_tool_hooks() {
return None;
}

let ToolPayload::Function { arguments } = &invocation.payload else {
return None;
};

Some(PreToolUsePayload {
tool_name: function_hook_tool_name(invocation),
tool_input: function_hook_tool_input(arguments),
})
}

/// Rebuilds a tool invocation from hook-facing `tool_input`.
Expand All @@ -80,14 +123,43 @@ pub(crate) trait CoreToolRuntime: ToolExecutor<ToolInvocation> {
/// hook contract they expose from `pre_tool_use_payload`.
fn with_updated_hook_input(
&self,
_invocation: ToolInvocation,
_updated_input: Value,
invocation: ToolInvocation,
updated_input: Value,
) -> Result<ToolInvocation, FunctionCallError> {
if self.supports_default_function_tool_hooks() {
let ToolPayload::Function { .. } = &invocation.payload else {
return Err(FunctionCallError::RespondToModel(
"hook input rewrite received unsupported function tool payload".to_string(),
));
};

let arguments = serde_json::to_string(&updated_input).map_err(|err| {
FunctionCallError::RespondToModel(format!(
"failed to serialize rewritten {} arguments: {err}",
flat_tool_name(&invocation.tool_name)
))
})?;
return Ok(ToolInvocation {
payload: ToolPayload::Function { arguments },
..invocation
});
}

Err(FunctionCallError::RespondToModel(
"tool does not support hook input rewriting".to_string(),
))
}

/// Returns whether this tool uses the generic function-tool hook contract.
///
/// Most local function tools expose their JSON arguments directly to hooks.
/// Tools with compatibility-specific hook contracts can override the hook
/// payload methods instead, while function tools that should not run hooks
/// can opt out here.
fn supports_default_function_tool_hooks(&self) -> bool {
true
}

/// Creates an optional consumer for streamed tool argument diffs.
fn create_diff_consumer(&self) -> Option<Box<dyn ToolArgumentDiffConsumer>> {
None
Expand All @@ -111,6 +183,7 @@ pub(crate) struct AnyToolResult {
pub(crate) call_id: String,
pub(crate) payload: ToolPayload,
pub(crate) result: Box<dyn ToolOutput>,
pub(crate) model_visible_override: Option<FunctionToolOutput>,
pub(crate) post_tool_use_payload: Option<PostToolUsePayload>,
}

Expand All @@ -120,9 +193,13 @@ impl AnyToolResult {
call_id,
payload,
result,
model_visible_override,
..
} = self;
result.to_response_item(&call_id, &payload)
model_visible_override.map_or_else(
|| result.to_response_item(&call_id, &payload),
|output| output.to_response_item(&call_id, &payload),
)
}

pub(crate) fn code_mode_result(self) -> serde_json::Value {
Expand Down Expand Up @@ -234,6 +311,10 @@ impl CoreToolRuntime for ExposureOverride {
.with_updated_hook_input(invocation, updated_input)
}

fn supports_default_function_tool_hooks(&self) -> bool {
self.handler.supports_default_function_tool_hooks()
}

fn telemetry_tags<'a>(
&'a self,
invocation: &'a ToolInvocation,
Expand Down Expand Up @@ -539,7 +620,7 @@ impl ToolRegistry {
if let Some(replacement_text) = replacement_text {
let mut guard = response_cell.lock().await;
if let Some(result) = guard.as_mut() {
result.result = Box::new(FunctionToolOutput::from_text(
result.model_visible_override = Some(FunctionToolOutput::from_text(
replacement_text,
/*success*/ None,
));
Expand Down Expand Up @@ -614,10 +695,32 @@ async fn handle_any_tool(
call_id,
payload,
result: output,
model_visible_override: None,
post_tool_use_payload,
})
}

fn function_hook_tool_name(invocation: &ToolInvocation) -> HookToolName {
if invocation.tool_name.name == "spawn_agent"
&& matches!(
invocation.tool_name.namespace.as_deref(),
None | Some(MULTI_AGENT_V1_NAMESPACE)
)
{
return HookToolName::spawn_agent();
}

HookToolName::new(flat_tool_name(&invocation.tool_name).into_owned())
}

fn function_hook_tool_input(arguments: &str) -> Value {
if arguments.trim().is_empty() {
return Value::Object(serde_json::Map::new());
}

serde_json::from_str(arguments).unwrap_or_else(|_| Value::String(arguments.to_string()))
}

fn unsupported_tool_call_message(payload: &ToolPayload, tool_name: &ToolName) -> String {
match payload {
ToolPayload::Custom { .. } => format!("unsupported custom tool call: {tool_name}"),
Expand Down
Loading
Loading