2026-07-11 20:21:59 +07:00
|
|
|
use std::collections::VecDeque;
|
|
|
|
|
|
|
|
|
|
use crate::app::harness::Verdict;
|
|
|
|
|
use sha2::Digest;
|
2026-07-11 13:16:10 +07:00
|
|
|
use crate::app::mode::ModeKind;
|
2026-07-11 20:21:59 +07:00
|
|
|
use crate::app::review::{should_trigger_review, trigger_review};
|
|
|
|
|
use crate::app::state::rest::{AppStateRest, ChatMessageDisplay};
|
|
|
|
|
use crate::app::state::runtime::TurnEvent;
|
|
|
|
|
use crate::app::state::types::{AgentMode, Origin, Overlay, Toast, ToastKind};
|
|
|
|
|
use crate::dto::chat::message::{ChatMessage, Role};
|
|
|
|
|
|
|
|
|
|
const MAX_AGENT_STEPS: usize = 40;
|
2026-07-11 13:16:10 +07:00
|
|
|
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub enum Action {
|
|
|
|
|
Quit,
|
|
|
|
|
ForceQuit,
|
|
|
|
|
SwitchMode(ModeKind),
|
|
|
|
|
SubmitInput(String),
|
|
|
|
|
InsertChar(char),
|
|
|
|
|
DeleteChar,
|
|
|
|
|
DeleteCharRight,
|
|
|
|
|
CursorLeft,
|
|
|
|
|
CursorRight,
|
|
|
|
|
HistoryUp,
|
|
|
|
|
HistoryDown,
|
|
|
|
|
ScrollUp,
|
|
|
|
|
ScrollDown,
|
|
|
|
|
OpenOverlay(Overlay),
|
|
|
|
|
CloseOverlay,
|
|
|
|
|
ToggleYoloArm,
|
2026-07-11 20:21:59 +07:00
|
|
|
CycleAgentMode,
|
2026-07-11 13:16:10 +07:00
|
|
|
ToolResult {
|
|
|
|
|
tool_call_id: String,
|
|
|
|
|
output: String,
|
|
|
|
|
is_error: bool,
|
|
|
|
|
},
|
|
|
|
|
StreamToken(String),
|
|
|
|
|
StreamDone,
|
|
|
|
|
StreamError(String),
|
|
|
|
|
SystemNote {
|
|
|
|
|
kind: String,
|
|
|
|
|
message: String,
|
|
|
|
|
},
|
|
|
|
|
RunCommand(String),
|
|
|
|
|
QuitConfirm,
|
|
|
|
|
Resize(u16, u16),
|
|
|
|
|
Tick,
|
2026-07-11 18:23:01 +07:00
|
|
|
LessonExport {
|
|
|
|
|
path: String,
|
|
|
|
|
},
|
|
|
|
|
LessonImport {
|
|
|
|
|
path: String,
|
|
|
|
|
},
|
2026-07-11 20:21:59 +07:00
|
|
|
#[expect(dead_code)]
|
2026-07-11 13:16:10 +07:00
|
|
|
RecordUsage {
|
|
|
|
|
tokens_in: u64,
|
|
|
|
|
tokens_out: u64,
|
|
|
|
|
duration_ms: u64,
|
|
|
|
|
},
|
2026-07-11 20:21:59 +07:00
|
|
|
#[expect(dead_code)]
|
2026-07-11 13:16:10 +07:00
|
|
|
RecordReviewTokens {
|
|
|
|
|
tokens: u64,
|
|
|
|
|
},
|
2026-07-11 20:21:59 +07:00
|
|
|
SaveSession,
|
|
|
|
|
ResumeSession,
|
|
|
|
|
RefreshSessions,
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
|
|
|
|
match action {
|
|
|
|
|
Action::Quit => {
|
2026-07-11 20:21:59 +07:00
|
|
|
save_current_session(state);
|
2026-07-11 13:16:10 +07:00
|
|
|
state.quit = true;
|
|
|
|
|
}
|
|
|
|
|
Action::ForceQuit => {
|
2026-07-11 20:21:59 +07:00
|
|
|
save_current_session(state);
|
2026-07-11 13:16:10 +07:00
|
|
|
state.quit = true;
|
|
|
|
|
}
|
|
|
|
|
Action::SwitchMode(mode) => {
|
|
|
|
|
state.misc.overlay = match mode {
|
|
|
|
|
ModeKind::Chat
|
|
|
|
|
| ModeKind::Agents
|
|
|
|
|
| ModeKind::Bash
|
|
|
|
|
| ModeKind::Workflow => Overlay::None,
|
|
|
|
|
ModeKind::Help => Overlay::Help,
|
|
|
|
|
ModeKind::Settings => Overlay::Settings,
|
|
|
|
|
ModeKind::SessionHub => Overlay::SessionHub,
|
|
|
|
|
ModeKind::QuitConfirm => Overlay::QuitConfirm,
|
|
|
|
|
ModeKind::Onboard => Overlay::Onboard,
|
|
|
|
|
ModeKind::OnboardProvider => Overlay::OnboardProvider,
|
|
|
|
|
ModeKind::Picker => Overlay::Picker,
|
|
|
|
|
ModeKind::KeyInput => Overlay::KeyInput,
|
|
|
|
|
ModeKind::Editor => Overlay::Editor,
|
|
|
|
|
ModeKind::Effort => Overlay::Effort,
|
|
|
|
|
ModeKind::Mcp => Overlay::Mcp,
|
|
|
|
|
ModeKind::Security => Overlay::Security,
|
|
|
|
|
ModeKind::Todo => Overlay::Todo,
|
|
|
|
|
ModeKind::Rewind => Overlay::Rewind,
|
|
|
|
|
ModeKind::Loading => Overlay::Loading,
|
|
|
|
|
};
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::SubmitInput(text) => {
|
2026-07-11 20:21:59 +07:00
|
|
|
state.input.submit();
|
|
|
|
|
let text = text.trim().to_string();
|
|
|
|
|
if text.is_empty() {
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
return;
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
2026-07-11 20:21:59 +07:00
|
|
|
state.push_transcript(ChatMessageDisplay::new(Role::User, text.clone()));
|
|
|
|
|
if let Some(ref mut rt) = state.session_runtime {
|
|
|
|
|
rt.push_message(ChatMessage::user(text));
|
|
|
|
|
}
|
|
|
|
|
spawn_turn(state);
|
2026-07-11 13:16:10 +07:00
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::InsertChar(c) => {
|
|
|
|
|
state.input.insert(c);
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::DeleteChar => {
|
|
|
|
|
state.input.delete_left();
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::DeleteCharRight => {
|
|
|
|
|
state.input.delete_right();
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::CursorLeft => {
|
|
|
|
|
state.input.char_left();
|
|
|
|
|
}
|
|
|
|
|
Action::CursorRight => {
|
|
|
|
|
state.input.char_right();
|
|
|
|
|
}
|
|
|
|
|
Action::HistoryUp => {
|
|
|
|
|
state.input.history_up();
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::HistoryDown => {
|
|
|
|
|
state.input.history_down();
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::ScrollUp => {
|
|
|
|
|
let total = state.transcript_cache.messages.len();
|
|
|
|
|
state.scroll.scroll_up();
|
|
|
|
|
state.scroll.scroll_down(total);
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::ScrollDown => {
|
|
|
|
|
let total = state.transcript_cache.messages.len();
|
|
|
|
|
state.scroll.scroll_down(total);
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::OpenOverlay(overlay) => {
|
|
|
|
|
state.misc.overlay = overlay;
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::CloseOverlay => {
|
|
|
|
|
state.misc.overlay = Overlay::None;
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::ToggleYoloArm => {
|
|
|
|
|
state.misc.yolo_armed = !state.misc.yolo_armed;
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
2026-07-11 20:21:59 +07:00
|
|
|
Action::CycleAgentMode => {
|
|
|
|
|
let next = state.mode.cycle();
|
|
|
|
|
state.set_mode(next);
|
|
|
|
|
let toast = Toast::new(ToastKind::Info, format!("Mode: {}", state.mode.name()));
|
|
|
|
|
state.push_toast(toast);
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
2026-07-11 13:16:10 +07:00
|
|
|
Action::ToolResult {
|
|
|
|
|
tool_call_id,
|
|
|
|
|
output,
|
|
|
|
|
is_error,
|
|
|
|
|
} => {
|
|
|
|
|
if let Some(ref mut rt) = state.session_runtime {
|
|
|
|
|
rt.push_message(ChatMessage::tool_result(tool_call_id.clone(), output.clone()));
|
|
|
|
|
rt.tool_call_results.push(
|
|
|
|
|
crate::app::state::runtime::ToolCallResult {
|
|
|
|
|
tool_call_id,
|
|
|
|
|
tool_name: String::new(),
|
|
|
|
|
output,
|
|
|
|
|
is_error,
|
|
|
|
|
duration_ms: 0,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::StreamToken(token) => {
|
|
|
|
|
if let Some(ref mut rt) = state.session_runtime {
|
|
|
|
|
let found = rt.messages.iter_mut().rev().find(|m| {
|
|
|
|
|
matches!(m.role, crate::dto::chat::message::Role::Assistant)
|
|
|
|
|
});
|
|
|
|
|
if let Some(last) = found {
|
|
|
|
|
let current = last.content.take().unwrap_or_default();
|
|
|
|
|
last.content = Some(current + &token);
|
|
|
|
|
} else {
|
|
|
|
|
let mut msg = ChatMessage::assistant(None);
|
|
|
|
|
msg.content = Some(token);
|
|
|
|
|
rt.push_message(msg);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::StreamDone => {
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::StreamError(msg) => {
|
|
|
|
|
let toast = crate::app::state::types::Toast::new(
|
|
|
|
|
crate::app::state::types::ToastKind::Error,
|
|
|
|
|
msg,
|
|
|
|
|
);
|
|
|
|
|
state.push_toast(toast);
|
|
|
|
|
}
|
|
|
|
|
Action::SystemNote { kind: _kind, message } => {
|
|
|
|
|
let toast = crate::app::state::types::Toast::new(
|
|
|
|
|
crate::app::state::types::ToastKind::Info,
|
|
|
|
|
message,
|
|
|
|
|
);
|
|
|
|
|
state.push_toast(toast);
|
|
|
|
|
}
|
|
|
|
|
Action::RunCommand(text) => {
|
|
|
|
|
if let Some(ref mut rt) = state.session_runtime {
|
|
|
|
|
rt.push_message(ChatMessage::user(text));
|
|
|
|
|
}
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::QuitConfirm => {
|
|
|
|
|
state.misc.overlay = Overlay::QuitConfirm;
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::Resize(w, _h) => {
|
|
|
|
|
state.scroll.set_max_visible(w as usize);
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
2026-07-11 18:23:01 +07:00
|
|
|
Action::LessonExport { path } => {
|
|
|
|
|
let dest = std::path::Path::new(&path);
|
|
|
|
|
if let Some(parent) = dest.parent() {
|
|
|
|
|
let _ = std::fs::create_dir_all(parent);
|
|
|
|
|
}
|
|
|
|
|
match crate::model::memory::export_lessons(&state.memory_dir, dest) {
|
|
|
|
|
Ok(_) => {
|
|
|
|
|
state.push_toast(crate::app::state::types::Toast::new(
|
|
|
|
|
crate::app::state::types::ToastKind::Success,
|
|
|
|
|
format!("lessons exported to {}", path),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
state.push_toast(crate::app::state::types::Toast::new(
|
|
|
|
|
crate::app::state::types::ToastKind::Error,
|
|
|
|
|
format!("export failed: {}", e),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::LessonImport { path } => {
|
|
|
|
|
let src = std::path::Path::new(&path);
|
|
|
|
|
match crate::model::memory::import_lessons(&state.memory_dir, src) {
|
|
|
|
|
Ok(count) => {
|
|
|
|
|
state.push_toast(crate::app::state::types::Toast::new(
|
|
|
|
|
crate::app::state::types::ToastKind::Success,
|
|
|
|
|
format!("imported {} lessons from {}", count, path),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
state.push_toast(crate::app::state::types::Toast::new(
|
|
|
|
|
crate::app::state::types::ToastKind::Error,
|
|
|
|
|
format!("import failed: {}", e),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
2026-07-11 20:21:59 +07:00
|
|
|
Action::SaveSession => {
|
|
|
|
|
save_current_session(state);
|
|
|
|
|
state.push_toast(Toast::new(ToastKind::Success, "session saved".to_string()));
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::ResumeSession => {
|
|
|
|
|
let base = state.store_base_dir();
|
|
|
|
|
let sessions = crate::model::session::Session::list(&base);
|
|
|
|
|
let target = sessions.into_iter()
|
|
|
|
|
.filter(|s| s.id != state.session_id)
|
|
|
|
|
.max_by_key(|s| s.updated_at);
|
|
|
|
|
if let Some(session) = target {
|
|
|
|
|
let conv_path = session.conversation_path(&base);
|
|
|
|
|
let loaded_msgs: Vec<crate::dto::chat::message::ChatMessage> =
|
|
|
|
|
std::fs::read_to_string(&conv_path)
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|data| serde_json::from_str(&data).ok())
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
state.session_id = session.id.clone();
|
|
|
|
|
state.session_dir = session.session_dir(&base);
|
|
|
|
|
state.session_runtime = Some(crate::app::state::runtime::SessionRuntime::new(
|
|
|
|
|
state.session_dir.clone(),
|
|
|
|
|
));
|
|
|
|
|
state.transcript_cache.messages.clear();
|
|
|
|
|
if let Some(ref mut rt) = state.session_runtime {
|
|
|
|
|
for msg in loaded_msgs {
|
|
|
|
|
let display = ChatMessageDisplay::new(
|
|
|
|
|
msg.role.clone(),
|
|
|
|
|
msg.content.clone().unwrap_or_default(),
|
|
|
|
|
);
|
|
|
|
|
state.transcript_cache.messages.push(display);
|
|
|
|
|
rt.push_message(msg);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
state.push_toast(Toast::new(ToastKind::Success,
|
|
|
|
|
format!("resumed session: {}", session.title)));
|
|
|
|
|
} else {
|
|
|
|
|
state.push_toast(Toast::new(ToastKind::Info,
|
|
|
|
|
"no other sessions to resume".to_string()));
|
|
|
|
|
}
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::RefreshSessions => {
|
|
|
|
|
let base = state.store_base_dir();
|
|
|
|
|
state.sessions = crate::model::session::Session::list(&base);
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
2026-07-11 13:16:10 +07:00
|
|
|
Action::Tick => {
|
|
|
|
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
|
|
|
|
state.misc.drain_expired_toasts(now_ms);
|
2026-07-11 20:21:59 +07:00
|
|
|
let events: Vec<TurnEvent> = {
|
|
|
|
|
if let Ok(mut q) = state.turn_events.lock() {
|
|
|
|
|
q.drain(..).collect()
|
|
|
|
|
} else {
|
|
|
|
|
Vec::new()
|
|
|
|
|
}
|
2026-07-11 13:16:10 +07:00
|
|
|
};
|
2026-07-11 20:21:59 +07:00
|
|
|
let mut turn_finished = false;
|
|
|
|
|
for event in events {
|
|
|
|
|
match event {
|
|
|
|
|
TurnEvent::AssistantMessage(msg) => {
|
|
|
|
|
let display_content = msg.content.clone().unwrap_or_default();
|
|
|
|
|
if !display_content.is_empty() {
|
|
|
|
|
state.push_transcript(ChatMessageDisplay::new(Role::Assistant, display_content));
|
|
|
|
|
}
|
|
|
|
|
if let Some(ref mut rt) = state.session_runtime {
|
|
|
|
|
rt.push_message(msg);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
TurnEvent::ToolResult { tool_call_id, tool_name, output, is_error } => {
|
|
|
|
|
state.push_transcript(ChatMessageDisplay::new(
|
|
|
|
|
Role::Tool,
|
|
|
|
|
format!("{}: {}", tool_name, output),
|
|
|
|
|
));
|
|
|
|
|
if let Some(ref mut rt) = state.session_runtime {
|
|
|
|
|
rt.push_message(ChatMessage::tool_result(tool_call_id.clone(), output.clone()));
|
|
|
|
|
rt.tool_call_results.push(crate::app::state::runtime::ToolCallResult {
|
|
|
|
|
tool_call_id,
|
|
|
|
|
tool_name,
|
|
|
|
|
output,
|
|
|
|
|
is_error,
|
|
|
|
|
duration_ms: 0,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
TurnEvent::SystemNote { kind, message } => {
|
|
|
|
|
if kind == "edits" {
|
|
|
|
|
if let Some(ref mut rt) = state.session_runtime {
|
|
|
|
|
if let Ok(count) = message.parse::<u32>() {
|
|
|
|
|
rt.edit_count += count;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if should_trigger_review(state, Origin::Main) {
|
|
|
|
|
let _ = trigger_review(state);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
state.push_toast(Toast::new(ToastKind::Info, message));
|
|
|
|
|
}
|
|
|
|
|
TurnEvent::Error(msg) => {
|
|
|
|
|
state.push_toast(Toast::new(ToastKind::Error, msg));
|
|
|
|
|
turn_finished = true;
|
|
|
|
|
}
|
|
|
|
|
TurnEvent::Done => {
|
|
|
|
|
turn_finished = true;
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
}
|
2026-07-11 20:21:59 +07:00
|
|
|
}
|
|
|
|
|
if turn_finished {
|
|
|
|
|
maybe_trigger_review(state);
|
|
|
|
|
}
|
|
|
|
|
if turn_finished || state.dirty {
|
2026-07-11 13:16:10 +07:00
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Action::RecordUsage { tokens_in, tokens_out, duration_ms } => {
|
|
|
|
|
if let Some(ref mut rt) = state.session_runtime {
|
|
|
|
|
rt.record_api_call(tokens_in, tokens_out, duration_ms);
|
|
|
|
|
}
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::RecordReviewTokens { tokens } => {
|
|
|
|
|
if let Some(ref mut rt) = state.session_runtime {
|
|
|
|
|
rt.record_review_tokens(tokens);
|
|
|
|
|
}
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-11 20:21:59 +07:00
|
|
|
|
|
|
|
|
fn spawn_turn(state: &AppStateRest) {
|
|
|
|
|
let in_flight = if let Ok(guard) = state.turn_in_flight.lock() {
|
|
|
|
|
*guard
|
|
|
|
|
} else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
if in_flight {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let messages = state
|
|
|
|
|
.session_runtime
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|rt| rt.messages.clone())
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
if messages.is_empty() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let api_key = state.settings.api_key.clone().unwrap_or_default();
|
|
|
|
|
if api_key.is_empty() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let model = state.settings.model.clone();
|
|
|
|
|
let mut tools = crate::tool::all_tools();
|
|
|
|
|
tools.extend(state.mcp_manager.as_tools());
|
|
|
|
|
let tool_defs = crate::tool::tool_defs(&tools);
|
|
|
|
|
let ctx = state.tool_ctx();
|
|
|
|
|
let mode = state.mode;
|
|
|
|
|
let edit_log_path = state.edit_log.path.clone();
|
|
|
|
|
let session_id = state.session_id.clone();
|
|
|
|
|
let turn_events = state.turn_events.clone();
|
|
|
|
|
let in_flight_flag = state.turn_in_flight.clone();
|
|
|
|
|
let workspace_roots: Vec<std::path::PathBuf> = ctx.workspaces.clone();
|
|
|
|
|
|
|
|
|
|
*in_flight_flag.lock().unwrap() = true;
|
|
|
|
|
|
|
|
|
|
let events_q = turn_events.clone();
|
|
|
|
|
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
let tc = TurnCtx {
|
|
|
|
|
client: crate::service::openrouter::OpenRouterClient::new(api_key, model),
|
|
|
|
|
tdefs: tool_defs,
|
|
|
|
|
tools,
|
|
|
|
|
ctx,
|
|
|
|
|
mode,
|
|
|
|
|
workspace_roots,
|
|
|
|
|
edit_log_path,
|
|
|
|
|
session_id,
|
|
|
|
|
};
|
|
|
|
|
let result = run_agent_turn(tc, &messages, &events_q);
|
|
|
|
|
if let Err(e) = result {
|
|
|
|
|
if let Ok(mut q) = events_q.lock() {
|
|
|
|
|
q.push_back(TurnEvent::Error(e.to_string()));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if let Ok(mut flag) = in_flight_flag.lock() {
|
|
|
|
|
*flag = false;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct TurnCtx {
|
|
|
|
|
client: crate::service::openrouter::OpenRouterClient,
|
|
|
|
|
tdefs: Vec<crate::dto::openrouter::request::ToolDef>,
|
|
|
|
|
tools: Vec<Box<dyn crate::tool::Tool>>,
|
|
|
|
|
ctx: crate::tool::ToolCtx,
|
|
|
|
|
mode: AgentMode,
|
|
|
|
|
workspace_roots: Vec<std::path::PathBuf>,
|
|
|
|
|
edit_log_path: std::path::PathBuf,
|
|
|
|
|
session_id: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn run_agent_turn(
|
|
|
|
|
tc: TurnCtx,
|
|
|
|
|
messages: &[ChatMessage],
|
|
|
|
|
events_q: &std::sync::Mutex<VecDeque<TurnEvent>>,
|
|
|
|
|
) -> anyhow::Result<()> {
|
|
|
|
|
let mut msgs = messages.to_vec();
|
|
|
|
|
let mut edits_this_turn = 0u32;
|
|
|
|
|
|
|
|
|
|
for _step in 0..MAX_AGENT_STEPS {
|
|
|
|
|
let response = tc
|
|
|
|
|
.client
|
|
|
|
|
.chat_with_tools(&msgs, Some(tc.tdefs.clone()))?;
|
|
|
|
|
|
|
|
|
|
let has_tool_calls = response.tool_calls.is_some()
|
|
|
|
|
&& response.tool_calls.as_ref().is_some_and(|tc| !tc.is_empty());
|
|
|
|
|
|
|
|
|
|
let content = response.content.clone().unwrap_or_default();
|
|
|
|
|
if has_tool_calls {
|
|
|
|
|
let tool_calls = response.tool_calls.clone().unwrap_or_default();
|
|
|
|
|
msgs.push(response);
|
|
|
|
|
for tool_call in tool_calls {
|
|
|
|
|
let tool_name = tool_call.function.name.clone();
|
|
|
|
|
let args = crate::dto::chat::tool::sanitize_tool_arguments(
|
|
|
|
|
&tool_call.function.arguments,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let ws_roots: Vec<&std::path::Path> =
|
|
|
|
|
tc.workspace_roots.iter().map(|p| p.as_path()).collect();
|
|
|
|
|
let verdict = crate::app::harness::Harness::gate_tool_call(
|
|
|
|
|
&tool_name,
|
|
|
|
|
&args,
|
|
|
|
|
&tc.mode,
|
|
|
|
|
&ws_roots,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let is_edit_tool = tool_name == "write" || tool_name == "edit";
|
|
|
|
|
let (output, is_error, is_edit) = match verdict {
|
|
|
|
|
Verdict::Allow => match execute_one_tool(
|
|
|
|
|
&tc.tools,
|
|
|
|
|
&tc.ctx,
|
|
|
|
|
&tool_name,
|
|
|
|
|
&args,
|
|
|
|
|
&tc.edit_log_path,
|
|
|
|
|
&tc.session_id,
|
|
|
|
|
) {
|
|
|
|
|
Ok(result) => (result, false, is_edit_tool),
|
|
|
|
|
Err(e) => (e.to_string(), true, false),
|
|
|
|
|
},
|
|
|
|
|
Verdict::Block(reason) => (format!("Blocked: {}", reason), true, false),
|
|
|
|
|
Verdict::Escalate => (
|
|
|
|
|
"Tool requires approval. Switch to Auto mode or provide explicit approval."
|
|
|
|
|
.to_string(),
|
|
|
|
|
true,
|
|
|
|
|
false,
|
|
|
|
|
),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if is_edit {
|
|
|
|
|
edits_this_turn += 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
{
|
|
|
|
|
if let Ok(mut q) = events_q.lock() {
|
|
|
|
|
q.push_back(TurnEvent::ToolResult {
|
|
|
|
|
tool_call_id: tool_call.id.clone(),
|
|
|
|
|
tool_name: tool_name.clone(),
|
|
|
|
|
output: output.clone(),
|
|
|
|
|
is_error,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let tool_msg = ChatMessage::tool_result(tool_call.id.clone(), output);
|
|
|
|
|
msgs.push(tool_msg);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
if !content.is_empty() {
|
|
|
|
|
if let Ok(mut q) = events_q.lock() {
|
|
|
|
|
q.push_back(TurnEvent::AssistantMessage(response));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if edits_this_turn > 0 {
|
|
|
|
|
if let Ok(mut q) = events_q.lock() {
|
|
|
|
|
q.push_back(TurnEvent::SystemNote {
|
|
|
|
|
kind: "edits".to_string(),
|
|
|
|
|
message: edits_this_turn.to_string(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let Ok(mut q) = events_q.lock() {
|
|
|
|
|
q.push_back(TurnEvent::Done);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn execute_one_tool(
|
|
|
|
|
tools: &[Box<dyn crate::tool::Tool>],
|
|
|
|
|
ctx: &crate::tool::ToolCtx,
|
|
|
|
|
name: &str,
|
|
|
|
|
args: &serde_json::Value,
|
|
|
|
|
edit_log_path: &std::path::Path,
|
|
|
|
|
session_id: &str,
|
|
|
|
|
) -> anyhow::Result<String> {
|
|
|
|
|
for tool in tools {
|
|
|
|
|
if tool.name() == name {
|
|
|
|
|
let result = tool.run(ctx, args)?;
|
|
|
|
|
if name == "write" || name == "edit" {
|
|
|
|
|
let reason = args
|
|
|
|
|
.get("reason")
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
.unwrap_or("unnamed");
|
|
|
|
|
let path = args
|
|
|
|
|
.get("path")
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
.unwrap_or("unknown");
|
|
|
|
|
let content_sha256 = {
|
|
|
|
|
let content = args.get("content").or_else(|| args.get("new"));
|
|
|
|
|
let hash = sha2::Sha256::digest(
|
|
|
|
|
content.and_then(|v| v.as_str()).unwrap_or("").as_bytes(),
|
|
|
|
|
);
|
|
|
|
|
format!("{:x}", hash)
|
|
|
|
|
};
|
|
|
|
|
let entry = crate::model::editlog::EditLogEntry {
|
|
|
|
|
ts: chrono::Utc::now().timestamp_millis(),
|
|
|
|
|
tool: name.to_string(),
|
|
|
|
|
path: path.to_string(),
|
|
|
|
|
reason: reason.to_string(),
|
|
|
|
|
content_sha256,
|
|
|
|
|
bytes_delta: result.len() as i64,
|
|
|
|
|
origin: ctx.origin.tag(),
|
|
|
|
|
session_id: session_id.to_string(),
|
|
|
|
|
};
|
|
|
|
|
let mut el = crate::model::editlog::EditLog::new(edit_log_path);
|
|
|
|
|
el.append(entry).ok();
|
|
|
|
|
}
|
|
|
|
|
return Ok(result);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
anyhow::bail!("tool not found: {}", name)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn maybe_trigger_review(state: &mut AppStateRest) {
|
|
|
|
|
if !state.settings.review_enabled {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let edit_count = state
|
|
|
|
|
.session_runtime
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|rt| rt.edit_count)
|
|
|
|
|
.unwrap_or(0);
|
|
|
|
|
if edit_count == 0 {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
state.push_toast(Toast::new(
|
|
|
|
|
ToastKind::Info,
|
|
|
|
|
format!("{} file(s) modified this turn. Review available.", edit_count),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn save_current_session(state: &AppStateRest) {
|
|
|
|
|
let base = state.store_base_dir();
|
|
|
|
|
let session = crate::model::session::Session::new(
|
|
|
|
|
state.session_id.clone(),
|
|
|
|
|
"session".to_string(),
|
|
|
|
|
);
|
|
|
|
|
let _ = session.save(&base);
|
|
|
|
|
if let Some(ref rt) = state.session_runtime {
|
|
|
|
|
let conv_path = session.conversation_path(&base);
|
|
|
|
|
if let Ok(data) = serde_json::to_string(&rt.messages) {
|
|
|
|
|
let _ = std::fs::write(&conv_path, data);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|