mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
## Why
Extension contributors are registered behind `dyn Trait` objects, so
native `async fn`/RPITIT methods would make these traits
non-object-safe. Spell out the boxed, `Send` future contract directly so
`extension-api` no longer needs `async-trait` while retaining the
existing runtime model.
## What changed
- add a shared `ExtensionFuture` alias and use it for asynchronous
contributor methods
- migrate production and test implementations to return `Box::pin(async
move { ... })`
- remove `async-trait` dependencies where they are no longer used,
keeping it dev-only where unrelated test executors still require it
## Behavior
No behavior change is intended. Contributor futures remain boxed,
`Send`, dynamically dispatched, and lazily executed; cancellation and
callback ordering stay unchanged.
## Testing
- `just test -p codex-extension-api` (11 passed)
- affected extension crates (64 passed)
- targeted `codex-core` contributor tests (14 passed)
- `just fmt`
- `just bazel-lock-update`
- `just bazel-lock-check`
A broad local `codex-core` run compiled successfully but encountered
unrelated sandbox and missing test-binary fixture failures; CI will run
the full checks.
496 lines
17 KiB
Rust
496 lines
17 KiB
Rust
use std::sync::Arc;
|
|
use std::sync::Weak;
|
|
|
|
use codex_analytics::AnalyticsEventsClient;
|
|
use codex_core::ThreadManager;
|
|
use codex_extension_api::ConfigContributor;
|
|
use codex_extension_api::ExtensionData;
|
|
use codex_extension_api::ExtensionEventSink;
|
|
use codex_extension_api::ExtensionFuture;
|
|
use codex_extension_api::ExtensionRegistryBuilder;
|
|
use codex_extension_api::ThreadIdleInput;
|
|
use codex_extension_api::ThreadLifecycleContributor;
|
|
use codex_extension_api::ThreadResumeInput;
|
|
use codex_extension_api::ThreadStartInput;
|
|
use codex_extension_api::ThreadStopInput;
|
|
use codex_extension_api::TokenUsageContributor;
|
|
use codex_extension_api::ToolCallOutcome;
|
|
use codex_extension_api::ToolContributor;
|
|
use codex_extension_api::ToolFinishInput;
|
|
use codex_extension_api::ToolLifecycleContributor;
|
|
use codex_extension_api::ToolLifecycleFuture;
|
|
use codex_extension_api::TurnAbortInput;
|
|
use codex_extension_api::TurnErrorInput;
|
|
use codex_extension_api::TurnLifecycleContributor;
|
|
use codex_extension_api::TurnStartInput;
|
|
use codex_extension_api::TurnStopInput;
|
|
use codex_otel::MetricsClient;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::protocol::CodexErrorInfo;
|
|
use codex_protocol::protocol::SessionSource;
|
|
use codex_protocol::protocol::SubAgentSource;
|
|
use codex_protocol::protocol::ThreadGoalStatus;
|
|
use codex_protocol::protocol::TokenUsageInfo;
|
|
|
|
use crate::accounting::BudgetLimitedGoalDisposition;
|
|
use crate::accounting::GoalAccountingState;
|
|
use crate::analytics::GoalAnalytics;
|
|
use crate::api::GoalService;
|
|
use crate::events::GoalEventEmitter;
|
|
use crate::metrics::GoalMetrics;
|
|
use crate::runtime::ActiveGoalStopReason;
|
|
use crate::runtime::GoalRuntimeConfig;
|
|
use crate::runtime::GoalRuntimeHandle;
|
|
use crate::spec::UPDATE_GOAL_TOOL_NAME;
|
|
use crate::steering::budget_limit_steering_item;
|
|
use crate::tool::GoalToolExecutor;
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct GoalExtensionConfig {
|
|
pub enabled: bool,
|
|
}
|
|
|
|
impl GoalExtensionConfig {
|
|
fn from_enabled(enabled: bool) -> Self {
|
|
Self { enabled }
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct GoalExtension<C> {
|
|
state_dbs: Arc<codex_state::StateRuntime>,
|
|
analytics: GoalAnalytics,
|
|
event_emitter: GoalEventEmitter,
|
|
metrics: GoalMetrics,
|
|
thread_manager: Weak<ThreadManager>,
|
|
goal_service: Arc<GoalService>,
|
|
goals_enabled: Arc<dyn Fn(&C) -> bool + Send + Sync>,
|
|
}
|
|
|
|
impl<C> std::fmt::Debug for GoalExtension<C> {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("GoalExtension").finish_non_exhaustive()
|
|
}
|
|
}
|
|
|
|
impl<C> GoalExtension<C> {
|
|
pub(crate) fn new_with_host_capabilities(
|
|
state_dbs: Arc<codex_state::StateRuntime>,
|
|
analytics_events_client: AnalyticsEventsClient,
|
|
event_sink: Arc<dyn ExtensionEventSink>,
|
|
metrics_client: Option<MetricsClient>,
|
|
thread_manager: Weak<ThreadManager>,
|
|
goal_service: Arc<GoalService>,
|
|
goals_enabled: impl Fn(&C) -> bool + Send + Sync + 'static,
|
|
) -> Self {
|
|
Self {
|
|
state_dbs,
|
|
analytics: GoalAnalytics::new(analytics_events_client),
|
|
event_emitter: GoalEventEmitter::new(event_sink),
|
|
metrics: GoalMetrics::new(metrics_client),
|
|
thread_manager,
|
|
goal_service,
|
|
goals_enabled: Arc::new(goals_enabled),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<C> ThreadLifecycleContributor<C> for GoalExtension<C>
|
|
where
|
|
C: Send + Sync + 'static,
|
|
{
|
|
fn on_thread_start<'a>(&'a self, input: ThreadStartInput<'a, C>) -> ExtensionFuture<'a, ()> {
|
|
Box::pin(async move {
|
|
let enabled = (self.goals_enabled)(input.config);
|
|
let tools_available_for_thread = input.persistent_thread_state_available
|
|
&& !matches!(
|
|
input.session_source,
|
|
SessionSource::SubAgent(SubAgentSource::Review)
|
|
);
|
|
input
|
|
.thread_store
|
|
.insert(GoalExtensionConfig::from_enabled(enabled));
|
|
let accounting_state = input
|
|
.thread_store
|
|
.get_or_init::<GoalAccountingState>(GoalAccountingState::default);
|
|
let Ok(thread_id) = ThreadId::from_string(input.thread_store.level_id()) else {
|
|
return;
|
|
};
|
|
let runtime = input.thread_store.get_or_init::<GoalRuntimeHandle>(|| {
|
|
GoalRuntimeHandle::new(
|
|
thread_id,
|
|
Arc::clone(&self.state_dbs),
|
|
self.event_emitter.clone(),
|
|
self.metrics.clone(),
|
|
self.thread_manager.clone(),
|
|
accounting_state,
|
|
GoalRuntimeConfig {
|
|
analytics: self.analytics.clone(),
|
|
enabled,
|
|
tools_available_for_thread,
|
|
},
|
|
)
|
|
});
|
|
runtime.set_enabled(enabled);
|
|
self.goal_service.register_runtime(&runtime);
|
|
})
|
|
}
|
|
|
|
fn on_thread_resume<'a>(&'a self, input: ThreadResumeInput<'a>) -> ExtensionFuture<'a, ()> {
|
|
Box::pin(async move {
|
|
let Some(runtime) = goal_runtime_handle(input.thread_store) else {
|
|
return;
|
|
};
|
|
|
|
if let Err(err) = runtime.restore_after_resume().await {
|
|
tracing::warn!(
|
|
"failed to restore goal runtime after thread resume for {}: {err}",
|
|
runtime.thread_id()
|
|
);
|
|
}
|
|
})
|
|
}
|
|
|
|
fn on_thread_idle<'a>(&'a self, input: ThreadIdleInput<'a>) -> ExtensionFuture<'a, ()> {
|
|
Box::pin(async move {
|
|
let Some(runtime) = goal_runtime_handle(input.thread_store) else {
|
|
return;
|
|
};
|
|
|
|
if let Err(err) = runtime.continue_if_idle().await {
|
|
tracing::warn!(
|
|
"failed to continue active goal for idle thread {}: {err}",
|
|
runtime.thread_id()
|
|
);
|
|
}
|
|
})
|
|
}
|
|
|
|
fn on_thread_stop<'a>(&'a self, input: ThreadStopInput<'a>) -> ExtensionFuture<'a, ()> {
|
|
Box::pin(async move {
|
|
if let Some(runtime) = goal_runtime_handle(input.thread_store) {
|
|
self.goal_service.unregister_runtime(&runtime);
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
impl<C> ConfigContributor<C> for GoalExtension<C>
|
|
where
|
|
C: Send + Sync + 'static,
|
|
{
|
|
fn on_config_changed(
|
|
&self,
|
|
_session_store: &ExtensionData,
|
|
thread_store: &ExtensionData,
|
|
_previous_config: &C,
|
|
new_config: &C,
|
|
) {
|
|
let enabled = (self.goals_enabled)(new_config);
|
|
thread_store.insert(GoalExtensionConfig::from_enabled(enabled));
|
|
if let Some(runtime) = goal_runtime_handle(thread_store) {
|
|
runtime.set_enabled(enabled);
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<C> TurnLifecycleContributor for GoalExtension<C>
|
|
where
|
|
C: Send + Sync + 'static,
|
|
{
|
|
fn on_turn_start<'a>(&'a self, input: TurnStartInput<'a>) -> ExtensionFuture<'a, ()> {
|
|
Box::pin(async move {
|
|
let Some(runtime) = goal_runtime_handle(input.thread_store) else {
|
|
return;
|
|
};
|
|
if !runtime.is_enabled() {
|
|
return;
|
|
}
|
|
|
|
let accounting = runtime.accounting_state();
|
|
accounting.start_turn(
|
|
input.turn_id,
|
|
input.collaboration_mode.mode,
|
|
input.token_usage_at_turn_start,
|
|
);
|
|
if matches!(
|
|
input.collaboration_mode.mode,
|
|
codex_protocol::config_types::ModeKind::Plan
|
|
) {
|
|
accounting.clear_current_turn_goal();
|
|
return;
|
|
}
|
|
let Ok(goal) = self
|
|
.state_dbs
|
|
.thread_goals()
|
|
.get_thread_goal(runtime.thread_id())
|
|
.await
|
|
else {
|
|
return;
|
|
};
|
|
if let Some(goal) = goal
|
|
&& matches!(
|
|
goal.status,
|
|
codex_state::ThreadGoalStatus::Active
|
|
| codex_state::ThreadGoalStatus::BudgetLimited
|
|
)
|
|
{
|
|
accounting.mark_turn_goal_active(input.turn_id, goal.goal_id);
|
|
}
|
|
})
|
|
}
|
|
|
|
fn on_turn_stop<'a>(&'a self, input: TurnStopInput<'a>) -> ExtensionFuture<'a, ()> {
|
|
Box::pin(async move {
|
|
let Some(runtime) = goal_runtime_handle(input.thread_store) else {
|
|
return;
|
|
};
|
|
if !runtime.is_enabled() {
|
|
return;
|
|
}
|
|
|
|
let turn_id = input.turn_store.level_id();
|
|
if let Err(err) = runtime
|
|
.account_active_goal_progress(
|
|
turn_id,
|
|
&format!("{turn_id}:turn-stop"),
|
|
codex_state::GoalAccountingMode::ActiveOnly,
|
|
BudgetLimitedGoalDisposition::ClearActive,
|
|
)
|
|
.await
|
|
{
|
|
tracing::warn!(
|
|
"failed to account active goal progress at turn stop for {turn_id}: {err}"
|
|
);
|
|
return;
|
|
}
|
|
runtime.accounting_state().finish_turn(turn_id);
|
|
})
|
|
}
|
|
|
|
fn on_turn_abort<'a>(&'a self, input: TurnAbortInput<'a>) -> ExtensionFuture<'a, ()> {
|
|
Box::pin(async move {
|
|
let Some(runtime) = goal_runtime_handle(input.thread_store) else {
|
|
return;
|
|
};
|
|
if !runtime.is_enabled() {
|
|
return;
|
|
}
|
|
|
|
let turn_id = input.turn_store.level_id();
|
|
if let Err(err) = runtime
|
|
.account_active_goal_progress(
|
|
turn_id,
|
|
&format!("{turn_id}:turn-abort"),
|
|
codex_state::GoalAccountingMode::ActiveOnly,
|
|
BudgetLimitedGoalDisposition::ClearActive,
|
|
)
|
|
.await
|
|
{
|
|
tracing::warn!(
|
|
"failed to account active goal progress after turn abort for {turn_id}: {err}"
|
|
);
|
|
return;
|
|
}
|
|
runtime.accounting_state().finish_turn(turn_id);
|
|
})
|
|
}
|
|
|
|
fn on_turn_error<'a>(&'a self, input: TurnErrorInput<'a>) -> ExtensionFuture<'a, ()> {
|
|
Box::pin(async move {
|
|
let Some(runtime) = goal_runtime_handle(input.thread_store) else {
|
|
return;
|
|
};
|
|
|
|
let reason = match input.error {
|
|
CodexErrorInfo::UsageLimitExceeded => ActiveGoalStopReason::UsageLimit,
|
|
// The turn has ended because the error was non-retryable or its
|
|
// retries were exhausted. Block the goal to prevent automatic
|
|
// continuation from looping and consuming tokens, as can happen
|
|
// with compaction errors.
|
|
_ => ActiveGoalStopReason::TurnError,
|
|
};
|
|
if let Err(err) = runtime
|
|
.stop_active_goal_for_turn(input.turn_id, reason)
|
|
.await
|
|
{
|
|
tracing::warn!(
|
|
error = ?input.error,
|
|
"failed to stop active goal after turn error: {err}"
|
|
);
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
impl<C> TokenUsageContributor for GoalExtension<C>
|
|
where
|
|
C: Send + Sync + 'static,
|
|
{
|
|
fn on_token_usage<'a>(
|
|
&'a self,
|
|
_session_store: &'a ExtensionData,
|
|
thread_store: &'a ExtensionData,
|
|
turn_store: &'a ExtensionData,
|
|
token_usage: &'a TokenUsageInfo,
|
|
) -> ExtensionFuture<'a, ()> {
|
|
Box::pin(async move {
|
|
let Some(runtime) = goal_runtime_handle(thread_store) else {
|
|
return;
|
|
};
|
|
if !runtime.is_enabled() {
|
|
return;
|
|
}
|
|
|
|
let Some(_recorded) = runtime
|
|
.accounting_state()
|
|
.record_token_usage(turn_store.level_id(), &token_usage.total_token_usage)
|
|
else {
|
|
return;
|
|
};
|
|
})
|
|
}
|
|
}
|
|
|
|
impl<C> ToolLifecycleContributor for GoalExtension<C>
|
|
where
|
|
C: Send + Sync + 'static,
|
|
{
|
|
fn on_tool_finish<'a>(&'a self, input: ToolFinishInput<'a>) -> ToolLifecycleFuture<'a> {
|
|
Box::pin(async move {
|
|
let Some(runtime) = goal_runtime_handle(input.thread_store) else {
|
|
return;
|
|
};
|
|
let should_count_for_goal_progress = runtime.is_enabled()
|
|
&& tool_attempt_counts_for_goal_progress(input.outcome)
|
|
&& !(input.tool_name.namespace.is_none()
|
|
&& input.tool_name.name == UPDATE_GOAL_TOOL_NAME);
|
|
if !should_count_for_goal_progress {
|
|
return;
|
|
}
|
|
let turn_id = input.turn_id;
|
|
let progress = match runtime
|
|
.account_active_goal_progress(
|
|
turn_id,
|
|
input.call_id,
|
|
codex_state::GoalAccountingMode::ActiveOnly,
|
|
BudgetLimitedGoalDisposition::KeepActive,
|
|
)
|
|
.await
|
|
{
|
|
Ok(Some(progress)) => progress,
|
|
Ok(None) => return,
|
|
Err(err) => {
|
|
tracing::warn!(
|
|
"failed to account active goal progress after tool finish for {turn_id}: {err}"
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
let goal = progress.goal;
|
|
if goal.status != ThreadGoalStatus::BudgetLimited {
|
|
return;
|
|
}
|
|
if !runtime
|
|
.accounting_state()
|
|
.mark_budget_limit_reported_if_new(progress.goal_id.as_str())
|
|
{
|
|
return;
|
|
}
|
|
let item = budget_limit_steering_item(&goal);
|
|
runtime.inject_active_turn_steering(item).await;
|
|
})
|
|
}
|
|
}
|
|
|
|
impl<C> ToolContributor for GoalExtension<C>
|
|
where
|
|
C: Send + Sync + 'static,
|
|
{
|
|
fn tools(
|
|
&self,
|
|
_session_store: &ExtensionData,
|
|
thread_store: &ExtensionData,
|
|
) -> Vec<Arc<dyn codex_extension_api::ToolExecutor<codex_extension_api::ToolCall>>> {
|
|
let Some(runtime) = goal_runtime_handle(thread_store) else {
|
|
return Vec::new();
|
|
};
|
|
if !runtime.tools_visible() {
|
|
return Vec::new();
|
|
}
|
|
|
|
vec![
|
|
Arc::new(GoalToolExecutor::get(
|
|
runtime.thread_id(),
|
|
Arc::clone(&self.state_dbs),
|
|
runtime.accounting_state(),
|
|
self.analytics.clone(),
|
|
self.event_emitter.clone(),
|
|
self.metrics.clone(),
|
|
)),
|
|
Arc::new(GoalToolExecutor::create(
|
|
runtime.thread_id(),
|
|
Arc::clone(&self.state_dbs),
|
|
runtime.accounting_state(),
|
|
self.analytics.clone(),
|
|
self.event_emitter.clone(),
|
|
self.metrics.clone(),
|
|
)),
|
|
Arc::new(GoalToolExecutor::update(
|
|
runtime.thread_id(),
|
|
Arc::clone(&self.state_dbs),
|
|
runtime.accounting_state(),
|
|
self.analytics.clone(),
|
|
self.event_emitter.clone(),
|
|
self.metrics.clone(),
|
|
)),
|
|
]
|
|
}
|
|
}
|
|
|
|
pub fn install_with_backend<C>(
|
|
registry: &mut ExtensionRegistryBuilder<C>,
|
|
state_dbs: Arc<codex_state::StateRuntime>,
|
|
analytics_events_client: AnalyticsEventsClient,
|
|
metrics_client: Option<MetricsClient>,
|
|
thread_manager: Weak<ThreadManager>,
|
|
goal_service: Arc<GoalService>,
|
|
goals_enabled: impl Fn(&C) -> bool + Send + Sync + 'static,
|
|
) where
|
|
C: Send + Sync + 'static,
|
|
{
|
|
let extension = Arc::new(GoalExtension::new_with_host_capabilities(
|
|
state_dbs,
|
|
analytics_events_client,
|
|
registry.event_sink(),
|
|
metrics_client,
|
|
thread_manager,
|
|
Arc::clone(&goal_service),
|
|
goals_enabled,
|
|
));
|
|
registry.thread_lifecycle_contributor(extension.clone());
|
|
registry.config_contributor(extension.clone());
|
|
registry.turn_lifecycle_contributor(extension.clone());
|
|
registry.token_usage_contributor(extension.clone());
|
|
registry.tool_lifecycle_contributor(extension.clone());
|
|
registry.tool_contributor(extension);
|
|
}
|
|
|
|
fn goal_runtime_handle(thread_store: &ExtensionData) -> Option<Arc<GoalRuntimeHandle>> {
|
|
thread_store.get::<GoalRuntimeHandle>()
|
|
}
|
|
|
|
fn tool_attempt_counts_for_goal_progress(outcome: ToolCallOutcome) -> bool {
|
|
match outcome {
|
|
ToolCallOutcome::Completed { .. } => true,
|
|
ToolCallOutcome::Failed {
|
|
handler_executed: true,
|
|
} => true,
|
|
ToolCallOutcome::Blocked
|
|
| ToolCallOutcome::Failed {
|
|
handler_executed: false,
|
|
}
|
|
| ToolCallOutcome::Aborted => false,
|
|
}
|
|
}
|