feat: Enhance OAuth module and OpenRouter client functionality
- Updated OAuth module to include unused imports for better clarity. - Refactored OpenRouterClient to improve chat functionality and added support for tools in chat requests. - Modified internet tools (Download, Fetch, Search) to use a more flexible internet mode check. - Introduced new Bash tools for managing background jobs (BashOutput, BashKill). - Enhanced workflow tool to parse and execute workflow scripts with arguments. - Updated status and workflow views to reflect new agent and findings counts. - Added IPC protocol definitions for client requests and state payloads.
This commit is contained in:
+392
-43
@@ -1,10 +1,17 @@
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::app::harness::Verdict;
|
||||
use sha2::Digest;
|
||||
use crate::app::mode::ModeKind;
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use crate::app::state::types::Overlay;
|
||||
use crate::dto::chat::message::ChatMessage;
|
||||
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;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[expect(dead_code)]
|
||||
pub enum Action {
|
||||
Quit,
|
||||
ForceQuit,
|
||||
@@ -22,6 +29,7 @@ pub enum Action {
|
||||
OpenOverlay(Overlay),
|
||||
CloseOverlay,
|
||||
ToggleYoloArm,
|
||||
CycleAgentMode,
|
||||
ToolResult {
|
||||
tool_call_id: String,
|
||||
output: String,
|
||||
@@ -44,22 +52,29 @@ pub enum Action {
|
||||
LessonImport {
|
||||
path: String,
|
||||
},
|
||||
#[expect(dead_code)]
|
||||
RecordUsage {
|
||||
tokens_in: u64,
|
||||
tokens_out: u64,
|
||||
duration_ms: u64,
|
||||
},
|
||||
#[expect(dead_code)]
|
||||
RecordReviewTokens {
|
||||
tokens: u64,
|
||||
},
|
||||
SaveSession,
|
||||
ResumeSession,
|
||||
RefreshSessions,
|
||||
}
|
||||
|
||||
pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
match action {
|
||||
Action::Quit => {
|
||||
save_current_session(state);
|
||||
state.quit = true;
|
||||
}
|
||||
Action::ForceQuit => {
|
||||
save_current_session(state);
|
||||
state.quit = true;
|
||||
}
|
||||
Action::SwitchMode(mode) => {
|
||||
@@ -87,32 +102,17 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
state.dirty = true;
|
||||
}
|
||||
Action::SubmitInput(text) => {
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
rt.push_message(ChatMessage::user(text.clone()));
|
||||
let api_key = state.settings.api_key.clone();
|
||||
let model = state.settings.model.clone();
|
||||
let msgs = rt.messages.clone();
|
||||
let pending = state.pending_api_response.clone();
|
||||
if let Some(key) = api_key {
|
||||
if !key.is_empty() {
|
||||
std::thread::spawn(move || {
|
||||
let client = crate::service::openrouter::OpenRouterClient::new(key, model);
|
||||
match client.chat(&msgs) {
|
||||
Ok(response) => {
|
||||
if let Ok(mut guard) = pending.lock() {
|
||||
*guard = Some(response);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if let Ok(mut guard) = pending.lock() {
|
||||
*guard = Some(format!("Error: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
state.input.submit();
|
||||
let text = text.trim().to_string();
|
||||
if text.is_empty() {
|
||||
state.dirty = true;
|
||||
return;
|
||||
}
|
||||
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);
|
||||
state.dirty = true;
|
||||
}
|
||||
Action::InsertChar(c) => {
|
||||
@@ -164,6 +164,13 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
state.misc.yolo_armed = !state.misc.yolo_armed;
|
||||
state.dirty = true;
|
||||
}
|
||||
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;
|
||||
}
|
||||
Action::ToolResult {
|
||||
tool_call_id,
|
||||
output,
|
||||
@@ -269,26 +276,117 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
}
|
||||
state.dirty = true;
|
||||
}
|
||||
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;
|
||||
}
|
||||
Action::Tick => {
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
state.misc.drain_expired_toasts(now_ms);
|
||||
let api_response = if let Ok(mut guard) = state.pending_api_response.lock() {
|
||||
guard.take()
|
||||
} else {
|
||||
None
|
||||
let events: Vec<TurnEvent> = {
|
||||
if let Ok(mut q) = state.turn_events.lock() {
|
||||
q.drain(..).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
if let Some(response) = api_response {
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
if response.starts_with("Error:") {
|
||||
let toast = crate::app::state::types::Toast::new(
|
||||
crate::app::state::types::ToastKind::Error,
|
||||
response,
|
||||
);
|
||||
state.push_toast(toast);
|
||||
} else {
|
||||
rt.push_message(ChatMessage::assistant(Some(response)));
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
if turn_finished {
|
||||
maybe_trigger_review(state);
|
||||
}
|
||||
if turn_finished || state.dirty {
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
@@ -306,3 +404,254 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user