Add tool lifecycle extension contributor (#23309)

## Why

Extensions that need to track runtime progress currently have no typed
host signal for tool execution. The goal extension in particular needs
to observe tool attempts without inspecting tool payloads, owning tool
implementations, or staying coupled to core-only runtime plumbing.

This adds a narrow lifecycle contributor API for host-owned tool
execution: extensions can observe when an accepted tool call starts and
how it finishes, while policy hooks and tool handlers continue to own
payload rewriting, blocking, and execution.

Relevant code:

-
[`ToolLifecycleContributor`](3ad2850ffc/codex-rs/ext/extension-api/src/contributors.rs (L119))
defines the extension-facing observer contract.
-
[`tool_lifecycle.rs`](3ad2850ffc/codex-rs/ext/extension-api/src/contributors/tool_lifecycle.rs)
defines the typed start/finish inputs, source, and outcome enums.
- [`notify_tool_start` /
`notify_tool_finish`](3ad2850ffc/codex-rs/core/src/tools/lifecycle.rs)
bridges core tool dispatch into the extension registry.

## What Changed

- Added `ToolLifecycleContributor` to `codex-extension-api`, including:
  - `ToolStartInput`
  - `ToolFinishInput`
  - `ToolCallSource`
  - `ToolCallOutcome`
- Added registration and lookup support on `ExtensionRegistryBuilder` /
`ExtensionRegistry`.
- Wired core tool dispatch to notify lifecycle contributors for:
  - accepted tool starts
  - completed tool calls, including the tool output success marker
  - pre-tool-use blocks
  - failures before or after the handler runs
  - cancellation/abort in the parallel tool path
- Registered the goal extension as a lifecycle contributor and added the
outcome filter it will use for goal progress accounting.

## Test Coverage

- Added `dispatch_notifies_tool_lifecycle_contributors` to cover
lifecycle notification ordering and outcomes for successful and
handler-failed tool calls.
This commit is contained in:
jif-oai
2026-05-18 21:55:57 +02:00
committed by GitHub
parent 4dbca61e20
commit c69cde3547
11 changed files with 777 additions and 34 deletions

View File

@@ -11,6 +11,7 @@ use crate::ExtensionData;
mod prompt;
mod thread_lifecycle;
mod tool_lifecycle;
mod turn_lifecycle;
pub use prompt::PromptFragment;
@@ -18,6 +19,11 @@ pub use prompt::PromptSlot;
pub use thread_lifecycle::ThreadResumeInput;
pub use thread_lifecycle::ThreadStartInput;
pub use thread_lifecycle::ThreadStopInput;
pub use tool_lifecycle::ToolCallOutcome;
pub use tool_lifecycle::ToolCallSource;
pub use tool_lifecycle::ToolFinishInput;
pub use tool_lifecycle::ToolLifecycleFuture;
pub use tool_lifecycle::ToolStartInput;
pub use turn_lifecycle::TurnAbortInput;
pub use turn_lifecycle::TurnStartInput;
pub use turn_lifecycle::TurnStopInput;
@@ -111,6 +117,23 @@ pub trait ToolContributor: Send + Sync {
) -> Vec<Arc<dyn ToolExecutor<ToolCall>>>;
}
/// Contributor for host-owned tool lifecycle gates.
///
/// Implementations should use these callbacks to observe tool execution without
/// inspecting or rewriting tool input/output. Use `ToolContributor` for owning a
/// tool implementation and hooks for policy that needs tool payloads.
pub trait ToolLifecycleContributor: Send + Sync {
/// Called once the host has accepted a tool call for execution.
fn on_tool_start<'a>(&'a self, _input: ToolStartInput<'a>) -> ToolLifecycleFuture<'a> {
Box::pin(std::future::ready(()))
}
/// Called after the tool call returns, is blocked, fails, or is cancelled.
fn on_tool_finish<'a>(&'a self, _input: ToolFinishInput<'a>) -> ToolLifecycleFuture<'a> {
Box::pin(std::future::ready(()))
}
}
/// Future returned by one claimed approval-review contribution.
pub type ApprovalReviewFuture<'a> =
std::pin::Pin<Box<dyn Future<Output = ReviewDecision> + Send + 'a>>;

View File

