mod ask;
mod listen;
pub(super) mod outside;
mod rewind;
mod rm;
use std::collections::{BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use argonauts_conductor::sandbox::profile::PROTECTED_PATHS;
use argonauts_core::app::{AppEvent, ChoiceKind};
use argonauts_core::asking::{Ask, AskReply};
use argonauts_core::event::AgentEvent;
use argonauts_core::events::approval::AnswerAction;
use argonauts_core::events::run::{CaptainOrCrewDecision, RunMode};
use argonauts_core::events::session::{InputPart, InputPartKind};
use argonauts_core::names::{ChoiceId, ModelRef, TurnId};
use argonauts_core::outside::changed_outside;
use argonauts_core::settings::approvals::ApprovalMode;
use argonauts_core::settings::approvals::SandboxChoice;
use argonauts_core::settings::fallback::FallbackEntry;
use argonauts_core::settings::protocol::{CaptainKind, ForcedMode};
use argonauts_core::settings::{SettingScope, Settings};
use argonauts_core::{Asks, NoteLevel};
use serde_json::Value;
pub(crate) use self::listen::Asked;
use self::listen::Listener;
use self::rewind::Snapshot;
use super::{
ConversationState, Turn, handover, lifecycle, run_request, send_queued, standing, tools,
};
use crate::choices::{self, option};
use crate::controller::turn::{self, DirectWords, TurnStep, part};
use crate::controller::{Ctx, Effect};
use argonauts_conductor::fallback::shortlist::{
Sources, auto_check, shortlist as fallback_shortlist,
};
use argonauts_core::TaskId;
use argonauts_core::events::task::{TaskKind, TaskLimit, TaskSpec};
use argonauts_core::limits::{Candidate, CandidateQuery, TrackRecord, TrackRecords};
use argonauts_core::names::RoleName;
const ANTIGRAVITY: &str = "antigravity-cli";
const RUN_REFUSED: &str = "You may run the commands you were refused.";
#[derive(Debug, Default)]
pub(crate) struct DirectState {
approval: ApprovalMode,
asks: HashMap<ChoiceId, PendingAsk>,
edits_allowed: bool,
turns: Vec<TurnRecord>,
stopping: HashMap<TurnId, Running>,
refused: HashMap<ChoiceId, RefusedOffer>,
rewinding: Option<ChoiceId>,
rewound: Option<String>,
plans: HashMap<ChoiceId, Planned>,
fallback: Option<(ChoiceId, PendingFallback)>,
watched: BTreeSet<String>,
pub(super) told_setup: Option<String>,
}
impl std::fmt::Debug for PendingFallback {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PendingFallback")
.field("turn", &self.turn.id)
.field("why", &self.why)
.field("candidates", &self.candidates)
.finish_non_exhaustive()
}
}
struct PendingFallback {
turn: Turn,
tried: Vec<ModelRef>,
why: String,
candidates: Vec<ModelRef>,
}
#[derive(Debug)]
struct Planned {
request: String,
words: DirectWords,
cli: String,
}
impl DirectState {
pub(super) fn carry(&mut self) -> Self {
Self {
approval: self.approval,
turns: std::mem::take(&mut self.turns),
watched: std::mem::take(&mut self.watched),
..Self::default()
}
}
pub(super) fn forget_turns(&mut self) {
self.turns.clear();
self.watched.clear();
}
}
#[derive(Debug)]
pub(super) struct Running {
text: String,
listener: Option<Listener>,
asks: Asks,
before: Option<Snapshot>,
commands: HashMap<String, String>,
refused: Vec<String>,
said: String,
watched: BTreeSet<String>,
written: BTreeSet<String>,
unmade: Vec<String>,
root: PathBuf,
has_tools: bool,
setup_wrote: BTreeSet<String>,
untracked: BTreeSet<String>,
home: Option<PathBuf>,
}
#[derive(Debug)]
struct PendingAsk {
turn: TurnId,
tool: String,
input: Value,
question: ask::Question,
reply: tokio::sync::oneshot::Sender<AskReply>,
}
#[derive(Debug)]
struct RefusedOffer {
worker: ModelRef,
request: String,
}
#[derive(Debug)]
struct TurnRecord {
text: String,
before: Snapshot,
after: Snapshot,
changed: Vec<String>,
}
pub(crate) fn captain_is_direct(settings: &Settings) -> bool {
settings.captain.direct()
}
pub(super) const GO_AHEAD: &str = "Go ahead.";
pub(super) fn picks_direct(captain_or_crew: &CaptainOrCrewDecision) -> bool {
captain_or_crew.mode == RunMode::Captain && captain_or_crew.captain == Some(CaptainKind::Direct)
}
pub(crate) fn workflow_is_direct(settings: &Settings) -> bool {
settings.captain_or_crew.force == Some(ForcedMode::Captain) && captain_is_direct(settings)
}
pub(crate) fn set_approval(conversation: &mut ConversationState, mode: ApprovalMode) {
conversation.direct.approval = mode;
}
pub(super) fn approval(conversation: &ConversationState) -> ApprovalMode {
conversation.direct.approval
}
pub(super) fn begin(
ctx: &mut Ctx,
conversation: &mut ConversationState,
text: String,
asks: Option<Asks>,
) -> anyhow::Result<Vec<Effect>> {
begin_with(ctx, conversation, text, asks, DirectWords::default())
}
pub(super) fn begin_with(
ctx: &mut Ctx,
conversation: &mut ConversationState,
text: String,
asks: Option<Asks>,
words: DirectWords,
) -> anyhow::Result<Vec<Effect>> {
let asks = asks.unwrap_or_else(|| mode_asks(ctx, conversation, &words));
if let Some(instead) = instead(ctx, conversation, asks, &text) {
return Ok(instead);
}
start(ctx, conversation, text, asks, |asking| TurnStep::Direct {
asking,
go_ahead: None,
words,
})?;
Ok(Vec::new())
}
pub(super) fn go_ahead(
ctx: &mut Ctx,
conversation: &mut ConversationState,
request: String,
said: &str,
asks: Option<Asks>,
words: DirectWords,
) -> anyhow::Result<Vec<Effect>> {
let asks = asks.unwrap_or_else(|| mode_asks(ctx, conversation, &words));
if let Some(instead) = instead(ctx, conversation, asks, &request) {
return Ok(instead);
}
start(ctx, conversation, request, asks, |asking| {
TurnStep::Direct {
asking,
go_ahead: Some(said.to_owned()),
words,
}
})?;
Ok(Vec::new())
}
pub(super) fn shortlist(ctx: &Ctx, stopped: &ModelRef, tried: &[ModelRef]) -> Vec<Candidate> {
let settings = ctx.settings();
let rules = settings
.fallback
.applied_to(RoleName::Main, &stopped.agent_id());
let listed = if rules.candidates.is_empty() {
let defaults = turn::default_fallbacks(ctx);
defaults.into_iter().map(FallbackEntry::Model).collect()
} else {
rules.candidates.clone()
};
let query = CandidateQuery {
task: your_checkout(),
role: RoleName::Main,
stopped: stopped.clone(),
exclude_providers: Vec::new(),
fallback: listed,
all_valid: false,
};
let sources = Sources {
settings,
registry: ctx.services.registry.as_ref(),
plan_usage: ctx.services.plan_usage.as_ref(),
health: ctx.services.health.as_ref(),
records: &NoRecords,
slots: None,
workers: Some(ctx.services.workers.as_ref()),
};
fallback_shortlist(&query, &sources)
.candidates
.into_iter()
.filter(|candidate| {
!tried.contains(&candidate.worker)
&& (!rules.same_cli || candidate.worker.agent == stopped.agent)
&& ctx.services.workers.get(&candidate.worker).is_ok()
})
.collect()
}
fn your_checkout() -> TaskSpec {
TaskSpec {
id: TaskId::new("direct-001").expect("a valid task id"),
title: String::new(),
task: String::new(),
kind: TaskKind::Captain,
owned_paths: Vec::new(),
read_only_paths: Vec::new(),
forbidden_paths: Vec::new(),
hidden_paths: Vec::new(),
depends_on: Vec::new(),
criteria: Vec::new(),
worker: None,
reviewers: Vec::new(),
limit: TaskLimit::default(),
predictions: None,
item: None,
}
}
struct NoRecords;
impl TrackRecords for NoRecords {
fn get(&self, _: &ModelRef, _: &str) -> Option<TrackRecord> {
None
}
}
pub(super) fn ask_fallback(
ctx: &mut Ctx,
conversation: &mut ConversationState,
turn: Turn,
tried: Vec<ModelRef>,
why: String,
candidates: &[Candidate],
) {
let from = ctx.names.worker(&turn.worker);
let mut options: Vec<_> = candidates
.iter()
.take(9)
.zip('1'..='9')
.map(|(candidate, key)| {
let mut offered = option(
key,
&ctx.names.worker(&candidate.worker),
AnswerAction::Choose {
value: candidate.worker.to_string(),
},
false,
);
offered.detail = Some(if candidate.needs_confirm {
"an API key, billed to your account".to_owned()
} else {
candidate.plan_usage_text.clone()
})
.filter(|detail| !detail.trim().is_empty());
offered
})
.collect();
options.push(option(
's',
"Stop here",
AnswerAction::Choose {
value: "stop".to_owned(),
},
false,
));
let (asked, detail) = if turn.step.writes() {
let (told, _) = fallback(ctx, &turn);
("Who carries it on?", vec![format!("They're told {told}.")])
} else {
("Who answers instead?", Vec::new())
};
let id = ctx.next_choice_id();
let mut choice = choices::app_choice(
id.clone(),
ChoiceKind::Fallback,
format!("{from} {why}. {asked}"),
detail,
options,
);
choice.bell = true;
let pending = PendingFallback {
turn,
tried,
why,
candidates: candidates
.iter()
.map(|candidate| candidate.worker.clone())
.collect(),
};
if let Some((old, earlier)) = conversation.direct.fallback.replace((id, pending)) {
ctx.send(AppEvent::ChoiceClosed {
id: old,
outcome: "Stopped".to_owned(),
});
if earlier.turn.step.writes() {
let error = format!("{} {}", earlier.turn.worker, earlier.why);
lifecycle::end_direct(ctx, conversation, earlier.turn, error);
}
}
ctx.send(AppEvent::Choice { choice });
}
fn answer_fallback(
ctx: &mut Ctx,
conversation: &mut ConversationState,
id: ChoiceId,
pending: PendingFallback,
value: &str,
) -> anyhow::Result<Vec<Effect>> {
let picked = pending
.candidates
.iter()
.find(|worker| worker.to_string() == value)
.cloned();
let answers = !pending.turn.step.writes();
let Some(to) = picked.filter(|_| conversation.turn.is_none()) else {
ctx.send(AppEvent::ChoiceClosed {
id,
outcome: "Stopped".to_owned(),
});
if answers {
return Ok(send_queued(conversation));
}
let error = format!("{} {}", pending.turn.worker, pending.why);
return Ok(lifecycle::end_direct(
ctx,
conversation,
pending.turn,
error,
));
};
let does = if answers { "answers" } else { "carries it on" };
ctx.send(AppEvent::ChoiceClosed {
id,
outcome: format!("{} {does}", ctx.names.worker(&to)),
});
if answers {
return lifecycle::answer_on(ctx, conversation, pending.turn, pending.tried, &to);
}
lifecycle::fallback(
ctx,
conversation,
pending.turn,
pending.tried,
pending.why,
to,
)
}
pub(super) fn plan(
ctx: &mut Ctx,
conversation: &mut ConversationState,
text: String,
words: DirectWords,
) -> anyhow::Result<Vec<Effect>> {
lifecycle::begin(ctx, conversation, text, |_| TurnStep::Plan { words })?;
Ok(Vec::new())
}
pub(super) const GO_AHEAD_WITH_PLAN: &str = "Go ahead with this plan.";
pub(super) fn offer_plan(
ctx: &mut Ctx,
conversation: &mut ConversationState,
turn: &Turn,
) -> Option<DirectWords> {
match &turn.step {
TurnStep::Plan { words } => offer_go_ahead(ctx, conversation, turn, words.clone()),
_ => None,
}
}
pub(super) fn offer_go_ahead(
ctx: &mut Ctx,
conversation: &mut ConversationState,
turn: &Turn,
words: DirectWords,
) -> Option<DirectWords> {
let cli = ctx.names.cli(&turn.worker.agent);
let folder = if conversation.folder(ctx) == ctx.env.repo_root.as_path() {
"your checkout"
} else {
"the run's worktree"
};
if conversation.direct.approval == ApprovalMode::Autopilot {
ctx.notice(
NoteLevel::Info,
format!("Decided on autopilot · {cli} goes ahead with its plan in {folder}."),
);
return Some(words);
}
let id = ctx.next_choice_id();
let mut choice = choices::app_choice(
id.clone(),
ChoiceKind::GoAhead,
"Go ahead with this plan?".to_owned(),
vec![format!("{cli} carries it out in {folder}.")],
choices::options(ChoiceKind::GoAhead),
);
choice.bell = true;
conversation.direct.plans.insert(
id,
Planned {
request: turn.text.clone(),
words,
cli,
},
);
ctx.send(AppEvent::Choice { choice });
None
}
fn answer_plan(
ctx: &mut Ctx,
conversation: &mut ConversationState,
id: ChoiceId,
planned: Planned,
value: &str,
) -> anyhow::Result<Vec<Effect>> {
if value != "yes" {
ctx.send(AppEvent::ChoiceClosed {
id,
outcome: "No · nothing changed".to_owned(),
});
return Ok(Vec::new());
}
if conversation.turn.is_some() {
ctx.notice(
NoteLevel::Info,
"A turn is running. Answer the plan's question once it ends.",
);
conversation.direct.plans.insert(id, planned);
return Ok(Vec::new());
}
ctx.send(AppEvent::ChoiceClosed {
id,
outcome: "Yes, go ahead".to_owned(),
});
go_ahead(
ctx,
conversation,
planned.request,
GO_AHEAD_WITH_PLAN,
None,
planned.words,
)
}
fn asks_each(worker: &str) -> bool {
!matches!(worker, "codex" | ANTIGRAVITY | "opencode")
}
fn instead(
ctx: &mut Ctx,
conversation: &ConversationState,
asks: Asks,
text: &str,
) -> Option<Vec<Effect>> {
match &conversation.place {
Some(place) if asks == Asks::EveryChange && !asks_each(&place.worker.agent) => {
let cli = ctx.names.cli(&place.worker.agent);
ctx.notice(
NoteLevel::Info,
format!(
"{cli} can't ask before each edit in a headless run, and a checked worktree \
would leave this session behind. Auto mode, or captain.asks = \"like_cli\", \
lets it carry on."
),
);
Some(Vec::new())
}
Some(_) => None,
None => checked_instead(ctx, asks, text).map(|run| vec![run]),
}
}
pub(super) fn begin_agent(
ctx: &mut Ctx,
conversation: &mut ConversationState,
shown: String,
prompt: String,
) -> anyhow::Result<Vec<Effect>> {
let asks = mode_asks(ctx, conversation, &DirectWords::default());
start(ctx, conversation, shown, asks, |asking| TurnStep::Cli {
prompt,
direct: Some(asking),
})?;
Ok(Vec::new())
}
fn mode_asks(ctx: &Ctx, conversation: &ConversationState, words: &DirectWords) -> Asks {
match conversation.direct.approval {
ApprovalMode::Auto | ApprovalMode::Autopilot if !words.supervised => Asks::Never,
ApprovalMode::Auto
| ApprovalMode::Autopilot
| ApprovalMode::Supervised
| ApprovalMode::AutoPreview => ctx.settings().captain.asks,
}
}
fn checked_instead(ctx: &Ctx, asks: Asks, text: &str) -> Option<Effect> {
let main = &ctx.settings().crew.main;
if asks != Asks::EveryChange || asks_each(&main.agent) {
return None;
}
let cli = ctx.names.cli(&main.agent);
ctx.notice(
NoteLevel::Info,
format!(
"{cli} can't ask before each edit in a headless run, so this runs in a checked worktree."
),
);
let request = run_request(ctx, text.to_owned(), RunMode::Captain, None, None);
Some(handover::start(request))
}
fn start(
ctx: &mut Ctx,
conversation: &mut ConversationState,
text: String,
asks: Asks,
step: impl FnOnce(Option<argonauts_core::session_runner::Asking>) -> TurnStep,
) -> anyhow::Result<()> {
if !lifecycle::place_ready(ctx, conversation)? {
return Ok(());
}
let settings = ctx.settings();
let offered = tools::offered(ctx, conversation);
let lines = standing::lines(&offered);
let rewind = settings.captain.rewind.then(|| max_bytes(settings));
let next = TurnId::numbered(conversation.turns + 1);
let roles = crate::controller::ask_role::roles(settings);
let listener = if asks != Asks::Never || !offered.is_empty() || !roles.is_empty() {
Some(listen::listen(ctx.internal.clone(), next)?)
} else {
None
};
let asking = listener.as_ref().map(|listener| {
let mut asking = listener.asking(asks, offered, lines);
asking.roles = roles;
asking
});
let root = conversation.folder(ctx).to_path_buf();
let watched = conversation.direct.watched.clone();
let rewind = rewind.filter(|_| ctx.env.git().has_history() || root != ctx.env.repo_root);
let before = rewind.and_then(|max| match rewind::take(&root, max, &watched) {
Ok(snapshot) => Some(snapshot),
Err(err) => {
ctx.notice(
NoteLevel::Warn,
format!("/rewind can't put back what this turn changes: {err:#}"),
);
None
}
});
let unmade = unmade_files(ctx, &root);
let untracked = untracked_files(&root);
lifecycle::begin(ctx, conversation, text.clone(), |_| step(asking))?;
if let Some(turn) = conversation.turn.as_mut() {
turn.direct = Some(Running {
text,
listener,
asks,
before,
commands: HashMap::new(),
refused: Vec::new(),
said: String::new(),
watched,
written: BTreeSet::new(),
unmade,
root,
has_tools: turn.step.has_tools(),
setup_wrote: BTreeSet::new(),
untracked,
home: ctx.env.user_home.clone(),
});
}
Ok(())
}
fn untracked_files(root: &Path) -> BTreeSet<String> {
if !crate::git::has_git(root) {
return BTreeSet::new();
}
let listed = crate::git::git(root, &["ls-files", "--others", "--exclude-standard", "-z"]);
listed
.map(|listed| {
listed
.split('\0')
.filter(|path| !path.is_empty())
.map(str::to_owned)
.collect()
})
.unwrap_or_default()
}
const MOST_UNTRACKED: usize = 3;
fn left_untracked(running: &Running) -> Option<String> {
let made: Vec<String> = untracked_files(&running.root)
.into_iter()
.filter(|path| !running.untracked.contains(path) && !running.setup_wrote.contains(path))
.collect();
let named = match made.as_slice() {
[] => return None,
[one] => one.clone(),
[first, second] => format!("{first} and {second}"),
[first, second, third] => format!("{first}, {second} and {third}"),
many => format!(
"{} and {} more",
many[..MOST_UNTRACKED].join(", "),
many.len() - MOST_UNTRACKED
),
};
Some(format!("Left untracked: {named}"))
}
fn unmade_files(ctx: &Ctx, root: &Path) -> Vec<String> {
let sandboxed = !matches!(
ctx.settings().sandbox.mode,
SandboxChoice::Tool | SandboxChoice::None
);
PROTECTED_PATHS
.iter()
.filter(|path| sandboxed && !path.ends_with('/'))
.filter(|path| std::fs::symlink_metadata(root.join(path)).is_err())
.map(|path| (*path).to_owned())
.collect()
}
fn set_aside_planted(
ctx: &Ctx,
conversation: &ConversationState,
running: &Running,
turn: &TurnId,
) {
let root = &running.root;
let made: Vec<&String> = running
.unmade
.iter()
.filter(|path| std::fs::symlink_metadata(root.join(path)).is_ok())
.collect();
if made.is_empty() {
return;
}
let aside = ctx
.env
.conversation_dir()
.join(conversation.id.as_str())
.join(turn.as_str())
.join("set-aside");
for path in made {
let moved = std::fs::create_dir_all(&aside)
.and_then(|()| std::fs::rename(root.join(path), aside.join(path)));
match moved {
Ok(()) => ctx.notice(
NoteLevel::Warn,
format!(
"This turn made {path}, which configures agents, so it's set aside in {} where \
nothing loads it. Move it back yourself if you want it.",
aside.display()
),
),
Err(err) => ctx.notice(
NoteLevel::Warn,
format!("This turn made {path}, which configures agents, and it couldn't be set aside: {err}. Look it over before any agent runs here."),
),
}
}
}
pub(super) fn notes(
ctx: &Ctx,
conversation: &mut ConversationState,
step: &TurnStep,
fresh: bool,
) -> Vec<InputPart> {
let mut notes: Vec<InputPart> = conversation
.direct
.rewound
.take()
.map(|text| part(InputPartKind::Notes, "rewind", text))
.into_iter()
.collect();
if step.has_tools() {
notes.extend(standing::setup_note(ctx, conversation, fresh));
}
notes
}
pub(super) fn on_agent(running: &mut Running, event: &AgentEvent) -> Option<String> {
match event {
AgentEvent::ToolStarted { id, input, .. } => {
let command = ["CommandLine", "command", "cmd"]
.iter()
.find_map(|key| input.get(*key).and_then(Value::as_str));
if let Some(command) = command {
running.commands.insert(id.clone(), command.to_owned());
}
}
AgentEvent::FileChanged { path, .. } => {
running.written.extend(in_your_folder(&running.root, path));
return changed_outside(&[&running.root], event, running.home.as_deref());
}
AgentEvent::ToolFinished {
id,
ok: false,
output,
parent: None,
} => {
let refused = output.as_deref().is_some_and(|output| {
output.contains("permission check failed")
|| output.contains("required approval")
|| output.contains("auto-denied")
});
if let Some(command) = running.commands.get(id).filter(|_| refused)
&& !running.refused.contains(command)
{
running.refused.push(command.clone());
}
}
AgentEvent::TextDelta { text, parent: None } => running.said.push_str(text),
AgentEvent::Message { text, parent: None } if running.said.is_empty() => {
running.said.push_str(text);
}
_ => {}
}
None
}
fn in_your_folder(root: &Path, changed: &str) -> Option<String> {
let path = Path::new(changed);
let real = root.canonicalize().ok();
let relative = if path.is_absolute() {
path.strip_prefix(root)
.ok()
.or_else(|| path.strip_prefix(real.as_deref()?).ok())?
} else {
path
};
let relative = relative.to_str()?.trim_start_matches("./");
(!relative.is_empty() && !relative.starts_with("..")).then(|| relative.to_owned())
}
pub(super) fn finished(ctx: &mut Ctx, conversation: &mut ConversationState, turn: &mut Turn) {
let Some(mut running) = turn.direct.take() else {
return;
};
close_asks(ctx, conversation, &turn.id);
set_aside_planted(ctx, conversation, &running, &turn.id);
record(ctx, conversation, &mut running);
if let Some(left) = left_untracked(&running) {
ctx.notice(NoteLevel::Info, left);
}
let agy = turn.worker.agent == ANTIGRAVITY;
if agy && running.asks != Asks::Never && !running.refused.is_empty() {
offer_refused(ctx, conversation, turn, &running.refused);
}
}
pub(super) fn failed(ctx: &mut Ctx, conversation: &mut ConversationState, turn: &mut Turn) {
let Some(mut running) = turn.direct.take() else {
return;
};
close_asks(ctx, conversation, &turn.id);
set_aside_planted(ctx, conversation, &running, &turn.id);
let changed = record(ctx, conversation, &mut running);
match changed {
Some(summary) => ctx.notice(NoteLevel::Info, format!("Stopped · {summary}")),
None => ctx.notice(NoteLevel::Info, "Stopped · nothing changed"),
}
if let Some(left) = left_untracked(&running) {
ctx.notice(NoteLevel::Info, left);
}
}
#[derive(Debug, Default)]
pub(super) struct Kept {
pub(super) changed: Option<String>,
untracked: Option<String>,
}
impl Kept {
pub(super) fn say(&self, ctx: &Ctx) {
for line in [&self.changed, &self.untracked].into_iter().flatten() {
ctx.notice(NoteLevel::Info, line.clone());
}
}
}
pub(super) fn kept_after_watchdog(
ctx: &mut Ctx,
conversation: &mut ConversationState,
turn: &mut Turn,
) -> Kept {
let Some(mut running) = turn.direct.take() else {
return Kept::default();
};
close_asks(ctx, conversation, &turn.id);
set_aside_planted(ctx, conversation, &running, &turn.id);
let changed = record(ctx, conversation, &mut running);
Kept {
changed,
untracked: left_untracked(&running),
}
}
pub(super) fn cancelled(ctx: &mut Ctx, conversation: &mut ConversationState, turn: &mut Turn) {
close_asks(ctx, conversation, &turn.id);
if let Some(mut running) = turn.direct.take() {
running.listener = None;
conversation
.direct
.stopping
.insert(turn.id.clone(), running);
}
}
pub(super) fn stopped(ctx: &mut Ctx, conversation: &mut ConversationState, id: &TurnId) {
let Some(mut running) = conversation.direct.stopping.remove(id) else {
return;
};
set_aside_planted(ctx, conversation, &running, id);
if let Some(summary) = record(ctx, conversation, &mut running) {
ctx.notice(NoteLevel::Info, summary);
}
if let Some(left) = left_untracked(&running) {
ctx.notice(NoteLevel::Info, left);
}
}
pub(super) fn fallback(ctx: &Ctx, turn: &Turn) -> (String, Option<InputPart>) {
let Some(running) = turn.direct.as_ref() else {
return ("your request".to_owned(), None);
};
let root = &running.root;
let max = max_bytes(ctx.settings());
let changed = running
.before
.as_ref()
.and_then(|before| {
let keep = running.watched.union(&running.written).cloned().collect();
let now = rewind::take(root, max, &keep).ok()?;
let mut changed = rewind::changed(root, before, &now).ok()?;
changed.extend(rewind::unkept(root, before, &running.written, max));
changed.sort();
changed.dedup();
Some(changed)
})
.unwrap_or_default();
let told = match changed.as_slice() {
[] => "your request and the transcript so far".to_owned(),
[one] => format!("your request, the transcript so far, and that {one} changed"),
many => format!(
"your request, the transcript so far, and that {} files changed",
many.len()
),
};
let mut text = String::from(
"Another agent started on this request in this folder and stopped before it finished. \
Carry on from where it left off.",
);
if !changed.is_empty() {
text.push_str(&format!(
"\n\nFiles it changed so far: {}.",
changed.join(", ")
));
}
if !running.said.trim().is_empty() {
text.push_str(&format!("\n\nWhat it said:\n{}", running.said.trim()));
}
(told, Some(part(InputPartKind::Fallback, "fallback", text)))
}
fn record(
ctx: &Ctx,
conversation: &mut ConversationState,
running: &mut Running,
) -> Option<String> {
running.listener = None;
let before = running.before.take()?;
let root = &running.root;
let max = max_bytes(ctx.settings());
for path in &running.written {
if rewind::ignored(root, path) {
conversation.direct.watched.insert(path.clone());
}
}
let after = rewind::take(root, max, &conversation.direct.watched)
.inspect_err(|err| {
ctx.notice(
NoteLevel::Warn,
format!("/rewind can't put back what this turn changed: {err:#}"),
);
})
.ok()?;
let unkept = rewind::unkept(root, &before, &running.written, max);
let mut changed = rewind::changed(root, &before, &after).ok()?;
changed.extend(unkept.iter().cloned());
changed.sort();
changed.dedup();
if changed.is_empty() {
return None;
}
keep_setup_files(ctx, running, &before, &after, &changed, max);
let mut summary = rewind::summary(root, &before, &after, &changed);
summary.push_str(" · /rewind puts it back");
match unkept.as_slice() {
[] => {}
[one] => summary.push_str(&format!(", except {one}")),
[rest @ .., last] => summary.push_str(&format!(", except {} and {last}", rest.join(", "))),
}
conversation.direct.turns.push(TurnRecord {
text: running.text.clone(),
before,
after,
changed,
});
Some(summary)
}
pub(in crate::controller::conversation) fn setup_wrote(
conversation: &mut super::ConversationState,
turn: &TurnId,
paths: &[PathBuf],
) {
let Some(running) = conversation
.turn
.as_mut()
.filter(|running| &running.id == turn)
.and_then(|turn| turn.direct.as_mut())
else {
return;
};
let root = running.root.clone();
let from_root = paths
.iter()
.filter_map(|path| path.strip_prefix(&root).ok())
.filter_map(|path| path.to_str().map(str::to_owned));
running.setup_wrote.extend(from_root);
}
fn named_by_command(running: &Running, path: &str) -> bool {
let name = Path::new(path)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(path);
running
.commands
.values()
.any(|command| ask::names_setup_folder(command) && command.contains(name))
}
fn keep_setup_files(
ctx: &Ctx,
running: &Running,
before: &rewind::Snapshot,
after: &rewind::Snapshot,
changed: &[String],
max: u64,
) {
if !ctx.settings().sandbox.mode.ours_off() {
return;
}
let (edited, other): (Vec<String>, Vec<String>) = changed
.iter()
.filter(|path| ask::in_setup_folder(path))
.filter(|path| !running.setup_wrote.contains(*path))
.cloned()
.partition(|path| running.written.contains(path) || named_by_command(running, path));
if !edited.is_empty() {
match rewind::restore(&running.root, before, after, &edited, max) {
Ok(restored) if !restored.put_back.is_empty() => ctx.notice(
NoteLevel::Warn,
format!(
"This turn edited {}, which only a setup changes, so it's put back. Ask for \
the change in words and a question asks where to keep it.",
restored.put_back.join(", ")
),
),
Ok(_) => {}
Err(err) => ctx.notice(
NoteLevel::Warn,
format!(
"This turn edited Argonauts Code's own files and they couldn't be put back: {err:#}"
),
),
}
}
if !other.is_empty() && !running.has_tools {
ctx.notice(
NoteLevel::Warn,
format!(
"{} changed during this turn. Only a setup should change Argonauts Code's files; \
/rewind puts them back.",
other.join(", ")
),
);
}
}
fn max_bytes(settings: &Settings) -> u64 {
settings.captain.rewind_max_mb.saturating_mul(1024 * 1024)
}
pub(crate) fn on_asked(
ctx: &mut Ctx,
conversation: &mut ConversationState,
turn: &TurnId,
asked: Asked,
) -> anyhow::Result<Vec<Effect>> {
let current = conversation
.turn
.as_ref()
.filter(|current| ¤t.id == turn);
let Some(current) = current else {
let _ = asked
.reply
.send(AskReply::deny("That turn is over, so nothing runs for it."));
return Ok(Vec::new());
};
match asked.ask {
Ask::Permission {
tool_name, input, ..
} => {
let patterns = &ctx.settings().approvals.allow_commands;
if ask::allowed(
&tool_name,
&input,
conversation.direct.edits_allowed,
patterns,
) {
let _ = asked.reply.send(AskReply::allow(&input));
return Ok(Vec::new());
}
if conversation.direct.approval == ApprovalMode::Autopilot && !asked_by_words(current) {
let _ = asked.reply.send(on_autopilot(&tool_name, &input));
return Ok(Vec::new());
}
let cli = ctx.names.cli(¤t.worker.agent);
let question = ask::Question::of(&tool_name, &input);
let id = ctx.next_choice_id();
let mut choice = choices::app_choice(
id.clone(),
ChoiceKind::Permission,
question.title(&cli, &tool_name),
question.body.clone(),
question.options(&cli),
);
choice.bell = true;
conversation.direct.asks.insert(
id,
PendingAsk {
turn: turn.clone(),
tool: tool_name,
input,
question,
reply: asked.reply,
},
);
ctx.send(AppEvent::Choice { choice });
Ok(Vec::new())
}
Ask::Tool { name, arguments } => {
let call = tools::Call {
turn: turn.clone(),
arguments,
reply: asked.reply,
};
Ok(tools::call(ctx, conversation, &name, call))
}
Ask::Role { role, what, .. } => {
let role_asked = crate::controller::ask_role::Asked {
role,
what,
request: current.text.clone(),
folder: conversation.folder(ctx).to_path_buf(),
};
crate::controller::ask_role::answer(ctx, role_asked, asked.reply);
Ok(Vec::new())
}
}
}
fn asked_by_words(turn: &Turn) -> bool {
matches!(&turn.step, TurnStep::Direct { words, .. } if words.supervised)
}
fn on_autopilot(tool: &str, input: &Value) -> AskReply {
if ask::autopilot_allows(tool, input) {
AskReply::allow(input)
} else {
ask::not_on_autopilot()
}
}
pub(super) fn settle_on_autopilot(
ctx: &mut Ctx,
conversation: &mut ConversationState,
) -> anyhow::Result<Vec<Effect>> {
settle_asks(ctx, conversation);
let mut effects = settle_fallback(ctx, conversation)?;
effects.extend(settle_plans(ctx, conversation)?);
effects.extend(settle_refused(ctx, conversation)?);
Ok(effects)
}
fn settle_asks(ctx: &Ctx, conversation: &mut ConversationState) {
let asks_you = conversation
.turn
.as_ref()
.filter(|turn| asked_by_words(turn))
.map(|turn| turn.id.clone());
let mut open: Vec<ChoiceId> = conversation
.direct
.asks
.iter()
.filter(|(_, pending)| Some(&pending.turn) != asks_you.as_ref())
.map(|(id, _)| id.clone())
.collect();
open.sort();
for id in open {
let Some(pending) = conversation.direct.asks.remove(&id) else {
continue;
};
let about = &pending.question.about;
let outcome = if ask::autopilot_allows(&pending.tool, &pending.input) {
format!("Decided on autopilot · yes to {about}")
} else {
format!("Decided on autopilot · no to {about}, which is asked about every time")
};
let _ = pending
.reply
.send(on_autopilot(&pending.tool, &pending.input));
ctx.send(AppEvent::ChoiceClosed { id, outcome });
}
}
fn settle_fallback(
ctx: &mut Ctx,
conversation: &mut ConversationState,
) -> anyhow::Result<Vec<Effect>> {
let Some((id, pending)) = conversation.direct.fallback.take() else {
return Ok(Vec::new());
};
let next = if conversation.turn.is_some() {
Err("Decided on autopilot · another turn is running, so it stops here.")
} else {
shortlist(ctx, &pending.turn.worker, &pending.tried)
.into_iter()
.find(|candidate| auto_check(candidate).is_ok())
.map(|candidate| candidate.worker)
.ok_or(lifecycle::PAID_ONLY_ON_AUTOPILOT)
};
let answers = !pending.turn.step.writes();
match next {
Ok(to) => {
let does = if answers { "answers" } else { "carries it on" };
ctx.send(AppEvent::ChoiceClosed {
id,
outcome: format!("Decided on autopilot · {} {does}", ctx.names.worker(&to)),
});
if answers {
return lifecycle::answer_on(ctx, conversation, pending.turn, pending.tried, &to);
}
lifecycle::fallback(
ctx,
conversation,
pending.turn,
pending.tried,
pending.why,
to,
)
}
Err(why_not) => {
ctx.send(AppEvent::ChoiceClosed {
id,
outcome: why_not.to_owned(),
});
if answers {
return Ok(send_queued(conversation));
}
let error = format!("{} {}", pending.turn.worker, pending.why);
Ok(lifecycle::end_direct(
ctx,
conversation,
pending.turn,
error,
))
}
}
}
fn settle_plans(
ctx: &mut Ctx,
conversation: &mut ConversationState,
) -> anyhow::Result<Vec<Effect>> {
let mut open: Vec<ChoiceId> = conversation.direct.plans.keys().cloned().collect();
open.sort();
let mut effects = Vec::new();
for id in open {
let Some(planned) = conversation.direct.plans.remove(&id) else {
continue;
};
if conversation.turn.is_some() {
ctx.send(AppEvent::ChoiceClosed {
id,
outcome: "Decided on autopilot · not carried out, since a turn is running. \
Ask again to have it done."
.to_owned(),
});
continue;
}
ctx.send(AppEvent::ChoiceClosed {
id,
outcome: format!(
"Decided on autopilot · {} goes ahead with its plan",
planned.cli
),
});
effects.extend(go_ahead(
ctx,
conversation,
planned.request,
GO_AHEAD_WITH_PLAN,
None,
planned.words,
)?);
}
Ok(effects)
}
fn settle_refused(
ctx: &mut Ctx,
conversation: &mut ConversationState,
) -> anyhow::Result<Vec<Effect>> {
let mut open: Vec<ChoiceId> = conversation.direct.refused.keys().cloned().collect();
open.sort();
let mut effects = Vec::new();
for id in open {
let Some(offer) = conversation.direct.refused.remove(&id) else {
continue;
};
if conversation.turn.is_some() {
ctx.send(AppEvent::ChoiceClosed {
id,
outcome: "Decided on autopilot · not run, since a turn is running. Ask again \
to have them run."
.to_owned(),
});
continue;
}
let cli = ctx.names.cli(&offer.worker.agent);
ctx.send(AppEvent::ChoiceClosed {
id,
outcome: format!(
"Decided on autopilot · {cli} continues, running commands without asking"
),
});
effects.extend(go_ahead(
ctx,
conversation,
offer.request,
RUN_REFUSED,
Some(Asks::Never),
DirectWords::default(),
)?);
}
Ok(effects)
}
pub(super) fn waits_on_you(conversation: &ConversationState, turn: &TurnId) -> bool {
conversation
.direct
.asks
.values()
.any(|ask| &ask.turn == turn)
}
pub(crate) fn owns_choice(conversation: &ConversationState, id: &ChoiceId) -> bool {
conversation.direct.asks.contains_key(id)
|| conversation.direct.refused.contains_key(id)
|| conversation.direct.plans.contains_key(id)
|| conversation.direct.rewinding.as_ref() == Some(id)
|| conversation
.direct
.fallback
.as_ref()
.is_some_and(|(open, _)| open == id)
}
pub(crate) fn answer_choice(
ctx: &mut Ctx,
conversation: &mut ConversationState,
id: ChoiceId,
action: &AnswerAction,
) -> anyhow::Result<Vec<Effect>> {
let value = match action {
AnswerAction::Choose { value } => value.as_str(),
AnswerAction::Cancel => "no",
other => anyhow::bail!("{other:?} doesn't answer this question"),
};
if let Some(pending) = conversation.direct.asks.remove(&id) {
return answer_ask(ctx, conversation, id, pending, value);
}
if let Some(offer) = conversation.direct.refused.remove(&id) {
return answer_refused(ctx, conversation, id, offer, value);
}
if let Some(planned) = conversation.direct.plans.remove(&id) {
return answer_plan(ctx, conversation, id, planned, value);
}
if conversation
.direct
.fallback
.as_ref()
.is_some_and(|(open, _)| open == &id)
&& let Some((_, pending)) = conversation.direct.fallback.take()
{
return answer_fallback(ctx, conversation, id, pending, value);
}
if conversation.direct.rewinding.as_ref() == Some(&id) {
return rewind_to(ctx, conversation, id, value);
}
ctx.notice(NoteLevel::Info, "That question is already closed.");
Ok(Vec::new())
}
pub(crate) fn set_aside(
ctx: &mut Ctx,
conversation: &mut ConversationState,
id: ChoiceId,
) -> anyhow::Result<Vec<Effect>> {
if conversation.direct.refused.contains_key(&id) {
return Ok(Vec::new());
}
answer_choice(ctx, conversation, id, &AnswerAction::Cancel)
}
fn answer_ask(
ctx: &mut Ctx,
conversation: &mut ConversationState,
id: ChoiceId,
pending: PendingAsk,
value: &str,
) -> anyhow::Result<Vec<Effect>> {
let mut effects = Vec::new();
let about = pending.question.about.clone();
let outcome = match value {
"yes" => {
let _ = pending.reply.send(AskReply::allow(&pending.input));
format!("Yes to {about}")
}
"always" => {
effects = grant(ctx, conversation, &pending.question.grant)?;
let _ = pending.reply.send(AskReply::allow(&pending.input));
answer_waiting(ctx, conversation);
format!("Yes to {about}, and don't ask again")
}
"no" => {
let _ = pending.reply.send(ask::no());
if conversation
.turn
.as_ref()
.is_some_and(|turn| turn.id == pending.turn)
{
effects.extend(super::cancel_turn(ctx, conversation)?);
}
format!("No to {about}")
}
other => {
conversation.direct.asks.insert(id, pending);
anyhow::bail!("{other:?} isn't one of this question's answers");
}
};
ctx.send(AppEvent::ChoiceClosed { id, outcome });
Ok(effects)
}
fn grant(
ctx: &mut Ctx,
conversation: &mut ConversationState,
grant: &ask::Grant,
) -> anyhow::Result<Vec<Effect>> {
let pattern = match grant {
ask::Grant::Edits => {
conversation.direct.edits_allowed = true;
return Ok(Vec::new());
}
ask::Grant::Commands(prefix) => ask::command_pattern(prefix),
ask::Grant::Tool(tool) => tool.clone(),
ask::Grant::Never => return Ok(Vec::new()),
};
let mut patterns = ctx.settings().approvals.allow_commands.clone();
if patterns.contains(&pattern) {
return Ok(Vec::new());
}
patterns.push(pattern);
let value = toml::Value::Array(patterns.into_iter().map(toml::Value::String).collect());
crate::controller::settings::save_answered(
ctx,
"approvals.allow_commands",
value.to_string(),
SettingScope::ProjectLocal,
)
}
fn answer_waiting(ctx: &mut Ctx, conversation: &mut ConversationState) {
let patterns = ctx.settings().approvals.allow_commands.clone();
let edits = conversation.direct.edits_allowed;
let allowed: Vec<ChoiceId> = conversation
.direct
.asks
.iter()
.filter(|(_, pending)| ask::allowed(&pending.tool, &pending.input, edits, &patterns))
.map(|(id, _)| id.clone())
.collect();
for id in allowed {
if let Some(pending) = conversation.direct.asks.remove(&id) {
let _ = pending.reply.send(AskReply::allow(&pending.input));
ctx.send(AppEvent::ChoiceClosed {
id,
outcome: "Yes".to_owned(),
});
}
}
}
fn close_asks(ctx: &Ctx, conversation: &mut ConversationState, turn: &TurnId) {
let ids: Vec<ChoiceId> = conversation
.direct
.asks
.iter()
.filter(|(_, pending)| &pending.turn == turn)
.map(|(id, _)| id.clone())
.collect();
for id in ids {
conversation.direct.asks.remove(&id);
ctx.send(AppEvent::ChoiceClosed {
id,
outcome: "The turn ended.".to_owned(),
});
}
}
fn offer_refused(
ctx: &mut Ctx,
conversation: &mut ConversationState,
turn: &Turn,
commands: &[String],
) {
let cli = ctx.names.cli(&turn.worker.agent);
let id = ctx.next_choice_id();
let mut options = choices::options(ChoiceKind::RefusedCommands);
if let Some(first) = options.first_mut() {
first.detail = Some(
"one more turn in which agy runs commands without asking, in our sandbox".to_owned(),
);
}
let choice = choices::app_choice(
id.clone(),
ChoiceKind::RefusedCommands,
format!("Let {cli} run what it was refused?"),
commands.to_vec(),
options,
);
conversation.direct.refused.insert(
id,
RefusedOffer {
worker: turn.worker.clone(),
request: turn.text.clone(),
},
);
ctx.send(AppEvent::Choice { choice });
}
fn answer_refused(
ctx: &mut Ctx,
conversation: &mut ConversationState,
id: ChoiceId,
offer: RefusedOffer,
value: &str,
) -> anyhow::Result<Vec<Effect>> {
if value != "continue" {
ctx.send(AppEvent::ChoiceClosed {
id,
outcome: "No".to_owned(),
});
return Ok(Vec::new());
}
if conversation.turn.is_some() {
ctx.notice(
NoteLevel::Info,
"A turn is running. Answer agy's question once it ends.",
);
conversation.direct.refused.insert(id, offer);
return Ok(Vec::new());
}
let cli = ctx.names.cli(&offer.worker.agent);
ctx.send(AppEvent::ChoiceClosed {
id,
outcome: format!("{cli} continues, running commands without asking"),
});
go_ahead(
ctx,
conversation,
offer.request,
RUN_REFUSED,
Some(Asks::Never),
DirectWords::default(),
)
}
pub(crate) fn rewind(ctx: &mut Ctx, conversation: &mut ConversationState) -> bool {
if conversation.direct.turns.is_empty() {
if !ctx.env.git().has_history() && conversation.place.is_none() {
ctx.notice(
NoteLevel::Info,
"This folder has no git, so nothing was kept to go back to. /git turns on a \
hidden history, and /rewind works from then on.",
);
return true;
}
return false;
}
let id = ctx.next_choice_id();
let newest = conversation.direct.turns.len() - 1;
let options = conversation
.direct
.turns
.iter()
.enumerate()
.rev()
.zip('a'..='z')
.map(|((index, record), key)| {
let mut option = option(
key,
&crate::controller::recap::title_from_request(&record.text),
choices::choose(&index.to_string()),
index == newest,
);
option.detail = Some(match record.changed.len() {
1 => record.changed[0].clone(),
n => format!("{n} files"),
});
option
})
.collect();
let choice = choices::app_choice(
id.clone(),
ChoiceKind::RewindTurn,
"Put back the files changed since which message?",
vec!["Files you changed since stay as you left them.".to_owned()],
options,
);
conversation.direct.rewinding = Some(id);
ctx.send(AppEvent::Choice { choice });
true
}
fn rewind_to(
ctx: &mut Ctx,
conversation: &mut ConversationState,
id: ChoiceId,
value: &str,
) -> anyhow::Result<Vec<Effect>> {
conversation.direct.rewinding = None;
let Ok(from) = value.parse::<usize>() else {
ctx.send(AppEvent::ChoiceClosed {
id,
outcome: choices::NOT_CHANGED.to_owned(),
});
return Ok(Vec::new());
};
let (Some(target), Some(last)) = (
conversation.direct.turns.get(from),
conversation.direct.turns.last(),
) else {
anyhow::bail!("that message's files can't be put back any more");
};
let mut paths: Vec<String> = conversation.direct.turns[from..]
.iter()
.flat_map(|record| record.changed.iter().cloned())
.collect();
paths.sort();
paths.dedup();
let root = conversation.folder(ctx).to_path_buf();
let restored = rewind::restore(
&root,
&target.before,
&last.after,
&paths,
max_bytes(ctx.settings()),
)?;
let asked = target.text.clone();
conversation.direct.turns.truncate(from);
let mut outcome = match restored.put_back.len() {
0 => "Nothing to put back".to_owned(),
1 => format!("Put back {}", restored.put_back[0]),
n => format!("Put back {n} files"),
};
if !restored.kept.is_empty() {
outcome.push_str(&format!(
" · kept as you changed them: {}",
restored.kept.join(", ")
));
}
if !restored.cant.is_empty() {
outcome.push_str(&format!(
" · can't put back, as nothing kept how they were: {}",
restored.cant.join(", ")
));
}
ctx.send(AppEvent::ChoiceClosed { id, outcome });
if !restored.put_back.is_empty() {
conversation.direct.rewound = Some(format!(
"The user put back these files to how they were before \"{}\": {}.",
asked,
restored.put_back.join(", ")
));
}
Ok(Vec::new())
}