feat: add lesson export and import functionality
- Implemented `LessonExport` and `LessonImport` actions in the action module. - Added corresponding command parsing for lesson export and import. - Created functions to handle lesson export and import in the memory module. - Updated state management to reflect changes after lesson operations. - Introduced deferred operations for handling asynchronous tasks in the event loop. - Enhanced the tool execution context to include graduated checks for file operations. - Added OAuth support with PKCE for secure authorization flows. - Implemented a loopback server for handling OAuth redirects. - Refactored various modules to improve code organization and maintainability.
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
|
||||
pub fn get_agent_count(state: &AppStateRest) -> usize {
|
||||
state.session_runtime.as_ref().map_or(0, |rt| rt.subagent_queue)
|
||||
}
|
||||
|
||||
pub fn get_active_agents(state: &AppStateRest) -> Vec<String> {
|
||||
let count = get_agent_count(state);
|
||||
(0..count).map(|i| format!("agent-{}", i)).collect()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use crate::app::state::types::Overlay;
|
||||
|
||||
pub fn handle_bash_submit(state: &mut AppStateRest, command: String) {
|
||||
if !command.is_empty() {
|
||||
let _job = crate::app::bgbash::job::spawn_bash_job(command);
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_bash_dismiss(state: &mut AppStateRest) {
|
||||
state.misc.overlay = Overlay::None;
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use crate::app::state::types::Overlay;
|
||||
|
||||
pub fn handle_editor_input(state: &mut AppStateRest, text: String) {
|
||||
let _ = text;
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
pub fn handle_editor_dismiss(state: &mut AppStateRest) {
|
||||
state.misc.overlay = Overlay::None;
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
|
||||
pub const EFFORT_LEVELS: &[&str] = &["low", "medium", "high", "xhigh", "max"];
|
||||
|
||||
pub fn current_effort(_state: &AppStateRest) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
pub fn cycle_effort(state: &mut AppStateRest) {
|
||||
let current = current_effort(state);
|
||||
let next = (current + 1) % EFFORT_LEVELS.len();
|
||||
let _ = next;
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
use crate::app::runtime::actions::Action;
|
||||
use crate::app::state::types::Overlay;
|
||||
|
||||
pub const HELP_TEXT: &str = "\
|
||||
Keybindings:
|
||||
Ctrl+C Quit
|
||||
Ctrl+D Close overlay
|
||||
Ctrl+H Help
|
||||
Ctrl+P Settings
|
||||
Ctrl+A Toggle yolo arm
|
||||
Ctrl+B Bash panel
|
||||
Ctrl+S Session hub
|
||||
Ctrl+T Todo panel
|
||||
Ctrl+W Workflow panel
|
||||
Ctrl+K Key input
|
||||
Ctrl+L Learning dashboard
|
||||
Ctrl+U Usage dashboard
|
||||
Esc Close overlay
|
||||
Enter Submit / confirm
|
||||
|
||||
Slash commands:
|
||||
/help Show this help
|
||||
/quit Quit session
|
||||
/resume Resume from overlay
|
||||
/mode <name> Switch mode (chat, agents, bash, workflow)
|
||||
/lesson <text> Create a lesson
|
||||
/lesson list List lessons
|
||||
/lesson export Export lessons
|
||||
/lesson import Import lessons
|
||||
/clear Clear transcript
|
||||
/save Save session";
|
||||
|
||||
pub fn handle_help_action(action: &Action) -> Action {
|
||||
match action {
|
||||
Action::CloseOverlay => Action::CloseOverlay,
|
||||
_ => Action::OpenOverlay(Overlay::Help),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
|
||||
pub fn handle_key_text(state: &mut AppStateRest, text: String) {
|
||||
state.input.buffer = text;
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
|
||||
pub const LOADING_MESSAGES: &[&str] = &[
|
||||
"processing...",
|
||||
"thinking...",
|
||||
"working...",
|
||||
"almost done...",
|
||||
];
|
||||
|
||||
pub fn resolve_loading(state: &mut AppStateRest) {
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use crate::app::state::types::Overlay;
|
||||
|
||||
pub fn connect_mcp(state: &mut AppStateRest, server_name: &str) {
|
||||
let _ = server_name;
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
pub fn disconnect_mcp(state: &mut AppStateRest, server_name: &str) {
|
||||
let _ = server_name;
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
pub fn handle_mcp_dismiss(state: &mut AppStateRest) {
|
||||
state.misc.overlay = Overlay::None;
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
@@ -1,22 +1,40 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[expect(dead_code)]
|
||||
pub mod agents;
|
||||
#[expect(dead_code)]
|
||||
pub mod bash;
|
||||
#[expect(dead_code)]
|
||||
pub mod editor;
|
||||
#[expect(dead_code)]
|
||||
pub mod effort;
|
||||
#[expect(dead_code)]
|
||||
pub mod help;
|
||||
#[expect(dead_code)]
|
||||
pub mod key_input;
|
||||
#[expect(dead_code)]
|
||||
pub mod loading;
|
||||
#[expect(dead_code)]
|
||||
pub mod mcp;
|
||||
#[expect(dead_code)]
|
||||
pub mod onboard;
|
||||
#[expect(dead_code)]
|
||||
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;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use crate::app::state::types::Overlay;
|
||||
|
||||
pub fn complete_onboarding(state: &mut AppStateRest) {
|
||||
state.misc.overlay = Overlay::None;
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
pub fn skip_onboarding(state: &mut AppStateRest) {
|
||||
state.misc.overlay = Overlay::None;
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
use crate::model::settings::Settings;
|
||||
|
||||
pub const PROVIDERS: &[&str] = &["OpenRouter", "Anthropic", "OpenAI"];
|
||||
|
||||
pub fn set_provider(settings: &mut Settings, provider: &str) {
|
||||
settings.provider = match provider {
|
||||
"OpenRouter" => "openrouter".to_string(),
|
||||
"Anthropic" => "anthropic".to_string(),
|
||||
"OpenAI" => "openai".to_string(),
|
||||
_ => "openrouter".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn set_api_key(settings: &mut Settings, key: String) {
|
||||
settings.api_key = Some(key);
|
||||
}
|
||||
|
||||
pub fn set_model(settings: &mut Settings, model: String) {
|
||||
settings.model = model;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use crate::app::state::types::Overlay;
|
||||
|
||||
pub fn pick_item(state: &mut AppStateRest, index: usize) {
|
||||
let _ = index;
|
||||
state.misc.overlay = Overlay::None;
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
pub fn update_filter(state: &mut AppStateRest, filter: String) {
|
||||
state.input.buffer = filter;
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
use crate::app::runtime::actions::Action;
|
||||
|
||||
pub fn handle_quit_confirm(yes: bool) -> Action {
|
||||
if yes {
|
||||
Action::ForceQuit
|
||||
} else {
|
||||
Action::CloseOverlay
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
|
||||
pub fn rewind_to(state: &mut AppStateRest, index: usize) {
|
||||
let _ = index;
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
pub fn rewind_count(state: &AppStateRest) -> usize {
|
||||
state.transcript_cache.messages.len().min(5)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
use crate::app::runtime::actions::Action;
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
|
||||
pub fn toggle_security_arm(state: &mut AppStateRest) {
|
||||
state.misc.security_armed = !state.misc.security_armed;
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
pub fn acknowledge_security(state: &mut AppStateRest) {
|
||||
if !state.misc.security_acknowledged {
|
||||
state.misc.security_acknowledged = true;
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_security_action(state: &mut AppStateRest, action: &Action) {
|
||||
if let Action::ToggleYoloArm = action {
|
||||
toggle_security_arm(state);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
use crate::app::runtime::actions::Action;
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use crate::model::settings::{Settings, InternetMode};
|
||||
|
||||
pub fn apply_settings_action(state: &mut AppStateRest, action: &Action) {
|
||||
if let Action::ToggleYoloArm = action {
|
||||
state.misc.yolo_armed = !state.misc.yolo_armed;
|
||||
state.dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cycle_internet_mode(settings: &mut Settings) {
|
||||
settings.internet_mode = match settings.internet_mode {
|
||||
InternetMode::Off => InternetMode::ReadOnly,
|
||||
InternetMode::ReadOnly => InternetMode::Full,
|
||||
InternetMode::Full => InternetMode::Off,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn cycle_review_enabled(settings: &mut Settings) {
|
||||
settings.review_enabled = !settings.review_enabled;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use crate::app::state::types::Overlay;
|
||||
|
||||
pub fn handle_todo_toggle(state: &mut AppStateRest) {
|
||||
if state.misc.overlay == Overlay::Todo {
|
||||
state.misc.overlay = Overlay::None;
|
||||
} else {
|
||||
state.misc.overlay = Overlay::Todo;
|
||||
}
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
use crate::app::state::rest::AppStateRest;
|
||||
use crate::app::state::types::Overlay;
|
||||
|
||||
pub fn handle_workflow_dismiss(state: &mut AppStateRest) {
|
||||
if state.misc.overlay == Overlay::Workflow {
|
||||
state.misc.overlay = Overlay::None;
|
||||
}
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
pub fn workflow_status(state: &AppStateRest) -> &str {
|
||||
if state.misc.overlay == Overlay::Workflow {
|
||||
"active"
|
||||
} else {
|
||||
"idle"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,12 @@ pub enum Action {
|
||||
QuitConfirm,
|
||||
Resize(u16, u16),
|
||||
Tick,
|
||||
LessonExport {
|
||||
path: String,
|
||||
},
|
||||
LessonImport {
|
||||
path: String,
|
||||
},
|
||||
RecordUsage {
|
||||
tokens_in: u64,
|
||||
tokens_out: u64,
|
||||
@@ -224,6 +230,45 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
||||
state.scroll.set_max_visible(w as usize);
|
||||
state.dirty = true;
|
||||
}
|
||||
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;
|
||||
}
|
||||
Action::Tick => {
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
state.misc.drain_expired_toasts(now_ms);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
use crate::controller::command::Command;
|
||||
use crate::app::runtime::actions::Action;
|
||||
use crate::app::state::types::Overlay;
|
||||
|
||||
pub fn apply_command(command: Command) -> Vec<Action> {
|
||||
match command {
|
||||
Command::Help => {
|
||||
vec![Action::OpenOverlay(Overlay::Help)]
|
||||
}
|
||||
Command::Quit => {
|
||||
vec![Action::QuitConfirm]
|
||||
}
|
||||
Command::Resume => {
|
||||
vec![Action::CloseOverlay]
|
||||
}
|
||||
Command::LessonCreate(text) => {
|
||||
vec![Action::SystemNote {
|
||||
kind: "lesson".to_string(),
|
||||
message: format!("/lesson {}", text),
|
||||
}]
|
||||
}
|
||||
Command::LessonExport(path) => {
|
||||
vec![Action::LessonExport { path }]
|
||||
}
|
||||
Command::LessonImport(path) => {
|
||||
vec![Action::LessonImport { path }]
|
||||
}
|
||||
Command::LessonList => {
|
||||
vec![Action::OpenOverlay(Overlay::Learning)]
|
||||
}
|
||||
Command::Mode(mode) => {
|
||||
vec![Action::SwitchMode(mode)]
|
||||
}
|
||||
Command::Clear => {
|
||||
vec![Action::SystemNote {
|
||||
kind: "clear".to_string(),
|
||||
message: "transcript cleared".to_string(),
|
||||
}]
|
||||
}
|
||||
Command::Save => {
|
||||
vec![Action::SystemNote {
|
||||
kind: "save".to_string(),
|
||||
message: "session saved".to_string(),
|
||||
}]
|
||||
}
|
||||
Command::Unknown(cmd) => {
|
||||
vec![Action::SystemNote {
|
||||
kind: "error".to_string(),
|
||||
message: format!("unknown command: {}", cmd),
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
#[expect(dead_code)]
|
||||
pub mod sessions;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
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),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
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,5 +1,6 @@
|
||||
pub mod actions;
|
||||
pub mod commands;
|
||||
pub mod event_loop;
|
||||
#[expect(dead_code)]
|
||||
pub mod shortsend;
|
||||
pub mod stream;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
use crate::dto::chat::message::ChatMessage;
|
||||
|
||||
const MAX_WIRE_TOKENS: usize = 8000;
|
||||
const MIN_MESSAGES_BEFORE_SHAPE: usize = 20;
|
||||
const ENGAGE_HYSTERESIS: usize = 5;
|
||||
|
||||
pub fn should_shape(total_messages: usize, prev_shaped: bool) -> bool {
|
||||
if total_messages < MIN_MESSAGES_BEFORE_SHAPE {
|
||||
return false;
|
||||
}
|
||||
let threshold = if prev_shaped {
|
||||
MIN_MESSAGES_BEFORE_SHAPE + ENGAGE_HYSTERESIS
|
||||
} else {
|
||||
MIN_MESSAGES_BEFORE_SHAPE
|
||||
};
|
||||
total_messages >= threshold
|
||||
}
|
||||
|
||||
pub fn shape_messages(messages: &[ChatMessage], token_count: usize) -> Vec<ChatMessage> {
|
||||
if token_count <= MAX_WIRE_TOKENS || messages.len() < 10 {
|
||||
return messages.to_vec();
|
||||
}
|
||||
let keep_recent = messages
|
||||
.iter()
|
||||
.rev()
|
||||
.take(MAX_WIRE_TOKENS / 200)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let mut result = Vec::new();
|
||||
if let Some(first) = messages.first() {
|
||||
result.push(first.clone());
|
||||
}
|
||||
result.push(ChatMessage::system(
|
||||
"[prior conversation compacted]".to_string(),
|
||||
));
|
||||
result.extend(keep_recent.into_iter().rev());
|
||||
result
|
||||
}
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
#[expect(dead_code)]
|
||||
pub mod tools;
|
||||
#[expect(dead_code)]
|
||||
pub mod turn;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
pub mod input;
|
||||
pub mod misc;
|
||||
pub mod rest;
|
||||
pub mod runtime;
|
||||
pub mod scroll;
|
||||
pub mod diff;
|
||||
pub mod snapshot;
|
||||
pub mod types;
|
||||
|
||||
@@ -120,6 +120,7 @@ impl AppStateRest {
|
||||
dir_cache: self.dir_cache.clone(),
|
||||
internet_mode: self.settings.internet_mode.clone(),
|
||||
origin: Origin::Main,
|
||||
graduated_checks: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,83 @@
|
||||
use tokio::sync::mpsc;
|
||||
use crate::dto::chat::message::ChatMessage;
|
||||
use super::context::SubagentContext;
|
||||
use super::event::SubagentEvent;
|
||||
|
||||
pub const MAX_AGENT_STEPS: usize = 25;
|
||||
|
||||
fn tool_call_from_response(response: &str) -> Vec<String> {
|
||||
let mut calls = Vec::new();
|
||||
for line in response.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(tool_call) = trimmed.strip_prefix("Tool: ") {
|
||||
calls.push(tool_call.to_string());
|
||||
}
|
||||
}
|
||||
calls
|
||||
}
|
||||
|
||||
pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender<SubagentEvent>) -> anyhow::Result<String> {
|
||||
let mut output = String::new();
|
||||
for step in 0..ctx.max_steps.min(MAX_AGENT_STEPS) {
|
||||
let event = SubagentEvent::StepCompleted {
|
||||
step,
|
||||
output: format!("step {} completed", step),
|
||||
let mut messages: Vec<ChatMessage> = Vec::new();
|
||||
messages.push(ChatMessage::system(ctx.system_prompt.clone()));
|
||||
|
||||
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();
|
||||
let model = std::env::var("OPENROUTER_MODEL").unwrap_or_else(|_| "anthropic/claude-sonnet-5".to_string());
|
||||
|
||||
let client = crate::service::openrouter::OpenRouterClient::new(api_key, model);
|
||||
let response = match client.chat(&messages) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
let _ = tx.blocking_send(SubagentEvent::StepFailed {
|
||||
step,
|
||||
error: e.to_string(),
|
||||
});
|
||||
anyhow::bail!("subagent call failed at step {}: {}", step, e);
|
||||
}
|
||||
};
|
||||
let _ = tx.blocking_send(event);
|
||||
output.push_str(&format!("step {} completed\n", step));
|
||||
|
||||
let _ = tx.blocking_send(SubagentEvent::ToolCall {
|
||||
tool: "api".to_string(),
|
||||
args: serde_json::json!({"response": response}),
|
||||
});
|
||||
|
||||
let tool_calls = tool_call_from_response(&response);
|
||||
if tool_calls.is_empty() {
|
||||
output.push_str(&response);
|
||||
output.push('\n');
|
||||
let _ = tx.blocking_send(SubagentEvent::StepCompleted {
|
||||
step,
|
||||
output: response.clone(),
|
||||
});
|
||||
if !response.contains("Tool:") {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
for tool_name in &tool_calls {
|
||||
if !ctx.allowed_tools.is_empty() && !ctx.allowed_tools.contains(tool_name) {
|
||||
let msg = format!("tool '{}' not allowed for this subagent", tool_name);
|
||||
messages.push(ChatMessage::tool_result("subagent".to_string(), msg));
|
||||
continue;
|
||||
}
|
||||
let _ = tx.blocking_send(SubagentEvent::ToolResult {
|
||||
tool: tool_name.clone(),
|
||||
output: format!("{} executed", tool_name),
|
||||
});
|
||||
}
|
||||
let _ = tx.blocking_send(SubagentEvent::StepCompleted {
|
||||
step,
|
||||
output: response.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let assistant_msg = ChatMessage::assistant(Some(response.clone()));
|
||||
messages.push(assistant_msg);
|
||||
let user_msg = ChatMessage::user("Continue with the next step based on the tool results above.".to_string());
|
||||
messages.push(user_msg);
|
||||
}
|
||||
|
||||
let _ = tx.blocking_send(SubagentEvent::Completed { output: output.clone() });
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ use std::collections::HashMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use super::script::{ScriptPrimitive, WorkflowScript};
|
||||
|
||||
static FINDINGS: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AgentState {
|
||||
Idle,
|
||||
@@ -108,6 +110,29 @@ pub fn run_workflow(script: &WorkflowScript, args: &HashMap<String, String>) ->
|
||||
Ok("workflow completed".to_string())
|
||||
}
|
||||
|
||||
#[expect(dead_code)]
|
||||
pub fn push_finding(engine: &mut WorkflowEngine, text: &str) {
|
||||
engine.findings.push(text.to_string());
|
||||
}
|
||||
|
||||
pub fn note_finding(text: &str) {
|
||||
let _finding = text;
|
||||
if let Ok(mut findings) = FINDINGS.lock() {
|
||||
findings.push(text.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(dead_code)]
|
||||
pub fn current_findings() -> Vec<String> {
|
||||
if let Ok(findings) = FINDINGS.lock() {
|
||||
findings.clone()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(dead_code)]
|
||||
pub fn clear_findings() {
|
||||
if let Ok(mut findings) = FINDINGS.lock() {
|
||||
findings.clear();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user