@@ -0,0 +1,82 @@
use std::future::Future;
use std::pin::Pin;
use codex_tools::ToolName;
use crate::ExtensionData;
/// Future returned by one tool-lifecycle callback.
pub type ToolLifecycleFuture<'a> = Pin<Box<dyn Future<Output = ()> + Send + 'a>>;
/// Host-visible source for a model tool call.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ToolCallSource {
/// The model invoked the tool directly.
Direct,
/// Code mode invoked the tool while executing a runtime cell.
CodeMode {
/// Runtime cell that issued the nested tool request.
cell_id: String,
/// Code-mode's per-cell tool invocation id.
runtime_tool_call_id: String,
},
}
/// Extension-facing outcome for a finished tool call.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ToolCallOutcome {
/// The tool returned a normal output.
Completed {
/// The tool output's own success marker for telemetry/logging.
success: bool,
},
/// The tool was blocked by host policy before the handler ran.
Blocked,
/// The tool did not produce a normal output.
Failed {
/// Whether the host reached the tool handler before the failure.
handler_executed: bool,
},
/// The host cancelled the tool before normal completion. Cancellation can
/// win before the dispatch path accepts the call, so contributors should not
/// assume a matching start callback exists.
Aborted,
}
/// Input supplied when the host starts executing one tool call.
pub struct ToolStartInput<'a> {
/// Store scoped to the host session runtime.
pub session_store: &'a ExtensionData,
/// Store scoped to this thread runtime.
pub thread_store: &'a ExtensionData,
/// Store scoped to this turn runtime.
pub turn_store: &'a ExtensionData,
/// Current turn submission id.
pub turn_id: &'a str,
/// Model-visible tool call id.
pub call_id: &'a str,
/// Tool name as routed by the host.
pub tool_name: &'a ToolName,
/// Source that issued the tool call.
pub source: ToolCallSource,
}
/// Input supplied when the host finishes executing one tool call.
pub struct ToolFinishInput<'a> {
/// Store scoped to the host session runtime.
pub session_store: &'a ExtensionData,
/// Store scoped to this thread runtime.
pub thread_store: &'a ExtensionData,
/// Store scoped to this turn runtime.
pub turn_store: &'a ExtensionData,
/// Current turn submission id.
pub turn_id: &'a str,
/// Model-visible tool call id.
pub call_id: &'a str,
/// Tool name as routed by the host.
pub tool_name: &'a ToolName,
/// Source that issued the tool call.
pub source: ToolCallSource,
/// Host-observed result of the tool call.
pub outcome: ToolCallOutcome,
}

View File

@@ -28,7 +28,13 @@ pub use contributors::ThreadResumeInput;
pub use contributors::ThreadStartInput;
pub use contributors::ThreadStopInput;
pub use contributors::TokenUsageContributor;
pub use contributors::ToolCallOutcome;
pub use contributors::ToolCallSource;
pub use contributors::ToolContributor;
pub use contributors::ToolFinishInput;
pub use contributors::ToolLifecycleContributor;
pub use contributors::ToolLifecycleFuture;
pub use contributors::ToolStartInput;
pub use contributors::TurnAbortInput;
pub use contributors::TurnItemContributionFuture;
pub use contributors::TurnItemContributor;

View File

@@ -10,6 +10,7 @@ use crate::NoopExtensionEventSink;
use crate::ThreadLifecycleContributor;
use crate::TokenUsageContributor;
use crate::ToolContributor;
use crate::ToolLifecycleContributor;
use crate::TurnItemContributor;
use crate::TurnLifecycleContributor;
@@ -22,6 +23,7 @@ pub struct ExtensionRegistryBuilder<C: Sync> {
token_usage_contributors: Vec<Arc<dyn TokenUsageContributor>>,
context_contributors: Vec<Arc<dyn ContextContributor>>,
tool_contributors: Vec<Arc<dyn ToolContributor>>,
tool_lifecycle_contributors: Vec<Arc<dyn ToolLifecycleContributor>>,
turn_item_contributors: Vec<Arc<dyn TurnItemContributor>>,
approval_review_contributors: Vec<Arc<dyn ApprovalReviewContributor>>,
}
@@ -37,6 +39,7 @@ impl<C: Sync> Default for ExtensionRegistryBuilder<C> {
approval_review_contributors: Vec::new(),
context_contributors: Vec::new(),
tool_contributors: Vec::new(),
tool_lifecycle_contributors: Vec::new(),
turn_item_contributors: Vec::new(),
}
}
@@ -99,6 +102,11 @@ impl<C: Sync> ExtensionRegistryBuilder<C> {
self.tool_contributors.push(contributor);
}
/// Registers one tool-lifecycle contributor.
pub fn tool_lifecycle_contributor(&mut self, contributor: Arc<dyn ToolLifecycleContributor>) {
self.tool_lifecycle_contributors.push(contributor);
}
/// Registers one ordered turn-item contributor.
pub fn turn_item_contributor(&mut self, contributor: Arc<dyn TurnItemContributor>) {
self.turn_item_contributors.push(contributor);
@@ -115,6 +123,7 @@ impl<C: Sync> ExtensionRegistryBuilder<C> {
approval_review_contributors: self.approval_review_contributors,
context_contributors: self.context_contributors,
tool_contributors: self.tool_contributors,
tool_lifecycle_contributors: self.tool_lifecycle_contributors,
turn_item_contributors: self.turn_item_contributors,
}
}
@@ -129,6 +138,7 @@ pub struct ExtensionRegistry<C: Sync> {
token_usage_contributors: Vec<Arc<dyn TokenUsageContributor>>,
context_contributors: Vec<Arc<dyn ContextContributor>>,
tool_contributors: Vec<Arc<dyn ToolContributor>>,
tool_lifecycle_contributors: Vec<Arc<dyn ToolLifecycleContributor>>,
turn_item_contributors: Vec<Arc<dyn TurnItemContributor>>,
approval_review_contributors: Vec<Arc<dyn ApprovalReviewContributor>>,
}
@@ -182,6 +192,11 @@ impl<C: Sync> ExtensionRegistry<C> {
&self.tool_contributors
}
/// Returns the registered tool-lifecycle contributors.
pub fn tool_lifecycle_contributors(&self) -> &[Arc<dyn ToolLifecycleContributor>] {
&self.tool_lifecycle_contributors
}
/// Returns the registered ordered turn-item contributors.
pub fn turn_item_contributors(&self) -> &[Arc<dyn TurnItemContributor>] {
&self.turn_item_contributors