mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
11898 lines
488 KiB
Rust
11898 lines
488 KiB
Rust
// Poisoned mutex should fail the program
|
||
#![allow(clippy::unwrap_used)]
|
||
|
||
use std::borrow::Cow;
|
||
use std::collections::HashMap;
|
||
use std::collections::HashSet;
|
||
use std::collections::VecDeque;
|
||
use std::path::Component;
|
||
use std::path::Path;
|
||
use std::path::PathBuf;
|
||
use std::sync::Arc;
|
||
use std::sync::Mutex;
|
||
use std::sync::RwLock;
|
||
use std::sync::Weak;
|
||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||
use std::time::Duration;
|
||
|
||
use async_channel::Receiver;
|
||
use async_channel::Sender;
|
||
use base64::Engine;
|
||
use code_apply_patch::ApplyPatchAction;
|
||
use code_apply_patch::MaybeApplyPatchVerified;
|
||
use code_browser::BrowserConfig as CodexBrowserConfig;
|
||
use code_browser::BrowserManager;
|
||
use code_otel::otel_event_manager::OtelEventManager;
|
||
use code_otel::otel_event_manager::ToolDecisionSource;
|
||
use code_protocol::config_types::ReasoningEffort as ProtoReasoningEffort;
|
||
use code_protocol::config_types::ReasoningSummary as ProtoReasoningSummary;
|
||
use code_protocol::protocol::AskForApproval as ProtoAskForApproval;
|
||
use code_protocol::protocol::ReviewDecision as ProtoReviewDecision;
|
||
use code_protocol::protocol::SandboxPolicy as ProtoSandboxPolicy;
|
||
use crate::config_types::ReasoningEffort as ReasoningEffortConfig;
|
||
use crate::config_types::ReasoningSummary as ReasoningSummaryConfig;
|
||
use crate::config_types::ClientTools;
|
||
// unused: AuthManager
|
||
// unused: ConversationHistoryResponseEvent
|
||
use code_protocol::protocol::TurnAbortReason;
|
||
use code_protocol::protocol::TurnAbortedEvent;
|
||
use futures::prelude::*;
|
||
use mcp_types::CallToolResult;
|
||
use serde::Serialize;
|
||
use serde_json;
|
||
use serde_json::json;
|
||
use tokio::sync::oneshot;
|
||
use tokio::task::AbortHandle;
|
||
use tracing::debug;
|
||
use tracing::error;
|
||
use tracing::info;
|
||
use tracing::trace;
|
||
use tracing::warn;
|
||
use uuid::Uuid;
|
||
use crate::AuthManager;
|
||
use crate::CodexAuth;
|
||
use crate::agent_tool::AgentStatusUpdatePayload;
|
||
use crate::git_worktree;
|
||
use crate::protocol::ApprovedCommandMatchKind;
|
||
use crate::protocol::WebSearchBeginEvent;
|
||
use crate::protocol::WebSearchCompleteEvent;
|
||
use code_protocol::mcp_protocol::AuthMode;
|
||
use crate::account_usage;
|
||
use crate::auth_accounts;
|
||
use crate::agent_defaults::{default_agent_configs, enabled_agent_model_specs};
|
||
use code_protocol::models::WebSearchAction;
|
||
use code_protocol::protocol::RolloutItem;
|
||
use shlex::split as shlex_split;
|
||
use shlex::try_join as shlex_try_join;
|
||
use chrono::Utc;
|
||
|
||
pub mod compact;
|
||
use self::compact::build_compacted_history;
|
||
use self::compact::collect_user_messages;
|
||
|
||
/// Initial submission ID for session configuration
|
||
pub(crate) const INITIAL_SUBMIT_ID: &str = "";
|
||
const HOOK_OUTPUT_LIMIT: usize = 2048;
|
||
const PENDING_ONLY_SENTINEL: &str = "__code_pending_only__";
|
||
const MIN_SHELL_TIMEOUT_MS: u64 = 30 * 60 * 1000;
|
||
|
||
#[derive(Clone, Default)]
|
||
struct ConfirmGuardRuntime {
|
||
patterns: Vec<ConfirmGuardPatternRuntime>,
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
struct ConfirmGuardPatternRuntime {
|
||
regex: regex_lite::Regex,
|
||
message: Option<String>,
|
||
raw: String,
|
||
}
|
||
|
||
impl ConfirmGuardRuntime {
|
||
fn from_config(config: &crate::config_types::ConfirmGuardConfig) -> Self {
|
||
let mut patterns = Vec::new();
|
||
for pattern in &config.patterns {
|
||
match regex_lite::Regex::new(&pattern.regex) {
|
||
Ok(regex) => patterns.push(ConfirmGuardPatternRuntime {
|
||
regex,
|
||
message: pattern.message.clone(),
|
||
raw: pattern.regex.clone(),
|
||
}),
|
||
Err(err) => {
|
||
tracing::warn!("Skipping confirm guard pattern `{}`: {err}", pattern.regex);
|
||
}
|
||
}
|
||
}
|
||
Self { patterns }
|
||
}
|
||
|
||
fn matched_pattern(&self, input: &str) -> Option<&ConfirmGuardPatternRuntime> {
|
||
self.patterns.iter().find(|pat| pat.regex.is_match(input))
|
||
}
|
||
|
||
fn is_empty(&self) -> bool {
|
||
self.patterns.is_empty()
|
||
}
|
||
}
|
||
|
||
impl ConfirmGuardPatternRuntime {
|
||
fn guidance(&self, original_label: &str, original_value: &str, suggested: &str) -> String {
|
||
let header = self
|
||
.message
|
||
.clone()
|
||
.unwrap_or_else(|| {
|
||
format!(
|
||
"Blocked command matching confirm guard pattern `{}`. Resend with 'confirm:' if you intend to proceed.",
|
||
self.raw
|
||
)
|
||
});
|
||
format!("{header}\n\n{original_label}: {original_value}\nresend_exact_argv: {suggested}")
|
||
}
|
||
}
|
||
|
||
fn to_proto_reasoning_effort(effort: ReasoningEffortConfig) -> ProtoReasoningEffort {
|
||
match effort {
|
||
ReasoningEffortConfig::Minimal => ProtoReasoningEffort::Minimal,
|
||
ReasoningEffortConfig::Low => ProtoReasoningEffort::Low,
|
||
ReasoningEffortConfig::Medium => ProtoReasoningEffort::Medium,
|
||
ReasoningEffortConfig::High => ProtoReasoningEffort::High,
|
||
ReasoningEffortConfig::None => ProtoReasoningEffort::Minimal,
|
||
}
|
||
}
|
||
|
||
fn to_proto_reasoning_summary(summary: ReasoningSummaryConfig) -> ProtoReasoningSummary {
|
||
match summary {
|
||
ReasoningSummaryConfig::Auto => ProtoReasoningSummary::Auto,
|
||
ReasoningSummaryConfig::Concise => ProtoReasoningSummary::Concise,
|
||
ReasoningSummaryConfig::Detailed => ProtoReasoningSummary::Detailed,
|
||
ReasoningSummaryConfig::None => ProtoReasoningSummary::None,
|
||
}
|
||
}
|
||
|
||
fn to_proto_approval_policy(policy: AskForApproval) -> ProtoAskForApproval {
|
||
match policy {
|
||
AskForApproval::UnlessTrusted => ProtoAskForApproval::UnlessTrusted,
|
||
AskForApproval::OnFailure => ProtoAskForApproval::OnFailure,
|
||
AskForApproval::OnRequest => ProtoAskForApproval::OnRequest,
|
||
AskForApproval::Never => ProtoAskForApproval::Never,
|
||
}
|
||
}
|
||
|
||
fn to_proto_sandbox_policy(policy: SandboxPolicy) -> ProtoSandboxPolicy {
|
||
match policy {
|
||
SandboxPolicy::DangerFullAccess => ProtoSandboxPolicy::DangerFullAccess,
|
||
SandboxPolicy::ReadOnly => ProtoSandboxPolicy::ReadOnly,
|
||
SandboxPolicy::WorkspaceWrite {
|
||
writable_roots,
|
||
network_access,
|
||
exclude_tmpdir_env_var,
|
||
exclude_slash_tmp,
|
||
allow_git_writes,
|
||
} => ProtoSandboxPolicy::WorkspaceWrite {
|
||
writable_roots,
|
||
network_access,
|
||
exclude_tmpdir_env_var,
|
||
exclude_slash_tmp,
|
||
allow_git_writes,
|
||
},
|
||
}
|
||
}
|
||
|
||
fn to_proto_review_decision(decision: ReviewDecision) -> ProtoReviewDecision {
|
||
match decision {
|
||
ReviewDecision::Approved => ProtoReviewDecision::Approved,
|
||
ReviewDecision::ApprovedForSession => ProtoReviewDecision::ApprovedForSession,
|
||
ReviewDecision::Denied => ProtoReviewDecision::Denied,
|
||
ReviewDecision::Abort => ProtoReviewDecision::Abort,
|
||
}
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
trait MutexExt<T> {
|
||
fn lock_unchecked(&self) -> std::sync::MutexGuard<'_, T>;
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
impl<T> MutexExt<T> for Mutex<T> {
|
||
fn lock_unchecked(&self) -> std::sync::MutexGuard<'_, T> {
|
||
#[expect(clippy::expect_used)]
|
||
self.lock().expect("poisoned lock")
|
||
}
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
pub(crate) struct TurnContext {
|
||
pub(crate) client: ModelClient,
|
||
pub(crate) cwd: PathBuf,
|
||
pub(crate) base_instructions: Option<String>,
|
||
pub(crate) user_instructions: Option<String>,
|
||
pub(crate) approval_policy: AskForApproval,
|
||
pub(crate) sandbox_policy: SandboxPolicy,
|
||
pub(crate) shell_environment_policy: ShellEnvironmentPolicy,
|
||
pub(crate) is_review_mode: bool,
|
||
pub(crate) text_format_override: Option<TextFormat>,
|
||
}
|
||
|
||
/// Gather ephemeral, per-turn context that should not be persisted to history.
|
||
/// Combines environment info and (when enabled) a live browser snapshot and status.
|
||
struct EphemeralJar {
|
||
items: Vec<ResponseItem>,
|
||
}
|
||
|
||
impl EphemeralJar {
|
||
fn new() -> Self {
|
||
Self { items: Vec::new() }
|
||
}
|
||
|
||
fn into_items(self) -> Vec<ResponseItem> {
|
||
self.items
|
||
}
|
||
}
|
||
|
||
/// Convert a vector of core `InputItem`s into a single `ResponseInputItem`
|
||
/// suitable for sending to the model. Handles images (local and pre‑encoded)
|
||
/// and our fork's ephemeral image variant by inlining a brief metadata marker
|
||
/// followed by the image as a data URL.
|
||
fn response_input_from_core_items(items: Vec<InputItem>) -> ResponseInputItem {
|
||
let mut content_items = Vec::new();
|
||
|
||
for item in items {
|
||
match item {
|
||
InputItem::Text { text } => {
|
||
content_items.push(ContentItem::InputText { text });
|
||
}
|
||
InputItem::Image { image_url } => {
|
||
content_items.push(ContentItem::InputImage { image_url });
|
||
}
|
||
InputItem::LocalImage { path } => match std::fs::read(&path) {
|
||
Ok(bytes) => {
|
||
let mime = mime_guess::from_path(&path)
|
||
.first()
|
||
.map(|m| m.essence_str().to_owned())
|
||
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||
content_items.push(ContentItem::InputImage {
|
||
image_url: format!("data:{mime};base64,{encoded}"),
|
||
});
|
||
}
|
||
Err(err) => {
|
||
tracing::warn!(
|
||
"Skipping image {} – could not read file: {}",
|
||
path.display(),
|
||
err
|
||
);
|
||
}
|
||
},
|
||
InputItem::EphemeralImage { path, metadata } => {
|
||
tracing::info!(
|
||
"Processing ephemeral image: {} with metadata: {:?}",
|
||
path.display(),
|
||
metadata
|
||
);
|
||
|
||
if let Some(meta) = metadata {
|
||
content_items.push(ContentItem::InputText {
|
||
text: format!("[EPHEMERAL:{}]", meta),
|
||
});
|
||
}
|
||
|
||
match std::fs::read(&path) {
|
||
Ok(bytes) => {
|
||
let mime = mime_guess::from_path(&path)
|
||
.first()
|
||
.map(|m| m.essence_str().to_owned())
|
||
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||
tracing::info!("Created ephemeral image data URL with mime: {}", mime);
|
||
content_items.push(ContentItem::InputImage {
|
||
image_url: format!("data:{mime};base64,{encoded}"),
|
||
});
|
||
}
|
||
Err(err) => {
|
||
tracing::error!(
|
||
"Failed to read ephemeral image {} – {}",
|
||
path.display(),
|
||
err
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
ResponseInputItem::Message {
|
||
role: "user".to_string(),
|
||
content: content_items,
|
||
}
|
||
}
|
||
|
||
fn convert_call_tool_result_to_function_call_output_payload(
|
||
result: &Result<CallToolResult, String>,
|
||
) -> FunctionCallOutputPayload {
|
||
match result {
|
||
Ok(ok) => FunctionCallOutputPayload {
|
||
content: serde_json::to_string(ok)
|
||
.unwrap_or_else(|e| format!("JSON serialization error: {e}")),
|
||
success: Some(true),
|
||
},
|
||
Err(e) => FunctionCallOutputPayload {
|
||
content: format!("err: {e:?}"),
|
||
success: Some(false),
|
||
},
|
||
}
|
||
}
|
||
|
||
fn get_git_branch(cwd: &std::path::Path) -> Option<String> {
|
||
let head_path = cwd.join(".git/HEAD");
|
||
if let Ok(contents) = std::fs::read_to_string(&head_path) {
|
||
if let Some(rest) = contents.trim().strip_prefix("ref: ") {
|
||
if let Some(branch) = rest.trim().rsplit('/').next() {
|
||
return Some(branch.to_string());
|
||
}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
fn maybe_update_from_model_info<T: Copy + PartialEq>(
|
||
field: &mut Option<T>,
|
||
old_default: Option<T>,
|
||
new_default: Option<T>,
|
||
) {
|
||
if field.is_none() {
|
||
if let Some(new_val) = new_default {
|
||
*field = Some(new_val);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if let (Some(current), Some(old_val)) = (*field, old_default) {
|
||
if current == old_val {
|
||
*field = new_default;
|
||
}
|
||
}
|
||
}
|
||
|
||
async fn build_turn_status_items(sess: &Session) -> Vec<ResponseItem> {
|
||
let mut jar = EphemeralJar::new();
|
||
|
||
// Collect environment context
|
||
let cwd = sess.cwd.to_string_lossy().to_string();
|
||
let branch = get_git_branch(&sess.cwd).unwrap_or_else(|| "unknown".to_string());
|
||
let reasoning_effort = sess.client.get_reasoning_effort();
|
||
|
||
// Build current system status (UI-only; not persisted)
|
||
let mut current_status = format!(
|
||
r#"== System Status ==
|
||
[automatic message added by system]
|
||
|
||
cwd: {cwd}
|
||
branch: {branch}
|
||
reasoning: {reasoning_effort:?}"#
|
||
);
|
||
|
||
// Prepare browser context + optional screenshot
|
||
let mut screenshot_content: Option<ContentItem> = None;
|
||
let mut include_screenshot = false;
|
||
|
||
if let Some(browser_manager) = code_browser::global::get_browser_manager().await {
|
||
if browser_manager.is_enabled().await {
|
||
// Get current URL and browser info
|
||
let url = browser_manager
|
||
.get_current_url()
|
||
.await
|
||
.unwrap_or_else(|| "unknown".to_string());
|
||
|
||
// Try to get a tab title if available
|
||
let title = match browser_manager.get_or_create_page().await {
|
||
Ok(page) => page.get_title().await,
|
||
Err(_) => None,
|
||
};
|
||
|
||
// Get browser type description
|
||
let browser_type = browser_manager.get_browser_type().await;
|
||
|
||
// Get viewport dimensions
|
||
let (viewport_width, viewport_height) = browser_manager.get_viewport_size().await;
|
||
let viewport_info = format!(" | Viewport: {}x{}", viewport_width, viewport_height);
|
||
|
||
// Get cursor position
|
||
let cursor_info = match browser_manager.get_cursor_position().await {
|
||
Ok((x, y)) => format!(
|
||
" | Mouse position: ({:.0}, {:.0}) [shown as a blue cursor in the screenshot]",
|
||
x, y
|
||
),
|
||
Err(_) => String::new(),
|
||
};
|
||
|
||
// Try to capture screenshot and compare with last one
|
||
let screenshot_status = match capture_browser_screenshot(sess).await {
|
||
Ok((screenshot_path, _url)) => {
|
||
// Always update the UI with the latest screenshot, even if unchanged for LLM payload
|
||
// This ensures the user sees that a fresh capture occurred each turn.
|
||
add_pending_screenshot(sess, screenshot_path.clone(), url.clone());
|
||
// Check if screenshot has changed using image hashing
|
||
let mut last_screenshot_info = sess.last_screenshot_info.lock().unwrap();
|
||
|
||
// Compute hash for current screenshot
|
||
let current_hash =
|
||
crate::image_comparison::compute_image_hash(&screenshot_path).ok();
|
||
|
||
let should_include_screenshot = if let (
|
||
Some((_last_path, last_phash, last_dhash)),
|
||
Some((cur_phash, cur_dhash)),
|
||
) =
|
||
(last_screenshot_info.as_ref(), current_hash.as_ref())
|
||
{
|
||
// Compare hashes to see if screenshots are similar
|
||
let similar = crate::image_comparison::are_hashes_similar(
|
||
last_phash, last_dhash, cur_phash, cur_dhash,
|
||
);
|
||
|
||
if !similar {
|
||
// Screenshot has changed, include it
|
||
*last_screenshot_info = Some((
|
||
screenshot_path.clone(),
|
||
cur_phash.clone(),
|
||
cur_dhash.clone(),
|
||
));
|
||
true
|
||
} else {
|
||
// Screenshot unchanged
|
||
false
|
||
}
|
||
} else {
|
||
// No previous screenshot or hash computation failed, include it
|
||
if let Some((phash, dhash)) = current_hash {
|
||
*last_screenshot_info = Some((screenshot_path.clone(), phash, dhash));
|
||
}
|
||
true
|
||
};
|
||
|
||
if should_include_screenshot {
|
||
if let Ok(bytes) = std::fs::read(&screenshot_path) {
|
||
let mime = mime_guess::from_path(&screenshot_path)
|
||
.first()
|
||
.map(|m| m.to_string())
|
||
.unwrap_or_else(|| "image/png".to_string());
|
||
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||
screenshot_content = Some(ContentItem::InputImage {
|
||
image_url: format!("data:{mime};base64,{encoded}"),
|
||
});
|
||
include_screenshot = true;
|
||
""
|
||
} else {
|
||
" [Screenshot file read failed]"
|
||
}
|
||
} else {
|
||
" [Screenshot unchanged]"
|
||
}
|
||
}
|
||
Err(err_msg) => {
|
||
// Include error message so LLM knows screenshot failed
|
||
format!(" [Screenshot unavailable: {}]", err_msg).leak()
|
||
}
|
||
};
|
||
|
||
let status_line = if let Some(t) = title {
|
||
format!(
|
||
"Browser url: {} — {} ({}){}{}{}. You can interact with it using browser_* tools.",
|
||
url, t, browser_type, viewport_info, cursor_info, screenshot_status
|
||
)
|
||
} else {
|
||
format!(
|
||
"Browser url: {} ({}){}{}{}. You can interact with it using browser_* tools.",
|
||
url, browser_type, viewport_info, cursor_info, screenshot_status
|
||
)
|
||
};
|
||
current_status.push_str("\n");
|
||
current_status.push_str(&status_line);
|
||
}
|
||
}
|
||
|
||
// Check if system status has changed
|
||
let mut last_status = sess.last_system_status.lock().unwrap();
|
||
let status_changed = last_status.as_ref() != Some(¤t_status);
|
||
|
||
if status_changed {
|
||
// Update last status
|
||
*last_status = Some(current_status.clone());
|
||
}
|
||
|
||
// Only include items if something has changed or is new
|
||
let mut content: Vec<ContentItem> = Vec::new();
|
||
|
||
if status_changed {
|
||
content.push(ContentItem::InputText {
|
||
text: current_status,
|
||
});
|
||
}
|
||
|
||
if include_screenshot {
|
||
if let Some(image) = screenshot_content {
|
||
content.push(image);
|
||
}
|
||
}
|
||
|
||
if !content.is_empty() {
|
||
jar.items.push(ResponseItem::Message {
|
||
id: None,
|
||
role: "user".to_string(),
|
||
content,
|
||
});
|
||
}
|
||
|
||
jar.into_items()
|
||
}
|
||
use crate::agent_tool::AGENT_MANAGER;
|
||
use crate::agent_tool::AgentStatus;
|
||
use crate::agent_tool::AgentToolRequest;
|
||
use crate::agent_tool::CancelAgentParams;
|
||
use crate::agent_tool::CheckAgentStatusParams;
|
||
use crate::agent_tool::GetAgentResultParams;
|
||
use crate::agent_tool::ListAgentsParams;
|
||
use crate::agent_tool::normalize_agent_name;
|
||
use crate::agent_tool::RunAgentParams;
|
||
use crate::agent_tool::WaitForAgentParams;
|
||
use crate::apply_patch::convert_apply_patch_to_protocol;
|
||
use crate::apply_patch::get_writable_roots;
|
||
use crate::apply_patch::{self, ApplyPatchResult};
|
||
use crate::client::ModelClient;
|
||
use crate::client_common::{Prompt, ResponseEvent, TextFormat, REVIEW_PROMPT};
|
||
use crate::environment_context::EnvironmentContext;
|
||
use crate::user_instructions::UserInstructions;
|
||
use crate::config::{persist_model_selection, Config};
|
||
use crate::config_types::ProjectHookEvent;
|
||
use crate::config_types::ShellEnvironmentPolicy;
|
||
use crate::conversation_history::ConversationHistory;
|
||
use crate::error::CodexErr;
|
||
use crate::error::Result as CodexResult;
|
||
use crate::error::SandboxErr;
|
||
use crate::error::get_error_message_ui;
|
||
use crate::exec::ExecParams;
|
||
use crate::exec::ExecToolCallOutput;
|
||
use crate::exec::SandboxType;
|
||
use crate::exec::StdoutStream;
|
||
use crate::exec::StreamOutput;
|
||
use crate::exec::EXEC_CAPTURE_MAX_BYTES;
|
||
use crate::exec::process_exec_tool_call;
|
||
use crate::review_format::format_review_findings_block;
|
||
use crate::exec_env::create_env;
|
||
use crate::mcp_connection_manager::McpConnectionManager;
|
||
use crate::mcp_tool_call::handle_mcp_tool_call;
|
||
use crate::model_family::{derive_default_model_family, find_family_for_model};
|
||
use code_protocol::models::ContentItem;
|
||
use code_protocol::models::FunctionCallOutputPayload;
|
||
use code_protocol::models::LocalShellAction;
|
||
use code_protocol::models::ReasoningItemContent;
|
||
use code_protocol::models::ReasoningItemReasoningSummary;
|
||
use code_protocol::models::ResponseInputItem;
|
||
use code_protocol::models::ResponseItem;
|
||
use code_protocol::models::ShellToolCallParams;
|
||
use crate::openai_model_info::get_model_info;
|
||
use crate::openai_tools::ToolsConfig;
|
||
use crate::openai_tools::get_openai_tools;
|
||
use crate::slash_commands::get_enabled_agents;
|
||
use crate::dry_run_guard::{analyze_command, DryRunAnalysis, DryRunDisposition, DryRunGuardState};
|
||
use crate::parse_command::parse_command;
|
||
use crate::plan_tool::handle_update_plan;
|
||
use crate::project_doc::get_user_instructions;
|
||
use crate::project_features::{ProjectCommand, ProjectHook, ProjectHooks};
|
||
use crate::protocol::AgentMessageDeltaEvent;
|
||
use crate::protocol::AgentMessageEvent;
|
||
use crate::protocol::AgentReasoningDeltaEvent;
|
||
use crate::protocol::AgentReasoningEvent;
|
||
use crate::protocol::AgentReasoningRawContentDeltaEvent;
|
||
use crate::protocol::AgentReasoningRawContentEvent;
|
||
use crate::protocol::AgentReasoningSectionBreakEvent;
|
||
use crate::protocol::AgentStatusUpdateEvent;
|
||
use crate::protocol::ApplyPatchApprovalRequestEvent;
|
||
use crate::protocol::AskForApproval;
|
||
use crate::protocol::BackgroundEventEvent;
|
||
use crate::protocol::BrowserScreenshotUpdateEvent;
|
||
use crate::protocol::ErrorEvent;
|
||
use crate::protocol::Event;
|
||
use crate::protocol::EventMsg;
|
||
use crate::protocol::ExecApprovalRequestEvent;
|
||
use crate::protocol::ExecCommandBeginEvent;
|
||
use crate::protocol::ExecCommandEndEvent;
|
||
use crate::protocol::FileChange;
|
||
use crate::protocol::InputItem;
|
||
use crate::protocol::Op;
|
||
use crate::protocol::PatchApplyBeginEvent;
|
||
use crate::protocol::PatchApplyEndEvent;
|
||
use crate::protocol::RateLimitSnapshotEvent;
|
||
use crate::protocol::TokenCountEvent;
|
||
use crate::protocol::TokenUsageInfo;
|
||
use crate::protocol::ReviewDecision;
|
||
use crate::protocol::ValidationGroup;
|
||
use crate::protocol::ReviewOutputEvent;
|
||
use crate::protocol::ReviewRequest;
|
||
use crate::protocol::SandboxPolicy;
|
||
use crate::protocol::SessionConfiguredEvent;
|
||
use crate::protocol::Submission;
|
||
use crate::protocol::TaskCompleteEvent;
|
||
use std::sync::OnceLock;
|
||
use tokio::sync::Notify;
|
||
use crate::protocol::TurnDiffEvent;
|
||
use crate::rollout::RolloutRecorder;
|
||
use crate::safety::SafetyCheck;
|
||
use crate::safety::assess_command_safety;
|
||
use crate::safety::assess_safety_for_untrusted_command;
|
||
use crate::shell;
|
||
use crate::turn_diff_tracker::TurnDiffTracker;
|
||
use crate::user_notification::UserNotification;
|
||
use crate::util::backoff;
|
||
use code_protocol::protocol::SessionSource;
|
||
use crate::rollout::recorder::SessionStateSnapshot;
|
||
use serde_json::Value;
|
||
use crate::exec_command::ExecSessionManager;
|
||
|
||
/// The high-level interface to the Codex system.
|
||
/// It operates as a queue pair where you send submissions and receive events.
|
||
pub struct Codex {
|
||
next_id: AtomicU64,
|
||
tx_sub: Sender<Submission>,
|
||
rx_event: Receiver<Event>,
|
||
}
|
||
|
||
// Allow internal components (like background exec completions) to trigger a new
|
||
// turn without fabricating a visible user message. We enqueue an empty
|
||
// UserInput; the model will only see queued developer/system items.
|
||
static TX_SUB_GLOBAL: OnceLock<Sender<Submission>> = OnceLock::new();
|
||
static ANY_BG_NOTIFY: OnceLock<std::sync::Arc<Notify>> = OnceLock::new();
|
||
|
||
/// Wrapper returned by [`Codex::spawn`] containing the spawned [`Codex`],
|
||
/// the submission id for the initial `ConfigureSession` request and the
|
||
/// unique session id.
|
||
pub struct CodexSpawnOk {
|
||
pub codex: Codex,
|
||
pub init_id: String,
|
||
pub session_id: Uuid,
|
||
}
|
||
|
||
impl Codex {
|
||
/// Spawn a new [`Codex`] and initialize the session.
|
||
pub async fn spawn(config: Config, auth: Option<CodexAuth>) -> CodexResult<CodexSpawnOk> {
|
||
let auth_manager = auth.map(crate::AuthManager::from_auth_for_testing);
|
||
Self::spawn_with_auth_manager(config, auth_manager).await
|
||
}
|
||
|
||
pub async fn spawn_with_auth_manager(
|
||
config: Config,
|
||
auth_manager: Option<Arc<AuthManager>>,
|
||
) -> CodexResult<CodexSpawnOk> {
|
||
// experimental resume path (undocumented)
|
||
let resume_path = config.experimental_resume.clone();
|
||
info!("resume_path: {resume_path:?}");
|
||
// Use an unbounded submission queue to avoid any possibility of back‑pressure
|
||
// between the TUI submit worker and the core loop during interrupts/cancels.
|
||
let (tx_sub, rx_sub) = async_channel::unbounded();
|
||
let (tx_event, rx_event) = async_channel::unbounded();
|
||
|
||
let user_instructions = get_user_instructions(&config).await;
|
||
|
||
let configure_session = Op::ConfigureSession {
|
||
provider: config.model_provider.clone(),
|
||
model: config.model.clone(),
|
||
model_reasoning_effort: config.model_reasoning_effort,
|
||
model_reasoning_summary: config.model_reasoning_summary,
|
||
model_text_verbosity: config.model_text_verbosity,
|
||
user_instructions,
|
||
base_instructions: config.base_instructions.clone(),
|
||
approval_policy: config.approval_policy,
|
||
sandbox_policy: config.sandbox_policy.clone(),
|
||
disable_response_storage: config.disable_response_storage,
|
||
notify: config.notify.clone(),
|
||
cwd: config.cwd.clone(),
|
||
resume_path: resume_path.clone(),
|
||
};
|
||
|
||
let config = Arc::new(config);
|
||
|
||
// Generate a unique ID for the lifetime of this Codex session.
|
||
let session_id = Uuid::new_v4();
|
||
|
||
// This task will run until Op::Shutdown is received.
|
||
tokio::spawn(submission_loop(
|
||
session_id,
|
||
config,
|
||
auth_manager,
|
||
rx_sub,
|
||
tx_event,
|
||
));
|
||
let codex = Codex {
|
||
next_id: AtomicU64::new(0),
|
||
tx_sub,
|
||
rx_event,
|
||
};
|
||
// Make a clone of tx_sub available for internal auto-turn triggers.
|
||
let _ = TX_SUB_GLOBAL.set(codex.tx_sub.clone());
|
||
let _ = ANY_BG_NOTIFY.set(std::sync::Arc::new(Notify::new()));
|
||
let init_id = codex.submit(configure_session).await?;
|
||
|
||
Ok(CodexSpawnOk {
|
||
codex,
|
||
init_id,
|
||
session_id,
|
||
})
|
||
}
|
||
|
||
/// Submit the `op` wrapped in a `Submission` with a unique ID.
|
||
pub async fn submit(&self, op: Op) -> CodexResult<String> {
|
||
let id = self
|
||
.next_id
|
||
.fetch_add(1, std::sync::atomic::Ordering::SeqCst)
|
||
.to_string();
|
||
let sub = Submission { id: id.clone(), op };
|
||
self.submit_with_id(sub).await?;
|
||
Ok(id)
|
||
}
|
||
|
||
/// Use sparingly: prefer `submit()` so Codex is responsible for generating
|
||
/// unique IDs for each submission.
|
||
pub async fn submit_with_id(&self, sub: Submission) -> CodexResult<()> {
|
||
self.tx_sub
|
||
.send(sub)
|
||
.await
|
||
.map_err(|_| CodexErr::InternalAgentDied)?;
|
||
Ok(())
|
||
}
|
||
|
||
pub async fn next_event(&self) -> CodexResult<Event> {
|
||
let event = self
|
||
.rx_event
|
||
.recv()
|
||
.await
|
||
.map_err(|_| CodexErr::InternalAgentDied)?;
|
||
Ok(event)
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||
pub struct ApprovedCommandPattern {
|
||
argv: Vec<String>,
|
||
kind: ApprovedCommandMatchKind,
|
||
semantic_prefix: Option<Vec<String>>,
|
||
}
|
||
|
||
impl ApprovedCommandPattern {
|
||
pub(crate) fn new(
|
||
argv: Vec<String>,
|
||
kind: ApprovedCommandMatchKind,
|
||
semantic_prefix: Option<Vec<String>>,
|
||
) -> Self {
|
||
let semantic_prefix = if matches!(kind, ApprovedCommandMatchKind::Prefix) {
|
||
semantic_prefix.or_else(|| Some(argv.clone()))
|
||
} else {
|
||
None
|
||
};
|
||
Self {
|
||
argv,
|
||
kind,
|
||
semantic_prefix,
|
||
}
|
||
}
|
||
|
||
pub(crate) fn matches(&self, command: &[String]) -> bool {
|
||
match self.kind {
|
||
ApprovedCommandMatchKind::Exact => command == self.argv.as_slice(),
|
||
ApprovedCommandMatchKind::Prefix => {
|
||
if command.starts_with(&self.argv) {
|
||
return true;
|
||
}
|
||
if let (Some(pattern), Some(candidate)) = (
|
||
self.semantic_prefix.as_ref(),
|
||
semantic_tokens(command),
|
||
) {
|
||
return candidate.starts_with(pattern);
|
||
}
|
||
false
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn argv(&self) -> &[String] { &self.argv }
|
||
|
||
pub fn kind(&self) -> ApprovedCommandMatchKind { self.kind }
|
||
}
|
||
|
||
fn semantic_tokens(command: &[String]) -> Option<Vec<String>> {
|
||
if command.is_empty() {
|
||
return None;
|
||
}
|
||
if let Some(tokens) = shell_script_tokens(command) {
|
||
return Some(tokens);
|
||
}
|
||
Some(command.to_vec())
|
||
}
|
||
|
||
fn shell_script_tokens(command: &[String]) -> Option<Vec<String>> {
|
||
if command.len() == 3 && is_shell_wrapper(&command[0], &command[1]) {
|
||
if let Some(tokens) = shlex_split(&command[2]) {
|
||
return Some(tokens);
|
||
}
|
||
return Some(vec![command[2].clone()]);
|
||
}
|
||
None
|
||
}
|
||
|
||
fn is_shell_wrapper(shell: &str, flag: &str) -> bool {
|
||
let file_name = Path::new(shell)
|
||
.file_name()
|
||
.and_then(|s| s.to_str())
|
||
.unwrap_or(shell)
|
||
.to_ascii_lowercase();
|
||
matches!(
|
||
file_name.as_str(),
|
||
"bash" | "sh" | "zsh" | "ksh" | "fish" | "dash"
|
||
) && matches!(flag, "-lc" | "-c")
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
struct RunningExecMeta {
|
||
sub_id: String,
|
||
order_meta: crate::protocol::OrderMeta,
|
||
cancel_flag: Arc<AtomicBool>,
|
||
end_emitted: Arc<AtomicBool>,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
enum WaitInterruptReason {
|
||
UserMessage,
|
||
SessionAborted,
|
||
}
|
||
|
||
#[derive(Default)]
|
||
struct State {
|
||
approved_commands: HashSet<ApprovedCommandPattern>,
|
||
current_task: Option<AgentTask>,
|
||
pending_approvals: HashMap<String, oneshot::Sender<ReviewDecision>>,
|
||
pending_input: Vec<ResponseInputItem>,
|
||
pending_user_input: Vec<QueuedUserInput>,
|
||
history: ConversationHistory,
|
||
/// Tracks which completed agents (by id) have already been returned to the
|
||
/// model for a given batch when using `agent` with `action="wait"` and
|
||
/// `return_all=false`.
|
||
/// This enables sequential waiting behavior across multiple calls.
|
||
seen_completed_agents_by_batch: HashMap<String, HashSet<String>>,
|
||
/// Scratchpad that buffers streamed items/deltas for the current HTTP attempt
|
||
/// so we can seed retries without losing progress.
|
||
turn_scratchpad: Option<TurnScratchpad>,
|
||
/// Per-submission monotonic event sequence (resets at TaskStarted)
|
||
event_seq_by_sub_id: HashMap<String, u64>,
|
||
/// Per-submission sequence used when synthesizing background OrderMeta.
|
||
background_seq_by_sub_id: HashMap<String, u64>,
|
||
/// 1-based ordinal of the current HTTP request attempt in this session.
|
||
request_ordinal: u64,
|
||
dry_run_guard: DryRunGuardState,
|
||
/// Background execs by call_id
|
||
background_execs: std::collections::HashMap<String, BackgroundExecState>,
|
||
/// Active foreground exec calls keyed by call_id (ExecCommandBegin/End lifecycle)
|
||
running_execs: HashMap<String, RunningExecMeta>,
|
||
next_internal_sub_id: u64,
|
||
token_usage_info: Option<TokenUsageInfo>,
|
||
latest_rate_limits: Option<RateLimitSnapshotEvent>,
|
||
pending_manual_compacts: VecDeque<String>,
|
||
wait_interrupt_epoch: u64,
|
||
wait_interrupt_reason: Option<WaitInterruptReason>,
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
pub(crate) struct QueuedUserInput {
|
||
submission_id: String,
|
||
response_item: ResponseInputItem,
|
||
core_items: Vec<InputItem>,
|
||
}
|
||
|
||
/// Buffers partial turn progress produced during a single HTTP streaming attempt.
|
||
/// This is not recorded to persistent history. It is only used to seed retries
|
||
/// when the SSE stream disconnects mid‑turn.
|
||
#[derive(Default, Clone, Debug)]
|
||
struct TurnScratchpad {
|
||
/// Output items that reached `response.output_item.done` during this attempt
|
||
items: Vec<ResponseItem>,
|
||
/// Tool outputs we produced locally in reaction to output items
|
||
responses: Vec<ResponseInputItem>,
|
||
/// Last assistant text fragment received via deltas (not yet finalized)
|
||
partial_assistant_text: String,
|
||
/// Last reasoning summary fragment received via deltas (not yet finalized)
|
||
partial_reasoning_summary: String,
|
||
}
|
||
|
||
#[derive(Clone)]
|
||
struct AccountUsageContext {
|
||
code_home: PathBuf,
|
||
account_id: String,
|
||
plan: Option<String>,
|
||
}
|
||
|
||
fn account_usage_context(sess: &Session) -> Option<AccountUsageContext> {
|
||
let code_home = sess.client.code_home().to_path_buf();
|
||
let account_id = auth_accounts::get_active_account_id(&code_home).ok().flatten()?;
|
||
let plan = auth_accounts::find_account(&code_home, &account_id)
|
||
.ok()
|
||
.flatten()
|
||
.and_then(|account| {
|
||
account
|
||
.tokens
|
||
.as_ref()
|
||
.and_then(|tokens| tokens.id_token.get_chatgpt_plan_type())
|
||
});
|
||
Some(AccountUsageContext {
|
||
code_home,
|
||
account_id,
|
||
plan,
|
||
})
|
||
}
|
||
|
||
fn spawn_usage_task<F>(task: F)
|
||
where
|
||
F: FnOnce() + Send + 'static,
|
||
{
|
||
let _ = tokio::task::spawn_blocking(task);
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
struct BackgroundExecState {
|
||
notify: std::sync::Arc<tokio::sync::Notify>,
|
||
result_cell: std::sync::Arc<std::sync::Mutex<Option<ExecToolCallOutput>>>,
|
||
tail_buf: Option<std::sync::Arc<std::sync::Mutex<Vec<u8>>>>,
|
||
cmd_display: String,
|
||
suppress_event: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||
task_handle: Option<tokio::task::JoinHandle<()>>,
|
||
order_meta_for_end: crate::protocol::OrderMeta,
|
||
sub_id: String,
|
||
}
|
||
|
||
/// Context for an initialized model agent
|
||
///
|
||
/// A session has at most 1 running agent at a time, and can be interrupted by user input.
|
||
pub(crate) struct Session {
|
||
id: Uuid,
|
||
client: ModelClient,
|
||
tx_event: Sender<Event>,
|
||
|
||
/// The session's current working directory. All relative paths provided by
|
||
/// the model as well as sandbox policies are resolved against this path
|
||
/// instead of `std::env::current_dir()`.
|
||
cwd: PathBuf,
|
||
base_instructions: Option<String>,
|
||
user_instructions: Option<String>,
|
||
approval_policy: AskForApproval,
|
||
sandbox_policy: SandboxPolicy,
|
||
shell_environment_policy: ShellEnvironmentPolicy,
|
||
_writable_roots: Vec<PathBuf>,
|
||
disable_response_storage: bool,
|
||
tools_config: ToolsConfig,
|
||
|
||
/// Manager for external MCP servers/tools.
|
||
mcp_connection_manager: McpConnectionManager,
|
||
client_tools: Option<ClientTools>,
|
||
#[allow(dead_code)]
|
||
session_manager: ExecSessionManager,
|
||
|
||
/// Configuration for available agent models
|
||
agents: Vec<crate::config_types::AgentConfig>,
|
||
|
||
/// External notifier command (will be passed as args to exec()). When
|
||
/// `None` this feature is disabled.
|
||
notify: Option<Vec<String>>,
|
||
|
||
/// Optional rollout recorder for persisting the conversation transcript so
|
||
/// sessions can be replayed or inspected later.
|
||
rollout: Mutex<Option<RolloutRecorder>>,
|
||
state: Mutex<State>,
|
||
code_linux_sandbox_exe: Option<PathBuf>,
|
||
user_shell: shell::Shell,
|
||
show_raw_agent_reasoning: bool,
|
||
/// Pending browser screenshots to include in the next model request
|
||
#[allow(dead_code)]
|
||
pending_browser_screenshots: Mutex<Vec<PathBuf>>,
|
||
/// Track the last system status to detect changes
|
||
last_system_status: Mutex<Option<String>>,
|
||
/// Track the last screenshot path and hash to detect changes
|
||
last_screenshot_info: Mutex<Option<(PathBuf, Vec<u8>, Vec<u8>)>>, // (path, phash, dhash)
|
||
confirm_guard: ConfirmGuardRuntime,
|
||
project_hooks: ProjectHooks,
|
||
project_commands: Vec<ProjectCommand>,
|
||
hook_guard: AtomicBool,
|
||
github: Arc<RwLock<crate::config_types::GithubConfig>>,
|
||
validation: Arc<RwLock<crate::config_types::ValidationConfig>>,
|
||
self_handle: Weak<Session>,
|
||
active_review: Mutex<Option<ReviewRequest>>,
|
||
next_turn_text_format: Mutex<Option<TextFormat>>,
|
||
}
|
||
|
||
struct HookGuard<'a> {
|
||
flag: &'a AtomicBool,
|
||
}
|
||
|
||
impl<'a> HookGuard<'a> {
|
||
fn try_acquire(flag: &'a AtomicBool) -> Option<Self> {
|
||
flag
|
||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||
.ok()
|
||
.map(|_| Self { flag })
|
||
}
|
||
}
|
||
|
||
impl Drop for HookGuard<'_> {
|
||
fn drop(&mut self) {
|
||
self.flag.store(false, Ordering::SeqCst);
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub(crate) struct ToolCallCtx {
|
||
pub sub_id: String,
|
||
pub call_id: String,
|
||
pub seq_hint: Option<u64>,
|
||
pub output_index: Option<u32>,
|
||
}
|
||
|
||
impl ToolCallCtx {
|
||
pub fn new(sub_id: String, call_id: String, seq_hint: Option<u64>, output_index: Option<u32>) -> Self {
|
||
Self { sub_id, call_id, seq_hint, output_index }
|
||
}
|
||
|
||
pub fn order_meta(&self, req_ordinal: u64) -> crate::protocol::OrderMeta {
|
||
crate::protocol::OrderMeta { request_ordinal: req_ordinal, output_index: self.output_index, sequence_number: self.seq_hint }
|
||
}
|
||
}
|
||
|
||
impl Session {
|
||
#[allow(dead_code)]
|
||
pub(crate) fn get_writable_roots(&self) -> &[PathBuf] {
|
||
&self._writable_roots
|
||
}
|
||
|
||
pub(crate) fn get_approval_policy(&self) -> AskForApproval {
|
||
self.approval_policy
|
||
}
|
||
|
||
fn next_background_sequence(&self, sub_id: &str) -> u64 {
|
||
let mut state = self.state.lock().unwrap();
|
||
let entry = state
|
||
.background_seq_by_sub_id
|
||
.entry(sub_id.to_string())
|
||
.or_insert(0);
|
||
let current = *entry;
|
||
*entry = entry.saturating_add(1);
|
||
current
|
||
}
|
||
|
||
pub(crate) fn next_background_order(
|
||
&self,
|
||
sub_id: &str,
|
||
req_ordinal: u64,
|
||
output_index: Option<u32>,
|
||
) -> crate::protocol::OrderMeta {
|
||
let normalized_req = if req_ordinal == 0 { 1 } else { req_ordinal };
|
||
let sequence = self.next_background_sequence(sub_id);
|
||
let stored_output_index = output_index.unwrap_or(i32::MAX as u32);
|
||
crate::protocol::OrderMeta {
|
||
request_ordinal: normalized_req,
|
||
output_index: Some(stored_output_index),
|
||
sequence_number: Some(sequence),
|
||
}
|
||
}
|
||
|
||
pub(crate) fn background_order_for_ctx(
|
||
&self,
|
||
ctx: &ToolCallCtx,
|
||
req_ordinal: u64,
|
||
) -> crate::protocol::OrderMeta {
|
||
let base_output = ctx.output_index.unwrap_or(i32::MAX as u32);
|
||
self.next_background_order(&ctx.sub_id, req_ordinal, Some(base_output))
|
||
}
|
||
|
||
pub(crate) fn get_cwd(&self) -> &Path {
|
||
&self.cwd
|
||
}
|
||
|
||
pub(crate) fn get_sandbox_policy(&self) -> &SandboxPolicy {
|
||
&self.sandbox_policy
|
||
}
|
||
|
||
pub(crate) fn session_uuid(&self) -> Uuid {
|
||
self.id
|
||
}
|
||
|
||
pub(crate) fn get_github_config(&self) -> Arc<RwLock<crate::config_types::GithubConfig>> {
|
||
Arc::clone(&self.github)
|
||
}
|
||
|
||
pub(crate) fn validation_config(&self) -> Arc<RwLock<crate::config_types::ValidationConfig>> {
|
||
Arc::clone(&self.validation)
|
||
}
|
||
|
||
pub(crate) fn client_tools(&self) -> Option<&ClientTools> {
|
||
self.client_tools.as_ref()
|
||
}
|
||
|
||
fn set_active_review(&self, review_request: ReviewRequest) {
|
||
let mut guard = self.active_review.lock().unwrap();
|
||
*guard = Some(review_request);
|
||
}
|
||
|
||
fn take_active_review(&self) -> Option<ReviewRequest> {
|
||
self.active_review.lock().unwrap().take()
|
||
}
|
||
|
||
pub(crate) fn mcp_connection_manager(&self) -> &McpConnectionManager {
|
||
&self.mcp_connection_manager
|
||
}
|
||
|
||
pub(crate) async fn shutdown_mcp_clients(&self) {
|
||
self.mcp_connection_manager.shutdown_all().await;
|
||
}
|
||
|
||
pub(crate) fn update_validation_tool(&self, name: &str, enable: bool) {
|
||
if name == "actionlint" {
|
||
if let Ok(mut github) = self.github.write() {
|
||
github.actionlint_on_patch = enable;
|
||
}
|
||
return;
|
||
}
|
||
|
||
if let Ok(mut cfg) = self.validation.write() {
|
||
let tools = &mut cfg.tools;
|
||
match name {
|
||
"shellcheck" => tools.shellcheck = Some(enable),
|
||
"markdownlint" => tools.markdownlint = Some(enable),
|
||
"hadolint" => tools.hadolint = Some(enable),
|
||
"yamllint" => tools.yamllint = Some(enable),
|
||
"cargo-check" => tools.cargo_check = Some(enable),
|
||
"shfmt" => tools.shfmt = Some(enable),
|
||
"prettier" => tools.prettier = Some(enable),
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
pub(crate) fn update_validation_group(&self, group: ValidationGroup, enable: bool) {
|
||
if let Ok(mut cfg) = self.validation.write() {
|
||
match group {
|
||
ValidationGroup::Functional => cfg.groups.functional = enable,
|
||
ValidationGroup::Stylistic => cfg.groups.stylistic = enable,
|
||
}
|
||
}
|
||
}
|
||
|
||
fn resolve_path(&self, path: Option<String>) -> PathBuf {
|
||
path.as_ref()
|
||
.map(PathBuf::from)
|
||
.map_or_else(|| self.cwd.clone(), |p| self.cwd.join(p))
|
||
}
|
||
|
||
pub(crate) async fn maybe_parse_apply_patch_verified(
|
||
&self,
|
||
argv: &[String],
|
||
cwd: &Path,
|
||
) -> MaybeApplyPatchVerified {
|
||
// Upstream parser no longer needs a filesystem; it is pure and sync.
|
||
let _ = self.client_tools.as_ref();
|
||
code_apply_patch::maybe_parse_apply_patch_verified(argv, cwd)
|
||
}
|
||
|
||
// ────────────────────────────
|
||
// Scratchpad helpers
|
||
// ────────────────────────────
|
||
fn begin_attempt_scratchpad(&self) {
|
||
let mut state = self.state.lock().unwrap();
|
||
state.turn_scratchpad = Some(TurnScratchpad::default());
|
||
}
|
||
|
||
/// Bump the per-session HTTP request attempt ordinal so `OrderMeta`
|
||
/// reflects the correct provider request index for this attempt.
|
||
fn begin_http_attempt(&self) {
|
||
let mut state = self.state.lock().unwrap();
|
||
state.request_ordinal = state.request_ordinal.saturating_add(1);
|
||
}
|
||
|
||
fn scratchpad_push(&self, item: &ResponseItem, response: &Option<ResponseInputItem>, sub_id: &str) {
|
||
let mut state = self.state.lock().unwrap();
|
||
if let Some(sp) = &mut state.turn_scratchpad {
|
||
sp.items.push(item.clone());
|
||
if let Some(r) = response {
|
||
let mut truncated = r.clone();
|
||
self.enforce_user_message_limits(sub_id, &mut truncated);
|
||
sp.responses.push(truncated);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn scratchpad_add_text_delta(&self, delta: &str) {
|
||
let mut state = self.state.lock().unwrap();
|
||
if let Some(sp) = &mut state.turn_scratchpad {
|
||
sp.partial_assistant_text.push_str(delta);
|
||
// Keep memory bounded (ensure UTF-8 char boundary when trimming)
|
||
if sp.partial_assistant_text.len() > 4000 {
|
||
let mut drain_up_to = sp.partial_assistant_text.len() - 4000;
|
||
while !sp.partial_assistant_text.is_char_boundary(drain_up_to) {
|
||
drain_up_to -= 1;
|
||
}
|
||
sp.partial_assistant_text.drain(..drain_up_to);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn scratchpad_add_reasoning_delta(&self, delta: &str) {
|
||
let mut state = self.state.lock().unwrap();
|
||
if let Some(sp) = &mut state.turn_scratchpad {
|
||
sp.partial_reasoning_summary.push_str(delta);
|
||
if sp.partial_reasoning_summary.len() > 4000 {
|
||
let mut drain_up_to = sp.partial_reasoning_summary.len() - 4000;
|
||
while !sp.partial_reasoning_summary.is_char_boundary(drain_up_to) {
|
||
drain_up_to -= 1;
|
||
}
|
||
sp.partial_reasoning_summary.drain(..drain_up_to);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn scratchpad_clear_partial_message(&self) {
|
||
let mut state = self.state.lock().unwrap();
|
||
if let Some(sp) = &mut state.turn_scratchpad {
|
||
sp.partial_assistant_text.clear();
|
||
}
|
||
}
|
||
|
||
fn take_scratchpad(&self) -> Option<TurnScratchpad> {
|
||
let mut state = self.state.lock().unwrap();
|
||
state.turn_scratchpad.take()
|
||
}
|
||
|
||
fn clear_scratchpad(&self) {
|
||
let mut state = self.state.lock().unwrap();
|
||
state.turn_scratchpad = None;
|
||
}
|
||
}
|
||
|
||
impl Session {
|
||
pub fn set_task(&self, agent: AgentTask) {
|
||
let mut state = self.state.lock().unwrap();
|
||
if let Some(current_task) = state.current_task.take() {
|
||
current_task.abort(TurnAbortReason::Replaced);
|
||
}
|
||
state.current_task = Some(agent);
|
||
}
|
||
|
||
pub fn remove_task(&self, sub_id: &str) {
|
||
let mut state = self.state.lock().unwrap();
|
||
if let Some(agent) = &state.current_task {
|
||
if agent.sub_id == sub_id {
|
||
state.current_task.take();
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn has_running_task(&self) -> bool {
|
||
self.state.lock().unwrap().current_task.is_some()
|
||
}
|
||
|
||
pub fn queue_user_input(&self, queued: QueuedUserInput) {
|
||
let mut state = self.state.lock().unwrap();
|
||
state.pending_user_input.push(queued);
|
||
}
|
||
|
||
fn notify_wait_interrupted(&self, reason: WaitInterruptReason) {
|
||
let mut state = self.state.lock().unwrap();
|
||
state.wait_interrupt_epoch = state.wait_interrupt_epoch.saturating_add(1);
|
||
state.wait_interrupt_reason = Some(reason);
|
||
}
|
||
|
||
fn wait_interrupt_snapshot(&self) -> (u64, Option<WaitInterruptReason>) {
|
||
let state = self.state.lock().unwrap();
|
||
(state.wait_interrupt_epoch, state.wait_interrupt_reason)
|
||
}
|
||
|
||
fn enforce_user_message_limits(
|
||
&self,
|
||
sub_id: &str,
|
||
response_item: &mut ResponseInputItem,
|
||
) {
|
||
let ResponseInputItem::Message { role, content } = response_item else {
|
||
return;
|
||
};
|
||
if role != "user" {
|
||
return;
|
||
}
|
||
|
||
let mut aggregated = String::new();
|
||
let mut text_segments: Vec<(usize, usize)> = Vec::new();
|
||
for item in content.iter() {
|
||
if let ContentItem::InputText { text } = item {
|
||
let start = aggregated.len();
|
||
aggregated.push_str(text);
|
||
let end = aggregated.len();
|
||
text_segments.push((start, end));
|
||
}
|
||
}
|
||
|
||
if text_segments.is_empty() {
|
||
return;
|
||
}
|
||
|
||
let (_, was_truncated, prefix_end, suffix_start) =
|
||
truncate_middle_bytes(&aggregated, MAX_TOOL_OUTPUT_BYTES_FOR_MODEL);
|
||
if !was_truncated {
|
||
return;
|
||
}
|
||
|
||
let cwd = self.get_cwd().to_path_buf();
|
||
let filename = format!("user-message-{}-{}.txt", sub_id, Uuid::new_v4());
|
||
let file_note = match ensure_user_dir(&cwd)
|
||
.and_then(|dir| write_agent_file(&dir, &filename, &aggregated))
|
||
{
|
||
Ok(path) => format!("\n\n[Full output saved to: {}]", path.display()),
|
||
Err(e) => format!("\n\n[Full output was too large and truncation applied; failed to save file: {e}]")
|
||
};
|
||
|
||
let original = std::mem::take(content);
|
||
let mut new_content = Vec::with_capacity(original.len());
|
||
let mut segment_iter = text_segments.into_iter();
|
||
let mut marker_inserted = false;
|
||
let mut last_text_idx: Option<usize> = None;
|
||
|
||
for item in original.into_iter() {
|
||
match item {
|
||
ContentItem::InputText { text } => {
|
||
if let Some((seg_start, seg_end)) = segment_iter.next() {
|
||
let mut new_text = String::new();
|
||
|
||
if seg_start < prefix_end {
|
||
let slice_end = seg_end.min(prefix_end) - seg_start;
|
||
if let Some(prefix_slice) = text.get(..slice_end) {
|
||
new_text.push_str(prefix_slice);
|
||
}
|
||
}
|
||
|
||
if !marker_inserted && seg_end > prefix_end && seg_start < suffix_start {
|
||
new_text.push_str(TRUNCATION_MARKER);
|
||
marker_inserted = true;
|
||
}
|
||
|
||
if seg_end > suffix_start {
|
||
let slice_start = seg_start.max(suffix_start) - seg_start;
|
||
if let Some(suffix_slice) = text.get(slice_start..) {
|
||
new_text.push_str(suffix_slice);
|
||
}
|
||
}
|
||
|
||
new_content.push(ContentItem::InputText { text: new_text });
|
||
last_text_idx = Some(new_content.len() - 1);
|
||
}
|
||
}
|
||
other => new_content.push(other),
|
||
}
|
||
}
|
||
|
||
if !marker_inserted {
|
||
if let Some(idx) = last_text_idx {
|
||
if let ContentItem::InputText { text } = &mut new_content[idx] {
|
||
text.push_str(TRUNCATION_MARKER);
|
||
}
|
||
} else {
|
||
new_content.push(ContentItem::InputText {
|
||
text: TRUNCATION_MARKER.to_string(),
|
||
});
|
||
last_text_idx = Some(new_content.len() - 1);
|
||
}
|
||
}
|
||
|
||
if let Some(idx) = last_text_idx {
|
||
if let ContentItem::InputText { text } = &mut new_content[idx] {
|
||
text.push_str(&file_note);
|
||
}
|
||
} else {
|
||
new_content.push(ContentItem::InputText { text: file_note });
|
||
}
|
||
|
||
*content = new_content;
|
||
}
|
||
|
||
pub fn pop_next_queued_user_input(&self) -> Option<QueuedUserInput> {
|
||
let mut state = self.state.lock().unwrap();
|
||
if state.pending_user_input.is_empty() {
|
||
None
|
||
} else {
|
||
Some(state.pending_user_input.remove(0))
|
||
}
|
||
}
|
||
|
||
/// Enqueue a response item that should be surfaced to the model at the start of the
|
||
/// next turn. Returns `true` if no agent is currently running and a new turn should be
|
||
/// scheduled immediately.
|
||
pub fn enqueue_out_of_turn_item(&self, item: ResponseInputItem) -> bool {
|
||
let mut state = self.state.lock().unwrap();
|
||
let should_start_turn = state.current_task.is_none();
|
||
state.pending_input.push(item);
|
||
should_start_turn
|
||
}
|
||
|
||
pub(crate) fn next_internal_sub_id(&self) -> String {
|
||
let mut state = self.state.lock().unwrap();
|
||
let id = state.next_internal_sub_id;
|
||
state.next_internal_sub_id = state.next_internal_sub_id.saturating_add(1);
|
||
format!("auto-compact-{id}")
|
||
}
|
||
|
||
/// Sends the given event to the client and swallows the send error, if
|
||
/// any, logging it as an error.
|
||
pub(crate) async fn send_event(&self, event: Event) {
|
||
if let Err(e) = self.tx_event.send(event).await {
|
||
error!("failed to send tool call event: {e}");
|
||
}
|
||
}
|
||
|
||
/// Persist an event into the rollout log if appropriate.
|
||
fn persist_event(&self, event: &Event) {
|
||
if !crate::rollout::policy::should_persist_event_msg(&event.msg) {
|
||
return;
|
||
}
|
||
let Some(msg) = crate::protocol::event_msg_to_protocol(&event.msg) else {
|
||
return;
|
||
};
|
||
let recorder = {
|
||
let guard = self.rollout.lock().unwrap();
|
||
guard.as_ref().cloned()
|
||
};
|
||
if let Some(rec) = recorder {
|
||
let order = event
|
||
.order
|
||
.as_ref()
|
||
.map(crate::protocol::order_meta_to_protocol);
|
||
let protocol_event = code_protocol::protocol::RecordedEvent {
|
||
id: event.id.clone(),
|
||
event_seq: event.event_seq,
|
||
order,
|
||
msg,
|
||
};
|
||
tokio::spawn(async move {
|
||
if let Err(e) = rec.record_events(&[protocol_event]).await {
|
||
warn!("failed to persist rollout event: {e}");
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
/// Create a stamped Event with a per-turn sequence number.
|
||
fn stamp_event(&self, sub_id: &str, msg: EventMsg) -> Event {
|
||
let mut state = self.state.lock().unwrap();
|
||
let seq = match msg {
|
||
EventMsg::TaskStarted => {
|
||
// Reset per-sub_id sequence at the start of a turn.
|
||
// We increment request_ordinal per HTTP attempt instead
|
||
// (see `begin_http_attempt`).
|
||
let e = state
|
||
.event_seq_by_sub_id
|
||
.entry(sub_id.to_string())
|
||
.or_insert(0);
|
||
*e = 0;
|
||
0
|
||
}
|
||
_ => {
|
||
let e = state
|
||
.event_seq_by_sub_id
|
||
.entry(sub_id.to_string())
|
||
.or_insert(0);
|
||
*e = e.saturating_add(1);
|
||
*e
|
||
}
|
||
};
|
||
Event {
|
||
id: sub_id.to_string(),
|
||
event_seq: seq,
|
||
msg,
|
||
order: None,
|
||
}
|
||
}
|
||
|
||
pub(crate) fn make_event(&self, sub_id: &str, msg: EventMsg) -> Event {
|
||
let event = self.stamp_event(sub_id, msg);
|
||
self.persist_event(&event);
|
||
event
|
||
}
|
||
|
||
/// Same as make_event but allows supplying a provider sequence_number
|
||
/// (e.g., Responses API SSE event). We DO NOT overwrite `event_seq`
|
||
/// with this hint because `event_seq` must remain monotonic per turn
|
||
/// and local to our runtime. Provider ordering is carried via
|
||
/// `OrderMeta` when applicable.
|
||
fn make_event_with_hint(&self, sub_id: &str, msg: EventMsg, _seq_hint: Option<u64>) -> Event {
|
||
let event = self.stamp_event(sub_id, msg);
|
||
self.persist_event(&event);
|
||
event
|
||
}
|
||
|
||
fn make_event_with_order(
|
||
&self,
|
||
sub_id: &str,
|
||
msg: EventMsg,
|
||
order: crate::protocol::OrderMeta,
|
||
_seq_hint: Option<u64>,
|
||
) -> Event {
|
||
let mut ev = self.stamp_event(sub_id, msg);
|
||
ev.order = Some(order);
|
||
self.persist_event(&ev);
|
||
ev
|
||
}
|
||
|
||
// Kept private helpers focused on ctx-based flow to avoid misuse.
|
||
|
||
pub(crate) async fn send_ordered_from_ctx(&self, ctx: &ToolCallCtx, msg: EventMsg) {
|
||
let order = ctx.order_meta(self.current_request_ordinal());
|
||
let ev = self.make_event_with_order(&ctx.sub_id, msg, order, ctx.seq_hint);
|
||
let _ = self.tx_event.send(ev).await;
|
||
}
|
||
|
||
fn current_request_ordinal(&self) -> u64 {
|
||
let state = self.state.lock().unwrap();
|
||
state.request_ordinal
|
||
}
|
||
|
||
fn make_turn_context(&self) -> Arc<TurnContext> {
|
||
Arc::new(TurnContext {
|
||
client: self.client.clone(),
|
||
cwd: self.cwd.clone(),
|
||
base_instructions: self.base_instructions.clone(),
|
||
user_instructions: self.user_instructions.clone(),
|
||
approval_policy: self.approval_policy,
|
||
sandbox_policy: self.sandbox_policy.clone(),
|
||
shell_environment_policy: self.shell_environment_policy.clone(),
|
||
is_review_mode: false,
|
||
text_format_override: self.next_turn_text_format.lock().unwrap().take(),
|
||
})
|
||
}
|
||
|
||
pub async fn request_command_approval(
|
||
&self,
|
||
sub_id: String,
|
||
call_id: String,
|
||
command: Vec<String>,
|
||
cwd: PathBuf,
|
||
reason: Option<String>,
|
||
) -> oneshot::Receiver<ReviewDecision> {
|
||
let (tx_approve, rx_approve) = oneshot::channel();
|
||
let event = self.make_event(
|
||
&sub_id,
|
||
EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
||
call_id: call_id.clone(),
|
||
command,
|
||
cwd,
|
||
reason,
|
||
}),
|
||
);
|
||
let _ = self.tx_event.send(event).await;
|
||
{
|
||
let mut state = self.state.lock().unwrap();
|
||
// Track pending approval by call_id (unique per request) rather than sub_id
|
||
// so parallel approvals in the same turn do not clobber each other.
|
||
state.pending_approvals.insert(call_id, tx_approve);
|
||
}
|
||
rx_approve
|
||
}
|
||
|
||
pub async fn request_patch_approval(
|
||
&self,
|
||
sub_id: String,
|
||
call_id: String,
|
||
action: &ApplyPatchAction,
|
||
reason: Option<String>,
|
||
grant_root: Option<PathBuf>,
|
||
) -> oneshot::Receiver<ReviewDecision> {
|
||
let (tx_approve, rx_approve) = oneshot::channel();
|
||
let event = self.make_event(
|
||
&sub_id,
|
||
EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
|
||
call_id: call_id.clone(),
|
||
changes: convert_apply_patch_to_protocol(action),
|
||
reason,
|
||
grant_root,
|
||
}),
|
||
);
|
||
let _ = self.tx_event.send(event).await;
|
||
{
|
||
let mut state = self.state.lock().unwrap();
|
||
// Track pending approval by call_id to avoid collisions.
|
||
state.pending_approvals.insert(call_id, tx_approve);
|
||
}
|
||
rx_approve
|
||
}
|
||
|
||
pub fn notify_approval(&self, call_id: &str, decision: ReviewDecision) {
|
||
let mut state = self.state.lock().unwrap();
|
||
if let Some(tx_approve) = state.pending_approvals.remove(call_id) {
|
||
let _ = tx_approve.send(decision);
|
||
} else {
|
||
// If we cannot find a pending approval for this call id, surface a warning
|
||
// to aid debugging of stuck approvals.
|
||
tracing::warn!("no pending approval found for call_id={}", call_id);
|
||
}
|
||
}
|
||
|
||
pub fn add_approved_command(&self, pattern: ApprovedCommandPattern) {
|
||
let mut state = self.state.lock().unwrap();
|
||
state.approved_commands.insert(pattern);
|
||
}
|
||
|
||
/// Records items to both the rollout and the chat completions/ZDR
|
||
/// transcript, if enabled.
|
||
async fn record_conversation_items(&self, items: &[ResponseItem]) {
|
||
debug!("Recording items for conversation: {items:?}");
|
||
self.record_state_snapshot(items).await;
|
||
|
||
self.state.lock().unwrap().history.record_items(items);
|
||
|
||
}
|
||
|
||
/// Clean up old screenshots and system status messages from conversation history
|
||
/// This is called when a new user message arrives to keep history manageable
|
||
async fn cleanup_old_status_items(&self) {
|
||
let mut state = self.state.lock().unwrap();
|
||
|
||
// Get current history items
|
||
let current_items = state.history.contents();
|
||
|
||
// Track various message types and their positions
|
||
let mut real_user_messages = Vec::new(); // Non-status user messages
|
||
let mut status_messages = Vec::new(); // Messages with screenshots or status
|
||
|
||
for (idx, item) in current_items.iter().enumerate() {
|
||
match item {
|
||
ResponseItem::Message { role, content, .. } if role == "user" => {
|
||
// Check message content
|
||
let has_status = content.iter().any(|c| {
|
||
if let ContentItem::InputText { text } = c {
|
||
text.contains("== System Status ==")
|
||
|| text.contains("Current working directory:")
|
||
|| text.contains("Git branch:")
|
||
} else {
|
||
false
|
||
}
|
||
});
|
||
|
||
let has_screenshot = content
|
||
.iter()
|
||
.any(|c| matches!(c, ContentItem::InputImage { .. }));
|
||
|
||
let has_real_text = content.iter().any(|c| {
|
||
if let ContentItem::InputText { text } = c {
|
||
// Real user text doesn't contain system status markers
|
||
!text.contains("== System Status ==")
|
||
&& !text.contains("Current working directory:")
|
||
&& !text.contains("Git branch:")
|
||
&& !text.trim().is_empty()
|
||
} else {
|
||
false
|
||
}
|
||
});
|
||
|
||
if has_real_text && !has_status && !has_screenshot {
|
||
// This is a real user message
|
||
real_user_messages.push(idx);
|
||
} else if has_status || has_screenshot {
|
||
// This is a status/screenshot message
|
||
status_messages.push(idx);
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
// Find screenshots to keep: last 2 that directly follow real user commands
|
||
let mut screenshots_to_keep = std::collections::HashSet::new();
|
||
|
||
// Work backwards through real user messages
|
||
for &user_idx in real_user_messages.iter().rev().take(2) {
|
||
// Find the first status message after this user message
|
||
for &status_idx in status_messages.iter() {
|
||
if status_idx > user_idx {
|
||
// Check if this status message contains a screenshot
|
||
if let Some(ResponseItem::Message { content, .. }) =
|
||
current_items.get(status_idx)
|
||
{
|
||
let has_screenshot = content
|
||
.iter()
|
||
.any(|c| matches!(c, ContentItem::InputImage { .. }));
|
||
if has_screenshot {
|
||
screenshots_to_keep.insert(status_idx);
|
||
break; // Only keep one screenshot per user message
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Build the filtered history
|
||
let mut items_to_keep = Vec::new();
|
||
let mut removed_screenshots = 0;
|
||
let mut removed_status = 0;
|
||
|
||
for (idx, item) in current_items.iter().enumerate() {
|
||
let should_keep = if status_messages.contains(&idx) {
|
||
// This is a status/screenshot message
|
||
if screenshots_to_keep.contains(&idx) {
|
||
true // Keep this screenshot
|
||
} else {
|
||
// Count what we're removing
|
||
if let ResponseItem::Message { content, .. } = item {
|
||
let has_screenshot = content
|
||
.iter()
|
||
.any(|c| matches!(c, ContentItem::InputImage { .. }));
|
||
if has_screenshot {
|
||
removed_screenshots += 1;
|
||
} else {
|
||
removed_status += 1;
|
||
}
|
||
}
|
||
false // Remove this status/screenshot
|
||
}
|
||
} else {
|
||
true // Keep all non-status messages (real user messages, assistant messages, etc.)
|
||
};
|
||
|
||
if should_keep {
|
||
items_to_keep.push(item.clone());
|
||
}
|
||
}
|
||
|
||
// Replace the history with cleaned items
|
||
state.history = ConversationHistory::new();
|
||
state.history.record_items(&items_to_keep);
|
||
|
||
if removed_screenshots > 0 || removed_status > 0 {
|
||
info!(
|
||
"Cleaned up history: removed {} old screenshots and {} status messages, kept {} recent screenshots",
|
||
removed_screenshots,
|
||
removed_status,
|
||
screenshots_to_keep.len()
|
||
);
|
||
}
|
||
}
|
||
|
||
async fn record_state_snapshot(&self, items: &[ResponseItem]) {
|
||
let snapshot = { SessionStateSnapshot {} };
|
||
|
||
let recorder = self.clone_rollout_recorder();
|
||
|
||
if let Some(rec) = recorder {
|
||
if let Err(e) = rec.record_state(snapshot).await {
|
||
error!("failed to record rollout state: {e:#}");
|
||
}
|
||
if let Err(e) = rec.record_response_items(items).await {
|
||
error!("failed to record rollout items: {e:#}");
|
||
}
|
||
}
|
||
}
|
||
|
||
fn clone_rollout_recorder(&self) -> Option<RolloutRecorder> {
|
||
let guard = self.rollout.lock().unwrap();
|
||
guard.as_ref().cloned()
|
||
}
|
||
|
||
pub(crate) async fn persist_rollout_items(&self, items: &[RolloutItem]) {
|
||
let recorder = {
|
||
let guard = self.rollout.lock().unwrap();
|
||
guard.as_ref().cloned()
|
||
};
|
||
if let Some(rec) = recorder {
|
||
if let Err(e) = rec.record_items(items).await {
|
||
error!("failed to record rollout items: {e:#}");
|
||
}
|
||
}
|
||
}
|
||
|
||
async fn on_exec_command_begin(
|
||
&self,
|
||
turn_diff_tracker: &mut TurnDiffTracker,
|
||
exec_command_context: ExecCommandContext,
|
||
seq_hint: Option<u64>,
|
||
output_index: Option<u32>,
|
||
attempt_req: u64,
|
||
) {
|
||
let ExecCommandContext {
|
||
sub_id,
|
||
call_id,
|
||
command_for_display,
|
||
cwd,
|
||
apply_patch,
|
||
} = exec_command_context;
|
||
let msg = match apply_patch {
|
||
Some(ApplyPatchCommandContext {
|
||
user_explicitly_approved_this_action,
|
||
changes,
|
||
}) => {
|
||
turn_diff_tracker.on_patch_begin(&changes);
|
||
|
||
EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
|
||
call_id,
|
||
auto_approved: !user_explicitly_approved_this_action,
|
||
changes,
|
||
})
|
||
}
|
||
None => EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||
call_id,
|
||
command: command_for_display.clone(),
|
||
cwd,
|
||
parsed_cmd: parse_command(&command_for_display),
|
||
}),
|
||
};
|
||
let order = crate::protocol::OrderMeta { request_ordinal: attempt_req, output_index, sequence_number: seq_hint };
|
||
let event = self.make_event_with_order(&sub_id, msg, order, seq_hint);
|
||
let _ = self.tx_event.send(event).await;
|
||
}
|
||
|
||
async fn on_exec_command_end(
|
||
&self,
|
||
turn_diff_tracker: &mut TurnDiffTracker,
|
||
sub_id: &str,
|
||
call_id: &str,
|
||
output: &ExecToolCallOutput,
|
||
is_apply_patch: bool,
|
||
seq_hint: Option<u64>,
|
||
output_index: Option<u32>,
|
||
attempt_req: u64,
|
||
) {
|
||
let ExecToolCallOutput {
|
||
stdout,
|
||
stderr,
|
||
aggregated_output: _,
|
||
duration,
|
||
exit_code,
|
||
timed_out: _,
|
||
} = output;
|
||
// Because stdout and stderr could each be up to 100 KiB, we send
|
||
// truncated versions.
|
||
const MAX_STREAM_OUTPUT: usize = 5 * 1024; // 5KiB
|
||
let stdout = stdout.text.chars().take(MAX_STREAM_OUTPUT).collect();
|
||
let stderr = stderr.text.chars().take(MAX_STREAM_OUTPUT).collect();
|
||
// Precompute formatted output if needed in future for logging/pretty UI.
|
||
|
||
let msg = if is_apply_patch {
|
||
EventMsg::PatchApplyEnd(PatchApplyEndEvent {
|
||
call_id: call_id.to_string(),
|
||
stdout,
|
||
stderr,
|
||
success: *exit_code == 0,
|
||
})
|
||
} else {
|
||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||
call_id: call_id.to_string(),
|
||
stdout,
|
||
stderr,
|
||
exit_code: *exit_code,
|
||
duration: *duration,
|
||
})
|
||
};
|
||
let order = crate::protocol::OrderMeta { request_ordinal: attempt_req, output_index, sequence_number: seq_hint };
|
||
let event = self.make_event_with_order(sub_id, msg, order, seq_hint);
|
||
let _ = self.tx_event.send(event).await;
|
||
|
||
// If this is an apply_patch, after we emit the end patch, emit a second event
|
||
// with the full turn diff if there is one.
|
||
if is_apply_patch {
|
||
let unified_diff = turn_diff_tracker.get_unified_diff();
|
||
if let Ok(Some(unified_diff)) = unified_diff {
|
||
let msg = EventMsg::TurnDiff(TurnDiffEvent { unified_diff });
|
||
let event = self.make_event(sub_id, msg);
|
||
let _ = self.tx_event.send(event).await;
|
||
}
|
||
}
|
||
|
||
}
|
||
/// Runs the exec tool call and emits events for the begin and end of the
|
||
/// command even on error.
|
||
///
|
||
/// Returns the output of the exec tool call.
|
||
async fn run_exec_with_events<'a>(
|
||
&self,
|
||
turn_diff_tracker: &mut TurnDiffTracker,
|
||
begin_ctx: ExecCommandContext,
|
||
exec_args: ExecInvokeArgs<'a>,
|
||
seq_hint: Option<u64>,
|
||
output_index: Option<u32>,
|
||
attempt_req: u64,
|
||
) -> crate::error::Result<ExecToolCallOutput> {
|
||
self
|
||
.run_exec_with_events_inner(
|
||
turn_diff_tracker,
|
||
begin_ctx,
|
||
exec_args,
|
||
seq_hint,
|
||
output_index,
|
||
attempt_req,
|
||
true,
|
||
)
|
||
.await
|
||
}
|
||
|
||
fn track_running_exec(
|
||
&self,
|
||
call_id: &str,
|
||
sub_id: &str,
|
||
order_meta: crate::protocol::OrderMeta,
|
||
cancel_flag: Arc<AtomicBool>,
|
||
end_emitted: Arc<AtomicBool>,
|
||
) {
|
||
let mut state = self.state.lock().unwrap();
|
||
state.running_execs.insert(
|
||
call_id.to_string(),
|
||
RunningExecMeta {
|
||
sub_id: sub_id.to_string(),
|
||
order_meta,
|
||
cancel_flag,
|
||
end_emitted,
|
||
},
|
||
);
|
||
}
|
||
|
||
fn unregister_running_exec(&self, call_id: &str) {
|
||
let mut state = self.state.lock().unwrap();
|
||
state.running_execs.remove(call_id);
|
||
}
|
||
|
||
fn mark_running_exec_as_cancelled(&self, sub_id: &str) {
|
||
let state = self.state.lock().unwrap();
|
||
for meta in state.running_execs.values() {
|
||
if meta.sub_id == sub_id {
|
||
meta.cancel_flag.store(true, Ordering::Release);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn mark_all_running_execs_as_cancelled(&self) {
|
||
let sub_ids: Vec<String> = {
|
||
let state = self.state.lock().unwrap();
|
||
state
|
||
.running_execs
|
||
.values()
|
||
.map(|meta| meta.sub_id.clone())
|
||
.collect()
|
||
};
|
||
for sub_id in sub_ids {
|
||
self.mark_running_exec_as_cancelled(&sub_id);
|
||
}
|
||
}
|
||
|
||
async fn finalize_cancelled_execs(&self, sub_id: &str) {
|
||
let mut to_emit = Vec::new();
|
||
{
|
||
let mut state = self.state.lock().unwrap();
|
||
let mut remove_keys = Vec::new();
|
||
for (call_id, meta) in state.running_execs.iter() {
|
||
if meta.sub_id == sub_id && !meta.end_emitted.load(Ordering::Acquire) {
|
||
to_emit.push((
|
||
call_id.clone(),
|
||
meta.order_meta.clone(),
|
||
meta.cancel_flag.clone(),
|
||
meta.end_emitted.clone(),
|
||
));
|
||
remove_keys.push(call_id.clone());
|
||
}
|
||
}
|
||
for key in remove_keys {
|
||
state.running_execs.remove(&key);
|
||
}
|
||
}
|
||
|
||
for (call_id, order_meta, cancel_flag, end_emitted) in to_emit {
|
||
cancel_flag.store(true, Ordering::Release);
|
||
if !end_emitted.swap(true, Ordering::AcqRel) {
|
||
let (exit_code, stderr) = synthetic_exec_end_payload(true);
|
||
let msg = EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||
call_id,
|
||
stdout: String::new(),
|
||
stderr,
|
||
exit_code,
|
||
duration: Duration::ZERO,
|
||
});
|
||
let event = self.make_event_with_order(sub_id, msg, order_meta.clone(), order_meta.sequence_number);
|
||
let _ = self.tx_event.send(event).await;
|
||
}
|
||
}
|
||
}
|
||
|
||
async fn run_exec_with_events_inner<'a>(
|
||
&self,
|
||
turn_diff_tracker: &mut TurnDiffTracker,
|
||
begin_ctx: ExecCommandContext,
|
||
exec_args: ExecInvokeArgs<'a>,
|
||
seq_hint: Option<u64>,
|
||
output_index: Option<u32>,
|
||
attempt_req: u64,
|
||
enable_hooks: bool,
|
||
) -> crate::error::Result<ExecToolCallOutput> {
|
||
let is_apply_patch = begin_ctx.apply_patch.is_some();
|
||
let sub_id = begin_ctx.sub_id.clone();
|
||
let call_id = begin_ctx.call_id.clone();
|
||
|
||
let order_for_end = crate::protocol::OrderMeta {
|
||
request_ordinal: attempt_req,
|
||
output_index,
|
||
sequence_number: seq_hint.map(|h| h.saturating_add(1)),
|
||
};
|
||
|
||
let cancel_flag = Arc::new(AtomicBool::new(false));
|
||
let end_emitted = Arc::new(AtomicBool::new(false));
|
||
self.track_running_exec(&call_id, &sub_id, order_for_end.clone(), cancel_flag.clone(), end_emitted.clone());
|
||
|
||
let mut exec_guard = ExecDropGuard::new(
|
||
self.self_handle.clone(),
|
||
self.tx_event.clone(),
|
||
sub_id.clone(),
|
||
call_id.clone(),
|
||
order_for_end.clone(),
|
||
cancel_flag,
|
||
end_emitted,
|
||
);
|
||
|
||
let ExecInvokeArgs { params, sandbox_type, sandbox_policy, sandbox_cwd, code_linux_sandbox_exe, stdout_stream } = exec_args;
|
||
let tracking_command = params.command.clone();
|
||
let dry_run_analysis = analyze_command(&tracking_command);
|
||
let params = maybe_run_with_user_profile(params, self);
|
||
let params_for_hooks = if enable_hooks {
|
||
Some(params.clone())
|
||
} else {
|
||
None
|
||
};
|
||
|
||
if enable_hooks {
|
||
if let Some(params_ref) = params_for_hooks.as_ref() {
|
||
let before_event = if is_apply_patch {
|
||
ProjectHookEvent::FileBeforeWrite
|
||
} else {
|
||
ProjectHookEvent::ToolBefore
|
||
};
|
||
self
|
||
.run_hooks_for_exec_event(
|
||
turn_diff_tracker,
|
||
before_event,
|
||
&begin_ctx,
|
||
params_ref,
|
||
None,
|
||
attempt_req,
|
||
)
|
||
.await;
|
||
}
|
||
}
|
||
|
||
self.on_exec_command_begin(turn_diff_tracker, begin_ctx.clone(), seq_hint, output_index, attempt_req)
|
||
.await;
|
||
|
||
let result = process_exec_tool_call(params, sandbox_type, sandbox_policy, sandbox_cwd, code_linux_sandbox_exe, stdout_stream)
|
||
.await;
|
||
|
||
let output_stderr;
|
||
let borrowed: &ExecToolCallOutput = match &result {
|
||
Ok(output) => output,
|
||
Err(CodexErr::Sandbox(SandboxErr::Timeout { output })) => output,
|
||
Err(e) => {
|
||
output_stderr = ExecToolCallOutput {
|
||
exit_code: -1,
|
||
stdout: StreamOutput::new(String::new()),
|
||
stderr: StreamOutput::new(get_error_message_ui(e)),
|
||
aggregated_output: StreamOutput::new(get_error_message_ui(e)),
|
||
duration: Duration::default(),
|
||
timed_out: false,
|
||
};
|
||
&output_stderr
|
||
}
|
||
};
|
||
self.on_exec_command_end(
|
||
turn_diff_tracker,
|
||
&sub_id,
|
||
&call_id,
|
||
borrowed,
|
||
is_apply_patch,
|
||
seq_hint.map(|h| h.saturating_add(1)),
|
||
output_index,
|
||
attempt_req,
|
||
)
|
||
.await;
|
||
|
||
exec_guard.mark_completed();
|
||
self.finalize_cancelled_execs(&sub_id).await;
|
||
|
||
if enable_hooks {
|
||
if let Some(params_ref) = params_for_hooks.as_ref() {
|
||
let after_event = if is_apply_patch {
|
||
ProjectHookEvent::FileAfterWrite
|
||
} else {
|
||
ProjectHookEvent::ToolAfter
|
||
};
|
||
self
|
||
.run_hooks_for_exec_event(
|
||
turn_diff_tracker,
|
||
after_event,
|
||
&begin_ctx,
|
||
params_ref,
|
||
Some(borrowed),
|
||
attempt_req,
|
||
)
|
||
.await;
|
||
}
|
||
}
|
||
|
||
if let Some(analysis) = dry_run_analysis.as_ref() {
|
||
let mut state = self.state.lock().unwrap();
|
||
state.dry_run_guard.note_execution(analysis);
|
||
}
|
||
|
||
result
|
||
}
|
||
|
||
/// Helper that emits a BackgroundEvent with explicit ordering metadata.
|
||
pub(crate) async fn notify_background_event_with_order(
|
||
&self,
|
||
sub_id: &str,
|
||
order: crate::protocol::OrderMeta,
|
||
message: impl Into<String>,
|
||
) {
|
||
let event = self.make_event_with_order(
|
||
sub_id,
|
||
EventMsg::BackgroundEvent(BackgroundEventEvent { message: message.into() }),
|
||
order,
|
||
None,
|
||
);
|
||
let _ = self.tx_event.send(event).await;
|
||
}
|
||
|
||
async fn notify_stream_error(&self, sub_id: &str, message: impl Into<String>) {
|
||
let event = self.make_event(
|
||
sub_id,
|
||
EventMsg::Error(ErrorEvent { message: message.into() }),
|
||
);
|
||
let _ = self.tx_event.send(event).await;
|
||
}
|
||
|
||
fn resolve_internal_sandbox(&self, with_escalated_permissions: bool) -> SandboxType {
|
||
match assess_safety_for_untrusted_command(
|
||
self.approval_policy,
|
||
&self.sandbox_policy,
|
||
with_escalated_permissions,
|
||
) {
|
||
SafetyCheck::AutoApprove { sandbox_type, .. } => sandbox_type,
|
||
SafetyCheck::AskUser | SafetyCheck::Reject { .. } => {
|
||
crate::safety::get_platform_sandbox().unwrap_or(SandboxType::None)
|
||
}
|
||
}
|
||
}
|
||
|
||
async fn run_hooks_for_exec_event(
|
||
&self,
|
||
turn_diff_tracker: &mut TurnDiffTracker,
|
||
event: ProjectHookEvent,
|
||
exec_ctx: &ExecCommandContext,
|
||
params: &ExecParams,
|
||
output: Option<&ExecToolCallOutput>,
|
||
attempt_req: u64,
|
||
) {
|
||
if self.project_hooks.is_empty() {
|
||
return;
|
||
}
|
||
let hooks: Vec<ProjectHook> = self.project_hooks.hooks_for(event).cloned().collect();
|
||
if hooks.is_empty() {
|
||
return;
|
||
}
|
||
let Some(_guard) = HookGuard::try_acquire(&self.hook_guard) else {
|
||
return;
|
||
};
|
||
let payload = build_exec_hook_payload(event, exec_ctx, params, output);
|
||
for (idx, hook) in hooks.into_iter().enumerate() {
|
||
self
|
||
.run_hook_command(turn_diff_tracker, &hook, event, &payload, Some(exec_ctx), attempt_req, idx)
|
||
.await;
|
||
}
|
||
}
|
||
|
||
async fn run_session_hooks(&self, event: ProjectHookEvent) {
|
||
if self.project_hooks.is_empty() {
|
||
return;
|
||
}
|
||
let hooks: Vec<ProjectHook> = self.project_hooks.hooks_for(event).cloned().collect();
|
||
if hooks.is_empty() {
|
||
return;
|
||
}
|
||
let Some(_guard) = HookGuard::try_acquire(&self.hook_guard) else {
|
||
return;
|
||
};
|
||
let payload = self.build_session_payload(event);
|
||
let mut tracker = TurnDiffTracker::new();
|
||
let attempt_req = self.current_request_ordinal();
|
||
for (idx, hook) in hooks.into_iter().enumerate() {
|
||
self
|
||
.run_hook_command(&mut tracker, &hook, event, &payload, None, attempt_req, idx)
|
||
.await;
|
||
}
|
||
}
|
||
|
||
fn build_session_payload(&self, event: ProjectHookEvent) -> Value {
|
||
match event {
|
||
ProjectHookEvent::SessionStart => json!({
|
||
"event": event.as_str(),
|
||
"cwd": self.cwd.to_string_lossy(),
|
||
"sandbox_policy": format!("{}", self.sandbox_policy),
|
||
"approval_policy": format!("{}", self.approval_policy),
|
||
}),
|
||
ProjectHookEvent::SessionEnd => json!({
|
||
"event": event.as_str(),
|
||
"cwd": self.cwd.to_string_lossy(),
|
||
"sandbox_policy": format!("{}", self.sandbox_policy),
|
||
"approval_policy": format!("{}", self.approval_policy),
|
||
}),
|
||
_ => json!({ "event": event.as_str() }),
|
||
}
|
||
}
|
||
|
||
async fn run_hook_command(
|
||
&self,
|
||
turn_diff_tracker: &mut TurnDiffTracker,
|
||
hook: &ProjectHook,
|
||
event: ProjectHookEvent,
|
||
payload: &Value,
|
||
base_ctx: Option<&ExecCommandContext>,
|
||
attempt_req: u64,
|
||
index: usize,
|
||
) {
|
||
let sub_id = base_ctx
|
||
.map(|ctx| ctx.sub_id.clone())
|
||
.unwrap_or_else(|| INITIAL_SUBMIT_ID.to_string());
|
||
let base_slug = base_ctx
|
||
.map(|ctx| sanitize_identifier(&ctx.call_id))
|
||
.unwrap_or_else(|| event.slug().to_string());
|
||
let call_id = format!("{base_slug}_hook_{}_{}", event.slug(), index + 1);
|
||
|
||
let mut env = hook.env.clone();
|
||
env.entry("CODE_HOOK_EVENT".to_string())
|
||
.or_insert_with(|| event.as_str().to_string());
|
||
env.entry("CODE_HOOK_TRIGGER".to_string())
|
||
.or_insert_with(|| event.slug().to_string());
|
||
env.insert("CODE_HOOK_CALL_ID".to_string(), call_id.clone());
|
||
env.insert("CODE_HOOK_SUB_ID".to_string(), sub_id.clone());
|
||
env.insert("CODE_HOOK_INDEX".to_string(), (index + 1).to_string());
|
||
env.insert("CODE_HOOK_PAYLOAD".to_string(), payload.to_string());
|
||
env.entry("CODE_SESSION_CWD".to_string())
|
||
.or_insert_with(|| self.cwd.to_string_lossy().to_string());
|
||
if let Some(name) = &hook.name {
|
||
env.entry("CODE_HOOK_NAME".to_string())
|
||
.or_insert_with(|| name.clone());
|
||
}
|
||
if let Some(ctx) = base_ctx {
|
||
env.entry("CODE_HOOK_SOURCE_CALL_ID".to_string())
|
||
.or_insert_with(|| ctx.call_id.clone());
|
||
}
|
||
|
||
let exec_params = ExecParams {
|
||
command: hook.command.clone(),
|
||
cwd: hook.resolved_cwd(self.get_cwd()),
|
||
timeout_ms: hook.timeout_ms,
|
||
env,
|
||
with_escalated_permissions: Some(false),
|
||
justification: None,
|
||
};
|
||
|
||
let exec_ctx = ExecCommandContext {
|
||
sub_id: sub_id.clone(),
|
||
call_id: call_id.clone(),
|
||
command_for_display: exec_params.command.clone(),
|
||
cwd: exec_params.cwd.clone(),
|
||
apply_patch: None,
|
||
};
|
||
|
||
let sandbox_type = self.resolve_internal_sandbox(false);
|
||
let exec_args = ExecInvokeArgs {
|
||
params: exec_params,
|
||
sandbox_type,
|
||
sandbox_policy: &self.sandbox_policy,
|
||
sandbox_cwd: self.get_cwd(),
|
||
code_linux_sandbox_exe: &self.code_linux_sandbox_exe,
|
||
stdout_stream: None,
|
||
};
|
||
|
||
if let Err(err) = Box::pin(self.run_exec_with_events_inner(
|
||
turn_diff_tracker,
|
||
exec_ctx,
|
||
exec_args,
|
||
None,
|
||
None,
|
||
attempt_req,
|
||
false,
|
||
))
|
||
.await
|
||
{
|
||
let hook_label = hook
|
||
.name
|
||
.as_deref()
|
||
.unwrap_or_else(|| hook.command.first().map(String::as_str).unwrap_or("hook"));
|
||
let order = self.next_background_order(&sub_id, attempt_req, None);
|
||
self
|
||
.notify_background_event_with_order(
|
||
&sub_id,
|
||
order,
|
||
format!("Hook `{}` failed: {}", hook_label, get_error_message_ui(&err)),
|
||
)
|
||
.await;
|
||
}
|
||
}
|
||
|
||
fn find_project_command(&self, candidate: &str) -> Option<ProjectCommand> {
|
||
self.project_commands
|
||
.iter()
|
||
.find(|cmd| cmd.matches(candidate))
|
||
.cloned()
|
||
}
|
||
|
||
async fn run_project_command(
|
||
&self,
|
||
turn_diff_tracker: &mut TurnDiffTracker,
|
||
sub_id: &str,
|
||
name: &str,
|
||
attempt_req: u64,
|
||
) {
|
||
let Some(command) = self.find_project_command(name) else {
|
||
let order = self.next_background_order(sub_id, attempt_req, None);
|
||
self
|
||
.notify_background_event_with_order(
|
||
sub_id,
|
||
order,
|
||
format!("Unknown project command `{}`", name.trim()),
|
||
)
|
||
.await;
|
||
return;
|
||
};
|
||
|
||
let mut env = command.env.clone();
|
||
env.entry("CODE_PROJECT_COMMAND_NAME".to_string())
|
||
.or_insert_with(|| command.name.clone());
|
||
if let Some(desc) = &command.description {
|
||
env.entry("CODE_PROJECT_COMMAND_DESCRIPTION".to_string())
|
||
.or_insert_with(|| desc.clone());
|
||
}
|
||
env.entry("CODE_SESSION_CWD".to_string())
|
||
.or_insert_with(|| self.cwd.to_string_lossy().to_string());
|
||
|
||
let exec_params = ExecParams {
|
||
command: command.command.clone(),
|
||
cwd: command.resolved_cwd(self.get_cwd()),
|
||
timeout_ms: command.timeout_ms,
|
||
env,
|
||
with_escalated_permissions: Some(false),
|
||
justification: None,
|
||
};
|
||
|
||
let call_id = format!("project_cmd_{}", sanitize_identifier(&command.name));
|
||
let exec_ctx = ExecCommandContext {
|
||
sub_id: sub_id.to_string(),
|
||
call_id: call_id.clone(),
|
||
command_for_display: exec_params.command.clone(),
|
||
cwd: exec_params.cwd.clone(),
|
||
apply_patch: None,
|
||
};
|
||
|
||
let sandbox_type = self.resolve_internal_sandbox(false);
|
||
let exec_args = ExecInvokeArgs {
|
||
params: exec_params,
|
||
sandbox_type,
|
||
sandbox_policy: &self.sandbox_policy,
|
||
sandbox_cwd: self.get_cwd(),
|
||
code_linux_sandbox_exe: &self.code_linux_sandbox_exe,
|
||
stdout_stream: None,
|
||
};
|
||
|
||
if let Err(err) = self
|
||
.run_exec_with_events(turn_diff_tracker, exec_ctx, exec_args, None, None, attempt_req)
|
||
.await
|
||
{
|
||
let order = self.next_background_order(sub_id, attempt_req, None);
|
||
self
|
||
.notify_background_event_with_order(
|
||
sub_id,
|
||
order,
|
||
format!(
|
||
"Project command `{}` failed: {}",
|
||
command.name,
|
||
get_error_message_ui(&err)
|
||
),
|
||
)
|
||
.await;
|
||
}
|
||
}
|
||
|
||
/// Build the full turn input by concatenating the current conversation
|
||
/// history with additional items for this turn.
|
||
/// Browser screenshots are filtered out from history to keep them ephemeral.
|
||
pub fn turn_input_with_history(&self, extra: Vec<ResponseItem>) -> Vec<ResponseItem> {
|
||
let history = self.state.lock().unwrap().history.contents();
|
||
|
||
// Debug: Count function call outputs in history
|
||
let fc_output_count = history
|
||
.iter()
|
||
.filter(|item| matches!(item, ResponseItem::FunctionCallOutput { .. }))
|
||
.count();
|
||
if fc_output_count > 0 {
|
||
debug!(
|
||
"History contains {} FunctionCallOutput items",
|
||
fc_output_count
|
||
);
|
||
}
|
||
|
||
// Count images in extra for debugging (we can't distinguish ephemeral at this level anymore)
|
||
let images_in_extra = extra
|
||
.iter()
|
||
.filter(|item| {
|
||
if let ResponseItem::Message { content, .. } = item {
|
||
content
|
||
.iter()
|
||
.any(|c| matches!(c, ContentItem::InputImage { .. }))
|
||
} else {
|
||
false
|
||
}
|
||
})
|
||
.count();
|
||
|
||
if images_in_extra > 0 {
|
||
tracing::info!(
|
||
"Found {} images in current turn's extra items",
|
||
images_in_extra
|
||
);
|
||
}
|
||
|
||
// Filter out browser screenshots from historical messages
|
||
// We identify them by the [EPHEMERAL:...] marker that precedes them
|
||
let filtered_history: Vec<ResponseItem> = history
|
||
.into_iter()
|
||
.map(|item| {
|
||
if let ResponseItem::Message { id, role, content } = item {
|
||
if role == "user" {
|
||
// Filter out ephemeral content from user messages
|
||
let mut filtered_content: Vec<ContentItem> = Vec::new();
|
||
let mut skip_next_image = false;
|
||
|
||
for content_item in content {
|
||
match &content_item {
|
||
ContentItem::InputText { text }
|
||
if text.starts_with("[EPHEMERAL:") =>
|
||
{
|
||
// This is an ephemeral marker, skip it and the next image
|
||
skip_next_image = true;
|
||
tracing::info!("Filtering out ephemeral marker: {}", text);
|
||
}
|
||
ContentItem::InputImage { .. }
|
||
if skip_next_image =>
|
||
{
|
||
// Skip this image as it follows an ephemeral marker
|
||
skip_next_image = false;
|
||
tracing::info!("Filtering out ephemeral image from history");
|
||
}
|
||
_ => {
|
||
// Keep everything else
|
||
filtered_content.push(content_item);
|
||
}
|
||
}
|
||
}
|
||
|
||
ResponseItem::Message {
|
||
id,
|
||
role,
|
||
content: filtered_content,
|
||
}
|
||
} else {
|
||
// Keep assistant messages unchanged
|
||
ResponseItem::Message { id, role, content }
|
||
}
|
||
} else {
|
||
item
|
||
}
|
||
})
|
||
.collect();
|
||
|
||
// Concatenate filtered history with current turn's extras (which includes current ephemeral images)
|
||
let mut result = [filtered_history, extra].concat();
|
||
|
||
let current_auth_mode = self
|
||
.client
|
||
.get_auth_manager()
|
||
.and_then(|manager| manager.auth())
|
||
.map(|auth| auth.mode);
|
||
let sanitize_encrypted_reasoning = !matches!(current_auth_mode, Some(AuthMode::ChatGPT));
|
||
|
||
if sanitize_encrypted_reasoning {
|
||
let mut stripped = 0usize;
|
||
result = result
|
||
.into_iter()
|
||
.map(|item| match item {
|
||
ResponseItem::Reasoning {
|
||
id,
|
||
summary,
|
||
content,
|
||
encrypted_content,
|
||
} => {
|
||
if encrypted_content.is_some() {
|
||
stripped += 1;
|
||
}
|
||
ResponseItem::Reasoning {
|
||
id,
|
||
summary,
|
||
content,
|
||
encrypted_content: None,
|
||
}
|
||
}
|
||
other => other,
|
||
})
|
||
.collect();
|
||
if stripped > 0 {
|
||
debug!(
|
||
"Stripped encrypted reasoning from {} history items before sending request",
|
||
stripped
|
||
);
|
||
}
|
||
}
|
||
|
||
debug_history("turn_input_with_history", &result);
|
||
|
||
// Count total images in result for debugging
|
||
let total_images = result
|
||
.iter()
|
||
.filter(|item| {
|
||
if let ResponseItem::Message { content, .. } = item {
|
||
content
|
||
.iter()
|
||
.any(|c| matches!(c, ContentItem::InputImage { .. }))
|
||
} else {
|
||
false
|
||
}
|
||
})
|
||
.count();
|
||
|
||
if total_images > 0 {
|
||
tracing::info!("Total images being sent to model: {}", total_images);
|
||
}
|
||
|
||
result
|
||
}
|
||
|
||
pub(crate) fn build_initial_context(&self, turn_context: &TurnContext) -> Vec<ResponseItem> {
|
||
let mut items = Vec::new();
|
||
if let Some(user_instructions) = turn_context.user_instructions.as_deref() {
|
||
items.push(UserInstructions::new(user_instructions.to_string()).into());
|
||
}
|
||
items.push(ResponseItem::from(EnvironmentContext::new(
|
||
Some(turn_context.cwd.clone()),
|
||
Some(turn_context.approval_policy),
|
||
Some(turn_context.sandbox_policy.clone()),
|
||
Some(self.user_shell.clone()),
|
||
)));
|
||
items
|
||
}
|
||
|
||
pub(crate) fn reconstruct_history_from_rollout(
|
||
&self,
|
||
turn_context: &TurnContext,
|
||
rollout_items: &[RolloutItem],
|
||
) -> Vec<ResponseItem> {
|
||
let mut history = self.build_initial_context(turn_context);
|
||
for item in rollout_items {
|
||
match item {
|
||
RolloutItem::ResponseItem(response_item) => {
|
||
history.push(response_item.clone());
|
||
}
|
||
RolloutItem::Compacted(compacted) => {
|
||
let user_messages = collect_user_messages(&history);
|
||
history = build_compacted_history(
|
||
self.build_initial_context(turn_context),
|
||
&user_messages,
|
||
&compacted.message,
|
||
);
|
||
}
|
||
RolloutItem::Event(recorded_event) => {
|
||
if let code_protocol::protocol::EventMsg::UserMessage(user_msg_event) = &recorded_event.msg {
|
||
history.push(ResponseItem::Message {
|
||
id: Some(recorded_event.id.clone()),
|
||
role: "user".to_string(),
|
||
content: vec![ContentItem::InputText {
|
||
text: user_msg_event.message.clone(),
|
||
}],
|
||
});
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
history
|
||
}
|
||
|
||
/// Returns the input if there was no agent running to inject into
|
||
pub fn inject_input(&self, input: Vec<InputItem>) -> Result<(), Vec<InputItem>> {
|
||
let mut state = self.state.lock().unwrap();
|
||
if let Some(task) = state.current_task.as_ref() {
|
||
let mut response = response_input_from_core_items(input);
|
||
self.enforce_user_message_limits(&task.sub_id, &mut response);
|
||
state.pending_input.push(response);
|
||
Ok(())
|
||
} else {
|
||
Err(input)
|
||
}
|
||
}
|
||
|
||
pub fn enqueue_manual_compact(&self, sub_id: String) -> bool {
|
||
let mut state = self.state.lock().unwrap();
|
||
let was_empty = state.pending_manual_compacts.is_empty();
|
||
state.pending_manual_compacts.push_back(sub_id);
|
||
was_empty
|
||
}
|
||
|
||
pub fn dequeue_manual_compact(&self) -> Option<String> {
|
||
let mut state = self.state.lock().unwrap();
|
||
state.pending_manual_compacts.pop_front()
|
||
}
|
||
|
||
|
||
pub fn get_pending_input(&self) -> Vec<ResponseInputItem> {
|
||
self.get_pending_input_filtered(true)
|
||
}
|
||
|
||
/// Returns pending input for the current turn. Callers can decide whether
|
||
/// queued user inputs should be drained immediately (`drain_user_inputs = true`)
|
||
/// or preserved for a later turn—for example, review mode keeps them queued
|
||
/// so the primary agent can resume once the review finishes.
|
||
pub fn get_pending_input_filtered(&self, drain_user_inputs: bool) -> Vec<ResponseInputItem> {
|
||
let mut state = self.state.lock().unwrap();
|
||
if state.pending_input.is_empty()
|
||
&& (drain_user_inputs || state.pending_user_input.is_empty())
|
||
{
|
||
Vec::with_capacity(0)
|
||
} else {
|
||
let mut ret = Vec::new();
|
||
if !state.pending_input.is_empty() {
|
||
let mut model_inputs = Vec::new();
|
||
std::mem::swap(&mut model_inputs, &mut state.pending_input);
|
||
ret.extend(model_inputs);
|
||
}
|
||
|
||
if !state.pending_user_input.is_empty() {
|
||
if drain_user_inputs {
|
||
let mut queued_user_inputs = Vec::new();
|
||
std::mem::swap(&mut queued_user_inputs, &mut state.pending_user_input);
|
||
ret.extend(
|
||
queued_user_inputs
|
||
.into_iter()
|
||
.map(|queued| queued.response_item),
|
||
);
|
||
} else {
|
||
ret.extend(
|
||
state
|
||
.pending_user_input
|
||
.iter()
|
||
.map(|queued| queued.response_item.clone()),
|
||
);
|
||
}
|
||
}
|
||
ret
|
||
}
|
||
}
|
||
|
||
pub fn add_pending_input(&self, mut input: ResponseInputItem) {
|
||
let mut state = self.state.lock().unwrap();
|
||
if let Some(task) = state.current_task.as_ref() {
|
||
self.enforce_user_message_limits(&task.sub_id, &mut input);
|
||
}
|
||
state.pending_input.push(input);
|
||
}
|
||
|
||
pub async fn call_tool(
|
||
&self,
|
||
server: &str,
|
||
tool: &str,
|
||
arguments: Option<serde_json::Value>,
|
||
timeout: Option<Duration>,
|
||
) -> anyhow::Result<CallToolResult> {
|
||
self.mcp_connection_manager
|
||
.call_tool(server, tool, arguments, timeout)
|
||
.await
|
||
}
|
||
|
||
fn abort(&self) {
|
||
info!("Aborting existing session");
|
||
|
||
self.mark_all_running_execs_as_cancelled();
|
||
|
||
let mut state = self.state.lock().unwrap();
|
||
state.pending_approvals.clear();
|
||
// Do not clear `pending_input` here. When a user submits a new message
|
||
// immediately after an interrupt, it may have been routed to
|
||
// `pending_input` by an earlier code path. Clearing it would drop the
|
||
// user's message and prevent the next turn from ever starting.
|
||
state.turn_scratchpad = None;
|
||
// Take current task while holding the lock, then drop the lock BEFORE calling abort
|
||
let current = state.current_task.take();
|
||
drop(state);
|
||
if let Some(agent) = current {
|
||
agent.abort(TurnAbortReason::Interrupted);
|
||
}
|
||
// Also terminate any running exec sessions (PTY-based) so child processes do not linger.
|
||
// Best-effort cleanup for PTY-based exec sessions would go here. The
|
||
// PTY implementation already kills processes on session drop; in the
|
||
// common LocalShellCall path we also kill processes immediately via
|
||
// KillOnDrop in exec.rs.
|
||
}
|
||
|
||
/// Spawn the configured notifier (if any) with the given JSON payload as
|
||
/// the last argument. Failures are logged but otherwise ignored so that
|
||
/// notification issues do not interfere with the main workflow.
|
||
fn maybe_notify(&self, notification: UserNotification) {
|
||
let Some(notify_command) = &self.notify else {
|
||
return;
|
||
};
|
||
|
||
if notify_command.is_empty() {
|
||
return;
|
||
}
|
||
|
||
let Ok(json) = serde_json::to_string(¬ification) else {
|
||
error!("failed to serialise notification payload");
|
||
return;
|
||
};
|
||
|
||
let mut command = std::process::Command::new(¬ify_command[0]);
|
||
if notify_command.len() > 1 {
|
||
command.args(¬ify_command[1..]);
|
||
}
|
||
command.arg(json);
|
||
|
||
// Fire-and-forget – we do not wait for completion.
|
||
if let Err(e) = command.spawn() {
|
||
warn!("failed to spawn notifier '{}': {e}", notify_command[0]);
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Drop for Session {
|
||
fn drop(&mut self) {
|
||
// Interrupt any running turn when the session is dropped.
|
||
self.abort();
|
||
}
|
||
}
|
||
|
||
impl State {
|
||
pub fn partial_clone(&self) -> Self {
|
||
Self {
|
||
approved_commands: self.approved_commands.clone(),
|
||
history: self.history.clone(),
|
||
// Preserve request_ordinal so reconfigurations (e.g., /reasoning)
|
||
// do not reset provider ordering mid-session.
|
||
request_ordinal: self.request_ordinal,
|
||
background_seq_by_sub_id: self.background_seq_by_sub_id.clone(),
|
||
dry_run_guard: self.dry_run_guard.clone(),
|
||
next_internal_sub_id: self.next_internal_sub_id,
|
||
..Default::default()
|
||
}
|
||
}
|
||
}
|
||
|
||
fn synthetic_exec_end_payload(cancelled: bool) -> (i32, String) {
|
||
if cancelled {
|
||
(130, "Command cancelled by user.".to_string())
|
||
} else {
|
||
(130, "Command interrupted before completion.".to_string())
|
||
}
|
||
}
|
||
|
||
struct ExecDropGuard {
|
||
sub_id: String,
|
||
call_id: String,
|
||
order_meta: crate::protocol::OrderMeta,
|
||
tx_event: Sender<Event>,
|
||
cancel_flag: Arc<AtomicBool>,
|
||
end_emitted: Arc<AtomicBool>,
|
||
session: Weak<Session>,
|
||
completed: bool,
|
||
}
|
||
|
||
impl ExecDropGuard {
|
||
fn new(
|
||
session: Weak<Session>,
|
||
tx_event: Sender<Event>,
|
||
sub_id: String,
|
||
call_id: String,
|
||
order_meta: crate::protocol::OrderMeta,
|
||
cancel_flag: Arc<AtomicBool>,
|
||
end_emitted: Arc<AtomicBool>,
|
||
) -> Self {
|
||
Self {
|
||
sub_id,
|
||
call_id,
|
||
order_meta,
|
||
tx_event,
|
||
cancel_flag,
|
||
end_emitted,
|
||
session,
|
||
completed: false,
|
||
}
|
||
}
|
||
|
||
fn mark_completed(&mut self) {
|
||
self.completed = true;
|
||
self.end_emitted.store(true, Ordering::Release);
|
||
self.remove_from_registry();
|
||
}
|
||
|
||
fn remove_from_registry(&self) {
|
||
if let Some(session) = self.session.upgrade() {
|
||
session.unregister_running_exec(&self.call_id);
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Drop for ExecDropGuard {
|
||
fn drop(&mut self) {
|
||
self.remove_from_registry();
|
||
|
||
if self.completed {
|
||
return;
|
||
}
|
||
|
||
if self.end_emitted.swap(true, Ordering::AcqRel) {
|
||
return;
|
||
}
|
||
|
||
let (exit_code, stderr) = synthetic_exec_end_payload(
|
||
self.cancel_flag.load(Ordering::Acquire),
|
||
);
|
||
let msg = EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||
call_id: self.call_id.clone(),
|
||
stdout: String::new(),
|
||
stderr,
|
||
exit_code,
|
||
duration: Duration::ZERO,
|
||
});
|
||
|
||
if let Some(session) = self.session.upgrade() {
|
||
let event = session.make_event_with_order(
|
||
&self.sub_id,
|
||
msg,
|
||
self.order_meta.clone(),
|
||
self.order_meta.sequence_number,
|
||
);
|
||
let _ = self.tx_event.try_send(event);
|
||
} else {
|
||
// Fallback: emit directly if session no longer exists.
|
||
let event = Event {
|
||
id: self.sub_id.clone(),
|
||
event_seq: 0,
|
||
msg,
|
||
order: Some(self.order_meta.clone()),
|
||
};
|
||
let _ = self.tx_event.try_send(event);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub(crate) struct ExecCommandContext {
|
||
pub(crate) sub_id: String,
|
||
pub(crate) call_id: String,
|
||
pub(crate) command_for_display: Vec<String>,
|
||
pub(crate) cwd: PathBuf,
|
||
pub(crate) apply_patch: Option<ApplyPatchCommandContext>,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub(crate) struct ApplyPatchCommandContext {
|
||
pub(crate) user_explicitly_approved_this_action: bool,
|
||
pub(crate) changes: HashMap<PathBuf, FileChange>,
|
||
}
|
||
|
||
fn sanitize_identifier(value: &str) -> String {
|
||
let mut slug = String::with_capacity(value.len());
|
||
for ch in value.chars() {
|
||
if ch.is_ascii_alphanumeric() {
|
||
slug.push(ch.to_ascii_lowercase());
|
||
} else {
|
||
slug.push('_');
|
||
}
|
||
}
|
||
while slug.starts_with('_') {
|
||
slug.remove(0);
|
||
}
|
||
if slug.is_empty() {
|
||
slug.push_str("hook");
|
||
}
|
||
slug
|
||
}
|
||
|
||
fn truncate_payload(text: &str, limit: usize) -> String {
|
||
let mut iter = text.chars();
|
||
let truncated: String = iter.by_ref().take(limit).collect();
|
||
if iter.next().is_some() {
|
||
format!("{truncated}…")
|
||
} else {
|
||
truncated
|
||
}
|
||
}
|
||
|
||
fn build_exec_hook_payload(
|
||
event: ProjectHookEvent,
|
||
ctx: &ExecCommandContext,
|
||
params: &ExecParams,
|
||
output: Option<&ExecToolCallOutput>,
|
||
) -> Value {
|
||
let base = json!({
|
||
"event": event.as_str(),
|
||
"call_id": ctx.call_id,
|
||
"cwd": ctx.cwd.to_string_lossy(),
|
||
"command": params.command,
|
||
"timeout_ms": params.timeout_ms,
|
||
});
|
||
|
||
match event {
|
||
ProjectHookEvent::ToolBefore => base,
|
||
ProjectHookEvent::ToolAfter => {
|
||
if let Some(out) = output {
|
||
json!({
|
||
"event": event.as_str(),
|
||
"call_id": ctx.call_id,
|
||
"cwd": ctx.cwd.to_string_lossy(),
|
||
"command": params.command,
|
||
"timeout_ms": params.timeout_ms,
|
||
"exit_code": out.exit_code,
|
||
"duration_ms": out.duration.as_millis(),
|
||
"timed_out": out.timed_out,
|
||
"stdout": truncate_payload(&out.stdout.text, HOOK_OUTPUT_LIMIT),
|
||
"stderr": truncate_payload(&out.stderr.text, HOOK_OUTPUT_LIMIT),
|
||
})
|
||
} else {
|
||
base
|
||
}
|
||
}
|
||
ProjectHookEvent::FileBeforeWrite => {
|
||
let changes = ctx
|
||
.apply_patch
|
||
.as_ref()
|
||
.and_then(|p| serde_json::to_value(&p.changes).ok())
|
||
.unwrap_or(Value::Null);
|
||
json!({
|
||
"event": event.as_str(),
|
||
"call_id": ctx.call_id,
|
||
"cwd": ctx.cwd.to_string_lossy(),
|
||
"command": params.command,
|
||
"timeout_ms": params.timeout_ms,
|
||
"changes": changes,
|
||
})
|
||
}
|
||
ProjectHookEvent::FileAfterWrite => {
|
||
let changes = ctx
|
||
.apply_patch
|
||
.as_ref()
|
||
.and_then(|p| serde_json::to_value(&p.changes).ok())
|
||
.unwrap_or(Value::Null);
|
||
if let Some(out) = output {
|
||
json!({
|
||
"event": event.as_str(),
|
||
"call_id": ctx.call_id,
|
||
"cwd": ctx.cwd.to_string_lossy(),
|
||
"command": params.command,
|
||
"timeout_ms": params.timeout_ms,
|
||
"changes": changes,
|
||
"exit_code": out.exit_code,
|
||
"duration_ms": out.duration.as_millis(),
|
||
"timed_out": out.timed_out,
|
||
"stdout": truncate_payload(&out.stdout.text, HOOK_OUTPUT_LIMIT),
|
||
"stderr": truncate_payload(&out.stderr.text, HOOK_OUTPUT_LIMIT),
|
||
"success": out.exit_code == 0,
|
||
})
|
||
} else {
|
||
json!({
|
||
"event": event.as_str(),
|
||
"call_id": ctx.call_id,
|
||
"cwd": ctx.cwd.to_string_lossy(),
|
||
"command": params.command,
|
||
"timeout_ms": params.timeout_ms,
|
||
"changes": changes,
|
||
})
|
||
}
|
||
}
|
||
_ => base,
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||
enum AgentTaskKind {
|
||
Regular,
|
||
Review,
|
||
Compact,
|
||
}
|
||
|
||
/// A series of Turns in response to user input.
|
||
pub(crate) struct AgentTask {
|
||
sess: Arc<Session>,
|
||
sub_id: String,
|
||
handle: AbortHandle,
|
||
kind: AgentTaskKind,
|
||
}
|
||
|
||
impl AgentTask {
|
||
fn spawn(
|
||
sess: Arc<Session>,
|
||
turn_context: Arc<TurnContext>,
|
||
sub_id: String,
|
||
input: Vec<InputItem>,
|
||
) -> Self {
|
||
let handle = {
|
||
let sess_clone = Arc::clone(&sess);
|
||
let tc_clone = Arc::clone(&turn_context);
|
||
let sub_clone = sub_id.clone();
|
||
tokio::spawn(async move {
|
||
run_agent(sess_clone, tc_clone, sub_clone, input).await;
|
||
})
|
||
.abort_handle()
|
||
};
|
||
Self {
|
||
sess,
|
||
sub_id,
|
||
handle,
|
||
kind: AgentTaskKind::Regular,
|
||
}
|
||
}
|
||
|
||
fn compact(
|
||
sess: Arc<Session>,
|
||
turn_context: Arc<TurnContext>,
|
||
sub_id: String,
|
||
input: Vec<InputItem>,
|
||
compact_instructions: String,
|
||
) -> Self {
|
||
let handle = {
|
||
let sess_clone = Arc::clone(&sess);
|
||
let tc_clone = Arc::clone(&turn_context);
|
||
let sub_clone = sub_id.clone();
|
||
tokio::spawn(async move {
|
||
compact::run_compact_task(
|
||
sess_clone,
|
||
tc_clone,
|
||
sub_clone,
|
||
input,
|
||
compact_instructions,
|
||
)
|
||
.await;
|
||
})
|
||
.abort_handle()
|
||
};
|
||
Self {
|
||
sess,
|
||
sub_id,
|
||
handle,
|
||
kind: AgentTaskKind::Compact,
|
||
}
|
||
}
|
||
|
||
fn review(
|
||
sess: Arc<Session>,
|
||
turn_context: Arc<TurnContext>,
|
||
sub_id: String,
|
||
input: Vec<InputItem>,
|
||
) -> Self {
|
||
let handle = {
|
||
let sess_clone = Arc::clone(&sess);
|
||
let tc_clone = Arc::clone(&turn_context);
|
||
let sub_clone = sub_id.clone();
|
||
tokio::spawn(async move {
|
||
run_agent(sess_clone, tc_clone, sub_clone, input).await;
|
||
})
|
||
.abort_handle()
|
||
};
|
||
Self {
|
||
sess,
|
||
sub_id,
|
||
handle,
|
||
kind: AgentTaskKind::Review,
|
||
}
|
||
}
|
||
|
||
fn abort(self, reason: TurnAbortReason) {
|
||
if !self.handle.is_finished() {
|
||
self.handle.abort();
|
||
let event = self
|
||
.sess
|
||
.make_event(&self.sub_id, EventMsg::TurnAborted(TurnAbortedEvent { reason }));
|
||
let sess = self.sess.clone();
|
||
let sub_id = self.sub_id.clone();
|
||
let kind = self.kind;
|
||
tokio::spawn(async move {
|
||
if kind == AgentTaskKind::Review {
|
||
exit_review_mode(sess.clone(), sub_id, None).await;
|
||
}
|
||
sess.send_event(event).await;
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
async fn submission_loop(
|
||
mut session_id: Uuid,
|
||
config: Arc<Config>,
|
||
auth_manager: Option<Arc<AuthManager>>,
|
||
rx_sub: Receiver<Submission>,
|
||
tx_event: Sender<Event>,
|
||
) {
|
||
let mut config = config;
|
||
let mut sess: Option<Arc<Session>> = None;
|
||
let mut agent_manager_initialized = false;
|
||
// shorthand - send an event when there is no active session
|
||
let send_no_session_event = |sub_id: String| async {
|
||
let event = Event {
|
||
id: sub_id,
|
||
event_seq: 0,
|
||
msg: EventMsg::Error(ErrorEvent { message: "No session initialized, expected 'ConfigureSession' as first Op".to_string() }),
|
||
order: None,
|
||
};
|
||
tx_event.send(event).await.ok();
|
||
};
|
||
|
||
// To break out of this loop, send Op::Shutdown.
|
||
while let Ok(sub) = rx_sub.recv().await {
|
||
debug!(?sub, "Submission");
|
||
match sub.op {
|
||
Op::Interrupt => {
|
||
let sess = match sess.as_ref() {
|
||
Some(sess) => sess.clone(),
|
||
None => {
|
||
send_no_session_event(sub.id).await;
|
||
continue;
|
||
}
|
||
};
|
||
tokio::spawn(async move {
|
||
sess.notify_wait_interrupted(WaitInterruptReason::SessionAborted);
|
||
sess.abort();
|
||
});
|
||
}
|
||
Op::CancelAgents { batch_ids, agent_ids } => {
|
||
let sess_arc = match sess.as_ref() {
|
||
Some(sess) => Arc::clone(sess),
|
||
None => {
|
||
send_no_session_event(sub.id).await;
|
||
continue;
|
||
}
|
||
};
|
||
|
||
let mut manager = AGENT_MANAGER.write().await;
|
||
let mut seen_batches: HashSet<String> = HashSet::new();
|
||
let mut seen_agents: HashSet<String> = HashSet::new();
|
||
let mut cancelled = 0usize;
|
||
|
||
for batch in batch_ids {
|
||
let trimmed = batch.trim();
|
||
if trimmed.is_empty() {
|
||
continue;
|
||
}
|
||
if !seen_batches.insert(trimmed.to_string()) {
|
||
continue;
|
||
}
|
||
cancelled += manager.cancel_batch(trimmed).await;
|
||
}
|
||
|
||
for agent_id in agent_ids {
|
||
let trimmed = agent_id.trim();
|
||
if trimmed.is_empty() {
|
||
continue;
|
||
}
|
||
if !seen_agents.insert(trimmed.to_string()) {
|
||
continue;
|
||
}
|
||
if manager.cancel_agent(trimmed).await {
|
||
cancelled += 1;
|
||
}
|
||
}
|
||
|
||
drop(manager);
|
||
|
||
send_agent_status_update(&sess_arc).await;
|
||
|
||
let message = if cancelled == 0 {
|
||
"No running agents to cancel.".to_string()
|
||
} else {
|
||
let suffix = if cancelled == 1 { "" } else { "s" };
|
||
format!("Cancelled {cancelled} running agent{suffix}.")
|
||
};
|
||
|
||
let event = sess_arc.make_event(
|
||
&sub.id,
|
||
EventMsg::AgentMessage(AgentMessageEvent { message }),
|
||
);
|
||
sess_arc.send_event(event).await;
|
||
}
|
||
Op::AddPendingInputDeveloper { text } => {
|
||
let sess = match sess.as_ref() { Some(s) => s.clone(), None => { send_no_session_event(sub.id).await; continue; } };
|
||
let dev_msg = ResponseInputItem::Message { role: "developer".to_string(), content: vec![ContentItem::InputText { text }] };
|
||
let should_start_turn = sess.enqueue_out_of_turn_item(dev_msg);
|
||
if should_start_turn {
|
||
sess.cleanup_old_status_items().await;
|
||
let turn_context = sess.make_turn_context();
|
||
let sub_id = sess.next_internal_sub_id();
|
||
let sentinel_input = vec![InputItem::Text {
|
||
text: PENDING_ONLY_SENTINEL.to_string(),
|
||
}];
|
||
let agent = AgentTask::spawn(Arc::clone(&sess), turn_context, sub_id, sentinel_input);
|
||
sess.set_task(agent);
|
||
}
|
||
}
|
||
Op::ConfigureSession {
|
||
provider,
|
||
model,
|
||
model_reasoning_effort,
|
||
model_reasoning_summary,
|
||
model_text_verbosity,
|
||
user_instructions: provided_user_instructions,
|
||
base_instructions,
|
||
approval_policy,
|
||
sandbox_policy,
|
||
disable_response_storage,
|
||
notify,
|
||
cwd,
|
||
resume_path,
|
||
} => {
|
||
debug!(
|
||
"Configuring session: model={model}; provider={provider:?}; resume={resume_path:?}"
|
||
);
|
||
if !cwd.is_absolute() {
|
||
let message = format!("cwd is not absolute: {cwd:?}");
|
||
error!(message);
|
||
let event = Event { id: sub.id, event_seq: 0, msg: EventMsg::Error(ErrorEvent { message }), order: None };
|
||
if let Err(e) = tx_event.send(event).await {
|
||
error!("failed to send error message: {e:?}");
|
||
}
|
||
return;
|
||
}
|
||
let current_config = Arc::clone(&config);
|
||
let mut updated_config = (*current_config).clone();
|
||
|
||
let model_changed = !updated_config.model.eq_ignore_ascii_case(&model);
|
||
let effort_changed = updated_config.model_reasoning_effort != model_reasoning_effort;
|
||
|
||
let old_model_family = updated_config.model_family.clone();
|
||
let old_model_info = get_model_info(&old_model_family);
|
||
|
||
updated_config.model = model.clone();
|
||
updated_config.model_provider = provider.clone();
|
||
updated_config.model_reasoning_effort = model_reasoning_effort;
|
||
updated_config.model_reasoning_summary = model_reasoning_summary;
|
||
updated_config.model_text_verbosity = model_text_verbosity;
|
||
updated_config.user_instructions = provided_user_instructions.clone();
|
||
updated_config.base_instructions = base_instructions.clone();
|
||
updated_config.approval_policy = approval_policy;
|
||
updated_config.sandbox_policy = sandbox_policy.clone();
|
||
updated_config.disable_response_storage = disable_response_storage;
|
||
updated_config.notify = notify.clone();
|
||
updated_config.cwd = cwd.clone();
|
||
|
||
updated_config.model_family = find_family_for_model(&updated_config.model)
|
||
.unwrap_or_else(|| derive_default_model_family(&updated_config.model));
|
||
|
||
let new_model_info = get_model_info(&updated_config.model_family);
|
||
|
||
let old_context_window = old_model_info.as_ref().map(|info| info.context_window);
|
||
let new_context_window = new_model_info.as_ref().map(|info| info.context_window);
|
||
let old_max_tokens = old_model_info.as_ref().map(|info| info.max_output_tokens);
|
||
let new_max_tokens = new_model_info.as_ref().map(|info| info.max_output_tokens);
|
||
let old_auto_compact = old_model_info
|
||
.as_ref()
|
||
.and_then(|info| info.auto_compact_token_limit);
|
||
let new_auto_compact = new_model_info
|
||
.as_ref()
|
||
.and_then(|info| info.auto_compact_token_limit);
|
||
|
||
maybe_update_from_model_info(
|
||
&mut updated_config.model_context_window,
|
||
old_context_window,
|
||
new_context_window,
|
||
);
|
||
maybe_update_from_model_info(
|
||
&mut updated_config.model_max_output_tokens,
|
||
old_max_tokens,
|
||
new_max_tokens,
|
||
);
|
||
maybe_update_from_model_info(
|
||
&mut updated_config.model_auto_compact_token_limit,
|
||
old_auto_compact,
|
||
new_auto_compact,
|
||
);
|
||
|
||
let computed_user_instructions =
|
||
get_user_instructions(&updated_config).await;
|
||
updated_config.user_instructions = computed_user_instructions.clone();
|
||
|
||
let effective_user_instructions = computed_user_instructions.clone();
|
||
|
||
// Optionally resume an existing rollout.
|
||
let mut restored_items: Option<Vec<RolloutItem>> = None;
|
||
let mut restored_history_snapshot: Option<crate::history::HistorySnapshot> = None;
|
||
let mut resume_notice: Option<String> = None;
|
||
let mut rollout_recorder: Option<RolloutRecorder> = None;
|
||
if let Some(path) = resume_path.as_ref() {
|
||
match RolloutRecorder::resume(&updated_config, path).await {
|
||
Ok((rec, saved)) => {
|
||
session_id = saved.session_id;
|
||
if !saved.items.is_empty() {
|
||
restored_items = Some(saved.items);
|
||
}
|
||
if let Some(snapshot) = saved.history_snapshot {
|
||
restored_history_snapshot = Some(snapshot);
|
||
}
|
||
rollout_recorder = Some(rec);
|
||
}
|
||
Err(e) => {
|
||
warn!("failed to resume rollout from {path:?}: {e}");
|
||
resume_notice = Some(format!(
|
||
"⚠️ Failed to load previous session from {}: {e}. Starting a new conversation instead.",
|
||
path.display()
|
||
));
|
||
updated_config.experimental_resume = None;
|
||
}
|
||
}
|
||
}
|
||
|
||
let new_config = Arc::new(updated_config);
|
||
|
||
if model_changed || effort_changed {
|
||
if let Err(err) = persist_model_selection(
|
||
&new_config.code_home,
|
||
new_config.active_profile.as_deref(),
|
||
&new_config.model,
|
||
Some(new_config.model_reasoning_effort),
|
||
)
|
||
.await
|
||
{
|
||
warn!("failed to persist model selection: {err:#}");
|
||
}
|
||
}
|
||
|
||
config = Arc::clone(&new_config);
|
||
|
||
let rollout_recorder = match rollout_recorder {
|
||
Some(rec) => Some(rec),
|
||
None => {
|
||
match RolloutRecorder::new(
|
||
&config,
|
||
crate::rollout::recorder::RolloutRecorderParams::new(
|
||
code_protocol::mcp_protocol::ConversationId::from(session_id),
|
||
effective_user_instructions.clone(),
|
||
SessionSource::Cli,
|
||
),
|
||
)
|
||
.await
|
||
{
|
||
Ok(r) => Some(r),
|
||
Err(e) => {
|
||
warn!("failed to initialise rollout recorder: {e}");
|
||
None
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
// Create debug logger based on config
|
||
let debug_logger = match crate::debug_logger::DebugLogger::new(config.debug) {
|
||
Ok(logger) => std::sync::Arc::new(std::sync::Mutex::new(logger)),
|
||
Err(e) => {
|
||
warn!("Failed to create debug logger: {}", e);
|
||
// Create a disabled logger as fallback
|
||
std::sync::Arc::new(std::sync::Mutex::new(
|
||
crate::debug_logger::DebugLogger::new(false).unwrap(),
|
||
))
|
||
}
|
||
};
|
||
|
||
if config.debug {
|
||
if let Ok(logger) = debug_logger.lock() {
|
||
if let Err(e) = logger.set_session_usage_file(&session_id) {
|
||
warn!("failed to initialise session usage log: {e}");
|
||
}
|
||
}
|
||
}
|
||
|
||
let conversation_id = code_protocol::mcp_protocol::ConversationId::from(session_id);
|
||
let auth_snapshot = auth_manager.as_ref().and_then(|mgr| mgr.auth());
|
||
let otel_event_manager = {
|
||
let manager = OtelEventManager::new(
|
||
conversation_id,
|
||
config.model.as_str(),
|
||
config.model_family.slug.as_str(),
|
||
auth_snapshot
|
||
.as_ref()
|
||
.and_then(|auth| auth.get_account_id()),
|
||
auth_snapshot.as_ref().map(|auth| auth.mode),
|
||
config.otel.log_user_prompt,
|
||
crate::terminal::user_agent(),
|
||
);
|
||
manager.conversation_starts(
|
||
config.model_provider.name.as_str(),
|
||
Some(to_proto_reasoning_effort(model_reasoning_effort)),
|
||
to_proto_reasoning_summary(model_reasoning_summary),
|
||
config.model_context_window,
|
||
config.model_max_output_tokens,
|
||
config.model_auto_compact_token_limit,
|
||
to_proto_approval_policy(approval_policy),
|
||
to_proto_sandbox_policy(sandbox_policy.clone()),
|
||
config
|
||
.mcp_servers
|
||
.keys()
|
||
.map(String::as_str)
|
||
.collect(),
|
||
config.active_profile.clone(),
|
||
);
|
||
manager
|
||
};
|
||
|
||
// Wrap provided auth (if any) in a minimal AuthManager for client usage.
|
||
let client = ModelClient::new(
|
||
config.clone(),
|
||
auth_manager.clone(),
|
||
Some(otel_event_manager.clone()),
|
||
provider.clone(),
|
||
model_reasoning_effort,
|
||
model_reasoning_summary,
|
||
model_text_verbosity,
|
||
session_id,
|
||
debug_logger,
|
||
);
|
||
|
||
// abort any current running session and clone its state
|
||
let old_session = sess.take();
|
||
let state = if let Some(sess_arc) = old_session.as_ref() {
|
||
sess_arc.notify_wait_interrupted(WaitInterruptReason::SessionAborted);
|
||
sess_arc.abort();
|
||
sess_arc.state.lock().unwrap().partial_clone()
|
||
} else {
|
||
State {
|
||
history: ConversationHistory::new(),
|
||
..Default::default()
|
||
}
|
||
};
|
||
|
||
let writable_roots = get_writable_roots(&cwd);
|
||
|
||
// Error messages to dispatch after SessionConfigured is sent.
|
||
let mut mcp_connection_errors = Vec::<String>::new();
|
||
let mut excluded_tools = HashSet::new();
|
||
if let Some(client_tools) = config.experimental_client_tools.as_ref() {
|
||
for tool in [
|
||
client_tools.request_permission.as_ref(),
|
||
client_tools.read_text_file.as_ref(),
|
||
client_tools.write_text_file.as_ref(),
|
||
]
|
||
.into_iter()
|
||
.flatten()
|
||
{
|
||
excluded_tools.insert((
|
||
tool.mcp_server.to_string(),
|
||
tool.tool_name.to_string(),
|
||
));
|
||
}
|
||
}
|
||
|
||
if let Some(old_session_arc) = old_session {
|
||
old_session_arc.shutdown_mcp_clients().await;
|
||
drop(old_session_arc);
|
||
}
|
||
|
||
let (mcp_connection_manager, failed_clients) = match McpConnectionManager::new(
|
||
config.mcp_servers.clone(),
|
||
config.use_experimental_use_rmcp_client,
|
||
excluded_tools,
|
||
)
|
||
.await
|
||
{
|
||
Ok((mgr, failures)) => (mgr, failures),
|
||
Err(e) => {
|
||
let message = format!("Failed to create MCP connection manager: {e:#}");
|
||
error!("{message}");
|
||
mcp_connection_errors.push(message);
|
||
(McpConnectionManager::default(), Default::default())
|
||
}
|
||
};
|
||
|
||
// Surface individual client start-up failures to the user.
|
||
if !failed_clients.is_empty() {
|
||
for (server_name, err) in failed_clients {
|
||
let message =
|
||
format!("MCP client for `{server_name}` failed to start: {err:#}");
|
||
error!("{message}");
|
||
mcp_connection_errors.push(message);
|
||
}
|
||
}
|
||
let default_shell = shell::default_user_shell().await;
|
||
let mut tools_config = ToolsConfig::new(
|
||
&config.model_family,
|
||
approval_policy,
|
||
sandbox_policy.clone(),
|
||
config.include_plan_tool,
|
||
config.include_apply_patch_tool,
|
||
config.tools_web_search_request,
|
||
config.use_experimental_streamable_shell_tool,
|
||
config.include_view_image_tool,
|
||
);
|
||
tools_config.web_search_allowed_domains =
|
||
config.tools_web_search_allowed_domains.clone();
|
||
|
||
let mut agent_models: Vec<String> = if config.agents.is_empty() {
|
||
default_agent_configs()
|
||
.into_iter()
|
||
.filter(|cfg| cfg.enabled)
|
||
.map(|cfg| cfg.name)
|
||
.collect()
|
||
} else {
|
||
get_enabled_agents(&config.agents)
|
||
};
|
||
if agent_models.is_empty() {
|
||
agent_models = enabled_agent_model_specs()
|
||
.into_iter()
|
||
.map(|spec| spec.slug.to_string())
|
||
.collect();
|
||
}
|
||
agent_models.sort_by(|a, b| a.to_ascii_lowercase().cmp(&b.to_ascii_lowercase()));
|
||
agent_models.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
|
||
tools_config.set_agent_models(agent_models);
|
||
|
||
let mut new_session = Arc::new(Session {
|
||
id: session_id,
|
||
client,
|
||
tools_config,
|
||
tx_event: tx_event.clone(),
|
||
user_instructions: effective_user_instructions.clone(),
|
||
base_instructions,
|
||
approval_policy,
|
||
sandbox_policy,
|
||
shell_environment_policy: config.shell_environment_policy.clone(),
|
||
cwd,
|
||
_writable_roots: writable_roots,
|
||
mcp_connection_manager,
|
||
client_tools: config.experimental_client_tools.clone(),
|
||
session_manager: crate::exec_command::ExecSessionManager::default(),
|
||
agents: config.agents.clone(),
|
||
notify,
|
||
state: Mutex::new(state),
|
||
rollout: Mutex::new(rollout_recorder),
|
||
code_linux_sandbox_exe: config.code_linux_sandbox_exe.clone(),
|
||
disable_response_storage,
|
||
user_shell: default_shell,
|
||
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
|
||
pending_browser_screenshots: Mutex::new(Vec::new()),
|
||
last_system_status: Mutex::new(None),
|
||
last_screenshot_info: Mutex::new(None),
|
||
confirm_guard: ConfirmGuardRuntime::from_config(&config.confirm_guard),
|
||
project_hooks: config.project_hooks.clone(),
|
||
project_commands: config.project_commands.clone(),
|
||
hook_guard: AtomicBool::new(false),
|
||
github: Arc::new(RwLock::new(config.github.clone())),
|
||
validation: Arc::new(RwLock::new(config.validation.clone())),
|
||
self_handle: Weak::new(),
|
||
active_review: Mutex::new(None),
|
||
next_turn_text_format: Mutex::new(None),
|
||
});
|
||
let weak_handle = Arc::downgrade(&new_session);
|
||
if let Some(inner) = Arc::get_mut(&mut new_session) {
|
||
inner.self_handle = weak_handle;
|
||
}
|
||
sess = Some(new_session);
|
||
if let Some(sess_arc) = &sess {
|
||
if !config.always_allow_commands.is_empty() {
|
||
let mut st = sess_arc.state.lock().unwrap();
|
||
for pattern in &config.always_allow_commands {
|
||
st.approved_commands.insert(pattern.clone());
|
||
}
|
||
}
|
||
}
|
||
let mut replay_history_items: Option<Vec<ResponseItem>> = None;
|
||
|
||
|
||
// Patch restored state into the newly created session.
|
||
if let Some(sess_arc) = &sess {
|
||
if let Some(items) = &restored_items {
|
||
let turn_context = sess_arc.make_turn_context();
|
||
let reconstructed = sess_arc.reconstruct_history_from_rollout(&turn_context, items);
|
||
{
|
||
let mut st = sess_arc.state.lock().unwrap();
|
||
st.history = ConversationHistory::new();
|
||
st.history.record_items(reconstructed.iter());
|
||
}
|
||
replay_history_items = Some(reconstructed);
|
||
}
|
||
}
|
||
|
||
// Gather history metadata for SessionConfiguredEvent.
|
||
let (history_log_id, history_entry_count) =
|
||
crate::message_history::history_metadata(&config).await;
|
||
|
||
// ack
|
||
let sess_arc = sess.as_ref().expect("session initialized");
|
||
let events = std::iter::once(sess_arc.make_event(
|
||
INITIAL_SUBMIT_ID,
|
||
EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||
session_id,
|
||
model,
|
||
history_log_id,
|
||
history_entry_count,
|
||
}),
|
||
))
|
||
.chain(mcp_connection_errors.into_iter().map(|message| {
|
||
sess_arc.make_event(&sub.id, EventMsg::Error(ErrorEvent { message }))
|
||
}));
|
||
for event in events {
|
||
if let Err(e) = tx_event.send(event).await {
|
||
error!("failed to send event: {e:?}");
|
||
}
|
||
}
|
||
// If we resumed from a rollout, replay the prior transcript into the UI.
|
||
if replay_history_items.is_some()
|
||
|| restored_history_snapshot.is_some()
|
||
|| restored_items.is_some()
|
||
{
|
||
let items = replay_history_items.clone().unwrap_or_default();
|
||
let history_snapshot_value = restored_history_snapshot
|
||
.as_ref()
|
||
.and_then(|snapshot| serde_json::to_value(snapshot).ok());
|
||
let event = sess_arc.make_event(
|
||
&sub.id,
|
||
EventMsg::ReplayHistory(crate::protocol::ReplayHistoryEvent {
|
||
items,
|
||
history_snapshot: history_snapshot_value,
|
||
}),
|
||
);
|
||
if let Err(e) = tx_event.send(event).await {
|
||
warn!("failed to send ReplayHistory event: {e}");
|
||
}
|
||
}
|
||
|
||
if let Some(notice) = resume_notice {
|
||
let event = sess_arc.make_event(
|
||
&sub.id,
|
||
EventMsg::BackgroundEvent(BackgroundEventEvent { message: notice }),
|
||
);
|
||
if let Err(e) = tx_event.send(event).await {
|
||
warn!("failed to send resume notice event: {e}");
|
||
}
|
||
}
|
||
|
||
if let Some(sess_arc) = &sess {
|
||
sess_arc.run_session_hooks(ProjectHookEvent::SessionStart).await;
|
||
}
|
||
|
||
// Initialize agent manager after SessionConfigured is sent
|
||
if !agent_manager_initialized {
|
||
let mut manager = AGENT_MANAGER.write().await;
|
||
let (agent_tx, mut agent_rx) =
|
||
tokio::sync::mpsc::unbounded_channel::<AgentStatusUpdatePayload>();
|
||
manager.set_event_sender(agent_tx);
|
||
drop(manager);
|
||
|
||
let sess_for_agents = sess.as_ref().expect("session active").clone();
|
||
// Forward agent events to the main event channel
|
||
let tx_event_clone = tx_event.clone();
|
||
tokio::spawn(async move {
|
||
while let Some(payload) = agent_rx.recv().await {
|
||
let status_event = sess_for_agents.make_event(
|
||
"agent_status",
|
||
EventMsg::AgentStatusUpdate(AgentStatusUpdateEvent {
|
||
agents: payload.agents.clone(),
|
||
context: payload.context.clone(),
|
||
task: payload.task.clone(),
|
||
}),
|
||
);
|
||
let _ = tx_event_clone.send(status_event).await;
|
||
}
|
||
});
|
||
agent_manager_initialized = true;
|
||
}
|
||
}
|
||
Op::UserInput { items } => {
|
||
let sess = match sess.as_ref() {
|
||
Some(sess) => sess,
|
||
None => {
|
||
send_no_session_event(sub.id).await;
|
||
continue;
|
||
}
|
||
};
|
||
|
||
// Clean up old status items when new user input arrives
|
||
// This prevents token buildup from old screenshots/status messages
|
||
sess.cleanup_old_status_items().await;
|
||
|
||
// Abort synchronously here to avoid a race that can kill the
|
||
// newly spawned agent if the async abort runs after set_task.
|
||
sess.notify_wait_interrupted(WaitInterruptReason::UserMessage);
|
||
sess.abort();
|
||
|
||
// Spawn a new agent for this user input.
|
||
let turn_context = sess.make_turn_context();
|
||
let agent = AgentTask::spawn(Arc::clone(&sess), turn_context, sub.id.clone(), items);
|
||
sess.set_task(agent);
|
||
}
|
||
Op::QueueUserInput { items } => {
|
||
let sess = match sess.as_ref() {
|
||
Some(sess) => sess,
|
||
None => {
|
||
send_no_session_event(sub.id).await;
|
||
continue;
|
||
}
|
||
};
|
||
|
||
if sess.has_running_task() {
|
||
let mut response_item = response_input_from_core_items(items.clone());
|
||
sess.enforce_user_message_limits(&sub.id, &mut response_item);
|
||
sess.notify_wait_interrupted(WaitInterruptReason::UserMessage);
|
||
let queued = QueuedUserInput {
|
||
submission_id: sub.id.clone(),
|
||
response_item,
|
||
core_items: items,
|
||
};
|
||
sess.queue_user_input(queued);
|
||
} else {
|
||
// No task running: treat this as immediate user input without aborting.
|
||
sess.cleanup_old_status_items().await;
|
||
let turn_context = sess.make_turn_context();
|
||
let agent = AgentTask::spawn(Arc::clone(&sess), turn_context, sub.id.clone(), items);
|
||
sess.set_task(agent);
|
||
}
|
||
}
|
||
Op::ExecApproval { id, decision } => {
|
||
let sess = match sess.as_ref() {
|
||
Some(sess) => sess,
|
||
None => {
|
||
send_no_session_event(sub.id).await;
|
||
continue;
|
||
}
|
||
};
|
||
match decision {
|
||
ReviewDecision::Abort => {
|
||
sess.notify_wait_interrupted(WaitInterruptReason::SessionAborted);
|
||
sess.abort();
|
||
}
|
||
other => sess.notify_approval(&id, other),
|
||
}
|
||
}
|
||
Op::RegisterApprovedCommand {
|
||
command,
|
||
match_kind,
|
||
semantic_prefix,
|
||
} => {
|
||
if command.is_empty() {
|
||
continue;
|
||
}
|
||
if let Some(sess) = sess.as_ref() {
|
||
sess.add_approved_command(ApprovedCommandPattern::new(
|
||
command,
|
||
match_kind,
|
||
semantic_prefix,
|
||
));
|
||
} else {
|
||
send_no_session_event(sub.id).await;
|
||
}
|
||
}
|
||
Op::PatchApproval { id, decision } => {
|
||
let sess = match sess.as_ref() {
|
||
Some(sess) => sess,
|
||
None => {
|
||
send_no_session_event(sub.id).await;
|
||
continue;
|
||
}
|
||
};
|
||
match decision {
|
||
ReviewDecision::Abort => {
|
||
sess.notify_wait_interrupted(WaitInterruptReason::SessionAborted);
|
||
sess.abort();
|
||
}
|
||
other => sess.notify_approval(&id, other),
|
||
}
|
||
}
|
||
Op::UpdateValidationTool { name, enable } => {
|
||
if let Some(sess) = sess.as_ref() {
|
||
sess.update_validation_tool(&name, enable);
|
||
} else {
|
||
send_no_session_event(sub.id).await;
|
||
}
|
||
}
|
||
Op::UpdateValidationGroup { group, enable } => {
|
||
if let Some(sess) = sess.as_ref() {
|
||
sess.update_validation_group(group, enable);
|
||
} else {
|
||
send_no_session_event(sub.id).await;
|
||
}
|
||
}
|
||
Op::AddToHistory { text } => {
|
||
// TODO: What should we do if we got AddToHistory before ConfigureSession?
|
||
// currently, if ConfigureSession has resume path, this history will be ignored
|
||
let id = session_id;
|
||
let config = config.clone();
|
||
tokio::spawn(async move {
|
||
if let Err(e) = crate::message_history::append_entry(&text, &id, &config).await
|
||
{
|
||
warn!("failed to append to message history: {e}");
|
||
}
|
||
});
|
||
}
|
||
|
||
Op::PersistHistorySnapshot { snapshot } => {
|
||
let Some(sess) = sess.as_ref() else {
|
||
send_no_session_event(sub.id).await;
|
||
continue;
|
||
};
|
||
if let Some(recorder) = sess.clone_rollout_recorder() {
|
||
tokio::spawn(async move {
|
||
if let Err(e) = recorder.set_history_snapshot(snapshot).await {
|
||
warn!("failed to persist history snapshot: {e}");
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
Op::RunProjectCommand { name } => {
|
||
let sess = match sess.as_ref() {
|
||
Some(sess) => sess,
|
||
None => {
|
||
send_no_session_event(sub.id).await;
|
||
continue;
|
||
}
|
||
};
|
||
let mut tracker = TurnDiffTracker::new();
|
||
let attempt_req = sess.current_request_ordinal();
|
||
sess.run_project_command(&mut tracker, &sub.id, &name, attempt_req)
|
||
.await;
|
||
}
|
||
|
||
Op::GetHistoryEntryRequest { offset, log_id } => {
|
||
let config = config.clone();
|
||
let tx_event = tx_event.clone();
|
||
let sub_id = sub.id.clone();
|
||
|
||
tokio::spawn(async move {
|
||
// Run lookup in blocking thread because it does file IO + locking.
|
||
let entry_opt = tokio::task::spawn_blocking(move || {
|
||
crate::message_history::lookup(log_id, offset, &config)
|
||
})
|
||
.await
|
||
.unwrap_or(None);
|
||
|
||
let event = Event {
|
||
id: sub_id,
|
||
event_seq: 0,
|
||
msg: EventMsg::GetHistoryEntryResponse(
|
||
crate::protocol::GetHistoryEntryResponseEvent {
|
||
offset,
|
||
log_id,
|
||
entry: entry_opt,
|
||
},
|
||
),
|
||
order: None,
|
||
};
|
||
|
||
if let Err(e) = tx_event.send(event).await {
|
||
warn!("failed to send GetHistoryEntryResponse event: {e}");
|
||
}
|
||
});
|
||
}
|
||
// Upstream protocol no longer includes ListMcpTools; skip handling here.
|
||
Op::Compact => {
|
||
let sess = match sess.as_ref() {
|
||
Some(sess) => sess,
|
||
None => {
|
||
send_no_session_event(sub.id).await;
|
||
continue;
|
||
}
|
||
};
|
||
|
||
// Attempt to inject input into current task
|
||
if let Err(items) = sess.inject_input(vec![InputItem::Text {
|
||
text: compact::SUMMARIZATION_PROMPT.to_string(),
|
||
}]) {
|
||
let turn_context = sess.make_turn_context();
|
||
compact::spawn_compact_task(sess.clone(), turn_context, sub.id.clone(), items);
|
||
} else {
|
||
let was_empty = sess.enqueue_manual_compact(sub.id.clone());
|
||
let message = if was_empty {
|
||
"Manual compact queued; it will run after the current response finishes.".to_string()
|
||
} else {
|
||
"Manual compact already queued; waiting for the current response to finish.".to_string()
|
||
};
|
||
let event = sess.make_event(
|
||
&sub.id,
|
||
EventMsg::AgentMessage(AgentMessageEvent { message }),
|
||
);
|
||
sess.send_event(event).await;
|
||
}
|
||
}
|
||
Op::Review { review_request } => {
|
||
let sess = match sess.as_ref() {
|
||
Some(sess) => Arc::clone(sess),
|
||
None => {
|
||
send_no_session_event(sub.id).await;
|
||
continue;
|
||
}
|
||
};
|
||
let config = Arc::clone(&config);
|
||
let sub_id = sub.id.clone();
|
||
spawn_review_thread(sess, config, sub_id, review_request).await;
|
||
}
|
||
Op::SetNextTextFormat { format } => {
|
||
let sess_arc = match sess.as_ref() {
|
||
Some(sess) => Arc::clone(sess),
|
||
None => {
|
||
send_no_session_event(sub.id).await;
|
||
continue;
|
||
}
|
||
};
|
||
*sess_arc.next_turn_text_format.lock().unwrap() = Some(format);
|
||
}
|
||
Op::Shutdown => {
|
||
info!("Shutting down Codex instance");
|
||
|
||
// Ensure any running agent is aborted so streaming stops promptly.
|
||
if let Some(sess_arc) = sess.as_ref() {
|
||
let s2 = sess_arc.clone();
|
||
tokio::spawn(async move {
|
||
s2.notify_wait_interrupted(WaitInterruptReason::SessionAborted);
|
||
s2.abort();
|
||
});
|
||
}
|
||
|
||
// Gracefully flush and shutdown rollout recorder on session end so tests
|
||
// that inspect the rollout file do not race with the background writer.
|
||
if let Some(ref sess_arc) = sess {
|
||
let recorder_opt = sess_arc.rollout.lock().unwrap().take();
|
||
if let Some(rec) = recorder_opt {
|
||
if let Err(e) = rec.shutdown().await {
|
||
warn!("failed to shutdown rollout recorder: {e}");
|
||
let event = sess_arc.make_event(
|
||
&sub.id,
|
||
EventMsg::Error(ErrorEvent {
|
||
message: "Failed to shutdown rollout recorder".to_string(),
|
||
}),
|
||
);
|
||
if let Err(e) = tx_event.send(event).await {
|
||
warn!("failed to send error message: {e:?}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if let Some(ref sess_arc) = sess {
|
||
sess_arc.run_session_hooks(ProjectHookEvent::SessionEnd).await;
|
||
}
|
||
let event = match sess {
|
||
Some(ref sess_arc) => sess_arc.make_event(&sub.id, EventMsg::ShutdownComplete),
|
||
None => Event {
|
||
id: sub.id.clone(),
|
||
event_seq: 0,
|
||
msg: EventMsg::ShutdownComplete,
|
||
order: None,
|
||
},
|
||
};
|
||
if let Err(e) = tx_event.send(event).await {
|
||
warn!("failed to send Shutdown event: {e}");
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
debug!("Agent loop exited");
|
||
}
|
||
|
||
async fn spawn_review_thread(
|
||
sess: Arc<Session>,
|
||
config: Arc<Config>,
|
||
sub_id: String,
|
||
review_request: ReviewRequest,
|
||
) {
|
||
// Ensure any running task is stopped before starting the review flow.
|
||
sess.notify_wait_interrupted(WaitInterruptReason::SessionAborted);
|
||
sess.abort();
|
||
|
||
let parent_turn_context = sess.make_turn_context();
|
||
|
||
// Determine model + family for review mode.
|
||
let review_model = config.review_model.clone();
|
||
let review_family = find_family_for_model(&review_model)
|
||
.unwrap_or_else(|| derive_default_model_family(&review_model));
|
||
|
||
// Prepare a per-review configuration that favors deterministic feedback.
|
||
let mut review_config = (*config).clone();
|
||
review_config.model = review_model.clone();
|
||
review_config.model_family = review_family.clone();
|
||
review_config.model_reasoning_effort = ReasoningEffortConfig::Low;
|
||
review_config.model_reasoning_summary = ReasoningSummaryConfig::Detailed;
|
||
review_config.model_text_verbosity = config.model_text_verbosity;
|
||
review_config.user_instructions = None;
|
||
review_config.base_instructions = Some(REVIEW_PROMPT.to_string());
|
||
if let Some(info) = get_model_info(&review_family) {
|
||
review_config.model_context_window = Some(info.context_window);
|
||
review_config.model_max_output_tokens = Some(info.max_output_tokens);
|
||
}
|
||
let review_config = Arc::new(review_config);
|
||
|
||
let review_debug_logger = match crate::debug_logger::DebugLogger::new(review_config.debug) {
|
||
Ok(logger) => Arc::new(Mutex::new(logger)),
|
||
Err(err) => {
|
||
warn!("failed to create review debug logger: {err}");
|
||
Arc::new(Mutex::new(
|
||
crate::debug_logger::DebugLogger::new(false).unwrap(),
|
||
))
|
||
}
|
||
};
|
||
|
||
let review_otel = parent_turn_context
|
||
.client
|
||
.get_otel_event_manager()
|
||
.map(|mgr| mgr.with_model(review_config.model.as_str(), review_config.model_family.slug.as_str()));
|
||
|
||
let review_client = ModelClient::new(
|
||
review_config.clone(),
|
||
parent_turn_context.client.get_auth_manager(),
|
||
review_otel,
|
||
parent_turn_context.client.get_provider(),
|
||
review_config.model_reasoning_effort,
|
||
review_config.model_reasoning_summary,
|
||
review_config.model_text_verbosity,
|
||
sess.session_uuid(),
|
||
review_debug_logger,
|
||
);
|
||
|
||
let review_turn_context = Arc::new(TurnContext {
|
||
client: review_client,
|
||
cwd: parent_turn_context.cwd.clone(),
|
||
base_instructions: Some(REVIEW_PROMPT.to_string()),
|
||
user_instructions: None,
|
||
approval_policy: parent_turn_context.approval_policy,
|
||
sandbox_policy: parent_turn_context.sandbox_policy.clone(),
|
||
shell_environment_policy: parent_turn_context.shell_environment_policy.clone(),
|
||
is_review_mode: true,
|
||
text_format_override: None,
|
||
});
|
||
|
||
let review_prompt_text = format!(
|
||
"{}\n\n---\n\nNow, here's your task: {}",
|
||
REVIEW_PROMPT.trim(),
|
||
review_request.prompt.trim()
|
||
);
|
||
let review_input = vec![InputItem::Text {
|
||
text: review_prompt_text,
|
||
}];
|
||
|
||
let task = AgentTask::review(Arc::clone(&sess), Arc::clone(&review_turn_context), sub_id.clone(), review_input);
|
||
sess.set_active_review(review_request.clone());
|
||
sess.set_task(task);
|
||
|
||
let event = sess.make_event(
|
||
&sub_id,
|
||
EventMsg::EnteredReviewMode(review_request.clone()),
|
||
);
|
||
sess.send_event(event).await;
|
||
}
|
||
|
||
async fn exit_review_mode(
|
||
session: Arc<Session>,
|
||
task_sub_id: String,
|
||
review_output: Option<ReviewOutputEvent>,
|
||
) {
|
||
let event = session.make_event(&task_sub_id, EventMsg::ExitedReviewMode(review_output.clone()));
|
||
session.send_event(event).await;
|
||
|
||
let _active_request = session.take_active_review();
|
||
|
||
let developer_text = match review_output.clone() {
|
||
Some(output) => {
|
||
let mut sections: Vec<String> = Vec::new();
|
||
if !output.overall_explanation.trim().is_empty() {
|
||
sections.push(output.overall_explanation.trim().to_string());
|
||
}
|
||
if !output.findings.is_empty() {
|
||
sections.push(format_review_findings_block(&output.findings, None));
|
||
}
|
||
if !output.overall_correctness.trim().is_empty() {
|
||
sections.push(format!(
|
||
"Overall correctness: {}",
|
||
output.overall_correctness.trim()
|
||
));
|
||
}
|
||
if output.overall_confidence_score > 0.0 {
|
||
sections.push(format!(
|
||
"Confidence score: {:.1}",
|
||
output.overall_confidence_score
|
||
));
|
||
}
|
||
|
||
let results = if sections.is_empty() {
|
||
"Reviewer did not provide any findings.".to_string()
|
||
} else {
|
||
sections.join("\n\n")
|
||
};
|
||
|
||
format!(
|
||
"<user_action>\n <context>User initiated a review task. Here's the full review output from reviewer model. User may select one or more comments to resolve.</context>\n <action>review</action>\n <results>\n {}\n </results>\n</user_action>\n",
|
||
results
|
||
)
|
||
}
|
||
None => {
|
||
"<user_action>\n <context>User initiated a review task, but it ended without a final response. If the user asks about this, tell them to re-initiate a review with `/review` and wait for it to complete.</context>\n <action>review</action>\n <results>\n None.\n </results>\n</user_action>\n"
|
||
.to_string()
|
||
}
|
||
};
|
||
|
||
let developer_message = ResponseItem::Message {
|
||
id: None,
|
||
role: "user".to_string(),
|
||
content: vec![ContentItem::InputText { text: developer_text.clone() }],
|
||
};
|
||
|
||
session
|
||
.record_conversation_items(&[developer_message])
|
||
.await;
|
||
}
|
||
|
||
fn parse_review_output_event(text: &str) -> ReviewOutputEvent {
|
||
if let Ok(parsed) = serde_json::from_str::<ReviewOutputEvent>(text) {
|
||
return parsed;
|
||
}
|
||
|
||
// Attempt to extract JSON from fenced code blocks if present.
|
||
if let Some(idx) = text.find("```json") {
|
||
if let Some(end_idx) = text[idx + 7..].find("```") {
|
||
let json_slice = &text[idx + 7..idx + 7 + end_idx];
|
||
if let Ok(parsed) = serde_json::from_str::<ReviewOutputEvent>(json_slice) {
|
||
return parsed;
|
||
}
|
||
}
|
||
}
|
||
|
||
ReviewOutputEvent {
|
||
findings: Vec::new(),
|
||
overall_correctness: String::new(),
|
||
overall_explanation: text.trim().to_string(),
|
||
overall_confidence_score: 0.0,
|
||
}
|
||
}
|
||
|
||
// Intentionally omit upstream review thread spawning; our fork handles review flows differently.
|
||
/// Takes a user message as input and runs a loop where, at each turn, the model
|
||
/// replies with either:
|
||
///
|
||
/// - requested function calls
|
||
/// - an assistant message
|
||
///
|
||
/// While it is possible for the model to return multiple of these items in a
|
||
/// single turn, in practice, we generally one item per turn:
|
||
///
|
||
/// - If the model requests a function call, we execute it and send the output
|
||
/// back to the model in the next turn.
|
||
/// - If the model sends only an assistant message, we record it in the
|
||
/// conversation history and consider the agent complete.
|
||
async fn run_agent(sess: Arc<Session>, turn_context: Arc<TurnContext>, sub_id: String, input: Vec<InputItem>) {
|
||
if input.is_empty() {
|
||
return;
|
||
}
|
||
let event = sess.make_event(&sub_id, EventMsg::TaskStarted);
|
||
if sess.tx_event.send(event).await.is_err() {
|
||
return;
|
||
}
|
||
// Continue with our fork's history and input handling.
|
||
|
||
let is_review_mode = turn_context.is_review_mode;
|
||
let mut review_history: Vec<ResponseItem> = Vec::new();
|
||
let mut review_messages: Vec<String> = Vec::new();
|
||
let mut review_exit_emitted = false;
|
||
|
||
let pending_only_turn = input.len() == 1
|
||
&& matches!(
|
||
&input[0],
|
||
InputItem::Text { text } if text == PENDING_ONLY_SENTINEL
|
||
);
|
||
|
||
// Debug logging for ephemeral images
|
||
let ephemeral_count = input
|
||
.iter()
|
||
.filter(|item| matches!(item, InputItem::EphemeralImage { .. }))
|
||
.count();
|
||
|
||
if ephemeral_count > 0 {
|
||
tracing::info!(
|
||
"Processing {} ephemeral images in user input",
|
||
ephemeral_count
|
||
);
|
||
}
|
||
|
||
let mut initial_response_item: Option<ResponseItem> = None;
|
||
|
||
if !pending_only_turn {
|
||
// Convert input to ResponseInputItem
|
||
let mut response_input = response_input_from_core_items(input.clone());
|
||
sess.enforce_user_message_limits(&sub_id, &mut response_input);
|
||
let response_item: ResponseItem = response_input.into();
|
||
|
||
if is_review_mode {
|
||
review_history.push(response_item.clone());
|
||
} else {
|
||
// Record to history but we'll handle ephemeral images separately
|
||
sess.record_conversation_items(&[response_item.clone()])
|
||
.await;
|
||
}
|
||
initial_response_item = Some(response_item);
|
||
}
|
||
|
||
let mut last_task_message: Option<String> = None;
|
||
// Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Agent which contains
|
||
// many turns, from the perspective of the user, it is a single turn.
|
||
let mut turn_diff_tracker = TurnDiffTracker::new();
|
||
|
||
// Track if this is the first iteration - if so, include the initial input
|
||
let mut first_iteration = true;
|
||
|
||
loop {
|
||
// Note that pending_input would be something like a message the user
|
||
// submitted through the UI while the model was running. Though the UI
|
||
// may support this, the model might not.
|
||
// IMPORTANT: Do not inject queued user inputs into the review thread.
|
||
// Doing so routes user messages (e.g., auto-resolve fix prompts) to the
|
||
// review model, causing loops. Only include queued user inputs when not in
|
||
// review mode. They will be picked up after TaskComplete via
|
||
// pop_next_queued_user_input.
|
||
let pending_input = if is_review_mode {
|
||
sess.get_pending_input_filtered(false)
|
||
} else {
|
||
sess.get_pending_input()
|
||
}
|
||
.into_iter()
|
||
.map(ResponseItem::from)
|
||
.collect::<Vec<ResponseItem>>();
|
||
let mut pending_input_tail = pending_input.clone();
|
||
|
||
if initial_response_item.is_none() {
|
||
if let Some(first_pending) = pending_input_tail.first().cloned() {
|
||
pending_input_tail.remove(0);
|
||
if is_review_mode {
|
||
review_history.push(first_pending.clone());
|
||
} else {
|
||
sess.record_conversation_items(&[first_pending.clone()])
|
||
.await;
|
||
}
|
||
initial_response_item = Some(first_pending);
|
||
} else {
|
||
tracing::warn!(
|
||
"pending-only turn had no queued input; skipping model invocation"
|
||
);
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Do not duplicate the initial input in `pending_input`.
|
||
// It is already recorded to history above; ephemeral items are appended separately.
|
||
if first_iteration {
|
||
first_iteration = false;
|
||
} else {
|
||
// Only record pending input to history on subsequent iterations
|
||
sess.record_conversation_items(&pending_input).await;
|
||
}
|
||
|
||
// Construct the input that we will send to the model. When using the
|
||
// Chat completions API (or ZDR clients), the model needs the full
|
||
// conversation history on each turn. The rollout file, however, should
|
||
// only record the new items that originated in this turn so that it
|
||
// represents an append-only log without duplicates.
|
||
let turn_input: Vec<ResponseItem> = if is_review_mode {
|
||
if !pending_input_tail.is_empty() {
|
||
review_history.extend(pending_input_tail.clone());
|
||
}
|
||
review_history.clone()
|
||
} else {
|
||
sess.turn_input_with_history(pending_input_tail.clone())
|
||
};
|
||
|
||
let turn_input_messages: Vec<String> = turn_input
|
||
.iter()
|
||
.filter_map(|item| match item {
|
||
ResponseItem::Message { content, .. } => Some(content),
|
||
_ => None,
|
||
})
|
||
.flat_map(|content| {
|
||
content.iter().filter_map(|item| match item {
|
||
ContentItem::OutputText { text } => Some(text.clone()),
|
||
_ => None,
|
||
})
|
||
})
|
||
.collect();
|
||
match run_turn(
|
||
&sess,
|
||
&turn_context,
|
||
&mut turn_diff_tracker,
|
||
sub_id.clone(),
|
||
initial_response_item.clone(),
|
||
pending_input_tail,
|
||
turn_input,
|
||
)
|
||
.await
|
||
{
|
||
Ok(turn_output) => {
|
||
let mut items_to_record_in_conversation_history = Vec::<ResponseItem>::new();
|
||
let mut responses = Vec::<ResponseInputItem>::new();
|
||
for processed_response_item in turn_output {
|
||
let ProcessedResponseItem { item, response } = processed_response_item;
|
||
match (&item, &response) {
|
||
(ResponseItem::Message { role, .. }, None) if role == "assistant" => {
|
||
// If the model returned a message, we need to record it.
|
||
items_to_record_in_conversation_history.push(item.clone());
|
||
if is_review_mode {
|
||
if let ResponseItem::Message { content, .. } = &item {
|
||
for ci in content {
|
||
if let ContentItem::OutputText { text } = ci {
|
||
review_messages.push(text.clone());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
(
|
||
ResponseItem::LocalShellCall { .. },
|
||
Some(ResponseInputItem::FunctionCallOutput { call_id, output }),
|
||
) => {
|
||
items_to_record_in_conversation_history.push(item.clone());
|
||
items_to_record_in_conversation_history.push(
|
||
ResponseItem::FunctionCallOutput {
|
||
call_id: call_id.clone(),
|
||
output: output.clone(),
|
||
},
|
||
);
|
||
}
|
||
(
|
||
ResponseItem::FunctionCall { .. },
|
||
Some(ResponseInputItem::FunctionCallOutput { call_id, output }),
|
||
) => {
|
||
debug!(
|
||
"Recording function call and output for call_id: {}",
|
||
call_id
|
||
);
|
||
items_to_record_in_conversation_history.push(item.clone());
|
||
items_to_record_in_conversation_history.push(
|
||
ResponseItem::FunctionCallOutput {
|
||
call_id: call_id.clone(),
|
||
output: output.clone(),
|
||
},
|
||
);
|
||
}
|
||
(
|
||
ResponseItem::CustomToolCall { .. },
|
||
Some(ResponseInputItem::CustomToolCallOutput { call_id, output }),
|
||
) => {
|
||
items_to_record_in_conversation_history.push(item.clone());
|
||
items_to_record_in_conversation_history.push(
|
||
ResponseItem::CustomToolCallOutput {
|
||
call_id: call_id.clone(),
|
||
output: output.clone(),
|
||
},
|
||
);
|
||
}
|
||
(
|
||
ResponseItem::FunctionCall { .. },
|
||
Some(ResponseInputItem::McpToolCallOutput { call_id, result }),
|
||
) => {
|
||
items_to_record_in_conversation_history.push(item.clone());
|
||
let output =
|
||
convert_call_tool_result_to_function_call_output_payload(&result);
|
||
items_to_record_in_conversation_history.push(
|
||
ResponseItem::FunctionCallOutput {
|
||
call_id: call_id.clone(),
|
||
output,
|
||
},
|
||
);
|
||
}
|
||
(
|
||
ResponseItem::Reasoning {
|
||
id,
|
||
summary,
|
||
content,
|
||
encrypted_content,
|
||
},
|
||
None,
|
||
) => {
|
||
items_to_record_in_conversation_history.push(ResponseItem::Reasoning {
|
||
id: id.clone(),
|
||
summary: summary.clone(),
|
||
content: content.clone(),
|
||
encrypted_content: encrypted_content.clone(),
|
||
});
|
||
}
|
||
_ => {
|
||
warn!("Unexpected response item: {item:?} with response: {response:?}");
|
||
}
|
||
};
|
||
if let Some(response) = response {
|
||
responses.push(response);
|
||
}
|
||
}
|
||
|
||
// Only attempt to take the lock if there is something to record.
|
||
if !items_to_record_in_conversation_history.is_empty() {
|
||
if is_review_mode {
|
||
review_history.extend(items_to_record_in_conversation_history.clone());
|
||
} else {
|
||
// Record items in their original chronological order to maintain
|
||
// proper sequence of events. This ensures function calls and their
|
||
// outputs appear in the correct order in conversation history.
|
||
sess.record_conversation_items(&items_to_record_in_conversation_history)
|
||
.await;
|
||
}
|
||
}
|
||
|
||
// If there are responses, add them to pending input for the next iteration
|
||
if !responses.is_empty() {
|
||
if !is_review_mode {
|
||
for response in &responses {
|
||
sess.add_pending_input(response.clone());
|
||
}
|
||
}
|
||
}
|
||
|
||
if responses.is_empty() {
|
||
debug!("Turn completed");
|
||
last_task_message = get_last_assistant_message_from_turn(
|
||
&items_to_record_in_conversation_history,
|
||
);
|
||
if let Some(m) = last_task_message.as_ref() {
|
||
tracing::info!("core.turn completed: last_assistant_message.len={}", m.len());
|
||
}
|
||
sess.maybe_notify(UserNotification::AgentTurnComplete {
|
||
turn_id: sub_id.clone(),
|
||
input_messages: turn_input_messages,
|
||
last_assistant_message: last_task_message.clone(),
|
||
});
|
||
break;
|
||
}
|
||
}
|
||
Err(e) => {
|
||
info!("Turn error: {e:#}");
|
||
let event = sess.make_event(
|
||
&sub_id,
|
||
EventMsg::Error(ErrorEvent { message: e.to_string() }),
|
||
);
|
||
sess.tx_event.send(event).await.ok();
|
||
if is_review_mode && !review_exit_emitted {
|
||
exit_review_mode(sess.clone(), sub_id.clone(), None).await;
|
||
review_exit_emitted = true;
|
||
}
|
||
// let the user continue the conversation
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if is_review_mode && !review_exit_emitted {
|
||
let combined = if !review_messages.is_empty() {
|
||
review_messages.join("\n\n")
|
||
} else {
|
||
last_task_message.clone().unwrap_or_default()
|
||
};
|
||
let output = if combined.trim().is_empty() {
|
||
None
|
||
} else {
|
||
Some(parse_review_output_event(&combined))
|
||
};
|
||
exit_review_mode(sess.clone(), sub_id.clone(), output).await;
|
||
}
|
||
|
||
sess.remove_task(&sub_id);
|
||
let event = sess.make_event(
|
||
&sub_id,
|
||
EventMsg::TaskComplete(TaskCompleteEvent {
|
||
last_agent_message: last_task_message,
|
||
}),
|
||
);
|
||
match &event.msg {
|
||
EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message: Some(m) }) => {
|
||
tracing::info!("core.emit TaskComplete last_agent_message.len={}", m.len());
|
||
}
|
||
_ => {}
|
||
}
|
||
sess.tx_event.send(event).await.ok();
|
||
|
||
if let Some(compact_sub_id) = sess.dequeue_manual_compact() {
|
||
let turn_context = sess.make_turn_context();
|
||
compact::spawn_compact_task(
|
||
Arc::clone(&sess),
|
||
turn_context,
|
||
compact_sub_id,
|
||
vec![InputItem::Text {
|
||
text: compact::SUMMARIZATION_PROMPT.to_string(),
|
||
}],
|
||
);
|
||
return;
|
||
}
|
||
|
||
if let Some(queued) = sess.pop_next_queued_user_input() {
|
||
let sess_clone = Arc::clone(&sess);
|
||
tokio::spawn(async move {
|
||
sess_clone.cleanup_old_status_items().await;
|
||
let turn_context = sess_clone.make_turn_context();
|
||
let submission_id = queued.submission_id;
|
||
let items = queued.core_items;
|
||
let agent = AgentTask::spawn(Arc::clone(&sess_clone), turn_context, submission_id, items);
|
||
sess_clone.set_task(agent);
|
||
});
|
||
}
|
||
}
|
||
|
||
async fn run_turn(
|
||
sess: &Arc<Session>,
|
||
turn_context: &Arc<TurnContext>,
|
||
turn_diff_tracker: &mut TurnDiffTracker,
|
||
sub_id: String,
|
||
initial_user_item: Option<ResponseItem>,
|
||
pending_input_tail: Vec<ResponseItem>,
|
||
mut input: Vec<ResponseItem>,
|
||
) -> CodexResult<Vec<ProcessedResponseItem>> {
|
||
// Check if browser is enabled
|
||
let browser_enabled = code_browser::global::get_browser_manager().await.is_some();
|
||
|
||
let tc = &**turn_context;
|
||
let agents_active = {
|
||
let manager = AGENT_MANAGER.read().await;
|
||
manager.has_active_agents()
|
||
};
|
||
|
||
let tools = get_openai_tools(
|
||
&sess.tools_config,
|
||
Some(sess.mcp_connection_manager.list_all_tools()),
|
||
browser_enabled,
|
||
agents_active,
|
||
);
|
||
|
||
let mut retries = 0;
|
||
// Ensure we only auto-compact once per turn to avoid loops
|
||
let mut did_auto_compact = false;
|
||
// Attempt input starts as the provided input, and may be augmented with
|
||
// items from a previous dropped stream attempt so we don't lose progress.
|
||
let mut attempt_input: Vec<ResponseItem> = input.clone();
|
||
loop {
|
||
// Each loop iteration corresponds to a single provider HTTP request.
|
||
// Increment the attempt ordinal first and capture its value so all
|
||
// OrderMeta emitted during this attempt share the same `req`, even if
|
||
// later attempts start before all events have been delivered.
|
||
sess.begin_http_attempt();
|
||
let attempt_req = sess.current_request_ordinal();
|
||
// Build status items (screenshots, system status) fresh for each attempt
|
||
let status_items = build_turn_status_items(sess).await;
|
||
|
||
let prompt = Prompt {
|
||
input: attempt_input.clone(),
|
||
store: !sess.disable_response_storage,
|
||
user_instructions: tc.user_instructions.clone(),
|
||
environment_context: Some(EnvironmentContext::new(
|
||
Some(tc.cwd.clone()),
|
||
Some(tc.approval_policy),
|
||
Some(tc.sandbox_policy.clone()),
|
||
Some(sess.user_shell.clone()),
|
||
)),
|
||
tools: tools.clone(),
|
||
status_items, // Include status items with this request
|
||
base_instructions_override: tc.base_instructions.clone(),
|
||
include_additional_instructions: true,
|
||
text_format: tc.text_format_override.clone(),
|
||
model_override: None,
|
||
model_family_override: None,
|
||
output_schema: None,
|
||
log_tag: Some("codex/turn".to_string()),
|
||
session_id_override: None,
|
||
};
|
||
|
||
// Start a new scratchpad for this HTTP attempt
|
||
sess.begin_attempt_scratchpad();
|
||
|
||
match try_run_turn(sess, turn_diff_tracker, &sub_id, &prompt, attempt_req).await {
|
||
Ok(output) => {
|
||
// Record status items to conversation history after successful turn
|
||
// This ensures they persist for future requests in the right chronological order
|
||
if !prompt.status_items.is_empty() {
|
||
sess.record_conversation_items(&prompt.status_items).await;
|
||
}
|
||
// Commit successful attempt – scratchpad is no longer needed.
|
||
sess.clear_scratchpad();
|
||
return Ok(output);
|
||
}
|
||
Err(CodexErr::Interrupted) => return Err(CodexErr::Interrupted),
|
||
Err(CodexErr::EnvVar(var)) => return Err(CodexErr::EnvVar(var)),
|
||
Err(e @ (CodexErr::UsageLimitReached(_) | CodexErr::UsageNotIncluded)) => {
|
||
if let CodexErr::UsageLimitReached(limit_err) = &e {
|
||
if let Some(ctx) = account_usage_context(&sess) {
|
||
let usage_home = ctx.code_home.clone();
|
||
let usage_account = ctx.account_id.clone();
|
||
let usage_plan = ctx.plan.clone();
|
||
let resets = limit_err.resets_in_seconds;
|
||
spawn_usage_task(move || {
|
||
if let Err(err) = account_usage::record_usage_limit_hint(
|
||
&usage_home,
|
||
&usage_account,
|
||
usage_plan.as_deref(),
|
||
resets,
|
||
Utc::now(),
|
||
) {
|
||
warn!("Failed to persist usage limit hint: {err}");
|
||
}
|
||
});
|
||
}
|
||
}
|
||
return Err(e);
|
||
}
|
||
Err(e) => {
|
||
// Detect context-window overflow and auto-run a compact summarization once
|
||
if !did_auto_compact {
|
||
if let CodexErr::Stream(msg, _maybe_delay) = &e {
|
||
let lower = msg.to_ascii_lowercase();
|
||
let looks_like_context_overflow =
|
||
lower.contains("exceeds the context window")
|
||
|| lower.contains("exceed the context window")
|
||
|| lower.contains("context length exceeded")
|
||
|| lower.contains("maximum context length")
|
||
|| (lower.contains("context window")
|
||
&& (lower.contains("exceed")
|
||
|| lower.contains("exceeded")
|
||
|| lower.contains("full")
|
||
|| lower.contains("too long")));
|
||
|
||
if looks_like_context_overflow {
|
||
did_auto_compact = true;
|
||
sess
|
||
.notify_stream_error(
|
||
&sub_id,
|
||
"Model hit context-window limit; running /compact and retrying…"
|
||
.to_string(),
|
||
)
|
||
.await;
|
||
|
||
let previous_input_snapshot = input.clone();
|
||
let compacted_history = compact::run_inline_auto_compact_task(
|
||
Arc::clone(&sess),
|
||
Arc::clone(&turn_context),
|
||
)
|
||
.await;
|
||
|
||
// Reset any partial attempt state and rebuild the request payload using the
|
||
// newly compacted history plus the current user turn items.
|
||
sess.clear_scratchpad();
|
||
|
||
if compacted_history.is_empty() {
|
||
attempt_input = input.clone();
|
||
} else {
|
||
let mut rebuilt = compacted_history;
|
||
if let Some(initial_item) = initial_user_item.clone() {
|
||
rebuilt.push(initial_item);
|
||
}
|
||
if !pending_input_tail.is_empty() {
|
||
let (missing_calls, filtered_outputs) =
|
||
reconcile_pending_tool_outputs(&pending_input_tail, &rebuilt, &previous_input_snapshot);
|
||
if !missing_calls.is_empty() {
|
||
rebuilt.extend(missing_calls);
|
||
}
|
||
if !filtered_outputs.is_empty() {
|
||
rebuilt.extend(filtered_outputs);
|
||
}
|
||
}
|
||
input = rebuilt.clone();
|
||
attempt_input = rebuilt;
|
||
}
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Use the configured provider-specific stream retry budget.
|
||
let max_retries = tc.client.get_provider().stream_max_retries();
|
||
if retries < max_retries {
|
||
retries += 1;
|
||
let delay = match e {
|
||
CodexErr::Stream(_, Some(delay)) => delay,
|
||
_ => backoff(retries),
|
||
};
|
||
warn!(
|
||
"stream disconnected - retrying turn ({retries}/{max_retries} in {delay:?})...",
|
||
);
|
||
|
||
// Surface retry information to any UI/front‑end so the
|
||
// user understands what is happening instead of staring
|
||
// at a seemingly frozen screen.
|
||
sess.notify_stream_error(
|
||
&sub_id,
|
||
format!(
|
||
"stream error: {e}; retrying {retries}/{max_retries} in {delay:?}…"
|
||
),
|
||
)
|
||
.await;
|
||
// Pull any partial progress from this attempt and append to
|
||
// the next request's input so we do not lose tool progress
|
||
// or already-finalized items.
|
||
if let Some(sp) = sess.take_scratchpad() {
|
||
// Build a set of call_ids we have already included to avoid duplicate calls
|
||
let mut seen_calls: std::collections::HashSet<String> = attempt_input
|
||
.iter()
|
||
.filter_map(|ri| match ri {
|
||
ResponseItem::FunctionCall { call_id, .. } => Some(call_id.clone()),
|
||
ResponseItem::LocalShellCall { call_id: Some(c), .. } => Some(c.clone()),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
|
||
// Append finalized function/local shell calls from the dropped attempt
|
||
for item in sp.items {
|
||
match &item {
|
||
ResponseItem::FunctionCall { call_id, .. } => {
|
||
if seen_calls.insert(call_id.clone()) {
|
||
attempt_input.push(item.clone());
|
||
}
|
||
}
|
||
ResponseItem::LocalShellCall { call_id: Some(c), .. } => {
|
||
if seen_calls.insert(c.clone()) {
|
||
attempt_input.push(item.clone());
|
||
}
|
||
}
|
||
_ => {
|
||
// Avoid injecting assistant/Reasoning messages on retry to reduce duplication.
|
||
}
|
||
}
|
||
}
|
||
|
||
// Append tool outputs produced during the dropped attempt
|
||
for resp in sp.responses {
|
||
attempt_input.push(ResponseItem::from(resp));
|
||
}
|
||
|
||
// If we have partial deltas, include a short ephemeral hint so the model can resume.
|
||
if !sp.partial_assistant_text.is_empty() || !sp.partial_reasoning_summary.is_empty() {
|
||
use code_protocol::models::ContentItem;
|
||
let mut hint = String::from(
|
||
"[EPHEMERAL:RETRY_HINT]\nPrevious attempt aborted mid-stream. Continue without repeating.\n",
|
||
);
|
||
if !sp.partial_reasoning_summary.is_empty() {
|
||
let s = &sp.partial_reasoning_summary;
|
||
// Take the last 800 characters, respecting UTF-8 boundaries
|
||
let start_idx = if s.chars().count() > 800 {
|
||
s.char_indices()
|
||
.rev()
|
||
.nth(800 - 1)
|
||
.map(|(i, _)| i)
|
||
.unwrap_or(0)
|
||
} else {
|
||
0
|
||
};
|
||
let tail = &s[start_idx..];
|
||
hint.push_str(&format!("Last reasoning summary fragment:\n{}\n\n", tail));
|
||
}
|
||
if !sp.partial_assistant_text.is_empty() {
|
||
let s = &sp.partial_assistant_text;
|
||
// Take the last 800 characters, respecting UTF-8 boundaries
|
||
let start_idx = if s.chars().count() > 800 {
|
||
s.char_indices()
|
||
.rev()
|
||
.nth(800 - 1)
|
||
.map(|(i, _)| i)
|
||
.unwrap_or(0)
|
||
} else {
|
||
0
|
||
};
|
||
let tail = &s[start_idx..];
|
||
hint.push_str(&format!("Last assistant text fragment:\n{}\n", tail));
|
||
}
|
||
attempt_input.push(ResponseItem::Message {
|
||
id: None,
|
||
role: "user".to_string(),
|
||
content: vec![ContentItem::InputText { text: hint }],
|
||
});
|
||
}
|
||
}
|
||
|
||
tokio::time::sleep(delay).await;
|
||
} else {
|
||
return Err(e);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn reconcile_pending_tool_outputs(
|
||
pending_outputs: &[ResponseItem],
|
||
rebuilt_history: &[ResponseItem],
|
||
previous_input_snapshot: &[ResponseItem],
|
||
) -> (Vec<ResponseItem>, Vec<ResponseItem>) {
|
||
let mut call_ids = collect_tool_call_ids(rebuilt_history);
|
||
let mut missing_calls = Vec::new();
|
||
let mut filtered_outputs = Vec::new();
|
||
|
||
for item in pending_outputs {
|
||
match item {
|
||
ResponseItem::FunctionCallOutput { call_id, .. }
|
||
| ResponseItem::CustomToolCallOutput { call_id, .. } => {
|
||
if call_ids.contains(call_id) {
|
||
filtered_outputs.push(item.clone());
|
||
continue;
|
||
}
|
||
|
||
if let Some(call_item) = find_call_item_by_id(previous_input_snapshot, call_id) {
|
||
call_ids.insert(call_id.clone());
|
||
missing_calls.push(call_item);
|
||
filtered_outputs.push(item.clone());
|
||
} else {
|
||
warn!("Skipping tool output for missing call_id={call_id} after auto-compact");
|
||
}
|
||
}
|
||
_ => {
|
||
filtered_outputs.push(item.clone());
|
||
}
|
||
}
|
||
}
|
||
|
||
(missing_calls, filtered_outputs)
|
||
}
|
||
|
||
fn collect_tool_call_ids(items: &[ResponseItem]) -> HashSet<String> {
|
||
let mut ids = HashSet::new();
|
||
for item in items {
|
||
match item {
|
||
ResponseItem::FunctionCall { call_id, .. } => {
|
||
ids.insert(call_id.clone());
|
||
}
|
||
ResponseItem::LocalShellCall { call_id: Some(call_id), .. } => {
|
||
ids.insert(call_id.clone());
|
||
}
|
||
ResponseItem::CustomToolCall { call_id, .. } => {
|
||
ids.insert(call_id.clone());
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
ids
|
||
}
|
||
|
||
fn find_call_item_by_id(items: &[ResponseItem], call_id: &str) -> Option<ResponseItem> {
|
||
items.iter().rev().find_map(|item| match item {
|
||
ResponseItem::FunctionCall { call_id: existing, .. } if existing == call_id => Some(item.clone()),
|
||
ResponseItem::LocalShellCall { call_id: Some(existing), .. } if existing == call_id => Some(item.clone()),
|
||
ResponseItem::CustomToolCall { call_id: existing, .. } if existing == call_id => Some(item.clone()),
|
||
_ => None,
|
||
})
|
||
}
|
||
|
||
/// When the model is prompted, it returns a stream of events. Some of these
|
||
/// events map to a `ResponseItem`. A `ResponseItem` may need to be
|
||
/// "handled" such that it produces a `ResponseInputItem` that needs to be
|
||
/// sent back to the model on the next turn.
|
||
#[derive(Debug)]
|
||
struct ProcessedResponseItem {
|
||
item: ResponseItem,
|
||
response: Option<ResponseInputItem>,
|
||
}
|
||
|
||
async fn try_run_turn(
|
||
sess: &Session,
|
||
turn_diff_tracker: &mut TurnDiffTracker,
|
||
sub_id: &str,
|
||
prompt: &Prompt,
|
||
attempt_req: u64,
|
||
) -> CodexResult<Vec<ProcessedResponseItem>> {
|
||
// call_ids that are part of this response.
|
||
let completed_call_ids = prompt
|
||
.input
|
||
.iter()
|
||
.filter_map(|ri| match ri {
|
||
ResponseItem::FunctionCallOutput { call_id, .. } => Some(call_id),
|
||
ResponseItem::LocalShellCall {
|
||
call_id: Some(call_id),
|
||
..
|
||
} => Some(call_id),
|
||
ResponseItem::CustomToolCallOutput { call_id, .. } => Some(call_id),
|
||
_ => None,
|
||
})
|
||
.collect::<Vec<_>>();
|
||
|
||
// call_ids that were pending but are not part of this response.
|
||
// This usually happens because the user interrupted the model before we responded to one of its tool calls
|
||
// and then the user sent a follow-up message.
|
||
let missing_calls = {
|
||
prompt
|
||
.input
|
||
.iter()
|
||
.filter_map(|ri| match ri {
|
||
ResponseItem::FunctionCall { call_id, .. } => Some(call_id),
|
||
ResponseItem::LocalShellCall {
|
||
call_id: Some(call_id),
|
||
..
|
||
} => Some(call_id),
|
||
ResponseItem::CustomToolCall { call_id, .. } => Some(call_id),
|
||
_ => None,
|
||
})
|
||
.filter_map(|call_id| {
|
||
if completed_call_ids.contains(&call_id) {
|
||
None
|
||
} else {
|
||
Some(call_id.clone())
|
||
}
|
||
})
|
||
.map(|call_id| ResponseItem::CustomToolCallOutput {
|
||
call_id: call_id.clone(),
|
||
output: "aborted".to_string(),
|
||
})
|
||
.collect::<Vec<_>>()
|
||
};
|
||
let prompt: Cow<Prompt> = if missing_calls.is_empty() {
|
||
Cow::Borrowed(prompt)
|
||
} else {
|
||
// Add the synthetic aborted missing calls to the beginning of the input to ensure all call ids have responses.
|
||
let input = [missing_calls, prompt.input.clone()].concat();
|
||
Cow::Owned(Prompt {
|
||
input,
|
||
..prompt.clone()
|
||
})
|
||
};
|
||
|
||
let mut stream = sess.client.clone().stream(&prompt).await?;
|
||
|
||
let mut output = Vec::new();
|
||
loop {
|
||
// Poll the next item from the model stream. We must inspect *both* Ok and Err
|
||
// cases so that transient stream failures (e.g., dropped SSE connection before
|
||
// `response.completed`) bubble up and trigger the caller's retry logic.
|
||
let event = stream.next().await;
|
||
let Some(event) = event else {
|
||
// Channel closed without yielding a final Completed event or explicit error.
|
||
// Treat as a disconnected stream so the caller can retry.
|
||
return Err(CodexErr::Stream(
|
||
"stream closed before response.completed".into(),
|
||
None,
|
||
));
|
||
};
|
||
|
||
let event = match event {
|
||
Ok(ev) => ev,
|
||
Err(e) => {
|
||
// Propagate the underlying stream error to the caller (run_turn), which
|
||
// will apply the configured `stream_max_retries` policy.
|
||
return Err(e);
|
||
}
|
||
};
|
||
|
||
match event {
|
||
ResponseEvent::Created => {}
|
||
ResponseEvent::OutputItemDone { item, sequence_number, output_index } => {
|
||
let response =
|
||
handle_response_item(sess, turn_diff_tracker, sub_id, item.clone(), sequence_number, output_index, attempt_req).await?;
|
||
|
||
// Save into scratchpad so we can seed a retry if the stream drops later.
|
||
sess.scratchpad_push(&item, &response, &sub_id);
|
||
|
||
// If this was a finalized assistant message, clear partial text buffer
|
||
if let ResponseItem::Message { .. } = &item {
|
||
sess.scratchpad_clear_partial_message();
|
||
}
|
||
|
||
output.push(ProcessedResponseItem { item, response });
|
||
}
|
||
ResponseEvent::WebSearchCallBegin { call_id } => {
|
||
// Stamp OrderMeta so the TUI can place the search block within
|
||
// the correct request window instead of using an internal epilogue.
|
||
let ctx = ToolCallCtx::new(sub_id.to_string(), call_id.clone(), None, None);
|
||
let order = ctx.order_meta(attempt_req);
|
||
let ev = sess.make_event_with_order(
|
||
&sub_id,
|
||
EventMsg::WebSearchBegin(WebSearchBeginEvent { call_id, query: None }),
|
||
order,
|
||
None,
|
||
);
|
||
sess.send_event(ev).await;
|
||
}
|
||
ResponseEvent::WebSearchCallCompleted { call_id, query } => {
|
||
let ctx = ToolCallCtx::new(sub_id.to_string(), call_id.clone(), None, None);
|
||
let order = ctx.order_meta(attempt_req);
|
||
let ev = sess.make_event_with_order(
|
||
&sub_id,
|
||
EventMsg::WebSearchComplete(WebSearchCompleteEvent { call_id, query }),
|
||
order,
|
||
None,
|
||
);
|
||
sess.send_event(ev).await;
|
||
}
|
||
ResponseEvent::Completed {
|
||
response_id: _,
|
||
token_usage,
|
||
} => {
|
||
let (new_info, rate_limits, should_emit);
|
||
{
|
||
let mut state = sess.state.lock().unwrap();
|
||
let info = TokenUsageInfo::new_or_append(
|
||
&state.token_usage_info,
|
||
&token_usage,
|
||
sess.client.get_model_context_window(),
|
||
);
|
||
let limits = state.latest_rate_limits.clone();
|
||
let emit = info.is_some() || limits.is_some();
|
||
state.token_usage_info = info.clone();
|
||
new_info = info;
|
||
rate_limits = limits;
|
||
should_emit = emit;
|
||
}
|
||
|
||
if should_emit {
|
||
let payload = TokenCountEvent {
|
||
info: new_info,
|
||
rate_limits,
|
||
};
|
||
sess.tx_event
|
||
.send(sess.make_event(&sub_id, EventMsg::TokenCount(payload)))
|
||
.await
|
||
.ok();
|
||
}
|
||
|
||
if let Some(usage) = token_usage.as_ref() {
|
||
if let Some(ctx) = account_usage_context(sess) {
|
||
let usage_home = ctx.code_home.clone();
|
||
let usage_account = ctx.account_id.clone();
|
||
let usage_plan = ctx.plan.clone();
|
||
let usage_clone = usage.clone();
|
||
spawn_usage_task(move || {
|
||
if let Err(err) = account_usage::record_token_usage(
|
||
&usage_home,
|
||
&usage_account,
|
||
usage_plan.as_deref(),
|
||
&usage_clone,
|
||
Utc::now(),
|
||
) {
|
||
warn!("Failed to persist token usage: {err}");
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
let unified_diff = turn_diff_tracker.get_unified_diff();
|
||
if let Ok(Some(unified_diff)) = unified_diff {
|
||
let msg = EventMsg::TurnDiff(TurnDiffEvent { unified_diff });
|
||
let _ = sess.tx_event.send(sess.make_event(&sub_id, msg)).await;
|
||
}
|
||
|
||
return Ok(output);
|
||
}
|
||
ResponseEvent::OutputTextDelta { delta, item_id, sequence_number, output_index } => {
|
||
// Don't append to history during streaming - only send UI events.
|
||
// The complete message will be added to history when OutputItemDone arrives.
|
||
// This ensures items are recorded in the correct chronological order.
|
||
|
||
// Use the item_id if present, otherwise fall back to sub_id
|
||
let event_id = item_id.unwrap_or_else(|| sub_id.to_string());
|
||
let order = crate::protocol::OrderMeta {
|
||
request_ordinal: attempt_req,
|
||
output_index,
|
||
sequence_number,
|
||
};
|
||
let stamped = sess.make_event_with_order(&event_id, EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta: delta.clone() }), order, sequence_number);
|
||
sess.tx_event.send(stamped).await.ok();
|
||
|
||
// Track partial assistant text in the scratchpad to help resume on retry.
|
||
// Only accumulate when we have an item context or a single active stream.
|
||
// We deliberately do not scope by item_id to keep implementation simple.
|
||
sess.scratchpad_add_text_delta(&delta);
|
||
}
|
||
ResponseEvent::ReasoningSummaryDelta { delta, item_id, sequence_number, output_index, summary_index } => {
|
||
// Use the item_id if present, otherwise fall back to sub_id
|
||
let mut event_id = item_id.unwrap_or_else(|| sub_id.to_string());
|
||
if let Some(si) = summary_index { event_id = format!("{}#s{}", event_id, si); }
|
||
let order = crate::protocol::OrderMeta { request_ordinal: attempt_req, output_index, sequence_number };
|
||
let stamped = sess.make_event_with_order(&event_id, EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta: delta.clone() }), order, sequence_number);
|
||
sess.tx_event.send(stamped).await.ok();
|
||
|
||
// Buffer reasoning summary so we can include a hint on retry.
|
||
sess.scratchpad_add_reasoning_delta(&delta);
|
||
}
|
||
ResponseEvent::ReasoningSummaryPartAdded => {
|
||
let stamped = sess.make_event(&sub_id, EventMsg::AgentReasoningSectionBreak(AgentReasoningSectionBreakEvent {}));
|
||
sess.tx_event.send(stamped).await.ok();
|
||
}
|
||
ResponseEvent::ReasoningContentDelta { delta, item_id, sequence_number, output_index, content_index } => {
|
||
if sess.show_raw_agent_reasoning {
|
||
// Use the item_id if present, otherwise fall back to sub_id
|
||
let mut event_id = item_id.unwrap_or_else(|| sub_id.to_string());
|
||
if let Some(ci) = content_index { event_id = format!("{}#c{}", event_id, ci); }
|
||
let order = crate::protocol::OrderMeta { request_ordinal: attempt_req, output_index, sequence_number };
|
||
let stamped = sess.make_event_with_order(&event_id, EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent { delta }), order, sequence_number);
|
||
sess.tx_event.send(stamped).await.ok();
|
||
}
|
||
}
|
||
ResponseEvent::RateLimits(snapshot) => {
|
||
let mut state = sess.state.lock().unwrap();
|
||
state.latest_rate_limits = Some(snapshot.clone());
|
||
if let Some(ctx) = account_usage_context(sess) {
|
||
let usage_home = ctx.code_home.clone();
|
||
let usage_account = ctx.account_id.clone();
|
||
let usage_plan = ctx.plan.clone();
|
||
let snapshot_clone = snapshot.clone();
|
||
spawn_usage_task(move || {
|
||
if let Err(err) = account_usage::record_rate_limit_snapshot(
|
||
&usage_home,
|
||
&usage_account,
|
||
usage_plan.as_deref(),
|
||
&snapshot_clone,
|
||
Utc::now(),
|
||
) {
|
||
warn!("Failed to persist rate limit snapshot: {err}");
|
||
}
|
||
});
|
||
}
|
||
}
|
||
// Note: ReasoningSummaryPartAdded handled above without scratchpad mutation.
|
||
}
|
||
}
|
||
}
|
||
|
||
async fn handle_response_item(
|
||
sess: &Session,
|
||
turn_diff_tracker: &mut TurnDiffTracker,
|
||
sub_id: &str,
|
||
item: ResponseItem,
|
||
seq_hint: Option<u64>,
|
||
output_index: Option<u32>,
|
||
attempt_req: u64,
|
||
) -> CodexResult<Option<ResponseInputItem>> {
|
||
debug!(?item, "Output item");
|
||
let output = match item {
|
||
ResponseItem::Message { content, id, .. } => {
|
||
// Use the item_id if present, otherwise fall back to sub_id
|
||
let event_id = id.unwrap_or_else(|| sub_id.to_string());
|
||
for item in content {
|
||
if let ContentItem::OutputText { text } = item {
|
||
let order = crate::protocol::OrderMeta { request_ordinal: attempt_req, output_index, sequence_number: seq_hint };
|
||
let stamped = sess.make_event_with_order(&event_id, EventMsg::AgentMessage(AgentMessageEvent { message: text }), order, seq_hint);
|
||
sess.tx_event.send(stamped).await.ok();
|
||
}
|
||
}
|
||
None
|
||
}
|
||
ResponseItem::Reasoning {
|
||
id,
|
||
summary,
|
||
content,
|
||
encrypted_content: _,
|
||
} => {
|
||
// Use the item_id if present and not empty, otherwise fall back to sub_id
|
||
let event_id = if !id.is_empty() {
|
||
id.clone()
|
||
} else {
|
||
sub_id.to_string()
|
||
};
|
||
for (i, item) in summary.into_iter().enumerate() {
|
||
let text = match item {
|
||
ReasoningItemReasoningSummary::SummaryText { text } => text,
|
||
};
|
||
let eid = format!("{}#s{}", event_id, i);
|
||
let order = crate::protocol::OrderMeta { request_ordinal: attempt_req, output_index, sequence_number: seq_hint };
|
||
let stamped = sess.make_event_with_order(&eid, EventMsg::AgentReasoning(AgentReasoningEvent { text }), order, seq_hint);
|
||
sess.tx_event.send(stamped).await.ok();
|
||
}
|
||
if sess.show_raw_agent_reasoning && content.is_some() {
|
||
let content = content.unwrap();
|
||
for item in content.into_iter() {
|
||
let text = match item {
|
||
ReasoningItemContent::ReasoningText { text } => text,
|
||
ReasoningItemContent::Text { text } => text,
|
||
};
|
||
let order = crate::protocol::OrderMeta { request_ordinal: attempt_req, output_index, sequence_number: seq_hint };
|
||
let stamped = sess.make_event_with_order(&event_id, EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }), order, seq_hint);
|
||
sess.tx_event.send(stamped).await.ok();
|
||
}
|
||
}
|
||
None
|
||
}
|
||
ResponseItem::FunctionCall {
|
||
name,
|
||
arguments,
|
||
call_id,
|
||
..
|
||
} => {
|
||
info!("FunctionCall: {name}({arguments})");
|
||
Some(
|
||
handle_function_call(
|
||
sess,
|
||
turn_diff_tracker,
|
||
sub_id.to_string(),
|
||
name,
|
||
arguments,
|
||
call_id,
|
||
seq_hint,
|
||
output_index,
|
||
attempt_req,
|
||
)
|
||
.await,
|
||
)
|
||
}
|
||
ResponseItem::LocalShellCall {
|
||
id,
|
||
call_id,
|
||
status: _,
|
||
action,
|
||
} => {
|
||
let LocalShellAction::Exec(action) = action;
|
||
tracing::info!("LocalShellCall: {action:?}");
|
||
let params = ShellToolCallParams {
|
||
command: action.command,
|
||
workdir: action.working_directory,
|
||
timeout_ms: action.timeout_ms,
|
||
with_escalated_permissions: None,
|
||
justification: None,
|
||
};
|
||
let effective_call_id = match (call_id, id) {
|
||
(Some(call_id), _) => call_id,
|
||
(None, Some(id)) => id,
|
||
(None, None) => {
|
||
error!("LocalShellCall without call_id or id");
|
||
return Ok(Some(ResponseInputItem::FunctionCallOutput {
|
||
call_id: "".to_string(),
|
||
output: FunctionCallOutputPayload {
|
||
content: "LocalShellCall without call_id or id".to_string(),
|
||
success: None,
|
||
},
|
||
}));
|
||
}
|
||
};
|
||
|
||
let exec_params = to_exec_params(params, sess);
|
||
Some(
|
||
handle_container_exec_with_params(
|
||
exec_params,
|
||
sess,
|
||
turn_diff_tracker,
|
||
sub_id.to_string(),
|
||
effective_call_id,
|
||
seq_hint,
|
||
output_index,
|
||
attempt_req,
|
||
)
|
||
.await,
|
||
)
|
||
}
|
||
ResponseItem::CustomToolCall { call_id, name, .. } => {
|
||
// Minimal placeholder: custom tools are not handled here.
|
||
Some(ResponseInputItem::FunctionCallOutput {
|
||
call_id,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Custom tool '{name}' is not supported in this build"),
|
||
success: Some(false),
|
||
},
|
||
})
|
||
}
|
||
ResponseItem::FunctionCallOutput { .. } => {
|
||
debug!("unexpected FunctionCallOutput from stream");
|
||
None
|
||
}
|
||
ResponseItem::CustomToolCallOutput { .. } => {
|
||
debug!("unexpected CustomToolCallOutput from stream");
|
||
None
|
||
}
|
||
ResponseItem::WebSearchCall { id, action, .. } => {
|
||
if let WebSearchAction::Search { query } = action {
|
||
let call_id = id.unwrap_or_else(|| "".to_string());
|
||
let event = sess.make_event_with_hint(&sub_id, EventMsg::WebSearchComplete(WebSearchCompleteEvent { call_id, query: Some(query) }), seq_hint);
|
||
sess.tx_event.send(event).await.ok();
|
||
}
|
||
None
|
||
}
|
||
ResponseItem::Other => None,
|
||
};
|
||
Ok(output)
|
||
}
|
||
|
||
// Helper utilities for agent output/progress management
|
||
fn ensure_agent_dir(cwd: &Path, agent_id: &str) -> Result<PathBuf, String> {
|
||
let dir = cwd.join(".code").join("agents").join(agent_id);
|
||
std::fs::create_dir_all(&dir)
|
||
.map_err(|e| format!("Failed to create agent dir {}: {}", dir.display(), e))?;
|
||
Ok(dir)
|
||
}
|
||
|
||
fn ensure_user_dir(cwd: &Path) -> Result<PathBuf, String> {
|
||
let dir = cwd.join(".code").join("users");
|
||
std::fs::create_dir_all(&dir)
|
||
.map_err(|e| format!("Failed to create user dir {}: {}", dir.display(), e))?;
|
||
Ok(dir)
|
||
}
|
||
|
||
fn write_agent_file(dir: &Path, filename: &str, content: &str) -> Result<PathBuf, String> {
|
||
let path = dir.join(filename);
|
||
std::fs::write(&path, content)
|
||
.map_err(|e| format!("Failed to write {}: {}", path.display(), e))?;
|
||
Ok(path)
|
||
}
|
||
|
||
const AGENT_PREVIEW_MAX_BYTES: usize = 32 * 1024; // 32 KiB
|
||
|
||
fn preview_first_n_lines(s: &str, n: usize) -> (String, usize) {
|
||
let total_lines = s.lines().count();
|
||
let mut preview = s.lines().take(n).collect::<Vec<_>>().join("\n");
|
||
|
||
let (maybe_truncated, was_truncated, _, _) =
|
||
truncate_middle_bytes(&preview, AGENT_PREVIEW_MAX_BYTES);
|
||
if was_truncated {
|
||
preview = maybe_truncated;
|
||
preview.push_str(&format!(
|
||
"\n…preview truncated to roughly {AGENT_PREVIEW_MAX_BYTES} bytes…"
|
||
));
|
||
} else {
|
||
preview = maybe_truncated;
|
||
}
|
||
|
||
if total_lines > n {
|
||
if !preview.ends_with('\n') {
|
||
preview.push('\n');
|
||
}
|
||
preview.push_str("…additional lines omitted…");
|
||
}
|
||
|
||
(preview, total_lines)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod preview_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn truncates_excessively_long_single_line() {
|
||
let input = "x".repeat(AGENT_PREVIEW_MAX_BYTES + 1024);
|
||
let (preview, total_lines) = preview_first_n_lines(&input, 500);
|
||
assert_eq!(total_lines, 1);
|
||
assert!(preview.contains("…truncated…"));
|
||
assert!(preview.contains("preview truncated to roughly"));
|
||
}
|
||
|
||
#[test]
|
||
fn notes_when_additional_lines_omitted() {
|
||
let input = (0..600)
|
||
.map(|i| format!("line {i}"))
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
let (preview, total_lines) = preview_first_n_lines(&input, 500);
|
||
assert_eq!(total_lines, 600);
|
||
assert!(preview.contains("…additional lines omitted…"));
|
||
assert!(!preview.contains("preview truncated to roughly"));
|
||
}
|
||
}
|
||
|
||
async fn handle_function_call(
|
||
sess: &Session,
|
||
turn_diff_tracker: &mut TurnDiffTracker,
|
||
sub_id: String,
|
||
name: String,
|
||
arguments: String,
|
||
call_id: String,
|
||
seq_hint: Option<u64>,
|
||
output_index: Option<u32>,
|
||
attempt_req: u64,
|
||
) -> ResponseInputItem {
|
||
let ctx = ToolCallCtx::new(sub_id.clone(), call_id.clone(), seq_hint, output_index);
|
||
match name.as_str() {
|
||
"container.exec" | "shell" => {
|
||
let params = match parse_container_exec_arguments(arguments, sess, &call_id) {
|
||
Ok(params) => params,
|
||
Err(output) => {
|
||
return *output;
|
||
}
|
||
};
|
||
handle_container_exec_with_params(params, sess, turn_diff_tracker, sub_id, call_id, seq_hint, output_index, attempt_req)
|
||
.await
|
||
}
|
||
"update_plan" => handle_update_plan(sess, &ctx, arguments).await,
|
||
// agent tool
|
||
"agent" => handle_agent_tool(sess, &ctx, arguments).await,
|
||
// unified browser tool
|
||
"browser" => handle_browser_tool(sess, &ctx, arguments).await,
|
||
"web_fetch" => handle_web_fetch(sess, &ctx, arguments).await,
|
||
"wait" => handle_wait(sess, &ctx, arguments).await,
|
||
"kill" => handle_kill(sess, &ctx, arguments).await,
|
||
_ => {
|
||
match sess.mcp_connection_manager.parse_tool_name(&name) {
|
||
Some((server, tool_name)) => {
|
||
// Tool timeouts are derived from per-server config; no per-call override here.
|
||
handle_mcp_tool_call(sess, &ctx, server, tool_name, arguments).await
|
||
}
|
||
None => {
|
||
// Unknown function: reply with structured failure so the model can adapt.
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("unsupported call: {name}"),
|
||
success: None,
|
||
},
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
async fn handle_browser_cleanup(sess: &Session, ctx: &ToolCallCtx) -> ResponseInputItem {
|
||
let call_id_clone = ctx.call_id.clone();
|
||
let _sess_clone = sess;
|
||
execute_custom_tool(
|
||
sess,
|
||
ctx,
|
||
"browser_cleanup".to_string(),
|
||
Some(serde_json::json!({})),
|
||
|| async move {
|
||
if let Some(browser_manager) = get_browser_manager_for_session(_sess_clone).await {
|
||
match browser_manager.cleanup().await {
|
||
Ok(_) => ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload { content: "Browser cleanup completed".to_string(), success: Some(true) },
|
||
},
|
||
Err(e) => ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload { content: format!("Cleanup failed: {}", e), success: Some(false) },
|
||
},
|
||
}
|
||
} else {
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload { content: "Browser is not initialized. Use browser_open to start the browser.".to_string(), success: Some(false) },
|
||
}
|
||
}
|
||
}
|
||
).await
|
||
}
|
||
|
||
async fn handle_web_fetch(sess: &Session, ctx: &ToolCallCtx, arguments: String) -> ResponseInputItem {
|
||
// Include raw params in begin event for observability
|
||
let mut params_for_event = serde_json::from_str::<serde_json::Value>(&arguments).ok();
|
||
// If call_id is provided, include a friendly "for" string with the command we are waiting on
|
||
if let Some(serde_json::Value::Object(map)) = params_for_event.as_mut() {
|
||
if let Some(serde_json::Value::String(cid)) = map.get("call_id") {
|
||
let st = sess.state.lock().unwrap();
|
||
if let Some(bg) = st.background_execs.get(cid) {
|
||
map.insert("for".to_string(), serde_json::Value::String(bg.cmd_display.clone()));
|
||
}
|
||
}
|
||
}
|
||
let arguments_clone = arguments.clone();
|
||
let call_id_clone = ctx.call_id.clone();
|
||
|
||
execute_custom_tool(
|
||
sess,
|
||
ctx,
|
||
"web_fetch".to_string(),
|
||
params_for_event,
|
||
|| async move {
|
||
#[derive(serde::Deserialize)]
|
||
struct WebFetchParams {
|
||
url: String,
|
||
#[serde(default)]
|
||
timeout_ms: Option<u64>,
|
||
#[serde(default)]
|
||
mode: Option<String>, // "auto" (default), "browser", or "http"
|
||
}
|
||
|
||
let parsed: Result<WebFetchParams, _> = serde_json::from_str(&arguments_clone);
|
||
let params = match parsed {
|
||
Ok(p) => p,
|
||
Err(e) => {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Invalid web_fetch arguments: {e}"),
|
||
success: None,
|
||
},
|
||
};
|
||
}
|
||
};
|
||
|
||
struct BrowserFetchOutcome {
|
||
html: String,
|
||
final_url: Option<String>,
|
||
headless: bool,
|
||
}
|
||
|
||
async fn fetch_html_via_headless_browser(
|
||
url: &str,
|
||
timeout: Duration,
|
||
) -> Result<BrowserFetchOutcome, String> {
|
||
let mut config = CodexBrowserConfig::default();
|
||
config.enabled = true;
|
||
config.headless = true;
|
||
config.fullpage = false;
|
||
config.segments_max = 2;
|
||
config.persist_profile = false;
|
||
config.idle_timeout_ms = 10_000;
|
||
|
||
let manager = BrowserManager::new(config);
|
||
manager.set_enabled_sync(true);
|
||
|
||
const CHECK_JS: &str = r#"(function(){
|
||
const discuss = document.querySelectorAll('[data-test-selector=\"issue-comment-body\"]');
|
||
const timeline = document.querySelectorAll('.js-timeline-item');
|
||
const article = document.querySelectorAll('article, main');
|
||
return (discuss.length + timeline.length + article.length);
|
||
})()"#;
|
||
const HTML_JS: &str =
|
||
"(function(){ return { html: document.documentElement.outerHTML, title: document.title||'' }; })()";
|
||
|
||
let goto_result = match tokio::time::timeout(timeout, manager.goto(url)).await {
|
||
Ok(Ok(res)) => res,
|
||
Ok(Err(e)) => {
|
||
let _ = manager.stop().await;
|
||
return Err(format!("Headless goto failed: {e}"));
|
||
}
|
||
Err(_) => {
|
||
let _ = manager.stop().await;
|
||
return Err("Headless goto timed out".to_string());
|
||
}
|
||
};
|
||
|
||
for _ in 0..6 {
|
||
match tokio::time::timeout(Duration::from_millis(1500), manager.execute_javascript(CHECK_JS)).await {
|
||
Ok(Ok(val)) => {
|
||
let count = val
|
||
.get("value")
|
||
.and_then(|v| v.as_i64())
|
||
.unwrap_or(0);
|
||
if count > 0 {
|
||
break;
|
||
}
|
||
}
|
||
Ok(Err(e)) => {
|
||
tracing::debug!("Headless readiness check failed: {}", e);
|
||
break;
|
||
}
|
||
Err(_) => {
|
||
tracing::debug!("Headless readiness check timed out");
|
||
break;
|
||
}
|
||
}
|
||
tokio::time::sleep(Duration::from_millis(800)).await;
|
||
}
|
||
|
||
let html_value = match tokio::time::timeout(timeout, manager.execute_javascript(HTML_JS)).await {
|
||
Ok(Ok(val)) => val,
|
||
Ok(Err(e)) => {
|
||
let _ = manager.stop().await;
|
||
return Err(format!("Headless HTML extraction failed: {e}"));
|
||
}
|
||
Err(_) => {
|
||
let _ = manager.stop().await;
|
||
return Err("Headless HTML extraction timed out".to_string());
|
||
}
|
||
};
|
||
|
||
let html = html_value
|
||
.get("value")
|
||
.and_then(|v| v.get("html"))
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or("")
|
||
.to_string();
|
||
|
||
if html.trim().is_empty() {
|
||
let _ = manager.stop().await;
|
||
return Err("Headless browser returned empty HTML".to_string());
|
||
}
|
||
|
||
let final_url = Some(goto_result.url.clone());
|
||
let _ = manager.stop().await;
|
||
|
||
Ok(BrowserFetchOutcome {
|
||
html,
|
||
final_url,
|
||
headless: true,
|
||
})
|
||
}
|
||
|
||
async fn fetch_html_via_browser(
|
||
url: &str,
|
||
timeout: Duration,
|
||
prefer_global: bool,
|
||
) -> Option<BrowserFetchOutcome> {
|
||
const HTML_JS: &str =
|
||
"(function(){ return { html: document.documentElement.outerHTML, title: document.title||'' }; })()";
|
||
const CHECK_JS: &str = r#"(function(){
|
||
const discuss = document.querySelectorAll('[data-test-selector=\"issue-comment-body\"]');
|
||
const timeline = document.querySelectorAll('.js-timeline-item');
|
||
const article = document.querySelectorAll('article, main');
|
||
return (discuss.length + timeline.length + article.length);
|
||
})()"#;
|
||
|
||
if prefer_global {
|
||
if let Some(manager) = code_browser::global::get_browser_manager().await {
|
||
if manager.is_enabled_sync() {
|
||
match tokio::time::timeout(timeout, manager.goto(url)).await {
|
||
Ok(Ok(res)) => {
|
||
for _ in 0..6 {
|
||
match tokio::time::timeout(Duration::from_millis(1500), manager.execute_javascript(CHECK_JS)).await {
|
||
Ok(Ok(val)) => {
|
||
let count = val
|
||
.get("value")
|
||
.and_then(|v| v.as_i64())
|
||
.unwrap_or(0);
|
||
if count > 0 {
|
||
break;
|
||
}
|
||
}
|
||
Ok(Err(e)) => {
|
||
tracing::debug!("Global browser readiness check failed: {}", e);
|
||
break;
|
||
}
|
||
Err(_) => {
|
||
tracing::debug!("Global browser readiness timed out");
|
||
break;
|
||
}
|
||
}
|
||
tokio::time::sleep(Duration::from_millis(800)).await;
|
||
}
|
||
|
||
match tokio::time::timeout(timeout, manager.execute_javascript(HTML_JS)).await {
|
||
Ok(Ok(val)) => {
|
||
if let Some(html) = val
|
||
.get("value")
|
||
.and_then(|v| v.get("html"))
|
||
.and_then(|v| v.as_str())
|
||
{
|
||
if !html.trim().is_empty() {
|
||
return Some(BrowserFetchOutcome {
|
||
html: html.to_string(),
|
||
final_url: Some(res.url.clone()),
|
||
headless: false,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
Ok(Err(e)) => {
|
||
tracing::debug!("Global browser HTML extraction failed: {}", e);
|
||
}
|
||
Err(_) => {
|
||
tracing::debug!("Global browser HTML extraction timed out");
|
||
}
|
||
}
|
||
}
|
||
Ok(Err(e)) => {
|
||
tracing::warn!("Global browser navigation failed: {}", e);
|
||
}
|
||
Err(_) => {
|
||
tracing::warn!("Global browser navigation timed out");
|
||
}
|
||
}
|
||
} else {
|
||
tracing::debug!("Global browser manager disabled; skipping UI fetch");
|
||
}
|
||
}
|
||
}
|
||
|
||
match fetch_html_via_headless_browser(url, timeout).await {
|
||
Ok(outcome) => Some(outcome),
|
||
Err(err) => {
|
||
tracing::warn!("Headless browser fallback failed for {}: {}", url, err);
|
||
None
|
||
}
|
||
}
|
||
}
|
||
|
||
// Helper: build a client with a specific UA and common headers.
|
||
async fn do_request(
|
||
url: &str,
|
||
ua: &str,
|
||
timeout: Duration,
|
||
extra_headers: Option<&[(reqwest::header::HeaderName, &'static str)]>,
|
||
) -> Result<reqwest::Response, reqwest::Error> {
|
||
let client = reqwest::Client::builder()
|
||
.timeout(timeout)
|
||
.user_agent(ua)
|
||
.build()?;
|
||
let mut req = client.get(url)
|
||
// Add a few browser-like headers to reduce blocks
|
||
.header(reqwest::header::ACCEPT, "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||
.header(reqwest::header::ACCEPT_LANGUAGE, "en-US,en;q=0.9");
|
||
if let Some(pairs) = extra_headers {
|
||
for (k, v) in pairs.iter() {
|
||
req = req.header(k, *v);
|
||
}
|
||
}
|
||
req.send().await
|
||
}
|
||
|
||
// Helper: remove obvious noisy blocks before markdown conversion.
|
||
// This uses a lightweight ASCII-insensitive scan to drop whole
|
||
// elements whose contents should never be surfaced to the model
|
||
// (scripts, styles, templates, headers/footers/navigation, etc.).
|
||
fn strip_noisy_tags(mut html: String) -> String {
|
||
// Remove <script>, <style>, and <noscript> blocks with a simple
|
||
// ASCII case-insensitive scan that preserves UTF-8 boundaries.
|
||
// This avoids allocating lowercase copies and accidentally using
|
||
// indices from a different string representation.
|
||
fn eq_ascii_ci(a: u8, b: u8) -> bool {
|
||
a.to_ascii_lowercase() == b.to_ascii_lowercase()
|
||
}
|
||
fn starts_with_tag_ci(bytes: &[u8], tag: &[u8]) -> bool {
|
||
if bytes.len() < tag.len() { return false; }
|
||
for i in 0..tag.len() {
|
||
if !eq_ascii_ci(bytes[i], tag[i]) { return false; }
|
||
}
|
||
true
|
||
}
|
||
// Find the next opening tag like "<script" (allowing whitespace after '<').
|
||
fn find_open_tag_ci(s: &str, tag: &str, from: usize) -> Option<usize> {
|
||
let bytes = s.as_bytes();
|
||
let tag_bytes = tag.as_bytes();
|
||
let mut i = from;
|
||
while i + 1 < bytes.len() {
|
||
if bytes[i] == b'<' {
|
||
let mut j = i + 1;
|
||
while j < bytes.len() && (bytes[j] == b' ' || bytes[j] == b'\t' || bytes[j] == b'\n' || bytes[j] == b'\r') {
|
||
j += 1;
|
||
}
|
||
if j < bytes.len() && starts_with_tag_ci(&bytes[j..], tag_bytes) {
|
||
return Some(i);
|
||
}
|
||
}
|
||
i += 1;
|
||
}
|
||
None
|
||
}
|
||
// Find the corresponding closing tag like "</script>" starting at or after `from`.
|
||
// Returns the byte index just after the closing '>' if found.
|
||
fn find_close_after_ci(s: &str, tag: &str, from: usize) -> Option<usize> {
|
||
let bytes = s.as_bytes();
|
||
let tag_bytes = tag.as_bytes();
|
||
let mut i = from;
|
||
while i + 2 < bytes.len() { // need at least '<' '/' and one tag byte
|
||
if bytes[i] == b'<' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
|
||
let mut j = i + 2;
|
||
// Optional whitespace before tag name
|
||
while j < bytes.len() && (bytes[j] == b' ' || bytes[j] == b'\t' || bytes[j] == b'\n' || bytes[j] == b'\r') {
|
||
j += 1;
|
||
}
|
||
if starts_with_tag_ci(&bytes[j..], tag_bytes) {
|
||
// Advance past tag name
|
||
j += tag_bytes.len();
|
||
// Skip optional whitespace until '>'
|
||
while j < bytes.len() && bytes[j] != b'>' {
|
||
j += 1;
|
||
}
|
||
if j < bytes.len() && bytes[j] == b'>' {
|
||
return Some(j + 1);
|
||
}
|
||
return None; // No closing '>'
|
||
}
|
||
}
|
||
i += 1;
|
||
}
|
||
None
|
||
}
|
||
|
||
// Keep this conservative to avoid dropping content.
|
||
let tags = ["script", "style", "noscript"];
|
||
for tag in tags.iter() {
|
||
let mut guard = 0;
|
||
loop {
|
||
if guard > 64 { break; }
|
||
let Some(start) = find_open_tag_ci(&html, tag, 0) else { break; };
|
||
let search_from = start + 1; // after '<'
|
||
if let Some(end) = find_close_after_ci(&html, tag, search_from) {
|
||
// Safe because both start and end are on ASCII boundaries ('<' and '>')
|
||
html.replace_range(start..end, "");
|
||
} else {
|
||
// No close tag found; drop from the opening tag to end
|
||
html.truncate(start);
|
||
break;
|
||
}
|
||
guard += 1;
|
||
}
|
||
}
|
||
html
|
||
}
|
||
|
||
// Try to keep only <main> content if present; drastically reduces
|
||
// boilerplate from navigation and login banners on many sites.
|
||
fn extract_main(html: &str) -> Option<String> {
|
||
// Find opening <main ...>
|
||
let bytes = html.as_bytes();
|
||
let open = {
|
||
let mut i = 0usize;
|
||
let tag = b"main";
|
||
while i + 5 < bytes.len() { // < m a i n > (min)
|
||
if bytes[i] == b'<' {
|
||
// skip '<' and whitespace
|
||
let mut j = i + 1;
|
||
while j < bytes.len() && bytes[j].is_ascii_whitespace() { j += 1; }
|
||
if j + tag.len() <= bytes.len() && bytes[j..j+tag.len()].eq_ignore_ascii_case(tag) {
|
||
// Found '<main'; now find '>'
|
||
while j < bytes.len() && bytes[j] != b'>' { j += 1; }
|
||
if j < bytes.len() { Some((i, j + 1)) } else { None }
|
||
} else { None }
|
||
} else { None }
|
||
.map(|pair| return pair);
|
||
i += 1;
|
||
}
|
||
None
|
||
};
|
||
let (start, after_open) = open?;
|
||
// Find closing </main>
|
||
let mut i = after_open;
|
||
let tag_close = b"</main";
|
||
while i + tag_close.len() + 1 < bytes.len() {
|
||
if bytes[i] == b'<' && bytes[i+1] == b'/' {
|
||
if bytes[i..].len() >= tag_close.len() && bytes[i..i+tag_close.len()].eq_ignore_ascii_case(tag_close) {
|
||
// Find closing '>'
|
||
let mut j = i + tag_close.len();
|
||
while j < bytes.len() && bytes[j] != b'>' { j += 1; }
|
||
if j < bytes.len() {
|
||
return Some(html[start..j+1].to_string());
|
||
} else {
|
||
return Some(html[start..].to_string());
|
||
}
|
||
}
|
||
}
|
||
i += 1;
|
||
}
|
||
Some(html[start..].to_string())
|
||
}
|
||
|
||
// Inside fenced code blocks, collapse massively-escaped Windows paths like
|
||
// `C:\\Users\\...` to `C:\Users\...`. Only applies to drive-rooted paths.
|
||
fn unescape_windows_paths(line: &str) -> String {
|
||
let bytes = line.as_bytes();
|
||
let mut out = String::with_capacity(line.len());
|
||
let mut i = 0usize;
|
||
while i < bytes.len() {
|
||
// Pattern: [A-Za-z] : \\+
|
||
if i + 3 < bytes.len()
|
||
&& bytes[i].is_ascii_alphabetic()
|
||
&& bytes[i+1] == b':'
|
||
&& bytes[i+2] == b'\\'
|
||
&& bytes[i+3] == b'\\'
|
||
{
|
||
// Emit drive and a single backslash
|
||
out.push(bytes[i] as char);
|
||
out.push(':');
|
||
out.push('\\');
|
||
// Skip all following backslashes in this run
|
||
i += 4;
|
||
while i < bytes.len() && bytes[i] == b'\\' { i += 1; }
|
||
continue;
|
||
}
|
||
out.push(bytes[i] as char);
|
||
i += 1;
|
||
}
|
||
out
|
||
}
|
||
|
||
// Lightweight cleanup on the resulting markdown to remove leaked
|
||
// JSON blobs and obvious client boot payloads that sometimes escape
|
||
// the <script> filter on complex sites. Avoids touching fenced code.
|
||
fn postprocess_markdown(md: &str) -> String {
|
||
let mut out: Vec<String> = Vec::with_capacity(md.len() / 64 + 1);
|
||
let mut in_fence = false;
|
||
let mut empty_run = 0usize;
|
||
for line in md.lines() {
|
||
// Track fenced code blocks
|
||
if let Some(rest) = line.trim_start().strip_prefix("```") {
|
||
in_fence = !in_fence;
|
||
let _lang = if in_fence { Some(rest.trim()) } else { None };
|
||
out.push(line.to_string());
|
||
empty_run = 0;
|
||
continue;
|
||
}
|
||
if in_fence {
|
||
// Only normalize Windows path over-escaping; do not alter other content.
|
||
let normalized = unescape_windows_paths(line);
|
||
out.push(normalized);
|
||
continue;
|
||
}
|
||
|
||
let trimmed = line.trim();
|
||
// Drop extremely long single lines only if they're likely SPA boot payloads
|
||
if trimmed.len() > 8000 { continue; }
|
||
// Common SPA boot keys that shouldn't appear in human output.
|
||
// Keep this list tight to avoid dropping legitimate examples.
|
||
if trimmed.contains("\"payload\"") || trimmed.contains("\"props\"") || trimmed.contains("\"preloaded_records\"") || trimmed.contains("\"appPayload\"") || trimmed.contains("\"preloadedQueries\"") {
|
||
continue;
|
||
}
|
||
|
||
if trimmed.is_empty() {
|
||
// Collapse multiple empty lines to max 1
|
||
if empty_run == 0 {
|
||
out.push(String::new());
|
||
}
|
||
empty_run += 1;
|
||
} else {
|
||
out.push(line.to_string());
|
||
empty_run = 0;
|
||
}
|
||
}
|
||
// Trim leading/trailing blank lines
|
||
let mut s = out.join("\n");
|
||
while s.starts_with('\n') { s.remove(0); }
|
||
while s.ends_with('\n') { s.pop(); }
|
||
s
|
||
}
|
||
|
||
// Domain-specific: extract rich content from GitHub issue/PR pages
|
||
// without requiring a JS-capable browser. We parse JSON-LD and the
|
||
// inlined GraphQL payload (preloadedQueries) to reconstruct the
|
||
// issue body and comments into readable markdown.
|
||
fn try_extract_github_issue_markdown(html: &str) -> Option<String> {
|
||
// Helper: extract the first <script type="application/ld+json"> block
|
||
fn extract_ld_json(html: &str) -> Option<serde_json::Value> {
|
||
let mut s = html;
|
||
loop {
|
||
let start = s.find("<script").map(|i| i)?;
|
||
let rest = &s[start + 7..];
|
||
if rest.to_lowercase().contains("type=\"application/ld+json\"") {
|
||
// Find end of script open tag
|
||
let open_end_rel = rest.find('>')?;
|
||
let open_end = start + 7 + open_end_rel + 1;
|
||
let after_open = &s[open_end..];
|
||
// Find closing </script>
|
||
if let Some(close_rel) = after_open.to_lowercase().find("</script>") {
|
||
let json_str = &after_open[..close_rel];
|
||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(json_str) {
|
||
return Some(v);
|
||
}
|
||
// Some pages JSON-encode the JSON-LD; try to unescape once
|
||
if let Ok(un) = serde_json::from_str::<String>(json_str) {
|
||
if let Ok(v2) = serde_json::from_str::<serde_json::Value>(&un) {
|
||
return Some(v2);
|
||
}
|
||
}
|
||
// Advance after this script to search for next
|
||
s = &after_open[close_rel + 9..];
|
||
continue;
|
||
}
|
||
}
|
||
// Advance and continue search
|
||
s = &rest[1..];
|
||
}
|
||
}
|
||
|
||
// Helper: extract substring for the JSON array that follows key
|
||
fn extract_json_array_after(html: &str, key: &str) -> Option<String> {
|
||
let idx = html.find(key)?;
|
||
let bytes = html.as_bytes();
|
||
// Find the first '[' after key
|
||
let mut i = idx + key.len();
|
||
while i < bytes.len() && bytes[i] != b'[' { i += 1; }
|
||
if i >= bytes.len() { return None; }
|
||
let start = i;
|
||
// Scan to matching ']' accounting for strings and escapes
|
||
let mut depth: i32 = 0;
|
||
let mut in_str = false;
|
||
let mut escape = false;
|
||
while i < bytes.len() {
|
||
let c = bytes[i] as char;
|
||
if in_str {
|
||
if escape { escape = false; }
|
||
else if c == '\\' { escape = true; }
|
||
else if c == '"' { in_str = false; }
|
||
i += 1; continue;
|
||
}
|
||
match c {
|
||
'"' => { in_str = true; },
|
||
'[' => { depth += 1; },
|
||
']' => { depth -= 1; if depth == 0 { let end = i + 1; return Some(html[start..end].to_string()); } },
|
||
_ => {}
|
||
}
|
||
i += 1;
|
||
}
|
||
None
|
||
}
|
||
|
||
// Parse JSON-LD for headline, articleBody, author, date
|
||
let mut title: Option<String> = None;
|
||
let mut issue_body_md: Option<String> = None;
|
||
let mut opened_by: Option<String> = None;
|
||
let mut opened_at: Option<String> = None;
|
||
if let Some(ld) = extract_ld_json(html) {
|
||
if ld.get("@type").and_then(|v| v.as_str()) == Some("DiscussionForumPosting") {
|
||
title = ld.get("headline").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||
issue_body_md = ld.get("articleBody").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||
opened_by = ld.get("author").and_then(|a| a.get("name")).and_then(|v| v.as_str()).map(|s| s.to_string());
|
||
opened_at = ld.get("datePublished").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||
}
|
||
}
|
||
|
||
// Parse GraphQL payload for comments and state
|
||
let arr_str = extract_json_array_after(html, "\"preloadedQueries\"")?;
|
||
let arr: serde_json::Value = serde_json::from_str(&arr_str).ok()?;
|
||
let mut comments: Vec<(String, String, String)> = Vec::new();
|
||
let mut state: Option<String> = None;
|
||
let mut state_reason: Option<String> = None;
|
||
if let Some(items) = arr.as_array() {
|
||
for item in items {
|
||
let repo = item.get("result").and_then(|v| v.get("data")).and_then(|v| v.get("repository"));
|
||
let issue = repo.and_then(|r| r.get("issue"));
|
||
if let Some(issue) = issue {
|
||
if state.is_none() {
|
||
state = issue.get("state").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||
state_reason = issue.get("stateReason").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||
}
|
||
if let Some(edges) = issue.get("frontTimelineItems").and_then(|v| v.get("edges")).and_then(|v| v.as_array()) {
|
||
for e in edges {
|
||
let node = e.get("node");
|
||
let typename = node.and_then(|n| n.get("__typename")).and_then(|v| v.as_str()).unwrap_or("");
|
||
if typename == "IssueComment" {
|
||
let author = node.and_then(|n| n.get("author")).and_then(|a| a.get("login")).and_then(|v| v.as_str()).unwrap_or("");
|
||
let created = node.and_then(|n| n.get("createdAt")).and_then(|v| v.as_str()).unwrap_or("");
|
||
let body = node.and_then(|n| n.get("body")).and_then(|v| v.as_str()).unwrap_or("");
|
||
if !body.is_empty() {
|
||
comments.push((author.to_string(), created.to_string(), body.to_string()));
|
||
} else {
|
||
let body_html = node.and_then(|n| n.get("bodyHTML")).and_then(|v| v.as_str()).unwrap_or("");
|
||
if !body_html.is_empty() {
|
||
// Minimal HTML→MD for comments if body missing
|
||
let options = htmd::options::Options { heading_style: htmd::options::HeadingStyle::Atx, code_block_style: htmd::options::CodeBlockStyle::Fenced, link_style: htmd::options::LinkStyle::Inlined, ..Default::default() };
|
||
let conv = htmd::HtmlToMarkdown::builder().options(options).build();
|
||
if let Ok(md) = conv.convert(body_html) {
|
||
comments.push((author.to_string(), created.to_string(), md));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// If nothing meaningful extracted, bail out.
|
||
if title.is_none() && comments.is_empty() && issue_body_md.is_none() {
|
||
return None;
|
||
}
|
||
|
||
// Compose readable markdown
|
||
let mut out = String::new();
|
||
if let Some(t) = title { out.push_str(&format!("# {}\n\n", t)); }
|
||
if let (Some(by), Some(at)) = (opened_by, opened_at) { out.push_str(&format!("Opened by {} on {}\n\n", by, at)); }
|
||
if let (Some(s), _) = (state.clone(), state_reason.clone()) { out.push_str(&format!("State: {}\n\n", s)); }
|
||
if let Some(body) = issue_body_md { out.push_str(&format!("{}\n\n", body)); }
|
||
if !comments.is_empty() {
|
||
out.push_str("## Comments\n\n");
|
||
for (author, created, body) in comments {
|
||
out.push_str(&format!("- {} — {}\n\n{}\n\n", author, created, body));
|
||
}
|
||
}
|
||
Some(out)
|
||
}
|
||
|
||
// Helper: convert HTML to markdown and truncate if too large.
|
||
fn convert_html_to_markdown_trimmed(html: String, max_chars: usize) -> crate::error::Result<(String, bool)> {
|
||
let options = htmd::options::Options {
|
||
heading_style: htmd::options::HeadingStyle::Atx,
|
||
code_block_style: htmd::options::CodeBlockStyle::Fenced,
|
||
link_style: htmd::options::LinkStyle::Inlined,
|
||
..Default::default()
|
||
};
|
||
let converter = htmd::HtmlToMarkdown::builder().options(options).build();
|
||
let reduced = extract_main(&html).unwrap_or(html);
|
||
let sanitized = strip_noisy_tags(reduced);
|
||
let markdown = converter.convert(&sanitized)?;
|
||
let markdown = postprocess_markdown(&markdown);
|
||
let mut truncated = false;
|
||
let rendered = {
|
||
let char_count = markdown.chars().count();
|
||
if char_count > max_chars {
|
||
truncated = true;
|
||
let mut s: String = markdown.chars().take(max_chars).collect();
|
||
s.push_str("\n\n… (truncated)\n");
|
||
s
|
||
} else {
|
||
markdown
|
||
}
|
||
};
|
||
Ok((rendered, truncated))
|
||
}
|
||
|
||
// Helper: detect WAF/challenge pages to avoid dumping challenge content.
|
||
fn detect_block_vendor(_status: reqwest::StatusCode, body: &str) -> Option<&'static str> {
|
||
// Identify common bot-challenge pages regardless of HTTP status.
|
||
// Cloudflare often returns 200 with a challenge that requires JS/cookies.
|
||
let lower = body.to_lowercase();
|
||
if lower.contains("cloudflare")
|
||
|| lower.contains("cf-ray")
|
||
|| lower.contains("_cf_chl_opt")
|
||
|| lower.contains("challenge-platform")
|
||
|| lower.contains("checking if the site connection is secure")
|
||
|| lower.contains("waiting for")
|
||
|| lower.contains("just a moment")
|
||
{
|
||
return Some("cloudflare");
|
||
}
|
||
None
|
||
}
|
||
|
||
fn headers_indicate_block(headers: &reqwest::header::HeaderMap) -> bool {
|
||
let h = headers;
|
||
let has_cf_ray = h.get("cf-ray").is_some();
|
||
let has_cf_mitigated = h.get("cf-mitigated").is_some();
|
||
let has_cf_bm = h.get("set-cookie").and_then(|v| v.to_str().ok()).map(|s| s.contains("__cf_bm=")).unwrap_or(false);
|
||
let has_chlray = h.get("server-timing").and_then(|v| v.to_str().ok()).map(|s| s.to_lowercase().contains("chlray")).unwrap_or(false);
|
||
has_cf_ray || has_cf_mitigated || has_cf_bm || has_chlray
|
||
}
|
||
|
||
fn looks_like_challenge_markdown(md: &str) -> bool {
|
||
let l = md.to_lowercase();
|
||
l.contains("just a moment") || l.contains("enable javascript and cookies") || l.contains("waiting for ")
|
||
}
|
||
|
||
let timeout = Duration::from_millis(params.timeout_ms.unwrap_or(15000));
|
||
let code_ua = crate::default_client::get_code_user_agent(Some("web_fetch"));
|
||
|
||
if matches!(params.mode.as_deref(), Some("browser")) {
|
||
if let Some(browser_fetch) = fetch_html_via_browser(¶ms.url, timeout, true).await {
|
||
let (markdown, truncated) = match convert_html_to_markdown_trimmed(browser_fetch.html, 120_000) {
|
||
Ok(t) => t,
|
||
Err(e) => {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload { content: format!("Markdown conversion failed: {e}"), success: Some(false) },
|
||
};
|
||
}
|
||
};
|
||
|
||
let body = serde_json::json!({
|
||
"url": params.url,
|
||
"status": 200,
|
||
"final_url": browser_fetch.final_url.unwrap_or_else(|| params.url.clone()),
|
||
"content_type": "text/html",
|
||
"used_browser_ua": true,
|
||
"via_browser": true,
|
||
"headless": browser_fetch.headless,
|
||
"truncated": truncated,
|
||
"markdown": markdown,
|
||
});
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload { content: body.to_string(), success: Some(true) },
|
||
};
|
||
}
|
||
}
|
||
// Attempt 1: Codex UA + polite headers
|
||
let resp = match do_request(¶ms.url, &code_ua, timeout, None).await {
|
||
Ok(r) => r,
|
||
Err(e) => {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload { content: format!("Request failed: {e}"), success: Some(false) },
|
||
};
|
||
}
|
||
};
|
||
|
||
// Capture metadata before consuming the response body.
|
||
let mut status = resp.status();
|
||
let mut final_url = resp.url().to_string();
|
||
let mut headers = resp.headers().clone();
|
||
// Read body
|
||
let mut body_text = match resp.text().await {
|
||
Ok(t) => t,
|
||
Err(e) => {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload { content: format!("Failed to read response body: {e}"), success: Some(false) },
|
||
};
|
||
}
|
||
};
|
||
let mut used_browser_ua = false;
|
||
let browser_ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36";
|
||
if !matches!(params.mode.as_deref(), Some("http")) && (detect_block_vendor(status, &body_text).is_some() || headers_indicate_block(&headers)) {
|
||
// Simple retry with a browser UA and extra headers
|
||
let extra = [
|
||
(reqwest::header::HeaderName::from_static("upgrade-insecure-requests"), "1"),
|
||
];
|
||
if let Ok(r2) = do_request(¶ms.url, browser_ua, timeout, Some(&extra)).await {
|
||
let status2 = r2.status();
|
||
let final_url2 = r2.url().to_string();
|
||
let headers2 = r2.headers().clone();
|
||
if let Ok(t2) = r2.text().await {
|
||
used_browser_ua = true;
|
||
status = status2;
|
||
final_url = final_url2;
|
||
headers = headers2;
|
||
body_text = t2;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Response metadata
|
||
let content_type = headers
|
||
.get(reqwest::header::CONTENT_TYPE)
|
||
.and_then(|v| v.to_str().ok())
|
||
.unwrap_or("")
|
||
.to_string();
|
||
|
||
// Provide structured diagnostics if blocked by WAF (even if HTTP 200)
|
||
if !matches!(params.mode.as_deref(), Some("http")) && (detect_block_vendor(status, &body_text).is_some() || headers_indicate_block(&headers)) {
|
||
let vendor = "cloudflare";
|
||
let retry_after = headers
|
||
.get(reqwest::header::RETRY_AFTER)
|
||
.and_then(|v| v.to_str().ok())
|
||
.map(|s| s.to_string());
|
||
let cf_ray = headers
|
||
.get("cf-ray")
|
||
.and_then(|v| v.to_str().ok())
|
||
.map(|s| s.to_string());
|
||
|
||
let mut diag = serde_json::json!({
|
||
"final_url": final_url,
|
||
"content_type": content_type,
|
||
"used_browser_ua": used_browser_ua,
|
||
"blocked_by_waf": true,
|
||
"vendor": vendor,
|
||
});
|
||
if let Some(ra) = retry_after { diag["retry_after"] = serde_json::json!(ra); }
|
||
if let Some(ray) = cf_ray { diag["cf_ray"] = serde_json::json!(ray); }
|
||
|
||
if let Some(browser_fetch) = fetch_html_via_browser(¶ms.url, timeout, false).await {
|
||
let (markdown, truncated) = match convert_html_to_markdown_trimmed(browser_fetch.html, 120_000) {
|
||
Ok(t) => t,
|
||
Err(e) => {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload { content: format!("Markdown conversion failed: {e}"), success: Some(false) },
|
||
};
|
||
}
|
||
};
|
||
|
||
diag["via_browser"] = serde_json::json!(true);
|
||
if browser_fetch.headless {
|
||
diag["headless"] = serde_json::json!(true);
|
||
}
|
||
|
||
let body = serde_json::json!({
|
||
"url": params.url,
|
||
"status": 200,
|
||
"final_url": browser_fetch.final_url.unwrap_or_else(|| final_url.clone()),
|
||
"content_type": content_type,
|
||
"used_browser_ua": true,
|
||
"via_browser": true,
|
||
"headless": browser_fetch.headless,
|
||
"truncated": truncated,
|
||
"markdown": markdown,
|
||
});
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload { content: body.to_string(), success: Some(true) },
|
||
};
|
||
}
|
||
|
||
let (md_preview, _trunc) = match convert_html_to_markdown_trimmed(body_text, 2000) {
|
||
Ok(t) => t,
|
||
Err(_) => ("".to_string(), false),
|
||
};
|
||
|
||
let body = serde_json::json!({
|
||
"url": params.url,
|
||
"status": status.as_u16(),
|
||
"error": "Blocked by site challenge",
|
||
"diagnostics": diag,
|
||
"markdown": md_preview,
|
||
});
|
||
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload { content: body.to_string(), success: Some(false) },
|
||
};
|
||
}
|
||
|
||
// If not success, provide structured, minimal diagnostics without dumping content.
|
||
if !status.is_success() {
|
||
let waf_vendor = detect_block_vendor(status, &body_text);
|
||
let retry_after = headers
|
||
.get(reqwest::header::RETRY_AFTER)
|
||
.and_then(|v| v.to_str().ok())
|
||
.map(|s| s.to_string());
|
||
let cf_ray = headers
|
||
.get("cf-ray")
|
||
.and_then(|v| v.to_str().ok())
|
||
.map(|s| s.to_string());
|
||
|
||
let mut diag = serde_json::json!({
|
||
"final_url": final_url,
|
||
"content_type": content_type,
|
||
"used_browser_ua": used_browser_ua,
|
||
});
|
||
if let Some(vendor) = waf_vendor { diag["blocked_by_waf"] = serde_json::json!(true); diag["vendor"] = serde_json::json!(vendor); }
|
||
if let Some(ra) = retry_after { diag["retry_after"] = serde_json::json!(ra); }
|
||
if let Some(ray) = cf_ray { diag["cf_ray"] = serde_json::json!(ray); }
|
||
|
||
// Provide a tiny, safe preview of visible text only (converted and truncated).
|
||
let (md_preview, _trunc) = match convert_html_to_markdown_trimmed(body_text, 2000) {
|
||
Ok(t) => t,
|
||
Err(_) => ("".to_string(), false),
|
||
};
|
||
|
||
let body = serde_json::json!({
|
||
"url": params.url,
|
||
"status": status.as_u16(),
|
||
"error": format!("HTTP {} {}", status.as_u16(), status.canonical_reason().unwrap_or("")),
|
||
"diagnostics": diag,
|
||
// Keep a short, human-friendly preview; avoid dumping raw HTML or long JS.
|
||
"markdown": md_preview,
|
||
});
|
||
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload { content: body.to_string(), success: Some(false) },
|
||
};
|
||
}
|
||
|
||
// Domain-specific extraction first (e.g., GitHub issues)
|
||
if params.url.contains("github.com/") && params.url.contains("/issues/") {
|
||
if let Some(md) = try_extract_github_issue_markdown(&body_text) {
|
||
let body = serde_json::json!({
|
||
"url": params.url,
|
||
"status": status.as_u16(),
|
||
"final_url": final_url,
|
||
"content_type": content_type,
|
||
"used_browser_ua": used_browser_ua,
|
||
"truncated": false,
|
||
"markdown": md,
|
||
});
|
||
return ResponseInputItem::FunctionCallOutput { call_id: call_id_clone, output: FunctionCallOutputPayload { content: body.to_string(), success: Some(true) } };
|
||
}
|
||
}
|
||
|
||
// Success: convert to markdown (sanitized and size-limited)
|
||
let (markdown, truncated) = match convert_html_to_markdown_trimmed(body_text, 120_000) {
|
||
Ok(t) => t,
|
||
Err(e) => {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload { content: format!("Markdown conversion failed: {e}"), success: Some(false) },
|
||
};
|
||
}
|
||
};
|
||
|
||
// If the rendered markdown still looks like a challenge page, attempt browser fallback (unless http-only).
|
||
if !matches!(params.mode.as_deref(), Some("http")) && looks_like_challenge_markdown(&markdown) {
|
||
if let Some(browser_fetch) = fetch_html_via_browser(¶ms.url, timeout, false).await {
|
||
let (md2, truncated2) = match convert_html_to_markdown_trimmed(browser_fetch.html, 120_000) {
|
||
Ok(t) => t,
|
||
Err(e) => {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload { content: format!("Markdown conversion failed: {e}"), success: Some(false) },
|
||
};
|
||
}
|
||
};
|
||
|
||
let body = serde_json::json!({
|
||
"url": params.url,
|
||
"status": 200,
|
||
"final_url": browser_fetch.final_url.unwrap_or_else(|| final_url.clone()),
|
||
"content_type": content_type,
|
||
"used_browser_ua": true,
|
||
"via_browser": true,
|
||
"headless": browser_fetch.headless,
|
||
"truncated": truncated2,
|
||
"markdown": md2,
|
||
});
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload { content: body.to_string(), success: Some(true) },
|
||
};
|
||
}
|
||
|
||
// If fallback not possible, return structured error rather than a useless challenge page
|
||
let body = serde_json::json!({
|
||
"url": params.url,
|
||
"status": 200,
|
||
"error": "Blocked by site challenge",
|
||
"diagnostics": { "final_url": final_url, "content_type": content_type, "used_browser_ua": used_browser_ua, "blocked_by_waf": true, "vendor": "cloudflare", "detected_via": "markdown" },
|
||
"markdown": markdown.chars().take(2000).collect::<String>(),
|
||
});
|
||
return ResponseInputItem::FunctionCallOutput { call_id: call_id_clone, output: FunctionCallOutputPayload { content: body.to_string(), success: Some(false) } };
|
||
}
|
||
|
||
let body = serde_json::json!({
|
||
"url": params.url,
|
||
"status": status.as_u16(),
|
||
"final_url": final_url,
|
||
"content_type": content_type,
|
||
"used_browser_ua": used_browser_ua,
|
||
"truncated": truncated,
|
||
"markdown": markdown,
|
||
});
|
||
|
||
ResponseInputItem::FunctionCallOutput { call_id: call_id_clone, output: FunctionCallOutputPayload { content: body.to_string(), success: Some(true) } }
|
||
},
|
||
).await
|
||
}
|
||
|
||
// Wait for a background shell execution to complete.
|
||
// Parameters: { call_id?: string, timeout_ms?: number }
|
||
async fn handle_wait(
|
||
sess: &Session,
|
||
ctx: &ToolCallCtx,
|
||
arguments: String,
|
||
) -> ResponseInputItem {
|
||
use serde::Deserialize;
|
||
#[derive(Deserialize, Clone)]
|
||
struct Params { #[serde(default)] call_id: Option<String>, #[serde(default)] timeout_ms: Option<u64> }
|
||
let mut params_for_event = serde_json::from_str::<serde_json::Value>(&arguments).ok();
|
||
if let Some(serde_json::Value::Object(map)) = params_for_event.as_mut() {
|
||
if let Some(serde_json::Value::String(cid)) = map.get("call_id") {
|
||
let st = sess.state.lock().unwrap();
|
||
if let Some(bg) = st.background_execs.get(cid) {
|
||
map.insert("for".to_string(), serde_json::Value::String(bg.cmd_display.clone()));
|
||
}
|
||
}
|
||
}
|
||
let arguments_clone = arguments.clone();
|
||
let ctx_clone = ToolCallCtx::new(ctx.sub_id.clone(), ctx.call_id.clone(), ctx.seq_hint, ctx.output_index);
|
||
let ctx_for_closure = ctx_clone.clone();
|
||
execute_custom_tool(
|
||
sess,
|
||
&ctx_clone,
|
||
"wait".to_string(),
|
||
params_for_event,
|
||
move || async move {
|
||
let ctx_inner = ctx_for_closure.clone();
|
||
let parsed: Params = match serde_json::from_str(&arguments_clone) {
|
||
Ok(p) => p,
|
||
Err(e) => {
|
||
return ResponseInputItem::FunctionCallOutput { call_id: ctx_inner.call_id.clone(), output: FunctionCallOutputPayload { content: format!("Invalid wait arguments: {}", e), success: Some(false) } };
|
||
}
|
||
};
|
||
let call_id = match parsed.call_id {
|
||
Some(cid) if !cid.is_empty() => cid,
|
||
_ => {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: ctx_inner.call_id.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: "wait requires a call_id".to_string(),
|
||
success: Some(false),
|
||
},
|
||
};
|
||
}
|
||
};
|
||
let max_ms: u64 = 3_600_000; // 60 minutes cap
|
||
let default_ms: u64 = 600_000; // 10 minutes default
|
||
let timeout_ms = parsed.timeout_ms.unwrap_or(default_ms).min(max_ms);
|
||
use std::sync::atomic::Ordering;
|
||
let (notify_opt, done_opt, tail, suppress_flag) = {
|
||
let st = sess.state.lock().unwrap();
|
||
match st.background_execs.get(&call_id) {
|
||
Some(bg) => (
|
||
Some(bg.notify.clone()),
|
||
bg.result_cell.lock().unwrap().clone(),
|
||
bg.tail_buf.clone(),
|
||
Some(bg.suppress_event.clone()),
|
||
),
|
||
None => (None, None, None, None),
|
||
}
|
||
};
|
||
if let Some(flag) = &suppress_flag {
|
||
flag.store(true, Ordering::Relaxed);
|
||
}
|
||
if let Some(done) = done_opt {
|
||
let content = format_exec_output_with_limit(sess, &ctx_inner.sub_id, &ctx_inner.call_id, &done);
|
||
if let Some(flag) = &suppress_flag {
|
||
flag.store(true, Ordering::Relaxed);
|
||
}
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: ctx_inner.call_id.clone(),
|
||
output: FunctionCallOutputPayload { content, success: Some(done.exit_code == 0) },
|
||
};
|
||
}
|
||
let Some(spec_notify) = notify_opt else {
|
||
if let Some(flag) = &suppress_flag {
|
||
flag.store(false, Ordering::Relaxed);
|
||
}
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: ctx_inner.call_id.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("No background job found for call_id={call_id}"),
|
||
success: Some(false),
|
||
},
|
||
};
|
||
};
|
||
let any_notify = ANY_BG_NOTIFY.get().cloned().unwrap();
|
||
let raced = tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), async move {
|
||
tokio::select! {
|
||
_ = spec_notify.notified() => (),
|
||
_ = any_notify.notified() => (),
|
||
}
|
||
}).await;
|
||
if raced.is_err() {
|
||
let tail_text = tail
|
||
.map(|arc| String::from_utf8_lossy(&arc.lock().unwrap()).to_string())
|
||
.unwrap_or_default();
|
||
let msg = if tail_text.is_empty() {
|
||
format!("Background job {call_id} still running...")
|
||
} else {
|
||
format!("Background job {call_id} still running...\n\nOutput so far (tail):\n{tail_text}")
|
||
};
|
||
if let Some(flag) = &suppress_flag {
|
||
flag.store(false, Ordering::Relaxed);
|
||
}
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: ctx_inner.call_id.clone(),
|
||
output: FunctionCallOutputPayload { content: msg, success: Some(false) },
|
||
};
|
||
}
|
||
let done = {
|
||
let mut st = sess.state.lock().unwrap();
|
||
if let Some(bg) = st.background_execs.remove(&call_id) {
|
||
bg.result_cell.lock().unwrap().clone()
|
||
} else {
|
||
let found = st
|
||
.background_execs
|
||
.iter()
|
||
.find_map(|(k, v)| if v.result_cell.lock().unwrap().is_some() { Some(k.clone()) } else { None });
|
||
found
|
||
.and_then(|k| st.background_execs.remove(&k))
|
||
.and_then(|bg| bg.result_cell.lock().unwrap().clone())
|
||
}
|
||
};
|
||
if let Some(done) = done {
|
||
let content = format_exec_output_with_limit(sess, &ctx_inner.sub_id, &ctx_inner.call_id, &done);
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: ctx_inner.call_id.clone(),
|
||
output: FunctionCallOutputPayload { content, success: Some(done.exit_code == 0) },
|
||
}
|
||
} else {
|
||
if let Some(flag) = &suppress_flag {
|
||
flag.store(false, Ordering::Relaxed);
|
||
}
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: ctx_inner.call_id.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: "No completed background job found".to_string(),
|
||
success: Some(false),
|
||
},
|
||
}
|
||
}
|
||
}
|
||
).await
|
||
}
|
||
|
||
// Kill a background shell execution by call_id.
|
||
async fn handle_kill(
|
||
sess: &Session,
|
||
ctx: &ToolCallCtx,
|
||
arguments: String,
|
||
) -> ResponseInputItem {
|
||
use serde::Deserialize;
|
||
#[derive(Deserialize, Clone)]
|
||
struct Params {
|
||
call_id: String,
|
||
}
|
||
|
||
let mut params_for_event = serde_json::from_str::<serde_json::Value>(&arguments).ok();
|
||
let arguments_clone = arguments.clone();
|
||
let ctx_clone = ToolCallCtx::new(ctx.sub_id.clone(), ctx.call_id.clone(), ctx.seq_hint, ctx.output_index);
|
||
let ctx_for_closure = ctx_clone.clone();
|
||
let tx_event = sess.tx_event.clone();
|
||
|
||
execute_custom_tool(
|
||
sess,
|
||
&ctx_clone,
|
||
"kill".to_string(),
|
||
params_for_event.take(),
|
||
move || async move {
|
||
let ctx_inner = ctx_for_closure.clone();
|
||
let parsed: Params = match serde_json::from_str(&arguments_clone) {
|
||
Ok(p) => p,
|
||
Err(e) => {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: ctx_inner.call_id.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Invalid kill arguments: {e}"),
|
||
success: Some(false),
|
||
},
|
||
};
|
||
}
|
||
};
|
||
|
||
use std::sync::atomic::Ordering;
|
||
|
||
let (
|
||
notify,
|
||
result_cell,
|
||
suppress_flag,
|
||
cmd_display,
|
||
order_meta_for_end,
|
||
sub_id_for_end,
|
||
handle_opt,
|
||
already_done,
|
||
) = {
|
||
let mut st = sess.state.lock().unwrap();
|
||
match st.background_execs.get_mut(&parsed.call_id) {
|
||
Some(bg) => {
|
||
let done = bg.result_cell.lock().unwrap().is_some();
|
||
let handle = bg.task_handle.take();
|
||
(
|
||
bg.notify.clone(),
|
||
bg.result_cell.clone(),
|
||
bg.suppress_event.clone(),
|
||
bg.cmd_display.clone(),
|
||
bg.order_meta_for_end.clone(),
|
||
bg.sub_id.clone(),
|
||
handle,
|
||
done,
|
||
)
|
||
}
|
||
None => {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: ctx_inner.call_id.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("No background job found for call_id={}", parsed.call_id),
|
||
success: Some(false),
|
||
},
|
||
};
|
||
}
|
||
}
|
||
};
|
||
|
||
if already_done {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: ctx_inner.call_id.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Background job {} has already completed.", parsed.call_id),
|
||
success: Some(false),
|
||
},
|
||
};
|
||
}
|
||
|
||
suppress_flag.store(true, Ordering::Relaxed);
|
||
if let Some(handle) = handle_opt {
|
||
handle.abort();
|
||
let _ = handle.await;
|
||
}
|
||
|
||
let cancel_message = "Cancelled by user.".to_string();
|
||
let output = ExecToolCallOutput {
|
||
exit_code: 130,
|
||
stdout: StreamOutput::new(String::new()),
|
||
stderr: StreamOutput::new(cancel_message.clone()),
|
||
aggregated_output: StreamOutput::new(cancel_message.clone()),
|
||
duration: std::time::Duration::ZERO,
|
||
timed_out: false,
|
||
};
|
||
|
||
{
|
||
let mut slot = result_cell.lock().unwrap();
|
||
*slot = Some(output.clone());
|
||
}
|
||
|
||
notify.notify_waiters();
|
||
if let Some(global) = ANY_BG_NOTIFY.get() {
|
||
global.notify_waiters();
|
||
}
|
||
|
||
let end_msg = EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||
call_id: parsed.call_id.clone(),
|
||
stdout: output.stdout.text.clone(),
|
||
stderr: output.stderr.text.clone(),
|
||
exit_code: output.exit_code,
|
||
duration: output.duration,
|
||
});
|
||
let event = Event {
|
||
id: sub_id_for_end.clone(),
|
||
event_seq: 0,
|
||
msg: end_msg,
|
||
order: Some(order_meta_for_end),
|
||
};
|
||
let _ = tx_event.send(event).await;
|
||
|
||
let status = if cmd_display.trim().is_empty() {
|
||
format!("Killed background job {}", parsed.call_id)
|
||
} else {
|
||
format!("Killed background command: {}", cmd_display)
|
||
};
|
||
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: ctx_inner.call_id.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: status,
|
||
success: Some(true),
|
||
},
|
||
}
|
||
},
|
||
).await
|
||
}
|
||
|
||
fn to_exec_params(params: ShellToolCallParams, sess: &Session) -> ExecParams {
|
||
let timeout_ms = params
|
||
.timeout_ms
|
||
.map(|ms| ms.max(MIN_SHELL_TIMEOUT_MS));
|
||
ExecParams {
|
||
command: params.command,
|
||
cwd: sess.resolve_path(params.workdir.clone()),
|
||
timeout_ms,
|
||
env: create_env(&sess.shell_environment_policy),
|
||
with_escalated_permissions: params.with_escalated_permissions,
|
||
justification: params.justification,
|
||
}
|
||
}
|
||
|
||
fn resolve_agent_read_only(
|
||
write: Option<bool>,
|
||
read_only: Option<bool>,
|
||
config: Option<&crate::config_types::AgentConfig>,
|
||
) -> bool {
|
||
if let Some(flag) = write {
|
||
return !flag;
|
||
}
|
||
if let Some(flag) = read_only {
|
||
return flag;
|
||
}
|
||
config.map(|c| c.read_only).unwrap_or(false)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod resolve_read_only_tests {
|
||
use super::*;
|
||
use crate::config_types::AgentConfig;
|
||
|
||
fn make_config(read_only: bool) -> AgentConfig {
|
||
AgentConfig {
|
||
name: "test".into(),
|
||
command: "test".into(),
|
||
args: Vec::new(),
|
||
read_only,
|
||
enabled: true,
|
||
description: None,
|
||
env: None,
|
||
args_read_only: None,
|
||
args_write: None,
|
||
instructions: None,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn explicit_write_overrides_config_read_only() {
|
||
let cfg = make_config(true);
|
||
assert!(
|
||
!resolve_agent_read_only(Some(true), None, Some(&cfg)),
|
||
"write=true should allow writes even when config prefers read-only"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn explicit_read_only_flag_takes_precedence() {
|
||
let cfg = make_config(false);
|
||
assert!(
|
||
resolve_agent_read_only(None, Some(true), Some(&cfg)),
|
||
"read_only=true should force read-only even when config allows writes"
|
||
);
|
||
assert!(
|
||
resolve_agent_read_only(Some(false), None, Some(&cfg)),
|
||
"write=false should force read-only"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn falls_back_to_config_when_request_absent() {
|
||
let cfg = make_config(true);
|
||
assert!(resolve_agent_read_only(None, None, Some(&cfg)));
|
||
}
|
||
|
||
#[test]
|
||
fn defaults_to_false_without_config() {
|
||
assert!(!resolve_agent_read_only(None, None, None));
|
||
}
|
||
}
|
||
|
||
fn parse_container_exec_arguments(
|
||
arguments: String,
|
||
sess: &Session,
|
||
call_id: &str,
|
||
) -> Result<ExecParams, Box<ResponseInputItem>> {
|
||
// parse command
|
||
match serde_json::from_str::<ShellToolCallParams>(&arguments) {
|
||
Ok(shell_tool_call_params) => Ok(to_exec_params(shell_tool_call_params, sess)),
|
||
Err(e) => {
|
||
// allow model to re-sample
|
||
let output = ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id.to_string(),
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("failed to parse function arguments: {e}"),
|
||
success: None,
|
||
},
|
||
};
|
||
Err(Box::new(output))
|
||
}
|
||
}
|
||
}
|
||
|
||
pub struct ExecInvokeArgs<'a> {
|
||
pub params: ExecParams,
|
||
pub sandbox_type: SandboxType,
|
||
pub sandbox_policy: &'a SandboxPolicy,
|
||
pub sandbox_cwd: &'a std::path::Path,
|
||
pub code_linux_sandbox_exe: &'a Option<PathBuf>,
|
||
pub stdout_stream: Option<StdoutStream>,
|
||
}
|
||
|
||
fn maybe_run_with_user_profile(params: ExecParams, sess: &Session) -> ExecParams {
|
||
if sess.shell_environment_policy.use_profile {
|
||
let maybe_command = sess
|
||
.user_shell
|
||
.format_default_shell_invocation(params.command.clone());
|
||
if let Some(command) = maybe_command {
|
||
return ExecParams { command, ..params };
|
||
}
|
||
}
|
||
params
|
||
}
|
||
|
||
fn agent_tool_failure(ctx: &ToolCallCtx, message: impl Into<String>) -> ResponseInputItem {
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: ctx.call_id.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: message.into(),
|
||
success: Some(false),
|
||
},
|
||
}
|
||
}
|
||
|
||
pub(crate) async fn handle_agent_tool(
|
||
sess: &Session,
|
||
ctx: &ToolCallCtx,
|
||
arguments: String,
|
||
) -> ResponseInputItem {
|
||
let parsed = serde_json::from_str::<AgentToolRequest>(&arguments);
|
||
let mut req = match parsed {
|
||
Ok(req) => req,
|
||
Err(e) => {
|
||
return agent_tool_failure(ctx, format!("Invalid agent arguments: {}", e));
|
||
}
|
||
};
|
||
|
||
let action = req.action.to_ascii_lowercase();
|
||
match action.as_str() {
|
||
"create" => {
|
||
let mut create_opts = match req.create.take() {
|
||
Some(opts) => opts,
|
||
None => {
|
||
return agent_tool_failure(
|
||
ctx,
|
||
"action=create requires a 'create' object",
|
||
);
|
||
}
|
||
};
|
||
|
||
let task = match create_opts.task.take() {
|
||
Some(task) if !task.trim().is_empty() => task,
|
||
_ => {
|
||
return agent_tool_failure(
|
||
ctx,
|
||
"action=create requires a non-empty 'create.task' field",
|
||
);
|
||
}
|
||
};
|
||
|
||
let models = std::mem::take(&mut create_opts.models);
|
||
let context = create_opts.context.take();
|
||
let output = create_opts.output.take();
|
||
let files = create_opts.files.take();
|
||
let write = create_opts.write.take();
|
||
let read_only = create_opts.read_only.take();
|
||
let mut normalized_name = normalize_agent_name(create_opts.name.take());
|
||
if normalized_name.is_none() {
|
||
normalized_name = derive_agent_name_from_task(&task);
|
||
}
|
||
|
||
let run_params = RunAgentParams {
|
||
task: task.clone(),
|
||
models: models.clone(),
|
||
context: context.clone(),
|
||
output: output.clone(),
|
||
files: files.clone(),
|
||
write,
|
||
read_only,
|
||
name: normalized_name.clone(),
|
||
};
|
||
|
||
let mut create_event = serde_json::Map::new();
|
||
create_event.insert("task".to_string(), serde_json::Value::String(task));
|
||
if !models.is_empty() {
|
||
create_event.insert(
|
||
"models".to_string(),
|
||
serde_json::Value::Array(
|
||
models
|
||
.iter()
|
||
.cloned()
|
||
.map(serde_json::Value::String)
|
||
.collect(),
|
||
),
|
||
);
|
||
}
|
||
if let Some(ref ctx_str) = context {
|
||
if !ctx_str.is_empty() {
|
||
create_event.insert("context".to_string(), serde_json::Value::String(ctx_str.clone()));
|
||
}
|
||
}
|
||
if let Some(ref output_str) = output {
|
||
if !output_str.is_empty() {
|
||
create_event.insert("output".to_string(), serde_json::Value::String(output_str.clone()));
|
||
}
|
||
}
|
||
if let Some(ref files_vec) = files {
|
||
if !files_vec.is_empty() {
|
||
create_event.insert(
|
||
"files".to_string(),
|
||
serde_json::Value::Array(
|
||
files_vec
|
||
.iter()
|
||
.cloned()
|
||
.map(serde_json::Value::String)
|
||
.collect(),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
if let Some(flag) = write {
|
||
create_event.insert("write".to_string(), serde_json::Value::Bool(flag));
|
||
}
|
||
if let Some(flag) = read_only {
|
||
create_event.insert("read_only".to_string(), serde_json::Value::Bool(flag));
|
||
}
|
||
if let Some(ref name_str) = normalized_name {
|
||
if !name_str.is_empty() {
|
||
create_event.insert("name".to_string(), serde_json::Value::String(name_str.clone()));
|
||
}
|
||
}
|
||
|
||
let mut event_root = serde_json::Map::new();
|
||
event_root.insert("action".to_string(), serde_json::Value::String("create".to_string()));
|
||
event_root.insert("create".to_string(), serde_json::Value::Object(create_event));
|
||
let event_payload = serde_json::Value::Object(event_root);
|
||
|
||
match serde_json::to_string(&run_params) {
|
||
Ok(json) => handle_run_agent(sess, ctx, json, event_payload).await,
|
||
Err(e) => agent_tool_failure(ctx, format!("Failed to encode create arguments: {}", e)),
|
||
}
|
||
}
|
||
"status" => {
|
||
let mut status_opts = match req.status.take() {
|
||
Some(opts) => opts,
|
||
None => {
|
||
return agent_tool_failure(
|
||
ctx,
|
||
"action=status requires a 'status' object",
|
||
);
|
||
}
|
||
};
|
||
let agent_id = match status_opts.agent_id.take() {
|
||
Some(id) if !id.trim().is_empty() => id,
|
||
_ => {
|
||
return agent_tool_failure(
|
||
ctx,
|
||
"action=status requires 'status.agent_id'",
|
||
);
|
||
}
|
||
};
|
||
let batch_id = match status_opts.batch_id.take() {
|
||
Some(batch) if !batch.trim().is_empty() => batch,
|
||
_ => {
|
||
return agent_tool_failure(
|
||
ctx,
|
||
"action=status requires 'status.batch_id'",
|
||
);
|
||
}
|
||
};
|
||
let params = CheckAgentStatusParams {
|
||
agent_id: agent_id.clone(),
|
||
batch_id: batch_id.clone(),
|
||
};
|
||
let mut status_event = serde_json::Map::new();
|
||
status_event.insert("agent_id".to_string(), serde_json::Value::String(agent_id));
|
||
status_event.insert("batch_id".to_string(), serde_json::Value::String(batch_id));
|
||
let mut status_event_root = serde_json::Map::new();
|
||
status_event_root.insert("action".to_string(), serde_json::Value::String("status".to_string()));
|
||
status_event_root.insert("status".to_string(), serde_json::Value::Object(status_event));
|
||
let status_event_payload = serde_json::Value::Object(status_event_root);
|
||
match serde_json::to_string(¶ms) {
|
||
Ok(json) => handle_check_agent_status(sess, ctx, json, status_event_payload).await,
|
||
Err(e) => agent_tool_failure(ctx, format!("Failed to encode status arguments: {}", e)),
|
||
}
|
||
}
|
||
"result" => {
|
||
let mut result_opts = match req.result.take() {
|
||
Some(opts) => opts,
|
||
None => {
|
||
return agent_tool_failure(
|
||
ctx,
|
||
"action=result requires a 'result' object",
|
||
);
|
||
}
|
||
};
|
||
let agent_id = match result_opts.agent_id.take() {
|
||
Some(id) if !id.trim().is_empty() => id,
|
||
_ => {
|
||
return agent_tool_failure(
|
||
ctx,
|
||
"action=result requires 'result.agent_id'",
|
||
);
|
||
}
|
||
};
|
||
let batch_id = match result_opts.batch_id.take() {
|
||
Some(batch) if !batch.trim().is_empty() => batch,
|
||
_ => {
|
||
return agent_tool_failure(
|
||
ctx,
|
||
"action=result requires 'result.batch_id'",
|
||
);
|
||
}
|
||
};
|
||
let params = GetAgentResultParams {
|
||
agent_id: agent_id.clone(),
|
||
batch_id: batch_id.clone(),
|
||
};
|
||
let mut result_event = serde_json::Map::new();
|
||
result_event.insert("agent_id".to_string(), serde_json::Value::String(agent_id));
|
||
result_event.insert("batch_id".to_string(), serde_json::Value::String(batch_id));
|
||
let mut result_event_root = serde_json::Map::new();
|
||
result_event_root.insert("action".to_string(), serde_json::Value::String("result".to_string()));
|
||
result_event_root.insert("result".to_string(), serde_json::Value::Object(result_event));
|
||
let result_event_payload = serde_json::Value::Object(result_event_root);
|
||
match serde_json::to_string(¶ms) {
|
||
Ok(json) => handle_get_agent_result(sess, ctx, json, result_event_payload).await,
|
||
Err(e) => agent_tool_failure(ctx, format!("Failed to encode result arguments: {}", e)),
|
||
}
|
||
}
|
||
"cancel" => {
|
||
let mut cancel_opts = match req.cancel.take() {
|
||
Some(opts) => opts,
|
||
None => {
|
||
return agent_tool_failure(
|
||
ctx,
|
||
"action=cancel requires a 'cancel' object",
|
||
);
|
||
}
|
||
};
|
||
let cancel_agent_id = cancel_opts.agent_id.clone();
|
||
let cancel_batch_id = match cancel_opts.batch_id.take() {
|
||
Some(batch) if !batch.trim().is_empty() => batch,
|
||
_ => {
|
||
return agent_tool_failure(
|
||
ctx,
|
||
"action=cancel requires 'cancel.batch_id'",
|
||
);
|
||
}
|
||
};
|
||
let params = CancelAgentParams {
|
||
agent_id: cancel_opts.agent_id.take(),
|
||
batch_id: Some(cancel_batch_id.clone()),
|
||
};
|
||
let mut cancel_event = serde_json::Map::new();
|
||
if let Some(id) = cancel_agent_id {
|
||
cancel_event.insert("agent_id".to_string(), serde_json::Value::String(id));
|
||
}
|
||
cancel_event.insert("batch_id".to_string(), serde_json::Value::String(cancel_batch_id));
|
||
let mut cancel_event_root = serde_json::Map::new();
|
||
cancel_event_root.insert("action".to_string(), serde_json::Value::String("cancel".to_string()));
|
||
cancel_event_root.insert("cancel".to_string(), serde_json::Value::Object(cancel_event));
|
||
let cancel_event_payload = serde_json::Value::Object(cancel_event_root);
|
||
match serde_json::to_string(¶ms) {
|
||
Ok(json) => handle_cancel_agent(sess, ctx, json, cancel_event_payload).await,
|
||
Err(e) => agent_tool_failure(ctx, format!("Failed to encode cancel arguments: {}", e)),
|
||
}
|
||
}
|
||
"wait" => {
|
||
let mut wait_opts = match req.wait.take() {
|
||
Some(opts) => opts,
|
||
None => {
|
||
return agent_tool_failure(
|
||
ctx,
|
||
"action=wait requires a 'wait' object",
|
||
);
|
||
}
|
||
};
|
||
let wait_agent_id = wait_opts.agent_id.clone();
|
||
let wait_batch_id = match wait_opts.batch_id.take() {
|
||
Some(batch) if !batch.trim().is_empty() => batch,
|
||
_ => {
|
||
return agent_tool_failure(
|
||
ctx,
|
||
"action=wait requires 'wait.batch_id'",
|
||
);
|
||
}
|
||
};
|
||
let wait_timeout = wait_opts.timeout_seconds;
|
||
let wait_return_all = wait_opts.return_all;
|
||
let params = WaitForAgentParams {
|
||
agent_id: wait_opts.agent_id.take(),
|
||
batch_id: Some(wait_batch_id.clone()),
|
||
timeout_seconds: wait_timeout,
|
||
return_all: wait_return_all,
|
||
};
|
||
let mut wait_event = serde_json::Map::new();
|
||
if let Some(id) = wait_agent_id {
|
||
wait_event.insert("agent_id".to_string(), serde_json::Value::String(id));
|
||
}
|
||
wait_event.insert("batch_id".to_string(), serde_json::Value::String(wait_batch_id));
|
||
if let Some(timeout) = wait_timeout {
|
||
wait_event.insert("timeout_seconds".to_string(), serde_json::Value::from(timeout));
|
||
}
|
||
if let Some(return_all) = wait_return_all {
|
||
wait_event.insert("return_all".to_string(), serde_json::Value::Bool(return_all));
|
||
}
|
||
let mut wait_event_root = serde_json::Map::new();
|
||
wait_event_root.insert("action".to_string(), serde_json::Value::String("wait".to_string()));
|
||
wait_event_root.insert("wait".to_string(), serde_json::Value::Object(wait_event));
|
||
let wait_event_payload = serde_json::Value::Object(wait_event_root);
|
||
match serde_json::to_string(¶ms) {
|
||
Ok(json) => handle_wait_for_agent(sess, ctx, json, wait_event_payload).await,
|
||
Err(e) => agent_tool_failure(ctx, format!("Failed to encode wait arguments: {}", e)),
|
||
}
|
||
}
|
||
"list" => {
|
||
let mut list_opts = match req.list.take() {
|
||
Some(opts) => opts,
|
||
None => {
|
||
return agent_tool_failure(
|
||
ctx,
|
||
"action=list requires a 'list' object",
|
||
);
|
||
}
|
||
};
|
||
let status_filter = list_opts.status_filter.take();
|
||
let batch_id = match list_opts.batch_id.take() {
|
||
Some(batch) if !batch.trim().is_empty() => batch,
|
||
_ => {
|
||
return agent_tool_failure(
|
||
ctx,
|
||
"action=list requires 'list.batch_id'",
|
||
);
|
||
}
|
||
};
|
||
let recent_only = list_opts.recent_only;
|
||
let params = ListAgentsParams {
|
||
status_filter: status_filter.clone(),
|
||
batch_id: Some(batch_id.clone()),
|
||
recent_only,
|
||
};
|
||
let mut list_event = serde_json::Map::new();
|
||
if let Some(ref status) = status_filter {
|
||
if !status.is_empty() {
|
||
list_event.insert("status_filter".to_string(), serde_json::Value::String(status.clone()));
|
||
}
|
||
}
|
||
list_event.insert("batch_id".to_string(), serde_json::Value::String(batch_id));
|
||
if let Some(recent) = recent_only {
|
||
list_event.insert("recent_only".to_string(), serde_json::Value::Bool(recent));
|
||
}
|
||
let mut list_event_root = serde_json::Map::new();
|
||
list_event_root.insert("action".to_string(), serde_json::Value::String("list".to_string()));
|
||
list_event_root.insert("list".to_string(), serde_json::Value::Object(list_event));
|
||
let list_event_payload = serde_json::Value::Object(list_event_root);
|
||
match serde_json::to_string(¶ms) {
|
||
Ok(json) => handle_list_agents(sess, ctx, json, list_event_payload).await,
|
||
Err(e) => agent_tool_failure(ctx, format!("Failed to encode list arguments: {}", e)),
|
||
}
|
||
}
|
||
other => agent_tool_failure(ctx, format!("Unsupported agent action: {}", other)),
|
||
}
|
||
}
|
||
|
||
pub(crate) async fn handle_run_agent(
|
||
sess: &Session,
|
||
ctx: &ToolCallCtx,
|
||
arguments: String,
|
||
event_payload: serde_json::Value,
|
||
) -> ResponseInputItem {
|
||
let arguments_clone = arguments.clone();
|
||
let call_id_clone = ctx.call_id.clone();
|
||
let generated_batch_id = Uuid::new_v4().to_string();
|
||
let payload_with_batch = match event_payload {
|
||
serde_json::Value::Object(mut map) => {
|
||
map.insert(
|
||
"batch_id".to_string(),
|
||
serde_json::Value::String(generated_batch_id.clone()),
|
||
);
|
||
serde_json::Value::Object(map)
|
||
}
|
||
other => other,
|
||
};
|
||
let closure_batch_id = generated_batch_id.clone();
|
||
execute_custom_tool(
|
||
sess,
|
||
ctx,
|
||
"agent".to_string(),
|
||
Some(payload_with_batch),
|
||
move || async move {
|
||
let batch_id = closure_batch_id.clone();
|
||
match serde_json::from_str::<RunAgentParams>(&arguments_clone) {
|
||
Ok(mut params) => {
|
||
let trimmed_task = params.task.trim().to_string();
|
||
let word_count = trimmed_task
|
||
.split_whitespace()
|
||
.filter(|segment| !segment.is_empty())
|
||
.count();
|
||
|
||
if trimmed_task.is_empty() || word_count < 4 {
|
||
let guidance = format!(
|
||
"⚠️ Agent prompt too short: give the manager more context (at least a full sentence) before running agents. Current prompt: \"{}\".",
|
||
trimmed_task
|
||
);
|
||
let req = sess.current_request_ordinal();
|
||
let order = sess.background_order_for_ctx(ctx, req);
|
||
sess
|
||
.notify_background_event_with_order(&ctx.sub_id, order, guidance.clone())
|
||
.await;
|
||
|
||
let response = serde_json::json!({
|
||
"status": "blocked",
|
||
"reason": "prompt_too_short",
|
||
"message": guidance,
|
||
});
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: response.to_string(),
|
||
success: Some(false),
|
||
},
|
||
};
|
||
}
|
||
|
||
let mut manager = AGENT_MANAGER.write().await;
|
||
let mut agent_name = params.name.clone();
|
||
if agent_name.is_none() {
|
||
if let Some(fallback) = derive_agent_name_from_task(trimmed_task.as_str()) {
|
||
agent_name = Some(fallback.clone());
|
||
params.name = Some(fallback);
|
||
}
|
||
}
|
||
|
||
// Collect requested models from the `models` field.
|
||
let raw_models: Vec<String> = params.models.clone();
|
||
|
||
// Split comma-delimited strings, trim whitespace, and deduplicate case-insensitively.
|
||
let mut seen_models = HashSet::new();
|
||
let mut models: Vec<String> = Vec::new();
|
||
for entry in raw_models {
|
||
for candidate in entry.split(',') {
|
||
let trimmed = candidate.trim();
|
||
if trimmed.is_empty() {
|
||
continue;
|
||
}
|
||
let dedupe_key = trimmed.to_lowercase();
|
||
if seen_models.insert(dedupe_key) {
|
||
models.push(trimmed.to_string());
|
||
}
|
||
}
|
||
}
|
||
|
||
if models.is_empty() {
|
||
if sess.tools_config.agent_model_allowed_values.is_empty() {
|
||
models.push("code".to_string());
|
||
} else {
|
||
models.extend(
|
||
sess
|
||
.tools_config
|
||
.agent_model_allowed_values
|
||
.iter()
|
||
.cloned(),
|
||
);
|
||
}
|
||
}
|
||
|
||
models.sort_by(|a, b| a.to_ascii_lowercase().cmp(&b.to_ascii_lowercase()));
|
||
models.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
|
||
|
||
// Helper: derive the command to check for a given model/config pair.
|
||
fn resolve_command_for_check(model: &str, cfg: Option<&crate::config_types::AgentConfig>) -> (String, bool) {
|
||
if let Some(c) = cfg { return (c.command.clone(), false); }
|
||
let m = model.to_lowercase();
|
||
match m.as_str() {
|
||
// Built-in: always available via current_exe fallback.
|
||
"code" | "codex" => (m, true),
|
||
// External CLIs expected to be in PATH
|
||
"claude" => ("claude".to_string(), false),
|
||
"gemini" => ("gemini".to_string(), false),
|
||
"qwen" => ("qwen".to_string(), false),
|
||
// Cloud agent: treat as built-in via current executable (code cloud submit)
|
||
"cloud" => ("cloud".to_string(), true),
|
||
_ => (m, false),
|
||
}
|
||
}
|
||
|
||
// Helper: PATH lookup to determine if a command exists.
|
||
fn command_exists(cmd: &str) -> bool {
|
||
// Absolute/relative path with separators: verify it points to a file.
|
||
if cmd.contains(std::path::MAIN_SEPARATOR) || cmd.contains('/') || cmd.contains('\\') {
|
||
return std::fs::metadata(cmd).map(|m| m.is_file()).unwrap_or(false);
|
||
}
|
||
|
||
#[cfg(target_os = "windows")]
|
||
{
|
||
return which::which(cmd).map(|p| p.is_file()).unwrap_or(false);
|
||
}
|
||
|
||
#[cfg(not(target_os = "windows"))]
|
||
{
|
||
use std::os::unix::fs::PermissionsExt;
|
||
let Some(path_os) = std::env::var_os("PATH") else { return false; };
|
||
for dir in std::env::split_paths(&path_os) {
|
||
if dir.as_os_str().is_empty() { continue; }
|
||
let candidate = dir.join(cmd);
|
||
if let Ok(meta) = std::fs::metadata(&candidate) {
|
||
if meta.is_file() {
|
||
let mode = meta.permissions().mode();
|
||
if mode & 0o111 != 0 { return true; }
|
||
}
|
||
}
|
||
}
|
||
false
|
||
}
|
||
}
|
||
|
||
let multi_model = models.len() > 1;
|
||
let display_label_for = |model: &str| -> String {
|
||
agent_name
|
||
.as_ref()
|
||
.and_then(|value| {
|
||
if value.is_empty() {
|
||
None
|
||
} else if multi_model {
|
||
Some(format!("{} ({})", value, model))
|
||
} else {
|
||
Some(value.to_string())
|
||
}
|
||
})
|
||
.unwrap_or_else(|| model.to_string())
|
||
};
|
||
|
||
let mut agent_ids = Vec::new();
|
||
let mut agent_labels: Vec<(String, String)> = Vec::new();
|
||
let mut skipped: Vec<String> = Vec::new();
|
||
for model in models {
|
||
let model_key = model.to_lowercase();
|
||
// Check if this model is configured and enabled
|
||
let agent_config = sess.agents.iter().find(|a| {
|
||
a.name.to_lowercase() == model_key
|
||
|| a.command.to_lowercase() == model_key
|
||
});
|
||
|
||
if let Some(config) = agent_config {
|
||
if !config.enabled {
|
||
continue; // Skip disabled agents
|
||
}
|
||
|
||
let (cmd_to_check, is_builtin) = resolve_command_for_check(&model, Some(config));
|
||
if !is_builtin && !command_exists(&cmd_to_check) {
|
||
skipped.push(format!("{} (missing: {})", model, cmd_to_check));
|
||
continue;
|
||
}
|
||
|
||
// Respect explicit read_only flag from the caller; otherwise fall back to the config default.
|
||
let read_only = resolve_agent_read_only(
|
||
params.write,
|
||
params.read_only,
|
||
Some(config),
|
||
);
|
||
|
||
let agent_id = manager
|
||
.create_agent_with_config(
|
||
model.clone(),
|
||
agent_name.clone(),
|
||
params.task.clone(),
|
||
params.context.clone(),
|
||
params.output.clone(),
|
||
params.files.clone().unwrap_or_default(),
|
||
read_only,
|
||
Some(batch_id.clone()),
|
||
config.clone(),
|
||
)
|
||
.await;
|
||
agent_ids.push(agent_id);
|
||
let label = display_label_for(&model);
|
||
agent_labels.push((agent_ids.last().cloned().unwrap(), label));
|
||
} else {
|
||
// Use default configuration for unknown agents
|
||
let (cmd_to_check, is_builtin) = resolve_command_for_check(&model, None);
|
||
if !is_builtin && !command_exists(&cmd_to_check) {
|
||
skipped.push(format!("{} (missing: {})", model, cmd_to_check));
|
||
continue;
|
||
}
|
||
let read_only = resolve_agent_read_only(params.write, params.read_only, None);
|
||
let agent_id = manager
|
||
.create_agent(
|
||
model.clone(),
|
||
agent_name.clone(),
|
||
params.task.clone(),
|
||
params.context.clone(),
|
||
params.output.clone(),
|
||
params.files.clone().unwrap_or_default(),
|
||
read_only,
|
||
Some(batch_id.clone()),
|
||
)
|
||
.await;
|
||
agent_ids.push(agent_id);
|
||
let label = display_label_for(&model);
|
||
agent_labels.push((agent_ids.last().cloned().unwrap(), label));
|
||
}
|
||
}
|
||
|
||
// If nothing runnable remains, fall back to a single built‑in Codex agent.
|
||
if agent_ids.is_empty() {
|
||
let read_only = resolve_agent_read_only(params.write, params.read_only, None);
|
||
let agent_id = manager
|
||
.create_agent(
|
||
"code".to_string(),
|
||
agent_name.clone(),
|
||
params.task.clone(),
|
||
params.context.clone(),
|
||
params.output.clone(),
|
||
params.files.clone().unwrap_or_default(),
|
||
read_only,
|
||
Some(batch_id.clone()),
|
||
)
|
||
.await;
|
||
agent_ids.push(agent_id);
|
||
let label = display_label_for("code");
|
||
agent_labels.push((agent_ids.last().cloned().unwrap(), label));
|
||
}
|
||
|
||
// Send agent status update event
|
||
drop(manager); // Release the write lock first
|
||
if agent_ids.len() > 0 {
|
||
send_agent_status_update(sess).await;
|
||
}
|
||
|
||
let launch_hint = if agent_ids.len() > 1 {
|
||
let short_batch = short_id(&batch_id);
|
||
let agent_phrase = agent_labels
|
||
.iter()
|
||
.map(|(id, label)| format!("{} [{}]", short_id(id), label))
|
||
.collect::<Vec<_>>()
|
||
.join(", ");
|
||
let first_agent = agent_labels
|
||
.first()
|
||
.map(|(id, _)| id.as_str())
|
||
.unwrap_or(batch_id.as_str());
|
||
format!(
|
||
"🤖 Agent batch {short_batch} started: {agent_phrase}.\nUse `agent {{\"action\":\"wait\",\"wait\":{{\"batch_id\":\"{batch}\",\"return_all\":true}}}}` to wait for all agents, then `agent {{\"action\":\"result\",\"result\":{{\"agent_id\":\"{first_agent}\"}}}}` for a detailed report.",
|
||
batch = batch_id,
|
||
)
|
||
} else {
|
||
let (single_id, single_model) = agent_labels
|
||
.first()
|
||
.map(|(id, model)| (id.as_str(), model.as_str()))
|
||
.unwrap();
|
||
let short_batch = short_id(&batch_id);
|
||
format!(
|
||
"🤖 Agent batch {short_batch} started with {model}. Use `agent {{\"action\":\"wait\",\"wait\":{{\"batch_id\":\"{batch}\",\"return_all\":true}}}}` to follow progress, or `agent {{\"action\":\"result\",\"result\":{{\"agent_id\":\"{agent}\"}}}}` when it finishes.",
|
||
model = single_model,
|
||
batch = batch_id,
|
||
agent = single_id,
|
||
)
|
||
};
|
||
|
||
let mut response_map = serde_json::Map::new();
|
||
response_map.insert(
|
||
"batch_id".to_string(),
|
||
serde_json::Value::String(batch_id.clone()),
|
||
);
|
||
response_map.insert(
|
||
"agent_ids".to_string(),
|
||
serde_json::Value::Array(
|
||
agent_ids
|
||
.iter()
|
||
.cloned()
|
||
.map(serde_json::Value::String)
|
||
.collect(),
|
||
),
|
||
);
|
||
response_map.insert(
|
||
"status".to_string(),
|
||
serde_json::Value::String("started".to_string()),
|
||
);
|
||
let message = if agent_ids.len() > 1 {
|
||
format!("Started {} agents", agent_labels.len())
|
||
} else {
|
||
"Agent started successfully".to_string()
|
||
};
|
||
response_map.insert(
|
||
"message".to_string(),
|
||
serde_json::Value::String(message),
|
||
);
|
||
response_map.insert(
|
||
"next_steps".to_string(),
|
||
serde_json::Value::String(launch_hint.clone()),
|
||
);
|
||
if agent_ids.len() == 1 {
|
||
if let Some(first) = agent_ids.first() {
|
||
response_map.insert(
|
||
"agent_id".to_string(),
|
||
serde_json::Value::String(first.clone()),
|
||
);
|
||
}
|
||
}
|
||
if skipped.is_empty() {
|
||
response_map.insert("skipped".to_string(), serde_json::Value::Null);
|
||
} else {
|
||
response_map.insert(
|
||
"skipped".to_string(),
|
||
serde_json::Value::Array(
|
||
skipped
|
||
.into_iter()
|
||
.map(serde_json::Value::String)
|
||
.collect(),
|
||
),
|
||
);
|
||
}
|
||
let response = serde_json::Value::Object(response_map);
|
||
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: response.to_string(),
|
||
success: Some(true),
|
||
},
|
||
}
|
||
}
|
||
Err(e) => ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Invalid agent arguments: {}", e),
|
||
success: None,
|
||
},
|
||
},
|
||
}
|
||
}
|
||
).await
|
||
}
|
||
|
||
fn short_id(id: &str) -> String {
|
||
id.chars().take(8).collect()
|
||
}
|
||
|
||
fn derive_agent_name_from_task(task: &str) -> Option<String> {
|
||
let trimmed = task.trim();
|
||
if trimmed.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
let first_clause = trimmed
|
||
.split(|c: char| matches!(c, '.' | '!' | '?' | '\n'))
|
||
.find(|part| !part.trim().is_empty())
|
||
.unwrap_or(trimmed)
|
||
.trim();
|
||
|
||
let words: Vec<&str> = first_clause.split_whitespace().take(5).collect();
|
||
if words.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
normalize_agent_name(Some(words.join(" ")))
|
||
}
|
||
|
||
async fn handle_check_agent_status(
|
||
sess: &Session,
|
||
ctx: &ToolCallCtx,
|
||
arguments: String,
|
||
event_payload: serde_json::Value,
|
||
) -> ResponseInputItem {
|
||
let arguments_clone = arguments.clone();
|
||
let call_id_clone = ctx.call_id.clone();
|
||
execute_custom_tool(
|
||
sess,
|
||
ctx,
|
||
"agent".to_string(),
|
||
Some(event_payload),
|
||
|| async move {
|
||
match serde_json::from_str::<CheckAgentStatusParams>(&arguments_clone) {
|
||
Ok(params) => {
|
||
let manager = AGENT_MANAGER.read().await;
|
||
|
||
if let Some(agent) = manager.get_agent(¶ms.agent_id) {
|
||
match agent.batch_id.as_deref() {
|
||
Some(batch) if batch == params.batch_id => {}
|
||
_ => {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!(
|
||
"Agent {} does not belong to batch {}",
|
||
params.agent_id, params.batch_id
|
||
),
|
||
success: Some(false),
|
||
},
|
||
};
|
||
}
|
||
}
|
||
|
||
// Limit progress in the response; write full progress to file if large
|
||
let max_progress_lines = 50usize;
|
||
let total_progress = agent.progress.len();
|
||
let progress_preview: Vec<String> = if total_progress > max_progress_lines {
|
||
agent
|
||
.progress
|
||
.iter()
|
||
.skip(total_progress - max_progress_lines)
|
||
.cloned()
|
||
.collect()
|
||
} else {
|
||
agent.progress.clone()
|
||
};
|
||
|
||
let mut progress_file: Option<String> = None;
|
||
if total_progress > max_progress_lines {
|
||
let cwd = sess.get_cwd().to_path_buf();
|
||
drop(manager);
|
||
let dir = match ensure_agent_dir(&cwd, &agent.id) {
|
||
Ok(d) => d,
|
||
Err(e) => {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Failed to prepare agent progress file: {}", e),
|
||
success: Some(false),
|
||
},
|
||
};
|
||
}
|
||
};
|
||
// Re-acquire manager to get fresh progress after potential delay
|
||
let manager = AGENT_MANAGER.read().await;
|
||
if let Some(agent) = manager.get_agent(¶ms.agent_id) {
|
||
let joined = agent.progress.join("\n");
|
||
match write_agent_file(&dir, "progress.log", &joined) {
|
||
Ok(p) => progress_file = Some(p.display().to_string()),
|
||
Err(e) => {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Failed to write progress file: {}", e),
|
||
success: Some(false),
|
||
},
|
||
};
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
drop(manager);
|
||
}
|
||
|
||
let response = serde_json::json!({
|
||
"agent_id": params.agent_id,
|
||
"name": agent.name,
|
||
"status": agent.status,
|
||
"model": agent.model,
|
||
"batch_id": agent.batch_id,
|
||
"created_at": agent.created_at,
|
||
"started_at": agent.started_at,
|
||
"completed_at": agent.completed_at,
|
||
"progress_preview": progress_preview,
|
||
"progress_total": total_progress,
|
||
"progress_file": progress_file,
|
||
"error": agent.error,
|
||
"worktree_path": agent.worktree_path,
|
||
"branch_name": agent.branch_name,
|
||
});
|
||
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: response.to_string(),
|
||
success: Some(true),
|
||
},
|
||
}
|
||
} else {
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Agent not found: {}", params.agent_id),
|
||
success: Some(false),
|
||
},
|
||
}
|
||
}
|
||
}
|
||
Err(e) => ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Invalid agent arguments for action=status: {}", e),
|
||
success: None,
|
||
},
|
||
},
|
||
}
|
||
},
|
||
).await
|
||
}
|
||
|
||
async fn handle_get_agent_result(
|
||
sess: &Session,
|
||
ctx: &ToolCallCtx,
|
||
arguments: String,
|
||
event_payload: serde_json::Value,
|
||
) -> ResponseInputItem {
|
||
let arguments_clone = arguments.clone();
|
||
let call_id_clone = ctx.call_id.clone();
|
||
execute_custom_tool(
|
||
sess,
|
||
ctx,
|
||
"agent".to_string(),
|
||
Some(event_payload),
|
||
|| async move {
|
||
match serde_json::from_str::<GetAgentResultParams>(&arguments_clone) {
|
||
Ok(params) => {
|
||
let manager = AGENT_MANAGER.read().await;
|
||
|
||
if let Some(agent) = manager.get_agent(¶ms.agent_id) {
|
||
match agent.batch_id.as_deref() {
|
||
Some(batch) if batch == params.batch_id => {}
|
||
_ => {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!(
|
||
"Agent {} does not belong to batch {}",
|
||
params.agent_id, params.batch_id
|
||
),
|
||
success: Some(false),
|
||
},
|
||
};
|
||
}
|
||
}
|
||
let cwd = sess.get_cwd().to_path_buf();
|
||
let dir = match ensure_agent_dir(&cwd, ¶ms.agent_id) {
|
||
Ok(d) => d,
|
||
Err(e) => {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Failed to prepare agent output dir: {}", e),
|
||
success: Some(false),
|
||
},
|
||
};
|
||
}
|
||
};
|
||
|
||
match agent.status {
|
||
AgentStatus::Completed => {
|
||
let output_text = agent.result.unwrap_or_default();
|
||
let (preview, total_lines) = preview_first_n_lines(&output_text, 500);
|
||
let file_path = match write_agent_file(&dir, "result.txt", &output_text) {
|
||
Ok(p) => p.display().to_string(),
|
||
Err(e) => format!("Failed to write result file: {}", e),
|
||
};
|
||
let response = serde_json::json!({
|
||
"agent_id": params.agent_id,
|
||
"batch_id": params.batch_id.clone(),
|
||
"status": agent.status,
|
||
"output_preview": preview,
|
||
"output_total_lines": total_lines,
|
||
"output_file": file_path,
|
||
});
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: response.to_string(),
|
||
success: Some(true),
|
||
},
|
||
}
|
||
}
|
||
AgentStatus::Failed => {
|
||
let error_text = agent.error.unwrap_or_else(|| "Unknown error".to_string());
|
||
let (preview, total_lines) = preview_first_n_lines(&error_text, 500);
|
||
let file_path = match write_agent_file(&dir, "error.txt", &error_text) {
|
||
Ok(p) => p.display().to_string(),
|
||
Err(e) => format!("Failed to write error file: {}", e),
|
||
};
|
||
let response = serde_json::json!({
|
||
"agent_id": params.agent_id,
|
||
"batch_id": params.batch_id.clone(),
|
||
"status": agent.status,
|
||
"error_preview": preview,
|
||
"error_total_lines": total_lines,
|
||
"error_file": file_path,
|
||
});
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: response.to_string(),
|
||
success: Some(false),
|
||
},
|
||
}
|
||
}
|
||
_ => ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!(
|
||
"Agent is still {}: cannot get result yet",
|
||
serde_json::to_string(&agent.status)
|
||
.unwrap_or_else(|_| "running".to_string())
|
||
),
|
||
success: Some(false),
|
||
},
|
||
},
|
||
}
|
||
} else {
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Agent not found: {}", params.agent_id),
|
||
success: Some(false),
|
||
},
|
||
}
|
||
}
|
||
}
|
||
Err(e) => ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Invalid agent arguments for action=result: {}", e),
|
||
success: None,
|
||
},
|
||
},
|
||
}
|
||
},
|
||
).await
|
||
}
|
||
|
||
async fn handle_cancel_agent(
|
||
sess: &Session,
|
||
ctx: &ToolCallCtx,
|
||
arguments: String,
|
||
event_payload: serde_json::Value,
|
||
) -> ResponseInputItem {
|
||
let arguments_clone = arguments.clone();
|
||
let call_id_clone = ctx.call_id.clone();
|
||
execute_custom_tool(
|
||
sess,
|
||
ctx,
|
||
"agent".to_string(),
|
||
Some(event_payload),
|
||
|| async move {
|
||
match serde_json::from_str::<CancelAgentParams>(&arguments_clone) {
|
||
Ok(params) => {
|
||
let mut manager = AGENT_MANAGER.write().await;
|
||
|
||
if let Some(agent_id) = params.agent_id {
|
||
let batch_id = match params.batch_id.as_ref() {
|
||
Some(batch) => batch,
|
||
None => {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: "action=cancel requires 'cancel.batch_id'".to_string(),
|
||
success: Some(false),
|
||
},
|
||
};
|
||
}
|
||
};
|
||
if let Some(agent) = manager.get_agent(&agent_id) {
|
||
if agent.batch_id.as_deref() != Some(batch_id.as_str()) {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!(
|
||
"Agent {} does not belong to batch {}",
|
||
agent_id, batch_id
|
||
),
|
||
success: Some(false),
|
||
},
|
||
};
|
||
}
|
||
}
|
||
if manager.cancel_agent(&agent_id).await {
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Agent {} cancelled", agent_id),
|
||
success: Some(true),
|
||
},
|
||
}
|
||
} else {
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Failed to cancel agent {}", agent_id),
|
||
success: Some(false),
|
||
},
|
||
}
|
||
}
|
||
} else if let Some(batch_id) = params.batch_id {
|
||
let count = manager.cancel_batch(&batch_id).await;
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Cancelled {} agents in batch {}", count, batch_id),
|
||
success: Some(true),
|
||
},
|
||
}
|
||
} else {
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: "Either agent_id or batch_id must be provided".to_string(),
|
||
success: Some(false),
|
||
},
|
||
}
|
||
}
|
||
}
|
||
Err(e) => ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Invalid agent arguments for action=cancel: {}", e),
|
||
success: None,
|
||
},
|
||
},
|
||
}
|
||
},
|
||
).await
|
||
}
|
||
|
||
async fn handle_wait_for_agent(
|
||
sess: &Session,
|
||
ctx: &ToolCallCtx,
|
||
arguments: String,
|
||
event_payload: serde_json::Value,
|
||
) -> ResponseInputItem {
|
||
let arguments_clone = arguments.clone();
|
||
let call_id_clone = ctx.call_id.clone();
|
||
execute_custom_tool(
|
||
sess,
|
||
ctx,
|
||
"agent".to_string(),
|
||
Some(event_payload),
|
||
|| async move {
|
||
let (initial_wait_epoch, _) = sess.wait_interrupt_snapshot();
|
||
match serde_json::from_str::<WaitForAgentParams>(&arguments_clone) {
|
||
Ok(params) => {
|
||
let batch_id = match params.batch_id.as_ref() {
|
||
Some(batch) if !batch.trim().is_empty() => batch.clone(),
|
||
_ => {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: "action=wait requires 'wait.batch_id'".to_string(),
|
||
success: Some(false),
|
||
},
|
||
};
|
||
}
|
||
};
|
||
let timeout = std::time::Duration::from_secs(
|
||
params.timeout_seconds.unwrap_or(300).min(600),
|
||
);
|
||
let start = std::time::Instant::now();
|
||
|
||
loop {
|
||
if start.elapsed() > timeout {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: "Timeout waiting for agent completion".to_string(),
|
||
success: Some(false),
|
||
},
|
||
};
|
||
}
|
||
|
||
let manager = AGENT_MANAGER.read().await;
|
||
|
||
if let Some(agent_id) = ¶ms.agent_id {
|
||
if let Some(agent) = manager.get_agent(agent_id) {
|
||
match agent.batch_id.as_deref() {
|
||
Some(batch) if batch == batch_id => {}
|
||
_ => {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!(
|
||
"Agent {} does not belong to batch {}",
|
||
agent_id, batch_id
|
||
),
|
||
success: Some(false),
|
||
},
|
||
};
|
||
}
|
||
}
|
||
if matches!(
|
||
agent.status,
|
||
AgentStatus::Completed | AgentStatus::Failed | AgentStatus::Cancelled
|
||
) {
|
||
// Include output/error preview and file path
|
||
// Avoid holding manager lock during filesystem I/O
|
||
drop(manager);
|
||
let cwd = sess.get_cwd().to_path_buf();
|
||
let dir = match ensure_agent_dir(&cwd, &agent.id) {
|
||
Ok(d) => d,
|
||
Err(e) => {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Failed to prepare agent output dir: {}", e),
|
||
success: Some(false),
|
||
},
|
||
};
|
||
}
|
||
};
|
||
let (preview_key, file_key, preview, file_path, total_lines) = match agent.status {
|
||
AgentStatus::Completed => {
|
||
let text = agent.result.clone().unwrap_or_default();
|
||
let (p, total) = preview_first_n_lines(&text, 500);
|
||
let fp = write_agent_file(&dir, "result.txt", &text)
|
||
.map(|p| p.display().to_string())
|
||
.unwrap_or_else(|e| format!("Failed to write result file: {}", e));
|
||
("output_preview", "output_file", p, fp, total)
|
||
}
|
||
AgentStatus::Failed => {
|
||
let text = agent.error.clone().unwrap_or_else(|| "Unknown error".to_string());
|
||
let (p, total) = preview_first_n_lines(&text, 500);
|
||
let fp = write_agent_file(&dir, "error.txt", &text)
|
||
.map(|p| p.display().to_string())
|
||
.unwrap_or_else(|e| format!("Failed to write error file: {}", e));
|
||
("error_preview", "error_file", p, fp, total)
|
||
}
|
||
AgentStatus::Cancelled => {
|
||
let text = "Agent cancelled".to_string();
|
||
let (p, total) = preview_first_n_lines(&text, 500);
|
||
let fp = write_agent_file(&dir, "status.txt", &text)
|
||
.map(|p| p.display().to_string())
|
||
.unwrap_or_else(|e| format!("Failed to write status file: {}", e));
|
||
("status_preview", "status_file", p, fp, total)
|
||
}
|
||
_ => unreachable!(),
|
||
};
|
||
|
||
let hint = format!(
|
||
"agent {{\"action\":\"result\",\"result\":{{\"agent_id\":\"{}\",\"batch_id\":\"{}\"}}}}",
|
||
agent.id,
|
||
batch_id
|
||
);
|
||
let mut response = serde_json::json!({
|
||
"agent_id": agent.id,
|
||
"batch_id": batch_id,
|
||
"status": agent.status,
|
||
"wait_time_seconds": start.elapsed().as_secs(),
|
||
"total_lines": total_lines,
|
||
"agent_result_hint": hint,
|
||
"agent_result_params": { "action": "result", "result": { "agent_id": agent.id, "batch_id": batch_id } },
|
||
});
|
||
if let Some(obj) = response.as_object_mut() {
|
||
obj.insert(preview_key.to_string(), serde_json::Value::String(preview));
|
||
obj.insert(file_key.to_string(), serde_json::Value::String(file_path));
|
||
}
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: response.to_string(),
|
||
success: Some(true),
|
||
},
|
||
};
|
||
}
|
||
}
|
||
} else {
|
||
let agents = manager.list_agents(None, Some(batch_id.clone()), false);
|
||
|
||
// Separate terminal vs non-terminal agents
|
||
let completed_agents: Vec<_> = agents
|
||
.iter()
|
||
.filter(|t| {
|
||
matches!(
|
||
t.status,
|
||
AgentStatus::Completed
|
||
| AgentStatus::Failed
|
||
| AgentStatus::Cancelled
|
||
)
|
||
})
|
||
.cloned()
|
||
.collect();
|
||
let any_in_progress = agents.iter().any(|a| {
|
||
matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
|
||
});
|
||
|
||
if params.return_all.unwrap_or(false) {
|
||
// Wait for ALL agents in the batch to reach a terminal state
|
||
if !any_in_progress {
|
||
// Enriched response: include per-agent previews and file paths
|
||
// Avoid holding manager lock during filesystem I/O
|
||
drop(manager);
|
||
let cwd = sess.get_cwd().to_path_buf();
|
||
let mut summaries: Vec<serde_json::Value> = Vec::new();
|
||
for a in &completed_agents {
|
||
let dir = match ensure_agent_dir(&cwd, &a.id) {
|
||
Ok(d) => d,
|
||
Err(e) => {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Failed to prepare agent output dir: {}", e),
|
||
success: Some(false),
|
||
},
|
||
};
|
||
}
|
||
};
|
||
let (preview_key, file_key, preview, file_path, total_lines) = match a.status {
|
||
AgentStatus::Completed => {
|
||
let text = a.result.clone().unwrap_or_default();
|
||
let (p, total) = preview_first_n_lines(&text, 500);
|
||
let fp = write_agent_file(&dir, "result.txt", &text)
|
||
.map(|p| p.display().to_string())
|
||
.unwrap_or_else(|e| format!("Failed to write result file: {}", e));
|
||
("output_preview", "output_file", p, fp, total)
|
||
}
|
||
AgentStatus::Failed => {
|
||
let text = a.error.clone().unwrap_or_else(|| "Unknown error".to_string());
|
||
let (p, total) = preview_first_n_lines(&text, 500);
|
||
let fp = write_agent_file(&dir, "error.txt", &text)
|
||
.map(|p| p.display().to_string())
|
||
.unwrap_or_else(|e| format!("Failed to write error file: {}", e));
|
||
("error_preview", "error_file", p, fp, total)
|
||
}
|
||
AgentStatus::Cancelled => {
|
||
let text = "Agent cancelled".to_string();
|
||
let (p, total) = preview_first_n_lines(&text, 500);
|
||
let fp = write_agent_file(&dir, "status.txt", &text)
|
||
.map(|p| p.display().to_string())
|
||
.unwrap_or_else(|e| format!("Failed to write status file: {}", e));
|
||
("status_preview", "status_file", p, fp, total)
|
||
}
|
||
_ => unreachable!(),
|
||
};
|
||
|
||
let hint = format!(
|
||
"agent {{\"action\":\"result\",\"result\":{{\"agent_id\":\"{}\",\"batch_id\":\"{}\"}}}}",
|
||
a.id,
|
||
batch_id
|
||
);
|
||
let mut obj = serde_json::json!({
|
||
"agent_id": a.id,
|
||
"status": a.status,
|
||
"total_lines": total_lines,
|
||
"agent_result_hint": hint,
|
||
"agent_result_params": { "action": "result", "result": { "agent_id": a.id, "batch_id": batch_id } },
|
||
});
|
||
if let Some(map) = obj.as_object_mut() {
|
||
map.insert(preview_key.to_string(), serde_json::Value::String(preview));
|
||
map.insert(file_key.to_string(), serde_json::Value::String(file_path));
|
||
}
|
||
summaries.push(obj);
|
||
}
|
||
|
||
let response = serde_json::json!({
|
||
"batch_id": batch_id,
|
||
"completed_agents": completed_agents.iter().map(|t| t.id.clone()).collect::<Vec<_>>(),
|
||
"completed_summaries": summaries,
|
||
"wait_time_seconds": start.elapsed().as_secs(),
|
||
});
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: response.to_string(),
|
||
success: Some(true),
|
||
},
|
||
};
|
||
}
|
||
} else {
|
||
// Sequential behavior: return the next unseen completed agent if available
|
||
let mut state = sess.state.lock().unwrap();
|
||
let seen = state
|
||
.seen_completed_agents_by_batch
|
||
.entry(batch_id.clone())
|
||
.or_default();
|
||
|
||
// Find the first completed agent that we haven't returned yet
|
||
if let Some(unseen) = completed_agents
|
||
.iter()
|
||
.find(|a| !seen.contains(&a.id))
|
||
.cloned()
|
||
{
|
||
// Record as seen and return immediately
|
||
seen.insert(unseen.id.clone());
|
||
drop(state);
|
||
|
||
// Include output/error preview for the unseen completed agent
|
||
// Avoid holding manager lock during filesystem I/O
|
||
drop(manager);
|
||
let cwd = sess.get_cwd().to_path_buf();
|
||
let dir = match ensure_agent_dir(&cwd, &unseen.id) {
|
||
Ok(d) => d,
|
||
Err(e) => {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Failed to prepare agent output dir: {}", e),
|
||
success: Some(false),
|
||
},
|
||
};
|
||
}
|
||
};
|
||
let (preview_key, file_key, preview, file_path, total_lines) = match unseen.status {
|
||
AgentStatus::Completed => {
|
||
let text = unseen.result.clone().unwrap_or_default();
|
||
let (p, total) = preview_first_n_lines(&text, 500);
|
||
let fp = write_agent_file(&dir, "result.txt", &text)
|
||
.map(|p| p.display().to_string())
|
||
.unwrap_or_else(|e| format!("Failed to write result file: {}", e));
|
||
("output_preview", "output_file", p, fp, total)
|
||
}
|
||
AgentStatus::Failed => {
|
||
let text = unseen.error.clone().unwrap_or_else(|| "Unknown error".to_string());
|
||
let (p, total) = preview_first_n_lines(&text, 500);
|
||
let fp = write_agent_file(&dir, "error.txt", &text)
|
||
.map(|p| p.display().to_string())
|
||
.unwrap_or_else(|e| format!("Failed to write error file: {}", e));
|
||
("error_preview", "error_file", p, fp, total)
|
||
}
|
||
AgentStatus::Cancelled => {
|
||
let text = "Agent cancelled".to_string();
|
||
let (p, total) = preview_first_n_lines(&text, 500);
|
||
let fp = write_agent_file(&dir, "status.txt", &text)
|
||
.map(|p| p.display().to_string())
|
||
.unwrap_or_else(|e| format!("Failed to write status file: {}", e));
|
||
("status_preview", "status_file", p, fp, total)
|
||
}
|
||
_ => unreachable!(),
|
||
};
|
||
|
||
let hint = format!(
|
||
"agent {{\"action\":\"result\",\"result\":{{\"agent_id\":\"{}\",\"batch_id\":\"{}\"}}}}",
|
||
unseen.id,
|
||
batch_id
|
||
);
|
||
let mut response = serde_json::json!({
|
||
"agent_id": unseen.id,
|
||
"status": unseen.status,
|
||
"wait_time_seconds": start.elapsed().as_secs(),
|
||
"total_lines": total_lines,
|
||
"agent_result_hint": hint,
|
||
"agent_result_params": { "action": "result", "result": { "agent_id": unseen.id, "batch_id": batch_id } },
|
||
});
|
||
if let Some(obj) = response.as_object_mut() {
|
||
obj.insert(preview_key.to_string(), serde_json::Value::String(preview));
|
||
obj.insert(file_key.to_string(), serde_json::Value::String(file_path));
|
||
}
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: response.to_string(),
|
||
success: Some(true),
|
||
},
|
||
};
|
||
}
|
||
|
||
// If all agents in the batch are terminal and all have been seen, return immediately
|
||
if !any_in_progress && !completed_agents.is_empty() {
|
||
// Mark all as seen to keep state consistent
|
||
for a in &completed_agents {
|
||
seen.insert(a.id.clone());
|
||
}
|
||
drop(state);
|
||
|
||
let response = serde_json::json!({
|
||
"batch_id": batch_id,
|
||
"status": "no_agents_remaining",
|
||
"wait_time_seconds": start.elapsed().as_secs(),
|
||
});
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: response.to_string(),
|
||
success: Some(true),
|
||
},
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
drop(manager);
|
||
let (current_epoch, reason) = sess.wait_interrupt_snapshot();
|
||
if current_epoch != initial_wait_epoch {
|
||
let message = match reason {
|
||
Some(WaitInterruptReason::UserMessage) => {
|
||
"wait ended due to new user message".to_string()
|
||
}
|
||
_ => "wait ended because the session was interrupted".to_string(),
|
||
};
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: message,
|
||
success: Some(false),
|
||
},
|
||
};
|
||
}
|
||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||
}
|
||
}
|
||
Err(e) => ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Invalid agent arguments for action=wait: {}", e),
|
||
success: None,
|
||
},
|
||
},
|
||
}
|
||
},
|
||
).await
|
||
}
|
||
|
||
async fn handle_list_agents(
|
||
sess: &Session,
|
||
ctx: &ToolCallCtx,
|
||
arguments: String,
|
||
event_payload: serde_json::Value,
|
||
) -> ResponseInputItem {
|
||
let arguments_clone = arguments.clone();
|
||
let call_id_clone = ctx.call_id.clone();
|
||
execute_custom_tool(
|
||
sess,
|
||
ctx,
|
||
"agent".to_string(),
|
||
Some(event_payload),
|
||
|| async move {
|
||
match serde_json::from_str::<ListAgentsParams>(&arguments_clone) {
|
||
Ok(params) => {
|
||
let manager = AGENT_MANAGER.read().await;
|
||
|
||
let batch_id = match params.batch_id.clone() {
|
||
Some(batch) if !batch.trim().is_empty() => batch,
|
||
_ => {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: "action=list requires 'list.batch_id'".to_string(),
|
||
success: Some(false),
|
||
},
|
||
};
|
||
}
|
||
};
|
||
|
||
let status_filter =
|
||
params
|
||
.status_filter
|
||
.and_then(|s| match s.to_lowercase().as_str() {
|
||
"pending" => Some(AgentStatus::Pending),
|
||
"running" => Some(AgentStatus::Running),
|
||
"completed" => Some(AgentStatus::Completed),
|
||
"failed" => Some(AgentStatus::Failed),
|
||
"cancelled" => Some(AgentStatus::Cancelled),
|
||
_ => None,
|
||
});
|
||
|
||
let agents = manager.list_agents(
|
||
status_filter,
|
||
Some(batch_id.clone()),
|
||
params.recent_only.unwrap_or(false),
|
||
);
|
||
|
||
// Count running agents for status update
|
||
let running_count = agents
|
||
.iter()
|
||
.filter(|a| a.status == AgentStatus::Running)
|
||
.count();
|
||
if running_count > 0 {
|
||
let status_msg = format!(
|
||
"🤖 {} agent{} currently running",
|
||
running_count,
|
||
if running_count != 1 { "s" } else { "" }
|
||
);
|
||
let event = sess.make_event(
|
||
"agent-status",
|
||
EventMsg::BackgroundEvent(BackgroundEventEvent { message: status_msg }),
|
||
);
|
||
let _ = sess.tx_event.send(event).await;
|
||
}
|
||
|
||
// Add status counts to summary
|
||
let pending_count = agents
|
||
.iter()
|
||
.filter(|a| a.status == AgentStatus::Pending)
|
||
.count();
|
||
let running_count = agents
|
||
.iter()
|
||
.filter(|a| a.status == AgentStatus::Running)
|
||
.count();
|
||
let completed_count = agents
|
||
.iter()
|
||
.filter(|a| a.status == AgentStatus::Completed)
|
||
.count();
|
||
let failed_count = agents
|
||
.iter()
|
||
.filter(|a| a.status == AgentStatus::Failed)
|
||
.count();
|
||
let cancelled_count = agents
|
||
.iter()
|
||
.filter(|a| a.status == AgentStatus::Cancelled)
|
||
.count();
|
||
|
||
let summary = serde_json::json!({
|
||
"total_agents": agents.len(),
|
||
"status_counts": {
|
||
"pending": pending_count,
|
||
"running": running_count,
|
||
"completed": completed_count,
|
||
"failed": failed_count,
|
||
"cancelled": cancelled_count,
|
||
},
|
||
"batch_id": batch_id,
|
||
"agents": agents.iter().map(|t| {
|
||
serde_json::json!({
|
||
"id": t.id,
|
||
"name": t.name.clone(),
|
||
"model": t.model,
|
||
"status": t.status,
|
||
"created_at": t.created_at,
|
||
"batch_id": t.batch_id,
|
||
"worktree_path": t.worktree_path,
|
||
"branch_name": t.branch_name,
|
||
})
|
||
}).collect::<Vec<_>>(),
|
||
});
|
||
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: summary.to_string(),
|
||
success: Some(true),
|
||
},
|
||
}
|
||
}
|
||
Err(e) => ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Invalid agent arguments for action=list: {}", e),
|
||
success: None,
|
||
},
|
||
},
|
||
}
|
||
},
|
||
).await
|
||
}
|
||
|
||
async fn handle_container_exec_with_params(
|
||
params: ExecParams,
|
||
sess: &Session,
|
||
turn_diff_tracker: &mut TurnDiffTracker,
|
||
sub_id: String,
|
||
call_id: String,
|
||
seq_hint: Option<u64>,
|
||
output_index: Option<u32>,
|
||
attempt_req: u64,
|
||
) -> ResponseInputItem {
|
||
// Intercept risky git commands and require an explicit confirm prefix.
|
||
// We support a simple convention: prefix the script with `confirm:` to proceed.
|
||
// The prefix is stripped before execution.
|
||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||
enum SensitiveGitKind {
|
||
BranchChange,
|
||
PathCheckout,
|
||
Reset,
|
||
Revert,
|
||
}
|
||
|
||
fn detect_sensitive_git(script: &str) -> Option<SensitiveGitKind> {
|
||
// Goal: detect sensitive git invocations (branch changes, resets) while
|
||
// avoiding false positives from commit messages or other quoted strings.
|
||
// We do a lightweight scan that strips quoted regions before token analysis.
|
||
|
||
// 1) Strip quote characters but preserve content inside quotes, while
|
||
// neutralizing control separators to avoid over-splitting tokens.
|
||
let mut cleaned = String::with_capacity(script.len());
|
||
let mut in_squote = false;
|
||
let mut in_dquote = false;
|
||
let mut prev_was_backslash = false;
|
||
for ch in script.chars() {
|
||
let mut emit_space = false;
|
||
match ch {
|
||
'\\' => {
|
||
// Track escapes inside double quotes; in single quotes, backslash has no special meaning in POSIX sh.
|
||
prev_was_backslash = !prev_was_backslash;
|
||
}
|
||
'\'' if !in_dquote => {
|
||
in_squote = !in_squote;
|
||
emit_space = true; // token boundary at quote edges
|
||
prev_was_backslash = false;
|
||
}
|
||
'"' if !in_squote && !prev_was_backslash => {
|
||
in_dquote = !in_dquote;
|
||
emit_space = true; // token boundary at quote edges
|
||
prev_was_backslash = false;
|
||
}
|
||
_ => {
|
||
prev_was_backslash = false;
|
||
}
|
||
}
|
||
if emit_space {
|
||
cleaned.push(' ');
|
||
continue;
|
||
}
|
||
if in_squote || in_dquote {
|
||
if matches!(ch, '|' | '&' | ';' | '\n' | '\r') {
|
||
cleaned.push(' ');
|
||
} else {
|
||
cleaned.push(ch);
|
||
}
|
||
} else {
|
||
cleaned.push(ch);
|
||
}
|
||
}
|
||
|
||
// 2) Split into simple commands at common separators.
|
||
for chunk in cleaned.split(|c| matches!(c, ';' | '\n' | '\r')) {
|
||
// Further split on conditional operators while keeping order.
|
||
for part in chunk.split(|c| matches!(c, '|' | '&')) {
|
||
let s = part.trim();
|
||
if s.is_empty() { continue; }
|
||
// Tokenize on whitespace, skip wrappers and git globals to find the real subcommand.
|
||
let raw_tokens: Vec<&str> = s.split_whitespace().collect();
|
||
if raw_tokens.is_empty() { continue; }
|
||
fn strip_tok(t: &str) -> &str { t.trim_matches(|c| matches!(c, '(' | ')' | '{' | '}' | '\'' | '"')) }
|
||
let mut i = 0usize;
|
||
// Skip env assignments and lightweight wrappers/keywords.
|
||
loop {
|
||
if i >= raw_tokens.len() { break; }
|
||
let tok = strip_tok(raw_tokens[i]);
|
||
if tok.is_empty() { i += 1; continue; }
|
||
// Skip KEY=val assignments.
|
||
if tok.contains('=') && !tok.starts_with('=') && !tok.starts_with('-') {
|
||
i += 1; continue;
|
||
}
|
||
// Skip simple wrappers and control keywords.
|
||
if matches!(tok, "env" | "sudo" | "command" | "time" | "nohup" | "nice" | "then" | "do" | "{" | "(") {
|
||
// Best-effort: skip immediate option-like flags after some wrappers.
|
||
i += 1;
|
||
while i < raw_tokens.len() {
|
||
let peek = strip_tok(raw_tokens[i]);
|
||
if peek.starts_with('-') { i += 1; } else { break; }
|
||
}
|
||
continue;
|
||
}
|
||
break;
|
||
}
|
||
if i >= raw_tokens.len() { continue; }
|
||
let cmd = strip_tok(raw_tokens[i]);
|
||
let is_git = cmd.ends_with("/git") || cmd == "git";
|
||
if !is_git { continue; }
|
||
i += 1; // advance past git
|
||
// Skip git global options to find the real subcommand.
|
||
while i < raw_tokens.len() {
|
||
let t = strip_tok(raw_tokens[i]);
|
||
if t.is_empty() { i += 1; continue; }
|
||
if matches!(t, "-C" | "--git-dir" | "--work-tree" | "-c") {
|
||
i += 1; // skip option key
|
||
if i < raw_tokens.len() { i += 1; } // skip its value
|
||
continue;
|
||
}
|
||
if t.starts_with("--git-dir=") || t.starts_with("--work-tree=") || t.starts_with("-c") {
|
||
i += 1; continue;
|
||
}
|
||
if t.starts_with('-') { i += 1; continue; }
|
||
break;
|
||
}
|
||
if i >= raw_tokens.len() { continue; }
|
||
let sub = strip_tok(raw_tokens[i]);
|
||
i += 1;
|
||
match sub {
|
||
"checkout" => {
|
||
let args: Vec<&str> = raw_tokens[i..].iter().map(|t| strip_tok(t)).collect();
|
||
let has_path_delimiter = args.iter().any(|a| *a == "--");
|
||
if has_path_delimiter {
|
||
return Some(SensitiveGitKind::PathCheckout);
|
||
}
|
||
|
||
// If any of the strong branch-changing flags are present, flag it.
|
||
let mut saw_branch_change_flag = false;
|
||
for a in &args {
|
||
if matches!(*a, "-b" | "-B" | "--orphan" | "--detach") {
|
||
saw_branch_change_flag = true;
|
||
break;
|
||
}
|
||
}
|
||
if saw_branch_change_flag { return Some(SensitiveGitKind::BranchChange); }
|
||
|
||
// `git checkout -` switches to previous branch.
|
||
if args.first().copied() == Some("-") {
|
||
return Some(SensitiveGitKind::BranchChange);
|
||
}
|
||
|
||
// Heuristic: a single non-flag argument likely denotes a branch.
|
||
if let Some(first_arg) = args.first() {
|
||
let a = *first_arg;
|
||
if !a.starts_with('-') && a != "." && a != ".." {
|
||
return Some(SensitiveGitKind::BranchChange);
|
||
}
|
||
}
|
||
}
|
||
"switch" => {
|
||
// `git switch -c <name>` creates; `git switch <name>` changes.
|
||
let mut saw_c = false;
|
||
let mut saw_detach = false;
|
||
let mut first_non_flag: Option<&str> = None;
|
||
for a in &raw_tokens[i..] {
|
||
let a = strip_tok(a);
|
||
if a == "-c" { saw_c = true; break; }
|
||
if a == "--detach" { saw_detach = true; break; }
|
||
if a.starts_with('-') { continue; }
|
||
first_non_flag = Some(a);
|
||
break;
|
||
}
|
||
if saw_c || saw_detach || first_non_flag.is_some() { return Some(SensitiveGitKind::BranchChange); }
|
||
}
|
||
"reset" => {
|
||
// Any form of git reset is considered sensitive.
|
||
return Some(SensitiveGitKind::Reset);
|
||
}
|
||
"revert" => {
|
||
// Any form of git revert is considered sensitive.
|
||
return Some(SensitiveGitKind::Revert);
|
||
}
|
||
// Future: consider `git branch -D/-m` as branch‑modifying, but keep
|
||
// this minimal to avoid over‑blocking normal workflows.
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
fn strip_leading_confirm_prefix(argv: &mut Vec<String>) -> bool {
|
||
if argv.is_empty() {
|
||
return false;
|
||
}
|
||
|
||
let first = argv[0].trim().to_string();
|
||
for prefix in ["confirm:", "CONFIRM:"] {
|
||
if first == prefix {
|
||
argv.remove(0);
|
||
return true;
|
||
}
|
||
if let Some(rest) = first.strip_prefix(prefix) {
|
||
let trimmed = rest.trim_start();
|
||
if trimmed.is_empty() {
|
||
argv.remove(0);
|
||
} else {
|
||
argv[0] = trimmed.to_string();
|
||
}
|
||
return true;
|
||
}
|
||
}
|
||
|
||
false
|
||
}
|
||
|
||
fn guidance_for_sensitive_git(kind: SensitiveGitKind, original_label: &str, original_value: &str, suggested: &str) -> String {
|
||
match kind {
|
||
SensitiveGitKind::BranchChange => format!(
|
||
"Blocked git checkout/switch on a branch. Switching branches can discard or hide in-progress changes. Only continue if the user explicitly requested this branch change. Resend with 'confirm:' if you intend to proceed.\n\n{}: {}\nresend_exact_argv: {}",
|
||
original_label,
|
||
original_value,
|
||
suggested
|
||
),
|
||
SensitiveGitKind::PathCheckout => format!(
|
||
"Blocked git checkout -- <paths>. This command overwrites local modifications to the specified files. Consider backing up the files first. If you intentionally want to discard those edits, resend the exact command prefixed with 'confirm:'.\n\n{}: {}\nresend_exact_argv: {}",
|
||
original_label,
|
||
original_value,
|
||
suggested
|
||
),
|
||
SensitiveGitKind::Reset => format!(
|
||
"Blocked git reset. Reset rewrites the working tree/index and may delete local work. Consider backing up the files first. If backups exist and this was explicitly requested, resend prefixed with 'confirm:'.\n\n{}: {}\nresend_exact_argv: {}",
|
||
original_label,
|
||
original_value,
|
||
suggested
|
||
),
|
||
SensitiveGitKind::Revert => format!(
|
||
"Blocked git revert. Reverting commits alters history and should only happen when the user asks for it. If that’s the case, resend the command with 'confirm:'.\n\n{}: {}\nresend_exact_argv: {}",
|
||
original_label,
|
||
original_value,
|
||
suggested
|
||
),
|
||
}
|
||
}
|
||
|
||
fn guidance_for_dry_run_guard(
|
||
analysis: &DryRunAnalysis,
|
||
original_label: &str,
|
||
original_value: &str,
|
||
resend_exact_argv: Vec<String>,
|
||
) -> String {
|
||
let suggested_confirm = serde_json::to_string(&resend_exact_argv)
|
||
.unwrap_or_else(|_| "<failed to serialize suggested argv>".to_string());
|
||
let suggested_dry_run = analysis
|
||
.suggested_dry_run()
|
||
.unwrap_or_else(|| "<no canonical dry-run variant; remove mutating flags or use confirm:>".to_string());
|
||
format!(
|
||
"Blocked {} without a prior dry run. Run the dry-run variant first or resend with 'confirm:' if explicitly requested.\n\n{}: {}\nresend_exact_argv: {}\nsuggested_dry_run: {}",
|
||
analysis.display_name(),
|
||
original_label,
|
||
original_value,
|
||
suggested_confirm,
|
||
suggested_dry_run
|
||
)
|
||
}
|
||
|
||
|
||
// If the argv is a shell wrapper, analyze and optionally strip `confirm:`.
|
||
let mut params = params;
|
||
let seq_hint_for_exec = seq_hint;
|
||
let otel_event_manager = sess.client.get_otel_event_manager();
|
||
let tool_name = "local_shell";
|
||
if let Some((script_index, script)) = extract_shell_script_from_wrapper(¶ms.command) {
|
||
let trimmed = script.trim_start();
|
||
let confirm_prefixes = ["confirm:", "CONFIRM:"];
|
||
let has_confirm_prefix = confirm_prefixes
|
||
.iter()
|
||
.any(|p| trimmed.starts_with(p));
|
||
|
||
// If no confirm prefix and it looks like a sensitive git command, reject with guidance.
|
||
if !has_confirm_prefix {
|
||
if let Some(pattern) = if sess.confirm_guard.is_empty() {
|
||
None
|
||
} else {
|
||
sess.confirm_guard.matched_pattern(trimmed)
|
||
} {
|
||
let mut argv_confirm = params.command.clone();
|
||
argv_confirm[script_index] = format!("confirm: {}", script.trim_start());
|
||
let suggested = serde_json::to_string(&argv_confirm)
|
||
.unwrap_or_else(|_| "<failed to serialize suggested argv>".to_string());
|
||
let guidance = pattern.guidance("original_script", &script, &suggested);
|
||
|
||
let order = sess.next_background_order(&sub_id, attempt_req, output_index);
|
||
sess
|
||
.notify_background_event_with_order(
|
||
&sub_id,
|
||
order,
|
||
format!("Command guard: {}", guidance),
|
||
)
|
||
.await;
|
||
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id,
|
||
output: FunctionCallOutputPayload { content: guidance, success: None },
|
||
};
|
||
}
|
||
|
||
if let Some(kind) = detect_sensitive_git(trimmed) {
|
||
// Provide the exact argv the model should resend with the confirm prefix.
|
||
let mut argv_confirm = params.command.clone();
|
||
argv_confirm[script_index] = format!("confirm: {}", script.trim_start());
|
||
let suggested = serde_json::to_string(&argv_confirm)
|
||
.unwrap_or_else(|_| "<failed to serialize suggested argv>".to_string());
|
||
|
||
let guidance = guidance_for_sensitive_git(kind, "original_script", &script, &suggested);
|
||
|
||
let order = sess.next_background_order(&sub_id, attempt_req, output_index);
|
||
sess
|
||
.notify_background_event_with_order(
|
||
&sub_id,
|
||
order,
|
||
format!("Command guard: {}", guidance.clone()),
|
||
)
|
||
.await;
|
||
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id,
|
||
output: FunctionCallOutputPayload { content: guidance, success: None },
|
||
};
|
||
}
|
||
}
|
||
|
||
// If confirm prefix present, strip it before execution.
|
||
if has_confirm_prefix {
|
||
let without_prefix = confirm_prefixes
|
||
.iter()
|
||
.find_map(|p| {
|
||
let t = trimmed.strip_prefix(p)?;
|
||
Some(t.trim_start().to_string())
|
||
})
|
||
.unwrap_or_else(|| trimmed.to_string());
|
||
params.command[script_index] = without_prefix;
|
||
}
|
||
|
||
let dry_run_analysis = analyze_command(¶ms.command);
|
||
if !has_confirm_prefix {
|
||
if let Some(analysis) = dry_run_analysis.as_ref() {
|
||
if analysis.disposition == DryRunDisposition::Mutating {
|
||
let needs_dry_run = {
|
||
let state = sess.state.lock().unwrap();
|
||
!state.dry_run_guard.has_recent_dry_run(analysis.key)
|
||
};
|
||
if needs_dry_run {
|
||
let mut argv_confirm = params.command.clone();
|
||
argv_confirm[script_index] = format!("confirm: {}", params.command[script_index].trim_start());
|
||
let guidance = guidance_for_dry_run_guard(
|
||
analysis,
|
||
"original_script",
|
||
¶ms.command[script_index],
|
||
argv_confirm,
|
||
);
|
||
|
||
let order = sess.next_background_order(&sub_id, attempt_req, output_index);
|
||
sess
|
||
.notify_background_event_with_order(
|
||
&sub_id,
|
||
order,
|
||
format!("Command guard: {}", guidance.clone()),
|
||
)
|
||
.await;
|
||
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id,
|
||
output: FunctionCallOutputPayload { content: guidance, success: None },
|
||
};
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
strip_leading_confirm_prefix(&mut params.command);
|
||
|
||
if let Some(redundant) = detect_redundant_cd(¶ms.command, ¶ms.cwd) {
|
||
let guidance = guidance_for_redundant_cd(&redundant);
|
||
let order = sess.next_background_order(&sub_id, attempt_req, output_index);
|
||
sess
|
||
.notify_background_event_with_order(
|
||
&sub_id,
|
||
order,
|
||
format!("Command guard: {}", guidance.clone()),
|
||
)
|
||
.await;
|
||
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id,
|
||
output: FunctionCallOutputPayload {
|
||
content: guidance,
|
||
success: None,
|
||
},
|
||
};
|
||
}
|
||
|
||
if let Some(cat_guard) = detect_cat_write(¶ms.command) {
|
||
let guidance = guidance_for_cat_write(&cat_guard);
|
||
let order = sess.next_background_order(&sub_id, attempt_req, output_index);
|
||
sess
|
||
.notify_background_event_with_order(
|
||
&sub_id,
|
||
order,
|
||
format!("Command guard: {}", guidance.clone()),
|
||
)
|
||
.await;
|
||
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id,
|
||
output: FunctionCallOutputPayload {
|
||
content: guidance,
|
||
success: None,
|
||
},
|
||
};
|
||
}
|
||
|
||
if let Some(python_guard) = detect_python_write(¶ms.command) {
|
||
let guidance = guidance_for_python_write(&python_guard);
|
||
let order = sess.next_background_order(&sub_id, attempt_req, output_index);
|
||
sess
|
||
.notify_background_event_with_order(
|
||
&sub_id,
|
||
order,
|
||
format!("Command guard: {}", guidance.clone()),
|
||
)
|
||
.await;
|
||
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id,
|
||
output: FunctionCallOutputPayload {
|
||
content: guidance,
|
||
success: None,
|
||
},
|
||
};
|
||
}
|
||
|
||
// If no shell wrapper, perform a lightweight argv inspection for sensitive git commands.
|
||
if extract_shell_script_from_wrapper(¶ms.command).is_none() {
|
||
let joined = params.command.join(" ");
|
||
if !sess.confirm_guard.is_empty() {
|
||
if let Some(pattern) = sess.confirm_guard.matched_pattern(&joined) {
|
||
let suggested = serde_json::to_string(&vec![
|
||
"bash".to_string(),
|
||
"-lc".to_string(),
|
||
format!("confirm: {}", joined),
|
||
])
|
||
.unwrap_or_else(|_| "<failed to serialize suggested argv>".to_string());
|
||
let guidance = pattern.guidance(
|
||
"original_argv",
|
||
&format!("{:?}", params.command),
|
||
&suggested,
|
||
);
|
||
|
||
let order = sess.next_background_order(&sub_id, attempt_req, output_index);
|
||
sess
|
||
.notify_background_event_with_order(
|
||
&sub_id,
|
||
order,
|
||
format!("Command guard: {}", guidance.clone()),
|
||
)
|
||
.await;
|
||
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id,
|
||
output: FunctionCallOutputPayload { content: guidance, success: None },
|
||
};
|
||
}
|
||
}
|
||
|
||
if let Some(analysis) = analyze_command(¶ms.command) {
|
||
if analysis.disposition == DryRunDisposition::Mutating {
|
||
let needs_dry_run = {
|
||
let state = sess.state.lock().unwrap();
|
||
!state.dry_run_guard.has_recent_dry_run(analysis.key)
|
||
};
|
||
if needs_dry_run {
|
||
let resend = vec![
|
||
"bash".to_string(),
|
||
"-lc".to_string(),
|
||
format!("confirm: {}", joined),
|
||
];
|
||
let guidance = guidance_for_dry_run_guard(
|
||
&analysis,
|
||
"original_argv",
|
||
&format!("{:?}", params.command),
|
||
resend,
|
||
);
|
||
|
||
let order = sess.next_background_order(&sub_id, attempt_req, output_index);
|
||
sess
|
||
.notify_background_event_with_order(
|
||
&sub_id,
|
||
order,
|
||
format!("Command guard: {}", guidance.clone()),
|
||
)
|
||
.await;
|
||
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id,
|
||
output: FunctionCallOutputPayload { content: guidance, success: None },
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
fn strip_tok2(t: &str) -> &str { t.trim_matches(|c| matches!(c, '(' | ')' | '{' | '}' | '\'' | '"')) }
|
||
let mut i = 0usize;
|
||
// Skip env assignments and simple wrappers at the front
|
||
while i < params.command.len() {
|
||
let tok = strip_tok2(¶ms.command[i]);
|
||
if tok.is_empty() { i += 1; continue; }
|
||
if tok.contains('=') && !tok.starts_with('=') && !tok.starts_with('-') { i += 1; continue; }
|
||
if matches!(tok, "env" | "sudo" | "command" | "time" | "nohup" | "nice") {
|
||
i += 1;
|
||
while i < params.command.len() && strip_tok2(¶ms.command[i]).starts_with('-') { i += 1; }
|
||
continue;
|
||
}
|
||
break;
|
||
}
|
||
if i < params.command.len() {
|
||
let cmd = strip_tok2(¶ms.command[i]);
|
||
if cmd.ends_with("/git") || cmd == "git" {
|
||
i += 1;
|
||
while i < params.command.len() {
|
||
let t = strip_tok2(¶ms.command[i]);
|
||
if t.is_empty() { i += 1; continue; }
|
||
if matches!(t, "-C" | "--git-dir" | "--work-tree" | "-c") {
|
||
i += 1; if i < params.command.len() { i += 1; }
|
||
continue;
|
||
}
|
||
if t.starts_with("--git-dir=") || t.starts_with("--work-tree=") || t.starts_with("-c") { i += 1; continue; }
|
||
if t.starts_with('-') { i += 1; continue; }
|
||
break;
|
||
}
|
||
if i < params.command.len() {
|
||
let sub = strip_tok2(¶ms.command[i]);
|
||
let args: Vec<&str> = params.command[i + 1..].iter().map(|t| strip_tok2(t)).collect();
|
||
let kind = match sub {
|
||
"checkout" => {
|
||
if args.iter().any(|a| *a == "--") {
|
||
Some(SensitiveGitKind::PathCheckout)
|
||
} else if args.iter().any(|a| matches!(*a, "-b" | "-B" | "--orphan" | "--detach")) {
|
||
Some(SensitiveGitKind::BranchChange)
|
||
} else if args.first().copied() == Some("-") {
|
||
Some(SensitiveGitKind::BranchChange)
|
||
} else if let Some(first_arg) = args.first() {
|
||
let a = *first_arg;
|
||
if !a.starts_with('-') && a != "." && a != ".." {
|
||
Some(SensitiveGitKind::BranchChange)
|
||
} else {
|
||
None
|
||
}
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
"switch" => Some(SensitiveGitKind::BranchChange),
|
||
"reset" => Some(SensitiveGitKind::Reset),
|
||
"revert" => Some(SensitiveGitKind::Revert),
|
||
_ => None,
|
||
};
|
||
if let Some(kind) = kind {
|
||
let suggested = serde_json::to_string(&vec![
|
||
"bash".to_string(),
|
||
"-lc".to_string(),
|
||
format!("confirm: {}", params.command.join(" ")),
|
||
]).unwrap_or_else(|_| "<failed to serialize suggested argv>".to_string());
|
||
|
||
let guidance = guidance_for_sensitive_git(kind, "original_argv", &format!("{:?}", params.command), &suggested);
|
||
|
||
let order = sess.next_background_order(&sub_id, attempt_req, output_index);
|
||
sess
|
||
.notify_background_event_with_order(
|
||
&sub_id,
|
||
order,
|
||
format!("Command guard: {}", guidance.clone()),
|
||
)
|
||
.await;
|
||
|
||
return ResponseInputItem::FunctionCallOutput { call_id, output: FunctionCallOutputPayload { content: guidance, success: None } };
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check if this was a patch, and apply it in-process if so.
|
||
match sess
|
||
.maybe_parse_apply_patch_verified(¶ms.command, ¶ms.cwd)
|
||
.await
|
||
{
|
||
MaybeApplyPatchVerified::Body(action) => {
|
||
if let Some(branch_root) = git_worktree::branch_worktree_root(sess.get_cwd()) {
|
||
if let Some(guidance) = guard_apply_patch_outside_branch(&branch_root, &action) {
|
||
let order = sess.next_background_order(&sub_id, attempt_req, output_index);
|
||
sess
|
||
.notify_background_event_with_order(
|
||
&sub_id,
|
||
order,
|
||
format!("Command guard: {}", guidance.clone()),
|
||
)
|
||
.await;
|
||
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id,
|
||
output: FunctionCallOutputPayload { content: guidance, success: None },
|
||
};
|
||
}
|
||
}
|
||
let changes = convert_apply_patch_to_protocol(&action);
|
||
turn_diff_tracker.on_patch_begin(&changes);
|
||
|
||
match apply_patch::apply_patch(
|
||
sess,
|
||
&sub_id,
|
||
&call_id,
|
||
attempt_req,
|
||
output_index,
|
||
action,
|
||
)
|
||
.await
|
||
{
|
||
ApplyPatchResult::Reply(item) => return item,
|
||
ApplyPatchResult::Applied(run) => {
|
||
let order_begin = crate::protocol::OrderMeta {
|
||
request_ordinal: attempt_req,
|
||
output_index,
|
||
sequence_number: seq_hint,
|
||
};
|
||
let begin_event = EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
|
||
call_id: call_id.clone(),
|
||
auto_approved: run.auto_approved,
|
||
changes,
|
||
});
|
||
let event = sess.make_event_with_order(&sub_id, begin_event, order_begin, seq_hint);
|
||
let _ = sess.tx_event.send(event).await;
|
||
|
||
let order_end = crate::protocol::OrderMeta {
|
||
request_ordinal: attempt_req,
|
||
output_index,
|
||
sequence_number: seq_hint.map(|h| h.saturating_add(1)),
|
||
};
|
||
let end_event = EventMsg::PatchApplyEnd(PatchApplyEndEvent {
|
||
call_id: call_id.clone(),
|
||
stdout: run.stdout.clone(),
|
||
stderr: run.stderr.clone(),
|
||
success: run.success,
|
||
});
|
||
let event = sess.make_event_with_order(
|
||
&sub_id,
|
||
end_event,
|
||
order_end,
|
||
seq_hint.map(|h| h.saturating_add(1)),
|
||
);
|
||
let _ = sess.tx_event.send(event).await;
|
||
|
||
if let Ok(Some(unified_diff)) = turn_diff_tracker.get_unified_diff() {
|
||
let diff_event = sess.make_event(
|
||
&sub_id,
|
||
EventMsg::TurnDiff(TurnDiffEvent { unified_diff }),
|
||
);
|
||
let _ = sess.tx_event.send(diff_event).await;
|
||
}
|
||
|
||
let mut content = run.stdout;
|
||
if !run.success && !run.stderr.is_empty() {
|
||
if !content.is_empty() {
|
||
content.push('\n');
|
||
}
|
||
content.push_str(&format!("stderr: {}", run.stderr));
|
||
}
|
||
if let Some(summary) = run.harness_summary_json {
|
||
if !summary.is_empty() {
|
||
if !content.is_empty() {
|
||
content.push('\n');
|
||
}
|
||
content.push_str(&summary);
|
||
}
|
||
}
|
||
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id,
|
||
output: FunctionCallOutputPayload {
|
||
content,
|
||
success: Some(run.success),
|
||
},
|
||
};
|
||
}
|
||
}
|
||
}
|
||
MaybeApplyPatchVerified::CorrectnessError(parse_error) => {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("error: {parse_error:#}"),
|
||
success: None,
|
||
},
|
||
};
|
||
}
|
||
MaybeApplyPatchVerified::ShellParseError(error) => {
|
||
trace!("Failed to parse shell command, {error:?}");
|
||
}
|
||
MaybeApplyPatchVerified::NotApplyPatch => {}
|
||
}
|
||
|
||
let safety = {
|
||
let state = sess.state.lock().unwrap();
|
||
assess_command_safety(
|
||
¶ms.command,
|
||
sess.approval_policy,
|
||
&sess.sandbox_policy,
|
||
&state.approved_commands,
|
||
params.with_escalated_permissions.unwrap_or(false),
|
||
)
|
||
};
|
||
let command_for_display = params.command.clone();
|
||
let harness_summary_json: Option<String> = None;
|
||
|
||
let sandbox_type = match safety {
|
||
SafetyCheck::AutoApprove {
|
||
sandbox_type,
|
||
user_explicitly_approved,
|
||
} => {
|
||
if let Some(manager) = otel_event_manager.as_ref() {
|
||
let (decision_for_log, source) = if user_explicitly_approved {
|
||
(
|
||
ReviewDecision::ApprovedForSession,
|
||
ToolDecisionSource::User,
|
||
)
|
||
} else {
|
||
(ReviewDecision::Approved, ToolDecisionSource::Config)
|
||
};
|
||
manager.tool_decision(
|
||
tool_name,
|
||
call_id.as_str(),
|
||
to_proto_review_decision(decision_for_log),
|
||
source,
|
||
);
|
||
}
|
||
sandbox_type
|
||
}
|
||
SafetyCheck::AskUser => {
|
||
let rx_approve = sess
|
||
.request_command_approval(
|
||
sub_id.clone(),
|
||
call_id.clone(),
|
||
params.command.clone(),
|
||
params.cwd.clone(),
|
||
params.justification.clone(),
|
||
)
|
||
.await;
|
||
|
||
let decision = rx_approve.await.unwrap_or_default();
|
||
if let Some(manager) = otel_event_manager.as_ref() {
|
||
manager.tool_decision(
|
||
tool_name,
|
||
call_id.as_str(),
|
||
to_proto_review_decision(decision),
|
||
ToolDecisionSource::User,
|
||
);
|
||
}
|
||
|
||
match decision {
|
||
ReviewDecision::Approved => {}
|
||
ReviewDecision::ApprovedForSession => {
|
||
sess.add_approved_command(ApprovedCommandPattern::new(
|
||
params.command.clone(),
|
||
ApprovedCommandMatchKind::Exact,
|
||
None,
|
||
));
|
||
}
|
||
ReviewDecision::Denied | ReviewDecision::Abort => {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id,
|
||
output: FunctionCallOutputPayload {
|
||
content: "exec command rejected by user".to_string(),
|
||
success: None,
|
||
},
|
||
};
|
||
}
|
||
}
|
||
// No sandboxing is applied because the user has given
|
||
// explicit approval. Often, we end up in this case because
|
||
// the command cannot be run in a sandbox, such as
|
||
// installing a new dependency that requires network access.
|
||
SandboxType::None
|
||
}
|
||
SafetyCheck::Reject { reason } => {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("exec command rejected: {reason}"),
|
||
success: None,
|
||
},
|
||
};
|
||
}
|
||
};
|
||
|
||
let exec_command_context = ExecCommandContext {
|
||
sub_id: sub_id.clone(),
|
||
call_id: call_id.clone(),
|
||
command_for_display: command_for_display.clone(),
|
||
cwd: params.cwd.clone(),
|
||
apply_patch: None,
|
||
};
|
||
|
||
let display_label = crate::util::strip_bash_lc_and_escape(&exec_command_context.command_for_display);
|
||
let params = maybe_run_with_user_profile(params, sess);
|
||
|
||
// Prepare tail buffer and background registry entry
|
||
let tail_buf = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
|
||
let notify = std::sync::Arc::new(tokio::sync::Notify::new());
|
||
let result_cell: std::sync::Arc<std::sync::Mutex<Option<ExecToolCallOutput>>> = std::sync::Arc::new(std::sync::Mutex::new(None));
|
||
let backgrounded = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||
let suppress_event_flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||
let order_meta_for_end = crate::protocol::OrderMeta {
|
||
request_ordinal: attempt_req,
|
||
output_index,
|
||
sequence_number: seq_hint_for_exec.map(|h| h.saturating_add(1)),
|
||
};
|
||
let order_meta_for_deltas = crate::protocol::OrderMeta {
|
||
request_ordinal: attempt_req,
|
||
output_index,
|
||
sequence_number: None,
|
||
};
|
||
{
|
||
let mut st = sess.state.lock().unwrap();
|
||
st.background_execs.insert(
|
||
call_id.clone(),
|
||
BackgroundExecState {
|
||
notify: notify.clone(),
|
||
result_cell: result_cell.clone(),
|
||
tail_buf: Some(tail_buf.clone()),
|
||
cmd_display: display_label.clone(),
|
||
suppress_event: suppress_event_flag.clone(),
|
||
task_handle: None,
|
||
order_meta_for_end: order_meta_for_end.clone(),
|
||
sub_id: sub_id.clone(),
|
||
},
|
||
);
|
||
}
|
||
|
||
// Emit BEGIN event using the normal path so the TUI shows a running cell
|
||
sess
|
||
.on_exec_command_begin(
|
||
turn_diff_tracker,
|
||
exec_command_context.clone(),
|
||
seq_hint_for_exec,
|
||
output_index,
|
||
attempt_req,
|
||
)
|
||
.await;
|
||
|
||
// Spawn the runner that streams output and, on completion, emits END and records result.
|
||
let tx_event = sess.tx_event.clone();
|
||
let sub_id_for_events = sub_id.clone();
|
||
let call_id_for_events = call_id.clone();
|
||
let sandbox_policy = sess.sandbox_policy.clone();
|
||
let sandbox_cwd = sess.get_cwd().to_path_buf();
|
||
let code_linux_sandbox_exe = sess.code_linux_sandbox_exe.clone();
|
||
let result_cell_for_task = result_cell.clone();
|
||
let notify_task = notify.clone();
|
||
let tail_buf_task = tail_buf.clone();
|
||
let backgrounded_task = backgrounded.clone();
|
||
let suppress_event_flag_task = suppress_event_flag.clone();
|
||
let display_label_task = display_label.clone();
|
||
let task_handle = tokio::spawn(async move {
|
||
// Build stdout stream with tail capture. We cannot stamp via `Session` here,
|
||
// but deltas will be delivered with neutral ordering which the UI tolerates.
|
||
let stdout_stream = if exec_command_context.apply_patch.is_some() {
|
||
None
|
||
} else {
|
||
Some(StdoutStream {
|
||
sub_id: sub_id_for_events.clone(),
|
||
call_id: call_id_for_events.clone(),
|
||
tx_event: tx_event.clone(),
|
||
session: None,
|
||
tail_buf: Some(tail_buf_task.clone()),
|
||
order: Some(order_meta_for_deltas.clone()),
|
||
})
|
||
};
|
||
|
||
let start = std::time::Instant::now();
|
||
let res = crate::exec::process_exec_tool_call(
|
||
params.clone(),
|
||
sandbox_type,
|
||
&sandbox_policy,
|
||
&sandbox_cwd,
|
||
&code_linux_sandbox_exe,
|
||
stdout_stream,
|
||
)
|
||
.await;
|
||
|
||
// Normalize to ExecToolCallOutput
|
||
let (out, exit_code) = match res {
|
||
Ok(o) => { let exit = o.exit_code; (o, exit) },
|
||
Err(CodexErr::Sandbox(SandboxErr::Timeout { output })) => (output.as_ref().clone(), 124),
|
||
Err(e) => {
|
||
let msg = get_error_message_ui(&e);
|
||
(
|
||
ExecToolCallOutput {
|
||
exit_code: -1,
|
||
stdout: StreamOutput::new(String::new()),
|
||
stderr: StreamOutput::new(msg.clone()),
|
||
aggregated_output: StreamOutput::new(msg),
|
||
duration: start.elapsed(),
|
||
timed_out: false,
|
||
},
|
||
-1,
|
||
)
|
||
}
|
||
};
|
||
|
||
// Emit END event directly
|
||
let end_msg = EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||
call_id: call_id_for_events.clone(),
|
||
stdout: out.stdout.text.clone(),
|
||
stderr: out.stderr.text.clone(),
|
||
exit_code,
|
||
duration: out.duration,
|
||
});
|
||
let ev = Event { id: sub_id_for_events.clone(), event_seq: 0, msg: end_msg, order: Some(order_meta_for_end) };
|
||
let _ = tx_event.send(ev).await;
|
||
|
||
// Store result for waiters
|
||
{
|
||
let mut slot = result_cell_for_task.lock().unwrap();
|
||
*slot = Some(out.clone());
|
||
}
|
||
// Only emit background completion notifications if the command actually backgrounded
|
||
if backgrounded_task.load(std::sync::atomic::Ordering::Relaxed) {
|
||
if !suppress_event_flag_task.load(std::sync::atomic::Ordering::Relaxed) {
|
||
let label = display_label_task.trim();
|
||
let message = if label.is_empty() {
|
||
format!("Background shell '{}' completed.", call_id_for_events)
|
||
} else {
|
||
format!("{label} completed in background")
|
||
};
|
||
let bg_event = EventMsg::BackgroundEvent(BackgroundEventEvent { message });
|
||
let ev = Event { id: sub_id_for_events.clone(), event_seq: 0, msg: bg_event, order: None };
|
||
let _ = tx_event.send(ev).await;
|
||
|
||
if let Some(tx) = TX_SUB_GLOBAL.get() {
|
||
let header_label = if label.is_empty() {
|
||
format!("call_id={}", call_id_for_events)
|
||
} else {
|
||
display_label_task.clone()
|
||
};
|
||
let header = format!("Background shell completed ({header_label}), exit_code={}, duration={:?}.", out.exit_code, out.duration);
|
||
let full_body = format_exec_output_str(&out);
|
||
let body = truncate_exec_output_for_storage(&sandbox_cwd, &sub_id_for_events, &call_id_for_events, &full_body);
|
||
let dev_text = format!("{}\n\n{}", header, body);
|
||
let _ = tx
|
||
.send(Submission { id: uuid::Uuid::new_v4().to_string(), op: Op::AddPendingInputDeveloper { text: dev_text } })
|
||
.await;
|
||
}
|
||
}
|
||
if let Some(n) = ANY_BG_NOTIFY.get() { n.notify_waiters(); }
|
||
}
|
||
notify_task.notify_waiters();
|
||
});
|
||
|
||
{
|
||
let mut st = sess.state.lock().unwrap();
|
||
if let Some(bg) = st.background_execs.get_mut(&call_id) {
|
||
bg.task_handle = Some(task_handle);
|
||
}
|
||
}
|
||
|
||
// Wait up to 10 seconds for completion
|
||
let waited = tokio::time::timeout(std::time::Duration::from_secs(10), notify.notified()).await;
|
||
if waited.is_ok() {
|
||
// Completed within 10s - return the real output and drop the background entry.
|
||
let done_opt = {
|
||
let mut st = sess.state.lock().unwrap();
|
||
st.background_execs
|
||
.remove(&call_id)
|
||
.and_then(|bg| bg.result_cell.lock().unwrap().clone())
|
||
.or_else(|| {
|
||
st.background_execs
|
||
.iter()
|
||
.find_map(|(k, v)| {
|
||
if v.result_cell.lock().unwrap().is_some() {
|
||
Some(k.clone())
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
.and_then(|k| st.background_execs.remove(&k))
|
||
.and_then(|bg| bg.result_cell.lock().unwrap().clone())
|
||
})
|
||
};
|
||
if let Some(done) = done_opt {
|
||
let is_success = done.exit_code == 0;
|
||
let mut content = format_exec_output_with_limit(sess, &sub_id, &call_id, &done);
|
||
if let Some(harness) = harness_summary_json.as_ref() {
|
||
if !harness.is_empty() {
|
||
content.push('\n');
|
||
content.push_str(harness);
|
||
}
|
||
}
|
||
return ResponseInputItem::FunctionCallOutput { call_id: call_id.clone(), output: FunctionCallOutputPayload { content, success: Some(is_success) } };
|
||
} else {
|
||
// Fallback (should not happen): indicate completion without detail
|
||
let msg = format!("Command completed.");
|
||
return ResponseInputItem::FunctionCallOutput { call_id: call_id.clone(), output: FunctionCallOutputPayload { content: msg, success: Some(true) } };
|
||
}
|
||
}
|
||
|
||
// Still running: mark as backgrounded and return background notice + tail and instructions
|
||
backgrounded.store(true, std::sync::atomic::Ordering::Relaxed);
|
||
let tail = String::from_utf8_lossy(&tail_buf.lock().unwrap()).to_string();
|
||
let header = format!(
|
||
"Command running in background (call_id={}).\nTo wait: wait(call_id=\"{}\")\nYou can continue other work or wait. You'll be notified when the command completes.",
|
||
call_id,
|
||
call_id
|
||
);
|
||
let msg = if tail.is_empty() {
|
||
header
|
||
} else {
|
||
format!("{}\n\nOutput so far (tail):\n{}", header, tail)
|
||
};
|
||
ResponseInputItem::FunctionCallOutput { call_id: call_id.clone(), output: FunctionCallOutputPayload { content: msg, success: Some(true) } }
|
||
}
|
||
|
||
#[allow(dead_code)]
|
||
async fn handle_sandbox_error(
|
||
turn_diff_tracker: &mut TurnDiffTracker,
|
||
params: ExecParams,
|
||
exec_command_context: ExecCommandContext,
|
||
error: SandboxErr,
|
||
sandbox_type: SandboxType,
|
||
sess: &Session,
|
||
attempt_req: u64,
|
||
) -> ResponseInputItem {
|
||
let call_id = exec_command_context.call_id.clone();
|
||
let sub_id = exec_command_context.sub_id.clone();
|
||
let cwd = exec_command_context.cwd.clone();
|
||
let otel_event_manager = sess.client.get_otel_event_manager();
|
||
let tool_name = "local_shell";
|
||
|
||
// Early out if either the user never wants to be asked for approval, or
|
||
// we're letting the model manage escalation requests. Otherwise, continue
|
||
match sess.approval_policy {
|
||
AskForApproval::Never | AskForApproval::OnRequest => {
|
||
// Clarify when Read Only mode is the reason a command cannot proceed.
|
||
let content = if matches!(sess.sandbox_policy, SandboxPolicy::ReadOnly) {
|
||
format!("command blocked by Read Only mode: {error}")
|
||
} else {
|
||
format!("failed in sandbox {sandbox_type:?} with execution error: {error}")
|
||
};
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id,
|
||
output: FunctionCallOutputPayload { content, success: Some(false) },
|
||
};
|
||
}
|
||
AskForApproval::UnlessTrusted | AskForApproval::OnFailure => (),
|
||
}
|
||
|
||
// similarly, if the command timed out, we can simply return this failure to the model
|
||
if matches!(error, SandboxErr::Timeout { .. }) {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id,
|
||
output: FunctionCallOutputPayload {
|
||
content: "command timed out".to_string(),
|
||
success: Some(false),
|
||
},
|
||
};
|
||
}
|
||
|
||
// Note that when `error` is `SandboxErr::Denied`, it could be a false
|
||
// positive. That is, it may have exited with a non-zero exit code, not
|
||
// because the sandbox denied it, but because that is its expected behavior,
|
||
// i.e., a grep command that did not match anything. Ideally we would
|
||
// include additional metadata on the command to indicate whether non-zero
|
||
// exit codes merit a retry.
|
||
|
||
// For now, we categorically ask the user to retry without sandbox and
|
||
// emit the raw error as a background event.
|
||
let failure_order = sess.next_background_order(&sub_id, attempt_req, None);
|
||
sess
|
||
.notify_background_event_with_order(
|
||
&sub_id,
|
||
failure_order,
|
||
format!("Execution failed: {error}"),
|
||
)
|
||
.await;
|
||
|
||
let rx_approve = sess
|
||
.request_command_approval(
|
||
sub_id.clone(),
|
||
call_id.clone(),
|
||
params.command.clone(),
|
||
cwd.clone(),
|
||
Some("command failed; retry without sandbox?".to_string()),
|
||
)
|
||
.await;
|
||
|
||
let decision = rx_approve.await.unwrap_or_default();
|
||
if let Some(manager) = otel_event_manager.as_ref() {
|
||
manager.tool_decision(
|
||
tool_name,
|
||
call_id.as_str(),
|
||
to_proto_review_decision(decision),
|
||
ToolDecisionSource::User,
|
||
);
|
||
}
|
||
|
||
match decision {
|
||
ReviewDecision::Approved => {}
|
||
ReviewDecision::ApprovedForSession => {
|
||
// Persist this command as pre‑approved for the
|
||
// remainder of the session so future executions skip the sandbox directly.
|
||
sess.add_approved_command(ApprovedCommandPattern::new(
|
||
params.command.clone(),
|
||
ApprovedCommandMatchKind::Exact,
|
||
None,
|
||
));
|
||
}
|
||
ReviewDecision::Denied | ReviewDecision::Abort => {
|
||
// Fall through to original failure handling.
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id,
|
||
output: FunctionCallOutputPayload {
|
||
content: "exec command rejected by user".to_string(),
|
||
success: None,
|
||
},
|
||
};
|
||
}
|
||
};
|
||
|
||
// Inform UI we are retrying without sandbox.
|
||
let retry_order = sess.next_background_order(&sub_id, attempt_req, None);
|
||
sess
|
||
.notify_background_event_with_order(
|
||
&sub_id,
|
||
retry_order,
|
||
"retrying command without sandbox",
|
||
)
|
||
.await;
|
||
|
||
// This is an escalated retry; the policy will not be examined and the sandbox has been set to `None`.
|
||
// Use the same attempt_req as the tool call that failed; this retry is still part of the current provider attempt.
|
||
let retry_output_result = sess
|
||
.run_exec_with_events(
|
||
turn_diff_tracker,
|
||
exec_command_context.clone(),
|
||
ExecInvokeArgs {
|
||
params,
|
||
sandbox_type: SandboxType::None,
|
||
sandbox_policy: &sess.sandbox_policy,
|
||
sandbox_cwd: sess.get_cwd(),
|
||
code_linux_sandbox_exe: &sess.code_linux_sandbox_exe,
|
||
stdout_stream: if exec_command_context.apply_patch.is_some() {
|
||
None
|
||
} else {
|
||
Some(StdoutStream {
|
||
sub_id: sub_id.clone(),
|
||
call_id: call_id.clone(),
|
||
tx_event: sess.tx_event.clone(),
|
||
session: None,
|
||
tail_buf: None,
|
||
order: Some(crate::protocol::OrderMeta { request_ordinal: attempt_req, output_index: None, sequence_number: None }),
|
||
})
|
||
},
|
||
},
|
||
None,
|
||
None,
|
||
attempt_req,
|
||
)
|
||
.await;
|
||
|
||
match retry_output_result {
|
||
Ok(retry_output) => {
|
||
let ExecToolCallOutput { exit_code, .. } = &retry_output;
|
||
|
||
let is_success = *exit_code == 0;
|
||
let content = format_exec_output_with_limit(sess, &sub_id, &call_id, &retry_output);
|
||
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content,
|
||
success: Some(is_success),
|
||
},
|
||
}
|
||
}
|
||
Err(e) => ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("retry failed: {e}"),
|
||
success: None,
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
// Limit extremely large tool outputs before sending to the model to avoid
|
||
// context overflows. Keep this conservative because multiple tool outputs
|
||
// can appear in a single turn. The limit is in bytes (on the UTF‑8 string).
|
||
const MAX_TOOL_OUTPUT_BYTES_FOR_MODEL: usize = 32 * 1024; // 32 KiB
|
||
const TRUNCATION_MARKER: &str = "…truncated…\n";
|
||
|
||
fn truncate_middle_bytes(s: &str, max_bytes: usize) -> (String, bool, usize, usize) {
|
||
if s.len() <= max_bytes {
|
||
return (s.to_string(), false, s.len(), s.len());
|
||
}
|
||
if max_bytes == 0 {
|
||
return (TRUNCATION_MARKER.trim_end().to_string(), true, 0, s.len());
|
||
}
|
||
|
||
// Try to keep some head/tail, favoring newline boundaries when possible.
|
||
let keep = max_bytes.saturating_sub("…truncated…\n".len());
|
||
let left_budget = keep / 2;
|
||
let right_budget = keep - left_budget;
|
||
|
||
// Safe prefix end on a char boundary, prefer last newline within budget.
|
||
let prefix_end = {
|
||
let mut end = left_budget.min(s.len());
|
||
if let Some(head) = s.get(..end) {
|
||
if let Some(i) = head.rfind('\n') { end = i + 1; }
|
||
}
|
||
while end > 0 && !s.is_char_boundary(end) { end -= 1; }
|
||
end
|
||
};
|
||
|
||
// Safe suffix start on a char boundary, prefer first newline within budget.
|
||
let suffix_start = {
|
||
let mut start = s.len().saturating_sub(right_budget);
|
||
if let Some(tail) = s.get(start..) {
|
||
if let Some(i) = tail.find('\n') { start += i + 1; }
|
||
}
|
||
while start < s.len() && !s.is_char_boundary(start) { start += 1; }
|
||
start
|
||
};
|
||
|
||
let mut out = String::with_capacity(max_bytes);
|
||
out.push_str(&s[..prefix_end]);
|
||
out.push_str(TRUNCATION_MARKER);
|
||
out.push_str(&s[suffix_start..]);
|
||
(out, true, prefix_end, suffix_start)
|
||
}
|
||
|
||
fn format_exec_output_str(exec_output: &ExecToolCallOutput) -> String {
|
||
let ExecToolCallOutput {
|
||
aggregated_output,
|
||
..
|
||
} = exec_output;
|
||
|
||
// Always use the aggregated (stdout + stderr interleaved) stream so the
|
||
// model sees the full build log regardless of which stream a tool used.
|
||
let mut formatted_output = aggregated_output.text.clone();
|
||
if let Some(truncated_before_bytes) = aggregated_output.truncated_before_bytes {
|
||
let note = format!(
|
||
"… clipped {} from the start of command output (showing last {}).\n\n",
|
||
format_bytes(truncated_before_bytes),
|
||
format_bytes(EXEC_CAPTURE_MAX_BYTES),
|
||
);
|
||
formatted_output = format!("{note}{formatted_output}");
|
||
}
|
||
if let Some(truncated_after_lines) = aggregated_output.truncated_after_lines {
|
||
formatted_output.push_str(&format!(
|
||
"\n\n[Output truncated after {truncated_after_lines} lines: too many lines or bytes.]",
|
||
));
|
||
}
|
||
|
||
formatted_output
|
||
}
|
||
|
||
fn truncate_exec_output_for_storage(
|
||
cwd: &Path,
|
||
sub_id: &str,
|
||
call_id: &str,
|
||
full: &str,
|
||
) -> String {
|
||
let (maybe_truncated, was_truncated, _, _) = truncate_middle_bytes(full, MAX_TOOL_OUTPUT_BYTES_FOR_MODEL);
|
||
if !was_truncated {
|
||
return maybe_truncated;
|
||
}
|
||
|
||
let file_note = match ensure_agent_dir(cwd, sub_id)
|
||
.and_then(|dir| write_agent_file(&dir, &format!("exec-{call_id}.txt"), full))
|
||
{
|
||
Ok(path) => format!("\n\n[Full output saved to: {}]", path.display()),
|
||
Err(e) => format!("\n\n[Full output was too large and truncation applied; failed to save file: {e}]")
|
||
};
|
||
let mut truncated = maybe_truncated;
|
||
truncated.push_str(&file_note);
|
||
truncated
|
||
}
|
||
|
||
/// Exec output serialized for the model. If the payload is too large,
|
||
/// write the full output to a file and include a truncated preview here.
|
||
fn format_exec_output_with_limit(
|
||
sess: &Session,
|
||
sub_id: &str,
|
||
call_id: &str,
|
||
exec_output: &ExecToolCallOutput,
|
||
) -> String {
|
||
let ExecToolCallOutput {
|
||
exit_code,
|
||
duration,
|
||
..
|
||
} = exec_output;
|
||
|
||
#[derive(Serialize)]
|
||
struct ExecMetadata {
|
||
exit_code: i32,
|
||
duration_seconds: f32,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
struct ExecOutput<'a> { output: &'a str, metadata: ExecMetadata }
|
||
|
||
// round to 1 decimal place
|
||
let duration_seconds = ((duration.as_secs_f32()) * 10.0).round() / 10.0;
|
||
|
||
let cwd = sess.get_cwd().to_path_buf();
|
||
let full = format_exec_output_str(exec_output);
|
||
let final_output = truncate_exec_output_for_storage(&cwd, sub_id, call_id, &full);
|
||
|
||
let payload = ExecOutput {
|
||
output: &final_output,
|
||
metadata: ExecMetadata {
|
||
exit_code: *exit_code,
|
||
duration_seconds,
|
||
},
|
||
};
|
||
|
||
#[expect(clippy::expect_used)]
|
||
serde_json::to_string(&payload).expect("serialize ExecOutput")
|
||
}
|
||
|
||
fn format_bytes(bytes: usize) -> String {
|
||
const KIB: f64 = 1024.0;
|
||
const MIB: f64 = KIB * 1024.0;
|
||
const GIB: f64 = MIB * 1024.0;
|
||
let bytes_f = bytes as f64;
|
||
if bytes >= GIB as usize {
|
||
format!("{:.1} GiB", bytes_f / GIB)
|
||
} else if bytes >= MIB as usize {
|
||
format!("{:.1} MiB", bytes_f / MIB)
|
||
} else if bytes >= KIB as usize {
|
||
format!("{:.1} KiB", bytes_f / KIB)
|
||
} else {
|
||
format!("{bytes} B")
|
||
}
|
||
}
|
||
|
||
fn get_last_assistant_message_from_turn(responses: &[ResponseItem]) -> Option<String> {
|
||
responses.iter().rev().find_map(|item| {
|
||
if let ResponseItem::Message { role, content, .. } = item {
|
||
if role == "assistant" {
|
||
content.iter().rev().find_map(|ci| {
|
||
if let ContentItem::OutputText { text } = ci {
|
||
Some(text.clone())
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
} else {
|
||
None
|
||
}
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
}
|
||
|
||
/// Capture a screenshot from the browser and store it for the next model request
|
||
async fn capture_browser_screenshot(_sess: &Session) -> Result<(PathBuf, String), String> {
|
||
let browser_manager = code_browser::global::get_browser_manager()
|
||
.await
|
||
.ok_or_else(|| "No browser manager available".to_string())?;
|
||
|
||
if !browser_manager.is_enabled().await {
|
||
return Err("Browser manager is not enabled".to_string());
|
||
}
|
||
|
||
// Get current URL first
|
||
let url = browser_manager
|
||
.get_current_url()
|
||
.await
|
||
.unwrap_or_else(|| "Browser".to_string());
|
||
tracing::debug!("Attempting to capture screenshot at URL: {}", url);
|
||
|
||
match browser_manager.capture_screenshot().await {
|
||
Ok(screenshots) => {
|
||
if let Some(first_screenshot) = screenshots.first() {
|
||
tracing::info!(
|
||
"Captured browser screenshot: {} at URL: {}",
|
||
first_screenshot.display(),
|
||
url
|
||
);
|
||
Ok((first_screenshot.clone(), url))
|
||
} else {
|
||
let msg = format!("Screenshot capture returned empty results at URL: {}", url);
|
||
tracing::warn!("{}", msg);
|
||
Err(msg)
|
||
}
|
||
}
|
||
Err(e) => {
|
||
let msg = format!("Failed to capture screenshot at {}: {}", url, e);
|
||
tracing::warn!("{}", msg);
|
||
Err(msg)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Send agent status update event to the TUI
|
||
async fn send_agent_status_update(sess: &Session) {
|
||
let manager = AGENT_MANAGER.read().await;
|
||
|
||
// Collect all agents; include completed/failed so HUD can show final messages
|
||
let now = Utc::now();
|
||
let agents: Vec<crate::protocol::AgentInfo> = manager
|
||
.get_all_agents()
|
||
.map(|agent| {
|
||
let start = agent.started_at.unwrap_or(agent.created_at);
|
||
let end = agent.completed_at.unwrap_or(now);
|
||
let elapsed_ms = match end.signed_duration_since(start).num_milliseconds() {
|
||
value if value >= 0 => Some(value as u64),
|
||
_ => None,
|
||
};
|
||
|
||
crate::protocol::AgentInfo {
|
||
id: agent.id.clone(),
|
||
name: agent.model.clone(), // Use model name as the display name
|
||
status: match agent.status {
|
||
AgentStatus::Pending => "pending".to_string(),
|
||
AgentStatus::Running => "running".to_string(),
|
||
AgentStatus::Completed => "completed".to_string(),
|
||
AgentStatus::Failed => "failed".to_string(),
|
||
AgentStatus::Cancelled => "cancelled".to_string(),
|
||
},
|
||
batch_id: agent.batch_id.clone(),
|
||
model: Some(agent.model.clone()),
|
||
last_progress: agent.progress.last().cloned(),
|
||
result: agent.result.clone(),
|
||
error: agent.error.clone(),
|
||
elapsed_ms,
|
||
token_count: None,
|
||
}
|
||
})
|
||
.collect();
|
||
|
||
let event = sess.make_event(
|
||
"agent_status",
|
||
EventMsg::AgentStatusUpdate(AgentStatusUpdateEvent {
|
||
agents,
|
||
context: None,
|
||
task: None,
|
||
}),
|
||
);
|
||
|
||
// Send event asynchronously
|
||
let tx_event = sess.tx_event.clone();
|
||
tokio::spawn(async move {
|
||
if let Err(e) = tx_event.send(event).await {
|
||
tracing::error!("Failed to send agent status update event: {}", e);
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Add a screenshot to pending screenshots for the next model request
|
||
fn add_pending_screenshot(sess: &Session, screenshot_path: PathBuf, url: String) {
|
||
// Do not queue screenshots for next turn anymore; we inject fresh per-turn.
|
||
tracing::info!("Captured screenshot; updating UI and using per-turn injection");
|
||
|
||
// Also send an immediate event to update the TUI display
|
||
let event = sess.make_event(
|
||
"browser_screenshot",
|
||
EventMsg::BrowserScreenshotUpdate(BrowserScreenshotUpdateEvent {
|
||
screenshot_path,
|
||
url,
|
||
}),
|
||
);
|
||
|
||
// Send event asynchronously to avoid blocking
|
||
let tx_event = sess.tx_event.clone();
|
||
tokio::spawn(async move {
|
||
if let Err(e) = tx_event.send(event).await {
|
||
tracing::error!("Failed to send browser screenshot update event: {}", e);
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Consume pending screenshots and return them as ResponseInputItems
|
||
#[allow(dead_code)]
|
||
fn consume_pending_screenshots(sess: &Session) -> Vec<ResponseInputItem> {
|
||
let mut pending = sess.pending_browser_screenshots.lock().unwrap();
|
||
let screenshots = pending.drain(..).collect::<Vec<_>>();
|
||
|
||
screenshots
|
||
.into_iter()
|
||
.map(|path| {
|
||
let metadata = format!(
|
||
"[EPHEMERAL:browser_screenshot] Browser screenshot at {}",
|
||
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
|
||
);
|
||
|
||
// Read the screenshot file and create an ephemeral image
|
||
match std::fs::read(&path) {
|
||
Ok(bytes) => {
|
||
let mime = mime_guess::from_path(&path)
|
||
.first()
|
||
.map(|m| m.to_string())
|
||
.unwrap_or_else(|| "image/png".to_string());
|
||
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||
|
||
ResponseInputItem::Message {
|
||
role: "user".to_string(),
|
||
content: vec![
|
||
ContentItem::InputText { text: metadata },
|
||
ContentItem::InputImage {
|
||
image_url: format!("data:{mime};base64,{encoded}"),
|
||
},
|
||
],
|
||
}
|
||
}
|
||
Err(e) => {
|
||
tracing::error!("Failed to read screenshot {}: {}", path.display(), e);
|
||
ResponseInputItem::Message {
|
||
role: "user".to_string(),
|
||
content: vec![ContentItem::InputText {
|
||
text: format!("Failed to load browser screenshot: {}", e),
|
||
}],
|
||
}
|
||
}
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Helper function to wrap custom tool calls with events
|
||
async fn execute_custom_tool<F, Fut>(
|
||
sess: &Session,
|
||
ctx: &ToolCallCtx,
|
||
tool_name: String,
|
||
parameters: Option<serde_json::Value>,
|
||
tool_fn: F,
|
||
) -> ResponseInputItem
|
||
where
|
||
F: FnOnce() -> Fut,
|
||
Fut: std::future::Future<Output = ResponseInputItem>,
|
||
{
|
||
use crate::protocol::{CustomToolCallBeginEvent, CustomToolCallEndEvent};
|
||
use std::time::Instant;
|
||
|
||
// Send begin event with ordering
|
||
let begin_msg = EventMsg::CustomToolCallBegin(CustomToolCallBeginEvent {
|
||
call_id: ctx.call_id.clone(),
|
||
tool_name: tool_name.clone(),
|
||
parameters: parameters.clone(),
|
||
});
|
||
let begin_order = ctx.order_meta(sess.current_request_ordinal());
|
||
let begin_event = sess.make_event_with_order(&ctx.sub_id, begin_msg, begin_order, ctx.seq_hint);
|
||
sess.send_event(begin_event).await;
|
||
|
||
// Execute the tool
|
||
let start = Instant::now();
|
||
let result = tool_fn().await;
|
||
let duration = start.elapsed();
|
||
|
||
// Extract success/failure from result. Prefer explicit success flag when available.
|
||
let (success, message) = match &result {
|
||
ResponseInputItem::FunctionCallOutput { output, .. } => {
|
||
let content = &output.content;
|
||
let success_flag = output.success;
|
||
(success_flag.unwrap_or(true), content.clone())
|
||
}
|
||
_ => (true, String::from("Tool completed")),
|
||
};
|
||
|
||
// Send end event with ordering
|
||
let end_msg = EventMsg::CustomToolCallEnd(CustomToolCallEndEvent {
|
||
call_id: ctx.call_id.clone(),
|
||
tool_name,
|
||
parameters,
|
||
duration,
|
||
result: if success { Ok(message) } else { Err(message) },
|
||
});
|
||
let end_order = ctx.order_meta(sess.current_request_ordinal());
|
||
let end_event = sess.make_event_with_order(&ctx.sub_id, end_msg, end_order, ctx.seq_hint);
|
||
sess.send_event(end_event).await;
|
||
|
||
result
|
||
}
|
||
|
||
async fn handle_browser_tool(sess: &Session, ctx: &ToolCallCtx, arguments: String) -> ResponseInputItem {
|
||
use serde_json::Value;
|
||
|
||
let parsed_value = match serde_json::from_str::<Value>(&arguments) {
|
||
Ok(value) => value,
|
||
Err(e) => {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: ctx.call_id.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Invalid browser arguments: {e}"),
|
||
success: Some(false),
|
||
},
|
||
};
|
||
}
|
||
};
|
||
|
||
let mut object = match parsed_value {
|
||
Value::Object(map) => map,
|
||
_ => {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: ctx.call_id.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: "Invalid browser arguments: expected an object".to_string(),
|
||
success: Some(false),
|
||
},
|
||
};
|
||
}
|
||
};
|
||
|
||
let action_value = object.remove("action");
|
||
let action = match action_value.and_then(|v| v.as_str().map(|s| s.to_string())) {
|
||
Some(value) => value,
|
||
None => {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: ctx.call_id.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: "Invalid browser arguments: missing 'action'".to_string(),
|
||
success: Some(false),
|
||
},
|
||
};
|
||
}
|
||
};
|
||
|
||
let payload_value = Value::Object(object.clone());
|
||
let payload_string = if object.is_empty() {
|
||
"{}".to_string()
|
||
} else {
|
||
serde_json::to_string(&payload_value).unwrap_or_else(|_| "{}".to_string())
|
||
};
|
||
|
||
let action_lower = action.to_lowercase();
|
||
|
||
match action_lower.as_str() {
|
||
"open" => handle_browser_open(sess, ctx, payload_string.clone()).await,
|
||
"close" => handle_browser_close(sess, ctx).await,
|
||
"status" => handle_browser_status(sess, ctx).await,
|
||
"click" => handle_browser_click(sess, ctx, payload_string.clone()).await,
|
||
"move" => handle_browser_move(sess, ctx, payload_string.clone()).await,
|
||
"type" => handle_browser_type(sess, ctx, payload_string.clone()).await,
|
||
"key" => handle_browser_key(sess, ctx, payload_string.clone()).await,
|
||
"javascript" => handle_browser_javascript(sess, ctx, payload_string.clone()).await,
|
||
"scroll" => handle_browser_scroll(sess, ctx, payload_string.clone()).await,
|
||
"history" => handle_browser_history(sess, ctx, payload_string.clone()).await,
|
||
"inspect" => handle_browser_inspect(sess, ctx, payload_string.clone()).await,
|
||
"console" => handle_browser_console(sess, ctx, payload_string.clone()).await,
|
||
"cdp" => handle_browser_cdp(sess, ctx, payload_string.clone()).await,
|
||
"cleanup" => handle_browser_cleanup(sess, ctx).await,
|
||
"fetch" => handle_web_fetch(sess, ctx, payload_string.clone()).await,
|
||
_ => ResponseInputItem::FunctionCallOutput {
|
||
call_id: ctx.call_id.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Unknown browser action: {}", action),
|
||
success: Some(false),
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
async fn handle_browser_open(sess: &Session, ctx: &ToolCallCtx, arguments: String) -> ResponseInputItem {
|
||
// Parse arguments as JSON for the event
|
||
let params = serde_json::from_str(&arguments).ok();
|
||
|
||
let arguments_clone = arguments.clone();
|
||
let call_id_clone = ctx.call_id.clone();
|
||
|
||
execute_custom_tool(
|
||
sess,
|
||
ctx,
|
||
"browser_open".to_string(),
|
||
params,
|
||
|| async move {
|
||
// Parse the URL from arguments
|
||
let args: Result<Value, _> = serde_json::from_str(&arguments_clone);
|
||
|
||
match args {
|
||
Ok(json) => {
|
||
let url = json
|
||
.get("url")
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or("about:blank");
|
||
|
||
if url.trim().to_ascii_lowercase().starts_with("devtools://") {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: "Developer tools are disabled for this browser session. Use the browser.console tool to inspect logs instead.".to_string(),
|
||
success: Some(false),
|
||
},
|
||
};
|
||
}
|
||
|
||
// Use the global browser manager (create if needed)
|
||
let browser_manager = {
|
||
let existing_global = code_browser::global::get_browser_manager().await;
|
||
if let Some(existing) = existing_global {
|
||
tracing::info!("Using existing global browser manager");
|
||
Some(existing)
|
||
} else {
|
||
tracing::info!("Creating new browser manager");
|
||
let new_manager =
|
||
code_browser::global::get_or_create_browser_manager().await;
|
||
Some(new_manager)
|
||
}
|
||
};
|
||
|
||
if let Some(browser_manager) = browser_manager {
|
||
// Ensure the browser manager is marked enabled so status reflects reality
|
||
browser_manager.set_enabled_sync(true);
|
||
// Clear any lingering node highlight from previous commands
|
||
let _ = browser_manager
|
||
.execute_cdp("Overlay.hideHighlight", serde_json::json!({}))
|
||
.await;
|
||
// Navigate to the URL with detailed timing logs
|
||
let step_start = std::time::Instant::now();
|
||
tracing::info!("[browser_open] begin goto: {}", url);
|
||
match browser_manager.goto(url).await {
|
||
Ok(_) => {
|
||
tracing::info!(
|
||
"[browser_open] goto success: {} in {:?}",
|
||
url,
|
||
step_start.elapsed()
|
||
);
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Browser opened to: {}", url),
|
||
success: Some(true),
|
||
},
|
||
}
|
||
}
|
||
Err(e) => ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: format!(
|
||
"Failed to navigate browser to {}: {}",
|
||
url, e
|
||
),
|
||
success: Some(false),
|
||
},
|
||
},
|
||
}
|
||
} else {
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: "Failed to initialize browser manager.".to_string(),
|
||
success: Some(false),
|
||
},
|
||
}
|
||
}
|
||
}
|
||
Err(e) => ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Failed to parse browser_open arguments: {}", e),
|
||
success: Some(false),
|
||
},
|
||
},
|
||
}
|
||
},
|
||
)
|
||
.await
|
||
}
|
||
|
||
/// Get the browser manager for the session (always uses global)
|
||
async fn get_browser_manager_for_session(
|
||
_sess: &Session,
|
||
) -> Option<Arc<code_browser::BrowserManager>> {
|
||
// Always use the global browser manager
|
||
code_browser::global::get_browser_manager().await
|
||
}
|
||
|
||
async fn handle_browser_close(sess: &Session, ctx: &ToolCallCtx) -> ResponseInputItem {
|
||
let sess_clone = sess;
|
||
let call_id_clone = ctx.call_id.clone();
|
||
|
||
execute_custom_tool(
|
||
sess,
|
||
ctx,
|
||
"browser_close".to_string(),
|
||
None,
|
||
|| async move {
|
||
let browser_manager = get_browser_manager_for_session(sess_clone).await;
|
||
if let Some(browser_manager) = browser_manager {
|
||
// Clear any lingering highlight before closing
|
||
let _ = browser_manager
|
||
.execute_cdp("Overlay.hideHighlight", serde_json::json!({}))
|
||
.await;
|
||
match browser_manager.stop().await {
|
||
Ok(_) => {
|
||
// Clear the browser manager from global
|
||
code_browser::global::clear_browser_manager().await;
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: "Browser closed. Screenshot capture disabled.".to_string(),
|
||
success: Some(true),
|
||
},
|
||
}
|
||
}
|
||
Err(e) => ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Failed to close browser: {}", e),
|
||
success: Some(false),
|
||
},
|
||
},
|
||
}
|
||
} else {
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: "Browser is not currently open.".to_string(),
|
||
success: Some(false),
|
||
},
|
||
}
|
||
}
|
||
},
|
||
)
|
||
.await
|
||
}
|
||
|
||
async fn handle_browser_status(sess: &Session, ctx: &ToolCallCtx) -> ResponseInputItem {
|
||
let sess_clone = sess;
|
||
let call_id_clone = ctx.call_id.clone();
|
||
|
||
execute_custom_tool(
|
||
sess,
|
||
ctx,
|
||
"browser_status".to_string(),
|
||
None,
|
||
|| async move {
|
||
let browser_manager = get_browser_manager_for_session(sess_clone).await;
|
||
if let Some(browser_manager) = browser_manager {
|
||
let _ = browser_manager
|
||
.execute_cdp("Overlay.hideHighlight", serde_json::json!({}))
|
||
.await;
|
||
let status = browser_manager.get_status().await;
|
||
let status_msg = if status.enabled {
|
||
if let Some(url) = status.current_url {
|
||
format!("Browser status: Enabled, currently at {}", url)
|
||
} else {
|
||
"Browser status: Enabled, no page loaded".to_string()
|
||
}
|
||
} else {
|
||
"Browser status: Disabled".to_string()
|
||
};
|
||
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: status_msg,
|
||
success: Some(true),
|
||
},
|
||
}
|
||
} else {
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content:
|
||
"Browser is not initialized. Use browser_open to start the browser."
|
||
.to_string(),
|
||
success: Some(false),
|
||
},
|
||
}
|
||
}
|
||
},
|
||
)
|
||
.await
|
||
}
|
||
|
||
async fn handle_browser_click(sess: &Session, ctx: &ToolCallCtx, arguments: String) -> ResponseInputItem {
|
||
let params = serde_json::from_str::<serde_json::Value>(&arguments).ok();
|
||
let sess_clone = sess;
|
||
let call_id_clone = ctx.call_id.clone();
|
||
|
||
execute_custom_tool(
|
||
sess,
|
||
ctx,
|
||
"browser_click".to_string(),
|
||
params.clone(),
|
||
|| async move {
|
||
let browser_manager = get_browser_manager_for_session(sess_clone).await;
|
||
|
||
if let Some(browser_manager) = browser_manager {
|
||
let _ = browser_manager
|
||
.execute_cdp("Overlay.hideHighlight", serde_json::json!({}))
|
||
.await;
|
||
// Determine click type: default 'click', or 'mousedown'/'mouseup'
|
||
let click_type = params
|
||
.as_ref()
|
||
.and_then(|v| v.get("type"))
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or("click")
|
||
.to_lowercase();
|
||
|
||
// Optional absolute coordinates
|
||
let (mut target_x, mut target_y) = (None, None);
|
||
if let Some(p) = params.as_ref() {
|
||
if let Some(vx) = p.get("x").and_then(|v| v.as_f64()) {
|
||
target_x = Some(vx);
|
||
}
|
||
if let Some(vy) = p.get("y").and_then(|v| v.as_f64()) {
|
||
target_y = Some(vy);
|
||
}
|
||
}
|
||
|
||
// If x or y provided, resolve missing coord from current position, then move
|
||
if target_x.is_some() || target_y.is_some() {
|
||
// get current cursor for missing values
|
||
match browser_manager.get_cursor_position().await {
|
||
Ok((cx, cy)) => {
|
||
let x = target_x.unwrap_or(cx);
|
||
let y = target_y.unwrap_or(cy);
|
||
if let Err(e) = browser_manager.move_mouse(x, y).await {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Failed to move before click: {}", e),
|
||
success: Some(false),
|
||
},
|
||
};
|
||
}
|
||
}
|
||
Err(e) => {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Failed to get current cursor position: {}", e),
|
||
success: Some(false),
|
||
},
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
// Perform the action at current (possibly moved) position
|
||
let action_result = match click_type.as_str() {
|
||
"mousedown" => match browser_manager.mouse_down_at_current().await {
|
||
Ok((x, y)) => Ok((x, y, "Mouse down".to_string())),
|
||
Err(e) => Err(e),
|
||
},
|
||
"mouseup" => match browser_manager.mouse_up_at_current().await {
|
||
Ok((x, y)) => Ok((x, y, "Mouse up".to_string())),
|
||
Err(e) => Err(e),
|
||
},
|
||
"click" | _ => match browser_manager.click_at_current().await {
|
||
Ok((x, y)) => Ok((x, y, "Clicked".to_string())),
|
||
Err(e) => Err(e),
|
||
},
|
||
};
|
||
|
||
match action_result {
|
||
Ok((x, y, label)) => {
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("{} at ({}, {})", label, x, y),
|
||
success: Some(true),
|
||
},
|
||
}
|
||
}
|
||
Err(e) => ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Failed to perform mouse action: {}", e),
|
||
success: Some(false),
|
||
},
|
||
},
|
||
}
|
||
} else {
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: "Browser is not initialized. Use browser_open to start the browser."
|
||
.to_string(),
|
||
success: Some(false),
|
||
},
|
||
}
|
||
}
|
||
},
|
||
)
|
||
.await
|
||
}
|
||
|
||
async fn handle_browser_move(sess: &Session, ctx: &ToolCallCtx, arguments: String) -> ResponseInputItem {
|
||
let params = serde_json::from_str(&arguments).ok();
|
||
let sess_clone = sess;
|
||
let arguments_clone = arguments.clone();
|
||
let call_id_clone = ctx.call_id.clone();
|
||
|
||
execute_custom_tool(
|
||
sess,
|
||
ctx,
|
||
"browser_move".to_string(),
|
||
params,
|
||
|| async move {
|
||
let browser_manager = get_browser_manager_for_session(sess_clone).await;
|
||
|
||
if let Some(browser_manager) = browser_manager {
|
||
let _ = browser_manager
|
||
.execute_cdp("Overlay.hideHighlight", serde_json::json!({}))
|
||
.await;
|
||
let args: Result<Value, _> = serde_json::from_str(&arguments_clone);
|
||
match args {
|
||
Ok(json) => {
|
||
// Check if we have relative movement (dx, dy) or absolute (x, y)
|
||
let has_dx = json.get("dx").is_some();
|
||
let has_dy = json.get("dy").is_some();
|
||
let has_x = json.get("x").is_some();
|
||
let has_y = json.get("y").is_some();
|
||
|
||
let result = if has_dx || has_dy {
|
||
// Relative movement
|
||
let dx = json.get("dx").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||
let dy = json.get("dy").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||
browser_manager.move_mouse_relative(dx, dy).await
|
||
} else if has_x || has_y {
|
||
// Absolute movement
|
||
let x = json.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||
let y = json.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||
browser_manager.move_mouse(x, y).await.map(|_| (x, y))
|
||
} else {
|
||
// No parameters provided, just return current position
|
||
browser_manager.get_cursor_position().await
|
||
};
|
||
|
||
match result {
|
||
Ok((x, y)) => {
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Moved mouse position to ({}, {})", x, y),
|
||
success: Some(true),
|
||
},
|
||
}
|
||
}
|
||
Err(e) => ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Failed to move mouse: {}", e),
|
||
success: Some(false),
|
||
},
|
||
},
|
||
}
|
||
}
|
||
Err(e) => ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Failed to parse browser_move arguments: {}", e),
|
||
success: Some(false),
|
||
},
|
||
},
|
||
}
|
||
} else {
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: "Browser is not initialized. Use browser_open to start the browser."
|
||
.to_string(),
|
||
success: Some(false),
|
||
},
|
||
}
|
||
}
|
||
},
|
||
)
|
||
.await
|
||
}
|
||
|
||
async fn handle_browser_type(sess: &Session, ctx: &ToolCallCtx, arguments: String) -> ResponseInputItem {
|
||
let params = serde_json::from_str(&arguments).ok();
|
||
let sess_clone = sess;
|
||
let arguments_clone = arguments.clone();
|
||
let call_id_clone = ctx.call_id.clone();
|
||
|
||
execute_custom_tool(
|
||
sess,
|
||
ctx,
|
||
"browser_type".to_string(),
|
||
params,
|
||
|| async move {
|
||
let browser_manager = get_browser_manager_for_session(sess_clone).await;
|
||
if let Some(browser_manager) = browser_manager {
|
||
let _ = browser_manager
|
||
.execute_cdp("Overlay.hideHighlight", serde_json::json!({}))
|
||
.await;
|
||
let args: Result<Value, _> = serde_json::from_str(&arguments_clone);
|
||
match args {
|
||
Ok(json) => {
|
||
let text = json.get("text").and_then(|v| v.as_str()).unwrap_or("");
|
||
|
||
match browser_manager.type_text(text).await {
|
||
Ok(_) => {
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Typed: {}", text),
|
||
success: Some(true),
|
||
},
|
||
}
|
||
}
|
||
Err(e) => ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Failed to type text: {}", e),
|
||
success: Some(false),
|
||
},
|
||
},
|
||
}
|
||
}
|
||
Err(e) => ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Failed to parse browser_type arguments: {}", e),
|
||
success: Some(false),
|
||
},
|
||
},
|
||
}
|
||
} else {
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content:
|
||
"Browser is not initialized. Use browser_open to start the browser."
|
||
.to_string(),
|
||
success: Some(false),
|
||
},
|
||
}
|
||
}
|
||
},
|
||
)
|
||
.await
|
||
}
|
||
|
||
async fn handle_browser_key(sess: &Session, ctx: &ToolCallCtx, arguments: String) -> ResponseInputItem {
|
||
let params = serde_json::from_str(&arguments).ok();
|
||
let sess_clone = sess;
|
||
let arguments_clone = arguments.clone();
|
||
let call_id_clone = ctx.call_id.clone();
|
||
|
||
execute_custom_tool(
|
||
sess,
|
||
ctx,
|
||
"browser_key".to_string(),
|
||
params,
|
||
|| async move {
|
||
let browser_manager = get_browser_manager_for_session(sess_clone).await;
|
||
if let Some(browser_manager) = browser_manager {
|
||
let _ = browser_manager
|
||
.execute_cdp("Overlay.hideHighlight", serde_json::json!({}))
|
||
.await;
|
||
let args: Result<Value, _> = serde_json::from_str(&arguments_clone);
|
||
match args {
|
||
Ok(json) => {
|
||
let key = json.get("key").and_then(|v| v.as_str()).unwrap_or("");
|
||
|
||
let normalized = key
|
||
.split_whitespace()
|
||
.collect::<String>()
|
||
.to_ascii_lowercase();
|
||
if matches!(normalized.as_str(), "f12" | "ctrl+shift+i" | "control+shift+i") {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: "Developer tools are disabled for this browser session. Use the browser.console tool to inspect logs instead.".to_string(),
|
||
success: Some(false),
|
||
},
|
||
};
|
||
}
|
||
|
||
match browser_manager.press_key(key).await {
|
||
Ok(_) => {
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Pressed key: {}", key),
|
||
success: Some(true),
|
||
},
|
||
}
|
||
}
|
||
Err(e) => ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Failed to press key: {}", e),
|
||
success: Some(false),
|
||
},
|
||
},
|
||
}
|
||
}
|
||
Err(e) => ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Failed to parse browser_key arguments: {}", e),
|
||
success: Some(false),
|
||
},
|
||
},
|
||
}
|
||
} else {
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content:
|
||
"Browser is not initialized. Use browser_open to start the browser."
|
||
.to_string(),
|
||
success: Some(false),
|
||
},
|
||
}
|
||
}
|
||
},
|
||
)
|
||
.await
|
||
}
|
||
|
||
async fn handle_browser_javascript(sess: &Session, ctx: &ToolCallCtx, arguments: String) -> ResponseInputItem {
|
||
let params = serde_json::from_str(&arguments).ok();
|
||
let sess_clone = sess;
|
||
let arguments_clone = arguments.clone();
|
||
let call_id_clone = ctx.call_id.clone();
|
||
|
||
execute_custom_tool(
|
||
sess,
|
||
ctx,
|
||
"browser_javascript".to_string(),
|
||
params,
|
||
|| async move {
|
||
let browser_manager = get_browser_manager_for_session(sess_clone).await;
|
||
if let Some(browser_manager) = browser_manager {
|
||
let _ = browser_manager
|
||
.execute_cdp("Overlay.hideHighlight", serde_json::json!({}))
|
||
.await;
|
||
let args: Result<Value, _> = serde_json::from_str(&arguments_clone);
|
||
match args {
|
||
Ok(json) => {
|
||
let code = json.get("code").and_then(|v| v.as_str()).unwrap_or("");
|
||
|
||
match browser_manager.execute_javascript(code).await {
|
||
Ok(result) => {
|
||
// Log the JavaScript execution result
|
||
tracing::info!("JavaScript execution returned: {:?}", result);
|
||
|
||
// Format the result for the LLM
|
||
let formatted_result = if let Some(obj) = result.as_object() {
|
||
// Check if it's our wrapped result format
|
||
if let (Some(success), Some(value)) =
|
||
(obj.get("success"), obj.get("value"))
|
||
{
|
||
let logs = obj.get("logs").and_then(|v| v.as_array());
|
||
let mut output = String::new();
|
||
|
||
if let Some(logs) = logs {
|
||
if !logs.is_empty() {
|
||
output.push_str("Console logs:\n");
|
||
for log in logs {
|
||
if let Some(log_str) = log.as_str() {
|
||
output
|
||
.push_str(&format!(" {}\n", log_str));
|
||
}
|
||
}
|
||
output.push_str("\n");
|
||
}
|
||
}
|
||
|
||
if success.as_bool().unwrap_or(false) {
|
||
output.push_str("Result: ");
|
||
output.push_str(
|
||
&serde_json::to_string_pretty(value)
|
||
.unwrap_or_else(|_| "null".to_string()),
|
||
);
|
||
} else if let Some(error) = obj.get("error") {
|
||
output.push_str("Error: ");
|
||
output.push_str(&error.to_string());
|
||
}
|
||
|
||
output
|
||
} else {
|
||
// Fallback to raw JSON if not in expected format
|
||
serde_json::to_string_pretty(&result)
|
||
.unwrap_or_else(|_| "null".to_string())
|
||
}
|
||
} else {
|
||
// Not an object, return as-is
|
||
serde_json::to_string_pretty(&result)
|
||
.unwrap_or_else(|_| "null".to_string())
|
||
};
|
||
|
||
tracing::info!("Returning to LLM: {}", formatted_result);
|
||
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: formatted_result,
|
||
success: Some(true),
|
||
},
|
||
}
|
||
}
|
||
Err(e) => ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Failed to execute JavaScript: {}", e),
|
||
success: Some(false),
|
||
},
|
||
},
|
||
}
|
||
}
|
||
Err(e) => ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Failed to parse browser_javascript arguments: {}", e),
|
||
success: Some(false),
|
||
},
|
||
},
|
||
}
|
||
} else {
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content:
|
||
"Browser is not initialized. Use browser_open to start the browser."
|
||
.to_string(),
|
||
success: Some(false),
|
||
},
|
||
}
|
||
}
|
||
},
|
||
)
|
||
.await
|
||
}
|
||
|
||
async fn handle_browser_scroll(sess: &Session, ctx: &ToolCallCtx, arguments: String) -> ResponseInputItem {
|
||
let params = serde_json::from_str(&arguments).ok();
|
||
let sess_clone = sess;
|
||
let arguments_clone = arguments.clone();
|
||
let call_id_clone = ctx.call_id.clone();
|
||
|
||
execute_custom_tool(
|
||
sess,
|
||
ctx,
|
||
"browser_scroll".to_string(),
|
||
params,
|
||
|| async move {
|
||
let browser_manager = get_browser_manager_for_session(sess_clone).await;
|
||
if let Some(browser_manager) = browser_manager {
|
||
let _ = browser_manager
|
||
.execute_cdp("Overlay.hideHighlight", serde_json::json!({}))
|
||
.await;
|
||
let args: Result<Value, _> = serde_json::from_str(&arguments_clone);
|
||
match args {
|
||
Ok(json) => {
|
||
let dx = json.get("dx").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||
let dy = json.get("dy").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||
|
||
match browser_manager.scroll_by(dx, dy).await {
|
||
Ok(_) => {
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Scrolled by ({}, {})", dx, dy),
|
||
success: Some(true),
|
||
},
|
||
}
|
||
}
|
||
Err(e) => ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Failed to scroll: {}", e),
|
||
success: Some(false),
|
||
},
|
||
},
|
||
}
|
||
}
|
||
Err(e) => ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Failed to parse browser_scroll arguments: {}", e),
|
||
success: Some(false),
|
||
},
|
||
},
|
||
}
|
||
} else {
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: "Browser is not initialized. Use browser_open to start the browser.".to_string(),
|
||
success: Some(false),
|
||
},
|
||
}
|
||
}
|
||
},
|
||
)
|
||
.await
|
||
}
|
||
|
||
async fn handle_browser_console(sess: &Session, ctx: &ToolCallCtx, arguments: String) -> ResponseInputItem {
|
||
let params = serde_json::from_str(&arguments).ok();
|
||
let sess_clone = sess;
|
||
let arguments_clone = arguments.clone();
|
||
let call_id_clone = ctx.call_id.clone();
|
||
|
||
execute_custom_tool(
|
||
sess,
|
||
ctx,
|
||
"browser_console".to_string(),
|
||
params,
|
||
|| async move {
|
||
let browser_manager = get_browser_manager_for_session(sess_clone).await;
|
||
if let Some(browser_manager) = browser_manager {
|
||
let args: Result<Value, _> = serde_json::from_str(&arguments_clone);
|
||
let lines = match args {
|
||
Ok(json) => json.get("lines").and_then(|v| v.as_u64()).map(|n| n as usize),
|
||
Err(_) => None,
|
||
};
|
||
|
||
match browser_manager.get_console_logs(lines).await {
|
||
Ok(logs) => {
|
||
// Format the logs for display
|
||
let formatted = if let Some(logs_array) = logs.as_array() {
|
||
if logs_array.is_empty() {
|
||
"No console logs captured.".to_string()
|
||
} else {
|
||
let mut output = String::new();
|
||
output.push_str("Console logs:\n");
|
||
for log in logs_array {
|
||
if let Some(log_obj) = log.as_object() {
|
||
let timestamp = log_obj.get("timestamp")
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or("");
|
||
let level = log_obj.get("level")
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or("log");
|
||
let message = log_obj.get("message")
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or("");
|
||
|
||
output.push_str(&format!("[{}] [{}] {}\n", timestamp, level.to_uppercase(), message));
|
||
}
|
||
}
|
||
output
|
||
}
|
||
} else {
|
||
"No console logs captured.".to_string()
|
||
};
|
||
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: formatted,
|
||
success: Some(true),
|
||
},
|
||
}
|
||
}
|
||
Err(e) => ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Failed to get console logs: {}", e),
|
||
success: Some(false),
|
||
},
|
||
},
|
||
}
|
||
} else {
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: "Browser is not enabled. Use browser_open to enable it first.".to_string(),
|
||
success: Some(false),
|
||
},
|
||
}
|
||
}
|
||
},
|
||
)
|
||
.await
|
||
}
|
||
|
||
async fn handle_browser_cdp(sess: &Session, ctx: &ToolCallCtx, arguments: String) -> ResponseInputItem {
|
||
let params = serde_json::from_str(&arguments).ok();
|
||
let sess_clone = sess;
|
||
let arguments_clone = arguments.clone();
|
||
let call_id_clone = ctx.call_id.clone();
|
||
|
||
execute_custom_tool(
|
||
sess,
|
||
ctx,
|
||
"browser_cdp".to_string(),
|
||
params,
|
||
|| async move {
|
||
let browser_manager = get_browser_manager_for_session(sess_clone).await;
|
||
if let Some(browser_manager) = browser_manager {
|
||
let _ = browser_manager
|
||
.execute_cdp("Overlay.hideHighlight", serde_json::json!({}))
|
||
.await;
|
||
let args: Result<Value, _> = serde_json::from_str(&arguments_clone);
|
||
match args {
|
||
Ok(json) => {
|
||
let method = json
|
||
.get("method")
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or("")
|
||
.to_string();
|
||
let params = json.get("params").cloned().unwrap_or_else(|| Value::Object(serde_json::Map::new()));
|
||
let target = json
|
||
.get("target")
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or("page");
|
||
|
||
if method.is_empty() {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: "Missing required field: method".to_string(),
|
||
success: Some(false),
|
||
},
|
||
};
|
||
}
|
||
|
||
let exec_res = if target == "browser" {
|
||
browser_manager.execute_cdp_browser(&method, params).await
|
||
} else {
|
||
browser_manager.execute_cdp(&method, params).await
|
||
};
|
||
|
||
match exec_res {
|
||
Ok(result) => {
|
||
let pretty = serde_json::to_string_pretty(&result)
|
||
.unwrap_or_else(|_| "<non-serializable result>".to_string());
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: pretty,
|
||
success: Some(true),
|
||
},
|
||
}
|
||
}
|
||
Err(e) => ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Failed to execute CDP command: {}", e),
|
||
success: Some(false),
|
||
},
|
||
},
|
||
}
|
||
}
|
||
Err(e) => ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Failed to parse browser_cdp arguments: {}", e),
|
||
success: Some(false),
|
||
},
|
||
},
|
||
}
|
||
} else {
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: "Browser is not initialized. Use browser_open to start the browser.".to_string(),
|
||
success: Some(false),
|
||
},
|
||
}
|
||
}
|
||
},
|
||
)
|
||
.await
|
||
}
|
||
|
||
async fn handle_browser_inspect(sess: &Session, ctx: &ToolCallCtx, arguments: String) -> ResponseInputItem {
|
||
use serde_json::json;
|
||
let params = serde_json::from_str(&arguments).ok();
|
||
let sess_clone = sess;
|
||
let arguments_clone = arguments.clone();
|
||
let call_id_clone = ctx.call_id.clone();
|
||
|
||
execute_custom_tool(
|
||
sess,
|
||
ctx,
|
||
"browser_inspect".to_string(),
|
||
params,
|
||
|| async move {
|
||
let browser_manager = get_browser_manager_for_session(sess_clone).await;
|
||
if let Some(browser_manager) = browser_manager {
|
||
let args: Result<Value, _> = serde_json::from_str(&arguments_clone);
|
||
match args {
|
||
Ok(json) => {
|
||
// Determine target element: by id, by coords, or by cursor
|
||
let id_attr = json.get("id").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||
let mut x = json.get("x").and_then(|v| v.as_f64());
|
||
let mut y = json.get("y").and_then(|v| v.as_f64());
|
||
|
||
if (x.is_none() || y.is_none()) && id_attr.is_none() {
|
||
// No coords provided; use current cursor
|
||
if let Ok((cx, cy)) = browser_manager.get_cursor_position().await {
|
||
x = Some(cx);
|
||
y = Some(cy);
|
||
}
|
||
}
|
||
|
||
// Resolve nodeId
|
||
let node_id_value = if let Some(id_attr) = id_attr.clone() {
|
||
// Use DOM.getDocument -> DOM.querySelector with selector `#id`
|
||
let doc = browser_manager
|
||
.execute_cdp("DOM.getDocument", json!({}))
|
||
.await
|
||
.map_err(|e| e);
|
||
let root_id = match doc {
|
||
Ok(v) => v.get("root").and_then(|r| r.get("nodeId")).and_then(|n| n.as_u64()),
|
||
Err(_) => None,
|
||
};
|
||
if let Some(root_node_id) = root_id {
|
||
let sel = format!("#{}", id_attr);
|
||
let q = browser_manager
|
||
.execute_cdp(
|
||
"DOM.querySelector",
|
||
json!({"nodeId": root_node_id, "selector": sel}),
|
||
)
|
||
.await;
|
||
match q {
|
||
Ok(v) => v.get("nodeId").cloned(),
|
||
Err(_) => None,
|
||
}
|
||
} else {
|
||
None
|
||
}
|
||
} else if let (Some(x), Some(y)) = (x, y) {
|
||
// Use DOM.getNodeForLocation
|
||
let res = browser_manager
|
||
.execute_cdp(
|
||
"DOM.getNodeForLocation",
|
||
json!({
|
||
"x": x,
|
||
"y": y,
|
||
"includeUserAgentShadowDOM": true
|
||
}),
|
||
)
|
||
.await;
|
||
match res {
|
||
Ok(v) => {
|
||
// Prefer nodeId; if absent, push backendNodeId
|
||
if let Some(n) = v.get("nodeId").cloned() {
|
||
Some(n)
|
||
} else if let Some(backend) = v.get("backendNodeId").and_then(|b| b.as_u64()) {
|
||
let pushed = browser_manager
|
||
.execute_cdp(
|
||
"DOM.pushNodesByBackendIdsToFrontend",
|
||
json!({ "backendNodeIds": [backend] }),
|
||
)
|
||
.await
|
||
.ok();
|
||
pushed
|
||
.and_then(|pv| pv.get("nodeIds").and_then(|arr| arr.as_array().cloned()))
|
||
.and_then(|arr| arr.first().cloned())
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
Err(_) => None,
|
||
}
|
||
} else {
|
||
None
|
||
};
|
||
|
||
let node_id = match node_id_value.and_then(|v| v.as_u64()) {
|
||
Some(id) => id,
|
||
None => {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: "Failed to resolve target node for inspection".to_string(),
|
||
success: Some(false),
|
||
},
|
||
};
|
||
}
|
||
};
|
||
|
||
// Enable CSS domain to get matched rules
|
||
let _ = browser_manager.execute_cdp("CSS.enable", json!({})).await;
|
||
|
||
// Gather details
|
||
let attrs = browser_manager
|
||
.execute_cdp("DOM.getAttributes", json!({"nodeId": node_id}))
|
||
.await
|
||
.unwrap_or_else(|_| json!({}));
|
||
let outer = browser_manager
|
||
.execute_cdp("DOM.getOuterHTML", json!({"nodeId": node_id}))
|
||
.await
|
||
.unwrap_or_else(|_| json!({}));
|
||
let box_model = browser_manager
|
||
.execute_cdp("DOM.getBoxModel", json!({"nodeId": node_id}))
|
||
.await
|
||
.unwrap_or_else(|_| json!({}));
|
||
let styles = browser_manager
|
||
.execute_cdp("CSS.getMatchedStylesForNode", json!({"nodeId": node_id}))
|
||
.await
|
||
.unwrap_or_else(|_| json!({}));
|
||
|
||
// Highlight the inspected node using Overlay domain (no screenshot capture here)
|
||
let _ = browser_manager.execute_cdp("Overlay.enable", json!({})).await;
|
||
let highlight_config = json!({
|
||
"showInfo": true,
|
||
"showStyles": false,
|
||
"showRulers": false,
|
||
"contentColor": {"r": 111, "g": 168, "b": 220, "a": 0.20},
|
||
"paddingColor": {"r": 147, "g": 196, "b": 125, "a": 0.55},
|
||
"borderColor": {"r": 255, "g": 229, "b": 153, "a": 0.60},
|
||
"marginColor": {"r": 246, "g": 178, "b": 107, "a": 0.60}
|
||
});
|
||
let _ = browser_manager.execute_cdp(
|
||
"Overlay.highlightNode",
|
||
json!({ "nodeId": node_id, "highlightConfig": highlight_config })
|
||
).await;
|
||
// Do not hide here; keep highlight until the next browser command.
|
||
|
||
// Format output
|
||
let mut out = String::new();
|
||
if let (Some(ix), Some(iy)) = (x, y) {
|
||
out.push_str(&format!("Target: coordinates ({}, {})\n", ix, iy));
|
||
}
|
||
if let Some(id_attr) = id_attr {
|
||
out.push_str(&format!("Target: id '#{}'\n", id_attr));
|
||
}
|
||
out.push_str(&format!("NodeId: {}\n", node_id));
|
||
|
||
// Attributes
|
||
if let Some(arr) = attrs.get("attributes").and_then(|v| v.as_array()) {
|
||
out.push_str("Attributes:\n");
|
||
let mut it = arr.iter();
|
||
while let (Some(k), Some(v)) = (it.next(), it.next()) {
|
||
out.push_str(&format!(" {}=\"{}\"\n", k.as_str().unwrap_or(""), v.as_str().unwrap_or("")));
|
||
}
|
||
}
|
||
|
||
// Outer HTML
|
||
if let Some(html) = outer.get("outerHTML").and_then(|v| v.as_str()) {
|
||
let one = html.replace('\n', " ");
|
||
let snippet: String = one.chars().take(800).collect();
|
||
out.push_str("\nOuterHTML (truncated):\n");
|
||
out.push_str(&snippet);
|
||
if one.len() > snippet.len() { out.push_str("…"); }
|
||
out.push('\n');
|
||
}
|
||
|
||
// Box Model summary
|
||
if box_model.get("model").is_some() {
|
||
out.push_str("\nBoxModel: available (content/padding/border/margin)\n");
|
||
}
|
||
|
||
// Matched styles summary
|
||
if let Some(rules) = styles.get("matchedCSSRules").and_then(|v| v.as_array()) {
|
||
out.push_str(&format!("Matched CSS rules: {}\n", rules.len()));
|
||
}
|
||
|
||
// No inline screenshot capture; result reflects DOM details only.
|
||
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload { content: out, success: Some(true) },
|
||
}
|
||
}
|
||
Err(e) => ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Failed to parse browser_inspect arguments: {}", e),
|
||
success: Some(false),
|
||
},
|
||
},
|
||
}
|
||
} else {
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: "Browser is not initialized. Use browser_open to start the browser.".to_string(),
|
||
success: Some(false),
|
||
},
|
||
}
|
||
}
|
||
},
|
||
)
|
||
.await
|
||
}
|
||
|
||
async fn handle_browser_history(sess: &Session, ctx: &ToolCallCtx, arguments: String) -> ResponseInputItem {
|
||
let params = serde_json::from_str(&arguments).ok();
|
||
let sess_clone = sess;
|
||
let arguments_clone = arguments.clone();
|
||
let call_id_clone = ctx.call_id.clone();
|
||
|
||
execute_custom_tool(
|
||
sess,
|
||
ctx,
|
||
"browser_history".to_string(),
|
||
params,
|
||
|| async move {
|
||
let browser_manager = get_browser_manager_for_session(sess_clone).await;
|
||
if let Some(browser_manager) = browser_manager {
|
||
let args: Result<Value, _> = serde_json::from_str(&arguments_clone);
|
||
match args {
|
||
Ok(json) => {
|
||
let direction =
|
||
json.get("direction").and_then(|v| v.as_str()).unwrap_or("");
|
||
|
||
if direction != "back" && direction != "forward" {
|
||
return ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!(
|
||
"Unsupported direction: {} (expected 'back' or 'forward')",
|
||
direction
|
||
),
|
||
success: Some(false),
|
||
},
|
||
};
|
||
}
|
||
|
||
let action_res = if direction == "back" {
|
||
browser_manager.history_back().await
|
||
} else {
|
||
browser_manager.history_forward().await
|
||
};
|
||
|
||
match action_res {
|
||
Ok(_) => {
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("History {} triggered", direction),
|
||
success: Some(true),
|
||
},
|
||
}
|
||
}
|
||
Err(e) => ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone.clone(),
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Failed to navigate history: {}", e),
|
||
success: Some(false),
|
||
},
|
||
},
|
||
}
|
||
}
|
||
Err(e) => ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content: format!("Failed to parse browser_history arguments: {}", e),
|
||
success: Some(false),
|
||
},
|
||
},
|
||
}
|
||
} else {
|
||
ResponseInputItem::FunctionCallOutput {
|
||
call_id: call_id_clone,
|
||
output: FunctionCallOutputPayload {
|
||
content:
|
||
"Browser is not initialized. Use browser_open to start the browser."
|
||
.to_string(),
|
||
success: Some(false),
|
||
},
|
||
}
|
||
}
|
||
},
|
||
)
|
||
.await
|
||
}
|
||
|
||
fn extract_shell_script_from_wrapper(argv: &[String]) -> Option<(usize, String)> {
|
||
// Return (index_of_script, script) if argv matches: <shell> (-lc|-c) <script>
|
||
if argv.len() == 3 {
|
||
let shell = std::path::Path::new(&argv[0])
|
||
.file_name()
|
||
.and_then(|s| s.to_str())
|
||
.unwrap_or("");
|
||
let is_shell = matches!(shell, "bash" | "sh" | "zsh");
|
||
let is_flag = matches!(argv[1].as_str(), "-lc" | "-c");
|
||
if is_shell && is_flag {
|
||
return Some((2, argv[2].clone()));
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
struct CatWriteSuggestion {
|
||
label: &'static str,
|
||
original_value: String,
|
||
}
|
||
|
||
fn detect_cat_write(argv: &[String]) -> Option<CatWriteSuggestion> {
|
||
if let Some((_, script)) = extract_shell_script_from_wrapper(argv) {
|
||
if script_contains_cat_write(&script) {
|
||
return Some(CatWriteSuggestion {
|
||
label: "original_script",
|
||
original_value: script,
|
||
});
|
||
}
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
fn script_contains_cat_write(script: &str) -> bool {
|
||
script
|
||
.lines()
|
||
.any(|line| line_contains_cat_heredoc_write(line))
|
||
}
|
||
|
||
fn line_contains_cat_heredoc_write(line: &str) -> bool {
|
||
let trimmed = line.trim_start();
|
||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||
return false;
|
||
}
|
||
|
||
let lower = line.to_ascii_lowercase();
|
||
if !lower.contains("<<") || !lower.contains('>') {
|
||
return false;
|
||
}
|
||
|
||
let bytes = lower.as_bytes();
|
||
let mut idx = 0;
|
||
while idx + 3 <= bytes.len() {
|
||
if bytes[idx..].starts_with(b"cat") {
|
||
if idx > 0 {
|
||
let prev = bytes[idx - 1];
|
||
if prev.is_ascii_alphanumeric() || prev == b'_' {
|
||
idx += 1;
|
||
continue;
|
||
}
|
||
}
|
||
|
||
let after = &lower[idx + 3..];
|
||
let after_trimmed = after.trim_start();
|
||
if after_trimmed.starts_with("<<") {
|
||
let heredoc_offset_in_after = after.find("<<").unwrap_or(0);
|
||
let heredoc_offset = idx + 3 + heredoc_offset_in_after;
|
||
let redirect_section = &lower[heredoc_offset..];
|
||
if let Some(rel_redirect_idx) = redirect_section.find('>') {
|
||
let redirect_idx = heredoc_offset + rel_redirect_idx;
|
||
if redirect_idx > heredoc_offset {
|
||
let redirect_slice = &lower[redirect_idx..];
|
||
if redirect_slice.starts_with(">&") {
|
||
idx += 1;
|
||
continue;
|
||
}
|
||
let after_gt = redirect_slice[1..].trim_start();
|
||
if after_gt.starts_with('&') {
|
||
idx += 1;
|
||
continue;
|
||
}
|
||
if after_gt.starts_with('(') {
|
||
idx += 1;
|
||
continue;
|
||
}
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
idx += 1;
|
||
}
|
||
|
||
false
|
||
}
|
||
|
||
fn guard_apply_patch_outside_branch(branch_root: &Path, action: &ApplyPatchAction) -> Option<String> {
|
||
let branch_norm = match normalize_absolute(branch_root) {
|
||
Some(path) => path,
|
||
None => {
|
||
return Some(format!(
|
||
"apply_patch blocked: failed to resolve /branch worktree root {}. Stay inside the worktree until you finish with `/merge`.",
|
||
branch_root.display()
|
||
));
|
||
}
|
||
};
|
||
let action_cwd_norm = match normalize_absolute(&action.cwd) {
|
||
Some(path) => path,
|
||
None => {
|
||
return Some(format!(
|
||
"apply_patch blocked: the command resolved outside the /branch worktree (cwd {}). Stay inside {} until you finish with `/merge`.",
|
||
action.cwd.display(),
|
||
branch_root.display()
|
||
));
|
||
}
|
||
};
|
||
if !path_within(&action_cwd_norm, &branch_norm) {
|
||
return Some(format!(
|
||
"apply_patch blocked: the active /branch worktree is {} but the command tried to run from {}. Stay inside the worktree until you finish with `/merge`.",
|
||
branch_root.display(),
|
||
action.cwd.display()
|
||
));
|
||
}
|
||
|
||
for path in action.changes().keys() {
|
||
let normalized = match normalize_absolute(path) {
|
||
Some(value) => value,
|
||
None => {
|
||
return Some(format!(
|
||
"apply_patch blocked: could not resolve patch target {} inside worktree {}. Keep edits within the /branch directory.",
|
||
path.display(),
|
||
branch_root.display()
|
||
));
|
||
}
|
||
};
|
||
if !path_within(&normalized, &branch_norm) {
|
||
return Some(format!(
|
||
"apply_patch blocked: patch would modify {} outside the active /branch worktree {}. Apply changes from within the worktree before `/merge`.",
|
||
path.display(),
|
||
branch_root.display()
|
||
));
|
||
}
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
fn normalize_absolute(path: &Path) -> Option<PathBuf> {
|
||
if !path.is_absolute() {
|
||
return None;
|
||
}
|
||
let mut result = PathBuf::new();
|
||
for component in path.components() {
|
||
match component {
|
||
Component::Prefix(prefix) => result.push(prefix.as_os_str()),
|
||
Component::RootDir => result.push(component.as_os_str()),
|
||
Component::CurDir => {}
|
||
Component::ParentDir => {
|
||
if !result.pop() {
|
||
return None;
|
||
}
|
||
}
|
||
Component::Normal(part) => result.push(part),
|
||
}
|
||
}
|
||
if result.as_os_str().is_empty() {
|
||
None
|
||
} else {
|
||
Some(result)
|
||
}
|
||
}
|
||
|
||
fn path_within(path: &Path, base: &Path) -> bool {
|
||
path.starts_with(base)
|
||
}
|
||
|
||
fn guidance_for_cat_write(suggestion: &CatWriteSuggestion) -> String {
|
||
format!(
|
||
"Blocked cat heredoc that writes files directly. Use apply_patch to edit files so changes stay reviewable.\n\n{}: {}",
|
||
suggestion.label,
|
||
suggestion.original_value
|
||
)
|
||
}
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
struct PythonWriteSuggestion {
|
||
label: &'static str,
|
||
original_value: String,
|
||
}
|
||
|
||
fn detect_python_write(argv: &[String]) -> Option<PythonWriteSuggestion> {
|
||
if let Some((_, script)) = extract_shell_script_from_wrapper(argv) {
|
||
if script_contains_python_write(&script) {
|
||
return Some(PythonWriteSuggestion {
|
||
label: "original_script",
|
||
original_value: script,
|
||
});
|
||
}
|
||
}
|
||
|
||
detect_python_write_in_argv(argv)
|
||
}
|
||
|
||
fn detect_python_write_in_argv(argv: &[String]) -> Option<PythonWriteSuggestion> {
|
||
if argv.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
if !is_python_command(&argv[0]) {
|
||
return None;
|
||
}
|
||
|
||
if argv.len() >= 3 && argv[1] == "-c" {
|
||
let code = &argv[2];
|
||
if python_code_writes_files(code) {
|
||
return Some(PythonWriteSuggestion {
|
||
label: "python_inline_script",
|
||
original_value: code.clone(),
|
||
});
|
||
}
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
fn script_contains_python_write(script: &str) -> bool {
|
||
let lower = script.to_ascii_lowercase();
|
||
if !(lower.contains("python ")
|
||
|| lower.contains("python3")
|
||
|| lower.contains("python\n"))
|
||
{
|
||
return false;
|
||
}
|
||
contains_python_write_keywords(&lower)
|
||
}
|
||
|
||
fn python_code_writes_files(code: &str) -> bool {
|
||
contains_python_write_keywords(&code.to_ascii_lowercase())
|
||
}
|
||
|
||
fn contains_python_write_keywords(lower: &str) -> bool {
|
||
const KEYWORDS: &[&str] = &["write_text(", "write_bytes(", ".write_text(", ".write_bytes("];
|
||
KEYWORDS.iter().any(|needle| lower.contains(needle))
|
||
}
|
||
|
||
fn is_python_command(cmd: &str) -> bool {
|
||
std::path::Path::new(cmd)
|
||
.file_name()
|
||
.and_then(|s| s.to_str())
|
||
.map(|name| {
|
||
let lower = name.to_ascii_lowercase();
|
||
matches!(lower.as_str(), "python" | "python3" | "python2")
|
||
})
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
fn guidance_for_python_write(suggestion: &PythonWriteSuggestion) -> String {
|
||
format!(
|
||
"Blocked python command that writes files directly. Use apply_patch to edit files so changes stay reviewable.\n\n{}: {}",
|
||
suggestion.label,
|
||
suggestion.original_value
|
||
)
|
||
}
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
struct RedundantCdSuggestion {
|
||
label: &'static str,
|
||
original_value: String,
|
||
suggested: Vec<String>,
|
||
target_arg: String,
|
||
cwd: PathBuf,
|
||
}
|
||
|
||
fn detect_redundant_cd(argv: &[String], cwd: &Path) -> Option<RedundantCdSuggestion> {
|
||
let normalized_cwd = normalize_path(cwd);
|
||
if let Some((script_index, script)) = extract_shell_script_from_wrapper(argv) {
|
||
if let Some(suggestion) = detect_redundant_cd_in_shell(
|
||
argv,
|
||
script_index,
|
||
&script,
|
||
cwd,
|
||
&normalized_cwd,
|
||
) {
|
||
return Some(suggestion);
|
||
}
|
||
}
|
||
detect_redundant_cd_in_argv(argv, cwd, &normalized_cwd)
|
||
}
|
||
|
||
fn detect_redundant_cd_in_shell(
|
||
argv: &[String],
|
||
script_index: usize,
|
||
script: &str,
|
||
cwd: &Path,
|
||
normalized_cwd: &Path,
|
||
) -> Option<RedundantCdSuggestion> {
|
||
let trimmed = script.trim_start();
|
||
let tokens = shlex_split(trimmed)?;
|
||
if tokens.len() < 3 {
|
||
return None;
|
||
}
|
||
if tokens.first().map(String::as_str) != Some("cd") {
|
||
return None;
|
||
}
|
||
let target = tokens.get(1)?.clone();
|
||
if !is_simple_cd_target(&target) {
|
||
return None;
|
||
}
|
||
let resolved_target = resolve_cd_target(&target, cwd)?;
|
||
if resolved_target != normalized_cwd {
|
||
return None;
|
||
}
|
||
|
||
let mut idx = 2;
|
||
let mut saw_connector = false;
|
||
while idx < tokens.len() && is_connector(&tokens[idx]) {
|
||
saw_connector = true;
|
||
idx += 1;
|
||
}
|
||
if !saw_connector || idx >= tokens.len() {
|
||
return None;
|
||
}
|
||
|
||
let remainder_tokens = tokens[idx..].to_vec();
|
||
let suggested_script = shlex_try_join(remainder_tokens.iter().map(|s| s.as_str()))
|
||
.unwrap_or_else(|_| remainder_tokens.join(" "));
|
||
if suggested_script.trim().is_empty() {
|
||
return None;
|
||
}
|
||
|
||
let mut suggested = argv.to_vec();
|
||
suggested[script_index] = suggested_script;
|
||
|
||
Some(RedundantCdSuggestion {
|
||
label: "original_script",
|
||
original_value: script.to_string(),
|
||
suggested,
|
||
target_arg: target,
|
||
cwd: normalized_cwd.to_path_buf(),
|
||
})
|
||
}
|
||
|
||
fn detect_redundant_cd_in_argv(
|
||
argv: &[String],
|
||
cwd: &Path,
|
||
normalized_cwd: &Path,
|
||
) -> Option<RedundantCdSuggestion> {
|
||
if argv.len() < 4 {
|
||
return None;
|
||
}
|
||
if argv.first().map(String::as_str) != Some("cd") {
|
||
return None;
|
||
}
|
||
let target = argv.get(1)?.clone();
|
||
if !is_simple_cd_target(&target) {
|
||
return None;
|
||
}
|
||
let resolved_target = resolve_cd_target(&target, cwd)?;
|
||
if resolved_target != normalized_cwd {
|
||
return None;
|
||
}
|
||
|
||
let mut idx = 2;
|
||
let mut saw_connector = false;
|
||
while idx < argv.len() && is_connector(&argv[idx]) {
|
||
saw_connector = true;
|
||
idx += 1;
|
||
}
|
||
if !saw_connector || idx >= argv.len() {
|
||
return None;
|
||
}
|
||
|
||
let suggested = argv[idx..].to_vec();
|
||
if suggested.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
Some(RedundantCdSuggestion {
|
||
label: "original_argv",
|
||
original_value: format!("{:?}", argv),
|
||
suggested,
|
||
target_arg: target,
|
||
cwd: normalized_cwd.to_path_buf(),
|
||
})
|
||
}
|
||
|
||
fn resolve_cd_target(target: &str, cwd: &Path) -> Option<PathBuf> {
|
||
if target.is_empty() {
|
||
return None;
|
||
}
|
||
let candidate = if Path::new(target).is_absolute() {
|
||
PathBuf::from(target)
|
||
} else {
|
||
cwd.join(target)
|
||
};
|
||
Some(normalize_path(candidate.as_path()))
|
||
}
|
||
|
||
fn normalize_path(path: &Path) -> PathBuf {
|
||
let mut normalized = PathBuf::new();
|
||
for component in path.components() {
|
||
match component {
|
||
Component::CurDir => {}
|
||
Component::ParentDir => {
|
||
let _ = normalized.pop();
|
||
}
|
||
Component::Prefix(prefix) => {
|
||
normalized = PathBuf::from(prefix.as_os_str());
|
||
}
|
||
Component::RootDir => {
|
||
normalized.push(component.as_os_str());
|
||
}
|
||
Component::Normal(part) => normalized.push(part),
|
||
}
|
||
}
|
||
if normalized.as_os_str().is_empty() {
|
||
PathBuf::from(".")
|
||
} else {
|
||
normalized
|
||
}
|
||
}
|
||
|
||
fn is_simple_cd_target(target: &str) -> bool {
|
||
if target.is_empty() || target == "-" {
|
||
return false;
|
||
}
|
||
!target.chars().any(|ch| matches!(ch, '$' | '`' | '*' | '?' | '[' | ']' | '{' | '}' | '(' | ')' | '|' | '>' | '<' | '!'))
|
||
}
|
||
|
||
fn is_connector(token: &str) -> bool {
|
||
matches!(token, "&&" | ";" | "||")
|
||
}
|
||
|
||
fn guidance_for_redundant_cd(suggestion: &RedundantCdSuggestion) -> String {
|
||
let suggested = serde_json::to_string(&suggestion.suggested)
|
||
.unwrap_or_else(|_| "<failed to serialize suggested argv>".to_string());
|
||
let target_display = shlex_try_join(std::iter::once(suggestion.target_arg.as_str()))
|
||
.unwrap_or_else(|_| suggestion.target_arg.clone());
|
||
format!(
|
||
"Leading cd {target_display} is redundant because the command already runs in {}. Drop the prefix before retrying.\n\n{}: {}\nresend_exact_argv: {}",
|
||
suggestion.cwd.display(),
|
||
suggestion.label,
|
||
suggestion.original_value,
|
||
suggested
|
||
)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod command_guard_detection_tests {
|
||
use super::*;
|
||
use std::path::PathBuf;
|
||
|
||
#[test]
|
||
fn detects_shell_redundant_cd() {
|
||
let cwd = PathBuf::from("/tmp/project");
|
||
let argv = vec![
|
||
"bash".to_string(),
|
||
"-lc".to_string(),
|
||
"cd /tmp/project && ls".to_string(),
|
||
];
|
||
|
||
let suggestion = detect_redundant_cd(&argv, &cwd).expect("should flag redundant cd");
|
||
assert_eq!(suggestion.label, "original_script");
|
||
assert_eq!(suggestion.suggested, vec!["bash".to_string(), "-lc".to_string(), "ls".to_string()]);
|
||
}
|
||
|
||
#[test]
|
||
fn ignores_cd_to_different_directory() {
|
||
let cwd = PathBuf::from("/tmp/project");
|
||
let argv = vec![
|
||
"bash".to_string(),
|
||
"-lc".to_string(),
|
||
"cd /tmp/project/src && ls".to_string(),
|
||
];
|
||
|
||
assert!(detect_redundant_cd(&argv, &cwd).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn skips_dynamic_cd_targets() {
|
||
let cwd = PathBuf::from("/tmp/project");
|
||
let argv = vec![
|
||
"bash".to_string(),
|
||
"-lc".to_string(),
|
||
"cd $PWD && ls".to_string(),
|
||
];
|
||
|
||
assert!(detect_redundant_cd(&argv, &cwd).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn detects_cat_heredoc_write() {
|
||
let argv = vec![
|
||
"bash".to_string(),
|
||
"-lc".to_string(),
|
||
"cat <<'EOF' > code-rs/git-tooling/Cargo.toml\n[package]\nname = \"demo\"\nEOF".to_string(),
|
||
];
|
||
|
||
let suggestion = detect_cat_write(&argv).expect("should flag cat write");
|
||
assert_eq!(suggestion.label, "original_script");
|
||
assert!(suggestion
|
||
.original_value
|
||
.contains("cat <<'EOF' > code-rs/git-tooling/Cargo.toml"));
|
||
}
|
||
|
||
#[test]
|
||
fn allows_cat_heredoc_without_redirect() {
|
||
let argv = vec![
|
||
"bash".to_string(),
|
||
"-lc".to_string(),
|
||
"cat <<'EOF'\nhello\nEOF".to_string(),
|
||
];
|
||
|
||
assert!(detect_cat_write(&argv).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn allows_cat_redirect_to_fd() {
|
||
let argv = vec![
|
||
"bash".to_string(),
|
||
"-lc".to_string(),
|
||
"cat <<'EOF' >&2\nwarn\nEOF".to_string(),
|
||
];
|
||
|
||
assert!(detect_cat_write(&argv).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn detects_python_here_doc_write() {
|
||
let argv = vec![
|
||
"bash".to_string(),
|
||
"-lc".to_string(),
|
||
"python3 - <<'PY'\nfrom pathlib import Path\nPath('docs.txt').write_text('hello')\nPY".to_string(),
|
||
];
|
||
|
||
let suggestion = detect_python_write(&argv).expect("should flag python write");
|
||
assert_eq!(suggestion.label, "original_script");
|
||
assert!(suggestion.original_value.contains("write_text"));
|
||
}
|
||
|
||
#[test]
|
||
fn detects_python_inline_write() {
|
||
let argv = vec![
|
||
"python3".to_string(),
|
||
"-c".to_string(),
|
||
"from pathlib import Path; Path('foo.txt').write_text('hi')".to_string(),
|
||
];
|
||
|
||
let suggestion = detect_python_write(&argv).expect("should flag inline python write");
|
||
assert_eq!(suggestion.label, "python_inline_script");
|
||
assert!(suggestion.original_value.contains("write_text"));
|
||
}
|
||
|
||
#[test]
|
||
fn allows_read_only_python() {
|
||
let argv = vec![
|
||
"python3".to_string(),
|
||
"-c".to_string(),
|
||
"print('hello world')".to_string(),
|
||
];
|
||
|
||
assert!(detect_python_write(&argv).is_none());
|
||
}
|
||
}
|
||
|
||
fn debug_history(label: &str, items: &[ResponseItem]) {
|
||
let preview: Vec<String> = items
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(idx, item)| match item {
|
||
ResponseItem::Message { role, content, .. } => {
|
||
let text = content
|
||
.iter()
|
||
.filter_map(|c| match c {
|
||
ContentItem::InputText { text }
|
||
| ContentItem::OutputText { text } => Some(text.as_str()),
|
||
_ => None,
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join(" ");
|
||
let snippet: String = text.chars().take(80).collect();
|
||
format!("{idx}:{role}:{snippet}")
|
||
}
|
||
_ => format!("{idx}:{:?}", item),
|
||
})
|
||
.collect();
|
||
let rendered = preview.join(" | ");
|
||
if std::env::var_os("CODEX_COMPACT_TRACE").is_some() {
|
||
eprintln!("[compact_history] {} => [{}]", label, rendered);
|
||
}
|
||
info!(target = "code_core::compact_history", "{} => [{}]", label, rendered);
|
||
}
|