use std::collections::HashSet;
use crate::config::{CachePlan, Config, SteeringMode, ToolAdvertising};
use crate::error::{Error, Result};
use crate::event::AgentEvent;
use crate::message::{ChatMessage, Role};
use crate::provider::{self, ChatRequest, OpenAiProvider, Provider, ToolSchema};
use crate::reduce::rehydrate::CAP_NOTICE_MARKER;
use crate::reduce::{self, ReductionLog, ReductionPolicy};
use crate::session::Session;
use crate::sidecar::SidecarWriter;
use crate::tools::{ToolContext, ToolRegistry};
const MAX_SKILL_LOADS_PER_MESSAGE: usize = 6;
const MAX_FILE_MENTIONS_PER_MESSAGE: usize = 10;
const MAX_FILE_MENTION_BYTES: usize = 64 * 1024;
const TOOL_SEARCH: &str = "tool_search";
const EXPAND_REDUCTION: &str = "expand_reduction";
const SIDECAR_SEARCH: &str = "sidecar_search";
const SPAWN_SUBAGENT: &str = "spawn_subagent";
pub const REVIEW_PROMPT_NAME: &str = "code-review";
const SIDE_QUESTION_PREAMBLE: &str = "[side question — answer from the conversation above; this exchange is not part of the conversation and you have no tools for it]";
const CLAUDE_AGENT: &str = "Agent";
const CLAUDE_BASH: &str = "Bash";
const CLAUDE_READ: &str = "Read";
const CLAUDE_WRITE: &str = "Write";
const CLAUDE_EDIT: &str = "Edit";
const CLAUDE_GLOB: &str = "Glob";
const CLAUDE_GREP: &str = "Grep";
const CLAUDE_CRON_CREATE: &str = "CronCreate";
const CLAUDE_CRON_DELETE: &str = "CronDelete";
const CLAUDE_CRON_LIST: &str = "CronList";
const CLAUDE_SCHEDULE_WAKEUP: &str = "ScheduleWakeup";
#[derive(Default)]
pub(crate) struct SteerInbox {
queue: std::collections::VecDeque<QueuedSteer>,
accepting: bool,
}
struct QueuedSteer {
message: String,
sdk_bound: bool,
}
impl SteerInbox {
pub(crate) fn open(&mut self) {
self.queue.clear();
self.accepting = true;
}
pub(crate) fn enqueue(&mut self, message: String) -> bool {
if !self.accepting {
return false;
}
self.queue.push_back(QueuedSteer {
message,
sdk_bound: true,
});
true
}
pub(crate) fn close(&mut self) {
self.accepting = false;
self.queue.retain(|queued| !queued.sdk_bound);
}
fn drain(&mut self, mode: SteeringMode) -> Option<String> {
if self.queue.is_empty() {
return None;
}
match mode {
SteeringMode::All => Some(
self.queue
.drain(..)
.map(|queued| queued.message)
.collect::<Vec<_>>()
.join("\n\n"),
),
SteeringMode::OneAtATime => self.queue.pop_front().map(|queued| queued.message),
}
}
fn drain_or_close(&mut self, mode: SteeringMode) -> Option<String> {
if !self.queue.iter().any(|queued| queued.sdk_bound) {
self.accepting = false;
return None;
}
self.drain(mode)
}
fn queue_unchecked(&mut self, message: String) {
self.queue.push_back(QueuedSteer {
message,
sdk_bound: false,
});
}
fn len(&self) -> usize {
self.queue.len()
}
}
struct SteerTurnGuard {
inbox: std::sync::Arc<std::sync::Mutex<SteerInbox>>,
}
impl SteerTurnGuard {
fn new(inbox: std::sync::Arc<std::sync::Mutex<SteerInbox>>) -> Self {
inbox
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.accepting = true;
Self { inbox }
}
}
impl Drop for SteerTurnGuard {
fn drop(&mut self) {
self.inbox
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.close();
}
}
const SUBAGENT_STATUS: &str = "subagent_status";
const SUBAGENT_MESSAGE: &str = "subagent_message";
const SUBAGENT_RESUME: &str = "subagent_resume";
const BACKGROUND_EXEC: &str = "background_exec";
const BACKGROUND_STATUS: &str = "background_status";
const BACKGROUND_LIST: &str = "background_list";
const BACKGROUND_KILL: &str = "background_kill";
enum PreparedCall {
Done((String, bool)),
Ready {
name: String,
args: serde_json::Value,
},
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
static SUBAGENT_ID_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
fn next_subagent_id() -> String {
let seq = SUBAGENT_ID_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
format!("agent-{:x}-{:x}", now_ms(), seq)
}
type ChildApprovalHandlerFactory = dyn Fn(
String,
std::sync::Arc<std::sync::Mutex<Vec<crate::subagents::QueuedApproval>>>,
) -> std::sync::Arc<dyn crate::permissions::PermissionsApprovalHandler>
+ Send
+ Sync;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ContextUsage {
pub model: String,
pub messages: usize,
pub message_tokens: u64,
pub tool_count: usize,
pub tool_schema_tokens: u64,
pub request_tokens: u64,
pub projected_tokens: u64,
pub response_reserve_tokens: u64,
pub context_limit: Option<u64>,
pub remaining_tokens: u64,
pub used_pct: u32,
pub fits: bool,
}
impl ContextUsage {
pub fn summary_line(&self) -> String {
match self.context_limit {
Some(limit) => format!(
"{} · {}% of {} used ({} projected, {} left) · {} messages {} · {} tool schemas {}",
self.model,
self.used_pct,
crate::tokens::fmt_approx_tokens(limit),
crate::tokens::fmt_approx_tokens(self.projected_tokens),
crate::tokens::fmt_approx_tokens(self.remaining_tokens),
self.messages,
crate::tokens::fmt_approx_tokens(self.message_tokens),
self.tool_count,
crate::tokens::fmt_approx_tokens(self.tool_schema_tokens),
),
None => format!(
"{} · context window unknown · {} projected · {} messages {} · {} tool schemas {}",
self.model,
crate::tokens::fmt_approx_tokens(self.projected_tokens),
self.messages,
crate::tokens::fmt_approx_tokens(self.message_tokens),
self.tool_count,
crate::tokens::fmt_approx_tokens(self.tool_schema_tokens),
),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FallbackHop {
pub from: String,
pub to: String,
pub reason: String,
}
pub fn is_failover_worthy(error: &Error) -> bool {
matches!(error, Error::Provider { status, .. } if *status == 429 || *status >= 500)
}
pub struct Agent {
config: Config,
provider: std::sync::Arc<dyn Provider>,
registry: ToolRegistry,
history: Vec<ChatMessage>,
ctx: ToolContext,
total_output_tokens: u64,
activated_tools: HashSet<String>,
recorder: Option<SidecarWriter>,
journal: Option<std::sync::Arc<std::sync::Mutex<crate::session_journal::SessionJournal>>>,
session_tree: Option<crate::session_tree::SessionTree>,
rewind_undo: Vec<Vec<ChatMessage>>,
journaled_plan: Vec<crate::session_journal::PlanEntry>,
reduction_policy: Option<ReductionPolicy>,
reduction_log: ReductionLog,
imported_prefix_len: Option<usize>,
compacting_manually: bool,
env_context_live: Option<String>,
base_prompt_live: String,
shell_injection: crate::skills::ShellInjection,
spliced_context_blocks: Vec<crate::config::ContextInjectionBlock>,
span_summarizer: Option<std::sync::Arc<dyn reduce::summarize::SpanSummarizer + Send + Sync>>,
last_tool_schema_tier_signature: Option<u64>,
context_limit: Option<u64>,
requests_issued: bool,
last_cache_activity_ms: Option<i64>,
cache_established: bool,
pending_cache_turn: (bool, bool, Option<i64>),
session_titler: Option<std::sync::Arc<dyn crate::session_title::SessionTitler + Send + Sync>>,
usage_log: Vec<crate::usage_log::UsageRecord>,
turn_index: usize,
turn_records: Vec<crate::turn_record::TurnRecord>,
retry_log: std::sync::Arc<crate::provider::RetryLog>,
model_price: Option<crate::pricing::ModelPrice>,
total_cost_usd: f64,
total_steps: usize,
reaped_subagents: std::collections::HashMap<String, Vec<ChatMessage>>,
goal: Option<crate::goals::GoalRecord>,
steer_queue: std::sync::Arc<std::sync::Mutex<SteerInbox>>,
follow_up_queue: std::collections::VecDeque<String>,
doom_loop_last_call: Option<(String, String)>,
doom_loop_streak: u32,
model_change_log: Vec<crate::model_change::ModelChangeRecord>,
git_metadata: Option<crate::git_metadata::GitMetadataRecord>,
permissions_approval_cache: crate::permissions::ApprovalCache,
permissions_approval_handler:
Option<std::sync::Arc<dyn crate::permissions::PermissionsApprovalHandler>>,
mcp_prompts: std::collections::HashMap<String, Box<dyn crate::sdk::SdkPromptSource>>,
skills: Vec<crate::skills::LoopSkill>,
subagent_depth: usize,
subagent_concurrency_gauge: std::sync::Arc<std::sync::atomic::AtomicUsize>,
background_subagents: std::collections::HashMap<String, BackgroundSubagent>,
pending_child_approvals:
std::sync::Arc<std::sync::Mutex<Vec<crate::subagents::QueuedApproval>>>,
child_approval_handler_factory: Option<std::sync::Arc<ChildApprovalHandlerFactory>>,
subagent_store: Option<(std::sync::Arc<crate::store::SessionStore>, String)>,
claude_runtime_manifest: Option<crate::claude_runtime_state::ClaudeRuntimeManifest>,
background_jobs: std::collections::HashMap<String, BackgroundJob>,
background_concurrency_gauge: std::sync::Arc<std::sync::atomic::AtomicUsize>,
checkpoint_observer: Option<std::sync::Arc<crate::checkpoint::CheckpointObserver>>,
lsp_manager: Option<std::sync::Arc<crate::lsp::LspManager>>,
}
struct BackgroundJob {
child: tokio::process::Child,
command: String,
pid: Option<u32>,
output: std::sync::Arc<crate::background::CapturedOutput>,
started_at_ms: i64,
killed: bool,
_guard: crate::subagents::ConcurrencyGuard,
}
fn background_job_status(job: &mut BackgroundJob) -> crate::background::JobStatus {
if job.killed {
return crate::background::JobStatus::Killed;
}
match job.child.try_wait() {
Ok(Some(status)) => crate::background::JobStatus::Exited(status.code()),
Ok(None) | Err(_) => crate::background::JobStatus::Running,
}
}
#[cfg(unix)]
fn kill_job_process_group(job: &mut BackgroundJob) {
if let Some(pid) = job.pid {
unsafe {
libc::kill(-(pid as libc::pid_t), libc::SIGKILL);
}
}
let _ = job.child.start_kill();
let _ = job.child.try_wait();
}
#[cfg(not(unix))]
fn kill_job_process_group(job: &mut BackgroundJob) {
let _ = job.child.start_kill();
}
fn spawn_output_reader<R>(
reader: R,
output: std::sync::Arc<crate::background::CapturedOutput>,
cap: usize,
) -> tokio::task::JoinHandle<()>
where
R: tokio::io::AsyncRead + Unpin + Send + 'static,
{
tokio::spawn(async move {
use tokio::io::AsyncReadExt;
let mut reader = reader;
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf).await {
Ok(0) => break,
Ok(n) => {
let chunk = String::from_utf8_lossy(&buf[..n]);
output.append(&chunk, cap);
}
Err(_) => break,
}
}
})
}
fn merge_project_agent_definitions(config: &mut Config) {
if !config.subagents_enabled || config.subagent_depth > 0 {
return;
}
match crate::claude_compat::load_project_agents(&config.cwd) {
Ok(agents) => {
for agent in agents {
config
.subagents_definitions
.entry(agent.definition.name.clone())
.or_insert(agent.definition);
}
}
Err(e) => {
tracing::debug!(
error = %e,
"skipping .claude/agents discovery: a definition file could not be parsed"
);
}
}
}
struct BackgroundSubagent {
handle: tokio::task::JoinHandle<(String, Result<String>, Vec<ChatMessage>)>,
task: String,
agent_type: Option<String>,
started_at_ms: i64,
mailbox: std::sync::Arc<std::sync::Mutex<SteerInbox>>,
}
impl Drop for Agent {
fn drop(&mut self) {
for (child_id, bg) in self.background_subagents.drain() {
if !bg.handle.is_finished() {
tracing::debug!(
child_id = %child_id,
"parent Agent dropped: aborting still-running background subagent \
to stop further provider spend"
);
}
bg.handle.abort();
}
for (job_id, mut job) in self.background_jobs.drain() {
if !job.killed {
tracing::debug!(
job_id = %job_id,
command = %job.command,
"parent Agent dropped: killing still-tracked background job's real \
OS process (and its whole process group — see \
`kill_job_process_group`)"
);
}
kill_job_process_group(&mut job);
}
if let Some(lsp) = &self.lsp_manager {
lsp.kill_all_sync();
}
}
}
fn run_api_key_cmd(cmd: &str) -> String {
match std::process::Command::new("sh").arg("-c").arg(cmd).output() {
Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_string(),
Ok(out) => {
tracing::warn!(
"api_key_cmd exited with status {:?}; falling back to api_key_env",
out.status.code()
);
String::new()
}
Err(e) => {
tracing::warn!("api_key_cmd failed to run ({e}); falling back to api_key_env");
String::new()
}
}
}
pub(crate) fn run_api_key_command(argv: &[String]) -> String {
let Some((program, args)) = argv.split_first() else {
return String::new();
};
match std::process::Command::new(program).args(args).output() {
Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_string(),
Ok(out) => {
tracing::warn!(
"api_key_command exited with status {:?}; trying the next credential source",
out.status.code()
);
String::new()
}
Err(e) => {
tracing::warn!(
"api_key_command failed to run ({e}); trying the next credential source"
);
String::new()
}
}
}
fn capture_shell_env() -> std::collections::HashMap<String, String> {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "sh".to_string());
let out = match std::process::Command::new(&shell)
.arg("-lc")
.arg("env")
.output()
{
Ok(o) if o.status.success() => o.stdout,
Ok(o) => {
tracing::warn!(
"shell_env_snapshot: `{shell} -lc env` exited with status {:?}; snapshot is empty",
o.status.code()
);
return std::collections::HashMap::new();
}
Err(e) => {
tracing::warn!(
"shell_env_snapshot: failed to run `{shell} -lc env` ({e}); snapshot is empty"
);
return std::collections::HashMap::new();
}
};
let text = String::from_utf8_lossy(&out);
let mut map = std::collections::HashMap::new();
for line in text.lines() {
if let Some((k, v)) = line.split_once('=') {
if !k.is_empty() {
map.insert(k.to_string(), v.to_string());
}
}
}
map
}
pub(crate) fn build_tool_context(
config: &Config,
) -> (
ToolContext,
Option<std::sync::Arc<crate::checkpoint::CheckpointObserver>>,
Option<std::sync::Arc<crate::lsp::LspManager>>,
) {
let shell_env = if config.shell_env_snapshot {
Some(std::sync::Arc::new(capture_shell_env()))
} else {
None
};
let checkpoint_observer = crate::checkpoint::observer_for_config(config);
let format_observer = crate::formatters::observer_for_config(config);
let lsp_manager = crate::lsp::manager_for_config(config);
let lsp_observer = lsp_manager
.clone()
.map(|m| std::sync::Arc::new(crate::lsp::LspDiagnosticsObserver::new(m)));
let mut observers: Vec<std::sync::Arc<dyn crate::tools::WriteObserver>> = Vec::new();
if let Some(cp) = &checkpoint_observer {
observers.push(cp.clone() as std::sync::Arc<dyn crate::tools::WriteObserver>);
}
if let Some(f) = &format_observer {
observers.push(f.clone() as std::sync::Arc<dyn crate::tools::WriteObserver>);
}
if let Some(l) = &lsp_observer {
observers.push(l.clone() as std::sync::Arc<dyn crate::tools::WriteObserver>);
}
let write_observer: Option<std::sync::Arc<dyn crate::tools::WriteObserver>> =
match observers.len() {
0 => None,
1 => observers.into_iter().next(),
_ => Some(std::sync::Arc::new(crate::tools::WriteObserverChain::new(
observers,
))),
};
let ctx = ToolContext {
cwd: config.cwd.clone(),
extra_roots: config.additional_dirs.clone(),
sandbox: config.sandbox,
multimodal_read: config.read_file_multimodal,
read_line_numbers: config.read_file_line_numbers,
require_read_before_edit: config.edit_file_require_read_before_edit,
read_paths: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
notebook_aware: config.edit_file_notebook_aware,
shell_env,
nested_instructions: config.nested_instructions,
injected_instruction_dirs: std::sync::Arc::new(std::sync::Mutex::new(HashSet::new())),
path_rules: std::sync::Arc::new(Vec::new()),
injected_rule_files: std::sync::Arc::new(std::sync::Mutex::new(HashSet::new())),
network_policy: config.network_policy.clone(),
permission_rules: config.permissions_enabled.then(|| {
std::sync::Arc::new(crate::permissions::RuleSet {
deny: config.tool_deny_patterns.clone(),
ask: config.permissions_ask_patterns.clone(),
allow: config.tool_allow_patterns.clone(),
})
}),
bash_timeout_secs: config
.tool_overrides
.get("bash")
.and_then(|o| o.timeout_secs),
write_observer,
sandbox_os_enabled: config.sandbox_os_enabled,
sandbox_escalation: config.sandbox_escalation,
sandbox_env_policy: config.sandbox_env_policy,
sandbox_approval_handler: None,
question_handler: None,
approval_handler: None,
plan_mode: std::sync::Arc::new(crate::tools::PlanModeState::new()),
context_budget: std::sync::Arc::new(crate::tools::ContextBudget::new()),
plan: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
};
(ctx, checkpoint_observer, lsp_manager)
}
pub(crate) fn global_instructions_dir() -> std::path::PathBuf {
if let Ok(h) = std::env::var("SUPERCODE_HOME") {
if !h.is_empty() {
return std::path::PathBuf::from(h);
}
}
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
if !xdg.is_empty() {
return std::path::PathBuf::from(xdg).join("supercode");
}
}
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
std::path::PathBuf::from(home)
.join(".config")
.join("supercode")
}
fn import_path_is_safe(rel: &str) -> bool {
if rel.is_empty() || rel.contains('\0') {
return false;
}
let path = std::path::Path::new(rel);
if path.is_absolute() || rel.starts_with('~') {
return false;
}
!path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
}
pub(crate) fn import_target_is_contained(
candidate: &std::path::Path,
root: &std::path::Path,
) -> bool {
let (Ok(real_root), Ok(real_candidate)) = (
std::fs::canonicalize(root),
std::fs::canonicalize(candidate),
) else {
return false;
};
real_candidate.starts_with(&real_root)
}
fn expand_instruction_imports(
text: &str,
dir: &std::path::Path,
root: &std::path::Path,
project_scoped: bool,
depth: u8,
) -> String {
if depth >= 4 {
return text.to_string();
}
let mut out = String::with_capacity(text.len());
for token in split_preserving_whitespace(text) {
if let Some(rel) = token.strip_prefix('@') {
if !rel.is_empty()
&& !rel.contains(char::is_whitespace)
&& (!project_scoped || import_path_is_safe(rel))
{
let candidate = dir.join(rel);
if !project_scoped || import_target_is_contained(&candidate, root) {
if let Ok(imported) = std::fs::read_to_string(&candidate) {
let imported = imported.trim();
if !imported.is_empty() {
let imported_dir = candidate.parent().unwrap_or(dir);
out.push_str(&expand_instruction_imports(
imported,
imported_dir,
root,
project_scoped,
depth + 1,
));
continue;
}
}
}
}
}
out.push_str(token);
}
out
}
fn split_preserving_whitespace(text: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut start = 0;
let mut in_ws = None;
for (i, c) in text.char_indices() {
let ws = c.is_whitespace();
match in_ws {
None => in_ws = Some(ws),
Some(prev) if prev != ws => {
out.push(&text[start..i]);
start = i;
in_ws = Some(ws);
}
_ => {}
}
}
if start < text.len() {
out.push(&text[start..]);
}
out
}
fn instruction_file_excluded(
config: &Config,
path: &std::path::Path,
root: &std::path::Path,
) -> bool {
if config.project_doc_excludes.is_empty() {
return false;
}
let full = path.to_string_lossy().to_string();
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let rel = path
.strip_prefix(root)
.ok()
.map(|p| p.to_string_lossy().to_string());
config.project_doc_excludes.iter().any(|pat| {
crate::config::glob_match(pat, &full)
|| crate::config::glob_match(pat, &name)
|| rel
.as_deref()
.is_some_and(|r| crate::config::glob_match(pat, r))
})
}
fn strip_html_comments(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(open) = rest.find("<!--") {
out.push_str(&rest[..open]);
match rest[open..].find("-->") {
Some(close) => rest = &rest[open + close + 3..],
None => return out,
}
}
out.push_str(rest);
out
}
fn append_instruction_file(
blob: &mut String,
config: &Config,
path: &std::path::Path,
root: &std::path::Path,
label: &str,
project_scoped: bool,
budget: &mut InstructionBudget,
) {
if budget.exhausted() || instruction_file_excluded(config, path, root) {
return;
}
let Ok(text) = std::fs::read_to_string(path) else {
return;
};
let stripped;
let text = if config.project_doc_strip_comments {
stripped = strip_html_comments(&text);
stripped.trim()
} else {
text.trim()
};
if text.is_empty() {
return;
}
let dir = path.parent().unwrap_or(std::path::Path::new("."));
let mut content = if config.instruction_imports {
expand_instruction_imports(text, dir, root, project_scoped, 0)
} else {
text.to_string()
};
if !budget.take(&mut content) {
return;
}
blob.push_str(&format!("\n\n# {label}\n{content}"));
}
fn truncate_at_char_boundary(s: &mut String, max: usize) {
let mut end = max;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
s.truncate(end);
}
struct InstructionBudget {
remaining: Option<usize>,
hit: bool,
}
impl InstructionBudget {
fn new(config: &Config) -> Self {
InstructionBudget {
remaining: config.project_doc_max_bytes,
hit: false,
}
}
fn exhausted(&self) -> bool {
self.remaining == Some(0)
}
fn take(&mut self, content: &mut String) -> bool {
let Some(remaining) = self.remaining else {
return true;
};
if content.len() <= remaining {
self.remaining = Some(remaining - content.len());
return true;
}
self.hit = true;
self.remaining = Some(0);
if remaining == 0 {
return false;
}
truncate_at_char_boundary(content, remaining);
content.push_str("\n[supercode: file truncated at core.project_doc_max_bytes]");
true
}
fn aggregate_notice(&self) -> &'static str {
if self.hit {
"\n\n[supercode: instruction content truncated at core.project_doc_max_bytes]"
} else {
""
}
}
}
pub(crate) fn instruction_walk_roots(config: &Config) -> Vec<std::path::PathBuf> {
let root = crate::config::project_root_for(&config.cwd, &config.project_root_markers);
let mut chain: Vec<std::path::PathBuf> = Vec::new();
let mut dir = config.cwd.clone();
loop {
let at_root = root.as_deref() == Some(dir.as_path());
chain.push(dir.clone());
if at_root || chain.len() >= MAX_INSTRUCTION_WALK_DEPTH {
break;
}
match dir.parent() {
Some(parent) if parent != dir => dir = parent.to_path_buf(),
_ => break,
}
}
chain.reverse();
chain
}
const MAX_INSTRUCTION_WALK_DEPTH: usize = 64;
fn base_prompt_for_config(config: &Config) -> String {
crate::model_catalog::base_prompt_for(&config.model_family_prompts, &config.model)
.map(str::to_string)
.unwrap_or_else(|| config.system_prompt.clone())
}
fn assemble_project_instructions(config: &Config) -> String {
let mut blob = String::new();
let mut budget = InstructionBudget::new(config);
let global_dir = global_instructions_dir();
for name in ["CLAUDE.md", "AGENTS.md"] {
append_instruction_file(
&mut blob,
config,
&global_dir.join(name),
&global_dir,
name,
false,
&mut budget,
);
}
if !crate::trust::is_trusted(config, crate::trust::TrustSurface::Instructions) {
blob.push_str(
"\n[project instruction files were not loaded: this workspace is not trusted (capabilities.trust)]\n",
);
blob.push_str(budget.aggregate_notice());
return blob;
}
let walk = instruction_walk_roots(config);
for root in walk.iter().chain(config.additional_dirs.iter()) {
for name in ["CLAUDE.md", "AGENTS.md"] {
append_instruction_file(
&mut blob,
config,
&root.join(name),
root,
name,
true,
&mut budget,
);
}
for path in crate::agent_package::workspace_package_instruction_files(root) {
append_instruction_file(
&mut blob,
config,
&path,
root,
"Supercode agent package instructions",
true,
&mut budget,
);
}
}
blob.push_str(budget.aggregate_notice());
blob
}
fn skills_prompt_section(config: &Config, skills: &[crate::skills::LoopSkill]) -> String {
let listed: Vec<&crate::skills::LoopSkill> = skills
.iter()
.filter(|skill| skill.model_invocable)
.collect();
let mut templates: Vec<&str> = config.prompts.keys().map(String::as_str).collect();
templates.sort_unstable();
if listed.is_empty() && templates.is_empty() {
return String::new();
}
let mut out = String::from("\n\n# Skills\n");
if !listed.is_empty() {
out.push_str(
"Installed skill packages. Only each skill's name and description are listed \
here; call the `skill` tool with a name below to load that skill's full \
instructions when it applies, then follow them.\n",
);
for skill in listed {
out.push_str(&skill.index_line());
out.push('\n');
}
}
if !templates.is_empty() {
if !out.ends_with("# Skills\n") {
out.push('\n');
}
out.push_str("Prompt templates (invoke via `/name args`):\n");
for name in templates {
out.push_str(&format!("- {name}\n"));
}
}
out
}
fn env_context_block(config: &Config) -> String {
let mut lines = vec![
format!("cwd: {}", config.cwd.display()),
format!("platform: {}", std::env::consts::OS),
format!(
"date: {}",
crate::sidecar::now_rfc3339().get(..10).unwrap_or("")
),
format!(
"approval policy: {} · sandbox: {}",
approval_policy_label(config.approval),
sandbox_policy_label(config.sandbox),
),
];
let root = crate::config::project_root_for(&config.cwd, &config.project_root_markers)
.unwrap_or_else(|| config.cwd.clone());
if let Some(status) = env_context_git_status(&root) {
lines.push(status);
}
format!("\n\n# Environment\n{}", lines.join("\n"))
}
fn approval_policy_label(policy: crate::config::ApprovalPolicy) -> &'static str {
match policy {
crate::config::ApprovalPolicy::Never => "never",
crate::config::ApprovalPolicy::OnRequest => "on-request",
crate::config::ApprovalPolicy::Untrusted => "untrusted",
crate::config::ApprovalPolicy::ModelRequested => "model-requested",
}
}
fn sandbox_policy_label(policy: crate::tools::SandboxPolicy) -> &'static str {
match policy {
crate::tools::SandboxPolicy::ReadOnly => "read-only",
crate::tools::SandboxPolicy::WorkspaceWrite => "workspace-write",
crate::tools::SandboxPolicy::DangerFullAccess => "danger-full-access",
}
}
fn env_context_git_status(cwd: &std::path::Path) -> Option<String> {
let branch_out = std::process::Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(cwd)
.output()
.ok()?;
if !branch_out.status.success() {
return None;
}
let branch = String::from_utf8_lossy(&branch_out.stdout)
.trim()
.to_string();
if branch.is_empty() {
return None;
}
let dirty = std::process::Command::new("git")
.args(["status", "--porcelain"])
.current_dir(cwd)
.output()
.ok()
.map(|o| !o.stdout.is_empty())
.unwrap_or(false);
Some(format!(
"git branch: {branch} ({})",
if dirty { "dirty" } else { "clean" }
))
}
impl Agent {
pub fn new(config: Config) -> Result<Self> {
let api_key = match &config.api_key {
Some(k) if !k.is_empty() => k.clone(),
_ => match config
.api_key_command
.as_deref()
.filter(|argv| !argv.is_empty())
.map(run_api_key_command)
.filter(|k| !k.is_empty())
.or_else(|| {
config
.api_key_cmd
.as_deref()
.filter(|c| !c.is_empty())
.map(run_api_key_cmd)
}) {
Some(k) if !k.is_empty() => k,
_ => std::env::var(&config.api_key_env)
.ok()
.filter(|k| !k.is_empty())
.ok_or_else(|| Error::MissingApiKey(config.api_key_env.clone()))?,
},
};
let http_options = provider::HttpOptions::from_retry_config(
config.retry_enabled,
config.retry_max_retries,
config.retry_base_delay_ms,
);
if config.max_budget_usd.is_some_and(|b| b > 0.0)
&& crate::pricing::resolve(
&config.model,
config.price_input_per_mtok,
config.price_output_per_mtok,
)
.is_none()
{
return Err(Error::UnpriceableBudget {
model: config.model.clone(),
});
}
let retry_log = std::sync::Arc::new(crate::provider::RetryLog::default());
let provider = OpenAiProvider::new_with_options(
config.base_url.clone(),
api_key,
config.extra_headers.clone(),
http_options,
)
.with_retry_log(retry_log.clone());
let registry = ToolRegistry::from_config(&config);
let mut agent = Self::with_parts(config, Box::new(provider), registry);
agent.retry_log = retry_log;
Ok(agent)
}
pub fn with_provider(config: Config, provider: Box<dyn Provider>) -> Self {
let registry = ToolRegistry::from_config(&config);
Self::with_parts(config, provider, registry)
}
pub fn with_parts(
mut config: Config,
provider: Box<dyn Provider>,
mut registry: ToolRegistry,
) -> Self {
crate::plugins::register_into(&config, &mut registry);
let (mut ctx, checkpoint_observer, lsp_manager) = build_tool_context(&config);
let base_prompt_live = base_prompt_for_config(&config);
let output_style = crate::output_style::resolve(&config);
let mut system = match output_style.as_ref() {
Some(style) if style.replaces_base => style.text.clone(),
_ => base_prompt_live.clone(),
};
if config.load_project_context {
system.push_str(&assemble_project_instructions(&config));
}
let path_rules = crate::path_rules::load(&config);
system.push_str(&crate::path_rules::always_on_text(&path_rules));
let env_context_live = if config.env_context {
let block = env_context_block(&config);
system.push_str(&block);
Some(block)
} else {
None
};
system.push_str(&crate::context_injection::assemble(&config, &[]));
let skills = crate::skills::load_for_config(&config);
if config.module_registry && config.skills_enabled {
let has_read_pathway = config
.core_tools_enabled
.iter()
.any(|t| t == "read_file" || t == "bash");
if has_read_pathway {
system.push_str(&skills_prompt_section(&config, &skills));
}
}
if let Some(style) = output_style.as_ref().filter(|s| !s.replaces_base) {
system.push_str(&style.section());
}
ctx.path_rules = std::sync::Arc::new(path_rules);
let history = vec![ChatMessage::system(system)];
let git_metadata = if config.session_git_metadata {
crate::git_metadata::capture(&config.cwd, now_ms())
} else {
None
};
merge_project_agent_definitions(&mut config);
let subagent_depth = config.subagent_depth;
let shell_injection = crate::skills::ShellInjection::from_config(&config);
let session_tree = if config.session_tree_enabled {
Some(crate::session_tree::SessionTree::new())
} else {
None
};
let model_price = crate::pricing::resolve(
&config.model,
config.price_input_per_mtok,
config.price_output_per_mtok,
);
let permissions_approval_cache = crate::permissions::cache_for_config(&config);
Agent {
config,
provider: std::sync::Arc::from(provider),
registry,
history,
ctx,
total_output_tokens: 0,
activated_tools: HashSet::new(),
recorder: None,
journal: None,
session_tree,
rewind_undo: Vec::new(),
journaled_plan: Vec::new(),
reduction_policy: None,
reduction_log: ReductionLog::default(),
imported_prefix_len: None,
compacting_manually: false,
env_context_live,
base_prompt_live,
shell_injection,
spliced_context_blocks: Vec::new(),
span_summarizer: None,
last_tool_schema_tier_signature: None,
context_limit: None,
requests_issued: false,
last_cache_activity_ms: None,
cache_established: false,
pending_cache_turn: (false, false, None),
session_titler: None,
usage_log: Vec::new(),
turn_index: 0,
turn_records: Vec::new(),
retry_log: std::sync::Arc::new(crate::provider::RetryLog::default()),
model_price,
total_cost_usd: 0.0,
total_steps: 0,
reaped_subagents: std::collections::HashMap::new(),
goal: None,
steer_queue: std::sync::Arc::new(std::sync::Mutex::new(SteerInbox::default())),
follow_up_queue: std::collections::VecDeque::new(),
doom_loop_last_call: None,
doom_loop_streak: 0,
model_change_log: Vec::new(),
git_metadata,
permissions_approval_cache,
permissions_approval_handler: None,
mcp_prompts: std::collections::HashMap::new(),
skills,
subagent_depth,
subagent_concurrency_gauge: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
background_subagents: std::collections::HashMap::new(),
pending_child_approvals: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
child_approval_handler_factory: None,
subagent_store: None,
claude_runtime_manifest: None,
background_jobs: std::collections::HashMap::new(),
background_concurrency_gauge: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(
0,
)),
checkpoint_observer,
lsp_manager,
}
}
pub fn provider_arc(&self) -> std::sync::Arc<dyn Provider> {
self.provider.clone()
}
pub fn config(&self) -> &Config {
&self.config
}
pub fn checkpoint_observer(&self) -> Option<&crate::checkpoint::CheckpointObserver> {
self.checkpoint_observer.as_deref()
}
pub fn lsp_manager(&self) -> Option<&crate::lsp::LspManager> {
self.lsp_manager.as_deref()
}
pub async fn run_subagent(
&self,
system: impl Into<String>,
task: impl Into<String>,
) -> Result<String> {
let mut sub_config = Config::builder()
.model(self.config.model.clone())
.system_prompt(system)
.cwd(self.config.cwd.clone())
.sandbox(self.config.sandbox)
.max_iterations(self.config.max_iterations)
.build();
sub_config.base_url = self.config.base_url.clone();
let mut sub = Agent::with_provider_arc(sub_config, self.provider.clone());
sub.send(task).await
}
pub fn with_provider_arc(mut config: Config, provider: std::sync::Arc<dyn Provider>) -> Self {
let (ctx, checkpoint_observer, lsp_manager) = build_tool_context(&config);
let history = vec![ChatMessage::system(config.system_prompt.clone())];
let mut registry = ToolRegistry::from_config(&config);
crate::plugins::register_into(&config, &mut registry);
let git_metadata = if config.session_git_metadata {
crate::git_metadata::capture(&config.cwd, now_ms())
} else {
None
};
merge_project_agent_definitions(&mut config);
let subagent_depth = config.subagent_depth;
let skills = crate::skills::load_for_config(&config);
let base_prompt_live = config.system_prompt.clone();
let shell_injection = crate::skills::ShellInjection::from_config(&config);
let session_tree = if config.session_tree_enabled {
Some(crate::session_tree::SessionTree::new())
} else {
None
};
let model_price = crate::pricing::resolve(
&config.model,
config.price_input_per_mtok,
config.price_output_per_mtok,
);
let permissions_approval_cache = crate::permissions::cache_for_config(&config);
Agent {
config,
provider,
registry,
history,
ctx,
total_output_tokens: 0,
activated_tools: HashSet::new(),
recorder: None,
journal: None,
session_tree,
rewind_undo: Vec::new(),
journaled_plan: Vec::new(),
reduction_policy: None,
reduction_log: ReductionLog::default(),
imported_prefix_len: None,
compacting_manually: false,
env_context_live: None,
base_prompt_live,
shell_injection,
spliced_context_blocks: Vec::new(),
span_summarizer: None,
last_tool_schema_tier_signature: None,
context_limit: None,
requests_issued: false,
last_cache_activity_ms: None,
cache_established: false,
pending_cache_turn: (false, false, None),
session_titler: None,
usage_log: Vec::new(),
turn_index: 0,
turn_records: Vec::new(),
retry_log: std::sync::Arc::new(crate::provider::RetryLog::default()),
model_price,
total_cost_usd: 0.0,
total_steps: 0,
reaped_subagents: std::collections::HashMap::new(),
goal: None,
steer_queue: std::sync::Arc::new(std::sync::Mutex::new(SteerInbox::default())),
follow_up_queue: std::collections::VecDeque::new(),
doom_loop_last_call: None,
doom_loop_streak: 0,
model_change_log: Vec::new(),
git_metadata,
permissions_approval_cache,
permissions_approval_handler: None,
mcp_prompts: std::collections::HashMap::new(),
skills,
subagent_depth,
subagent_concurrency_gauge: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
background_subagents: std::collections::HashMap::new(),
pending_child_approvals: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
child_approval_handler_factory: None,
subagent_store: None,
claude_runtime_manifest: None,
background_jobs: std::collections::HashMap::new(),
background_concurrency_gauge: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(
0,
)),
checkpoint_observer,
lsp_manager,
}
}
pub fn run_in_background(
mut self,
prompt: impl Into<String>,
) -> tokio::task::JoinHandle<(Self, Result<String>)>
where
Self: Send + 'static,
{
let prompt = prompt.into();
tokio::spawn(async move {
let result = self.send(prompt).await;
(self, result)
})
}
pub fn resume(config: Config, session: Session) -> Result<Self> {
let mut agent = Agent::new(config)?;
agent.load_session(session);
Ok(agent)
}
pub fn resume_recorded(
config: Config,
session: Session,
sidecar_path: &std::path::Path,
) -> Result<Self> {
let mut agent = Agent::new(config)?;
let recorder = SidecarWriter::create(sidecar_path, &session)?;
agent.load_session(session);
agent.recorder = Some(recorder);
Ok(agent)
}
pub fn set_recorder(&mut self, w: SidecarWriter) {
self.recorder = Some(w);
}
pub fn set_journal(&mut self, journal: crate::session_journal::SessionJournal) {
self.journal = Some(std::sync::Arc::new(std::sync::Mutex::new(journal)));
}
pub fn has_journal(&self) -> bool {
self.journal.is_some()
}
fn journal_op(&self, op: crate::session_journal::JournalOp) {
let Some(journal) = &self.journal else { return };
let mut guard = journal
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Err(error) = guard.append(op) {
tracing::warn!("failed to append a session-journal record: {error}");
}
}
pub fn journal_checkpoint(&self, messages: usize) {
self.journal_op(crate::session_journal::JournalOp::Checkpoint { messages });
}
pub fn journal_usage(&self, record: &crate::usage_log::UsageRecord) {
self.journal_op(crate::session_journal::JournalOp::Usage {
record: record.clone(),
});
}
pub fn journal_model_change(&self, record: &crate::model_change::ModelChangeRecord) {
self.journal_op(crate::session_journal::JournalOp::ModelChange {
record: record.clone(),
});
}
pub fn session_tree(&self) -> Option<&crate::session_tree::SessionTree> {
self.session_tree.as_ref()
}
pub fn set_session_tree(&mut self, tree: crate::session_tree::SessionTree) {
if self.config.session_tree_enabled {
self.session_tree = Some(tree);
}
}
pub fn rebuild_session_tree_from_history(&mut self) {
if !self.config.session_tree_enabled {
return;
}
let linear: Vec<ChatMessage> = self.history.iter().skip(1).cloned().collect();
self.session_tree = Some(crate::session_tree::SessionTree::from_linear(
&linear,
now_ms(),
));
}
pub fn rewind_conversation(&mut self, keep: usize) -> RewindOutcome {
let keep = keep.max(1).min(self.history.len());
let removed: Vec<ChatMessage> = self.history.split_off(keep);
if removed.is_empty() {
return RewindOutcome {
kept: self.history.len(),
removed: 0,
preserved_branch: None,
};
}
let removed_count = removed.len();
self.rewind_undo.push(removed);
self.journal_op(crate::session_journal::JournalOp::Rewind { to: keep - 1 });
let preserved_branch = self.session_tree.as_mut().and_then(|tree| {
let path = tree.active_path().unwrap_or_default();
match keep.checked_sub(2).and_then(|i| path.get(i).cloned()) {
Some(node) => tree.rewind(&node, now_ms()).ok().flatten(),
None => None,
}
});
RewindOutcome {
kept: self.history.len(),
removed: removed_count,
preserved_branch,
}
}
pub fn undo_rewind(&mut self) -> bool {
let Some(mut tail) = self.rewind_undo.pop() else {
return false;
};
self.history.append(&mut tail);
self.journal_op(crate::session_journal::JournalOp::Unrewind);
if self.config.session_tree_enabled {
self.rebuild_session_tree_from_history();
}
true
}
pub fn append_recovered_messages(&mut self, messages: &[ChatMessage]) {
for msg in messages {
if let Some(tree) = self.session_tree.as_mut() {
tree.append_message(msg.clone(), now_ms());
}
self.history.push(msg.clone());
}
}
pub fn undoable_rewinds(&self) -> usize {
self.rewind_undo.len()
}
pub fn restore_rewind_undo(&mut self, stack: Vec<Vec<ChatMessage>>) {
self.rewind_undo = stack;
}
pub fn restore_queues(&mut self, steer: &[String], follow_up: &[String]) {
for message in steer {
self.steer_queue
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.queue_unchecked(message.clone());
}
for message in follow_up {
self.follow_up_queue.push_back(message.clone());
}
}
pub fn plan(&self) -> Vec<crate::session_journal::PlanEntry> {
self.ctx.plan_snapshot()
}
pub fn set_plan(&mut self, steps: Vec<crate::session_journal::PlanEntry>) {
self.ctx.set_plan(steps.clone());
self.journaled_plan = steps;
}
fn journal_queue_drain(&self, queue: crate::session_journal::QueueKind, count: usize) {
if count == 0 || !self.config.session_queue_persist {
return;
}
self.journal_op(crate::session_journal::JournalOp::Dequeue { queue, count });
}
fn journal_plan_if_changed(&mut self) {
if !self.config.todos_persist {
return;
}
let current = self.ctx.plan_snapshot();
if current == self.journaled_plan {
return;
}
self.journaled_plan.clone_from(¤t);
self.journal_op(crate::session_journal::JournalOp::Plan { steps: current });
}
pub fn set_reduction_policy(&mut self, policy: ReductionPolicy) {
self.reduction_policy = Some(policy);
}
pub fn reduction_policy(&self) -> Option<&ReductionPolicy> {
self.reduction_policy.as_ref()
}
pub fn set_schema_tier(&mut self, tier: crate::tools::SchemaTier) {
self.config.tool_schema_tier = tier;
}
pub fn set_tool_schema_tier(
&mut self,
name: impl Into<String>,
tier: crate::tools::SchemaTier,
) {
self.config
.tool_overrides
.entry(name.into())
.or_default()
.schema_tier = Some(tier);
}
fn schema_tier_signature(&self) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
self.config.tool_schema_tier.hash(&mut hasher);
let mut overrides: Vec<(&str, crate::tools::SchemaTier)> = self
.config
.tool_overrides
.iter()
.filter_map(|(name, o)| o.schema_tier.map(|t| (name.as_str(), t)))
.collect();
overrides.sort_by_key(|(name, _)| *name);
for (name, tier) in overrides {
name.hash(&mut hasher);
tier.hash(&mut hasher);
}
hasher.finish()
}
pub fn set_span_summarizer(
&mut self,
summarizer: impl reduce::summarize::SpanSummarizer + Send + Sync + 'static,
) {
self.span_summarizer = Some(std::sync::Arc::new(summarizer));
}
pub fn set_span_summarizer_arc(
&mut self,
summarizer: std::sync::Arc<dyn reduce::summarize::SpanSummarizer + Send + Sync>,
) {
self.span_summarizer = Some(summarizer);
}
pub fn prepare_cleared_turns_summary(
&self,
msgs: &[ChatMessage],
policy: &ReductionPolicy,
prior: &ReductionLog,
) -> Option<reduce::PreparedClearSummary> {
let summarizer = self.span_summarizer.as_deref()?;
reduce::prepare_cleared_turns_summary(msgs, policy, prior, summarizer)
}
pub fn set_event_sink(&mut self, sink: crate::EventSink) {
self.config.event_sink = Some(sink);
}
pub fn permissions_approval_cache(&self) -> &crate::permissions::ApprovalCache {
&self.permissions_approval_cache
}
pub fn set_permissions_approval_handler(
&mut self,
handler: impl crate::permissions::PermissionsApprovalHandler + 'static,
) {
let handler: std::sync::Arc<dyn crate::permissions::PermissionsApprovalHandler> =
std::sync::Arc::new(handler);
self.permissions_approval_handler = Some(handler.clone());
self.ctx.sandbox_approval_handler =
Some(crate::sandbox::SandboxApprovalHandler(handler.clone()));
self.ctx.approval_handler = Some(crate::tools::ToolApprovalHandler(handler));
}
pub fn set_user_question_handler(
&mut self,
handler: std::sync::Arc<dyn crate::mcp::McpElicitationHandler>,
) {
self.ctx.question_handler = Some(crate::tools::UserQuestionHandler(handler));
}
pub fn plan_mode(&self) -> &std::sync::Arc<crate::tools::PlanModeState> {
&self.ctx.plan_mode
}
pub fn set_legacy_approval_handler(&mut self, handler: crate::config::ApprovalHandler) {
self.config.approval_handler = Some(handler);
}
pub fn set_subagent_store(
&mut self,
store: std::sync::Arc<crate::store::SessionStore>,
session_name: impl Into<String>,
) {
self.subagent_store = Some((store, session_name.into()));
}
pub fn set_claude_runtime_manifest(
&mut self,
manifest: crate::claude_runtime_state::ClaudeRuntimeManifest,
) {
self.config.claude_runtime_tools_enabled = true;
self.claude_runtime_manifest = Some(manifest);
}
pub fn restore_claude_project_agents(&mut self) -> Result<usize> {
let definitions = crate::claude_compat::load_project_agents(&self.config.cwd)?;
crate::claude_compat::enable_claude_subagent_compatibility(&mut self.config);
for imported in &definitions {
self.config.subagents_definitions.insert(
imported.definition.name.clone(),
imported.definition.clone(),
);
}
Ok(definitions.len())
}
pub fn claude_runtime_manifest(
&self,
) -> Option<&crate::claude_runtime_state::ClaudeRuntimeManifest> {
self.claude_runtime_manifest.as_ref()
}
pub fn claude_runtime_manifest_mut(
&mut self,
) -> Option<&mut crate::claude_runtime_state::ClaudeRuntimeManifest> {
self.claude_runtime_manifest.as_mut()
}
pub fn set_child_approval_handler_factory(
&mut self,
factory: impl Fn(
String,
std::sync::Arc<std::sync::Mutex<Vec<crate::subagents::QueuedApproval>>>,
) -> std::sync::Arc<dyn crate::permissions::PermissionsApprovalHandler>
+ Send
+ Sync
+ 'static,
) {
self.child_approval_handler_factory = Some(std::sync::Arc::new(factory));
}
pub fn pending_child_approvals(&self) -> Vec<crate::subagents::QueuedApproval> {
self.pending_child_approvals
.lock()
.map(|q| q.clone())
.unwrap_or_default()
}
pub fn set_session_titler(
&mut self,
titler: impl crate::session_title::SessionTitler + Send + Sync + 'static,
) {
self.session_titler = Some(std::sync::Arc::new(titler));
}
pub fn auto_title(&self) -> Option<String> {
let titler = self.session_titler.as_deref()?;
crate::session_title::auto_title(&self.history, titler)
}
pub fn usage_records(&self) -> &[crate::usage_log::UsageRecord] {
&self.usage_log
}
pub fn save_usage_log(&self, store: &crate::store::SessionStore, name: &str) -> Result<()> {
store.save_usage_log(name, &self.usage_log)
}
pub fn turn_records(&self) -> &[crate::turn_record::TurnRecord] {
&self.turn_records
}
pub fn save_turn_records(&self, store: &crate::store::SessionStore, name: &str) -> Result<()> {
store.save_turn_records(name, &self.turn_records)
}
pub fn total_cost_usd(&self) -> f64 {
self.total_cost_usd
}
pub fn model_priced(&self) -> bool {
self.model_price.is_some()
}
pub fn total_steps(&self) -> usize {
self.total_steps
}
pub fn note_abort(&mut self, source: &str) {
let messages = self.history.len();
self.emit(AgentEvent::TurnAborted {
source: source.to_string(),
});
self.push_turn_marker(crate::turn_record::TurnMarker::Aborted {
source: source.to_string(),
messages,
});
}
pub fn set_goal(&mut self, objective: impl Into<String>) -> bool {
if !self.config.goals_enabled {
return false;
}
let objective = objective.into();
let now = now_ms();
match &mut self.goal {
Some(goal) => goal.revise(objective.clone(), now),
slot @ None => *slot = Some(crate::goals::GoalRecord::new(objective.clone(), now)),
}
self.push_turn_marker(crate::turn_record::TurnMarker::Goal { objective });
true
}
pub fn goal(&self) -> Option<&crate::goals::GoalRecord> {
self.goal.as_ref()
}
pub fn clear_goal(&mut self) -> bool {
if self.goal.take().is_none() {
return false;
}
self.push_turn_marker(crate::turn_record::TurnMarker::Goal {
objective: String::new(),
});
true
}
pub fn save_goal(&self, store: &crate::store::SessionStore, name: &str) -> Result<()> {
match &self.goal {
Some(goal) => store.save_goal(name, goal),
None => store.clear_goal(name),
}
}
pub fn restore_goal(&mut self, goal: Option<crate::goals::GoalRecord>) {
self.goal = goal;
}
pub fn effort(&self) -> Option<&str> {
self.config.effort.as_deref()
}
pub fn set_effort(&mut self, effort: Option<String>) -> Option<String> {
let previous = self.config.effort.clone();
if previous == effort {
return previous;
}
self.config.effort = effort.clone();
self.push_turn_marker(crate::turn_record::TurnMarker::Effort {
from: previous.clone(),
to: effort,
});
previous
}
pub fn review_prompt(&self, args: &str) -> Option<String> {
self.config
.prompts
.get(REVIEW_PROMPT_NAME)
.map(|template| template.replace("{args}", args.trim()))
}
pub async fn review(&mut self, args: &str) -> Result<String> {
let prompt = self.review_prompt(args).ok_or_else(|| {
Error::Other(format!(
"no `{REVIEW_PROMPT_NAME}` prompt template is configured for this harness"
))
})?;
self.send(prompt).await
}
pub async fn side_question(&self, question: &str) -> Result<String> {
let mut messages = self.history.clone();
if let Some(goal) = &self.goal {
messages.push(ChatMessage::system(goal.reminder()));
}
messages.push(ChatMessage::user(format!(
"{SIDE_QUESTION_PREAMBLE}
{question}"
)));
let mut req = ChatRequest {
model: self.config.model.clone(),
messages,
tools: Vec::new(),
temperature: self.config.temperature,
max_tokens: self.config.max_tokens,
effort: self.config.effort.clone(),
response_format: None,
service_tier: None,
thinking_budget: None,
extra_body: self.config.extra_body.clone(),
};
self.apply_routing(&mut req);
let (assistant, _usage) = self.provider.complete(&req, &|_: &str| {}).await?;
Ok(assistant.content.unwrap_or_default())
}
fn push_turn_marker(&mut self, marker: crate::turn_record::TurnMarker) {
self.push_turn_marker_at(self.turn_index, marker);
}
fn push_turn_marker_at(&mut self, turn: usize, marker: crate::turn_record::TurnMarker) {
self.turn_records.push(crate::turn_record::TurnRecord::new(
turn,
&self.config.model,
now_ms(),
marker,
));
}
pub fn queue_steer(&self, message: impl Into<String>) {
let message = message.into();
if self.config.session_queue_persist {
self.journal_op(crate::session_journal::JournalOp::Enqueue {
queue: crate::session_journal::QueueKind::Steer,
text: message.clone(),
});
}
self.steer_queue
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.queue_unchecked(message);
}
pub(crate) fn steer_queue_handle(&self) -> std::sync::Arc<std::sync::Mutex<SteerInbox>> {
self.steer_queue.clone()
}
pub fn queue_follow_up(&mut self, message: impl Into<String>) {
let message = message.into();
if self.config.session_queue_persist {
self.journal_op(crate::session_journal::JournalOp::Enqueue {
queue: crate::session_journal::QueueKind::FollowUp,
text: message.clone(),
});
}
self.follow_up_queue.push_back(message);
}
pub fn queued_steer_count(&self) -> usize {
self.steer_queue
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.len()
+ self.follow_up_queue.len()
}
pub fn reduction_log(&self) -> &ReductionLog {
&self.reduction_log
}
pub fn set_context_limit(&mut self, limit: u64) {
self.context_limit = Some(limit);
}
pub fn context_limit(&self) -> Option<u64> {
self.context_limit
}
pub fn model(&self) -> &str {
&self.config.model
}
pub fn set_model(&mut self, model: impl Into<String>) {
self.config.model = model.into();
self.refresh_base_prompt();
self.model_price = crate::pricing::resolve(
&self.config.model,
self.config.price_input_per_mtok,
self.config.price_output_per_mtok,
);
}
pub fn switch_model(&mut self, model: impl Into<String>) {
let to = model.into();
if !self.config.model_switch_allow_switch || self.config.model == to {
self.set_model(to);
return;
}
let from = self.config.model.clone();
self.record_model_change(&from, &to, None);
}
pub fn record_model_change(&mut self, from: &str, to: &str, reason: Option<&str>) {
if from == to {
return;
}
let touched = reduce::rehydrate::filter_reasoning_artifacts(&mut self.history);
self.set_model(to.to_string());
let record = crate::model_change::ModelChangeRecord::new(
self.turn_index,
from,
to,
true,
touched,
now_ms(),
)
.with_reason(reason.map(str::to_string));
self.journal_model_change(&record);
self.model_change_log.push(record);
self.emit(AgentEvent::ModelChanged {
from: from.to_string(),
to: to.to_string(),
reason: reason.map(str::to_string),
});
if self.config.model_switch_notice {
let notice = ChatMessage::user(format!(
"[model changed: {from} -> {to}{}]",
match reason {
Some(r) => format!(" ({r})"),
None => String::new(),
}
));
let _ = self.record(¬ice);
self.history.push(notice);
}
}
pub fn set_service_tier(&mut self, tier: Option<String>) {
self.config.service_tier = tier;
}
fn apply_routing(&self, req: &mut ChatRequest) {
let routing = &self.config.model_routing;
let rules = routing.rules_for(&req.model);
let session_effort = match (
self.ctx.plan_mode.is_active(),
self.config.plan_mode_effort.as_deref(),
) {
(true, Some(effort)) => Some(effort),
_ => self.config.effort.as_deref(),
};
req.effort = routing.effective_effort(&req.model, session_effort);
req.thinking_budget = rules.thinking_budget;
req.service_tier = self.config.service_tier.clone().or(rules.service_tier);
}
async fn complete_with_fallback(
&self,
req: &mut ChatRequest,
on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
) -> (Result<(ChatMessage, provider::Usage)>, Vec<FallbackHop>) {
let mut hops = Vec::new();
let mut result = self.provider.complete(req, on_delta).await;
for next in &self.config.model_fallback {
let Err(error) = &result else {
break;
};
if !is_failover_worthy(error) {
break;
}
if next.is_empty() || next == &req.model {
continue;
}
let reason = error.to_string();
let from = std::mem::replace(&mut req.model, next.clone());
reduce::rehydrate::filter_reasoning_artifacts(&mut req.messages);
self.apply_routing(req);
hops.push(FallbackHop {
from,
to: next.clone(),
reason,
});
result = self.provider.complete(req, on_delta).await;
}
(result, hops)
}
pub fn model_change_records(&self) -> &[crate::model_change::ModelChangeRecord] {
&self.model_change_log
}
pub fn save_model_change_log(
&self,
store: &crate::store::SessionStore,
name: &str,
) -> Result<()> {
store.save_model_change_log(name, &self.model_change_log)
}
pub fn git_metadata(&self) -> Option<&crate::git_metadata::GitMetadataRecord> {
self.git_metadata.as_ref()
}
pub fn save_git_metadata(&self, store: &crate::store::SessionStore, name: &str) -> Result<()> {
match &self.git_metadata {
Some(record) => store.save_git_metadata(name, record),
None => Ok(()),
}
}
pub fn session_persist(&self) -> bool {
self.config.session_persist
}
pub fn session_name(&self) -> Option<&str> {
self.config.session_name.as_deref()
}
pub fn request_issued(&self) -> bool {
self.requests_issued
}
pub fn cache_established(&self) -> bool {
self.cache_established
}
pub fn imported_prefix_len(&self) -> Option<usize> {
self.imported_prefix_len
}
pub fn set_reduction_log(&mut self, log: ReductionLog) {
self.reduction_log = log;
}
pub fn load_session(&mut self, session: Session) {
let system = self.history.first().cloned();
self.history.clear();
if let Some(sys) = system {
self.history.push(sys);
}
self.history.extend(session.messages);
self.imported_prefix_len = Some(self.history.len());
self.cache_established = false;
self.last_cache_activity_ms = self
.history
.iter()
.rev()
.find_map(|m| m.metadata.get("timestamp"))
.and_then(|ts| crate::sidecar::rfc3339_to_ms(ts));
}
fn record(&mut self, msg: &ChatMessage) -> Result<()> {
if let Some(recorder) = self.recorder.as_mut() {
recorder.append(msg)?;
}
if let Some(journal) = &self.journal {
let mut guard = journal
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Err(error) = guard.append_message(msg) {
tracing::warn!("failed to journal a message: {error}");
}
}
if let Some(tree) = self.session_tree.as_mut() {
tree.append_message(msg.clone(), now_ms());
}
Ok(())
}
pub fn save_transcript(&self, path: impl AsRef<std::path::Path>) -> Result<()> {
let mut out = String::new();
for m in &self.history {
out.push_str(&serde_json::to_string(m).map_err(Error::Decode)?);
out.push('\n');
}
std::fs::write(path, out)?;
Ok(())
}
pub fn load_transcript(&mut self, path: impl AsRef<std::path::Path>) -> Result<()> {
let text = std::fs::read_to_string(path)?;
let mut history = Vec::new();
for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) {
history.push(serde_json::from_str::<ChatMessage>(line).map_err(Error::Decode)?);
}
self.history = history;
Ok(())
}
pub fn checkpoint(&self) -> usize {
self.history.len()
}
pub fn rewind_to(&mut self, checkpoint: usize) {
self.history.truncate(checkpoint.min(self.history.len()));
}
pub async fn send_with_files(
&mut self,
text: impl Into<String>,
files: &[std::path::PathBuf],
) -> Result<String> {
let mut prompt = text.into();
for path in files {
let block = match std::fs::read(path) {
Ok(bytes) => match String::from_utf8(bytes.clone()) {
Ok(s) => format!("\n\n[file: {}]\n{}", path.display(), s),
Err(_) => format!(
"\n\n[file: {} — {} bytes, binary content omitted]",
path.display(),
bytes.len()
),
},
Err(e) => format!("\n\n[file: {} — could not read: {e}]", path.display()),
};
prompt.push_str(&block);
}
let expanded = self.expand_prompt_async(&prompt).await;
let msg = ChatMessage::user(expanded);
self.guard_candidate_message(&msg)?;
self.record(&msg)?;
self.history.push(msg);
self.run_loop().await
}
pub async fn send_with_images(
&mut self,
text: impl Into<String>,
image_urls: &[String],
) -> Result<String> {
let expanded = self.expand_prompt_async(&text.into()).await;
let msg = ChatMessage::user_with_images(expanded, image_urls);
self.guard_candidate_message(&msg)?;
self.record(&msg)?;
self.history.push(msg);
self.run_loop().await
}
pub fn expand_prompt(&self, input: &str) -> String {
let input = &self.expand_file_mentions(input);
let trimmed = input.trim_start();
let Some(rest) = trimmed.strip_prefix('/') else {
return self.expand_skill_mentions(input);
};
let (name, args) = match rest.split_once(char::is_whitespace) {
Some((n, a)) => (n, a.trim()),
None => (rest, ""),
};
match self.config.prompts.get(name) {
Some(template) => template.replace("{args}", args),
None => match self.expand_skill_command(name, args) {
Some(expanded) => expanded,
None => self.expand_skill_mentions(input),
},
}
}
fn expand_file_mentions(&self, input: &str) -> String {
if !self.config.file_mentions || !input.contains('@') {
return input.to_string();
}
let mut attachments = String::new();
let mut seen: Vec<String> = Vec::new();
for token in input.split_whitespace() {
let Some(rel) = token.strip_prefix('@') else {
continue;
};
let rel = rel.trim_end_matches([',', ';', ':', '.', ')', ']', '"', '\'']);
if rel.is_empty() || seen.iter().any(|s| s == rel) {
continue;
}
let path = if std::path::Path::new(rel).is_absolute() {
std::path::PathBuf::from(rel)
} else {
self.config.cwd.join(rel)
};
if !path.is_file() {
continue;
}
seen.push(rel.to_string());
attachments.push_str(&self.render_mention(rel, &path));
if seen.len() >= MAX_FILE_MENTIONS_PER_MESSAGE {
break;
}
}
if attachments.is_empty() {
return input.to_string();
}
format!("{input}{attachments}")
}
fn render_mention(&self, shown: &str, path: &std::path::Path) -> String {
use crate::permissions::{Decision, PathKind};
let rules = crate::permissions::rules_for_config(&self.config);
let mut roots = vec![self.config.cwd.clone()];
roots.extend(self.config.additional_dirs.iter().cloned());
let decision = crate::permissions::evaluate_path_safe_roots(
&rules,
PathKind::Read,
&roots,
&path.to_string_lossy(),
Decision::Allow,
);
if decision != Decision::Allow {
return format!(
"\n\n[file: {shown} — not attached; the permission rules for this session \
resolve reading it to {decision:?}]"
);
}
match std::fs::read(path) {
Ok(bytes) => match String::from_utf8(bytes) {
Ok(text) => {
let mut text = text;
if text.len() > MAX_FILE_MENTION_BYTES {
let mut cut = MAX_FILE_MENTION_BYTES;
while cut > 0 && !text.is_char_boundary(cut) {
cut -= 1;
}
text.truncate(cut);
text.push_str("\n[file truncated]");
}
format!("\n\n[file: {shown}]\n{text}")
}
Err(e) => format!(
"\n\n[file: {shown} — {} bytes, binary content omitted]",
e.into_bytes().len()
),
},
Err(e) => format!("\n\n[file: {shown} — could not read: {e}]"),
}
}
pub fn skills(&self) -> &[crate::skills::LoopSkill] {
&self.skills
}
fn expand_skill_command(&self, name: &str, args: &str) -> Option<String> {
if self.skills.is_empty() {
return None;
}
let bare = match name.strip_prefix("skill:") {
Some(rest) => rest,
None if self.config.skills_harness.as_deref()
== Some(crate::HarnessId::CLAUDE_CODE) =>
{
name
}
None => return None,
};
let skill = self.find_skill(bare)?;
skill
.body_with_shell(args, &self.shell_injection)
.ok()
.map(|body| crate::skills::render_skill(skill, &body))
}
fn expand_skill_mentions(&self, input: &str) -> String {
if self.skills.is_empty() {
return input.to_string();
}
let mut loaded: Vec<String> = Vec::new();
let mut names: Vec<String> = Vec::new();
if self.config.skills_harness.as_deref() == Some(crate::HarnessId::CODEX) {
for token in input.split_whitespace() {
let Some(slug) = token.strip_prefix('$') else {
continue;
};
let slug =
slug.trim_matches(|c: char| !c.is_alphanumeric() && c != '-' && c != ':');
if slug.is_empty() {
continue;
}
let Some(skill) = self.find_skill(slug) else {
continue;
};
if names.contains(&skill.name) || loaded.len() >= MAX_SKILL_LOADS_PER_MESSAGE {
continue;
}
if let Ok(body) = skill.body_with_shell("", &self.shell_injection) {
names.push(skill.name.clone());
loaded.push(crate::skills::render_skill(skill, &body));
}
}
}
if loaded.is_empty() && self.config.skills_implicit_match {
if let Some(skill) = crate::skills::implicit_skill_match(&self.skills, input) {
if let Ok(body) = skill.body_with_shell("", &self.shell_injection) {
loaded.push(crate::skills::render_skill(skill, &body));
}
}
}
if loaded.is_empty() {
return input.to_string();
}
format!("{input}\n\n{}", loaded.join("\n\n"))
}
fn find_skill(&self, name: &str) -> Option<&crate::skills::LoopSkill> {
crate::skills::find_skill(&self.skills, name)
}
pub async fn expand_prompt_async(&self, input: &str) -> String {
let local = self.expand_prompt(input);
if local != input {
return local; }
let trimmed = input.trim_start();
let Some(rest) = trimmed.strip_prefix('/') else {
return input.to_string();
};
let (name, args) = match rest.split_once(char::is_whitespace) {
Some((n, a)) => (n, a.trim()),
None => (rest, ""),
};
let Some(source) = self.mcp_prompts.get(name) else {
return input.to_string();
};
let arg_map = match source.arg_names() {
[] => std::collections::BTreeMap::new(),
[single] => {
let mut m = std::collections::BTreeMap::new();
if !args.is_empty() {
m.insert(single.clone(), args.to_string());
}
m
}
_ => args
.split_whitespace()
.filter_map(|pair| pair.split_once('='))
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
};
match source.render(arg_map).await {
Ok(rendered) => rendered,
Err(e) => format!("Error: mcp prompt `{name}` failed: {e}"),
}
}
pub fn register_mcp_prompt(
&mut self,
command_name: impl Into<String>,
source: impl crate::sdk::SdkPromptSource + 'static,
) {
self.mcp_prompts
.insert(command_name.into(), Box::new(source));
}
pub fn append_system_note(&mut self, text: &str) {
if let Some(system) = self.history.first_mut() {
if system.role == Role::System {
system
.content
.get_or_insert_with(String::new)
.push_str(text);
}
}
}
pub fn refresh_env_context(&mut self) -> bool {
if !self.config.env_context {
return false;
}
let fresh = env_context_block(&self.config);
let Some(stale) = self.env_context_live.clone() else {
self.append_system_note(&fresh);
self.env_context_live = Some(fresh);
return true;
};
if stale == fresh {
return false;
}
if let Some(system) = self.history.first_mut() {
if system.role == Role::System {
if let Some(content) = system.content.as_mut() {
if let Some(at) = content.find(&stale) {
content.replace_range(at..at + stale.len(), &fresh);
self.env_context_live = Some(fresh);
return true;
}
}
}
}
false
}
fn refresh_base_prompt(&mut self) -> bool {
let fresh = base_prompt_for_config(&self.config);
if fresh == self.base_prompt_live {
return false;
}
let stale = std::mem::replace(&mut self.base_prompt_live, fresh.clone());
if stale.is_empty() {
return false;
}
if let Some(system) = self.history.first_mut() {
if system.role == Role::System {
if let Some(content) = system.content.as_mut() {
if let Some(at) = content.find(&stale) {
content.replace_range(at..at + stale.len(), &fresh);
return true;
}
}
}
}
false
}
fn chat_request(&self, messages: Vec<ChatMessage>, tools: Vec<ToolSchema>) -> ChatRequest {
let mut req = ChatRequest {
model: self.config.model.clone(),
messages,
tools,
temperature: self.config.temperature,
max_tokens: self.config.max_tokens,
effort: self.config.effort.clone(),
response_format: self.config.response_format.clone(),
service_tier: None,
thinking_budget: None,
extra_body: self.config.extra_body.clone(),
};
self.apply_routing(&mut req);
req
}
pub fn model_input(&mut self) -> ChatRequest {
let tools = self.tool_schemas();
let messages = self.build_request_messages();
self.chat_request(messages, tools)
}
pub async fn model_input_for(&mut self, prompt: &str) -> ChatRequest {
let expanded = self.expand_prompt_async(prompt).await;
self.history.push(ChatMessage::user(expanded));
self.model_input()
}
pub fn render_model_input(req: &ChatRequest) -> serde_json::Value {
serde_json::json!({
"model": req.model,
"temperature": req.temperature,
"max_tokens": req.max_tokens,
"effort": req.effort,
"response_format": req.response_format,
"messages": serde_json::to_value(&req.messages).unwrap_or(serde_json::Value::Null),
"tools": serde_json::to_value(&req.tools).unwrap_or(serde_json::Value::Null),
})
}
pub fn base_prompt(&self) -> &str {
&self.base_prompt_live
}
pub fn inject_context_block(
&mut self,
name: impl Into<String>,
content: impl Into<String>,
) -> bool {
if !self.config.context_injections {
return false;
}
let block = crate::config::ContextInjectionBlock::new(name, content);
let rendered = crate::context_injection::render(std::slice::from_ref(&block));
self.spliced_context_blocks.push(block);
self.append_system_note(&rendered);
true
}
pub fn spliced_context_blocks(&self) -> &[crate::config::ContextInjectionBlock] {
&self.spliced_context_blocks
}
pub fn maybe_compact(&mut self) -> bool {
if !self.config.compaction_enabled {
return false;
}
let threshold = self.config.compact_after_messages;
let message_trigger = threshold.is_some_and(|t| self.history.len() > t);
let pressure_trigger = self.compaction_pressure_triggered();
if threshold.is_none() && self.config.compaction_reserve_tokens.is_none() {
return false;
}
if !message_trigger && !pressure_trigger {
return false;
}
if let Some(policy) = self.reduction_policy.as_mut() {
if let Some(t) = threshold {
policy.clear_turns_older_than = Some(t);
}
return message_trigger;
}
let keep_recent = if message_trigger {
(threshold.unwrap() / 2).max(2)
} else {
self.keep_recent_count_by_tokens()
};
self.compact_in_place(keep_recent, None)
}
pub fn compact_now(&mut self, focus: Option<&str>) -> bool {
self.compacting_manually = true;
let compacted = self.compact_now_inner(focus);
self.compacting_manually = false;
compacted
}
fn compact_now_inner(&mut self, focus: Option<&str>) -> bool {
if self.reduction_policy.is_some() {
return false;
}
let keep_recent = self
.keep_recent_count_by_tokens()
.min((self.history.len() / 2).max(2));
let focus = focus.map(str::trim).filter(|f| !f.is_empty());
self.compact_in_place(keep_recent, focus)
}
fn compact_in_place(&mut self, keep_recent: usize, focus_override: Option<&str>) -> bool {
if self.history.len() <= keep_recent {
return false;
}
let mut first = 1usize;
if matches!(self.config.cache_plan, CachePlan::ImportedPrefix) {
if let Some(protected) = self.imported_prefix_len {
first = first.max(protected);
}
}
let mut cut = self.history.len() - keep_recent;
if cut <= first {
return false;
}
while cut < self.history.len() && self.history[cut].role == Role::Tool {
cut += 1;
}
if cut >= self.history.len() {
return false;
}
let dropped = cut - first;
self.fire_lifecycle(&crate::config::LifecycleEvent::PreCompact {
messages: self.history.len(),
dropped,
manual: focus_override.is_some() || self.compacting_manually,
});
let focus: Option<String> = focus_override.map(str::to_string).or_else(|| {
self.config
.compaction_focus_instructions
.clone()
.filter(|f| !f.is_empty())
});
let verb = if self.config.compaction_summarize {
"summarized"
} else {
"cleared"
};
let summary_body = if self.config.compaction_summarize {
self.summarize_span(first..cut, focus.as_deref())
} else {
None
};
let retention = if self.recorder.is_some() {
"The compacted messages remain in this session's transcript sidecar."
} else {
"No transcript sidecar is attached, so this marker is the only remaining record of them."
};
let mut summary_text = format!(
"[earlier conversation compacted: {dropped} message(s) {verb} to save context]\n{retention}"
);
if let Some(focus) = &focus {
summary_text.push_str(&format!("\n\nFocus: {focus}"));
}
if let Some(body) = &summary_body {
summary_text.push_str(&format!("\n\nSummary of the compacted span:\n{body}"));
}
let summary = ChatMessage::system(summary_text);
if let Err(error) = self.record(&summary) {
tracing::warn!("failed to persist the compaction marker: {error}");
}
let mut new_history = Vec::with_capacity(first + keep_recent + 2);
new_history.extend(self.history[..first].iter().cloned());
new_history.push(summary);
new_history.extend(self.history.split_off(cut));
self.history = new_history;
self.fire_lifecycle(&crate::config::LifecycleEvent::PostCompact {
messages: self.history.len(),
dropped,
});
self.journal_checkpoint(self.history.len());
true
}
fn summarize_span(&self, span: std::ops::Range<usize>, focus: Option<&str>) -> Option<String> {
let summarizer = self.span_summarizer.as_deref()?;
let mut span_text = String::new();
if let Some(focus) = focus {
span_text.push_str(&format!("[compaction focus requested: {focus}]\n\n"));
}
for msg in self.history.get(span)? {
let role = match msg.role {
Role::System => "system",
Role::User => "user",
Role::Assistant => "assistant",
Role::Tool => "tool",
};
span_text.push_str(role);
span_text.push_str(": ");
span_text.push_str(msg.content.as_deref().unwrap_or(""));
span_text.push('\n');
}
match summarizer.summarize(&span_text) {
Ok(text) if !text.trim().is_empty() => Some(text.trim().to_string()),
Ok(_) => None,
Err(error) => {
tracing::warn!("compaction span summarizer failed: {error}");
None
}
}
}
pub fn new_context(&mut self, objective: &str, keep_recent: Option<usize>) -> usize {
let keep_recent = keep_recent.unwrap_or_else(|| self.keep_recent_count_by_tokens());
let mut first = 1usize;
if matches!(self.config.cache_plan, CachePlan::ImportedPrefix) {
if let Some(protected) = self.imported_prefix_len {
first = first.max(protected);
}
}
let mut cut = self.history.len().saturating_sub(keep_recent).max(first);
while cut < self.history.len() && self.history[cut].role == Role::Tool {
cut += 1;
}
let dropped = cut.saturating_sub(first);
let objective = objective.trim();
let retention = if self.recorder.is_some() {
"They remain in this session's transcript sidecar."
} else {
"No transcript sidecar is attached, so they are not retained."
};
let marker = ChatMessage::system(format!(
"[handoff: a fresh working context starts here]\nObjective: {objective}\n\
{dropped} earlier message(s) were set aside; the most recent {kept} were kept. \
{retention}",
kept = self.history.len() - cut,
));
if let Err(error) = self.record(&marker) {
tracing::warn!("failed to persist the handoff marker: {error}");
}
let mut new_history = Vec::with_capacity(first + keep_recent + 2);
new_history.extend(self.history[..first].iter().cloned());
new_history.push(marker);
new_history.extend(self.history.split_off(cut));
self.history = new_history;
self.journal_checkpoint(self.history.len());
dropped
}
fn compaction_pressure_triggered(&self) -> bool {
let Some(reserve) = self.config.compaction_reserve_tokens else {
return false;
};
let limit = provider::model_context_limit(&self.config.model)
.unwrap_or(provider::UNKNOWN_MODEL_CONTEXT_FLOOR);
let used = crate::tokens::estimate_view_tokens(&self.history);
used.saturating_add(reserve) > limit
}
fn keep_recent_count_by_tokens(&self) -> usize {
let budget = self.config.compaction_keep_recent_tokens.unwrap_or(20_000);
let mut used = 0u64;
let mut count = 0usize;
for msg in self.history.iter().skip(1).rev() {
let t = crate::tokens::estimate_view_tokens(std::slice::from_ref(msg));
if used.saturating_add(t) > budget && count > 0 {
break;
}
used = used.saturating_add(t);
count += 1;
}
count.max(2)
}
pub fn register_tool(&mut self, tool: impl crate::tools::Tool + 'static) {
self.registry.register(tool);
if self.requests_issued {
self.cache_established = false;
}
}
pub fn history(&self) -> &[ChatMessage] {
&self.history
}
pub async fn send(&mut self, user_input: impl Into<String>) -> Result<String> {
let expanded = self.expand_prompt_async(&user_input.into()).await;
let msg = ChatMessage::user(expanded);
self.guard_candidate_message(&msg)?;
self.record(&msg)?;
self.history.push(msg);
self.run_loop().await
}
pub fn context_usage(&self) -> ContextUsage {
let messages = self.projected_view(None);
let tools = self.tool_schemas();
let message_tokens = crate::tokens::estimate_view_tokens(&messages);
let request_tokens = crate::tokens::estimate_request_tokens(&messages, &tools);
let limit = self.context_limit.or_else(|| {
crate::provider::model_context_limit(&self.config.model)
.or(Some(crate::provider::UNKNOWN_MODEL_CONTEXT_FLOOR))
});
let projected_tokens = crate::tokens::with_guard_margin(request_tokens);
let reserve = crate::tokens::CONTEXT_RESPONSE_RESERVE_TOKENS;
let (fits, remaining_tokens, used_pct) = match limit {
Some(limit) => (
projected_tokens.saturating_add(reserve) <= limit,
limit
.saturating_sub(reserve)
.saturating_sub(projected_tokens),
if limit == 0 {
0
} else {
(projected_tokens as f64 / limit as f64 * 100.0).round() as u32
},
),
None => (true, 0, 0),
};
ContextUsage {
model: self.config.model.clone(),
messages: messages.len(),
message_tokens,
tool_count: tools.len(),
tool_schema_tokens: request_tokens.saturating_sub(message_tokens),
request_tokens,
projected_tokens,
response_reserve_tokens: reserve,
context_limit: limit,
remaining_tokens,
used_pct,
fits,
}
}
fn projected_view(&self, candidate: Option<&ChatMessage>) -> Vec<ChatMessage> {
let messages = match &self.reduction_policy {
None => {
let mut messages = self.history.clone();
if let Some(candidate) = candidate {
messages.push(candidate.clone());
}
messages
}
Some(policy) => {
let has_system = self.history.first().is_some_and(|m| m.role == Role::System);
let mut reducible = self.history[usize::from(has_system)..].to_vec();
if let Some(candidate) = candidate {
reducible.push(candidate.clone());
}
let mut prepared = policy.clone();
reduce::prepare_read_freshness(&mut prepared, &reducible);
let (view, _) =
reduce::project_messages(&reducible, &prepared, &self.reduction_log);
let mut messages = Vec::with_capacity(view.len() + usize::from(has_system));
if has_system {
messages.push(self.history[0].clone());
}
messages.extend(view);
messages
}
};
provider::apply_cache_plan(&messages, self.config.cache_plan, self.imported_prefix_len)
}
fn guard_candidate_message(&self, msg: &ChatMessage) -> Result<()> {
let Some(limit) = self.context_limit else {
return Ok(());
};
let messages = self.projected_view(Some(msg));
let tools = self.tool_schemas();
let (fits, projected_tokens) = crate::tokens::context_guard(&messages, &tools, limit);
if !fits {
return Err(Error::ContextLimitExceeded {
projected_tokens,
reserve_tokens: crate::tokens::CONTEXT_RESPONSE_RESERVE_TOKENS,
context_limit: limit,
model: self.config.model.clone(),
});
}
Ok(())
}
fn build_request_messages(&mut self) -> Vec<ChatMessage> {
let messages = match self.reduction_policy.clone() {
None => self.history.clone(),
Some(mut policy) => {
reduce::prepare_read_freshness(&mut policy, &self.history[1..]);
if matches!(self.config.cache_plan, CachePlan::ImportedPrefix) {
policy.protect_imported_prefix =
self.imported_prefix_len.map(|n| n.saturating_sub(1));
}
if policy.summarize_cleared_turns {
if let Some(summarizer) = self.span_summarizer.as_deref() {
policy.cleared_turns_summary = reduce::prepare_cleared_turns_summary(
&self.history[1..],
&policy,
&self.reduction_log,
summarizer,
);
}
}
let (view, log) =
reduce::project_messages(&self.history[1..], &policy, &self.reduction_log);
self.reduction_log = log;
let mut messages = Vec::with_capacity(view.len() + 1);
messages.push(self.history[0].clone());
messages.extend(view);
messages
}
};
let tier_sig = self.schema_tier_signature();
let busted =
provider::tier_change_is_cache_bust(self.last_tool_schema_tier_signature, tier_sig);
self.last_tool_schema_tier_signature = Some(tier_sig);
let effective_cache_plan = if busted {
CachePlan::Off
} else {
self.config.cache_plan
};
let will_annotate = matches!(effective_cache_plan, CachePlan::ImportedPrefix)
&& self.imported_prefix_len.is_some_and(|n| n > 0);
let idle_secs = self
.last_cache_activity_ms
.map(|last| (now_ms() - last).max(0) / 1000);
self.pending_cache_turn = (will_annotate, self.cache_established, idle_secs);
let mut messages =
provider::apply_cache_plan(&messages, effective_cache_plan, self.imported_prefix_len);
if let Some(goal) = &self.goal {
messages.push(ChatMessage::system(goal.reminder()));
}
messages
}
fn drain_steer_queue(
queue: &mut std::collections::VecDeque<String>,
mode: SteeringMode,
) -> Option<String> {
if queue.is_empty() {
return None;
}
match mode {
SteeringMode::All => Some(queue.drain(..).collect::<Vec<_>>().join("\n\n")),
SteeringMode::OneAtATime => queue.pop_front(),
}
}
async fn run_loop(&mut self) -> Result<String> {
let _steer_turn = SteerTurnGuard::new(self.steer_queue.clone());
let mut output_tokens_used: u64 = 0;
if let Some(budget) = self.config.max_budget_usd.filter(|b| *b > 0.0) {
if self.total_cost_usd >= budget {
return Err(Error::BudgetExhausted {
spent_usd: self.total_cost_usd,
budget_usd: budget,
});
}
}
if let Some(cp) = &self.checkpoint_observer {
let label = self
.history
.last()
.and_then(|m| m.content.as_deref())
.unwrap_or("")
.to_string();
cp.begin_turn(&label);
}
self.refresh_env_context();
for _ in 0..self.config.max_iterations {
self.journal_plan_if_changed();
let round_trip = self.turn_index;
self.maybe_compact();
let (steer_msg, steer_taken) = {
let mut inbox = self
.steer_queue
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let before = inbox.len();
let drained = inbox.drain(self.config.steering_mode);
let taken = before - inbox.len();
(drained, taken)
};
if let Some(steer_msg) = steer_msg {
self.journal_queue_drain(crate::session_journal::QueueKind::Steer, steer_taken);
let msg = ChatMessage::user(steer_msg);
self.record(&msg)?;
self.history.push(msg);
}
let tools = self.tool_schemas();
let messages = self.build_request_messages();
if let Some(limit) = self.context_limit {
let (fits, projected_tokens) =
crate::tokens::context_guard(&messages, &tools, limit);
if !fits {
return Err(Error::ContextLimitExceeded {
projected_tokens,
reserve_tokens: crate::tokens::CONTEXT_RESPONSE_RESERVE_TOKENS,
context_limit: limit,
model: self.config.model.clone(),
});
}
}
self.push_turn_marker_at(
round_trip,
crate::turn_record::TurnMarker::Context {
messages: messages.len(),
tools: tools.len(),
estimated_tokens: crate::tokens::estimate_request_tokens(&messages, &tools),
},
);
let mut req = self.chat_request(messages, tools);
let fallback_hops: Vec<FallbackHop>;
let completion = {
let sink = self.config.event_sink.as_ref();
let on_delta = move |s: &str| {
if let Some(sink) = sink {
sink(AgentEvent::TextDelta(s.to_string()));
}
};
self.requests_issued = true;
let (result, hops) = self.complete_with_fallback(&mut req, &on_delta).await;
fallback_hops = hops;
result
};
for notice in self.retry_log.drain() {
self.emit(AgentEvent::ProviderRetry {
attempt: notice.attempt,
delay_ms: notice.delay_ms,
reason: notice.reason.clone(),
});
self.push_turn_marker_at(
round_trip,
crate::turn_record::TurnMarker::Retry {
attempt: notice.attempt,
delay_ms: notice.delay_ms,
reason: notice.reason,
},
);
}
for hop in fallback_hops {
self.record_model_change(&hop.from, &hop.to, Some(hop.reason.as_str()));
}
let (mut assistant, usage) = completion?;
assistant
.metadata
.insert("model".to_string(), self.config.model.clone());
output_tokens_used += usage.completion_tokens;
self.total_output_tokens += usage.completion_tokens;
let (will_annotate, cache_established, idle_secs) = self.pending_cache_turn;
let warning_applies_to_this_model =
provider::is_anthropic_family_model(&self.config.model);
if self.config.cache_warnings && warning_applies_to_this_model {
if let Some(reason) =
provider::cache_cold_reason(will_annotate, cache_established, idle_secs, &usage)
{
self.emit(AgentEvent::CacheWarning {
message: reason.message(),
});
}
}
if will_annotate {
self.last_cache_activity_ms = Some(now_ms());
self.cache_established = true;
}
self.emit(AgentEvent::Usage(usage.clone()));
self.emit(AgentEvent::TurnCompleted);
let served = assistant
.metadata
.get(crate::provider::SERVED_MODEL_KEY)
.cloned();
let record = crate::usage_log::UsageRecord::from_usage(
self.turn_index,
&self.config.model,
&usage,
now_ms(),
)
.priced(self.model_price)
.with_served_model(served);
self.total_cost_usd += record.cost_usd.unwrap_or(0.0);
self.push_turn_marker_at(
round_trip,
crate::turn_record::TurnMarker::Usage {
prompt_tokens: record.prompt_tokens,
completion_tokens: record.completion_tokens,
total_tokens: record.total_tokens,
cached_tokens: record.cached_tokens,
cost_usd: record.cost_usd,
},
);
self.journal_usage(&record);
self.usage_log.push(record);
self.turn_index += 1;
self.record(&assistant)?;
self.history.push(assistant.clone());
let calls = assistant.tool_calls().to_vec();
if calls.is_empty() {
let (steer_msg, steer_taken) = {
let mut inbox = self
.steer_queue
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let before = inbox.len();
let drained = inbox.drain_or_close(self.config.steering_mode);
let taken = before - inbox.len();
(drained, taken)
};
if let Some(steer_msg) = steer_msg {
self.journal_queue_drain(crate::session_journal::QueueKind::Steer, steer_taken);
let msg = ChatMessage::user(steer_msg);
self.record(&msg)?;
self.history.push(msg);
continue;
}
let follow_up_before = self.follow_up_queue.len();
if let Some(follow_up_msg) =
Self::drain_steer_queue(&mut self.follow_up_queue, self.config.follow_up_mode)
{
self.journal_queue_drain(
crate::session_journal::QueueKind::FollowUp,
follow_up_before - self.follow_up_queue.len(),
);
let msg = ChatMessage::user(follow_up_msg);
self.record(&msg)?;
self.history.push(msg);
continue;
}
let final_content = assistant.content.clone().unwrap_or_default();
let veto_reason: Option<String> = self
.config
.stop_gate
.as_ref()
.and_then(|gate| gate(&final_content));
if let Some(reason) = veto_reason {
let msg = ChatMessage::user(reason);
self.record(&msg)?;
self.history.push(msg);
continue;
}
self.journal_plan_if_changed();
self.push_turn_marker_at(
round_trip,
crate::turn_record::TurnMarker::Finish {
reason: crate::turn_record::FinishReason::EndTurn,
},
);
return Ok(assistant.content.unwrap_or_default());
}
self.push_turn_marker_at(
round_trip,
crate::turn_record::TurnMarker::Finish {
reason: crate::turn_record::FinishReason::ToolCalls,
},
);
if let Some(budget) = self.config.max_total_output_tokens {
if output_tokens_used >= budget {
for call in &calls {
let msg = ChatMessage::tool_result(
call.id.clone(),
call.function.name.clone(),
"[skipped: output token budget reached]".to_string(),
);
self.record(&msg)?;
self.history.push(msg);
}
self.push_turn_marker_at(
round_trip,
crate::turn_record::TurnMarker::Finish {
reason: crate::turn_record::FinishReason::OutputTokenBudget,
},
);
return Ok(assistant.content.clone().unwrap_or_default());
}
}
let spend_exhausted = self
.config
.max_budget_usd
.is_some_and(|b| b > 0.0 && self.total_cost_usd >= b);
let steps_exhausted = self
.config
.max_steps
.is_some_and(|n| n > 0 && self.total_steps + calls.len() > n);
if spend_exhausted || steps_exhausted {
let (label, reason) = if spend_exhausted {
(
"[skipped: spend budget reached]",
crate::turn_record::FinishReason::SpendBudget,
)
} else {
(
"[skipped: step budget reached]",
crate::turn_record::FinishReason::StepBudget,
)
};
for call in &calls {
let msg = ChatMessage::tool_result(
call.id.clone(),
call.function.name.clone(),
label.to_string(),
);
self.record(&msg)?;
self.history.push(msg);
}
self.push_turn_marker_at(
round_trip,
crate::turn_record::TurnMarker::Finish { reason },
);
return Ok(assistant.content.clone().unwrap_or_default());
}
self.total_steps += calls.len();
if self.config.parallel_tool_calls && calls.len() > 1 {
for call in &calls {
self.emit(AgentEvent::tool_started(call));
}
let results = self.run_tools_concurrently(&calls).await;
for (call, (output, is_error)) in calls.iter().zip(results) {
self.emit(AgentEvent::ToolCallCompleted {
id: call.id.clone(),
name: call.function.name.clone(),
output: output.clone(),
is_error,
});
self.apply_tool_result(call, output, is_error)?;
}
} else {
for call in &calls {
self.emit(AgentEvent::tool_started(call));
let (output, is_error) = self.run_tool(call).await;
self.emit(AgentEvent::ToolCallCompleted {
id: call.id.clone(),
name: call.function.name.clone(),
output: output.clone(),
is_error,
});
self.apply_tool_result(call, output, is_error)?;
}
}
self.apply_pending_new_context();
}
self.push_turn_marker_at(
self.turn_index.saturating_sub(1),
crate::turn_record::TurnMarker::Finish {
reason: crate::turn_record::FinishReason::MaxIterations,
},
);
Err(Error::MaxIterations(self.config.max_iterations))
}
fn apply_pending_new_context(&mut self) {
let Some(request) = self.ctx.context_budget.take_new_context() else {
return;
};
self.new_context(&request.objective, request.keep_recent);
}
fn apply_tool_result(
&mut self,
call: &crate::message::ToolCall,
output: String,
is_error: bool,
) -> Result<()> {
if let Some(data_url) = output.strip_prefix(crate::tools::MULTIMODAL_IMAGE_MARKER) {
let notice = format!("[{}: image content attached below]", call.function.name);
let full_result = ChatMessage::tool_result_with_image(
call.id.clone(),
call.function.name.clone(),
notice.clone(),
data_url.to_string(),
);
let hist_result = ChatMessage::tool_result_with_image(
call.id.clone(),
call.function.name.clone(),
notice,
data_url.to_string(),
);
self.record(&full_result)?;
self.history.push(hist_result);
return Ok(());
}
let mut full_result =
ChatMessage::tool_result(call.id.clone(), call.function.name.clone(), output.clone());
let for_history = if self.recorder.is_some() && self.reduction_policy.is_some() {
output
} else {
self.cap_tool_output(output)
};
let mut hist_result =
ChatMessage::tool_result(call.id.clone(), call.function.name.clone(), for_history);
if is_error {
reduce::mark_tool_error(&mut full_result);
reduce::mark_tool_error(&mut hist_result);
}
self.record(&full_result)?;
self.history.push(hist_result);
Ok(())
}
fn cap_tool_output(&self, output: String) -> String {
let Some(max) = self.config.max_tool_output_bytes else {
return output;
};
if max == 0 || output.len() <= max {
return output;
}
let mut end = max;
while end > 0 && !output.is_char_boundary(end) {
end -= 1;
}
let total = output.len();
let mut s = output[..end].to_string();
let spill = if self.config.tool_output_spill {
self.spill_tool_output(&output)
} else {
None
};
let retention = if self.recorder.is_some() {
"full output in session sidecar"
} else if spill.is_some() {
"full output on disk"
} else {
"full output not retained"
};
let recovery = match &spill {
Some(path) => {
let door = if self.registry.get("read_file").is_some() {
"read it with `read_file`"
} else {
"read it with `cat`"
};
format!("; full output spilled to {} — {door}", path.display())
}
None => String::new(),
};
s.push_str(&format!(
"{CAP_NOTICE_MARKER}{total} bytes total, showing first {end}; {retention}{recovery}]"
));
s
}
fn spill_tool_output(&self, full: &str) -> Option<std::path::PathBuf> {
let dir = self.spill_dir();
std::fs::create_dir_all(&dir).ok()?;
let digest = blake3::hash(full.as_bytes()).to_hex();
let path = dir.join(format!("tool-output-{}.txt", &digest[..16]));
if !path.exists() {
std::fs::write(&path, full).ok()?;
}
Some(path)
}
fn spill_dir(&self) -> std::path::PathBuf {
if let Some(recorder) = &self.recorder {
let path = recorder.path();
if let (Some(parent), Some(stem)) = (path.parent(), path.file_stem()) {
return parent.join(format!("{}.spill", stem.to_string_lossy()));
}
}
std::env::temp_dir().join(format!("supercode-spill-{}", std::process::id()))
}
fn run_tool<'a>(
&'a mut self,
call: &'a crate::message::ToolCall,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = (String, bool)> + Send + 'a>> {
Box::pin(async move {
let translated_builtin = if self.config.claude_runtime_tools_enabled {
match self.translate_claude_builtin_call(call) {
Ok(translated) => translated,
Err(error) => return (format!("Error: {error}"), true),
}
} else {
None
};
let call = translated_builtin.as_ref().unwrap_or(call);
if self.config.claude_runtime_tools_enabled
&& matches!(
call.function.name.as_str(),
CLAUDE_CRON_CREATE
| CLAUDE_CRON_DELETE
| CLAUDE_CRON_LIST
| CLAUDE_SCHEDULE_WAKEUP
)
{
return self.run_claude_runtime_tool(call);
}
if call.function.name == CLAUDE_AGENT && self.config.subagents_claude_agent_alias {
return match self.translate_claude_agent_call(call) {
Ok(translated) => self.run_spawn_subagent(&translated).await,
Err(error) => (format!("Error: {error}"), true),
};
}
if call.function.name == SPAWN_SUBAGENT {
return self.run_spawn_subagent(call).await;
}
if call.function.name == SUBAGENT_STATUS && self.config.subagents_enabled {
return self.run_subagent_status(call).await;
}
if call.function.name == SUBAGENT_MESSAGE && self.config.subagents_enabled {
return self.run_subagent_message(call);
}
if call.function.name == SUBAGENT_RESUME && self.config.subagents_enabled {
return self.run_subagent_resume(call).await;
}
match self.prepare_tool_call(call) {
PreparedCall::Done(result) => result,
PreparedCall::Ready { name, args } => {
let tool = self.registry.get(&name).expect("prepared as Ready");
let (output, is_error) = match tool.execute(args, &self.ctx).await {
Ok(out) => (out, false),
Err(e) => (format!("Error: {e}"), true),
};
if let Some(hook) = &self.config.post_tool_hook {
hook(&name, &output, is_error);
}
(output, is_error)
}
}
})
}
fn prepare_tool_call(&mut self, call: &crate::message::ToolCall) -> PreparedCall {
let name = &call.function.name;
if name == crate::tools::GET_CONTEXT_REMAINING {
if let Ok(usage) = serde_json::to_value(self.context_usage()) {
self.ctx.context_budget.publish(usage);
}
}
if name == TOOL_SEARCH {
return PreparedCall::Done(self.run_tool_search(call));
}
if name == EXPAND_REDUCTION {
return PreparedCall::Done(self.run_expand_reduction(call));
}
if name == SIDECAR_SEARCH {
return PreparedCall::Done(self.run_sidecar_search(call));
}
if self.config.tools_background_enabled {
if name == BACKGROUND_EXEC {
return PreparedCall::Done(self.run_background_exec(call));
}
if name == BACKGROUND_STATUS {
return PreparedCall::Done(self.run_background_status(call));
}
if name == BACKGROUND_LIST {
return PreparedCall::Done(self.run_background_list(call));
}
if name == BACKGROUND_KILL {
return PreparedCall::Done(self.run_background_kill(call));
}
}
if self.registry.get(name).is_none() {
let err = Error::UnknownTool(name.clone());
return PreparedCall::Done((format!("Error: {err}"), true));
}
if self.config.permissions_enabled {
let args = match call.function.parsed_arguments() {
Ok(v) => v,
Err(e) => {
let err = Error::InvalidArguments {
tool: name.clone(),
message: e.to_string(),
};
return PreparedCall::Done((format!("Error: {err}"), true));
}
};
if let Some(reason) = self.check_doom_loop(name, &args) {
return PreparedCall::Done((format!("Error: {reason}"), true));
}
let (args, hook_decision) = match self.run_pre_tool_hook(name, args) {
Ok(pair) => pair,
Err(done) => return done,
};
if let Some(reason) = self.permissions_gate_denial(name, &args, hook_decision) {
return PreparedCall::Done((format!("Error: {reason}"), true));
}
PreparedCall::Ready {
name: name.clone(),
args,
}
} else {
if self.config.needs_approval(name) {
let approved = self
.config
.approval_handler
.as_ref()
.map(|h| h(call))
.unwrap_or(false);
if !approved {
return PreparedCall::Done((
format!("Error: tool `{name}` was not approved for execution"),
true,
));
}
}
let args = match call.function.parsed_arguments() {
Ok(v) => v,
Err(e) => {
let err = Error::InvalidArguments {
tool: name.clone(),
message: e.to_string(),
};
return PreparedCall::Done((format!("Error: {err}"), true));
}
};
self.finish_prepare(name.clone(), args)
}
}
fn finish_prepare(&mut self, name: String, args: serde_json::Value) -> PreparedCall {
if let Some(reason) = self.check_doom_loop(&name, &args) {
return PreparedCall::Done((format!("Error: {reason}"), true));
}
let mut args = args;
if let Some(hook) = &self.config.pre_tool_hook {
let outcome = hook(&name, &args);
if let Some(rewritten) = outcome.updated_args {
args = rewritten;
}
if outcome.decision == crate::config::HookDecision::Deny {
let reason = outcome.reason.unwrap_or_else(|| "denied".to_string());
return PreparedCall::Done((
format!("Error: blocked by pre-tool hook: {reason}"),
true,
));
}
}
PreparedCall::Ready { name, args }
}
#[allow(clippy::type_complexity)]
fn run_pre_tool_hook(
&self,
name: &str,
args: serde_json::Value,
) -> std::result::Result<(serde_json::Value, crate::config::HookDecision), PreparedCall> {
let Some(hook) = &self.config.pre_tool_hook else {
return Ok((args, crate::config::HookDecision::Pass));
};
let outcome = hook(name, &args);
let args = outcome.updated_args.unwrap_or(args);
if outcome.decision == crate::config::HookDecision::Deny {
let reason = outcome.reason.unwrap_or_else(|| "denied".to_string());
return Err(PreparedCall::Done((
format!("Error: blocked by pre-tool hook: {reason}"),
true,
)));
}
Ok((args, outcome.decision))
}
const SUBJECT_BEARING_BUILTIN_TOOLS: &'static [&'static str] = &[
"bash",
"shell",
"read_file",
"write_file",
"edit_file",
"view_image",
"apply_patch",
];
fn permissions_gate_denial(
&self,
name: &str,
args: &serde_json::Value,
hook: crate::config::HookDecision,
) -> Option<String> {
self.permissions_gate_denial_impl(
name,
args,
self.permissions_approval_handler.as_deref(),
hook,
)
}
fn permissions_gate_denial_impl(
&self,
name: &str,
args: &serde_json::Value,
handler: Option<&dyn crate::permissions::PermissionsApprovalHandler>,
hook: crate::config::HookDecision,
) -> Option<String> {
use crate::permissions::{self, Decision, PathKind};
let mut rules = permissions::rules_for_config(&self.config);
rules
.deny
.extend(crate::tools::plan_mode::deny_rules(&self.ctx.plan_mode));
let default = permissions::default_decision(&self.config, name);
let mut roots = vec![self.config.cwd.clone()];
roots.extend(self.config.additional_dirs.iter().cloned());
let command = args.get("command").and_then(|v| v.as_str());
let path = args.get("path").and_then(|v| v.as_str());
let patch = (name == "apply_patch")
.then(|| args.get("patch").and_then(|v| v.as_str()))
.flatten();
let decision = if let Some(command) = command {
permissions::evaluate_command(&rules, name, command, default)
} else if let Some(patch) = patch {
let mut d = rules.evaluate(name, None).unwrap_or(default);
match crate::tools::patch_target_paths(patch) {
Ok(paths) => {
for p in &paths {
let pseudo = permissions::evaluate_path_safe_roots(
&rules,
PathKind::Write,
&roots,
p,
Decision::Allow,
);
let real_tool = permissions::evaluate_path_subject_safe_roots(
&rules,
name,
&roots,
p,
Decision::Allow,
);
d = d.stricter(pseudo).stricter(real_tool);
}
}
Err(_) => {
d = d.stricter(Decision::Ask);
}
}
d
} else if let Some(path) = path {
let kind = if matches!(name, "write_file" | "edit_file") {
PathKind::Write
} else {
PathKind::Read
};
let pseudo_decision =
permissions::evaluate_path_safe_roots(&rules, kind, &roots, path, default);
let real_tool_decision =
permissions::evaluate_path_subject_safe_roots(&rules, name, &roots, path, default);
pseudo_decision.stricter(real_tool_decision)
} else {
rules.evaluate(name, None).unwrap_or(default)
};
let decision = match hook {
crate::config::HookDecision::Deny => Decision::Deny,
crate::config::HookDecision::Ask => decision.stricter(Decision::Ask),
crate::config::HookDecision::Allow | crate::config::HookDecision::Pass => decision,
};
let decision = if args
.get("with_escalated_permissions")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
decision.stricter(Decision::Ask)
} else {
decision
};
let subject = Self::SUBJECT_BEARING_BUILTIN_TOOLS
.contains(&name)
.then(|| command.or(path).or(patch))
.flatten();
let req = permissions::ApprovalRequest {
tool: name,
subject,
raw_args: args,
};
let approved = permissions::decision_to_approved(decision, || {
if hook == crate::config::HookDecision::Allow {
return true;
}
permissions::resolve_ask(&self.permissions_approval_cache, handler, &req)
});
if approved {
None
} else {
Some(format!(
"tool `{name}` was not approved for execution (permissions engine: {decision:?})"
))
}
}
fn background_permission_denial(
&self,
command: &str,
job_id: &str,
hook: crate::config::HookDecision,
) -> Option<String> {
let args = serde_json::json!({ "command": command });
if self.config.permissions_enabled {
if let Some(crate::subagents::BackgroundPromptsPolicy::Parent) =
self.config.subagents_background_prompts
{
let handler = crate::subagents::ParentQueueApprovalHandler {
child_agent_id: format!("bg:{job_id}"),
queue: self.pending_child_approvals.clone(),
};
self.permissions_gate_denial_impl("bash", &args, Some(&handler), hook)
} else {
self.permissions_gate_denial_impl("bash", &args, None, hook)
}
} else if self.config.needs_approval("bash") {
Some(
"tool `bash` requires approval, which a background job cannot request \
interactively (§2.2 C6: auto-policy denies)"
.to_string(),
)
} else {
None
}
}
async fn run_tools_concurrently(
&mut self,
calls: &[crate::message::ToolCall],
) -> Vec<(String, bool)> {
if calls.iter().any(|c| {
c.function.name == SPAWN_SUBAGENT
|| c.function.name == SUBAGENT_STATUS
|| c.function.name == SUBAGENT_MESSAGE
|| c.function.name == SUBAGENT_RESUME
|| (self.config.claude_runtime_tools_enabled
&& matches!(
c.function.name.as_str(),
CLAUDE_CRON_CREATE
| CLAUDE_CRON_DELETE
| CLAUDE_CRON_LIST
| CLAUDE_SCHEDULE_WAKEUP
))
}) {
let mut out = Vec::with_capacity(calls.len());
for call in calls {
out.push(self.run_tool(call).await);
}
return out;
}
let prepared: Vec<PreparedCall> = calls.iter().map(|c| self.prepare_tool_call(c)).collect();
let mut slots: Vec<Option<(String, bool)>> = prepared
.iter()
.map(|p| match p {
PreparedCall::Done(r) => Some(r.clone()),
PreparedCall::Ready { .. } => None,
})
.collect();
let ready_idxs: Vec<usize> = prepared
.iter()
.enumerate()
.filter(|(_, p)| matches!(p, PreparedCall::Ready { .. }))
.map(|(i, _)| i)
.collect();
if !ready_idxs.is_empty() {
let futs = ready_idxs.iter().map(|&i| {
let PreparedCall::Ready { name, args } = &prepared[i] else {
unreachable!("filtered to Ready above")
};
let tool = self.registry.get(name).expect("prepared as Ready");
let args = args.clone();
let ctx = self.ctx.clone();
async move {
match tool.execute(args, &ctx).await {
Ok(out) => (out, false),
Err(e) => (format!("Error: {e}"), true),
}
}
});
let results = futures::future::join_all(futs).await;
for (idx, result) in ready_idxs.iter().zip(results) {
slots[*idx] = Some(result);
}
}
let out: Vec<(String, bool)> = slots
.into_iter()
.map(|s| s.expect("every call resolved to Some above"))
.collect();
let ready_set: std::collections::HashSet<usize> = ready_idxs.into_iter().collect();
for (i, call) in calls.iter().enumerate() {
if !ready_set.contains(&i) {
continue;
}
let (output, is_error) = &out[i];
if let Some(hook) = &self.config.post_tool_hook {
hook(&call.function.name, output, *is_error);
}
}
out
}
fn check_doom_loop(&mut self, name: &str, args: &serde_json::Value) -> Option<String> {
let threshold = self.config.doom_loop_threshold?;
if threshold < 2 {
return None;
}
let key = (name.to_string(), args.to_string());
if self.doom_loop_last_call.as_ref() == Some(&key) {
self.doom_loop_streak += 1;
} else {
self.doom_loop_last_call = Some(key);
self.doom_loop_streak = 1;
}
if self.doom_loop_streak >= threshold {
Some(format!(
"doom-loop breaker: `{name}` called with identical arguments {} times in a row \
— try a different approach instead of repeating the same call",
self.doom_loop_streak
))
} else {
None
}
}
fn is_core_tool(&self, name: &str) -> bool {
match &self.config.tool_advertising {
ToolAdvertising::Full => true,
ToolAdvertising::Deferred { core } => core.iter().any(|c| c == name),
}
}
fn schema_for(&self, t: &dyn crate::tools::Tool) -> ToolSchema {
let raw = self.raw_schema_for(t);
let tier = self.config.schema_tier_for(t.name());
let (description, parameters) =
crate::tools::tiers::minify(&raw.description, &raw.parameters, tier);
ToolSchema {
name: raw.name,
description,
parameters,
}
}
fn raw_schema_for(&self, t: &dyn crate::tools::Tool) -> ToolSchema {
ToolSchema {
name: t.name().to_string(),
description: self
.config
.tool_description(t.name(), t.description())
.to_string(),
parameters: t.parameters(),
}
}
fn tool_search_schema() -> ToolSchema {
ToolSchema {
name: TOOL_SEARCH.to_string(),
description: "Search for additional tools not currently advertised (the deferred \
MCP surface and any other non-core tools). Matches keywords case-insensitively \
against each tool's name and description. Matched tools become callable starting \
with your NEXT message, not this one."
.to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Keyword(s) to search for in tool names and descriptions."
},
"max_results": {
"type": "integer",
"description": "Maximum number of matching tools to return."
}
},
"required": ["query"],
"additionalProperties": false
}),
}
}
pub fn tool_schemas(&self) -> Vec<ToolSchema> {
let mut out = match &self.config.tool_advertising {
ToolAdvertising::Full => self
.registry
.iter()
.filter(|t| self.config.tool_enabled(t.name()))
.map(|t| self.schema_for(t))
.collect(),
ToolAdvertising::Deferred { .. } => {
let mut out: Vec<ToolSchema> = self
.registry
.iter()
.filter(|t| self.config.tool_enabled(t.name()))
.filter(|t| {
self.is_core_tool(t.name()) || self.activated_tools.contains(t.name())
})
.map(|t| self.schema_for(t))
.collect();
out.push(Self::tool_search_schema());
out
}
};
if self.reduction_policy.is_some() {
out.push(Self::expand_reduction_schema());
out.push(Self::sidecar_search_schema());
}
if self.config.subagents_enabled {
out.push(self.spawn_subagent_schema());
if self.config.subagents_claude_agent_alias {
out.push(self.claude_agent_schema());
}
if self.config.subagents_background {
out.push(Self::subagent_status_schema());
out.push(Self::subagent_message_schema());
out.push(Self::subagent_resume_schema());
}
}
if self.config.claude_runtime_tools_enabled {
out.extend(self.claude_builtin_tool_schemas());
out.push(Self::claude_cron_create_schema());
out.push(Self::claude_cron_delete_schema());
out.push(Self::claude_cron_list_schema());
out.push(Self::claude_schedule_wakeup_schema());
}
if self.config.tools_background_enabled {
out.push(Self::background_exec_schema());
out.push(Self::background_status_schema());
out.push(Self::background_list_schema());
out.push(Self::background_kill_schema());
}
out.retain(|schema| !self.policy_hides_tool(&schema.name));
out
}
fn policy_hides_tool(&self, name: &str) -> bool {
if !self.config.permissions_enabled || self.config.tool_deny_patterns.is_empty() {
return false;
}
let rules = crate::permissions::RuleSet {
deny: self.config.tool_deny_patterns.clone(),
..Default::default()
};
rules.evaluate(name, None) == Some(crate::permissions::Decision::Deny)
}
fn claude_builtin_tool_schemas(&self) -> Vec<ToolSchema> {
let mut schemas = Vec::new();
let mut push = |alias: &str, native: &str, description: &str, parameters| {
if self.registry.get(native).is_some() && self.config.tool_enabled(native) {
schemas.push(ToolSchema {
name: alias.to_string(),
description: description.to_string(),
parameters,
});
}
};
push(
CLAUDE_BASH,
"bash",
"Claude Code-compatible shell command execution.",
serde_json::json!({
"type": "object",
"properties": {
"command": {"type": "string"},
"timeout": {"type": "integer", "description": "Timeout in milliseconds."},
"description": {"type": "string"}
},
"required": ["command"],
"additionalProperties": true
}),
);
push(
CLAUDE_READ,
"read_file",
"Claude Code-compatible file reader.",
serde_json::json!({
"type": "object",
"properties": {
"file_path": {"type": "string"},
"offset": {"type": "integer"},
"limit": {"type": "integer"}
},
"required": ["file_path"],
"additionalProperties": false
}),
);
push(
CLAUDE_WRITE,
"write_file",
"Claude Code-compatible file writer.",
serde_json::json!({
"type": "object",
"properties": {"file_path": {"type": "string"}, "content": {"type": "string"}},
"required": ["file_path", "content"],
"additionalProperties": false
}),
);
push(
CLAUDE_EDIT,
"edit_file",
"Claude Code-compatible exact file edit.",
serde_json::json!({
"type": "object",
"properties": {
"file_path": {"type": "string"},
"old_string": {"type": "string"},
"new_string": {"type": "string"},
"replace_all": {"type": "boolean"}
},
"required": ["file_path", "old_string", "new_string"],
"additionalProperties": false
}),
);
push(
CLAUDE_GLOB,
"glob",
"Claude Code-compatible file glob.",
serde_json::json!({
"type": "object",
"properties": {"pattern": {"type": "string"}, "path": {"type": "string"}},
"required": ["pattern"],
"additionalProperties": false
}),
);
push(
CLAUDE_GREP,
"search",
"Claude Code-compatible content search.",
serde_json::json!({
"type": "object",
"properties": {"pattern": {"type": "string"}, "path": {"type": "string"}},
"required": ["pattern"],
"additionalProperties": true
}),
);
schemas
}
fn translate_claude_builtin_call(
&self,
call: &crate::message::ToolCall,
) -> Result<Option<crate::message::ToolCall>> {
let native = match call.function.name.as_str() {
CLAUDE_BASH => "bash",
CLAUDE_READ => "read_file",
CLAUDE_WRITE => "write_file",
CLAUDE_EDIT => "edit_file",
CLAUDE_GLOB => "glob",
CLAUDE_GREP => "search",
_ => return Ok(None),
};
let mut args = call.function.parsed_arguments()?;
let object = args
.as_object_mut()
.ok_or_else(|| Error::InvalidArguments {
tool: call.function.name.clone(),
message: "expected a JSON object".to_string(),
})?;
if let Some(path) = object.remove("file_path") {
object.entry("path".to_string()).or_insert(path);
}
if call.function.name == CLAUDE_BASH {
if let Some(timeout) = object.remove("timeout") {
object.entry("timeout_ms".to_string()).or_insert(timeout);
}
}
if call.function.name == CLAUDE_GLOB {
if let Some(path) = object
.remove("path")
.and_then(|value| value.as_str().map(str::to_owned))
{
if let Some(pattern) = object.get_mut("pattern") {
if let Some(value) = pattern.as_str() {
if !std::path::Path::new(value).is_absolute() {
*pattern = serde_json::Value::String(
std::path::Path::new(&path)
.join(value)
.to_string_lossy()
.into_owned(),
);
}
}
}
}
}
let mut translated = call.clone();
translated.function.name = native.to_string();
translated.function.arguments = serde_json::to_string(&args)?;
Ok(Some(translated))
}
fn claude_cron_create_schema() -> ToolSchema {
ToolSchema {
name: CLAUDE_CRON_CREATE.to_string(),
description: "Record a Claude-compatible cron job in the imported runtime manifest. \
The job inherits the manifest's ACTIVE or PAUSED posture; an embedding scheduler, \
not this agent loop, owns execution."
.to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"cron": {"type": "string", "description": "Cron expression to preserve."},
"prompt": {"type": "string", "description": "Prompt associated with the job."},
"recurring": {"type": "boolean", "default": false},
"durable": {"type": "boolean", "default": false}
},
"required": ["cron", "prompt"],
"additionalProperties": false
}),
}
}
fn claude_cron_delete_schema() -> ToolSchema {
ToolSchema {
name: CLAUDE_CRON_DELETE.to_string(),
description: "Delete a Claude-compatible cron job from the imported manifest. \
This updates state only; an embedding scheduler owns execution."
.to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {"id": {"type": "string"}},
"required": ["id"],
"additionalProperties": false
}),
}
}
fn claude_cron_list_schema() -> ToolSchema {
ToolSchema {
name: CLAUDE_CRON_LIST.to_string(),
description: "List imported Claude cron jobs and their explicit ACTIVE or PAUSED \
manifest posture. This agent loop itself does not run a scheduler."
.to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
}
}
fn claude_schedule_wakeup_schema() -> ToolSchema {
ToolSchema {
name: CLAUDE_SCHEDULE_WAKEUP.to_string(),
description: "Replace the one-shot wakeup stored in the imported Claude manifest. \
The wakeup inherits the manifest's ACTIVE or PAUSED posture; an embedding scheduler \
owns timer execution."
.to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"delaySeconds": {"type": "integer", "minimum": 0},
"reason": {"type": "string"},
"prompt": {"type": "string"}
},
"required": ["delaySeconds"],
"additionalProperties": false
}),
}
}
fn background_exec_schema() -> ToolSchema {
ToolSchema {
name: BACKGROUND_EXEC.to_string(),
description: "Run a shell command in the BACKGROUND: spawns it as a detached \
process and returns a `job_id` IMMEDIATELY, before the command finishes — this \
call never returns the command's output. Poll `background_status` with the \
`job_id` to check progress and retrieve captured output; use `background_kill` \
to cancel it early. The command goes through the exact same sandbox/permission \
checks as a foreground `bash` call, and any check that would need an \
interactive approval is denied automatically (a background job cannot wait for \
one)."
.to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "Shell command to run in the background via `sh -c`."
}
},
"required": ["command"],
"additionalProperties": false
}),
}
}
fn background_status_schema() -> ToolSchema {
ToolSchema {
name: BACKGROUND_STATUS.to_string(),
description: "Check on a background job spawned via background_exec: its \
running/exited/killed status, exit code (once known), and the command's \
captured stdout/stderr so far (bounded — very large output is truncated with a \
marker). Once the job has exited or been killed, this call also reaps it (it \
will no longer appear in background_list or accept further status polls)."
.to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"job_id": {
"type": "string",
"description": "The id `background_exec` returned when this job was \
started."
}
},
"required": ["job_id"],
"additionalProperties": false
}),
}
}
fn background_list_schema() -> ToolSchema {
ToolSchema {
name: BACKGROUND_LIST.to_string(),
description: "List every background job currently tracked (running, or finished \
but not yet polled via background_status) — job id, command, status, pid, and \
start time for each. Does not retrieve output or reap anything."
.to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
}
}
fn background_kill_schema() -> ToolSchema {
ToolSchema {
name: BACKGROUND_KILL.to_string(),
description: "Kill a background job's real process immediately (a no-op, not an \
error, if it already exited on its own) and reap it."
.to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"job_id": {
"type": "string",
"description": "The id `background_exec` returned when this job was \
started."
}
},
"required": ["job_id"],
"additionalProperties": false
}),
}
}
fn spawn_subagent_schema(&self) -> ToolSchema {
let mut names: Vec<&str> = self
.config
.subagents_definitions
.keys()
.map(String::as_str)
.collect();
names.sort_unstable();
let agent_type_desc = if names.is_empty() {
"Optional named subagent type to run (none configured — omit this and pass \
`system_prompt` instead)."
.to_string()
} else {
format!(
"Optional named subagent type to run: {}. Omit to run an ad-hoc subagent with \
your own `system_prompt` instead.",
names.join(", ")
)
};
let background_desc = if self.config.subagents_background {
"Run this subagent in the background instead of waiting for it — this call \
returns immediately with a `subagent_id`; poll `subagent_status` with that id for \
the result."
} else {
"Background subagents are disabled for this agent — this must be omitted or false."
};
ToolSchema {
name: SPAWN_SUBAGENT.to_string(),
description: "Spawn a subagent to work on a self-contained task and (by default) \
wait for its final answer, which is returned as this call's result. The \
subagent runs its own independent reasoning/tool loop; it does not see your \
conversation except for the `task` text you give it here."
.to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"task": {
"type": "string",
"description": "The self-contained task/prompt for the subagent."
},
"agent_type": {
"type": "string",
"description": agent_type_desc
},
"system_prompt": {
"type": "string",
"description": "Inline system prompt for an ad-hoc subagent (ignored \
if `agent_type` is given — the named type's own prompt is used \
instead)."
},
"background": {
"type": "boolean",
"description": background_desc
}
},
"required": ["task"],
"additionalProperties": false
}),
}
}
fn claude_agent_schema(&self) -> ToolSchema {
let mut names: Vec<String> = self.config.subagents_definitions.keys().cloned().collect();
names.push("general-purpose".into());
names.sort_unstable();
names.dedup();
ToolSchema {
name: CLAUDE_AGENT.to_string(),
description: "Claude Code-compatible subagent dispatcher. Runs a named or ad-hoc \
child agent; children default to background execution in this compatibility mode."
.to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"prompt": {"type": "string", "description": "Self-contained child task."},
"subagent_type": {
"type": "string",
"description": format!("Named agent type. Available: {}", names.join(", "))
},
"description": {
"type": "string",
"description": "Short human-facing task label; preserved as descriptive input."
},
"model": {
"type": "string",
"description": "Optional model alias or full provider slug for this child."
},
"run_in_background": {
"type": "boolean",
"description": "Whether to return immediately with a child id (default true)."
}
},
"required": ["prompt"],
"additionalProperties": false
}),
}
}
fn translate_claude_agent_call(
&self,
call: &crate::message::ToolCall,
) -> Result<crate::message::ToolCall> {
let args = call
.function
.parsed_arguments()
.map_err(|error| Error::InvalidArguments {
tool: CLAUDE_AGENT.to_string(),
message: error.to_string(),
})?;
let object = args.as_object().ok_or_else(|| Error::InvalidArguments {
tool: CLAUDE_AGENT.to_string(),
message: "arguments must be an object".to_string(),
})?;
let mut translated = serde_json::Map::new();
if let Some(value) = object.get("prompt") {
translated.insert("task".to_string(), value.clone());
}
if let Some(value) = object.get("subagent_type") {
if value.as_str() != Some("general-purpose") {
translated.insert("agent_type".to_string(), value.clone());
}
}
if let Some(value) = object.get("model") {
translated.insert("model".to_string(), value.clone());
}
translated.insert(
"background".to_string(),
object
.get("run_in_background")
.cloned()
.unwrap_or(serde_json::Value::Bool(true)),
);
Ok(crate::message::ToolCall {
id: call.id.clone(),
kind: call.kind.clone(),
function: crate::message::FunctionCall {
name: SPAWN_SUBAGENT.to_string(),
arguments: serde_json::Value::Object(translated).to_string(),
},
})
}
fn run_claude_runtime_tool(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
let args = match call.function.parsed_arguments() {
Ok(value) if value.is_object() => value,
Ok(_) => {
return (
format!("Error: {} arguments must be an object", call.function.name),
true,
)
}
Err(error) => return (format!("Error: {error}"), true),
};
let object = args.as_object().expect("checked object above");
let Some(manifest) = self.claude_runtime_manifest.as_mut() else {
return (
"Error: Claude runtime compatibility was enabled without an imported runtime \
manifest; refusing to invent scheduler state"
.to_string(),
true,
);
};
let state = "paused";
match call.function.name.as_str() {
CLAUDE_CRON_LIST => {
let jobs: Vec<serde_json::Value> = manifest
.active_crons
.iter()
.map(|job| {
serde_json::json!({
"id": job.id,
"cron": job.schedule,
"prompt": job.prompt,
"recurring": job.recurring,
"durable": job.durable_requested,
"state": state
})
})
.collect();
let notice = "Imported jobs are preserved but no scheduler is running.";
(
serde_json::json!({
"execution_state": state,
"execution_notice": notice,
"jobs": jobs
})
.to_string(),
false,
)
}
CLAUDE_CRON_CREATE => {
let Some(schedule) = object.get("cron").and_then(serde_json::Value::as_str) else {
return ("Error: CronCreate requires string `cron`".to_string(), true);
};
let Some(prompt) = object.get("prompt").and_then(serde_json::Value::as_str) else {
return (
"Error: CronCreate requires string `prompt`".to_string(),
true,
);
};
let recurring = object
.get("recurring")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let durable_requested = object
.get("durable")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let mut sequence = 1_u64;
let id = loop {
let candidate = format!("sc{sequence:06}");
if !manifest.active_crons.iter().any(|job| job.id == candidate) {
break candidate;
}
sequence += 1;
};
let kind = if recurring { "recurring " } else { "" };
let result = format!(
"Scheduled {kind}job {id} ({schedule}) in PAUSED state. The job is preserved \
in the continuation manifest but no scheduler is running and it will not execute."
);
manifest
.active_crons
.push(crate::claude_runtime_state::ClaudeCronJob {
id: id.clone(),
tool_use_id: call.id.clone(),
schedule: schedule.to_string(),
recurring,
durable_requested,
prompt: prompt.to_string(),
created_at: Some(crate::sidecar::ms_to_rfc3339(now_ms())),
expires_after_seconds: None,
creation_result: result.clone(),
});
manifest
.active_crons
.sort_by(|left, right| left.id.cmp(&right.id));
(result, false)
}
CLAUDE_CRON_DELETE => {
let Some(id) = object.get("id").and_then(serde_json::Value::as_str) else {
return ("Error: CronDelete requires string `id`".to_string(), true);
};
let Some(index) = manifest.active_crons.iter().position(|job| job.id == id) else {
return (
format!("Error: unknown {state} Claude cron job `{id}`"),
true,
);
};
manifest.active_crons.remove(index);
(
format!("Cancelled job {id}. The job was PAUSED; no execution occurred."),
false,
)
}
CLAUDE_SCHEDULE_WAKEUP => {
let Some(delay_seconds) = object
.get("delaySeconds")
.and_then(serde_json::Value::as_u64)
else {
return (
"Error: ScheduleWakeup requires integer `delaySeconds`".to_string(),
true,
);
};
let reason = object
.get("reason")
.and_then(serde_json::Value::as_str)
.map(str::to_string);
let prompt = object
.get("prompt")
.and_then(serde_json::Value::as_str)
.map(str::to_string);
let now = now_ms();
let created_at = Some(crate::sidecar::ms_to_rfc3339(now));
let delay_ms = i64::try_from(delay_seconds)
.unwrap_or(i64::MAX)
.saturating_mul(1_000);
let scheduled_for = crate::sidecar::ms_to_rfc3339(now.saturating_add(delay_ms));
let result = format!(
"Next wakeup recorded for {scheduled_for} (in {delay_seconds}s) in PAUSED \
state. The request replaced the prior wakeup in the manifest, but no timer \
is running and it will not execute."
);
manifest.pending_wakeups.clear();
manifest
.pending_wakeups
.push(crate::claude_runtime_state::ClaudeWakeup {
tool_use_id: call.id.clone(),
delay_seconds,
reason,
prompt,
created_at,
scheduled_for: Some(scheduled_for),
creation_result: result.clone(),
});
(result, false)
}
_ => unreachable!("runtime tool dispatch is name-gated"),
}
}
fn subagent_status_schema() -> ToolSchema {
ToolSchema {
name: SUBAGENT_STATUS.to_string(),
description: "Check on (and, once finished, retrieve the result of) a background \
subagent spawned via spawn_subagent with background=true. Pass the \
`subagent_id` that spawn returned."
.to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"subagent_id": {
"type": "string",
"description": "The id `spawn_subagent` returned when this subagent \
was spawned."
}
},
"required": ["subagent_id"],
"additionalProperties": false
}),
}
}
fn subagent_message_schema() -> ToolSchema {
ToolSchema {
name: SUBAGENT_MESSAGE.to_string(),
description: "Send a message to a background subagent that is STILL RUNNING. The message is delivered to that subagent at the start of its next step, without interrupting the step it is on. Use `subagent_status` to check whether it is still running and to collect its result."
.to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"subagent_id": {
"type": "string",
"description": "The id `spawn_subagent` returned."
},
"message": {
"type": "string",
"description": "What to tell the running subagent."
}
},
"required": ["subagent_id", "message"],
"additionalProperties": false
}),
}
}
fn subagent_resume_schema() -> ToolSchema {
ToolSchema {
name: SUBAGENT_RESUME.to_string(),
description: "Continue a subagent that has already FINISHED, with its own previous conversation restored, so it keeps everything it learned instead of being briefed again from scratch. Pass the id it was spawned with and the next task."
.to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"subagent_id": {
"type": "string",
"description": "The id of a subagent that has already finished."
},
"task": {
"type": "string",
"description": "What the resumed subagent should do next."
}
},
"required": ["subagent_id", "task"],
"additionalProperties": false
}),
}
}
fn run_subagent_message(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
let args = match call.function.parsed_arguments() {
Ok(v) => v,
Err(e) => {
let err = Error::InvalidArguments {
tool: SUBAGENT_MESSAGE.to_string(),
message: e.to_string(),
};
return (format!("Error: {err}"), true);
}
};
let Some(id) = args.get("subagent_id").and_then(serde_json::Value::as_str) else {
let err = Error::InvalidArguments {
tool: SUBAGENT_MESSAGE.to_string(),
message: "`subagent_id` is required".to_string(),
};
return (format!("Error: {err}"), true);
};
let message = args
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
if message.is_empty() {
let err = Error::InvalidArguments {
tool: SUBAGENT_MESSAGE.to_string(),
message: "`message` is required and must be non-empty".to_string(),
};
return (format!("Error: {err}"), true);
}
let Some(entry) = self.background_subagents.get(id) else {
let err = Error::SubagentNotFound(id.to_string());
return (format!("Error: {err}"), true);
};
if entry.handle.is_finished() {
let out = serde_json::json!({
"subagent_id": id,
"status": "finished",
"delivered": false,
"hint": "this subagent already finished — collect it with subagent_status, then continue it with subagent_resume",
});
return (out.to_string(), false);
}
entry
.mailbox
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.queue_unchecked(message.to_string());
let out = serde_json::json!({
"subagent_id": id,
"status": "running",
"delivered": true,
});
(out.to_string(), false)
}
async fn run_subagent_resume(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
let args = match call.function.parsed_arguments() {
Ok(v) => v,
Err(e) => {
let err = Error::InvalidArguments {
tool: SUBAGENT_RESUME.to_string(),
message: e.to_string(),
};
return (format!("Error: {err}"), true);
}
};
let Some(id) = args
.get("subagent_id")
.and_then(serde_json::Value::as_str)
.map(String::from)
else {
let err = Error::InvalidArguments {
tool: SUBAGENT_RESUME.to_string(),
message: "`subagent_id` is required".to_string(),
};
return (format!("Error: {err}"), true);
};
let task = args
.get("task")
.and_then(serde_json::Value::as_str)
.unwrap_or("")
.to_string();
if task.is_empty() {
let err = Error::InvalidArguments {
tool: SUBAGENT_RESUME.to_string(),
message: "`task` is required and must be non-empty".to_string(),
};
return (format!("Error: {err}"), true);
}
if self
.background_subagents
.get(&id)
.is_some_and(|e| !e.handle.is_finished())
{
let err = Error::tool(
SUBAGENT_RESUME,
format!(
"subagent `{id}` is still running — send it a message with subagent_message, or collect it with subagent_status first"
),
);
return (format!("Error: {err}"), true);
}
let lineage = self
.subagent_store
.as_ref()
.and_then(|(store, parent)| store.load_subagent_lineage(parent, &id).ok().flatten());
let Some(prior) = self.prior_subagent_transcript(&id) else {
let err = Error::SubagentNotFound(id.clone());
return (format!("Error: {err}"), true);
};
let agent_type = lineage.as_ref().and_then(|l| l.agent_type.clone());
let definition = agent_type
.as_ref()
.and_then(|name| self.config.subagents_definitions.get(name).cloned());
let Some(guard) = crate::subagents::try_acquire(
&self.subagent_concurrency_gauge,
self.config.subagents_max_concurrent,
) else {
let err = Error::SubagentConcurrencyExceeded {
max_concurrent: self.config.subagents_max_concurrent,
};
return (format!("Error: {err}"), true);
};
let child_config = self.build_child_config(
definition.as_ref(),
None,
lineage
.as_ref()
.map(|l| l.model.clone())
.or_else(|| definition.as_ref().and_then(|d| d.model.clone())),
);
let mut child = Agent::with_provider_arc(child_config, self.provider.clone());
child.subagent_depth = self.subagent_depth + 1;
child.subagent_concurrency_gauge = self.subagent_concurrency_gauge.clone();
child.history.extend(prior);
let result = child.send(task).await;
let transcript = child.history()[1..].to_vec();
if let Some(lineage) = &lineage {
self.persist_subagent_transcript(&id, lineage, &transcript);
}
self.reaped_subagents.insert(id.clone(), transcript);
drop(guard);
match result {
Ok(text) => {
let out = serde_json::json!({
"subagent_id": id,
"status": "done",
"resumed": true,
"result": text,
});
(out.to_string(), false)
}
Err(e) => {
let out = serde_json::json!({
"subagent_id": id,
"status": "error",
"resumed": true,
"message": e.to_string(),
});
(out.to_string(), true)
}
}
}
pub fn child_config_for_agent_type(&self, agent_type: &str) -> Option<Config> {
let definition = self.config.subagents_definitions.get(agent_type)?.clone();
Some(self.build_child_config(Some(&definition), None, definition.model.clone()))
}
pub fn reaped_subagent_ids(&self) -> Vec<String> {
let mut ids: Vec<String> = self.reaped_subagents.keys().cloned().collect();
ids.sort();
ids
}
fn prior_subagent_transcript(&self, id: &str) -> Option<Vec<ChatMessage>> {
if let Some(messages) = self.reaped_subagents.get(id) {
return Some(messages.clone());
}
let (store, parent) = self.subagent_store.as_ref()?;
let jsonl = store.load_subagent_transcript(parent, id).ok()??;
let session = crate::session::Session::from_sidecar_str(&jsonl).ok()?;
Some(
session
.messages
.into_iter()
.filter(|m| m.role != crate::message::Role::System)
.collect(),
)
}
fn build_child_config(
&self,
definition: Option<&crate::subagents::NamedAgentDefinition>,
inline_system_prompt: Option<String>,
model_override: Option<String>,
) -> Config {
let system_prompt = definition
.map(|d| d.system_prompt.clone())
.filter(|s| !s.is_empty())
.or(inline_system_prompt)
.unwrap_or_else(|| self.config.system_prompt.clone());
let model = model_override.unwrap_or_else(|| self.config.model.clone());
let mut child = Config::builder()
.model(model)
.system_prompt(system_prompt)
.cwd(self.config.cwd.clone())
.sandbox(self.config.sandbox)
.approval(self.config.approval)
.max_iterations(self.config.max_iterations)
.build();
child.base_url = self.config.base_url.clone();
child.api_key = self.config.api_key.clone();
child.api_key_env = self.config.api_key_env.clone();
child.api_key_cmd = self.config.api_key_cmd.clone();
child.max_total_output_tokens = self.config.max_total_output_tokens;
child.max_tool_output_bytes = self.config.max_tool_output_bytes;
child.max_tokens = self.config.max_tokens;
child.doom_loop_threshold = self.config.doom_loop_threshold;
child.edit_file_require_read_before_edit = self.config.edit_file_require_read_before_edit;
child.tool_overrides = self.config.tool_overrides.clone();
child.auto_approved_tools = self.config.auto_approved_tools.clone();
child.tool_deny_patterns = self.config.tool_deny_patterns.clone();
child.tool_allow_patterns = self.config.tool_allow_patterns.clone();
child.permissions_enabled = self.config.permissions_enabled;
child.permissions_ask_patterns = self.config.permissions_ask_patterns.clone();
child.permissions_protected_paths = self.config.permissions_protected_paths.clone();
child.network_policy = self.config.network_policy.clone();
child.sandbox_os_enabled = self.config.sandbox_os_enabled;
child.sandbox_escalation = self.config.sandbox_escalation;
child.sandbox_env_policy = self.config.sandbox_env_policy;
if let Some(def) = definition {
if let Some(allowed) = &def.tools {
for name in &self.config.core_tools_enabled {
if !allowed.iter().any(|t| t == name) {
child
.tool_overrides
.entry(name.clone())
.or_default()
.enabled = Some(false);
}
}
}
if let Some(perms) = &def.permissions {
if let Some(approval) = perms.approval {
if crate::configfile::approval_rank(approval)
< crate::configfile::approval_rank(child.approval)
{
child.approval = approval;
}
}
if let Some(sandbox) = perms.sandbox {
if crate::configfile::sandbox_rank(sandbox)
< crate::configfile::sandbox_rank(child.sandbox)
{
child.sandbox = sandbox;
}
}
if let Some(allowed) = &perms.auto_approved_tools {
child
.auto_approved_tools
.retain(|tool| allowed.iter().any(|a| a == tool));
}
for pattern in &perms.deny {
if !child.tool_deny_patterns.iter().any(|p| p == pattern) {
child.tool_deny_patterns.push(pattern.clone());
}
}
}
}
child.core_tools_enabled = self.config.core_tools_enabled.clone();
child.module_registry = self.config.module_registry;
child.module_activation = self.config.module_activation.clone();
child.subagents_enabled = self.config.subagents_enabled;
child.subagents_max_depth = self.config.subagents_max_depth;
child.subagents_max_concurrent = self.config.subagents_max_concurrent;
child.subagents_background = self.config.subagents_background;
child.subagents_background_prompts = self.config.subagents_background_prompts;
child.subagents_claude_agent_alias = self.config.subagents_claude_agent_alias;
child.subagents_definitions = self.config.subagents_definitions.clone();
child.subagent_depth = self.subagent_depth + 1;
child
}
async fn run_spawn_subagent(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
let task = if self.config.subagents_enabled {
call.function
.parsed_arguments()
.ok()
.and_then(|v| {
v.get("task")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
})
.filter(|t| !t.is_empty())
} else {
None
};
if let Some(task) = &task {
self.fire_lifecycle(&crate::config::LifecycleEvent::SubagentStart {
task: task.clone(),
});
}
let (output, is_error) = self.run_spawn_subagent_inner(call).await;
if let Some(task) = task {
self.fire_lifecycle(&crate::config::LifecycleEvent::SubagentStop {
task,
is_error,
output_len: output.len(),
});
}
(output, is_error)
}
fn fire_lifecycle(&self, event: &crate::config::LifecycleEvent) {
if let Some(hook) = self.config.lifecycle_hook.as_ref() {
hook(event);
}
}
pub fn set_lifecycle_hook(&mut self, hook: crate::config::LifecycleHook) {
self.config.lifecycle_hook = Some(hook);
}
async fn run_spawn_subagent_inner(
&mut self,
call: &crate::message::ToolCall,
) -> (String, bool) {
if !self.config.subagents_enabled {
let err = Error::UnknownTool(SPAWN_SUBAGENT.to_string());
return (format!("Error: {err}"), true);
}
let args = match call.function.parsed_arguments() {
Ok(v) => v,
Err(e) => {
let err = Error::InvalidArguments {
tool: SPAWN_SUBAGENT.to_string(),
message: e.to_string(),
};
return (format!("Error: {err}"), true);
}
};
let task = args
.get("task")
.and_then(serde_json::Value::as_str)
.unwrap_or("")
.to_string();
if task.is_empty() {
let err = Error::InvalidArguments {
tool: SPAWN_SUBAGENT.to_string(),
message: "`task` is required and must be non-empty".to_string(),
};
return (format!("Error: {err}"), true);
}
let agent_type = args
.get("agent_type")
.and_then(serde_json::Value::as_str)
.map(String::from);
let inline_system_prompt = args
.get("system_prompt")
.and_then(serde_json::Value::as_str)
.map(String::from);
let background = args
.get("background")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let requested_model = args
.get("model")
.and_then(serde_json::Value::as_str)
.map(|model| crate::model_catalog::resolve_alias(model));
let definition = match &agent_type {
Some(name) => match self.config.subagents_definitions.get(name) {
Some(d) => Some(d.clone()),
None => {
let err = Error::SubagentDefinitionNotFound(name.clone());
return (format!("Error: {err}"), true);
}
},
None => None,
};
if background {
if !self.config.subagents_background {
let err = Error::tool(
SPAWN_SUBAGENT,
"background=true requires capabilities.subagents.background = true",
);
return (format!("Error: {err}"), true);
}
if self.config.subagents_background_prompts.is_none() {
let err = Error::SubagentBackgroundPolicyMissing;
return (format!("Error: {err}"), true);
}
}
if let Err(e) =
crate::subagents::check_depth(self.subagent_depth, self.config.subagents_max_depth)
{
return (format!("Error: {e}"), true);
}
let Some(guard) = crate::subagents::try_acquire(
&self.subagent_concurrency_gauge,
self.config.subagents_max_concurrent,
) else {
let err = Error::SubagentConcurrencyExceeded {
max_concurrent: self.config.subagents_max_concurrent,
};
return (format!("Error: {err}"), true);
};
let child_id = next_subagent_id();
let child_config = self.build_child_config(
definition.as_ref(),
inline_system_prompt,
requested_model.or_else(|| definition.as_ref().and_then(|d| d.model.clone())),
);
let child_model = child_config.model.clone();
let mut child = Agent::with_provider_arc(child_config, self.provider.clone());
child.subagent_depth = self.subagent_depth + 1;
child.subagent_concurrency_gauge = self.subagent_concurrency_gauge.clone();
if background {
if let Some(crate::subagents::BackgroundPromptsPolicy::Parent) =
self.config.subagents_background_prompts
{
let handler: std::sync::Arc<dyn crate::permissions::PermissionsApprovalHandler> =
match &self.child_approval_handler_factory {
Some(factory) => {
factory(child_id.clone(), self.pending_child_approvals.clone())
}
None => std::sync::Arc::new(crate::subagents::ParentQueueApprovalHandler {
child_agent_id: child_id.clone(),
queue: self.pending_child_approvals.clone(),
}),
};
child.ctx.sandbox_approval_handler =
Some(crate::sandbox::SandboxApprovalHandler(handler.clone()));
child.permissions_approval_handler = Some(handler);
}
}
let lineage = crate::subagents::SubagentLineage {
child_agent_id: child_id.clone(),
parent_session_id: self.subagent_store.as_ref().map(|(_, name)| name.clone()),
parent_tool_use_id: call.id.clone(),
depth: self.subagent_depth + 1,
agent_type: agent_type.clone(),
task: task.clone(),
background,
spawned_at_ms: now_ms(),
model: child_model,
};
if let Some((store, parent_name)) = &self.subagent_store {
let _ = store.save_subagent_lineage(parent_name, &child_id, &lineage);
}
if background {
let spawned_task_text = task.clone();
let mailbox = child.steer_queue_handle();
self.background_subagents.insert(
child_id.clone(),
BackgroundSubagent {
handle: tokio::spawn(async move {
let _guard = guard;
let result = child.send(spawned_task_text).await;
let transcript = child.history()[1..].to_vec();
(child_id, result, transcript)
}),
task,
agent_type,
started_at_ms: lineage.spawned_at_ms,
mailbox,
},
);
let out = serde_json::json!({
"subagent_id": lineage.child_agent_id,
"status": "spawned",
"background": true,
});
return (out.to_string(), false);
}
let result = child.send(task).await;
let transcript = child.history()[1..].to_vec();
self.persist_subagent_transcript(&child_id, &lineage, &transcript);
self.reaped_subagents
.insert(child_id.clone(), transcript.clone());
drop(guard);
match result {
Ok(text) => (text, false),
Err(e) => (format!("Error: subagent `{child_id}` failed: {e}"), true),
}
}
async fn run_subagent_status(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
let args = match call.function.parsed_arguments() {
Ok(v) => v,
Err(e) => {
let err = Error::InvalidArguments {
tool: SUBAGENT_STATUS.to_string(),
message: e.to_string(),
};
return (format!("Error: {err}"), true);
}
};
let Some(id) = args.get("subagent_id").and_then(serde_json::Value::as_str) else {
let err = Error::InvalidArguments {
tool: SUBAGENT_STATUS.to_string(),
message: "`subagent_id` is required".to_string(),
};
return (format!("Error: {err}"), true);
};
let Some(entry) = self.background_subagents.get(id) else {
let err = Error::SubagentNotFound(id.to_string());
return (format!("Error: {err}"), true);
};
if !entry.handle.is_finished() {
let out = serde_json::json!({
"subagent_id": id,
"status": "pending",
"task": entry.task,
"agent_type": entry.agent_type,
"started_at_ms": entry.started_at_ms,
});
return (out.to_string(), false);
}
let entry = self
.background_subagents
.remove(id)
.expect("checked Some above");
let (child_id, result, transcript) = match entry.handle.await {
Ok(v) => v,
Err(join_err) => {
let err = Error::tool(
SUBAGENT_STATUS,
format!("subagent `{id}` task panicked: {join_err}"),
);
return (format!("Error: {err}"), true);
}
};
if let Some((store, parent_name)) = self.subagent_store.clone() {
if let Ok(Some(lineage)) = store.load_subagent_lineage(&parent_name, &child_id) {
self.persist_subagent_transcript(&child_id, &lineage, &transcript);
}
}
self.reaped_subagents
.insert(child_id.clone(), transcript.clone());
match result {
Ok(text) => {
let out = serde_json::json!({
"subagent_id": child_id,
"status": "done",
"result": text,
});
(out.to_string(), false)
}
Err(e) => {
let out = serde_json::json!({
"subagent_id": child_id,
"status": "error",
"message": e.to_string(),
});
(out.to_string(), true)
}
}
}
fn run_background_exec(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
let args = match call.function.parsed_arguments() {
Ok(v) => v,
Err(e) => {
let err = Error::InvalidArguments {
tool: BACKGROUND_EXEC.to_string(),
message: e.to_string(),
};
return (format!("Error: {err}"), true);
}
};
let command = args
.get("command")
.and_then(serde_json::Value::as_str)
.unwrap_or("")
.to_string();
if command.is_empty() {
let err = Error::InvalidArguments {
tool: BACKGROUND_EXEC.to_string(),
message: "`command` is required and must be non-empty".to_string(),
};
return (format!("Error: {err}"), true);
}
let job_id = crate::background::next_job_id(now_ms());
let mut command = command;
let mut hook_decision = crate::config::HookDecision::Pass;
if let Some(hook) = &self.config.pre_tool_hook {
let outcome = hook(BACKGROUND_EXEC, &args);
if outcome.decision == crate::config::HookDecision::Deny {
let reason = outcome.reason.unwrap_or_else(|| "denied".to_string());
return (format!("Error: blocked by pre-tool hook: {reason}"), true);
}
if let Some(rewritten) = outcome.updated_args {
command = rewritten
.get("command")
.and_then(|v| v.as_str())
.unwrap_or(&command)
.to_string();
}
hook_decision = outcome.decision;
}
if let Some(reason) = self.background_permission_denial(&command, &job_id, hook_decision) {
return (format!("Error: {reason}"), true);
}
let Some(guard) = crate::subagents::try_acquire(
&self.background_concurrency_gauge,
self.config.tools_background_max_concurrent,
) else {
let err = Error::BackgroundJobConcurrencyExceeded {
max_concurrent: self.config.tools_background_max_concurrent,
};
return (format!("Error: {err}"), true);
};
let mut cmd = match crate::tools::build_sandboxed_sh(&command, &self.ctx) {
Ok(cmd) => cmd,
Err(e) => return (format!("Error: {e}"), true),
};
cmd.current_dir(&self.ctx.cwd)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
#[cfg(unix)]
cmd.process_group(0);
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
drop(guard);
let err = Error::tool(
BACKGROUND_EXEC,
format!("failed to spawn background command: {e}"),
);
return (format!("Error: {err}"), true);
}
};
let pid = child.id();
let output = std::sync::Arc::new(crate::background::CapturedOutput::new());
let cap = self.config.tools_background_max_output_bytes;
if let Some(stdout) = child.stdout.take() {
let _stdout_reader = spawn_output_reader(stdout, output.clone(), cap);
}
if let Some(stderr) = child.stderr.take() {
let _stderr_reader = spawn_output_reader(stderr, output.clone(), cap);
}
let started_at_ms = now_ms();
self.background_jobs.insert(
job_id.clone(),
BackgroundJob {
child,
command: command.clone(),
pid,
output,
started_at_ms,
killed: false,
_guard: guard,
},
);
let out = serde_json::json!({
"job_id": job_id,
"status": "running",
"pid": pid,
"command": command,
});
(out.to_string(), false)
}
fn run_background_status(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
let args = match call.function.parsed_arguments() {
Ok(v) => v,
Err(e) => {
let err = Error::InvalidArguments {
tool: BACKGROUND_STATUS.to_string(),
message: e.to_string(),
};
return (format!("Error: {err}"), true);
}
};
let Some(job_id) = args.get("job_id").and_then(serde_json::Value::as_str) else {
let err = Error::InvalidArguments {
tool: BACKGROUND_STATUS.to_string(),
message: "`job_id` is required".to_string(),
};
return (format!("Error: {err}"), true);
};
let job_id = job_id.to_string();
let (command, pid, started_at_ms, status, output_so_far, truncated, delta) = {
let Some(job) = self.background_jobs.get_mut(&job_id) else {
let err = Error::BackgroundJobNotFound(job_id);
return (format!("Error: {err}"), true);
};
let status = background_job_status(job);
let (output_so_far, truncated) = job.output.snapshot();
let delta = job.output.drain_new();
(
job.command.clone(),
job.pid,
job.started_at_ms,
status,
output_so_far,
truncated,
delta,
)
};
if !delta.is_empty() {
self.emit(AgentEvent::BackgroundOutput {
job_id: job_id.clone(),
chunk: delta,
truncated,
});
}
let exit_code = match status {
crate::background::JobStatus::Exited(code) => code,
_ => None,
};
let out = serde_json::json!({
"job_id": job_id,
"command": command,
"status": status.as_str(),
"exit_code": exit_code,
"pid": pid,
"started_at_ms": started_at_ms,
"output": output_so_far,
"output_truncated": truncated,
});
if !matches!(status, crate::background::JobStatus::Running) {
self.background_jobs.remove(&job_id);
}
(out.to_string(), false)
}
fn run_background_list(&mut self, _call: &crate::message::ToolCall) -> (String, bool) {
let mut jobs = Vec::new();
for (job_id, job) in self.background_jobs.iter_mut() {
let status = background_job_status(job);
jobs.push(serde_json::json!({
"job_id": job_id,
"command": job.command,
"status": status.as_str(),
"pid": job.pid,
"started_at_ms": job.started_at_ms,
}));
}
let out = serde_json::json!({ "jobs": jobs });
(out.to_string(), false)
}
fn run_background_kill(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
let args = match call.function.parsed_arguments() {
Ok(v) => v,
Err(e) => {
let err = Error::InvalidArguments {
tool: BACKGROUND_KILL.to_string(),
message: e.to_string(),
};
return (format!("Error: {err}"), true);
}
};
let Some(job_id) = args.get("job_id").and_then(serde_json::Value::as_str) else {
let err = Error::InvalidArguments {
tool: BACKGROUND_KILL.to_string(),
message: "`job_id` is required".to_string(),
};
return (format!("Error: {err}"), true);
};
let job_id = job_id.to_string();
let Some(mut job) = self.background_jobs.remove(&job_id) else {
let err = Error::BackgroundJobNotFound(job_id);
return (format!("Error: {err}"), true);
};
kill_job_process_group(&mut job);
job.killed = true;
let out = serde_json::json!({
"job_id": job_id,
"status": "killed",
"pid": job.pid,
});
(out.to_string(), false)
}
fn persist_subagent_transcript(
&self,
child_id: &str,
lineage: &crate::subagents::SubagentLineage,
transcript: &[ChatMessage],
) {
let Some((store, parent_name)) = &self.subagent_store else {
return;
};
let mut session = match Session::from_claude_code_str("") {
Ok(s) => s,
Err(_) => return,
};
session.meta.source = crate::session::SessionSource::Native;
session.meta.agent_id = Some(lineage.child_agent_id.clone());
session.meta.parent_tool_use_id = Some(lineage.parent_tool_use_id.clone());
session.meta.lineage = lineage.to_lineage_map();
let sidecar_jsonl = session.to_native_jsonl_v2(transcript);
let _ = store.save_subagent_transcript(parent_name, child_id, &sidecar_jsonl);
let _ = store.save_subagent_lineage(parent_name, child_id, lineage);
}
fn run_tool_search(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
let args = match call.function.parsed_arguments() {
Ok(v) => v,
Err(e) => {
let err = Error::InvalidArguments {
tool: TOOL_SEARCH.to_string(),
message: e.to_string(),
};
return (format!("Error: {err}"), true);
}
};
let query = args
.get("query")
.and_then(serde_json::Value::as_str)
.unwrap_or("")
.to_lowercase();
let max_results = args
.get("max_results")
.and_then(serde_json::Value::as_u64)
.map(|n| n as usize);
let mut matches: Vec<ToolSchema> = self
.registry
.iter()
.filter(|t| self.config.tool_enabled(t.name()))
.filter(|t| !self.is_core_tool(t.name()))
.filter(|t| !self.activated_tools.contains(t.name()))
.filter(|t| {
query.is_empty()
|| t.name().to_lowercase().contains(&query)
|| self
.config
.tool_description(t.name(), t.description())
.to_lowercase()
.contains(&query)
})
.map(|t| self.raw_schema_for(t))
.collect();
if let Some(max) = max_results {
matches.truncate(max);
}
for m in &matches {
self.activated_tools.insert(m.name.clone());
}
let result = serde_json::to_string(&matches).unwrap_or_else(|_| "[]".to_string());
(result, false)
}
fn expand_reduction_schema() -> ToolSchema {
ToolSchema {
name: EXPAND_REDUCTION.to_string(),
description: "Fetch back the original content hidden behind a reduction stub in \
your current view — a truncated tool output, cleared old turns, or an elided \
file read that was hidden to save context. Each stub line names a reduction id \
like r0042-9f3c: pass ONLY that id here, and never quote or repeat a stub line \
itself in your replies. The original is durably kept in the session sidecar. \
Pass `byte_range` to fetch a slice of a large one at a time instead of all of \
it at once; ranged results are prefixed with a `bytes start..end of total` \
header so you can plan the next slice."
.to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"reduction_id": {
"type": "string",
"description": "The reduction id named in the stub line, e.g. \
\"r0042-9f3c\". Pass the id alone."
},
"byte_range": {
"type": "array",
"items": {"type": "integer"},
"minItems": 2,
"maxItems": 2,
"description": "Optional [start, end) byte offsets within the original \
content to fetch instead of all of it. Exactly two non-negative \
integers with start <= end."
}
},
"required": ["reduction_id"],
"additionalProperties": false
}),
}
}
fn sidecar_search_schema() -> ToolSchema {
ToolSchema {
name: SIDECAR_SEARCH.to_string(),
description: "Search content currently hidden from your view by reduction stubs \
(large tool outputs, cleared old turns, elided file reads) for a substring or \
regex. Only hidden content is searched, never what you can already see. \
Returns match snippets with each match's reduction_id for use with \
expand_reduction; refer to results by their reduction id rather than quoting \
stub lines. Results are capped — if `truncated` is true, narrow the query."
.to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Non-empty substring or regex to search for \
(case-insensitive)."
}
},
"required": ["query"],
"additionalProperties": false
}),
}
}
fn recorded_messages(&self) -> std::result::Result<Option<Vec<ChatMessage>>, String> {
let Some(recorder) = &self.recorder else {
return Ok(None);
};
let raw = std::fs::read_to_string(recorder.path())
.map_err(|e| format!("failed to read the session sidecar: {e}"))?;
let session = Session::from_sidecar_str(&raw)
.map_err(|e| format!("failed to parse the session sidecar: {e}"))?;
Ok(Some(session.messages))
}
fn run_expand_reduction(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
let args = match call.function.parsed_arguments() {
Ok(v) => v,
Err(e) => {
let err = Error::InvalidArguments {
tool: EXPAND_REDUCTION.to_string(),
message: e.to_string(),
};
return (format!("Error: {err}"), true);
}
};
let Some(id) = args.get("reduction_id").and_then(serde_json::Value::as_str) else {
return (
"Error: expand_reduction requires a `reduction_id` string argument".to_string(),
true,
);
};
let recorded = match self.recorded_messages() {
Ok(r) => r,
Err(e) => return (format!("Error: expand_reduction: {e}"), true),
};
let recorded = recorded.as_deref();
let byte_range = match args.get("byte_range") {
None | Some(serde_json::Value::Null) => None,
Some(v) => {
let parsed = v
.as_array()
.filter(|a| a.len() == 2)
.and_then(|a| Some((a[0].as_u64()? as usize, a[1].as_u64()? as usize)));
match parsed {
Some(range) => Some(range),
None => {
let total = reduce::rehydrate::reduction_total_bytes(
&self.reduction_log,
&self.history[1..],
recorded,
id,
)
.map(|n| format!("; the original is {n} bytes"))
.unwrap_or_default();
return (
format!(
"Error: expand_reduction: malformed byte_range {v} — expected \
[start, end): exactly two non-negative integers with \
start <= end{total}"
),
true,
);
}
}
}
};
match reduce::rehydrate::expand_reduction(
&self.reduction_log,
&self.history[1..],
recorded,
id,
byte_range,
) {
Ok(outcome) => match outcome.range {
Some((start, end)) => (
format!(
"[{id}: bytes {start}..{end} of {total}]\n{content}",
total = outcome.total_bytes,
content = outcome.content
),
false,
),
None => (outcome.content, false),
},
Err(e) => (format!("Error: {e}"), true),
}
}
fn run_sidecar_search(&mut self, call: &crate::message::ToolCall) -> (String, bool) {
let args = match call.function.parsed_arguments() {
Ok(v) => v,
Err(e) => {
let err = Error::InvalidArguments {
tool: SIDECAR_SEARCH.to_string(),
message: e.to_string(),
};
return (format!("Error: {err}"), true);
}
};
let query = args
.get("query")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
if query.trim().is_empty() {
return (
"Error: sidecar_search requires a non-empty `query` string argument".to_string(),
true,
);
}
let recorded = match self.recorded_messages() {
Ok(r) => r,
Err(e) => return (format!("Error: sidecar_search: {e}"), true),
};
match reduce::rehydrate::sidecar_search(
&self.reduction_log,
&self.history[1..],
recorded.as_deref(),
query,
) {
Ok(result) => (
serde_json::to_string(&result).unwrap_or_else(|_| "{}".to_string()),
false,
),
Err(e) => (format!("Error: {e}"), true),
}
}
fn emit(&self, event: AgentEvent) {
if let Some(sink) = &self.config.event_sink {
sink(event);
}
}
pub fn turn_count(&self) -> usize {
self.history
.iter()
.filter(|m| m.role != Role::System)
.count()
}
pub fn total_output_tokens(&self) -> u64 {
self.total_output_tokens
}
}
#[cfg(test)]
mod bp2_spill_tests {
use super::*;
use crate::configfile::{resolve, ResolveOptions};
#[derive(Debug)]
struct NeverCalledProvider;
#[async_trait::async_trait]
impl Provider for NeverCalledProvider {
async fn complete(
&self,
_req: &ChatRequest,
_on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
) -> crate::Result<(ChatMessage, crate::provider::Usage)> {
unreachable!("BP-2 spill tests never issue a request")
}
}
fn resolved(preset: &str) -> crate::configfile::Resolved {
let toml = crate::presets::lookup(preset).unwrap();
resolve(toml, None, &ResolveOptions { strict: true })
.unwrap_or_else(|e| panic!("{preset} resolves: {e}"))
}
#[tokio::test]
async fn parity_presets_spill_capped_output_and_name_a_door_the_preset_has() {
for (preset, expected_door) in [
("cc-parity", "read it with `read_file`"),
("cx-parity", "read it with `cat`"),
] {
let r = resolved(preset);
assert!(
r.config.tool_output_spill,
"{preset} must set `core.tool_output_spill`"
);
assert_eq!(
r.modules.get("reduction"),
Some(&false),
"{preset} leaves `capabilities.reduction` off — the spill must not depend on it"
);
let mut config = resolved(preset).config;
config.max_tool_output_bytes = Some(1024);
let registry = crate::tools::ToolRegistry::from_config(&config);
let agent = Agent::with_parts(config, Box::new(NeverCalledProvider), registry);
assert!(
agent.reduction_policy.is_none(),
"no reduction policy is installed under {preset}"
);
let full = "R".repeat(50_000);
let capped = agent.cap_tool_output(full.clone());
assert!(capped.len() < full.len(), "{preset}: output must be capped");
assert!(capped.contains(expected_door), "{preset}: {capped:?}");
let marker = capped.split("spilled to ").nth(1).unwrap_or_default();
let path = marker.split(" — ").next().unwrap_or_default();
assert!(!path.is_empty(), "{preset}: no spill path in {capped:?}");
assert_eq!(
std::fs::read_to_string(path).unwrap(),
full,
"{preset}: the spill file must hold the FULL output"
);
let ctx = build_tool_context(agent.config()).0;
let recovered = match registry_read_tool(&agent) {
Some(("read_file", tool)) => tool
.execute(serde_json::json!({"path": path}), &ctx)
.await
.unwrap(),
Some(("bash", tool)) => tool
.execute(serde_json::json!({"command": format!("cat {path}")}), &ctx)
.await
.unwrap(),
_ => panic!("{preset}: no read door registered"),
};
assert!(
recovered.contains(&"R".repeat(2000)),
"{preset}: the door must return the spilled output"
);
let _ = std::fs::remove_file(path);
}
}
fn registry_read_tool<'a>(
agent: &'a Agent,
) -> Option<(&'static str, &'a dyn crate::tools::Tool)> {
if let Some(tool) = agent.registry.get("read_file") {
return Some(("read_file", tool));
}
agent.registry.get("bash").map(|tool| ("bash", tool))
}
#[test]
fn spill_off_leaves_the_notice_unchanged_and_writes_nothing() {
let config = Config::builder().max_tool_output_bytes(1024).build();
assert!(!config.tool_output_spill);
let agent = Agent::with_provider(config, Box::new(NeverCalledProvider));
let capped = agent.cap_tool_output("S".repeat(50_000));
assert!(capped.contains("full output not retained"), "{capped:?}");
assert!(!capped.contains("spilled to"), "{capped:?}");
}
}
#[cfg(test)]
mod bp3_new_core_tool_tests {
use super::*;
use crate::configfile::{resolve, ResolveOptions};
#[derive(Debug)]
struct NeverCalledProvider;
#[async_trait::async_trait]
impl Provider for NeverCalledProvider {
async fn complete(
&self,
_req: &ChatRequest,
_on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
) -> crate::Result<(ChatMessage, crate::provider::Usage)> {
unreachable!("BP-3 tests never issue a request")
}
}
fn resolved(preset: &str) -> Config {
let toml = crate::presets::lookup(preset).unwrap();
resolve(toml, None, &ResolveOptions { strict: true })
.unwrap_or_else(|e| panic!("{preset} resolves: {e}"))
.config
}
fn agent_for(preset: &str) -> Agent {
Agent::with_provider(resolved(preset), Box::new(NeverCalledProvider))
}
struct AlwaysAllow;
impl crate::permissions::PermissionsApprovalHandler for AlwaysAllow {
fn ask(
&self,
_req: &crate::permissions::ApprovalRequest,
) -> crate::permissions::ApprovalOutcome {
crate::permissions::ApprovalOutcome::Allow
}
}
fn call(name: &str, args: serde_json::Value) -> crate::message::ToolCall {
crate::message::ToolCall {
id: format!("call-{name}"),
kind: "function".to_string(),
function: crate::message::FunctionCall {
name: name.to_string(),
arguments: args.to_string(),
},
}
}
#[test]
fn cc_parity_plan_mode_denies_writes_through_the_permissions_engine() {
let mut agent = agent_for("cc-parity");
assert!(
agent.config.permissions_enabled,
"cc-parity runs the permissions engine; plan mode narrows it"
);
agent.set_permissions_approval_handler(AlwaysAllow);
let write = serde_json::json!({"path": "notes.txt", "content": "x"});
let bash = serde_json::json!({"command": "echo hi"});
assert!(
agent
.permissions_gate_denial("write_file", &write, crate::config::HookDecision::Pass)
.is_none(),
"outside plan mode an allowed write must pass"
);
assert!(agent
.permissions_gate_denial("bash", &bash, crate::config::HookDecision::Pass)
.is_none());
agent.plan_mode().enter(Some("research first"));
let denial = agent
.permissions_gate_denial("write_file", &write, crate::config::HookDecision::Pass)
.expect("plan mode must refuse a write");
assert!(denial.contains("Deny"), "{denial}");
assert!(agent
.permissions_gate_denial("bash", &bash, crate::config::HookDecision::Pass)
.is_some());
assert!(agent
.permissions_gate_denial(
"apply_patch",
&serde_json::json!({"patch": "*** Begin Patch\n*** End Patch"}),
crate::config::HookDecision::Pass
)
.is_some());
for (tool, args) in [
("read_file", serde_json::json!({"path": "notes.txt"})),
("glob", serde_json::json!({"pattern": "*.rs"})),
("exit_plan_mode", serde_json::json!({"plan": "the plan"})),
("ask_user", serde_json::json!({"questions": []})),
] {
assert!(
agent
.permissions_gate_denial(tool, &args, crate::config::HookDecision::Pass)
.is_none(),
"plan mode must leave `{tool}` reachable"
);
}
agent.plan_mode().exit();
assert!(
agent
.permissions_gate_denial("write_file", &write, crate::config::HookDecision::Pass)
.is_none(),
"leaving plan mode restores the write surface"
);
}
#[test]
fn cx_parity_publishes_its_context_accounting_when_the_budget_tool_runs() {
let mut agent = agent_for("cx-parity");
assert!(agent.ctx.context_budget.snapshot().is_none(), "nothing yet");
agent
.history
.push(ChatMessage::user("x".repeat(4000).to_string()));
let _ = agent.prepare_tool_call(&call("current_time", serde_json::json!({})));
assert!(
agent.ctx.context_budget.snapshot().is_none(),
"an unrelated tool call must not pay for the accounting"
);
let _ = agent.prepare_tool_call(&call("get_context_remaining", serde_json::json!({})));
let published = agent
.ctx
.context_budget
.snapshot()
.expect("the budget tool's own call publishes it");
assert_eq!(
published,
serde_json::to_value(agent.context_usage()).unwrap()
);
assert!(published["context_limit"].as_u64().unwrap() > 0);
assert!(published["remaining_tokens"].as_u64().unwrap() > 0);
}
#[test]
fn cx_parity_new_context_rebuilds_the_window_through_the_same_handoff_the_operator_gets() {
let mut agent = agent_for("cx-parity");
agent.history.push(ChatMessage::system("system"));
for i in 0..12 {
agent.history.push(ChatMessage::user(format!("turn {i}")));
}
let before = agent.history.clone();
agent
.ctx
.context_budget
.request_new_context(crate::tools::NewContextRequest {
objective: "finish the parser".to_string(),
keep_recent: Some(2),
});
agent.apply_pending_new_context();
let mut expected =
Agent::with_provider(resolved("cx-parity"), Box::new(NeverCalledProvider));
expected.history = before.clone();
expected.new_context("finish the parser", Some(2));
assert_eq!(agent.history, expected.history);
assert!(
agent.history.len() < before.len(),
"the window must actually shrink: {} -> {}",
before.len(),
agent.history.len()
);
assert_eq!(agent.history[0], before[0], "the system prompt survives");
let marker = agent.history[1].content.clone().unwrap_or_default();
assert!(marker.contains("fresh working context"), "{marker}");
assert!(marker.contains("finish the parser"), "{marker}");
assert_eq!(
agent.history[agent.history.len() - 2..],
before[before.len() - 2..],
"the requested tail is kept verbatim"
);
assert!(
agent.ctx.context_budget.take_new_context().is_none(),
"the request is consumed exactly once"
);
}
#[test]
fn cx_parity_new_context_states_the_retention_it_actually_has() {
let mut agent = agent_for("cx-parity");
agent.history.push(ChatMessage::system("system"));
for i in 0..12 {
agent.history.push(ChatMessage::user(format!("turn {i}")));
}
agent
.ctx
.context_budget
.request_new_context(crate::tools::NewContextRequest {
objective: "finish the parser".to_string(),
keep_recent: Some(2),
});
agent.apply_pending_new_context();
let marker = agent.history[1].content.clone().unwrap_or_default();
assert!(
marker.contains("No transcript sidecar is attached"),
"the marker must not overstate retention: {marker}"
);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RewindOutcome {
pub kept: usize,
pub removed: usize,
pub preserved_branch: Option<String>,
}
#[cfg(test)]
mod bp1_compaction_tests {
use super::*;
use crate::configfile::{resolve, ResolveOptions};
#[derive(Debug)]
struct NeverCalledProvider;
#[async_trait::async_trait]
impl Provider for NeverCalledProvider {
async fn complete(
&self,
_req: &ChatRequest,
_on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
) -> crate::Result<(ChatMessage, crate::provider::Usage)> {
unreachable!("BP-1 compaction tests never issue a request")
}
}
fn resolved_cx_parity() -> Config {
let toml = crate::presets::lookup("cx-parity").unwrap();
resolve(toml, None, &ResolveOptions { strict: true })
.expect("cx-parity resolves")
.config
}
fn stuff_history(agent: &mut Agent) {
agent.history.push(ChatMessage::system("system"));
for i in 0..400 {
agent
.history
.push(ChatMessage::user(format!("turn {i}: {}", "x".repeat(8000))));
}
}
#[test]
fn cx_parity_fires_maybe_compact_at_its_pressure_trigger() {
let config = resolved_cx_parity();
assert!(config.compaction_enabled);
assert_eq!(config.compaction_reserve_tokens, Some(16384));
assert!(config.compaction_summarize);
let mut agent = Agent::with_provider(config, Box::new(NeverCalledProvider));
stuff_history(&mut agent);
let before = agent.history.len();
assert!(
agent.maybe_compact(),
"cx-parity must compact under context pressure"
);
assert!(agent.history.len() < before, "history must actually shrink");
let marker = agent
.history
.iter()
.find(|m| {
m.content
.as_deref()
.is_some_and(|c| c.contains("earlier conversation compacted"))
})
.expect("a compaction marker must be present");
assert!(
marker
.content
.as_deref()
.unwrap()
.contains("summarized to save context"),
"cx-parity sets `core.compaction.summarize = true`"
);
}
#[test]
fn compaction_summarize_false_reaches_config_and_changes_the_marker() {
let toml = "extends = \"cx-parity\"\n[core.compaction]\nsummarize = false\n";
let config = resolve(toml, None, &ResolveOptions::default())
.expect("resolves")
.config;
assert!(!config.compaction_summarize);
let mut agent = Agent::with_provider(config, Box::new(NeverCalledProvider));
stuff_history(&mut agent);
assert!(agent.maybe_compact());
let marker = agent
.history
.iter()
.find(|m| {
m.content
.as_deref()
.is_some_and(|c| c.contains("earlier conversation compacted"))
})
.expect("a compaction marker must be present");
let text = marker.content.as_deref().unwrap();
assert!(text.contains("cleared to save context"), "got: {text}");
assert!(!text.contains("summarized"));
}
}
#[cfg(test)]
mod api_key_cmd_tests {
use super::*;
#[test]
fn default_none_falls_through_to_missing_api_key_error() {
let config = Config::builder()
.api_key_env("SUPERCODE_TEST_UNSET_VAR_API_KEY_CMD")
.build();
assert!(config.api_key.is_none());
assert!(config.api_key_cmd.is_none());
let err = Agent::new(config).err().expect("no key source configured");
assert!(matches!(err, Error::MissingApiKey(_)));
}
#[test]
fn api_key_cmd_alone_resolves_successfully() {
let config = Config::builder()
.api_key_cmd("echo sk-test-from-helper")
.api_key_env("SUPERCODE_TEST_UNSET_VAR_API_KEY_CMD_2")
.build();
assert!(Agent::new(config).is_ok());
}
#[test]
fn api_key_cmd_failure_falls_through_to_env() {
std::env::set_var(
"SUPERCODE_TEST_API_KEY_CMD_FALLBACK",
"sk-from-env-fallback",
);
let config = Config::builder()
.api_key_cmd("exit 1")
.api_key_env("SUPERCODE_TEST_API_KEY_CMD_FALLBACK")
.build();
assert!(Agent::new(config).is_ok());
std::env::remove_var("SUPERCODE_TEST_API_KEY_CMD_FALLBACK");
}
#[test]
fn api_key_cmd_failure_with_no_fallback_still_errors() {
let config = Config::builder()
.api_key_cmd("exit 1")
.api_key_env("SUPERCODE_TEST_UNSET_VAR_API_KEY_CMD_3")
.build();
let err = Agent::new(config)
.err()
.expect("helper failed, no env fallback");
assert!(matches!(err, Error::MissingApiKey(_)));
}
#[test]
fn run_api_key_cmd_trims_output() {
assert_eq!(run_api_key_cmd("echo ' sk-abc123 '"), "sk-abc123");
}
#[test]
fn run_api_key_cmd_spawn_failure_returns_empty() {
assert_eq!(
run_api_key_cmd("/no/such/binary/at/all --flag"),
String::new()
);
}
}
#[cfg(test)]
mod bp4_prompt_context_tests {
use super::*;
use crate::configfile::{resolve, ResolveOptions};
fn resolved(preset: &str) -> Config {
let toml = crate::presets::lookup(preset).unwrap();
resolve(toml, None, &ResolveOptions { strict: true })
.unwrap_or_else(|e| panic!("{preset} resolves: {e}"))
.config
}
#[derive(Debug)]
struct NoProvider;
#[async_trait::async_trait]
impl Provider for NoProvider {
async fn complete(
&self,
_req: &ChatRequest,
_on_delta: &(dyn for<'a> Fn(&'a str) + Send + Sync),
) -> crate::Result<(ChatMessage, crate::provider::Usage)> {
unreachable!("BP-4 prompt/context tests never issue a request")
}
}
fn scratch(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"supercode-bp4-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn presets_walk_ancestors_up_to_the_git_root_nearest_last() {
for preset in ["cc-parity", "cx-parity"] {
let base = scratch("walk");
let above = base.join("above");
let root = above.join("repo");
let deep = root.join("crates").join("thing");
std::fs::create_dir_all(&deep).unwrap();
std::fs::create_dir_all(root.join(".git")).unwrap();
std::fs::write(above.join("CLAUDE.md"), "ABOVE-THE-ROOT-MARKER").unwrap();
std::fs::write(above.join("AGENTS.md"), "ABOVE-THE-ROOT-MARKER").unwrap();
std::fs::write(root.join("CLAUDE.md"), "REPO-ROOT-MARKER").unwrap();
std::fs::write(root.join("AGENTS.md"), "REPO-ROOT-MARKER").unwrap();
std::fs::write(deep.join("CLAUDE.md"), "NEAREST-DIR-MARKER").unwrap();
std::fs::write(deep.join("AGENTS.md"), "NEAREST-DIR-MARKER").unwrap();
let mut config = resolved(preset);
config.cwd = deep.clone();
let blob = assemble_project_instructions(&config);
let root_at = blob
.find("REPO-ROOT-MARKER")
.unwrap_or_else(|| panic!("{preset}: the ancestor repo root was not walked"));
let near_at = blob
.find("NEAREST-DIR-MARKER")
.unwrap_or_else(|| panic!("{preset}: cwd's own file was not loaded"));
assert!(
root_at < near_at,
"{preset}: nearest-to-cwd must win by appearing LAST (root→cwd)"
);
assert!(
!blob.contains("ABOVE-THE-ROOT-MARKER"),
"{preset}: the walk must stop at the `.git` root"
);
let _ = std::fs::remove_dir_all(&base);
}
}
#[test]
fn walk_terminates_without_a_root_marker() {
let base = scratch("nomarker");
let deep = base.join("a").join("b").join("c");
std::fs::create_dir_all(&deep).unwrap();
let mut config = resolved("cx-parity");
config.cwd = deep.clone();
let roots = instruction_walk_roots(&config);
assert!(roots.len() <= MAX_INSTRUCTION_WALK_DEPTH);
assert_eq!(roots.last().unwrap(), &deep, "cwd is the LAST root");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn cx_parity_enforces_the_documented_instruction_byte_cap() {
let config = resolved("cx-parity");
assert_eq!(
config.project_doc_max_bytes,
Some(32_768),
"cx-parity must arm cx§2's documented 32 KiB cap"
);
let base = scratch("cap");
std::fs::create_dir_all(base.join(".git")).unwrap();
std::fs::write(base.join("CLAUDE.md"), "x".repeat(40_000)).unwrap();
std::fs::write(base.join("AGENTS.md"), "y".repeat(40_000)).unwrap();
let mut config = config;
config.cwd = base.clone();
let blob = assemble_project_instructions(&config);
assert!(
blob.contains("[supercode: file truncated at core.project_doc_max_bytes]"),
"the per-file cap must fire with a notice"
);
assert!(
blob.contains(
"[supercode: instruction content truncated at core.project_doc_max_bytes]"
),
"the aggregate cap must fire with a notice"
);
assert!(blob.len() < 33_200, "aggregate blob stayed over the cap");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn cc_parity_strips_html_comments_and_honours_excludes() {
let mut config = resolved("cc-parity");
assert!(config.project_doc_strip_comments, "cc strips `<!-- … -->`");
assert_eq!(
config.project_doc_max_bytes, None,
"`project_doc_max_bytes = 0` is §3.1's spelling for uncapped"
);
let base = scratch("hygiene");
std::fs::create_dir_all(base.join(".git")).unwrap();
std::fs::write(
base.join("CLAUDE.md"),
"KEEP-THIS<!-- MAINTAINER-NOTE -->AND-THIS",
)
.unwrap();
std::fs::write(base.join("AGENTS.md"), "EXCLUDED-FILE-MARKER").unwrap();
config.cwd = base.clone();
config.project_doc_excludes = vec!["AGENTS.md".to_string()];
let blob = assemble_project_instructions(&config);
assert!(blob.contains("KEEP-THIS") && blob.contains("AND-THIS"));
assert!(
!blob.contains("MAINTAINER-NOTE"),
"block HTML comments must be stripped before injection"
);
assert!(
!blob.contains("EXCLUDED-FILE-MARKER"),
"an excluded instruction file must never be read into the prompt"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn project_layer_cannot_set_instruction_excludes() {
let hc = crate::configfile::HarnessConfig::from_toml_str(
"schema_version = 1\n[core]\nproject_doc_excludes = [\"CLAUDE.md\"]\n",
)
.unwrap();
let (sanitized, dropped) = crate::configfile::sanitize_for_project(&hc);
assert!(sanitized.core.project_doc_excludes.is_none());
assert!(dropped.iter().any(|d| d == "core.project_doc_excludes"));
}
#[test]
fn env_context_block_carries_policy_and_re_emits_on_change() {
for preset in ["cc-parity", "cx-parity"] {
let base = scratch("env");
let here = base.join("here");
let other = base.join("elsewhere");
std::fs::create_dir_all(&here).unwrap();
std::fs::create_dir_all(&other).unwrap();
let mut config = resolved(preset);
config.cwd = here.clone();
assert!(config.env_context, "{preset} must set core.env_context");
let expected_policy = format!(
"approval policy: {} · sandbox: {}",
approval_policy_label(config.approval),
sandbox_policy_label(config.sandbox),
);
let mut agent = Agent::with_provider(config, Box::new(NoProvider));
let system = agent.history[0].content.clone().unwrap_or_default();
assert!(
system.contains(&expected_policy),
"{preset}: the environment block must state the approval/sandbox policy — {system}"
);
assert!(system.contains(&format!("cwd: {}", here.display())));
assert!(!agent.refresh_env_context(), "{preset}: spurious re-emit");
agent.config.cwd = other.clone();
agent.config.approval = crate::config::ApprovalPolicy::Untrusted;
assert!(
agent.refresh_env_context(),
"{preset}: change not re-emitted"
);
let system = agent.history[0].content.clone().unwrap_or_default();
assert!(
system.contains(&format!("cwd: {}", other.display())),
"{preset}: the fresh cwd must reach the model"
);
assert!(system.contains("approval policy: untrusted"));
assert!(
!system.contains(&format!("cwd: {}", here.display())),
"{preset}: the stale block must be REPLACED, not duplicated"
);
assert_eq!(
system.matches("# Environment").count(),
1,
"{preset}: exactly one environment block"
);
let _ = std::fs::remove_dir_all(&base);
}
}
#[test]
fn presets_splice_builtin_and_runtime_context_blocks() {
for preset in ["cc-parity", "cx-parity"] {
let config = resolved(preset);
assert!(
config.context_injections,
"{preset} must set core.context_injections"
);
let mut agent = Agent::with_provider(config, Box::new(NoProvider));
let system = agent.history[0].content.clone().unwrap_or_default();
assert!(
system.contains("# Task list"),
"{preset}: a built-in ambient block must reach the prompt"
);
assert!(agent.inject_context_block("Mid session", "SPLICED-BODY-MARKER"));
let system = agent.history[0].content.clone().unwrap_or_default();
assert!(
system.contains("# Mid session") && system.contains("SPLICED-BODY-MARKER"),
"{preset}: a runtime splice must reach the prompt"
);
assert_eq!(agent.spliced_context_blocks().len(), 1);
}
}
#[derive(Debug, Default)]
struct RecordingSummarizer {
seen: std::sync::Mutex<Vec<String>>,
}
impl reduce::summarize::SpanSummarizer for RecordingSummarizer {
fn summarize(&self, span_text: &str) -> reduce::Result<String> {
self.seen
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(span_text.to_string());
Ok("MODEL-WRITTEN-SUMMARY".to_string())
}
fn model_id(&self) -> &str {
"test-summarizer"
}
}
fn stuffed_agent(preset: &str) -> Agent {
let config = resolved(preset);
let mut agent = Agent::with_provider(config, Box::new(NoProvider));
for i in 0..40 {
agent.history.push(ChatMessage::user(format!("turn {i}")));
agent
.history
.push(ChatMessage::assistant(format!("reply {i}")));
}
agent
}
#[test]
fn presets_manual_compact_carries_focus_into_the_summarizer_and_the_marker() {
for preset in ["cc-parity", "cx-parity"] {
let config = resolved(preset);
assert!(
config.compaction_focus_instructions.is_some(),
"{preset} must state core.compaction.focus_instructions"
);
let mut agent = stuffed_agent(preset);
let summarizer = std::sync::Arc::new(RecordingSummarizer::default());
agent.set_span_summarizer_arc(summarizer.clone());
let before = agent.history().len();
assert!(
agent.compact_now(Some("keep the migration steps")),
"{preset}: /compact must compact on demand"
);
assert!(agent.history().len() < before, "{preset}: nothing dropped");
let marker = agent
.history()
.iter()
.find_map(|m| m.content.as_deref())
.filter(|c| c.contains("earlier conversation compacted"))
.or_else(|| {
agent
.history()
.iter()
.filter_map(|m| m.content.as_deref())
.find(|c| c.contains("earlier conversation compacted"))
})
.unwrap_or_else(|| panic!("{preset}: no compaction marker"))
.to_string();
assert!(
marker.contains("Focus: keep the migration steps"),
"{preset}: {marker}"
);
let seen = summarizer
.seen
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert_eq!(seen.len(), 1, "{preset}: exactly one side-call");
assert!(
seen[0].contains("keep the migration steps"),
"{preset}: the focus must reach the summarizer INPUT — {}",
&seen[0][..seen[0].len().min(200)]
);
}
}
#[test]
fn presets_summarize_the_cleared_span_without_the_reduction_module() {
for preset in ["cc-parity", "cx-parity"] {
let toml = crate::presets::lookup(preset).unwrap();
let r = resolve(toml, None, &ResolveOptions { strict: true }).unwrap();
assert_eq!(
r.modules.get("reduction"),
Some(&false),
"{preset}: this row must hold with the reduction module OFF"
);
assert!(r.config.compaction_summarize);
let mut agent = stuffed_agent(preset);
agent.set_span_summarizer_arc(std::sync::Arc::new(RecordingSummarizer::default()));
assert!(agent.compact_now(None));
let marker = agent
.history()
.iter()
.filter_map(|m| m.content.as_deref())
.find(|c| c.contains("earlier conversation compacted"))
.unwrap_or_else(|| panic!("{preset}: no compaction marker"));
assert!(
marker.contains("MODEL-WRITTEN-SUMMARY"),
"{preset}: the marker must carry the model-written summary — {marker}"
);
}
}
#[test]
fn compaction_fires_pre_and_post_lifecycle_events_under_both_presets() {
use crate::config::LifecycleEvent;
for preset in ["cc-parity", "cx-parity"] {
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let mut agent = stuffed_agent(preset);
let sink = seen.clone();
agent.set_lifecycle_hook(Box::new(move |event| {
sink.lock().unwrap().push(event.clone());
}));
let before = agent.history().len();
assert!(agent.compact_now(Some("keep the plan")), "{preset}");
let after = agent.history().len();
let seen = seen.lock().unwrap();
assert_eq!(seen.len(), 2, "{preset}: exactly pre + post — {seen:?}");
match &seen[0] {
LifecycleEvent::PreCompact {
messages,
dropped,
manual,
} => {
assert_eq!(*messages, before, "{preset}");
assert!(*dropped > 0, "{preset}");
assert!(*manual, "{preset}: /compact is the manual trigger");
}
other => panic!("{preset}: first event must be PreCompact, got {other:?}"),
}
match &seen[1] {
LifecycleEvent::PostCompact { messages, dropped } => {
assert_eq!(*messages, after, "{preset}");
assert_eq!(
*dropped,
before - after + 1,
"{preset}: dropped span + 1 marker"
);
}
other => panic!("{preset}: second event must be PostCompact, got {other:?}"),
}
}
}
#[test]
fn automatic_compaction_reports_the_auto_trigger_and_a_no_op_fires_nothing() {
use crate::config::LifecycleEvent;
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let mut agent = stuffed_agent("cc-parity");
let sink = seen.clone();
agent.set_lifecycle_hook(Box::new(move |event| {
sink.lock().unwrap().push(event.clone());
}));
agent.config.compact_after_messages = Some(10);
assert!(agent.maybe_compact());
assert!(matches!(
seen.lock().unwrap()[0],
LifecycleEvent::PreCompact { manual: false, .. }
));
seen.lock().unwrap().clear();
let mut small = Agent::with_provider(resolved("cc-parity"), Box::new(NoProvider));
let sink = seen.clone();
small.set_lifecycle_hook(Box::new(move |event| {
sink.lock().unwrap().push(event.clone());
}));
assert!(!small.compact_now(None));
assert!(seen.lock().unwrap().is_empty());
}
#[test]
fn compaction_without_a_summarizer_keeps_the_count_only_marker() {
let mut agent = stuffed_agent("cc-parity");
assert!(agent.compact_now(None));
let marker = agent
.history()
.iter()
.filter_map(|m| m.content.as_deref())
.find(|c| c.contains("earlier conversation compacted"))
.unwrap();
assert!(!marker.contains("Summary of the compacted span"));
}
#[test]
fn presets_persist_the_compaction_marker_to_the_transcript() {
for preset in ["cc-parity", "cx-parity"] {
let dir = scratch("marker");
let path = dir.join("session.jsonl");
let empty = crate::session::Session::from_claude_code_str("").unwrap();
let writer = crate::sidecar::SidecarWriter::create(&path, &empty).unwrap();
let mut agent = stuffed_agent(preset);
agent.set_recorder(writer);
assert!(agent.reduction_policy().is_none(), "{preset}");
assert!(agent.compact_now(None));
let on_disk = std::fs::read_to_string(&path).unwrap();
assert!(
on_disk.contains("earlier conversation compacted"),
"{preset}: the marker must reach the transcript on disk"
);
assert!(
on_disk.contains("remain in this session's transcript sidecar"),
"{preset}: the marker must say where the originals went"
);
let _ = std::fs::remove_dir_all(&dir);
}
}
#[test]
fn cx_parity_handoff_seeds_a_fresh_objective_with_a_curated_keep_set() {
let mut agent = stuffed_agent("cx-parity");
assert!(!agent.config().handoff_enabled, "reduction handoff is off");
agent.history.push(ChatMessage::user("LAST-USER-TURN"));
let before = agent.history().len();
let dropped = agent.new_context("ship the migration", Some(3));
assert!(dropped > 0, "messages must be set aside");
assert!(agent.history().len() < before);
let system_prompt = agent.history()[0].content.clone().unwrap_or_default();
assert!(
system_prompt.contains("supercode") || !system_prompt.is_empty(),
"the system prompt survives a handoff"
);
let marker = agent
.history()
.iter()
.filter_map(|m| m.content.as_deref())
.find(|c| c.contains("[handoff:"))
.expect("handoff marker");
assert!(marker.contains("Objective: ship the migration"));
assert!(
agent
.history()
.iter()
.any(|m| m.content.as_deref() == Some("LAST-USER-TURN")),
"the curated keep-set must carry the most recent turns"
);
}
#[test]
fn presets_report_live_context_usage() {
for preset in ["cc-parity", "cx-parity"] {
let agent = stuffed_agent(preset);
let usage = agent.context_usage();
assert_eq!(usage.messages, agent.history().len(), "{preset}");
assert!(usage.message_tokens > 0, "{preset}");
assert_eq!(
usage.request_tokens,
usage.message_tokens + usage.tool_schema_tokens,
"{preset}: the breakdown must add up"
);
assert!(usage.projected_tokens >= usage.request_tokens, "{preset}");
assert!(usage.context_limit.is_some(), "{preset}: window known");
assert!(usage.fits, "{preset}");
let line = usage.summary_line();
assert!(line.contains('%') && line.contains(&usage.model), "{line}");
let again = agent.context_usage();
assert_eq!(usage, again, "{preset}");
}
}
}
#[cfg(test)]
#[path = "agent_bp10_tests.rs"]
mod bp10_permissions_tests;