mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
Merge branch 'code-branch-20251023-043432'
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
# Continue Auto Drive Cleanup and Extraction
|
||||
|
||||
```
|
||||
You’re attached to worktree /home/azureuser/.code/working/code/branches/code-branch-20251023-043432 (branch code-branch-20251023-043432). Context so far: observer/QA plumbing in the TUI was guarded behind #[cfg(FALSE)] and the auto_coordinator loop had its observer wiring stripped. ./build-fast.sh was run successfully, but the build now emits warnings about those #[cfg(FALSE)] blocks plus a bunch of unused structs/fields (observer enums, helper fns, variables like cmd_tx/turn_cli_prompt/read_only_tools).
|
||||
|
||||
Goal: finish the cleanup and start the crate extraction.
|
||||
1. Delete the dormant observer/QA modules instead of #[cfg(FALSE)], including removing ChatWidget fields/methods and the now-unreferenced types in code-rs/tui/src/app_event.rs, auto_coordinator.rs, chatwidget.rs, and any other TUI files that were only serving the observer pipeline. Remove helper functions like compose_developer_intro/push_unique_guidance and dead structs (ObserverHistory entries, AutoReviewOutcome, etc.) that no longer compile-time check out.
|
||||
2. Resolve every warning from ./build-fast.sh by eliminating unused imports/vars, removing or refactoring dead code. Do NOT rustfmt. Stick to ASCII for edits.
|
||||
3. Once the TUI builds clean with zero warnings, scaffold a new crate under code-rs (name it code-auto-drive-core) that will house the coordinator. For now: create the crate, move auto_coordinator.rs and minimal dependencies (auto_drive_history.rs, coordinator_router.rs, coordinator_user_schema.rs if required) into it, and expose the coordinator loop via a trait-based interface so the TUI adapter can call into it. Keep the TUI compiling by adding a thin adapter layer in chatwidget.rs that uses the new crate. Update Cargo.toml/workspace members accordingly.
|
||||
4. After migrating the coordinator, run ./build-fast.sh again (allow 25+ min) and make sure there are zero warnings or errors. Summarize any remaining TODOs for completing the extraction (e.g., migrating history structs or configuration parsing) if you can’t finish within this run.
|
||||
|
||||
Constraints:
|
||||
- Always run commands via ["bash","-lc",…] with workdir set. Never rustfmt.
|
||||
- Treat codex-rs as read-only; modify files only under code-rs/.
|
||||
- Finish with a concise summary plus next steps if work remains.
|
||||
```
|
||||
Generated
+292
-272
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ members = [
|
||||
"app-server-protocol",
|
||||
"apply-patch",
|
||||
"arg0",
|
||||
"code-auto-drive-core",
|
||||
"code-backend-openapi-models",
|
||||
"cloud-tasks",
|
||||
"cloud-tasks-client",
|
||||
@@ -72,6 +73,7 @@ code-otel = { path = "otel" }
|
||||
code-tui = { path = "tui" }
|
||||
code-utils-json-to-toml = { path = "utils/json-to-toml" }
|
||||
code-utils-readiness = { path = "utils/readiness" }
|
||||
code-auto-drive-core = { path = "code-auto-drive-core" }
|
||||
mcp-types = { path = "mcp-types", package = "code-mcp-types" }
|
||||
codex-mcp-client = { path = "../codex-rs/mcp-client" }
|
||||
codex-ansi-escape = { path = "../codex-rs/ansi-escape" }
|
||||
@@ -126,6 +128,7 @@ log = "0.4"
|
||||
maplit = "1.0.2"
|
||||
mime_guess = "2.0.5"
|
||||
multimap = "0.10.0"
|
||||
once_cell = "1"
|
||||
nucleo-matcher = "0.3.1"
|
||||
openssl-sys = "*"
|
||||
opentelemetry = "0.30.0"
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
[package]
|
||||
name = "code-auto-drive-core"
|
||||
edition = "2024"
|
||||
version = { workspace = true }
|
||||
|
||||
[lib]
|
||||
name = "code_auto_drive_core"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
# Surface dev fault injection toggles for parity with the existing TUI feature flag.
|
||||
dev-faults = ["dep:once_cell"]
|
||||
test-helpers = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
code-common = { path = "../common", features = ["elapsed"] }
|
||||
code-core = { path = "../core" }
|
||||
code-git-tooling = { path = "../git-tooling" }
|
||||
code-protocol = { path = "../protocol" }
|
||||
futures = { workspace = true }
|
||||
once_cell = { workspace = true, optional = true }
|
||||
rand = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "time"] }
|
||||
tokio-util = { workspace = true, features = ["rt"] }
|
||||
tracing = { workspace = true, features = ["log"] }
|
||||
uuid = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
+126
-440
@@ -4,12 +4,10 @@ use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use code_browser::global as browser_global;
|
||||
use code_core::config::Config;
|
||||
use code_core::config_types::{AutoDriveSettings, ReasoningEffort};
|
||||
use code_core::debug_logger::DebugLogger;
|
||||
use code_core::model_family::{derive_default_model_family, find_family_for_model};
|
||||
use code_core::get_openai_tools;
|
||||
use code_core::project_doc::read_auto_drive_docs;
|
||||
use code_core::protocol::SandboxPolicy;
|
||||
use code_core::slash_commands::get_enabled_agents;
|
||||
@@ -20,39 +18,18 @@ use futures::StreamExt;
|
||||
use reqwest::StatusCode;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{self, json, Value};
|
||||
use super::coordinator_user_schema::{user_turn_schema, parse_user_turn_reply};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::app_event::{
|
||||
AppEvent,
|
||||
AutoCoordinatorStatus,
|
||||
AutoObserverReason,
|
||||
AutoObserverTelemetry,
|
||||
AutoTurnAgentsAction,
|
||||
AutoTurnAgentsTiming,
|
||||
AutoTurnCliAction,
|
||||
};
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::chatwidget::retry::{retry_with_backoff, RetryDecision, RetryError, RetryOptions};
|
||||
use crate::thread_spawner;
|
||||
use crate::coordinator_user_schema::{parse_user_turn_reply, user_turn_schema};
|
||||
use crate::retry::{retry_with_backoff, RetryDecision, RetryError, RetryOptions};
|
||||
#[cfg(feature = "dev-faults")]
|
||||
use crate::chatwidget::faults::{fault_to_error, next_fault, FaultScope, InjectedFault};
|
||||
use crate::faults::{fault_to_error, next_fault, FaultScope, InjectedFault};
|
||||
use code_common::elapsed::format_duration;
|
||||
use std::fs;
|
||||
use chrono::{DateTime, Local, Utc};
|
||||
use rand::Rng;
|
||||
use super::auto_observer::{
|
||||
build_observer_conversation,
|
||||
start_auto_observer,
|
||||
AutoObserverCommand,
|
||||
AutoObserverHandle,
|
||||
ObserverOutcome,
|
||||
ObserverReason,
|
||||
ObserverTrigger,
|
||||
};
|
||||
use crate::app_event::{ObserverMode, AutoObserverStatus};
|
||||
|
||||
const RATE_LIMIT_BUFFER: Duration = Duration::from_secs(120);
|
||||
const RATE_LIMIT_JITTER_MAX: Duration = Duration::from_secs(30);
|
||||
@@ -63,13 +40,79 @@ const MAX_DECISION_RECOVERY_ATTEMPTS: u32 = 3;
|
||||
#[error("auto coordinator cancelled")]
|
||||
struct AutoCoordinatorCancelled;
|
||||
|
||||
pub(super) const MODEL_SLUG: &str = "gpt-5";
|
||||
pub const MODEL_SLUG: &str = "gpt-5";
|
||||
const USER_TURN_SCHEMA_NAME: &str = "auto_coordinator_user_turn";
|
||||
pub(super) const CROSS_CHECK_RESTART_BANNER: &str = "Cross-check restart with minimal context";
|
||||
const COORDINATOR_PROMPT_PATH: &str = "code-rs/core/prompt_coordinator.md";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AutoCoordinatorEventSender {
|
||||
inner: Arc<dyn Fn(AutoCoordinatorEvent) + Send + Sync>,
|
||||
}
|
||||
|
||||
impl AutoCoordinatorEventSender {
|
||||
pub fn new<F>(f: F) -> Self
|
||||
where
|
||||
F: Fn(AutoCoordinatorEvent) + Send + Sync + 'static,
|
||||
{
|
||||
Self { inner: Arc::new(f) }
|
||||
}
|
||||
|
||||
pub fn send(&self, event: AutoCoordinatorEvent) {
|
||||
(self.inner)(event);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct AutoCoordinatorHandle {
|
||||
pub struct AutoTurnCliAction {
|
||||
pub prompt: String,
|
||||
pub context: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AutoTurnAgentsTiming {
|
||||
Parallel,
|
||||
Blocking,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AutoTurnAgentsAction {
|
||||
pub prompt: String,
|
||||
pub context: Option<String>,
|
||||
pub write: bool,
|
||||
pub write_requested: Option<bool>,
|
||||
pub models: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AutoCoordinatorStatus {
|
||||
Continue,
|
||||
Success,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AutoCoordinatorEvent {
|
||||
Decision {
|
||||
status: AutoCoordinatorStatus,
|
||||
progress_past: Option<String>,
|
||||
progress_current: Option<String>,
|
||||
cli: Option<AutoTurnCliAction>,
|
||||
agents_timing: Option<AutoTurnAgentsTiming>,
|
||||
agents: Vec<AutoTurnAgentsAction>,
|
||||
transcript: Vec<ResponseItem>,
|
||||
},
|
||||
Thinking {
|
||||
delta: String,
|
||||
summary_index: Option<u32>,
|
||||
},
|
||||
UserReply {
|
||||
user_response: Option<String>,
|
||||
cli_command: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AutoCoordinatorHandle {
|
||||
pub tx: Sender<AutoCoordinatorCommand>,
|
||||
cancel_token: CancellationToken,
|
||||
}
|
||||
@@ -85,24 +128,15 @@ impl AutoCoordinatorHandle {
|
||||
pub fn cancel(&self) {
|
||||
self.cancel_token.cancel();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) enum AutoCoordinatorCommand {
|
||||
pub enum AutoCoordinatorCommand {
|
||||
UpdateConversation(Vec<ResponseItem>),
|
||||
HandleUserPrompt {
|
||||
_prompt: String,
|
||||
conversation: Vec<ResponseItem>,
|
||||
},
|
||||
ObserverResult(ObserverOutcome),
|
||||
ObserverThinking {
|
||||
mode: ObserverMode,
|
||||
delta: String,
|
||||
summary_index: Option<u32>,
|
||||
},
|
||||
ObserverBootstrapLen(usize),
|
||||
ResetObserver,
|
||||
Stop,
|
||||
}
|
||||
|
||||
@@ -118,8 +152,8 @@ struct PendingDecision {
|
||||
}
|
||||
|
||||
impl PendingDecision {
|
||||
fn into_event(self) -> AppEvent {
|
||||
AppEvent::AutoCoordinatorDecision {
|
||||
fn into_event(self) -> AutoCoordinatorEvent {
|
||||
AutoCoordinatorEvent::Decision {
|
||||
status: self.status,
|
||||
progress_past: self.progress_past,
|
||||
progress_current: self.progress_current,
|
||||
@@ -133,14 +167,14 @@ impl PendingDecision {
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub(crate) enum TurnComplexity {
|
||||
pub enum TurnComplexity {
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct TurnConfig {
|
||||
pub struct TurnConfig {
|
||||
#[serde(default)]
|
||||
pub read_only: bool,
|
||||
#[serde(default)]
|
||||
@@ -150,7 +184,7 @@ pub(crate) struct TurnConfig {
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum TurnMode {
|
||||
pub enum TurnMode {
|
||||
Normal,
|
||||
SubAgentWrite,
|
||||
SubAgentReadOnly,
|
||||
@@ -165,7 +199,7 @@ impl Default for TurnMode {
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
pub(crate) struct AgentPreferences {
|
||||
pub struct AgentPreferences {
|
||||
#[serde(default)]
|
||||
pub prefer_research: bool,
|
||||
#[serde(default)]
|
||||
@@ -176,7 +210,7 @@ pub(crate) struct AgentPreferences {
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum ReviewTiming {
|
||||
pub enum ReviewTiming {
|
||||
PostTurn,
|
||||
PreWrite,
|
||||
Immediate,
|
||||
@@ -190,7 +224,7 @@ impl Default for ReviewTiming {
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct ReviewStrategy {
|
||||
pub struct ReviewStrategy {
|
||||
#[serde(default)]
|
||||
pub timing: ReviewTiming,
|
||||
#[serde(default)]
|
||||
@@ -211,7 +245,7 @@ impl Default for ReviewStrategy {
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub(crate) struct TurnDescriptor {
|
||||
pub struct TurnDescriptor {
|
||||
#[serde(default)]
|
||||
pub mode: TurnMode,
|
||||
#[serde(default)]
|
||||
@@ -614,38 +648,37 @@ struct AgentAction {
|
||||
models: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
pub(super) fn start_auto_coordinator(
|
||||
app_event_tx: AppEventSender,
|
||||
pub fn start_auto_coordinator(
|
||||
event_tx: AutoCoordinatorEventSender,
|
||||
goal_text: String,
|
||||
conversation: Vec<ResponseItem>,
|
||||
config: Config,
|
||||
debug_enabled: bool,
|
||||
observer_cadence: u32,
|
||||
) -> Result<AutoCoordinatorHandle> {
|
||||
let (cmd_tx, cmd_rx) = mpsc::channel();
|
||||
let thread_tx = cmd_tx.clone();
|
||||
let loop_tx = cmd_tx.clone();
|
||||
let cancel_token = CancellationToken::new();
|
||||
let thread_cancel = cancel_token.clone();
|
||||
|
||||
if thread_spawner::spawn_lightweight("auto-coordinator", move || {
|
||||
let builder = std::thread::Builder::new()
|
||||
.name("code-auto-coordinator".to_string())
|
||||
.stack_size(256 * 1024);
|
||||
let handle = builder.spawn(move || {
|
||||
if let Err(err) = run_auto_loop(
|
||||
app_event_tx,
|
||||
event_tx,
|
||||
goal_text,
|
||||
conversation,
|
||||
config,
|
||||
cmd_rx,
|
||||
loop_tx,
|
||||
debug_enabled,
|
||||
observer_cadence,
|
||||
thread_cancel,
|
||||
) {
|
||||
tracing::error!("auto coordinator loop error: {err:#}");
|
||||
}
|
||||
})
|
||||
.is_none()
|
||||
{
|
||||
tracing::error!("auto coordinator spawn rejected: background thread limit reached");
|
||||
});
|
||||
|
||||
if handle.is_err() {
|
||||
tracing::error!("auto coordinator spawn failed: {:#}", handle.unwrap_err());
|
||||
return Err(anyhow!("auto coordinator worker unavailable"));
|
||||
}
|
||||
|
||||
@@ -656,14 +689,12 @@ pub(super) fn start_auto_coordinator(
|
||||
}
|
||||
|
||||
fn run_auto_loop(
|
||||
app_event_tx: AppEventSender,
|
||||
event_tx: AutoCoordinatorEventSender,
|
||||
goal_text: String,
|
||||
initial_conversation: Vec<ResponseItem>,
|
||||
config: Config,
|
||||
cmd_rx: Receiver<AutoCoordinatorCommand>,
|
||||
cmd_tx: Sender<AutoCoordinatorCommand>,
|
||||
debug_enabled: bool,
|
||||
observer_cadence: u32,
|
||||
cancel_token: CancellationToken,
|
||||
) -> Result<()> {
|
||||
let preferred_auth = if config.using_chatgpt_auth {
|
||||
@@ -704,30 +735,6 @@ fn run_auto_loop(
|
||||
.build()
|
||||
.context("creating runtime for auto coordinator")?;
|
||||
|
||||
let browser_enabled = runtime.block_on(async {
|
||||
browser_global::get_browser_manager().await.is_some()
|
||||
});
|
||||
|
||||
let read_only_tools_config = client
|
||||
.as_ref()
|
||||
.build_tools_config_with_sandbox(SandboxPolicy::ReadOnly);
|
||||
let read_only_tools = get_openai_tools(
|
||||
&read_only_tools_config,
|
||||
None,
|
||||
browser_enabled,
|
||||
false,
|
||||
);
|
||||
|
||||
let full_access_tools_config = client
|
||||
.as_ref()
|
||||
.build_tools_config_with_sandbox(SandboxPolicy::DangerFullAccess);
|
||||
let full_access_tools = get_openai_tools(
|
||||
&full_access_tools_config,
|
||||
None,
|
||||
browser_enabled,
|
||||
false,
|
||||
);
|
||||
|
||||
let auto_instructions = match runtime.block_on(read_auto_drive_docs(config.as_ref())) {
|
||||
Ok(Some(text)) => {
|
||||
let trimmed = text.trim().to_string();
|
||||
@@ -752,7 +759,7 @@ fn run_auto_loop(
|
||||
let coordinator_prompt = read_coordinator_prompt(config.as_ref());
|
||||
let (base_developer_intro, primary_goal_message) =
|
||||
build_developer_message(&goal_text, &environment_details, coordinator_prompt.as_deref());
|
||||
let schema_features = SchemaFeatures::from_auto_settings(&config.tui.auto_drive);
|
||||
let schema_features = SchemaFeatures::from_auto_settings(&config.auto_drive);
|
||||
let include_agents = schema_features.include_agents;
|
||||
let schema = build_schema(&active_agent_names, schema_features);
|
||||
let platform = std::env::consts::OS;
|
||||
@@ -762,54 +769,6 @@ fn run_auto_loop(
|
||||
let mut stopped = false;
|
||||
let mut requests_completed: u64 = 0;
|
||||
let mut consecutive_decision_failures: u32 = 0;
|
||||
let mut observer_guidance: Vec<String> = Vec::new();
|
||||
let mut _observer_telemetry = AutoObserverTelemetry::default();
|
||||
let mut _last_cli_context: Option<String> = None;
|
||||
let mut _last_cli_prompt: Option<String> = None;
|
||||
let mut _last_progress_summary: Option<String> = None;
|
||||
let mut awaiting_cross_check = false;
|
||||
let mut pending_success: Option<PendingDecision> = None;
|
||||
let mut observer_last_sent = 0usize;
|
||||
let mut observer_current_len = 0usize;
|
||||
let mut observer_bootstrap_len = 0usize;
|
||||
let cross_check_enabled = config.tui.auto_drive.cross_check_enabled;
|
||||
let observer_cadence_enabled = config.tui.auto_drive.observer_enabled;
|
||||
let observer_thread_enabled = observer_cadence_enabled || cross_check_enabled;
|
||||
let mut observer_bootstrapped = !observer_thread_enabled;
|
||||
let mut cross_check_restart_conversation: Option<Vec<ResponseItem>> = None;
|
||||
let mut observer_handle = if observer_thread_enabled {
|
||||
match start_auto_observer(client.clone(), observer_cadence, cmd_tx.clone()) {
|
||||
Ok(handle) => Some(handle),
|
||||
Err(err) => {
|
||||
tracing::error!("failed to start auto observer: {err:#}");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(handle) = observer_handle.as_ref() {
|
||||
if handle
|
||||
.tx
|
||||
.send(AutoObserverCommand::Bootstrap {
|
||||
goal_text: goal_text.clone(),
|
||||
environment_details: environment_details.clone(),
|
||||
tools: read_only_tools.clone(),
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
warn!("failed to trigger observer bootstrap");
|
||||
} else {
|
||||
observer_bootstrapped = false;
|
||||
}
|
||||
}
|
||||
let observer_cadence_interval = if observer_cadence_enabled && observer_cadence != 0 {
|
||||
observer_handle
|
||||
.as_ref()
|
||||
.map(|handle: &AutoObserverHandle| handle.cadence() as u64)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
loop {
|
||||
if stopped {
|
||||
@@ -822,19 +781,17 @@ fn run_auto_loop(
|
||||
continue;
|
||||
}
|
||||
|
||||
let conv_for_observer = conv.clone();
|
||||
observer_current_len = conv_for_observer.len();
|
||||
let developer_intro =
|
||||
compose_developer_intro(&base_developer_intro, &observer_guidance);
|
||||
let developer_intro = base_developer_intro.as_str();
|
||||
let mut retry_conversation = Some(conv.clone());
|
||||
match request_coordinator_decision(
|
||||
&runtime,
|
||||
client.as_ref(),
|
||||
developer_intro.as_str(),
|
||||
developer_intro,
|
||||
&primary_goal_message,
|
||||
&schema,
|
||||
conv,
|
||||
auto_instructions.as_deref(),
|
||||
&app_event_tx,
|
||||
&event_tx,
|
||||
&cancel_token,
|
||||
) {
|
||||
Ok(ParsedCoordinatorDecision {
|
||||
@@ -846,67 +803,14 @@ fn run_auto_loop(
|
||||
mut agents,
|
||||
mut response_items,
|
||||
}) => {
|
||||
retry_conversation.take();
|
||||
if !include_agents {
|
||||
agents_timing = None;
|
||||
agents.clear();
|
||||
}
|
||||
consecutive_decision_failures = 0;
|
||||
let (turn_cli_prompt, turn_cli_context) = if let Some(cli_action) = &cli {
|
||||
let prompt = Some(cli_action.prompt.clone());
|
||||
let context = cli_action
|
||||
.context
|
||||
.as_ref()
|
||||
.map(|ctx| ctx.trim().to_string())
|
||||
.filter(|ctx| !ctx.is_empty());
|
||||
(prompt, context)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
_last_cli_prompt = turn_cli_prompt.clone();
|
||||
_last_cli_context = turn_cli_context.clone();
|
||||
|
||||
let mut latest_summary = progress_current.clone();
|
||||
if latest_summary.is_none() {
|
||||
latest_summary = progress_past.clone();
|
||||
}
|
||||
if let Some(summary_text) = latest_summary.clone() {
|
||||
_last_progress_summary = Some(summary_text);
|
||||
} else {
|
||||
_last_progress_summary = None;
|
||||
}
|
||||
|
||||
let cli_prompt_for_observer = turn_cli_prompt.as_deref();
|
||||
|
||||
if matches!(status, AutoCoordinatorStatus::Continue) {
|
||||
if observer_bootstrapped {
|
||||
let slice_start = observer_last_sent.min(observer_current_len);
|
||||
let delta_slice = conv_for_observer[slice_start..].to_vec();
|
||||
let conversation = build_observer_conversation(
|
||||
delta_slice,
|
||||
cli_prompt_for_observer,
|
||||
);
|
||||
if !conversation.is_empty() {
|
||||
if let (Some(handle), Some(cadence)) =
|
||||
(observer_handle.as_ref(), observer_cadence_interval)
|
||||
{
|
||||
if should_trigger_observer(requests_completed, cadence) {
|
||||
observer_last_sent = observer_current_len;
|
||||
let trigger = ObserverTrigger {
|
||||
conversation,
|
||||
goal_text: goal_text.clone(),
|
||||
environment_details: environment_details.clone(),
|
||||
reason: ObserverReason::Cadence,
|
||||
turn_snapshot: None,
|
||||
tools: read_only_tools.clone(),
|
||||
};
|
||||
if handle.tx.send(AutoObserverCommand::Trigger(trigger)).is_err() {
|
||||
warn!("failed to trigger auto observer");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let event = AppEvent::AutoCoordinatorDecision {
|
||||
let event = AutoCoordinatorEvent::Decision {
|
||||
status,
|
||||
progress_past,
|
||||
progress_current,
|
||||
@@ -915,7 +819,7 @@ fn run_auto_loop(
|
||||
agents: agents.iter().map(agent_action_to_event).collect(),
|
||||
transcript: std::mem::take(&mut response_items),
|
||||
};
|
||||
app_event_tx.send(event);
|
||||
event_tx.send(event);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -929,45 +833,7 @@ fn run_auto_loop(
|
||||
transcript: response_items,
|
||||
};
|
||||
|
||||
if observer_bootstrapped
|
||||
&& matches!(decision_event.status, AutoCoordinatorStatus::Success)
|
||||
&& cross_check_enabled
|
||||
&& !awaiting_cross_check
|
||||
{
|
||||
if let Some(handle) = observer_handle.as_ref() {
|
||||
let start = observer_bootstrap_len.min(observer_current_len);
|
||||
let delta_slice = conv_for_observer[start..].to_vec();
|
||||
let conversation = build_observer_conversation(
|
||||
delta_slice,
|
||||
cli_prompt_for_observer,
|
||||
);
|
||||
match handle.tx.send(AutoObserverCommand::BeginCrossCheck {
|
||||
conversation,
|
||||
_from_index: start,
|
||||
forced: true,
|
||||
summary: latest_summary.clone(),
|
||||
focus: None,
|
||||
tools: full_access_tools.clone(),
|
||||
}) {
|
||||
Ok(()) => {
|
||||
awaiting_cross_check = true;
|
||||
observer_last_sent = observer_current_len;
|
||||
cross_check_restart_conversation = Some(conv_for_observer.clone());
|
||||
pending_success = Some(decision_event);
|
||||
continue;
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("failed to trigger cross-check observer: {err:#}");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!(
|
||||
"cross-check enabled but observer thread unavailable; finishing without cross-check"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
app_event_tx.send(decision_event.into_event());
|
||||
event_tx.send(decision_event.into_event());
|
||||
stopped = true;
|
||||
continue;
|
||||
}
|
||||
@@ -987,18 +853,15 @@ fn run_auto_loop(
|
||||
MAX_DECISION_RECOVERY_ATTEMPTS,
|
||||
err
|
||||
);
|
||||
if let Some(hint) = recoverable.guidance.as_deref() {
|
||||
push_unique_guidance(&mut observer_guidance, hint);
|
||||
}
|
||||
let message = format!(
|
||||
"Coordinator response invalid (attempt {attempt}/{MAX_DECISION_RECOVERY_ATTEMPTS}): {}. Retrying…",
|
||||
recoverable.summary
|
||||
);
|
||||
let _ = app_event_tx.send(AppEvent::AutoCoordinatorThinking {
|
||||
let _ = event_tx.send(AutoCoordinatorEvent::Thinking {
|
||||
delta: message,
|
||||
summary_index: None,
|
||||
});
|
||||
pending_conversation = Some(conv_for_observer);
|
||||
pending_conversation = retry_conversation.take();
|
||||
continue;
|
||||
}
|
||||
warn!(
|
||||
@@ -1008,7 +871,7 @@ fn run_auto_loop(
|
||||
);
|
||||
}
|
||||
consecutive_decision_failures = 0;
|
||||
let event = AppEvent::AutoCoordinatorDecision {
|
||||
let event = AutoCoordinatorEvent::Decision {
|
||||
status: AutoCoordinatorStatus::Failed,
|
||||
progress_past: None,
|
||||
progress_current: Some(format!("Coordinator error: {err}")),
|
||||
@@ -1017,7 +880,7 @@ fn run_auto_loop(
|
||||
agents: Vec::new(),
|
||||
transcript: Vec::new(),
|
||||
};
|
||||
app_event_tx.send(event);
|
||||
event_tx.send(event);
|
||||
stopped = true;
|
||||
continue;
|
||||
}
|
||||
@@ -1026,18 +889,18 @@ fn run_auto_loop(
|
||||
|
||||
match cmd_rx.recv() {
|
||||
Ok(AutoCoordinatorCommand::HandleUserPrompt { _prompt, conversation }) => {
|
||||
let developer_intro = compose_developer_intro(&base_developer_intro, &observer_guidance);
|
||||
let developer_intro = base_developer_intro.as_str();
|
||||
let mut updated_conversation = conversation.clone();
|
||||
let schema = user_turn_schema();
|
||||
match request_user_turn_decision(
|
||||
&runtime,
|
||||
client.as_ref(),
|
||||
developer_intro.as_str(),
|
||||
developer_intro,
|
||||
&primary_goal_message,
|
||||
&schema,
|
||||
updated_conversation.clone(),
|
||||
auto_instructions.as_deref(),
|
||||
&app_event_tx,
|
||||
&event_tx,
|
||||
&cancel_token,
|
||||
) {
|
||||
Ok((user_response, cli_command)) => {
|
||||
@@ -1045,14 +908,14 @@ fn run_auto_loop(
|
||||
updated_conversation.push(make_message("assistant", response_text.clone()));
|
||||
}
|
||||
pending_conversation = Some(updated_conversation);
|
||||
app_event_tx.send(AppEvent::AutoCoordinatorUserReply {
|
||||
event_tx.send(AutoCoordinatorEvent::UserReply {
|
||||
user_response,
|
||||
cli_command,
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("failed to handle coordinator user prompt: {err:#}");
|
||||
app_event_tx.send(AppEvent::AutoCoordinatorUserReply {
|
||||
event_tx.send(AutoCoordinatorEvent::UserReply {
|
||||
user_response: Some(format!("Coordinator error: {err}")),
|
||||
cli_command: None,
|
||||
});
|
||||
@@ -1064,182 +927,14 @@ fn run_auto_loop(
|
||||
consecutive_decision_failures = 0;
|
||||
pending_conversation = Some(conv);
|
||||
}
|
||||
Ok(AutoCoordinatorCommand::ObserverResult(outcome)) => {
|
||||
let ObserverOutcome {
|
||||
mode,
|
||||
status,
|
||||
replace_message,
|
||||
additional_instructions,
|
||||
telemetry,
|
||||
reason,
|
||||
conversation,
|
||||
turn_snapshot: _turn_snapshot,
|
||||
raw_output,
|
||||
parsed_response,
|
||||
} = outcome;
|
||||
|
||||
if let Some(instr) = additional_instructions.as_deref() {
|
||||
push_unique_guidance(&mut observer_guidance, instr);
|
||||
}
|
||||
|
||||
_observer_telemetry = telemetry.clone();
|
||||
let report_raw = raw_output.clone();
|
||||
let report_parsed = parsed_response.clone();
|
||||
|
||||
let event = AppEvent::AutoObserverReport {
|
||||
mode,
|
||||
status,
|
||||
telemetry,
|
||||
replace_message: replace_message.clone(),
|
||||
additional_instructions: additional_instructions.clone(),
|
||||
reason: AutoObserverReason::from(reason.clone()),
|
||||
conversation: conversation.clone(),
|
||||
raw_output: report_raw.clone(),
|
||||
parsed_response: report_parsed.clone(),
|
||||
};
|
||||
app_event_tx.send(event);
|
||||
|
||||
if matches!(mode, ObserverMode::Bootstrap) {
|
||||
observer_bootstrapped = true;
|
||||
observer_current_len = conversation.len();
|
||||
observer_bootstrap_len = observer_current_len;
|
||||
observer_last_sent = observer_current_len;
|
||||
let baseline = report_raw
|
||||
.or_else(|| replace_message.clone())
|
||||
.or_else(|| additional_instructions.clone());
|
||||
let ready_event = AppEvent::AutoObserverReady {
|
||||
baseline_summary: baseline,
|
||||
bootstrap_len: conversation.len(),
|
||||
};
|
||||
app_event_tx.send(ready_event);
|
||||
}
|
||||
|
||||
if awaiting_cross_check && matches!(mode, ObserverMode::CrossCheck) {
|
||||
if let ObserverReason::CrossCheck { summary, .. } = reason {
|
||||
awaiting_cross_check = false;
|
||||
if matches!(status, AutoObserverStatus::Ok) {
|
||||
cross_check_restart_conversation = None;
|
||||
if let Some(decision) = pending_success.take() {
|
||||
app_event_tx.send(decision.into_event());
|
||||
stopped = true;
|
||||
continue;
|
||||
} else {
|
||||
warn!("cross-check completed but no pending success decision to finalize");
|
||||
}
|
||||
} else {
|
||||
pending_success = None;
|
||||
let mut detail = additional_instructions
|
||||
.as_ref()
|
||||
.and_then(|text| {
|
||||
let trimmed = text.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||
});
|
||||
if detail.is_none() {
|
||||
detail = summary
|
||||
.as_ref()
|
||||
.and_then(|text| {
|
||||
let trimmed = text.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||
});
|
||||
}
|
||||
let failure_message = detail
|
||||
.clone()
|
||||
.map(|text| format!("Cross-check failed: {text}"))
|
||||
.unwrap_or_else(|| "Cross-check failed".to_string());
|
||||
let _ = app_event_tx.send(AppEvent::AutoCoordinatorThinking {
|
||||
delta: CROSS_CHECK_RESTART_BANNER.to_string(),
|
||||
summary_index: None,
|
||||
});
|
||||
let _ = app_event_tx.send(AppEvent::AutoCoordinatorThinking {
|
||||
delta: failure_message.clone(),
|
||||
summary_index: None,
|
||||
});
|
||||
|
||||
let mut restart_conversation = cross_check_restart_conversation
|
||||
.take()
|
||||
.unwrap_or_else(|| conversation.clone());
|
||||
|
||||
if let Some(text) = detail {
|
||||
let prompt = format!(
|
||||
"Observer cross-check flagged unresolved issues:\n{text}\nPlease address these items before retrying completion."
|
||||
);
|
||||
restart_conversation.push(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText { text: prompt }],
|
||||
});
|
||||
}
|
||||
|
||||
if restart_conversation.is_empty() {
|
||||
restart_conversation = conversation;
|
||||
}
|
||||
|
||||
pending_conversation = Some(restart_conversation);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Ok(AutoCoordinatorCommand::ObserverThinking {
|
||||
mode,
|
||||
delta,
|
||||
summary_index,
|
||||
}) => {
|
||||
let _ = app_event_tx.send(AppEvent::AutoObserverThinking {
|
||||
mode,
|
||||
delta,
|
||||
summary_index,
|
||||
});
|
||||
}
|
||||
Ok(AutoCoordinatorCommand::ObserverBootstrapLen(len)) => {
|
||||
observer_bootstrap_len = len;
|
||||
observer_last_sent = observer_bootstrap_len.min(observer_current_len);
|
||||
}
|
||||
Ok(AutoCoordinatorCommand::ResetObserver) => {
|
||||
observer_bootstrapped = false;
|
||||
observer_last_sent = 0;
|
||||
observer_current_len = 0;
|
||||
observer_bootstrap_len = 0;
|
||||
awaiting_cross_check = false;
|
||||
pending_success = None;
|
||||
}
|
||||
Ok(AutoCoordinatorCommand::Stop) | Err(_) => {
|
||||
stopped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(handle) = observer_handle.take() {
|
||||
let _ = handle.tx.send(AutoObserverCommand::Stop);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn compose_developer_intro(base: &str, guidance: &[String]) -> String {
|
||||
if guidance.is_empty() {
|
||||
return base.to_string();
|
||||
}
|
||||
|
||||
let mut intro = String::with_capacity(base.len() + guidance.len() * 64);
|
||||
intro.push_str(base);
|
||||
intro.push_str("\n\n**Observer Guidance**\n");
|
||||
for hint in guidance {
|
||||
let trimmed = hint.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
intro.push_str("- ");
|
||||
intro.push_str(trimmed);
|
||||
intro.push('\n');
|
||||
}
|
||||
intro
|
||||
}
|
||||
|
||||
fn should_trigger_observer(requests_completed: u64, cadence: u64) -> bool {
|
||||
cadence != 0 && requests_completed > 0 && requests_completed % cadence == 0
|
||||
}
|
||||
fn read_coordinator_prompt(_config: &Config) -> Option<String> {
|
||||
match fs::read_to_string(COORDINATOR_PROMPT_PATH) {
|
||||
Ok(text) => {
|
||||
@@ -1260,17 +955,6 @@ fn read_coordinator_prompt(_config: &Config) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ObserverReason> for AutoObserverReason {
|
||||
fn from(reason: ObserverReason) -> Self {
|
||||
match reason {
|
||||
ObserverReason::Cadence => AutoObserverReason::Cadence,
|
||||
ObserverReason::CrossCheck { forced, summary, focus } => {
|
||||
AutoObserverReason::CrossCheck { forced, summary, focus }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_developer_message(
|
||||
goal_text: &str,
|
||||
environment_details: &str,
|
||||
@@ -1559,7 +1243,7 @@ fn request_coordinator_decision(
|
||||
schema: &Value,
|
||||
conversation: Vec<ResponseItem>,
|
||||
auto_instructions: Option<&str>,
|
||||
app_event_tx: &AppEventSender,
|
||||
event_tx: &AutoCoordinatorEventSender,
|
||||
cancel_token: &CancellationToken,
|
||||
) -> Result<ParsedCoordinatorDecision> {
|
||||
let (raw, response_items) = request_decision(
|
||||
@@ -1570,7 +1254,7 @@ fn request_coordinator_decision(
|
||||
schema,
|
||||
&conversation,
|
||||
auto_instructions,
|
||||
app_event_tx,
|
||||
event_tx,
|
||||
cancel_token,
|
||||
)?;
|
||||
let (mut decision, value) = parse_decision(&raw)?;
|
||||
@@ -1587,7 +1271,7 @@ fn request_decision(
|
||||
schema: &Value,
|
||||
conversation: &[ResponseItem],
|
||||
auto_instructions: Option<&str>,
|
||||
app_event_tx: &AppEventSender,
|
||||
event_tx: &AutoCoordinatorEventSender,
|
||||
cancel_token: &CancellationToken,
|
||||
) -> Result<(String, Vec<ResponseItem>)> {
|
||||
match request_decision_with_model(
|
||||
@@ -1598,7 +1282,7 @@ fn request_decision(
|
||||
schema,
|
||||
conversation,
|
||||
auto_instructions,
|
||||
app_event_tx,
|
||||
event_tx,
|
||||
cancel_token,
|
||||
MODEL_SLUG,
|
||||
) {
|
||||
@@ -1620,7 +1304,7 @@ fn request_decision(
|
||||
schema,
|
||||
conversation,
|
||||
auto_instructions,
|
||||
app_event_tx,
|
||||
event_tx,
|
||||
cancel_token,
|
||||
&fallback_slug,
|
||||
)
|
||||
@@ -1645,7 +1329,7 @@ fn request_user_turn_decision(
|
||||
schema: &Value,
|
||||
conversation: Vec<ResponseItem>,
|
||||
auto_instructions: Option<&str>,
|
||||
app_event_tx: &AppEventSender,
|
||||
event_tx: &AutoCoordinatorEventSender,
|
||||
cancel_token: &CancellationToken,
|
||||
) -> Result<(Option<String>, Option<String>)> {
|
||||
let (raw, _response_items) = request_decision(
|
||||
@@ -1656,7 +1340,7 @@ fn request_user_turn_decision(
|
||||
schema,
|
||||
&conversation,
|
||||
auto_instructions,
|
||||
app_event_tx,
|
||||
event_tx,
|
||||
cancel_token,
|
||||
)?;
|
||||
let (user_response, cli_command) = parse_user_turn_reply(&raw)?;
|
||||
@@ -1671,7 +1355,7 @@ fn request_decision_with_model(
|
||||
schema: &Value,
|
||||
conversation: &[ResponseItem],
|
||||
auto_instructions: Option<&str>,
|
||||
app_event_tx: &AppEventSender,
|
||||
event_tx: &AutoCoordinatorEventSender,
|
||||
cancel_token: &CancellationToken,
|
||||
model_slug: &str,
|
||||
) -> Result<RequestStreamResult> {
|
||||
@@ -1680,7 +1364,7 @@ fn request_decision_with_model(
|
||||
let schema = schema.clone();
|
||||
let conversation: Vec<ResponseItem> = conversation.to_vec();
|
||||
let auto_instructions = auto_instructions.map(|text| text.to_string());
|
||||
let tx = app_event_tx.clone();
|
||||
let tx = event_tx.clone();
|
||||
let cancel = cancel_token.clone();
|
||||
let classify = |error: &anyhow::Error| classify_model_error(error);
|
||||
let options = RetryOptions::with_defaults(MAX_RETRY_ELAPSED);
|
||||
@@ -1739,7 +1423,7 @@ fn request_decision_with_model(
|
||||
let cleaned = strip_role_prefix(&delta);
|
||||
reasoning_delta_accumulator.push_str(cleaned);
|
||||
let message = cleaned.to_string();
|
||||
tx_inner.send(AppEvent::AutoCoordinatorThinking {
|
||||
tx_inner.send(AutoCoordinatorEvent::Thinking {
|
||||
delta: message,
|
||||
summary_index,
|
||||
});
|
||||
@@ -1748,7 +1432,7 @@ fn request_decision_with_model(
|
||||
let cleaned = strip_role_prefix(&delta);
|
||||
reasoning_delta_accumulator.push_str(cleaned);
|
||||
let message = cleaned.to_string();
|
||||
tx_inner.send(AppEvent::AutoCoordinatorThinking {
|
||||
tx_inner.send(AutoCoordinatorEvent::Thinking {
|
||||
delta: message,
|
||||
summary_index: None,
|
||||
});
|
||||
@@ -1814,7 +1498,7 @@ fn request_decision_with_model(
|
||||
.map(|s| format!("; next attempt at {s}"))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
let _ = tx.send(AppEvent::AutoCoordinatorThinking {
|
||||
let _ = tx.send(AutoCoordinatorEvent::Thinking {
|
||||
delta: message,
|
||||
summary_index: None,
|
||||
});
|
||||
@@ -2108,6 +1792,7 @@ fn find_in_chain<'a, T: std::error::Error + 'static>(error: &'a anyhow::Error) -
|
||||
|
||||
struct RecoverableDecisionError {
|
||||
summary: String,
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
guidance: Option<String>,
|
||||
}
|
||||
|
||||
@@ -2190,6 +1875,7 @@ fn classify_recoverable_decision_error(err: &anyhow::Error) -> Option<Recoverabl
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn push_unique_guidance(guidance: &mut Vec<String>, message: &str) {
|
||||
let trimmed = message.trim();
|
||||
if trimmed.is_empty() {
|
||||
+8
-8
@@ -8,14 +8,14 @@ use code_protocol::models::{ContentItem, ResponseItem};
|
||||
/// when re-seeding the coordinator conversation. `raw` captures the exact
|
||||
/// ResponseItems returned by the Auto Drive model so we can retain full
|
||||
/// reasoning output without depending on UI rendering.
|
||||
pub(crate) struct AutoDriveHistory {
|
||||
pub struct AutoDriveHistory {
|
||||
converted: Vec<ResponseItem>,
|
||||
raw: Vec<ResponseItem>,
|
||||
pending_duplicates: VecDeque<NormalizedMessage>,
|
||||
}
|
||||
|
||||
impl AutoDriveHistory {
|
||||
pub(crate) fn new() -> Self {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
converted: Vec::new(),
|
||||
raw: Vec::new(),
|
||||
@@ -25,7 +25,7 @@ impl AutoDriveHistory {
|
||||
|
||||
/// Replace the stored converted transcript. Returns any new tail items that
|
||||
/// were not present previously, preserving insertion order.
|
||||
pub(crate) fn replace_converted(&mut self, items: Vec<ResponseItem>) -> Vec<ResponseItem> {
|
||||
pub fn replace_converted(&mut self, items: Vec<ResponseItem>) -> Vec<ResponseItem> {
|
||||
let prev_len = self.converted.len();
|
||||
self.converted = items;
|
||||
let tail: Vec<_> = if self.converted.len() <= prev_len {
|
||||
@@ -114,7 +114,7 @@ impl AutoDriveHistory {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn append_raw(&mut self, items: &[ResponseItem]) {
|
||||
pub fn append_raw(&mut self, items: &[ResponseItem]) {
|
||||
if items.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -126,24 +126,24 @@ impl AutoDriveHistory {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn append_converted_tail(&mut self, items: &[ResponseItem]) {
|
||||
pub fn append_converted_tail(&mut self, items: &[ResponseItem]) {
|
||||
if items.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.raw.extend(items.iter().cloned());
|
||||
}
|
||||
|
||||
pub(crate) fn raw_snapshot(&self) -> Vec<ResponseItem> {
|
||||
pub fn raw_snapshot(&self) -> Vec<ResponseItem> {
|
||||
self.raw.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn clear(&mut self) {
|
||||
pub fn clear(&mut self) {
|
||||
self.converted.clear();
|
||||
self.raw.clear();
|
||||
self.pending_duplicates.clear();
|
||||
}
|
||||
|
||||
pub(crate) fn converted_is_empty(&self) -> bool {
|
||||
pub fn converted_is_empty(&self) -> bool {
|
||||
self.converted.is_empty()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use code_common::elapsed::format_duration;
|
||||
use code_core::protocol::ReviewContextMetadata;
|
||||
use code_core::protocol::ReviewOutputEvent;
|
||||
use code_git_tooling::GhostCommit;
|
||||
|
||||
use crate::AutoTurnAgentsAction;
|
||||
use crate::AutoTurnAgentsTiming;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum AutoContinueMode {
|
||||
Immediate,
|
||||
TenSeconds,
|
||||
SixtySeconds,
|
||||
Manual,
|
||||
}
|
||||
|
||||
impl AutoContinueMode {
|
||||
pub fn seconds(self) -> Option<u8> {
|
||||
match self {
|
||||
Self::Immediate => Some(0),
|
||||
Self::TenSeconds => Some(10),
|
||||
Self::SixtySeconds => Some(60),
|
||||
Self::Manual => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Immediate => "Immediate",
|
||||
Self::TenSeconds => "10 seconds",
|
||||
Self::SixtySeconds => "60 seconds",
|
||||
Self::Manual => "Manual approval",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cycle_forward(self) -> Self {
|
||||
match self {
|
||||
Self::Immediate => Self::TenSeconds,
|
||||
Self::TenSeconds => Self::SixtySeconds,
|
||||
Self::SixtySeconds => Self::Manual,
|
||||
Self::Manual => Self::Immediate,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cycle_backward(self) -> Self {
|
||||
match self {
|
||||
Self::Immediate => Self::Manual,
|
||||
Self::TenSeconds => Self::Immediate,
|
||||
Self::SixtySeconds => Self::TenSeconds,
|
||||
Self::Manual => Self::SixtySeconds,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AutoContinueMode {
|
||||
fn default() -> Self {
|
||||
Self::TenSeconds
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AutoRunSummary {
|
||||
pub duration: Duration,
|
||||
pub turns_completed: usize,
|
||||
pub message: Option<String>,
|
||||
pub goal: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AutoRestartState {
|
||||
pub token: u64,
|
||||
pub attempt: u32,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub struct AutoTurnReviewState {
|
||||
#[cfg_attr(not(any(test, feature = "test-helpers")), allow(dead_code))]
|
||||
pub base_commit: Option<GhostCommit>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AutoResolveState {
|
||||
pub prompt: String,
|
||||
pub hint: String,
|
||||
pub metadata: Option<ReviewContextMetadata>,
|
||||
pub attempt: u32,
|
||||
pub max_attempts: u32,
|
||||
pub phase: AutoResolvePhase,
|
||||
pub last_review: Option<ReviewOutputEvent>,
|
||||
pub last_fix_message: Option<String>,
|
||||
}
|
||||
|
||||
impl AutoResolveState {
|
||||
pub fn new(prompt: String, hint: String, metadata: Option<ReviewContextMetadata>) -> Self {
|
||||
Self {
|
||||
prompt,
|
||||
hint,
|
||||
metadata,
|
||||
attempt: 0,
|
||||
max_attempts: AUTO_RESOLVE_MAX_REVIEW_ATTEMPTS,
|
||||
phase: AutoResolvePhase::WaitingForReview,
|
||||
last_review: None,
|
||||
last_fix_message: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum AutoResolvePhase {
|
||||
WaitingForReview,
|
||||
PendingFix { review: ReviewOutputEvent },
|
||||
AwaitingFix { review: ReviewOutputEvent },
|
||||
AwaitingJudge { review: ReviewOutputEvent },
|
||||
}
|
||||
|
||||
pub const AUTO_RESTART_MAX_ATTEMPTS: u32 = 6;
|
||||
pub const AUTO_RESTART_BASE_DELAY: Duration = Duration::from_secs(5);
|
||||
pub const AUTO_RESTART_MAX_DELAY: Duration = Duration::from_secs(120);
|
||||
pub const AUTO_RESOLVE_MAX_REVIEW_ATTEMPTS: u32 = 3;
|
||||
pub const AUTO_RESOLVE_REVIEW_FOLLOWUP: &str = "This issue has been resolved. Please continue your search and return all remaining issues you find.";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AutoControllerEffect {
|
||||
RefreshUi,
|
||||
StartCountdown { countdown_id: u64, seconds: u8 },
|
||||
SubmitPrompt,
|
||||
LaunchStarted { goal: String },
|
||||
LaunchFailed { goal: String, error: String },
|
||||
StopCompleted { summary: AutoRunSummary, message: Option<String> },
|
||||
TransientPause { attempt: u32, delay: Duration, reason: String },
|
||||
ScheduleRestart { token: u64, attempt: u32, delay: Duration },
|
||||
CancelCoordinator,
|
||||
ResetHistory,
|
||||
UpdateTerminalHint { hint: Option<String> },
|
||||
SetTaskRunning { running: bool },
|
||||
EnsureInputFocus,
|
||||
ClearCoordinatorView,
|
||||
ShowGoalEntry,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub struct AutoDriveController {
|
||||
pub active: bool,
|
||||
pub goal: Option<String>,
|
||||
pub current_summary: Option<String>,
|
||||
pub current_progress_past: Option<String>,
|
||||
pub current_progress_current: Option<String>,
|
||||
pub current_cli_prompt: Option<String>,
|
||||
pub current_cli_context: Option<String>,
|
||||
pub current_display_line: Option<String>,
|
||||
pub current_display_is_summary: bool,
|
||||
pub current_reasoning_title: Option<String>,
|
||||
pub placeholder_phrase: Option<String>,
|
||||
pub thinking_prefix_stripped: bool,
|
||||
pub current_summary_index: Option<u32>,
|
||||
pub awaiting_submission: bool,
|
||||
pub waiting_for_response: bool,
|
||||
pub paused_for_manual_edit: bool,
|
||||
pub resume_after_manual_submit: bool,
|
||||
pub waiting_for_review: bool,
|
||||
pub countdown_id: u64,
|
||||
pub seconds_remaining: u8,
|
||||
pub awaiting_goal_input: bool,
|
||||
pub last_broadcast_summary: Option<String>,
|
||||
pub last_decision_summary: Option<String>,
|
||||
pub last_decision_progress_past: Option<String>,
|
||||
pub last_decision_progress_current: Option<String>,
|
||||
pub last_decision_display: Option<String>,
|
||||
pub last_decision_display_is_summary: bool,
|
||||
pub coordinator_waiting: bool,
|
||||
pub review_enabled: bool,
|
||||
pub subagents_enabled: bool,
|
||||
pub cross_check_enabled: bool,
|
||||
pub qa_automation_enabled: bool,
|
||||
pub pending_agent_actions: Vec<AutoTurnAgentsAction>,
|
||||
pub pending_agent_timing: Option<AutoTurnAgentsTiming>,
|
||||
pub continue_mode: AutoContinueMode,
|
||||
pub started_at: Option<Instant>,
|
||||
pub turns_completed: usize,
|
||||
pub last_run_summary: Option<AutoRunSummary>,
|
||||
pub waiting_for_transient_recovery: bool,
|
||||
pub pending_restart: Option<AutoRestartState>,
|
||||
pub restart_token: u64,
|
||||
pub transient_restart_attempts: u32,
|
||||
pub intro_started_at: Option<Instant>,
|
||||
pub intro_reduced_motion: bool,
|
||||
pub intro_pending: bool,
|
||||
pub elapsed_override: Option<Duration>,
|
||||
}
|
||||
|
||||
impl AutoDriveController {
|
||||
pub fn prepare_launch(
|
||||
&mut self,
|
||||
goal: String,
|
||||
review_enabled: bool,
|
||||
subagents_enabled: bool,
|
||||
cross_check_enabled: bool,
|
||||
qa_automation_enabled: bool,
|
||||
continue_mode: AutoContinueMode,
|
||||
reduced_motion: bool,
|
||||
) {
|
||||
let seed_intro = self.take_intro_pending();
|
||||
self.reset();
|
||||
if seed_intro {
|
||||
self.mark_intro_pending();
|
||||
}
|
||||
|
||||
self.review_enabled = review_enabled;
|
||||
self.subagents_enabled = subagents_enabled;
|
||||
self.cross_check_enabled = cross_check_enabled;
|
||||
self.qa_automation_enabled = qa_automation_enabled;
|
||||
self.continue_mode = continue_mode;
|
||||
self.reset_countdown();
|
||||
self.ensure_intro_timing(reduced_motion);
|
||||
self.goal = Some(goal);
|
||||
self.awaiting_goal_input = false;
|
||||
}
|
||||
|
||||
pub fn launch_succeeded(
|
||||
&mut self,
|
||||
goal: String,
|
||||
placeholder_phrase: Option<String>,
|
||||
now: Instant,
|
||||
) -> Vec<AutoControllerEffect> {
|
||||
self.active = true;
|
||||
self.started_at = Some(now);
|
||||
self.turns_completed = 0;
|
||||
self.last_run_summary = None;
|
||||
self.goal = Some(goal.clone());
|
||||
self.current_summary = None;
|
||||
self.current_progress_past = None;
|
||||
self.current_progress_current = None;
|
||||
self.current_cli_prompt = None;
|
||||
self.current_cli_context = None;
|
||||
self.current_display_line = None;
|
||||
self.current_display_is_summary = false;
|
||||
self.current_reasoning_title = None;
|
||||
self.current_summary_index = None;
|
||||
self.placeholder_phrase = placeholder_phrase;
|
||||
self.thinking_prefix_stripped = false;
|
||||
self.last_broadcast_summary = None;
|
||||
self.last_decision_progress_past = None;
|
||||
self.last_decision_progress_current = None;
|
||||
self.waiting_for_response = true;
|
||||
self.coordinator_waiting = true;
|
||||
self.reset_countdown();
|
||||
|
||||
vec![
|
||||
AutoControllerEffect::LaunchStarted { goal },
|
||||
AutoControllerEffect::RefreshUi,
|
||||
]
|
||||
}
|
||||
|
||||
pub fn launch_failed(&mut self, goal: String, error: String) -> Vec<AutoControllerEffect> {
|
||||
self.active = false;
|
||||
self.goal = None;
|
||||
self.awaiting_goal_input = true;
|
||||
self.mark_intro_pending();
|
||||
self.reset_countdown();
|
||||
|
||||
vec![
|
||||
AutoControllerEffect::LaunchFailed { goal, error },
|
||||
AutoControllerEffect::ShowGoalEntry,
|
||||
AutoControllerEffect::RefreshUi,
|
||||
]
|
||||
}
|
||||
|
||||
pub fn stop_run(
|
||||
&mut self,
|
||||
now: Instant,
|
||||
message: Option<String>,
|
||||
) -> Vec<AutoControllerEffect> {
|
||||
let duration = self
|
||||
.started_at
|
||||
.map(|start| now.saturating_duration_since(start))
|
||||
.unwrap_or_default();
|
||||
let summary = AutoRunSummary {
|
||||
duration,
|
||||
turns_completed: self.turns_completed,
|
||||
message: message.clone(),
|
||||
goal: self.goal.clone(),
|
||||
};
|
||||
|
||||
self.reset();
|
||||
self.last_run_summary = Some(summary.clone());
|
||||
self.awaiting_goal_input = true;
|
||||
|
||||
vec![
|
||||
AutoControllerEffect::CancelCoordinator,
|
||||
AutoControllerEffect::ResetHistory,
|
||||
AutoControllerEffect::ClearCoordinatorView,
|
||||
AutoControllerEffect::UpdateTerminalHint { hint: None },
|
||||
AutoControllerEffect::SetTaskRunning { running: false },
|
||||
AutoControllerEffect::EnsureInputFocus,
|
||||
AutoControllerEffect::StopCompleted { summary, message },
|
||||
AutoControllerEffect::RefreshUi,
|
||||
]
|
||||
}
|
||||
|
||||
pub fn pause_for_transient_failure(
|
||||
&mut self,
|
||||
now: Instant,
|
||||
reason: String,
|
||||
) -> Vec<AutoControllerEffect> {
|
||||
let pending_attempt = self.transient_restart_attempts.saturating_add(1);
|
||||
let truncated_reason = Self::truncate_error(&reason);
|
||||
self.waiting_for_transient_recovery = true;
|
||||
self.waiting_for_response = false;
|
||||
self.coordinator_waiting = false;
|
||||
self.awaiting_submission = false;
|
||||
self.paused_for_manual_edit = false;
|
||||
self.resume_after_manual_submit = false;
|
||||
self.waiting_for_review = false;
|
||||
self.current_cli_prompt = None;
|
||||
self.current_cli_context = None;
|
||||
self.pending_agent_actions.clear();
|
||||
self.pending_agent_timing = None;
|
||||
|
||||
if pending_attempt > AUTO_RESTART_MAX_ATTEMPTS {
|
||||
self.transient_restart_attempts = pending_attempt;
|
||||
let summary = AutoRunSummary {
|
||||
duration: self
|
||||
.started_at
|
||||
.map(|start| now.saturating_duration_since(start))
|
||||
.unwrap_or_default(),
|
||||
turns_completed: self.turns_completed,
|
||||
message: Some(format!(
|
||||
"Auto Drive stopped after {AUTO_RESTART_MAX_ATTEMPTS} reconnect attempts."
|
||||
)),
|
||||
goal: self.goal.clone(),
|
||||
};
|
||||
self.reset();
|
||||
self.last_run_summary = Some(summary.clone());
|
||||
self.awaiting_goal_input = true;
|
||||
|
||||
return vec![
|
||||
AutoControllerEffect::CancelCoordinator,
|
||||
AutoControllerEffect::ResetHistory,
|
||||
AutoControllerEffect::ClearCoordinatorView,
|
||||
AutoControllerEffect::UpdateTerminalHint { hint: None },
|
||||
AutoControllerEffect::SetTaskRunning { running: false },
|
||||
AutoControllerEffect::EnsureInputFocus,
|
||||
AutoControllerEffect::StopCompleted {
|
||||
summary,
|
||||
message: Some(format!(
|
||||
"Auto Drive stopped after {AUTO_RESTART_MAX_ATTEMPTS} reconnect attempts. Last error: {truncated_reason}"
|
||||
)),
|
||||
},
|
||||
AutoControllerEffect::RefreshUi,
|
||||
];
|
||||
}
|
||||
|
||||
self.transient_restart_attempts = pending_attempt;
|
||||
let delay = Self::auto_restart_delay(pending_attempt);
|
||||
let token = self.restart_token.wrapping_add(1);
|
||||
self.restart_token = token;
|
||||
self.pending_restart = Some(AutoRestartState {
|
||||
token,
|
||||
attempt: pending_attempt,
|
||||
reason: truncated_reason.clone(),
|
||||
});
|
||||
|
||||
let human_delay = format_duration(delay);
|
||||
self.current_display_line = Some(format!(
|
||||
"Waiting for connection… retrying in {human_delay} (attempt {pending_attempt}/{AUTO_RESTART_MAX_ATTEMPTS})"
|
||||
));
|
||||
self.current_display_is_summary = true;
|
||||
self.current_progress_current = Some(format!("Last error: {truncated_reason}"));
|
||||
self.current_progress_past = None;
|
||||
self.placeholder_phrase = Some("Waiting for connection…".to_string());
|
||||
self.thinking_prefix_stripped = false;
|
||||
|
||||
vec![
|
||||
AutoControllerEffect::CancelCoordinator,
|
||||
AutoControllerEffect::SetTaskRunning { running: false },
|
||||
AutoControllerEffect::UpdateTerminalHint {
|
||||
hint: Some("Press Esc again to exit Auto Drive".to_string()),
|
||||
},
|
||||
AutoControllerEffect::TransientPause {
|
||||
attempt: pending_attempt,
|
||||
delay,
|
||||
reason: truncated_reason,
|
||||
},
|
||||
AutoControllerEffect::ScheduleRestart {
|
||||
token,
|
||||
attempt: pending_attempt,
|
||||
delay,
|
||||
},
|
||||
AutoControllerEffect::RefreshUi,
|
||||
]
|
||||
}
|
||||
|
||||
pub fn schedule_cli_prompt(&mut self, prompt_text: String) -> Vec<AutoControllerEffect> {
|
||||
self.current_cli_prompt = Some(prompt_text);
|
||||
self.awaiting_submission = true;
|
||||
self.reset_countdown();
|
||||
self.countdown_id = self.countdown_id.wrapping_add(1);
|
||||
let countdown_id = self.countdown_id;
|
||||
let countdown = self.countdown_seconds();
|
||||
self.seconds_remaining = countdown.unwrap_or(0);
|
||||
|
||||
let mut effects = vec![AutoControllerEffect::RefreshUi];
|
||||
if let Some(seconds) = countdown {
|
||||
effects.push(AutoControllerEffect::StartCountdown {
|
||||
countdown_id,
|
||||
seconds,
|
||||
});
|
||||
}
|
||||
effects
|
||||
}
|
||||
|
||||
pub fn update_continue_mode(&mut self, mode: AutoContinueMode) -> Vec<AutoControllerEffect> {
|
||||
self.continue_mode = mode;
|
||||
self.reset_countdown();
|
||||
self.seconds_remaining = self.countdown_seconds().unwrap_or(0);
|
||||
|
||||
let mut effects = vec![AutoControllerEffect::RefreshUi];
|
||||
if self.awaiting_submission && !self.paused_for_manual_edit {
|
||||
self.countdown_id = self.countdown_id.wrapping_add(1);
|
||||
let countdown_id = self.countdown_id;
|
||||
let countdown = self.countdown_seconds();
|
||||
if let Some(seconds) = countdown {
|
||||
effects.push(AutoControllerEffect::StartCountdown {
|
||||
countdown_id,
|
||||
seconds,
|
||||
});
|
||||
}
|
||||
}
|
||||
effects
|
||||
}
|
||||
|
||||
pub fn handle_countdown_tick(
|
||||
&mut self,
|
||||
countdown_id: u64,
|
||||
seconds_left: u8,
|
||||
) -> Vec<AutoControllerEffect> {
|
||||
if !self.active
|
||||
|| countdown_id != self.countdown_id
|
||||
|| !self.awaiting_submission
|
||||
|| self.paused_for_manual_edit
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
self.seconds_remaining = seconds_left;
|
||||
if seconds_left == 0 {
|
||||
vec![AutoControllerEffect::SubmitPrompt]
|
||||
} else {
|
||||
vec![AutoControllerEffect::RefreshUi]
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
let review_enabled = self.review_enabled;
|
||||
let subagents_enabled = self.subagents_enabled;
|
||||
let cross_check_enabled = self.cross_check_enabled;
|
||||
let qa_automation_enabled = self.qa_automation_enabled;
|
||||
let continue_mode = self.continue_mode;
|
||||
let intro_pending = self.intro_pending;
|
||||
let intro_started_at = self.intro_started_at;
|
||||
let intro_reduced_motion = self.intro_reduced_motion;
|
||||
let elapsed_override = self.elapsed_override;
|
||||
|
||||
*self = Self::default();
|
||||
|
||||
self.review_enabled = review_enabled;
|
||||
self.subagents_enabled = subagents_enabled;
|
||||
self.cross_check_enabled = cross_check_enabled;
|
||||
self.qa_automation_enabled = qa_automation_enabled;
|
||||
self.continue_mode = continue_mode;
|
||||
self.seconds_remaining = self.continue_mode.seconds().unwrap_or(0);
|
||||
self.intro_pending = intro_pending;
|
||||
self.intro_started_at = intro_started_at;
|
||||
self.intro_reduced_motion = intro_reduced_motion;
|
||||
self.elapsed_override = elapsed_override;
|
||||
}
|
||||
|
||||
pub fn reset_intro_timing(&mut self) {
|
||||
self.intro_started_at = None;
|
||||
self.intro_reduced_motion = false;
|
||||
}
|
||||
|
||||
pub fn ensure_intro_timing(&mut self, reduced_motion: bool) {
|
||||
if self.intro_started_at.is_none() {
|
||||
self.intro_started_at = Some(Instant::now());
|
||||
}
|
||||
self.intro_reduced_motion = reduced_motion;
|
||||
}
|
||||
|
||||
pub fn mark_intro_pending(&mut self) {
|
||||
self.intro_pending = true;
|
||||
}
|
||||
|
||||
pub fn take_intro_pending(&mut self) -> bool {
|
||||
if self.intro_pending {
|
||||
self.intro_pending = false;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn countdown_active(&self) -> bool {
|
||||
self.awaiting_submission
|
||||
&& !self.paused_for_manual_edit
|
||||
&& self
|
||||
.countdown_seconds()
|
||||
.map(|seconds| seconds > 0)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn countdown_seconds(&self) -> Option<u8> {
|
||||
self.continue_mode.seconds()
|
||||
}
|
||||
|
||||
pub fn reset_countdown(&mut self) {
|
||||
self.seconds_remaining = self.countdown_seconds().unwrap_or(0);
|
||||
}
|
||||
|
||||
fn auto_restart_delay(attempt: u32) -> Duration {
|
||||
if attempt == 0 {
|
||||
return AUTO_RESTART_BASE_DELAY.min(AUTO_RESTART_MAX_DELAY);
|
||||
}
|
||||
let exponent = attempt.saturating_sub(1).min(5);
|
||||
let multiplier = 1u32 << exponent;
|
||||
let mut delay = AUTO_RESTART_BASE_DELAY.saturating_mul(multiplier);
|
||||
if delay > AUTO_RESTART_MAX_DELAY {
|
||||
delay = AUTO_RESTART_MAX_DELAY;
|
||||
}
|
||||
delay
|
||||
}
|
||||
|
||||
fn truncate_error(reason: &str) -> String {
|
||||
const MAX_LEN: usize = 160;
|
||||
let text = reason.trim();
|
||||
if text.len() <= MAX_LEN {
|
||||
return text.to_string();
|
||||
}
|
||||
let mut truncated = text.chars().take(MAX_LEN).collect::<String>();
|
||||
truncated.push('…');
|
||||
truncated
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
use anyhow::Context;
|
||||
use serde_json::Value;
|
||||
|
||||
pub(crate) fn user_turn_schema() -> Value {
|
||||
pub fn user_turn_schema() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
@@ -23,7 +23,7 @@ pub(crate) fn user_turn_schema() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn parse_user_turn_reply(raw: &str) -> anyhow::Result<(Option<String>, Option<String>)> {
|
||||
pub fn parse_user_turn_reply(raw: &str) -> anyhow::Result<(Option<String>, Option<String>)> {
|
||||
let value: Value = serde_json::from_str(raw)
|
||||
.context("parsing coordinator user turn JSON")?;
|
||||
let obj = value
|
||||
@@ -0,0 +1,52 @@
|
||||
mod auto_coordinator;
|
||||
mod auto_drive_history;
|
||||
mod coordinator_router;
|
||||
mod coordinator_user_schema;
|
||||
mod controller;
|
||||
mod retry;
|
||||
|
||||
#[cfg(feature = "dev-faults")]
|
||||
mod faults;
|
||||
|
||||
pub use auto_coordinator::{
|
||||
start_auto_coordinator,
|
||||
AutoCoordinatorCommand,
|
||||
AutoCoordinatorEvent,
|
||||
AutoCoordinatorEventSender,
|
||||
AutoCoordinatorHandle,
|
||||
AutoCoordinatorStatus,
|
||||
AutoTurnAgentsAction,
|
||||
AutoTurnAgentsTiming,
|
||||
AutoTurnCliAction,
|
||||
TurnConfig,
|
||||
TurnDescriptor,
|
||||
TurnMode,
|
||||
MODEL_SLUG,
|
||||
};
|
||||
|
||||
pub use controller::{
|
||||
AutoContinueMode,
|
||||
AutoControllerEffect,
|
||||
AutoDriveController,
|
||||
AutoResolvePhase,
|
||||
AutoResolveState,
|
||||
AutoRestartState,
|
||||
AutoRunSummary,
|
||||
AutoTurnReviewState,
|
||||
AUTO_RESTART_BASE_DELAY,
|
||||
AUTO_RESTART_MAX_ATTEMPTS,
|
||||
AUTO_RESTART_MAX_DELAY,
|
||||
AUTO_RESOLVE_MAX_REVIEW_ATTEMPTS,
|
||||
AUTO_RESOLVE_REVIEW_FOLLOWUP,
|
||||
};
|
||||
|
||||
pub use auto_drive_history::AutoDriveHistory;
|
||||
pub use coordinator_router::{
|
||||
route_user_message,
|
||||
CoordinatorContext,
|
||||
CoordinatorRouterResponse,
|
||||
};
|
||||
pub use coordinator_user_schema::{
|
||||
parse_user_turn_reply,
|
||||
user_turn_schema,
|
||||
};
|
||||
@@ -242,6 +242,9 @@ pub struct Config {
|
||||
/// Collection of settings that are specific to the TUI.
|
||||
pub tui: Tui,
|
||||
|
||||
/// Shared Auto Drive defaults.
|
||||
pub auto_drive: AutoDriveSettings,
|
||||
|
||||
/// Path to the `codex-linux-sandbox` executable. This must be set if
|
||||
/// [`crate::exec::SandboxType::LinuxSeccomp`] is used. Note that this
|
||||
/// cannot be set in the config file: it must be set in code via
|
||||
@@ -981,8 +984,8 @@ pub fn set_tui_review_auto_resolve(code_home: &Path, enabled: bool) -> anyhow::R
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persist Auto Drive defaults under `[tui.auto_drive]`.
|
||||
pub fn set_tui_auto_drive_settings(
|
||||
/// Persist Auto Drive defaults under `[auto_drive]`.
|
||||
pub fn set_auto_drive_settings(
|
||||
code_home: &Path,
|
||||
settings: &AutoDriveSettings,
|
||||
) -> anyhow::Result<()> {
|
||||
@@ -995,14 +998,20 @@ pub fn set_tui_auto_drive_settings(
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
doc["tui"]["auto_drive"]["review_enabled"] = toml_edit::value(settings.review_enabled);
|
||||
doc["tui"]["auto_drive"]["agents_enabled"] = toml_edit::value(settings.agents_enabled);
|
||||
doc["tui"]["auto_drive"]["qa_automation_enabled"] =
|
||||
if let Some(tui_tbl) = doc["tui"].as_table_mut() {
|
||||
tui_tbl.remove("auto_drive");
|
||||
}
|
||||
|
||||
doc["auto_drive"]["review_enabled"] = toml_edit::value(settings.review_enabled);
|
||||
doc["auto_drive"]["agents_enabled"] = toml_edit::value(settings.agents_enabled);
|
||||
doc["auto_drive"]["qa_automation_enabled"] =
|
||||
toml_edit::value(settings.qa_automation_enabled);
|
||||
doc["tui"]["auto_drive"]["cross_check_enabled"] =
|
||||
doc["auto_drive"]["cross_check_enabled"] =
|
||||
toml_edit::value(settings.cross_check_enabled);
|
||||
doc["tui"]["auto_drive"]["observer_enabled"] =
|
||||
doc["auto_drive"]["observer_enabled"] =
|
||||
toml_edit::value(settings.observer_enabled);
|
||||
doc["auto_drive"]["coordinator_routing"] =
|
||||
toml_edit::value(settings.coordinator_routing);
|
||||
|
||||
let mode_str = match settings.continue_mode {
|
||||
AutoDriveContinueMode::Immediate => "immediate",
|
||||
@@ -1010,7 +1019,7 @@ pub fn set_tui_auto_drive_settings(
|
||||
AutoDriveContinueMode::SixtySeconds => "sixty-seconds",
|
||||
AutoDriveContinueMode::Manual => "manual",
|
||||
};
|
||||
doc["tui"]["auto_drive"]["continue_mode"] = toml_edit::value(mode_str);
|
||||
doc["auto_drive"]["continue_mode"] = toml_edit::value(mode_str);
|
||||
|
||||
std::fs::create_dir_all(code_home)?;
|
||||
let tmp_file = NamedTempFile::new_in(code_home)?;
|
||||
@@ -1020,6 +1029,16 @@ pub fn set_tui_auto_drive_settings(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Legacy helper: persist Auto Drive defaults under `[auto_drive]` while
|
||||
/// accepting the former API surface.
|
||||
#[deprecated(note = "use set_auto_drive_settings instead")]
|
||||
pub fn set_tui_auto_drive_settings(
|
||||
code_home: &Path,
|
||||
settings: &AutoDriveSettings,
|
||||
) -> anyhow::Result<()> {
|
||||
set_auto_drive_settings(code_home, settings)
|
||||
}
|
||||
|
||||
/// Persist the GitHub workflow check preference under `[github].check_workflows_on_push`.
|
||||
pub fn set_github_check_on_push(code_home: &Path, enabled: bool) -> anyhow::Result<()> {
|
||||
let config_path = code_home.join(CONFIG_TOML_FILE);
|
||||
@@ -1702,6 +1721,9 @@ pub struct ConfigToml {
|
||||
/// Collection of settings that are specific to the TUI.
|
||||
pub tui: Option<Tui>,
|
||||
|
||||
/// Auto Drive behavioral defaults.
|
||||
pub auto_drive: Option<AutoDriveSettings>,
|
||||
|
||||
#[serde(default)]
|
||||
pub auto_drive_observer_cadence: Option<u32>,
|
||||
|
||||
@@ -2287,6 +2309,11 @@ impl Config {
|
||||
history,
|
||||
file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode),
|
||||
tui: cfg.tui.clone().unwrap_or_default(),
|
||||
auto_drive: cfg
|
||||
.auto_drive
|
||||
.clone()
|
||||
.or_else(|| cfg.tui.as_ref().and_then(|t| t.auto_drive.clone()))
|
||||
.unwrap_or_default(),
|
||||
code_linux_sandbox_exe,
|
||||
active_profile: active_profile_name,
|
||||
|
||||
|
||||
@@ -666,9 +666,9 @@ pub struct Tui {
|
||||
#[serde(default)]
|
||||
pub theme: ThemeConfig,
|
||||
|
||||
/// Auto Drive behavioral defaults.
|
||||
/// Auto Drive behavioral defaults (legacy location; prefer top-level `[auto_drive]`).
|
||||
#[serde(default)]
|
||||
pub auto_drive: AutoDriveSettings,
|
||||
pub auto_drive: Option<AutoDriveSettings>,
|
||||
|
||||
/// Cached autodetect result so we can skip probing the terminal repeatedly.
|
||||
#[serde(default)]
|
||||
@@ -716,7 +716,7 @@ impl Default for Tui {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
theme: ThemeConfig::default(),
|
||||
auto_drive: AutoDriveSettings::default(),
|
||||
auto_drive: None,
|
||||
cached_terminal_background: None,
|
||||
highlight: HighlightConfig::default(),
|
||||
show_reasoning: false,
|
||||
|
||||
@@ -15,10 +15,10 @@ path = "src/lib.rs"
|
||||
default = ["code-fork"]
|
||||
# Gate verbose debug logging inside the TUI implementation.
|
||||
debug-logs = []
|
||||
dev-faults = []
|
||||
dev-faults = ["code-auto-drive-core/dev-faults"]
|
||||
# Fork-specific extensions layered on top of upstream history rendering.
|
||||
code-fork = []
|
||||
test-helpers = []
|
||||
test-helpers = ["code-auto-drive-core/test-helpers"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -44,6 +44,7 @@ code-ollama = { path = "../ollama" }
|
||||
code-protocol = { path = "../protocol" }
|
||||
code-version = { path = "../code-version" }
|
||||
code-cloud-tasks-client = { path = "../cloud-tasks-client", features = ["mock"] }
|
||||
code-auto-drive-core = { workspace = true }
|
||||
color-eyre = "0.6.3"
|
||||
crossterm = { version = "0.28.1", features = ["bracketed-paste"] }
|
||||
diffy = "0.4.2"
|
||||
|
||||
@@ -1773,7 +1773,6 @@ impl App<'_> {
|
||||
agents_enabled,
|
||||
cross_check_enabled,
|
||||
qa_automation_enabled,
|
||||
observer_enabled,
|
||||
continue_mode,
|
||||
} => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
@@ -1782,7 +1781,6 @@ impl App<'_> {
|
||||
agents_enabled,
|
||||
cross_check_enabled,
|
||||
qa_automation_enabled,
|
||||
observer_enabled,
|
||||
continue_mode,
|
||||
);
|
||||
}
|
||||
@@ -1804,16 +1802,6 @@ impl App<'_> {
|
||||
AppState::Chat { widget } => widget.submit_op(op),
|
||||
AppState::Onboarding { .. } => {}
|
||||
},
|
||||
AppEvent::AutoQaUpdate { note } => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.handle_auto_qa_update(note);
|
||||
}
|
||||
}
|
||||
AppEvent::AutoReviewRequest { summary } => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.handle_auto_review_request(summary);
|
||||
}
|
||||
}
|
||||
AppEvent::AutoCoordinatorDecision {
|
||||
status,
|
||||
progress_past,
|
||||
@@ -1858,48 +1846,6 @@ impl App<'_> {
|
||||
widget.auto_handle_restart(token, attempt);
|
||||
}
|
||||
}
|
||||
AppEvent::AutoObserverReport {
|
||||
mode,
|
||||
status,
|
||||
telemetry,
|
||||
replace_message,
|
||||
additional_instructions,
|
||||
reason,
|
||||
conversation,
|
||||
raw_output,
|
||||
parsed_response,
|
||||
} => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.auto_handle_observer_report(
|
||||
mode,
|
||||
status,
|
||||
telemetry,
|
||||
replace_message,
|
||||
additional_instructions,
|
||||
reason,
|
||||
conversation,
|
||||
raw_output,
|
||||
parsed_response,
|
||||
);
|
||||
}
|
||||
}
|
||||
AppEvent::AutoObserverThinking {
|
||||
mode,
|
||||
delta,
|
||||
summary_index,
|
||||
} => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.auto_handle_observer_thinking(mode, delta, summary_index);
|
||||
}
|
||||
}
|
||||
AppEvent::AutoObserverReady {
|
||||
baseline_summary,
|
||||
bootstrap_len,
|
||||
} => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.auto_handle_observer_ready(baseline_summary, bootstrap_len);
|
||||
}
|
||||
}
|
||||
AppEvent::PerformUndoRestore {
|
||||
commit,
|
||||
restore_files,
|
||||
|
||||
@@ -22,7 +22,6 @@ use crate::app::ChatWidgetArgs;
|
||||
use crate::chrome_launch::ChromeLaunchOption;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use code_protocol::models::ResponseItem;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::Sender as StdSender;
|
||||
@@ -78,123 +77,13 @@ pub(crate) enum BackgroundPlacement {
|
||||
BeforeNextOutput,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct AutoTurnCliAction {
|
||||
pub prompt: String,
|
||||
pub context: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum AutoTurnAgentsTiming {
|
||||
/// Launch agents and continue executing the CLI prompt while they run.
|
||||
Parallel,
|
||||
/// Launch agents and wait for their results before progressing.
|
||||
Blocking,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct AutoTurnAgentsAction {
|
||||
pub prompt: String,
|
||||
pub context: Option<String>,
|
||||
pub write: bool,
|
||||
pub write_requested: Option<bool>,
|
||||
pub models: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ObserverMode {
|
||||
Bootstrap,
|
||||
Cadence,
|
||||
CrossCheck,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum AutoCoordinatorStatus {
|
||||
Continue,
|
||||
Success,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum AutoObserverStatus {
|
||||
Ok,
|
||||
Failing,
|
||||
}
|
||||
|
||||
impl Default for AutoObserverStatus {
|
||||
fn default() -> Self {
|
||||
Self::Ok
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct AutoObserverTelemetry {
|
||||
pub trigger_count: u64,
|
||||
pub last_status: AutoObserverStatus,
|
||||
pub last_intervention: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum AutoObserverReason {
|
||||
Cadence,
|
||||
CrossCheck {
|
||||
forced: bool,
|
||||
summary: Option<String>,
|
||||
focus: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum AutoContinueMode {
|
||||
Immediate,
|
||||
TenSeconds,
|
||||
SixtySeconds,
|
||||
Manual,
|
||||
}
|
||||
|
||||
impl Default for AutoContinueMode {
|
||||
fn default() -> Self {
|
||||
Self::TenSeconds
|
||||
}
|
||||
}
|
||||
|
||||
impl AutoContinueMode {
|
||||
pub fn seconds(self) -> Option<u8> {
|
||||
match self {
|
||||
Self::Immediate => Some(0),
|
||||
Self::TenSeconds => Some(10),
|
||||
Self::SixtySeconds => Some(60),
|
||||
Self::Manual => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Immediate => "Immediate",
|
||||
Self::TenSeconds => "10 seconds",
|
||||
Self::SixtySeconds => "60 seconds",
|
||||
Self::Manual => "Manual approval",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cycle_forward(self) -> Self {
|
||||
match self {
|
||||
Self::Immediate => Self::TenSeconds,
|
||||
Self::TenSeconds => Self::SixtySeconds,
|
||||
Self::SixtySeconds => Self::Manual,
|
||||
Self::Manual => Self::Immediate,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cycle_backward(self) -> Self {
|
||||
match self {
|
||||
Self::Immediate => Self::Manual,
|
||||
Self::TenSeconds => Self::Immediate,
|
||||
Self::SixtySeconds => Self::TenSeconds,
|
||||
Self::Manual => Self::SixtySeconds,
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) use code_auto_drive_core::{
|
||||
AutoContinueMode,
|
||||
AutoCoordinatorStatus,
|
||||
AutoTurnAgentsAction,
|
||||
AutoTurnAgentsTiming,
|
||||
AutoTurnCliAction,
|
||||
};
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug)]
|
||||
@@ -224,12 +113,6 @@ pub(crate) enum AppEvent {
|
||||
elapsed: Duration,
|
||||
},
|
||||
|
||||
/// Placeholder QA automation updates (no-op for now).
|
||||
AutoQaUpdate { note: String },
|
||||
|
||||
/// Placeholder review requests routed from the QA orchestrator.
|
||||
AutoReviewRequest { summary: Option<String> },
|
||||
|
||||
/// Internal: flush any pending out-of-order ExecEnd events that did not
|
||||
/// receive a matching ExecBegin within a short pairing window. This lets
|
||||
/// the TUI render a fallback "Ran call_<id>" cell so output is not lost.
|
||||
@@ -275,26 +158,6 @@ pub(crate) enum AppEvent {
|
||||
token: u64,
|
||||
attempt: u32,
|
||||
},
|
||||
AutoObserverReport {
|
||||
mode: ObserverMode,
|
||||
status: AutoObserverStatus,
|
||||
telemetry: AutoObserverTelemetry,
|
||||
replace_message: Option<String>,
|
||||
additional_instructions: Option<String>,
|
||||
reason: AutoObserverReason,
|
||||
conversation: Vec<ResponseItem>,
|
||||
raw_output: Option<String>,
|
||||
parsed_response: Option<JsonValue>,
|
||||
},
|
||||
AutoObserverThinking {
|
||||
mode: ObserverMode,
|
||||
delta: String,
|
||||
summary_index: Option<u32>,
|
||||
},
|
||||
AutoObserverReady {
|
||||
baseline_summary: Option<String>,
|
||||
bootstrap_len: usize,
|
||||
},
|
||||
ShowAutoDriveSettings,
|
||||
CloseAutoDriveSettings,
|
||||
AutoDriveSettingsChanged {
|
||||
@@ -302,7 +165,6 @@ pub(crate) enum AppEvent {
|
||||
agents_enabled: bool,
|
||||
cross_check_enabled: bool,
|
||||
qa_automation_enabled: bool,
|
||||
observer_enabled: bool,
|
||||
continue_mode: AutoContinueMode,
|
||||
},
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ pub(crate) struct AutoDriveSettingsView {
|
||||
agents_enabled: bool,
|
||||
cross_check_enabled: bool,
|
||||
qa_automation_enabled: bool,
|
||||
observer_enabled: bool,
|
||||
diagnostics_enabled: bool,
|
||||
continue_mode: AutoContinueMode,
|
||||
closing: bool,
|
||||
@@ -35,11 +34,10 @@ impl AutoDriveSettingsView {
|
||||
agents_enabled: bool,
|
||||
cross_check_enabled: bool,
|
||||
qa_automation_enabled: bool,
|
||||
observer_enabled: bool,
|
||||
continue_mode: AutoContinueMode,
|
||||
) -> Self {
|
||||
let diagnostics_enabled = qa_automation_enabled
|
||||
&& (review_enabled || cross_check_enabled || observer_enabled);
|
||||
&& (review_enabled || cross_check_enabled);
|
||||
Self {
|
||||
app_event_tx,
|
||||
selected_index: 0,
|
||||
@@ -47,7 +45,6 @@ impl AutoDriveSettingsView {
|
||||
agents_enabled,
|
||||
cross_check_enabled,
|
||||
qa_automation_enabled,
|
||||
observer_enabled,
|
||||
diagnostics_enabled,
|
||||
continue_mode,
|
||||
closing: false,
|
||||
@@ -64,7 +61,6 @@ impl AutoDriveSettingsView {
|
||||
agents_enabled: self.agents_enabled,
|
||||
cross_check_enabled: self.cross_check_enabled,
|
||||
qa_automation_enabled: self.qa_automation_enabled,
|
||||
observer_enabled: self.observer_enabled,
|
||||
continue_mode: self.continue_mode,
|
||||
});
|
||||
}
|
||||
@@ -73,9 +69,8 @@ impl AutoDriveSettingsView {
|
||||
self.review_enabled = enabled;
|
||||
self.cross_check_enabled = enabled;
|
||||
self.qa_automation_enabled = enabled;
|
||||
self.observer_enabled = enabled;
|
||||
self.diagnostics_enabled = self.qa_automation_enabled
|
||||
&& (self.review_enabled || self.cross_check_enabled || self.observer_enabled);
|
||||
self.diagnostics_enabled =
|
||||
self.qa_automation_enabled && (self.review_enabled || self.cross_check_enabled);
|
||||
}
|
||||
|
||||
fn cycle_continue_mode(&mut self, forward: bool) {
|
||||
|
||||
+254
-1811
File diff suppressed because it is too large
Load Diff
@@ -1,881 +0,0 @@
|
||||
use std::sync::mpsc::{self, Receiver, Sender};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use code_core::{
|
||||
error::CodexErr,
|
||||
ModelClient,
|
||||
OpenAiTool,
|
||||
Prompt,
|
||||
ResponseEvent,
|
||||
TextFormat,
|
||||
};
|
||||
use code_core::model_family::{derive_default_model_family, find_family_for_model};
|
||||
use code_protocol::models::{ContentItem, ResponseItem};
|
||||
use futures::StreamExt;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{self, json, Value};
|
||||
use tracing::{debug, error, warn};
|
||||
|
||||
use crate::app_event::AutoObserverTelemetry;
|
||||
use crate::app_event;
|
||||
|
||||
type ObserverMode = app_event::ObserverMode;
|
||||
use crate::chatwidget::AutoObserverStatus;
|
||||
use crate::thread_spawner;
|
||||
|
||||
use super::auto_coordinator::{
|
||||
extract_first_json_object,
|
||||
make_message,
|
||||
AutoCoordinatorCommand,
|
||||
MODEL_SLUG,
|
||||
};
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct CrossCheckTurnSnapshot {
|
||||
pub cli_prompt: Option<String>,
|
||||
pub cli_context: Option<String>,
|
||||
pub progress_summary: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct AutoObserverHandle {
|
||||
pub tx: Sender<AutoObserverCommand>,
|
||||
cadence: u32,
|
||||
}
|
||||
|
||||
impl AutoObserverHandle {
|
||||
pub fn cadence(&self) -> u32 {
|
||||
self.cadence.max(1)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) enum AutoObserverCommand {
|
||||
Bootstrap {
|
||||
goal_text: String,
|
||||
environment_details: String,
|
||||
tools: Vec<OpenAiTool>,
|
||||
},
|
||||
Trigger(ObserverTrigger),
|
||||
BeginCrossCheck {
|
||||
conversation: Vec<ResponseItem>,
|
||||
_from_index: usize,
|
||||
forced: bool,
|
||||
summary: Option<String>,
|
||||
focus: Option<String>,
|
||||
tools: Vec<OpenAiTool>,
|
||||
},
|
||||
Stop,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) enum ObserverReason {
|
||||
Cadence,
|
||||
CrossCheck {
|
||||
forced: bool,
|
||||
summary: Option<String>,
|
||||
focus: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct ObserverTrigger {
|
||||
pub conversation: Vec<ResponseItem>,
|
||||
pub goal_text: String,
|
||||
pub environment_details: String,
|
||||
pub reason: ObserverReason,
|
||||
pub turn_snapshot: Option<CrossCheckTurnSnapshot>,
|
||||
pub tools: Vec<OpenAiTool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct ObserverEvaluation {
|
||||
pub status: AutoObserverStatus,
|
||||
pub replace_message: Option<String>,
|
||||
pub additional_instructions: Option<String>,
|
||||
pub raw_output: String,
|
||||
pub parsed_response: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct ObserverOutcome {
|
||||
pub mode: ObserverMode,
|
||||
pub status: AutoObserverStatus,
|
||||
pub replace_message: Option<String>,
|
||||
pub additional_instructions: Option<String>,
|
||||
pub telemetry: AutoObserverTelemetry,
|
||||
pub reason: ObserverReason,
|
||||
pub conversation: Vec<ResponseItem>,
|
||||
pub turn_snapshot: Option<CrossCheckTurnSnapshot>,
|
||||
pub raw_output: Option<String>,
|
||||
pub parsed_response: Option<Value>,
|
||||
}
|
||||
|
||||
const OBSERVER_SCHEMA_NAME: &str = "auto_coordinator_observer";
|
||||
|
||||
pub(super) fn start_auto_observer(
|
||||
client: Arc<ModelClient>,
|
||||
cadence: u32,
|
||||
coordinator_tx: Sender<AutoCoordinatorCommand>,
|
||||
) -> Result<AutoObserverHandle> {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let thread_tx = tx.clone();
|
||||
|
||||
if thread_spawner::spawn_lightweight("auto-observer", move || {
|
||||
if let Err(err) = run_observer_loop(client, rx, coordinator_tx) {
|
||||
error!("auto observer loop error: {err:#}");
|
||||
}
|
||||
})
|
||||
.is_none()
|
||||
{
|
||||
error!("auto observer spawn rejected: background thread limit reached");
|
||||
return Err(anyhow!("auto observer worker unavailable"));
|
||||
}
|
||||
|
||||
Ok(AutoObserverHandle {
|
||||
tx: thread_tx,
|
||||
cadence,
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn run_observer_once(
|
||||
runtime: &tokio::runtime::Runtime,
|
||||
client: Arc<ModelClient>,
|
||||
trigger: ObserverTrigger,
|
||||
) -> Result<ObserverEvaluation> {
|
||||
let (tx, _rx) = mpsc::channel();
|
||||
evaluate_observer(runtime, client, trigger, tx, ObserverMode::Cadence)
|
||||
}
|
||||
|
||||
fn build_bootstrap_trigger(
|
||||
goal_text: &str,
|
||||
environment_details: &str,
|
||||
tools: Vec<OpenAiTool>,
|
||||
) -> ObserverTrigger {
|
||||
let prompt = format!(
|
||||
"You are the QA observer. Before automation begins, inspect the repository and outline how to validate completion of the primary goal.\n\nPrimary goal:\n{goal}\n\nEnvironment:\n{environment}\n\nReturn a concise readiness summary and any immediate risks to monitor.",
|
||||
goal = goal_text.trim(),
|
||||
environment = environment_details.trim()
|
||||
);
|
||||
|
||||
let mut conversation = Vec::new();
|
||||
conversation.push(make_message("developer", prompt));
|
||||
|
||||
ObserverTrigger {
|
||||
conversation,
|
||||
goal_text: goal_text.to_string(),
|
||||
environment_details: environment_details.to_string(),
|
||||
reason: ObserverReason::Cadence,
|
||||
turn_snapshot: None,
|
||||
tools,
|
||||
}
|
||||
}
|
||||
|
||||
fn run_observer_loop(
|
||||
client: Arc<ModelClient>,
|
||||
rx: Receiver<AutoObserverCommand>,
|
||||
coordinator_tx: Sender<AutoCoordinatorCommand>,
|
||||
) -> Result<()> {
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.context("creating runtime for auto observer")?;
|
||||
|
||||
let mut telemetry = AutoObserverTelemetry {
|
||||
trigger_count: 0,
|
||||
last_status: AutoObserverStatus::Ok,
|
||||
last_intervention: None,
|
||||
};
|
||||
|
||||
while let Ok(cmd) = rx.recv() {
|
||||
match cmd {
|
||||
AutoObserverCommand::Bootstrap {
|
||||
goal_text,
|
||||
environment_details,
|
||||
tools,
|
||||
} => {
|
||||
let trigger = build_bootstrap_trigger(&goal_text, &environment_details, tools);
|
||||
telemetry.trigger_count += 1;
|
||||
match evaluate_observer(
|
||||
&runtime,
|
||||
client.clone(),
|
||||
trigger.clone(),
|
||||
coordinator_tx.clone(),
|
||||
ObserverMode::Bootstrap,
|
||||
) {
|
||||
Ok(eval) => {
|
||||
let ObserverEvaluation {
|
||||
status,
|
||||
replace_message,
|
||||
additional_instructions,
|
||||
raw_output,
|
||||
parsed_response,
|
||||
} = eval;
|
||||
|
||||
telemetry.last_status = status;
|
||||
telemetry.last_intervention = summarize_intervention(
|
||||
replace_message.as_deref(),
|
||||
additional_instructions.as_deref(),
|
||||
);
|
||||
|
||||
let outcome = ObserverOutcome {
|
||||
mode: ObserverMode::Bootstrap,
|
||||
status,
|
||||
replace_message,
|
||||
additional_instructions,
|
||||
telemetry: telemetry.clone(),
|
||||
reason: ObserverReason::Cadence,
|
||||
conversation: trigger.conversation.clone(),
|
||||
turn_snapshot: None,
|
||||
raw_output: Some(raw_output),
|
||||
parsed_response: Some(parsed_response),
|
||||
};
|
||||
|
||||
if coordinator_tx
|
||||
.send(AutoCoordinatorCommand::ObserverResult(outcome))
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("auto observer bootstrap error: {err:#}");
|
||||
telemetry.last_status = AutoObserverStatus::Ok;
|
||||
telemetry.last_intervention = Some(format!("error: {err}"));
|
||||
let outcome = ObserverOutcome {
|
||||
mode: ObserverMode::Bootstrap,
|
||||
status: AutoObserverStatus::Ok,
|
||||
replace_message: None,
|
||||
additional_instructions: None,
|
||||
telemetry: telemetry.clone(),
|
||||
reason: ObserverReason::Cadence,
|
||||
conversation: Vec::new(),
|
||||
turn_snapshot: None,
|
||||
raw_output: None,
|
||||
parsed_response: None,
|
||||
};
|
||||
if coordinator_tx
|
||||
.send(AutoCoordinatorCommand::ObserverResult(outcome))
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AutoObserverCommand::Trigger(trigger) => {
|
||||
telemetry.trigger_count += 1;
|
||||
let mode = match trigger.reason {
|
||||
ObserverReason::CrossCheck { .. } => ObserverMode::CrossCheck,
|
||||
_ => ObserverMode::Cadence,
|
||||
};
|
||||
match evaluate_observer(
|
||||
&runtime,
|
||||
client.clone(),
|
||||
trigger.clone(),
|
||||
coordinator_tx.clone(),
|
||||
mode,
|
||||
) {
|
||||
Ok(eval) => {
|
||||
let ObserverEvaluation {
|
||||
status,
|
||||
replace_message,
|
||||
additional_instructions,
|
||||
raw_output,
|
||||
parsed_response,
|
||||
} = eval;
|
||||
|
||||
telemetry.last_status = status;
|
||||
telemetry.last_intervention = summarize_intervention(
|
||||
replace_message.as_deref(),
|
||||
additional_instructions.as_deref(),
|
||||
);
|
||||
|
||||
let outcome = ObserverOutcome {
|
||||
mode,
|
||||
status,
|
||||
replace_message,
|
||||
additional_instructions,
|
||||
telemetry: telemetry.clone(),
|
||||
reason: trigger.reason.clone(),
|
||||
conversation: trigger.conversation.clone(),
|
||||
turn_snapshot: trigger.turn_snapshot.clone(),
|
||||
raw_output: Some(raw_output),
|
||||
parsed_response: Some(parsed_response),
|
||||
};
|
||||
|
||||
if coordinator_tx
|
||||
.send(AutoCoordinatorCommand::ObserverResult(outcome))
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("auto observer evaluation error: {err:#}");
|
||||
telemetry.last_status = AutoObserverStatus::Ok;
|
||||
telemetry.last_intervention = Some(format!("error: {err}"));
|
||||
let outcome = ObserverOutcome {
|
||||
mode,
|
||||
status: AutoObserverStatus::Ok,
|
||||
replace_message: None,
|
||||
additional_instructions: None,
|
||||
telemetry: telemetry.clone(),
|
||||
reason: trigger.reason,
|
||||
conversation: Vec::new(),
|
||||
turn_snapshot: None,
|
||||
raw_output: None,
|
||||
parsed_response: None,
|
||||
};
|
||||
if coordinator_tx
|
||||
.send(AutoCoordinatorCommand::ObserverResult(outcome))
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AutoObserverCommand::BeginCrossCheck {
|
||||
conversation,
|
||||
_from_index: _,
|
||||
forced,
|
||||
summary,
|
||||
focus,
|
||||
tools,
|
||||
} => {
|
||||
let summary_clone = summary.clone();
|
||||
let focus_clone = focus.clone();
|
||||
let trigger = ObserverTrigger {
|
||||
conversation,
|
||||
goal_text: String::new(),
|
||||
environment_details: String::new(),
|
||||
reason: ObserverReason::CrossCheck {
|
||||
forced,
|
||||
summary,
|
||||
focus,
|
||||
},
|
||||
turn_snapshot: None,
|
||||
tools,
|
||||
};
|
||||
telemetry.trigger_count += 1;
|
||||
match evaluate_observer(
|
||||
&runtime,
|
||||
client.clone(),
|
||||
trigger.clone(),
|
||||
coordinator_tx.clone(),
|
||||
ObserverMode::CrossCheck,
|
||||
) {
|
||||
Ok(eval) => {
|
||||
let ObserverEvaluation {
|
||||
status,
|
||||
replace_message,
|
||||
additional_instructions,
|
||||
raw_output,
|
||||
parsed_response,
|
||||
} = eval;
|
||||
|
||||
telemetry.last_status = status;
|
||||
telemetry.last_intervention = summarize_intervention(
|
||||
replace_message.as_deref(),
|
||||
additional_instructions.as_deref(),
|
||||
);
|
||||
|
||||
let outcome = ObserverOutcome {
|
||||
mode: ObserverMode::CrossCheck,
|
||||
status,
|
||||
replace_message,
|
||||
additional_instructions,
|
||||
telemetry: telemetry.clone(),
|
||||
reason: trigger.reason,
|
||||
conversation: trigger.conversation,
|
||||
turn_snapshot: None,
|
||||
raw_output: Some(raw_output),
|
||||
parsed_response: Some(parsed_response),
|
||||
};
|
||||
|
||||
if coordinator_tx
|
||||
.send(AutoCoordinatorCommand::ObserverResult(outcome))
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("auto observer cross-check error: {err:#}");
|
||||
telemetry.last_status = AutoObserverStatus::Failing;
|
||||
telemetry.last_intervention = Some(format!("error: {err}"));
|
||||
let outcome = ObserverOutcome {
|
||||
mode: ObserverMode::CrossCheck,
|
||||
status: AutoObserverStatus::Failing,
|
||||
replace_message: None,
|
||||
additional_instructions: Some(format!(
|
||||
"Cross-check failed due to observer error: {err}"
|
||||
)),
|
||||
telemetry: telemetry.clone(),
|
||||
reason: ObserverReason::CrossCheck {
|
||||
forced,
|
||||
summary: summary_clone,
|
||||
focus: focus_clone,
|
||||
},
|
||||
conversation: Vec::new(),
|
||||
turn_snapshot: None,
|
||||
raw_output: None,
|
||||
parsed_response: None,
|
||||
};
|
||||
if coordinator_tx
|
||||
.send(AutoCoordinatorCommand::ObserverResult(outcome))
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AutoObserverCommand::Stop => break,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn evaluate_observer(
|
||||
runtime: &tokio::runtime::Runtime,
|
||||
client: Arc<ModelClient>,
|
||||
trigger: ObserverTrigger,
|
||||
coordinator_tx: Sender<AutoCoordinatorCommand>,
|
||||
mode: ObserverMode,
|
||||
) -> Result<ObserverEvaluation> {
|
||||
let preferred_slug = match trigger.reason {
|
||||
ObserverReason::CrossCheck { .. } => "gpt-5",
|
||||
_ => MODEL_SLUG,
|
||||
};
|
||||
let mut prompt = build_observer_prompt(&trigger, preferred_slug);
|
||||
let log_tag = match mode {
|
||||
ObserverMode::Bootstrap => "auto/observer/bootstrap",
|
||||
ObserverMode::Cadence => "auto/observer/cadence",
|
||||
ObserverMode::CrossCheck => "auto/observer/cross_check",
|
||||
};
|
||||
prompt.set_log_tag(log_tag);
|
||||
match run_observer_prompt(runtime, client.clone(), prompt.clone(), coordinator_tx.clone(), mode)
|
||||
{
|
||||
Ok(result) => Ok(result),
|
||||
Err(err) => {
|
||||
let fallback_slug = client.default_model_slug().to_string();
|
||||
if should_retry_with_default_model(&err) && fallback_slug != preferred_slug {
|
||||
debug!(
|
||||
preferred = %preferred_slug,
|
||||
fallback = %fallback_slug,
|
||||
"auto observer falling back to configured model after invalid model error"
|
||||
);
|
||||
let mut fallback_prompt = build_observer_prompt(&trigger, &fallback_slug);
|
||||
fallback_prompt.set_log_tag(log_tag);
|
||||
let original_error = err.to_string();
|
||||
return run_observer_prompt(
|
||||
runtime,
|
||||
client,
|
||||
fallback_prompt,
|
||||
coordinator_tx,
|
||||
mode,
|
||||
)
|
||||
.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,
|
||||
coordinator_tx: Sender<AutoCoordinatorCommand>,
|
||||
mode: ObserverMode,
|
||||
) -> Result<ObserverEvaluation> {
|
||||
let raw = runtime.block_on(async {
|
||||
request_observer_response(client.clone(), &prompt, mode, coordinator_tx).await
|
||||
})?;
|
||||
|
||||
let (response, value) = parse_observer_response(&raw)?;
|
||||
|
||||
let status = match response.status.as_str() {
|
||||
"ok" => AutoObserverStatus::Ok,
|
||||
"failing" => AutoObserverStatus::Failing,
|
||||
other => return Err(anyhow!("unexpected status '{other}'")),
|
||||
};
|
||||
|
||||
let trimmed_replace_message = response
|
||||
.replace_message
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
let trimmed_additional_instructions = response
|
||||
.additional_instructions
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
if matches!(status, AutoObserverStatus::Failing)
|
||||
&& trimmed_replace_message.is_none()
|
||||
&& trimmed_additional_instructions.is_none()
|
||||
{
|
||||
warn!("observer returned failing status without guidance");
|
||||
}
|
||||
|
||||
let (replace_message, additional_instructions) = partition_observer_guidance(
|
||||
status,
|
||||
trimmed_replace_message,
|
||||
trimmed_additional_instructions,
|
||||
);
|
||||
|
||||
debug!(
|
||||
"[Auto observer] status={status:?} replace={} instructions={}",
|
||||
replace_message.is_some(),
|
||||
additional_instructions.is_some()
|
||||
);
|
||||
|
||||
Ok(ObserverEvaluation {
|
||||
status,
|
||||
replace_message,
|
||||
additional_instructions,
|
||||
raw_output: raw,
|
||||
parsed_response: value,
|
||||
})
|
||||
}
|
||||
|
||||
fn partition_observer_guidance(
|
||||
status: AutoObserverStatus,
|
||||
replace_message: Option<String>,
|
||||
additional_instructions: Option<String>,
|
||||
) -> (Option<String>, Option<String>) {
|
||||
if matches!(status, AutoObserverStatus::Failing) {
|
||||
(replace_message, additional_instructions)
|
||||
} else {
|
||||
(None, 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, trigger.reason.clone());
|
||||
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());
|
||||
prompt.set_tools(trigger.tools.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(code_err) = cause.downcast_ref::<CodexErr>() {
|
||||
if let CodexErr::UnexpectedStatus(err) = code_err {
|
||||
if !err.status.is_client_error() {
|
||||
return false;
|
||||
}
|
||||
let body_lower = err.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,
|
||||
mode: ObserverMode,
|
||||
coordinator_tx: Sender<AutoCoordinatorCommand>,
|
||||
) -> Result<String> {
|
||||
let mut stream = client.stream(prompt).await?;
|
||||
let mut out = String::new();
|
||||
let mut last_summary_index: Option<u32> = None;
|
||||
while let Some(ev) = stream.next().await {
|
||||
match ev {
|
||||
Ok(ResponseEvent::OutputTextDelta { delta, .. }) => out.push_str(&delta),
|
||||
Ok(ResponseEvent::OutputItemDone { item, .. }) => {
|
||||
if let ResponseItem::Message { content, .. } = item {
|
||||
for c in content {
|
||||
if let ContentItem::OutputText { text } = c {
|
||||
out.push_str(&text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ResponseEvent::ReasoningSummaryDelta {
|
||||
delta,
|
||||
summary_index,
|
||||
..
|
||||
}) => {
|
||||
last_summary_index = summary_index;
|
||||
let cleaned = strip_reasoning_prefix(&delta);
|
||||
if !cleaned.trim().is_empty() {
|
||||
let _ = coordinator_tx.send(AutoCoordinatorCommand::ObserverThinking {
|
||||
mode,
|
||||
delta: cleaned.to_string(),
|
||||
summary_index,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(ResponseEvent::ReasoningContentDelta { delta, .. }) => {
|
||||
let cleaned = strip_reasoning_prefix(&delta);
|
||||
if !cleaned.trim().is_empty() {
|
||||
let _ = coordinator_tx.send(AutoCoordinatorCommand::ObserverThinking {
|
||||
mode,
|
||||
delta: cleaned.to_string(),
|
||||
summary_index: last_summary_index,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(ResponseEvent::Completed { .. }) => break,
|
||||
Err(err) => return Err(anyhow!("observer stream error: {err}")),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn strip_reasoning_prefix(input: &str) -> &str {
|
||||
const PREFIXES: [&str; 2] = ["Observer:", "Coordinator:"];
|
||||
for prefix in PREFIXES {
|
||||
if let Some(head) = input.get(..prefix.len()) {
|
||||
if head.eq_ignore_ascii_case(prefix) {
|
||||
let rest = input.get(prefix.len()..).unwrap_or_default();
|
||||
return rest.strip_prefix(' ').unwrap_or(rest);
|
||||
}
|
||||
}
|
||||
}
|
||||
input
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ObserverResponse {
|
||||
status: String,
|
||||
#[serde(default)]
|
||||
replace_message: Option<String>,
|
||||
#[serde(default)]
|
||||
additional_instructions: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_observer_response(raw: &str) -> Result<(ObserverResponse, Value)> {
|
||||
let value: Value = match serde_json::from_str(raw) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
let Some(blob) = extract_first_json_object(raw) else {
|
||||
return Err(anyhow!("observer response was not valid JSON"));
|
||||
};
|
||||
serde_json::from_str(&blob).context("parsing JSON from observer output")?
|
||||
}
|
||||
};
|
||||
let response: ObserverResponse = serde_json::from_value(value.clone())
|
||||
.context("decoding observer response")?;
|
||||
Ok((response, value))
|
||||
}
|
||||
|
||||
fn build_observer_instructions(environment_details: &str, reason: ObserverReason) -> String {
|
||||
let body = match reason {
|
||||
ObserverReason::Cadence => "You are observing a AI Coordinator trying to drive a CLI towards a Primary Goal (shown below).\nPlease critically observe the conversation between the Coordinator and the CLI. Detect either of these issues;\n- Stuck in a loop\n- Not working towards primary goal\nGenerate a response based on this information;\n`status`: one of 'ok' or 'failing' - most of the time it will be 'ok', but use 'failing' when intervention absolutely is needed. When using 'failing' please provide one or both fields below to correct the problem;\n`replace_message`: A message to replace the last Coordinator message\n`additional_instructions`: Instructions to give to the Coordinator for future runs\n**Warning**\nYou almost always want to use `status`: \"ok\". You are a last resort. Avoid setting `status`: \"failing\" for minor issues as it will disrupt the progress of the task.".to_string(),
|
||||
ObserverReason::CrossCheck { forced, ref summary, ref focus } => {
|
||||
let mut lines = Vec::new();
|
||||
lines.push("You are a senior QA reviewer performing an end-to-end cross-check of the Auto Drive run.".to_string());
|
||||
lines.push("Confirm the Primary Goal is fully satisfied with explicit evidence. Assume nothing; require proof.".to_string());
|
||||
lines.push("Use the available tools (shell, browser inspection, web search, agents) to run commands, inspect artifacts, and gather concrete evidence.".to_string());
|
||||
|
||||
if let Some(text) = summary {
|
||||
if !text.trim().is_empty() {
|
||||
lines.push(format!("Cross-check emphasis: {}", text.trim()));
|
||||
}
|
||||
}
|
||||
if let Some(text) = focus {
|
||||
if !text.trim().is_empty() {
|
||||
lines.push(format!("Probe these risks or flows: {}", text.trim()));
|
||||
}
|
||||
}
|
||||
|
||||
if forced {
|
||||
lines.push("This cross-check gates completion. Default to `status`: 'failing' unless the goal is unquestionably complete.".to_string());
|
||||
} else {
|
||||
lines.push("Be strict. Favor `status`: 'failing' whenever evidence is missing, ambiguous, or work appears partial.".to_string());
|
||||
}
|
||||
|
||||
lines.push("Response contract (JSON):".to_string());
|
||||
lines.push("- `status`: 'ok' only when the goal is entirely complete and verified; otherwise 'failing'.".to_string());
|
||||
lines.push("- `additional_instructions`: concise, actionable developer steps to close the gaps you found.".to_string());
|
||||
lines.push("- `replace_message`: only when the last coordinator message must be replaced immediately.".to_string());
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
};
|
||||
format!("{body}\nEnvironment:\n{environment_details}")
|
||||
}
|
||||
|
||||
fn build_observer_schema() -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["ok", "failing"],
|
||||
},
|
||||
"replace_message": {
|
||||
"type": ["string", "null"],
|
||||
"minLength": 1,
|
||||
},
|
||||
"additional_instructions": {
|
||||
"type": ["string", "null"],
|
||||
"minLength": 1,
|
||||
}
|
||||
},
|
||||
"required": ["status", "replace_message", "additional_instructions"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn summarize_intervention(
|
||||
replace_message: Option<&str>,
|
||||
additional_instructions: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let source = replace_message.or(additional_instructions)?;
|
||||
let trimmed = source.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
const MAX_LEN: usize = 160;
|
||||
if trimmed.len() > MAX_LEN {
|
||||
let mut out = trimmed.chars().take(MAX_LEN).collect::<String>();
|
||||
out.push('…');
|
||||
Some(out)
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// Helper so observer can append the coordinator's latest prompt.
|
||||
pub(super) fn build_observer_conversation(
|
||||
conversation: Vec<ResponseItem>,
|
||||
coordinator_prompt: Option<&str>,
|
||||
) -> Vec<ResponseItem> {
|
||||
let mut filtered: Vec<ResponseItem> = Vec::new();
|
||||
let mut prefer_assistant_prompt = true;
|
||||
|
||||
for item in conversation {
|
||||
match item {
|
||||
ResponseItem::Message { id, role, content } => {
|
||||
if id.as_deref() == Some("auto-drive-reasoning") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if role == "assistant" {
|
||||
let mut new_content: Vec<ContentItem> = Vec::new();
|
||||
for entry in content {
|
||||
match entry {
|
||||
ContentItem::InputText { text } => {
|
||||
let already_prefixed = text.trim_start().starts_with("Coordinator:");
|
||||
if !already_prefixed {
|
||||
prefer_assistant_prompt = false;
|
||||
}
|
||||
let prefixed = if already_prefixed {
|
||||
text
|
||||
} else {
|
||||
format!("Coordinator: {text}")
|
||||
};
|
||||
new_content.push(ContentItem::InputText { text: prefixed });
|
||||
}
|
||||
ContentItem::OutputText { text } => {
|
||||
let already_prefixed = text.trim_start().starts_with("Coordinator:");
|
||||
if !already_prefixed {
|
||||
prefer_assistant_prompt = false;
|
||||
}
|
||||
let prefixed = if already_prefixed {
|
||||
text
|
||||
} else {
|
||||
format!("Coordinator: {text}")
|
||||
};
|
||||
new_content.push(ContentItem::OutputText { text: prefixed });
|
||||
}
|
||||
other => new_content.push(other),
|
||||
}
|
||||
}
|
||||
filtered.push(ResponseItem::Message {
|
||||
id: None,
|
||||
role,
|
||||
content: new_content,
|
||||
});
|
||||
} else {
|
||||
filtered.push(ResponseItem::Message { id, role, content });
|
||||
}
|
||||
}
|
||||
ResponseItem::Reasoning { .. } => {
|
||||
// Observer should not inspect reasoning blocks.
|
||||
continue;
|
||||
}
|
||||
other => filtered.push(other),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(prompt) = coordinator_prompt.and_then(|p| {
|
||||
let trimmed = p.trim();
|
||||
(!trimmed.is_empty()).then_some(trimmed)
|
||||
}) {
|
||||
let text = if prompt.trim_start().starts_with("Coordinator:") {
|
||||
prompt.to_string()
|
||||
} else {
|
||||
format!("Coordinator: {prompt}")
|
||||
};
|
||||
let append_as_assistant = prefer_assistant_prompt
|
||||
&& filtered
|
||||
.last()
|
||||
.map(|item| match item {
|
||||
ResponseItem::Message { role, content, .. } if role == "assistant" => content
|
||||
.iter()
|
||||
.all(|chunk| match chunk {
|
||||
ContentItem::InputText { text }
|
||||
| ContentItem::OutputText { text } => {
|
||||
text.trim_start().starts_with("Coordinator:")
|
||||
}
|
||||
_ => false,
|
||||
}),
|
||||
_ => false,
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
if append_as_assistant {
|
||||
filtered.push(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText { text }],
|
||||
});
|
||||
} else {
|
||||
filtered.push(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText { text }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
filtered
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
use std::num::NonZeroUsize;
|
||||
use std::sync::mpsc::{self, Receiver, Sender};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
|
||||
/// Internal commands understood by the QA orchestrator loop.
|
||||
enum QaMsg {
|
||||
Shutdown,
|
||||
#[allow(dead_code)]
|
||||
Tick,
|
||||
TurnFinished { has_diff: bool },
|
||||
Finalize { has_diff: bool },
|
||||
}
|
||||
|
||||
/// Handle giving the caller control over the QA orchestrator thread.
|
||||
pub struct QaOrchestratorHandle {
|
||||
tx: Sender<QaMsg>,
|
||||
join: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl QaOrchestratorHandle {
|
||||
/// Request shutdown and wait for the orchestrator thread to exit.
|
||||
pub fn stop(mut self) {
|
||||
let _ = self.tx.send(QaMsg::Shutdown);
|
||||
if let Some(join) = self.join.take() {
|
||||
let _ = join.join();
|
||||
}
|
||||
}
|
||||
|
||||
/// Notify the orchestrator that an Auto Drive turn finished.
|
||||
pub fn notify_turn_finished(&self, has_diff: bool) {
|
||||
let _ = self.tx.send(QaMsg::TurnFinished { has_diff });
|
||||
}
|
||||
|
||||
/// Notify the orchestrator that Auto Drive is about to stop so it can run
|
||||
/// a final QA cadence check before shutting down.
|
||||
pub fn notify_finalize(&self, has_diff: bool) {
|
||||
let _ = self.tx.send(QaMsg::Finalize { has_diff });
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a placeholder QA orchestrator thread. For now it only idles until
|
||||
/// told to shut down; future iterations will drive cross-check + review logic.
|
||||
pub fn start_qa_orchestrator(app_tx: AppEventSender) -> QaOrchestratorHandle {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let join = thread::Builder::new()
|
||||
.name("qa-orchestrator".into())
|
||||
.spawn(move || orchestrator_loop(app_tx, rx))
|
||||
.expect("failed to spawn qa orchestrator thread");
|
||||
|
||||
QaOrchestratorHandle {
|
||||
tx,
|
||||
join: Some(join),
|
||||
}
|
||||
}
|
||||
|
||||
const ENV_QA_CADENCE: &str = "CODE_QA_CADENCE";
|
||||
const ENV_QA_REVIEW_COOLDOWN_TURNS: &str = "CODE_QA_REVIEW_COOLDOWN_TURNS";
|
||||
|
||||
#[derive(Debug)]
|
||||
struct QaCadenceState {
|
||||
cadence: NonZeroUsize,
|
||||
completed: usize,
|
||||
review_cooldown: NonZeroUsize,
|
||||
since_last_review: usize,
|
||||
}
|
||||
|
||||
impl QaCadenceState {
|
||||
fn from_env() -> Self {
|
||||
let cadence = std::env::var(ENV_QA_CADENCE)
|
||||
.ok()
|
||||
.and_then(|raw| raw.parse::<usize>().ok())
|
||||
.and_then(NonZeroUsize::new)
|
||||
.unwrap_or_else(|| NonZeroUsize::new(3).expect("3 is non-zero"));
|
||||
let review_cooldown = std::env::var(ENV_QA_REVIEW_COOLDOWN_TURNS)
|
||||
.ok()
|
||||
.and_then(|raw| raw.parse::<usize>().ok())
|
||||
.and_then(NonZeroUsize::new)
|
||||
.unwrap_or_else(|| NonZeroUsize::new(1).expect("1 is non-zero"));
|
||||
|
||||
Self {
|
||||
cadence,
|
||||
completed: 0,
|
||||
review_cooldown,
|
||||
since_last_review: review_cooldown.get(),
|
||||
}
|
||||
}
|
||||
|
||||
fn record_turn(&mut self) -> bool {
|
||||
self.completed += 1;
|
||||
if self.completed >= self.cadence.get() {
|
||||
self.completed = 0;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.completed = 0;
|
||||
self.since_last_review = self.review_cooldown.get();
|
||||
}
|
||||
|
||||
fn record_review_turn(&mut self, has_diff: bool) -> Option<usize> {
|
||||
self.since_last_review = (self.since_last_review + 1)
|
||||
.min(self.review_cooldown.get());
|
||||
|
||||
if has_diff && self.since_last_review >= self.review_cooldown.get() {
|
||||
let waited = self.since_last_review;
|
||||
self.since_last_review = 0;
|
||||
Some(waited)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn orchestrator_loop(app_tx: AppEventSender, rx: Receiver<QaMsg>) {
|
||||
let mut cadence = QaCadenceState::from_env();
|
||||
|
||||
// Wait for commands; once we get Shutdown we return and end the thread.
|
||||
while let Ok(msg) = rx.recv_timeout(Duration::from_secs(60)) {
|
||||
match msg {
|
||||
QaMsg::Shutdown => {
|
||||
cadence.reset();
|
||||
break;
|
||||
}
|
||||
QaMsg::Tick => {
|
||||
// Placeholder: emit AppEvent::AutoQaUpdate or AutoReviewRequest.
|
||||
}
|
||||
QaMsg::TurnFinished { has_diff } => {
|
||||
if cadence.record_turn() {
|
||||
let diff_note = if has_diff { "changes detected" } else { "no changes" };
|
||||
let note = format!(
|
||||
"Auto QA cadence checkpoint ({} turns, {diff_note}).",
|
||||
cadence.cadence.get()
|
||||
);
|
||||
let _ = app_tx.send(AppEvent::AutoQaUpdate { note });
|
||||
}
|
||||
|
||||
if let Some(turns_waited) = cadence.record_review_turn(has_diff) {
|
||||
let summary = Some(format!(
|
||||
"Automated QA review requested after {turns_waited} turn(s) with workspace changes."
|
||||
));
|
||||
let _ = app_tx.send(AppEvent::AutoReviewRequest { summary });
|
||||
}
|
||||
}
|
||||
QaMsg::Finalize { has_diff } => {
|
||||
let note = format!(
|
||||
"Final QA check ({}-turn cooldown)",
|
||||
cadence.review_cooldown.get()
|
||||
);
|
||||
let _ = app_tx.send(AppEvent::AutoQaUpdate { note });
|
||||
|
||||
if has_diff && cadence.since_last_review > 0 {
|
||||
let summary = Some("Final safety review before stop.".to_string());
|
||||
let _ = app_tx.send(AppEvent::AutoReviewRequest { summary });
|
||||
}
|
||||
|
||||
cadence.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for QaOrchestratorHandle {
|
||||
fn drop(&mut self) {
|
||||
if let Some(join) = self.join.take() {
|
||||
let _ = self.tx.send(QaMsg::Shutdown);
|
||||
let _ = join.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user