fix(auto-drive): surface plan updates to coordinator

This commit is contained in:
James Peter
2025-09-30 08:20:33 +10:00
parent 29ff2a0c9b
commit b7a8d7fe19
4 changed files with 202 additions and 26 deletions
+8
View File
@@ -137,6 +137,14 @@ impl ModelClient {
})
}
pub fn default_model_slug(&self) -> &str {
self.config.model.as_str()
}
pub fn default_model_family(&self) -> &ModelFamily {
&self.config.model_family
}
/// Dispatches to either the Responses or Chat implementation depending on
/// the provider config. Public callers always invoke `stream()` – the
/// specialised helpers are private to avoid accidental misuse.
+42
View File
@@ -3221,6 +3221,48 @@ impl ChatWidget<'_> {
content: vec![content],
});
}
crate::history_cell::HistoryCellType::PlanUpdate => {
if let Some(plan) = cell
.as_any()
.downcast_ref::<crate::history_cell::PlanUpdateCell>()
{
let state = plan.state();
let mut lines: Vec<String> = Vec::new();
if !state.name.trim().is_empty() {
lines.push(format!("Plan update: {}", state.name.trim()));
} else {
lines.push("Plan update".to_string());
}
if state.progress.total > 0 {
lines.push(format!(
"Progress: {}/{}",
state.progress.completed, state.progress.total
));
}
if state.steps.is_empty() {
lines.push("(no steps recorded)".to_string());
} else {
for step in &state.steps {
let status_label = match step.status {
StepStatus::Completed => "[completed]",
StepStatus::InProgress => "[in_progress]",
StepStatus::Pending => "[pending]",
};
lines.push(format!("- {} {}", status_label, step.description));
}
}
let text = lines.join("\n");
let content = ContentItem::OutputText { text };
items.push(ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![content],
});
}
}
_ => {}
}
}
@@ -454,6 +454,61 @@ fn request_decision(
conversation: &[ResponseItem],
app_event_tx: &AppEventSender,
cancel_token: &CancellationToken,
) -> Result<String> {
match request_decision_with_model(
runtime,
client,
developer_intro,
primary_goal,
schema,
conversation,
app_event_tx,
cancel_token,
MODEL_SLUG,
) {
Ok(output) => Ok(output),
Err(err) => {
let fallback_slug = client.default_model_slug().to_string();
if fallback_slug != MODEL_SLUG && should_retry_with_default_model(&err) {
debug!(
preferred = %MODEL_SLUG,
fallback = %fallback_slug,
"auto coordinator falling back to configured model after invalid model error"
);
let original_error = err.to_string();
return request_decision_with_model(
runtime,
client,
developer_intro,
primary_goal,
schema,
conversation,
app_event_tx,
cancel_token,
&fallback_slug,
)
.map_err(|fallback_err| {
fallback_err.context(format!(
"coordinator fallback with model '{}' failed after original error: {}",
fallback_slug, original_error
))
});
}
Err(err)
}
}
}
fn request_decision_with_model(
runtime: &tokio::runtime::Runtime,
client: &ModelClient,
developer_intro: &str,
primary_goal: &str,
schema: &Value,
conversation: &[ResponseItem],
app_event_tx: &AppEventSender,
cancel_token: &CancellationToken,
model_slug: &str,
) -> Result<String> {
let developer_intro = developer_intro.to_string();
let primary_goal = primary_goal.to_string();
@@ -467,11 +522,12 @@ fn request_decision(
let result = runtime.block_on(async move {
retry_with_backoff(
|| {
let prompt = build_prompt(
let prompt = build_prompt_for_model(
&developer_intro,
&primary_goal,
&schema,
&conversation,
model_slug,
);
let tx_inner = tx.clone();
async move {
@@ -576,11 +632,12 @@ fn request_decision(
}
}
fn build_prompt(
fn build_prompt_for_model(
developer_intro: &str,
primary_goal: &str,
schema: &Value,
conversation: &[ResponseItem],
model_slug: &str,
) -> Prompt {
let mut prompt = Prompt::default();
prompt.store = true;
@@ -597,13 +654,31 @@ fn build_prompt(
strict: Some(true),
schema: Some(schema.clone()),
});
prompt.model_override = Some(MODEL_SLUG.to_string());
let family = find_family_for_model(MODEL_SLUG)
.unwrap_or_else(|| derive_default_model_family(MODEL_SLUG));
prompt.model_override = Some(model_slug.to_string());
let family = find_family_for_model(model_slug)
.unwrap_or_else(|| derive_default_model_family(model_slug));
prompt.model_family_override = Some(family);
prompt
}
fn should_retry_with_default_model(err: &anyhow::Error) -> bool {
err.chain().any(|cause| {
if let Some(codex_err) = cause.downcast_ref::<CodexErr>() {
if let CodexErr::UnexpectedStatus(status, body) = codex_err {
if !status.is_client_error() {
return false;
}
let body_lower = body.to_lowercase();
return body_lower.contains("invalid model")
|| body_lower.contains("unknown model")
|| body_lower.contains("model_not_found")
|| body_lower.contains("model does not exist");
}
}
false
})
}
fn classify_model_error(error: &anyhow::Error) -> RetryDecision {
if let Some(codex_err) = find_in_chain::<CodexErr>(error) {
match codex_err {
+72 -21
View File
@@ -2,7 +2,7 @@ use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::Arc;
use anyhow::{anyhow, Context, Result};
use codex_core::{ModelClient, Prompt, ResponseEvent, TextFormat};
use codex_core::{error::CodexErr, ModelClient, Prompt, ResponseEvent, TextFormat};
use codex_core::model_family::{derive_default_model_family, find_family_for_model};
use codex_protocol::models::{ContentItem, ResponseItem};
use futures::StreamExt;
@@ -148,27 +148,36 @@ fn evaluate_observer(
client: Arc<ModelClient>,
trigger: ObserverTrigger,
) -> Result<(AutoObserverStatus, Option<String>, Option<String>)> {
let mut prompt = Prompt::default();
prompt.store = true;
let instructions = build_observer_instructions(&trigger.environment_details);
prompt.input.push(make_message("developer", instructions));
let goal = format!("Primary Goal\n{}", trigger.goal_text);
prompt.input.push(make_message("developer", goal));
prompt.input.extend(trigger.conversation);
let schema = build_observer_schema();
prompt.text_format = Some(TextFormat {
r#type: "json_schema".to_string(),
name: Some(OBSERVER_SCHEMA_NAME.to_string()),
strict: Some(true),
schema: Some(schema),
});
prompt.model_override = Some(MODEL_SLUG.to_string());
let family = find_family_for_model(MODEL_SLUG)
.unwrap_or_else(|| derive_default_model_family(MODEL_SLUG));
prompt.model_family_override = Some(family);
let prompt = build_observer_prompt(&trigger, MODEL_SLUG);
match run_observer_prompt(runtime, client.clone(), prompt) {
Ok(result) => Ok(result),
Err(err) => {
let fallback_slug = client.default_model_slug().to_string();
if should_retry_with_default_model(&err) && fallback_slug != MODEL_SLUG {
debug!(
preferred = %MODEL_SLUG,
fallback = %fallback_slug,
"auto observer falling back to configured model after invalid model error"
);
let fallback_prompt = build_observer_prompt(&trigger, &fallback_slug);
let original_error = err.to_string();
return run_observer_prompt(runtime, client, fallback_prompt).map_err(|fallback_err| {
fallback_err.context(format!(
"observer fallback with model '{}' failed after original error: {}",
fallback_slug, original_error
))
});
}
Err(err)
}
}
}
fn run_observer_prompt(
runtime: &tokio::runtime::Runtime,
client: Arc<ModelClient>,
prompt: Prompt,
) -> Result<(AutoObserverStatus, Option<String>, Option<String>)> {
let raw = runtime.block_on(async {
request_observer_response(client.clone(), &prompt).await
})?;
@@ -206,6 +215,48 @@ fn evaluate_observer(
Ok((status, replace_message, additional_instructions))
}
fn build_observer_prompt(trigger: &ObserverTrigger, model_slug: &str) -> Prompt {
let mut prompt = Prompt::default();
prompt.store = true;
let instructions = build_observer_instructions(&trigger.environment_details);
prompt.input.push(make_message("developer", instructions));
let goal = format!("Primary Goal\n{}", trigger.goal_text);
prompt.input.push(make_message("developer", goal));
prompt.input.extend(trigger.conversation.clone());
let schema = build_observer_schema();
prompt.text_format = Some(TextFormat {
r#type: "json_schema".to_string(),
name: Some(OBSERVER_SCHEMA_NAME.to_string()),
strict: Some(true),
schema: Some(schema),
});
prompt.model_override = Some(model_slug.to_string());
let family = find_family_for_model(model_slug)
.unwrap_or_else(|| derive_default_model_family(model_slug));
prompt.model_family_override = Some(family);
prompt
}
fn should_retry_with_default_model(err: &anyhow::Error) -> bool {
err.chain().any(|cause| {
if let Some(codex_err) = cause.downcast_ref::<CodexErr>() {
if let CodexErr::UnexpectedStatus(status, body) = codex_err {
if !status.is_client_error() {
return false;
}
let body_lower = body.to_lowercase();
return body_lower.contains("invalid model")
|| body_lower.contains("unknown model")
|| body_lower.contains("model_not_found")
|| body_lower.contains("model does not exist");
}
}
false
})
}
async fn request_observer_response(
client: Arc<ModelClient>,
prompt: &Prompt,