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:
+52
-1
@@ -18,7 +18,58 @@ impl Harness {
|
||||
if mode.auto_approve() {
|
||||
return Verdict::Allow;
|
||||
}
|
||||
Verdict::Allow
|
||||
if matches!(mode, super::state::types::AgentMode::Plan) {
|
||||
return Verdict::Block("mutating tools are disabled in Plan mode".to_string());
|
||||
}
|
||||
Verdict::Escalate
|
||||
}
|
||||
|
||||
pub fn gate_tool_call(
|
||||
tool_name: &str,
|
||||
args: &serde_json::Value,
|
||||
mode: &super::state::types::AgentMode,
|
||||
workspace_roots: &[&std::path::Path],
|
||||
) -> Verdict {
|
||||
if let Err(e) = Self::run_catastrophic_guard(tool_name, args, workspace_roots) {
|
||||
return Verdict::Block(e);
|
||||
}
|
||||
if !crate::tool::tool_is_risky(tool_name) {
|
||||
return Verdict::Allow;
|
||||
}
|
||||
Self::classify(tool_name, mode)
|
||||
}
|
||||
|
||||
fn run_catastrophic_guard(
|
||||
tool_name: &str,
|
||||
args: &serde_json::Value,
|
||||
workspace_roots: &[&std::path::Path],
|
||||
) -> Result<(), String> {
|
||||
use super::catastrophic::CatastrophicGuard;
|
||||
match tool_name {
|
||||
"bash" => {
|
||||
let cmd = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
|
||||
CatastrophicGuard::check_all(cmd, workspace_roots)
|
||||
}
|
||||
"git_operator" => {
|
||||
let operation = args.get("operation").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let arg_list: Vec<String> = args
|
||||
.get("args")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
|
||||
.unwrap_or_default();
|
||||
let cmd = format!("git {} {}", operation, arg_list.join(" "));
|
||||
CatastrophicGuard::check_all(&cmd, workspace_roots)
|
||||
}
|
||||
"delete" => {
|
||||
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
|
||||
CatastrophicGuard::check_delete_path(std::path::Path::new(path), workspace_roots)
|
||||
}
|
||||
"web_download" | "download" => {
|
||||
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
|
||||
CatastrophicGuard::check_download_path(std::path::Path::new(path))
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,30 @@ pub struct McpManager {
|
||||
pub running: bool,
|
||||
}
|
||||
|
||||
pub struct McpToolAdapter {
|
||||
name: &'static str,
|
||||
description: &'static str,
|
||||
parameters: serde_json::Value,
|
||||
}
|
||||
|
||||
impl crate::tool::Tool for McpToolAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
self.name
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
self.description
|
||||
}
|
||||
|
||||
fn parameters(&self) -> serde_json::Value {
|
||||
self.parameters.clone()
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &crate::tool::ToolCtx, _args: &serde_json::Value) -> anyhow::Result<String> {
|
||||
Err(anyhow::anyhow!("MCP tool execution not yet implemented"))
|
||||
}
|
||||
}
|
||||
|
||||
impl McpManager {
|
||||
pub fn new() -> Self {
|
||||
McpManager {
|
||||
@@ -75,4 +99,15 @@ impl McpManager {
|
||||
self.running = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn as_tools(&self) -> Vec<Box<dyn crate::tool::Tool>> {
|
||||
self.all_tools().into_iter().map(|info| {
|
||||
let name = format!("mcp__{}", info.name);
|
||||
Box::new(McpToolAdapter {
|
||||
name: Box::leak(name.into_boxed_str()),
|
||||
description: Box::leak(info.description.clone().into_boxed_str()),
|
||||
parameters: info.input_schema.clone(),
|
||||
}) as Box<dyn crate::tool::Tool>
|
||||
}).collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ pub fn handle_bash_submit(state: &mut AppStateRest, command: String) {
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(dead_code)]
|
||||
pub fn handle_bash_dismiss(state: &mut AppStateRest) {
|
||||
state.misc.overlay = Overlay::None;
|
||||
state.dirty = true;
|
||||
|
||||
@@ -2,7 +2,6 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
#[expect(dead_code)]
|
||||
pub mod agents;
|
||||
#[expect(dead_code)]
|
||||
pub mod bash;
|
||||
#[expect(dead_code)]
|
||||
pub mod editor;
|
||||
@@ -22,17 +21,13 @@ pub mod onboard;
|
||||
pub mod onboard_provider;
|
||||
#[expect(dead_code)]
|
||||
pub mod picker;
|
||||
#[expect(dead_code)]
|
||||
pub mod quit_confirm;
|
||||
#[expect(dead_code)]
|
||||
pub mod rewind;
|
||||
#[expect(dead_code)]
|
||||
pub mod security;
|
||||
#[expect(dead_code)]
|
||||
pub mod session_hub;
|
||||
#[expect(dead_code)]
|
||||
pub mod settings;
|
||||
#[expect(dead_code)]
|
||||
pub mod todo;
|
||||
#[expect(dead_code)]
|
||||
pub mod workflow;
|
||||
|
||||
@@ -6,6 +6,7 @@ pub fn toggle_security_arm(state: &mut AppStateRest) {
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
#[expect(dead_code)]
|
||||
pub fn acknowledge_security(state: &mut AppStateRest) {
|
||||
if !state.misc.security_acknowledged {
|
||||
state.misc.security_acknowledged = true;
|
||||
|
||||
+36
-10
@@ -1,20 +1,46 @@
|
||||
use crate::model::session::Session;
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use crate::app::state::types::Overlay;
|
||||
|
||||
pub fn load_sessions(state: &mut AppStateRest) {
|
||||
state.sessions = Session::list(&state.session_dir);
|
||||
let base = state.store_base_dir();
|
||||
state.sessions = crate::model::session::Session::list(&base);
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
pub fn select_session(state: &mut AppStateRest, session_id: &str) {
|
||||
if let Some(session) = state.sessions.iter().find(|s| s.id == session_id) {
|
||||
let display = crate::app::state::rest::ChatMessageDisplay::new(
|
||||
crate::dto::chat::message::Role::System,
|
||||
format!("switched to session: {}", session.title),
|
||||
);
|
||||
state.push_transcript(display);
|
||||
state.misc.overlay = Overlay::None;
|
||||
state.dirty = true;
|
||||
let base = state.store_base_dir();
|
||||
let session = crate::model::session::Session::load(session_id, &base).ok();
|
||||
if session.is_none() {
|
||||
return;
|
||||
}
|
||||
let session = session.unwrap();
|
||||
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 = crate::app::state::rest::ChatMessageDisplay::new(
|
||||
msg.role.clone(),
|
||||
msg.content.clone().unwrap_or_default(),
|
||||
);
|
||||
state.transcript_cache.messages.push(display);
|
||||
rt.push_message(msg);
|
||||
}
|
||||
}
|
||||
state.push_transcript(crate::app::state::rest::ChatMessageDisplay::new(
|
||||
crate::dto::chat::message::Role::System,
|
||||
format!("switched to session: {}", session.title),
|
||||
));
|
||||
state.misc.overlay = Overlay::None;
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::app::runtime::actions::Action;
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use crate::model::settings::{Settings, InternetMode};
|
||||
|
||||
#[expect(dead_code)]
|
||||
pub fn apply_settings_action(state: &mut AppStateRest, action: &Action) {
|
||||
if let Action::ToggleYoloArm = action {
|
||||
state.misc.yolo_armed = !state.misc.yolo_armed;
|
||||
@@ -17,6 +18,7 @@ pub fn cycle_internet_mode(settings: &mut Settings) {
|
||||
};
|
||||
}
|
||||
|
||||
#[expect(dead_code)]
|
||||
pub fn cycle_review_enabled(settings: &mut Settings) {
|
||||
settings.review_enabled = !settings.review_enabled;
|
||||
}
|
||||
|
||||
+43
-4
@@ -1,6 +1,10 @@
|
||||
use std::collections::HashMap;
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use crate::app::state::runtime::TurnEvent;
|
||||
use crate::app::state::types::{AgentMode, Origin, Toast, ToastKind};
|
||||
use crate::app::subagent::context::build_subagent_context;
|
||||
use crate::app::subagent::engine::run_subagent;
|
||||
use crate::app::subagent::spawn::AgentDefinition;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
@@ -163,10 +167,45 @@ pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
|
||||
}
|
||||
|
||||
pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
|
||||
let _origin = Origin::Reviewer;
|
||||
state.push_transcript(crate::app::state::rest::ChatMessageDisplay::new(
|
||||
crate::dto::chat::message::Role::System,
|
||||
"review triggered".to_string(),
|
||||
let def = AgentDefinition::new(
|
||||
"quality-reviewer".to_string(),
|
||||
"reviewer".to_string(),
|
||||
);
|
||||
let mut ctx = build_subagent_context(def);
|
||||
ctx.session_dir = state.session_dir.clone();
|
||||
ctx.system_prompt = format!(
|
||||
"You are a code quality reviewer. Review the recent code changes \
|
||||
for correctness, security, and adherence to best practices. \
|
||||
Use read-only tools (read, grep, glob, recall, remember) to \
|
||||
inspect the session files and provide a concise review verdict. \
|
||||
Session directory: {:?}",
|
||||
state.session_dir
|
||||
);
|
||||
|
||||
let (tx, _rx) = tokio::sync::mpsc::channel(32);
|
||||
let turn_events = state.turn_events.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let result = run_subagent(ctx, tx);
|
||||
let message = match result {
|
||||
Ok(verdict) => {
|
||||
let first_line = verdict.lines().next().unwrap_or(&verdict);
|
||||
format!("Quality review: {}", first_line)
|
||||
}
|
||||
Err(e) => format!("Quality review failed: {}", e),
|
||||
};
|
||||
if let Ok(mut q) = turn_events.lock() {
|
||||
q.push_back(TurnEvent::SystemNote {
|
||||
kind: "review".to_string(),
|
||||
message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
state.push_toast(Toast::new(
|
||||
ToastKind::Info,
|
||||
"Quality review triggered".to_string(),
|
||||
));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ pub fn apply_command(command: Command) -> Vec<Action> {
|
||||
vec![Action::QuitConfirm]
|
||||
}
|
||||
Command::Resume => {
|
||||
vec![Action::CloseOverlay]
|
||||
vec![Action::SaveSession, Action::ResumeSession, Action::CloseOverlay]
|
||||
}
|
||||
Command::LessonCreate(text) => {
|
||||
vec![Action::SystemNote {
|
||||
@@ -38,9 +38,12 @@ pub fn apply_command(command: Command) -> Vec<Action> {
|
||||
}]
|
||||
}
|
||||
Command::Save => {
|
||||
vec![Action::SaveSession]
|
||||
}
|
||||
Command::Login { provider } => {
|
||||
vec![Action::SystemNote {
|
||||
kind: "save".to_string(),
|
||||
message: "session saved".to_string(),
|
||||
kind: "oauth".to_string(),
|
||||
message: format!("OAuth login flow started for {}", provider),
|
||||
}]
|
||||
}
|
||||
Command::Unknown(cmd) => {
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
#[expect(dead_code)]
|
||||
pub mod sessions;
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use crate::app::state::types::ToastKind;
|
||||
|
||||
pub struct DeferredOp {
|
||||
pub kind: String,
|
||||
pub handler: Box<dyn FnOnce(&mut AppStateRest) + Send>,
|
||||
}
|
||||
|
||||
pub fn run_deferred(state: &mut AppStateRest, op: DeferredOp) {
|
||||
let kind = op.kind.clone();
|
||||
(op.handler)(state);
|
||||
state.push_toast(crate::app::state::types::Toast::new(
|
||||
ToastKind::Info,
|
||||
format!("deferred '{}' completed", kind),
|
||||
));
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
use crate::model::session::Session;
|
||||
|
||||
#[expect(dead_code)]
|
||||
pub struct SessionManager {
|
||||
pub current_id: String,
|
||||
pub base_dir: PathBuf,
|
||||
pub sessions: Vec<Session>,
|
||||
}
|
||||
|
||||
impl SessionManager {
|
||||
pub fn new(base_dir: PathBuf) -> Self {
|
||||
SessionManager {
|
||||
current_id: String::new(),
|
||||
base_dir,
|
||||
sessions: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_sessions(&mut self) {
|
||||
self.sessions = Session::list(&self.base_dir);
|
||||
}
|
||||
|
||||
pub fn find_by_id(&self, id: &str) -> Option<&Session> {
|
||||
self.sessions.iter().find(|s| s.id == id)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,2 @@
|
||||
#[expect(dead_code)]
|
||||
pub mod tools;
|
||||
#[expect(dead_code)]
|
||||
pub mod turn;
|
||||
|
||||
@@ -1,24 +1,3 @@
|
||||
use crate::tool::{ToolCtx, all_tools};
|
||||
use serde_json::Value;
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn execute_tool_call(name: &str, args: &Value, ctx: &ToolCtx) -> Result<String> {
|
||||
let tools = all_tools();
|
||||
for tool in &tools {
|
||||
if tool.name() == name {
|
||||
return tool.run(ctx, args);
|
||||
}
|
||||
}
|
||||
Err(anyhow::anyhow!("tool not found: {}", name))
|
||||
}
|
||||
|
||||
#[expect(dead_code)]
|
||||
pub fn execute_deferred_tool(name: &str, args: &Value, ctx: &ToolCtx) -> Result<String> {
|
||||
let tools = all_tools();
|
||||
for tool in &tools {
|
||||
if tool.name() == name {
|
||||
return tool.run(ctx, args);
|
||||
}
|
||||
}
|
||||
Err(anyhow::anyhow!("deferred tool not found: {}", name))
|
||||
}
|
||||
// Tool execution dispatch — superseded by inline per-tool call in
|
||||
// app::runtime::actions::execute_one_tool within the SubmitInput loop.
|
||||
// This module is preserved as a placeholder.
|
||||
|
||||
@@ -1,70 +1,4 @@
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use crate::app::runtime::actions::{Action, apply_action};
|
||||
|
||||
pub fn advance_turn(state: &mut AppStateRest) {
|
||||
if state.session_runtime.is_none() {
|
||||
return;
|
||||
}
|
||||
let rt = state.session_runtime.as_mut().unwrap();
|
||||
if rt.messages.is_empty() {
|
||||
return;
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(dead_code)]
|
||||
pub fn process_tools(state: &mut AppStateRest) {
|
||||
let tool_calls: Vec<_> = {
|
||||
let rt = match state.session_runtime.as_ref() {
|
||||
Some(r) => r,
|
||||
None => return,
|
||||
};
|
||||
rt.pending_tool_queue.clone()
|
||||
};
|
||||
if tool_calls.is_empty() {
|
||||
return;
|
||||
}
|
||||
for tool_call in &tool_calls {
|
||||
let _result = format!("processing tool: {}", tool_call.tool_name);
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(dead_code)]
|
||||
pub fn finish_tool_round(state: &mut AppStateRest) {
|
||||
let tool_count = {
|
||||
let rt = match state.session_runtime.as_ref() {
|
||||
Some(r) => r,
|
||||
None => return,
|
||||
};
|
||||
rt.tool_call_results.len()
|
||||
};
|
||||
if tool_count > 0 {
|
||||
let note = format!("{} tool calls completed", tool_count);
|
||||
apply_action(state, Action::SystemNote {
|
||||
kind: "tool_round".to_string(),
|
||||
message: note,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Stream module — superseded by the inline tool-calling loop in
|
||||
// app::runtime::actions (Action::SubmitInput / Tick pipeline).
|
||||
// This module is preserved as a placeholder; all previous content
|
||||
// has been removed since it duplicated logic now in actions/mod.rs.
|
||||
|
||||
+32
-4
@@ -1,10 +1,13 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use super::misc::{DirCache, InputState, MiscState, ScrollState};
|
||||
use super::runtime::SessionRuntime;
|
||||
use super::runtime::{SessionRuntime, TurnEvent};
|
||||
use super::types::{AgentMode, Origin, Toast, TranscriptCache};
|
||||
use crate::app::mcp::manager::McpManager;
|
||||
use crate::app::workflow::engine::WorkflowEngine;
|
||||
use crate::model::editlog::EditLog;
|
||||
use crate::model::settings::Settings;
|
||||
|
||||
@@ -38,6 +41,7 @@ pub struct AppStateRest {
|
||||
pub mode: AgentMode,
|
||||
pub settings: Settings,
|
||||
pub workspace_roots: Vec<PathBuf>,
|
||||
pub session_id: String,
|
||||
pub session_dir: PathBuf,
|
||||
pub memory_dir: PathBuf,
|
||||
pub download_dir: PathBuf,
|
||||
@@ -52,7 +56,10 @@ pub struct AppStateRest {
|
||||
pub scroll: ScrollState,
|
||||
pub input: InputState,
|
||||
pub misc: MiscState,
|
||||
pub pending_api_response: Arc<Mutex<Option<String>>>,
|
||||
pub turn_events: Arc<Mutex<VecDeque<TurnEvent>>>,
|
||||
pub turn_in_flight: Arc<Mutex<bool>>,
|
||||
pub workflow_engine: WorkflowEngine,
|
||||
pub mcp_manager: McpManager,
|
||||
pub dirty: bool,
|
||||
pub quit: bool,
|
||||
}
|
||||
@@ -63,19 +70,27 @@ impl AppStateRest {
|
||||
let download_dir = memory_dir.parent().unwrap_or(&memory_dir).join("downloads");
|
||||
let worktrees_dir = memory_dir.parent().unwrap_or(&memory_dir).join("worktrees");
|
||||
let dir_cache = DirCache::new();
|
||||
let session_id = session_dir
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
AppStateRest {
|
||||
mode: AgentMode::Normal,
|
||||
settings,
|
||||
workspace_roots,
|
||||
session_id,
|
||||
session_dir: session_dir.clone(),
|
||||
memory_dir,
|
||||
download_dir,
|
||||
worktrees_dir,
|
||||
current_dir: std::env::current_dir().unwrap_or_default(),
|
||||
pending_api_response: Arc::new(Mutex::new(None)),
|
||||
turn_events: Arc::new(Mutex::new(VecDeque::new())),
|
||||
turn_in_flight: Arc::new(Mutex::new(false)),
|
||||
dir_cache: Arc::new(RwLock::new(dir_cache)),
|
||||
edit_log: EditLog::new(&session_dir),
|
||||
session_runtime: Some(SessionRuntime::new(session_dir.clone())),
|
||||
workflow_engine: WorkflowEngine::new(),
|
||||
mcp_manager: McpManager::new(),
|
||||
sessions: Vec::new(),
|
||||
crons: Vec::new(),
|
||||
transcript_cache: TranscriptCache::new(200),
|
||||
@@ -110,7 +125,20 @@ impl AppStateRest {
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
pub fn store_base_dir(&self) -> std::path::PathBuf {
|
||||
self.session_dir.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| self.session_dir.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| self.session_dir.clone()))
|
||||
}
|
||||
|
||||
pub fn tool_ctx(&self) -> crate::tool::ToolCtx {
|
||||
self.tool_ctx_for(Origin::Main)
|
||||
}
|
||||
|
||||
pub fn tool_ctx_for(&self, origin: Origin) -> crate::tool::ToolCtx {
|
||||
crate::tool::ToolCtx {
|
||||
workspaces: self.workspace_roots.clone(),
|
||||
session_dir: self.session_dir.clone(),
|
||||
@@ -119,7 +147,7 @@ impl AppStateRest {
|
||||
worktrees_dir: self.worktrees_dir.clone(),
|
||||
dir_cache: self.dir_cache.clone(),
|
||||
internet_mode: self.settings.internet_mode.clone(),
|
||||
origin: Origin::Main,
|
||||
origin,
|
||||
graduated_checks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,23 @@ pub struct BashJobRef {
|
||||
pub running: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TurnEvent {
|
||||
AssistantMessage(crate::dto::chat::message::ChatMessage),
|
||||
ToolResult {
|
||||
tool_call_id: String,
|
||||
tool_name: String,
|
||||
output: String,
|
||||
is_error: bool,
|
||||
},
|
||||
SystemNote {
|
||||
kind: String,
|
||||
message: String,
|
||||
},
|
||||
Error(String),
|
||||
Done,
|
||||
}
|
||||
|
||||
impl SessionRuntime {
|
||||
pub fn new(session_dir: PathBuf) -> Self {
|
||||
SessionRuntime {
|
||||
|
||||
@@ -21,6 +21,15 @@ impl AgentMode {
|
||||
AgentMode::Yolo => "Yolo",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cycle(&self) -> AgentMode {
|
||||
match self {
|
||||
AgentMode::Normal => AgentMode::Auto,
|
||||
AgentMode::Auto => AgentMode::Plan,
|
||||
AgentMode::Plan => AgentMode::Yolo,
|
||||
AgentMode::Yolo => AgentMode::Normal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -139,3 +148,13 @@ pub enum Origin {
|
||||
SubAgent,
|
||||
Reviewer,
|
||||
}
|
||||
|
||||
impl Origin {
|
||||
pub fn tag(&self) -> String {
|
||||
match self {
|
||||
Origin::Main => "main".to_string(),
|
||||
Origin::SubAgent => "subagent".to_string(),
|
||||
Origin::Reviewer => "reviewer".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use tokio::sync::mpsc;
|
||||
use crate::dto::chat::message::ChatMessage;
|
||||
use crate::tool::{all_tools, tool_is_risky};
|
||||
use super::context::SubagentContext;
|
||||
use super::event::SubagentEvent;
|
||||
|
||||
@@ -21,6 +22,11 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
|
||||
let mut messages: Vec<ChatMessage> = Vec::new();
|
||||
messages.push(ChatMessage::system(ctx.system_prompt.clone()));
|
||||
|
||||
let tool_ctx = crate::tool::ToolCtx::builder()
|
||||
.session_dir(ctx.session_dir.clone())
|
||||
.origin(crate::app::state::types::Origin::SubAgent)
|
||||
.build();
|
||||
|
||||
let max_steps = ctx.max_steps.min(MAX_AGENT_STEPS);
|
||||
for step in 0..max_steps {
|
||||
let api_key = std::env::var("OPENROUTER_API_KEY").unwrap_or_default();
|
||||
@@ -55,16 +61,51 @@ pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> an
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
let tools = all_tools();
|
||||
for tool_name in &tool_calls {
|
||||
if !ctx.allowed_tools.is_empty() && !ctx.allowed_tools.contains(tool_name) {
|
||||
let explicitly_allowed = ctx.allowed_tools.contains(tool_name);
|
||||
let generally_allowed = ctx.allowed_tools.is_empty() || explicitly_allowed;
|
||||
|
||||
if !generally_allowed {
|
||||
let msg = format!("tool '{}' not allowed for this subagent", tool_name);
|
||||
messages.push(ChatMessage::tool_result("subagent".to_string(), msg));
|
||||
messages.push(ChatMessage::tool_result(tool_name.clone(), msg.clone()));
|
||||
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
||||
tool: tool_name.clone(),
|
||||
output: msg,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
||||
tool: tool_name.clone(),
|
||||
output: format!("{} executed", tool_name),
|
||||
});
|
||||
|
||||
if tool_is_risky(tool_name) && !explicitly_allowed {
|
||||
let msg = format!("risky tool '{}' requires explicit permission; not allowed for this subagent", tool_name);
|
||||
messages.push(ChatMessage::tool_result(tool_name.clone(), msg.clone()));
|
||||
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
||||
tool: tool_name.clone(),
|
||||
output: msg,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let result = match tools.iter().find(|t| t.name() == *tool_name) {
|
||||
Some(tool) => tool.run(&tool_ctx, &serde_json::json!({})),
|
||||
None => Err(anyhow::anyhow!("tool '{}' not found", tool_name)),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(output_text) => {
|
||||
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
||||
tool: tool_name.clone(),
|
||||
output: output_text,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!("tool '{}' failed: {}", tool_name, e);
|
||||
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
||||
tool: tool_name.clone(),
|
||||
output: msg,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = tx.blocking_send(SubagentEvent::StepCompleted {
|
||||
step,
|
||||
|
||||
@@ -12,6 +12,7 @@ pub enum Command {
|
||||
Clear,
|
||||
Save,
|
||||
LessonList,
|
||||
Login { provider: String },
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
@@ -47,6 +48,8 @@ pub fn parse_command(text: &str) -> Command {
|
||||
"/lesson" if arg1 == "list" || arg1 == "ls" => Command::LessonList,
|
||||
"/lesson" if !arg1.is_empty() => Command::LessonCreate(arg1.to_string()),
|
||||
"/lesson" => Command::LessonList,
|
||||
"/login" if !arg1.is_empty() => Command::Login { provider: arg1.to_string() },
|
||||
"/login" => Command::Login { provider: "openrouter".to_string() },
|
||||
_ => Command::Unknown(cmd.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
+32
-5
@@ -1,12 +1,13 @@
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use crate::app::mode;
|
||||
use crate::app::runtime::actions::Action;
|
||||
use crate::app::runtime::commands::apply_command;
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use crate::app::state::types::Overlay;
|
||||
use crate::controller::command::parse_command;
|
||||
|
||||
pub fn handle_key(key: KeyEvent, state: &AppStateRest) -> Vec<Action> {
|
||||
pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
|
||||
match key.code {
|
||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
vec![Action::QuitConfirm]
|
||||
@@ -57,11 +58,19 @@ pub fn handle_key(key: KeyEvent, state: &AppStateRest) -> Vec<Action> {
|
||||
KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
vec![Action::ToggleYoloArm]
|
||||
}
|
||||
KeyCode::Char('m') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
vec![Action::CycleAgentMode]
|
||||
}
|
||||
KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
vec![Action::OpenOverlay(Overlay::Bash)]
|
||||
}
|
||||
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
vec![Action::OpenOverlay(Overlay::SessionHub)]
|
||||
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& !key.modifiers.contains(KeyModifiers::SHIFT) =>
|
||||
{
|
||||
vec![Action::RefreshSessions, Action::OpenOverlay(Overlay::SessionHub)]
|
||||
}
|
||||
KeyCode::Char('S') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
vec![Action::OpenOverlay(Overlay::Security)]
|
||||
}
|
||||
KeyCode::Char('t') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
vec![Action::OpenOverlay(Overlay::Todo)]
|
||||
@@ -95,10 +104,28 @@ pub fn handle_key(key: KeyEvent, state: &AppStateRest) -> Vec<Action> {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_overlay_enter(state: &AppStateRest) -> Vec<Action> {
|
||||
fn handle_overlay_enter(state: &mut AppStateRest) -> Vec<Action> {
|
||||
match state.misc.overlay {
|
||||
Overlay::Bash => {
|
||||
let command = state.input.buffer.clone();
|
||||
mode::bash::handle_bash_submit(state, command);
|
||||
Vec::new()
|
||||
}
|
||||
Overlay::Settings => {
|
||||
mode::settings::cycle_internet_mode(&mut state.settings);
|
||||
state.dirty = true;
|
||||
Vec::new()
|
||||
}
|
||||
Overlay::Todo => {
|
||||
mode::todo::handle_todo_toggle(state);
|
||||
Vec::new()
|
||||
}
|
||||
Overlay::Security => {
|
||||
mode::security::handle_security_action(state, &Action::ToggleYoloArm);
|
||||
Vec::new()
|
||||
}
|
||||
Overlay::QuitConfirm => {
|
||||
vec![Action::ForceQuit]
|
||||
vec![mode::quit_confirm::handle_quit_confirm(true)]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
|
||||
@@ -2,5 +2,6 @@ pub mod client;
|
||||
pub mod conn;
|
||||
pub mod diff;
|
||||
pub mod frame;
|
||||
pub mod protocol;
|
||||
pub mod server;
|
||||
pub mod snapshot;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum KeyAction {
|
||||
Char(char),
|
||||
Enter,
|
||||
Escape,
|
||||
Backspace,
|
||||
Delete,
|
||||
Tab,
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
Home,
|
||||
End,
|
||||
PageUp,
|
||||
PageDown,
|
||||
Function(u8),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ClientRequest {
|
||||
Tick,
|
||||
KeyPress {
|
||||
key: KeyAction,
|
||||
ctrl: bool,
|
||||
alt: bool,
|
||||
shift: bool,
|
||||
},
|
||||
Submit(String),
|
||||
Resize(u16, u16),
|
||||
Close,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MessageEntry {
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
pub timestamp: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToastEntry {
|
||||
pub kind: String,
|
||||
pub message: String,
|
||||
pub created_at: i64,
|
||||
pub lifetime_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StatePayload {
|
||||
pub mode: String,
|
||||
pub session_id: String,
|
||||
pub messages: Vec<MessageEntry>,
|
||||
pub edit_count: u32,
|
||||
pub message_count: usize,
|
||||
pub overlay: Option<String>,
|
||||
pub toasts: Vec<ToastEntry>,
|
||||
pub dirty: bool,
|
||||
pub input_buffer: String,
|
||||
pub input_cursor: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum DaemonFrame {
|
||||
StateUpdate(Box<StatePayload>),
|
||||
StreamToken(String),
|
||||
SystemNote { kind: String, message: String },
|
||||
Closed,
|
||||
}
|
||||
+59
-17
@@ -1,42 +1,84 @@
|
||||
use std::net::TcpListener;
|
||||
use std::os::unix::net::UnixListener;
|
||||
use std::thread;
|
||||
use anyhow::Result;
|
||||
use super::conn::Connection;
|
||||
|
||||
enum ListenerKind {
|
||||
Tcp(TcpListener),
|
||||
Unix(UnixListener),
|
||||
}
|
||||
|
||||
pub struct IpcServer {
|
||||
listener: TcpListener,
|
||||
listener: ListenerKind,
|
||||
}
|
||||
|
||||
impl IpcServer {
|
||||
pub fn bind(addr: &str) -> Result<Self> {
|
||||
let listener = TcpListener::bind(addr)?;
|
||||
Ok(IpcServer { listener })
|
||||
Ok(IpcServer { listener: ListenerKind::Tcp(listener) })
|
||||
}
|
||||
|
||||
pub fn bind_unix(path: &str) -> Result<Self> {
|
||||
let _ = std::fs::remove_file(path);
|
||||
let listener = UnixListener::bind(path)?;
|
||||
Ok(IpcServer { listener: ListenerKind::Unix(listener) })
|
||||
}
|
||||
|
||||
pub fn accept(&self) -> Result<Connection> {
|
||||
let (stream, _addr) = self.listener.accept()?;
|
||||
stream.set_nodelay(true)?;
|
||||
Ok(Connection::Tcp(stream))
|
||||
match &self.listener {
|
||||
ListenerKind::Tcp(l) => {
|
||||
let (stream, _addr) = l.accept()?;
|
||||
stream.set_nodelay(true)?;
|
||||
Ok(Connection::Tcp(stream))
|
||||
}
|
||||
ListenerKind::Unix(l) => {
|
||||
let (stream, _addr) = l.accept()?;
|
||||
Ok(Connection::Unix(stream))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn accept_with_handler<F>(self, handler: F) -> thread::JoinHandle<()>
|
||||
where
|
||||
F: Fn(Connection) -> Result<()> + Send + 'static,
|
||||
{
|
||||
thread::spawn(move || {
|
||||
for stream in self.listener.incoming() {
|
||||
match stream {
|
||||
Ok(stream) => {
|
||||
if let Err(e) = handler(Connection::Tcp(stream)) {
|
||||
eprintln!("ipc handler error: {}", e);
|
||||
match self.listener {
|
||||
ListenerKind::Tcp(l) => {
|
||||
thread::spawn(move || {
|
||||
for stream in l.incoming() {
|
||||
match stream {
|
||||
Ok(s) => {
|
||||
let _ = s.set_nodelay(true);
|
||||
if let Err(e) = handler(Connection::Tcp(s)) {
|
||||
eprintln!("ipc handler error: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("ipc accept error: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("ipc accept error: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
ListenerKind::Unix(l) => {
|
||||
thread::spawn(move || {
|
||||
for stream in l.incoming() {
|
||||
match stream {
|
||||
Ok(s) => {
|
||||
if let Err(e) = handler(Connection::Unix(s)) {
|
||||
eprintln!("ipc handler error: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("ipc accept error: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+398
-658
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,9 @@ pub mod loopback;
|
||||
#[expect(dead_code)]
|
||||
pub mod manager;
|
||||
|
||||
#[expect(unused_imports)]
|
||||
pub use manager::{OAuthManager, OAuthConfig};
|
||||
#[expect(unused_imports)]
|
||||
pub use pkce::CodeVerifier;
|
||||
#[expect(unused_imports)]
|
||||
pub use loopback::LoopbackServer;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::dto::chat::message::ChatMessage;
|
||||
use crate::dto::openrouter::request::ToolDef;
|
||||
|
||||
pub struct OpenRouterClient {
|
||||
pub client: reqwest::blocking::Client,
|
||||
@@ -18,13 +20,22 @@ impl OpenRouterClient {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn chat(&self, messages: &[crate::dto::chat::message::ChatMessage]) -> Result<String> {
|
||||
pub fn chat(&self, messages: &[ChatMessage]) -> Result<String> {
|
||||
let response = self.chat_with_tools(messages, None)?;
|
||||
Ok(response.content.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub fn chat_with_tools(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
tools: Option<Vec<ToolDef>>,
|
||||
) -> Result<ChatMessage> {
|
||||
let req = crate::dto::openrouter::request::ChatRequest {
|
||||
model: self.model.clone(),
|
||||
messages: messages.to_vec(),
|
||||
max_tokens: Some(4096),
|
||||
temperature: Some(0.7),
|
||||
tools: None,
|
||||
tools,
|
||||
stream: Some(false),
|
||||
top_p: None,
|
||||
stop: None,
|
||||
@@ -43,11 +54,13 @@ impl OpenRouterClient {
|
||||
anyhow::bail!("OpenRouter API error {}: {}", status, body);
|
||||
}
|
||||
|
||||
let data: Value = resp.json()?;
|
||||
let content = data["choices"][0]["message"]["content"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
Ok(content)
|
||||
let data: crate::dto::openrouter::response::ChatResponse = resp.json()?;
|
||||
let message = data
|
||||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|c| c.message)
|
||||
.ok_or_else(|| anyhow::anyhow!("OpenRouter response had no choices"))?;
|
||||
Ok(message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
use serde_json::{json, Value};
|
||||
use anyhow::{Result, anyhow};
|
||||
use super::Tool;
|
||||
use super::ToolCtx;
|
||||
|
||||
pub struct BashOutput;
|
||||
|
||||
impl Tool for BashOutput {
|
||||
fn name(&self) -> &'static str {
|
||||
"bash_output"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Retrieve output from a background bash job by job_id"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"job_id": {
|
||||
"type": "string",
|
||||
"description": "Job ID returned by bash with run_in_background=true"
|
||||
}
|
||||
},
|
||||
"required": ["job_id"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let job_id = args.get("job_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("missing required argument: job_id"))?
|
||||
.to_string();
|
||||
match crate::app::bgbash::control::bash_output(&job_id) {
|
||||
Some(lines) => Ok(lines.join("\n")),
|
||||
None => Ok(format!("No new output from job '{}'", job_id)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct BashKill;
|
||||
|
||||
impl Tool for BashKill {
|
||||
fn name(&self) -> &'static str {
|
||||
"bash_kill"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Kill a background bash job by job_id"
|
||||
}
|
||||
|
||||
fn parameters(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"job_id": {
|
||||
"type": "string",
|
||||
"description": "Job ID returned by bash with run_in_background=true"
|
||||
}
|
||||
},
|
||||
"required": ["job_id"]
|
||||
})
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let job_id = args.get("job_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("missing required argument: job_id"))?
|
||||
.to_string();
|
||||
crate::app::bgbash::control::bash_kill(&job_id)?;
|
||||
Ok(format!("Killed background job '{}'", job_id))
|
||||
}
|
||||
}
|
||||
@@ -37,8 +37,8 @@ impl Tool for Download {
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
if ctx.internet_mode == crate::model::settings::InternetMode::Off {
|
||||
anyhow::bail!("internet access is disabled. Enable it in settings to use download.");
|
||||
if !ctx.internet_mode.can_download() {
|
||||
anyhow::bail!("download requires internet mode Full, current mode: {:?}", ctx.internet_mode);
|
||||
}
|
||||
let url = args.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
|
||||
@@ -29,8 +29,8 @@ impl Tool for Fetch {
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
if ctx.internet_mode == crate::model::settings::InternetMode::Off {
|
||||
anyhow::bail!("internet access is disabled. Enable it in settings to use fetch.");
|
||||
if !ctx.internet_mode.can_fetch() {
|
||||
anyhow::bail!("fetch requires internet mode Full, current mode: {:?}", ctx.internet_mode);
|
||||
}
|
||||
let url = args.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
|
||||
@@ -28,8 +28,8 @@ impl Tool for Search {
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
if ctx.internet_mode == crate::model::settings::InternetMode::Off {
|
||||
anyhow::bail!("internet access is disabled. Enable it in settings to use web_search.");
|
||||
if !ctx.internet_mode.can_search() {
|
||||
anyhow::bail!("web_search requires internet mode Full, current mode: {:?}", ctx.internet_mode);
|
||||
}
|
||||
let query = args.get("query")
|
||||
.and_then(|v| v.as_str())
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::path::PathBuf;
|
||||
use serde_json::Value;
|
||||
use anyhow::Result;
|
||||
|
||||
pub mod bash_tools;
|
||||
pub mod fs;
|
||||
pub mod git_cred;
|
||||
pub mod git_operator;
|
||||
@@ -28,6 +29,7 @@ pub struct GraduatedCheck {
|
||||
pub rule: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ToolCtx {
|
||||
pub workspaces: Vec<PathBuf>,
|
||||
pub session_dir: PathBuf,
|
||||
@@ -116,6 +118,8 @@ pub fn all_tools() -> Vec<Box<dyn Tool>> {
|
||||
Box::new(super::tool::fs::edit::Edit),
|
||||
Box::new(super::tool::search::Grep),
|
||||
Box::new(super::tool::search::Glob),
|
||||
Box::new(super::tool::bash_tools::BashOutput),
|
||||
Box::new(super::tool::bash_tools::BashKill),
|
||||
Box::new(super::tool::shell::Bash),
|
||||
Box::new(super::tool::git_operator::GitOperator),
|
||||
Box::new(super::tool::git_worktree::GitWorktree),
|
||||
@@ -124,6 +128,9 @@ pub fn all_tools() -> Vec<Box<dyn Tool>> {
|
||||
Box::new(super::tool::plan::PlanEnter),
|
||||
Box::new(super::tool::plan::PlanReady),
|
||||
Box::new(super::tool::workflow::WorkflowRun),
|
||||
Box::new(super::tool::internet::fetch::Fetch),
|
||||
Box::new(super::tool::internet::download::Download),
|
||||
Box::new(super::tool::internet::search::Search),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -131,6 +138,20 @@ pub fn tool_is_risky(name: &str) -> bool {
|
||||
matches!(name, "write" | "delete" | "edit" | "bash" | "git_operator")
|
||||
}
|
||||
|
||||
pub fn tool_defs(tools: &[Box<dyn Tool>]) -> Vec<crate::dto::openrouter::request::ToolDef> {
|
||||
tools
|
||||
.iter()
|
||||
.map(|t| crate::dto::openrouter::request::ToolDef {
|
||||
type_: "function".to_string(),
|
||||
function: crate::dto::openrouter::request::ToolFunctionDef {
|
||||
name: t.name().to_string(),
|
||||
description: t.description().to_string(),
|
||||
parameters: t.parameters(),
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub const DEFERRED_TOOLS: &[&str] = &[
|
||||
"read", "write", "edit", "bash", "grep", "glob",
|
||||
"git_operator", "git_worktree", "git_cred",
|
||||
|
||||
@@ -31,6 +31,10 @@ impl Tool for Bash {
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"description": "Timeout in milliseconds (default 120000, max 600000)"
|
||||
},
|
||||
"run_in_background": {
|
||||
"type": "boolean",
|
||||
"description": "Run the command in the background and return immediately with a job ID"
|
||||
}
|
||||
},
|
||||
"required": ["command"]
|
||||
@@ -47,6 +51,11 @@ impl Tool for Bash {
|
||||
let workspace_roots: Vec<&std::path::Path> = ctx.workspaces.iter().map(|p| p.as_path()).collect();
|
||||
crate::app::catastrophic::CatastrophicGuard::check_all(&cmd, &workspace_roots)
|
||||
.map_err(|e| anyhow!("catastrophic guard blocked: {}", e))?;
|
||||
let run_in_background = args.get("run_in_background").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
if run_in_background {
|
||||
let job = crate::app::bgbash::job::spawn_bash_job(cmd);
|
||||
return Ok(format!("Background job: {}", job.id));
|
||||
}
|
||||
let mut child = Command::new("bash")
|
||||
.arg("-c")
|
||||
.arg(&cmd)
|
||||
|
||||
+16
-3
@@ -32,10 +32,23 @@ impl Tool for WorkflowRun {
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let _script = args.get("script")
|
||||
let script_str = args.get("script")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("missing required argument: script"))?;
|
||||
let _workflow_args = args.get("args");
|
||||
Ok("workflow delegated to workflow engine".to_string())
|
||||
|
||||
let workflow_script: crate::app::workflow::script::WorkflowScript =
|
||||
serde_json::from_str(script_str)
|
||||
.map_err(|e| anyhow!("failed to parse workflow script: {}", e))?;
|
||||
|
||||
let workflow_args: std::collections::HashMap<String, String> = args.get("args")
|
||||
.and_then(|v| v.as_object())
|
||||
.map(|obj| {
|
||||
obj.iter().filter_map(|(k, v)| {
|
||||
v.as_str().map(|s| (k.clone(), s.to_string()))
|
||||
}).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
crate::app::workflow::engine::run_workflow(&workflow_script, &workflow_args)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state:
|
||||
);
|
||||
|
||||
let center_text = Span::styled(
|
||||
format!(" | STATUS: {} | PROTOCOL: ZERO-STUBS ACTIVE ", mode_indicator),
|
||||
format!(" | STATUS: {} | AGENT LOOP: LIVE ", mode_indicator),
|
||||
Style::default().fg(mode_color),
|
||||
);
|
||||
|
||||
|
||||
+11
-8
@@ -25,8 +25,9 @@ pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::st
|
||||
let tool_count = session_runtime.tool_call_results.len();
|
||||
let pending_count = session_runtime.pending_tool_queue.len();
|
||||
let bash_count = session_runtime.bash_jobs.len();
|
||||
let subagent_queue = session_runtime.subagent_queue;
|
||||
let edit_count = session_runtime.edit_count;
|
||||
|
||||
let agent_count = state.workflow_engine.agents.len();
|
||||
let findings_count = state.workflow_engine.findings.len();
|
||||
|
||||
let mut items = Vec::new();
|
||||
|
||||
@@ -54,17 +55,19 @@ pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::st
|
||||
))));
|
||||
}
|
||||
|
||||
if subagent_queue > 0 {
|
||||
if agent_count > 0 {
|
||||
items.push(ListItem::new(Line::from(Span::styled(
|
||||
format!(" Subagent queue: {}", subagent_queue),
|
||||
format!(" Agents: {}", agent_count),
|
||||
Style::default().fg(Theme::INFO),
|
||||
))));
|
||||
}
|
||||
|
||||
items.push(ListItem::new(Line::from(Span::styled(
|
||||
format!(" Edits: {}", edit_count),
|
||||
Style::default().fg(Theme::INFO),
|
||||
))));
|
||||
if findings_count > 0 {
|
||||
items.push(ListItem::new(Line::from(Span::styled(
|
||||
format!(" Findings: {}", findings_count),
|
||||
Style::default().fg(Theme::WARNING),
|
||||
))));
|
||||
}
|
||||
|
||||
let phase_status = match state.mode {
|
||||
crate::app::state::types::AgentMode::Auto => "Auto-running",
|
||||
|
||||
Reference in New Issue
Block a user