diff --git a/cats/__pycache__/cat.cpython-314.pyc b/cats/__pycache__/cat.cpython-314.pyc index 645943d..f32bc4f 100644 Binary files a/cats/__pycache__/cat.cpython-314.pyc and b/cats/__pycache__/cat.cpython-314.pyc differ diff --git a/src/domain/agent_mode.rs b/src/domain/agent_mode.rs index 4b9054e..6e20f3e 100644 --- a/src/domain/agent_mode.rs +++ b/src/domain/agent_mode.rs @@ -2,8 +2,9 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum AgentModeType { - Build, // Full toolset for implementation - Plan, // Read-only toolset for planning + Build, // Full toolset for implementation + Plan, // Read-only toolset for planning + BuildFromPlan, // Auto-triggered: works through TODO list items sequentially } impl AgentModeType { @@ -11,6 +12,7 @@ impl AgentModeType { match self { AgentModeType::Build => "build", AgentModeType::Plan => "plan", + AgentModeType::BuildFromPlan => "build_from_plan", } } @@ -18,6 +20,7 @@ impl AgentModeType { match value { "plan" | "PLAN" | "Plan" => AgentModeType::Plan, "build" | "BUILD" | "Build" => AgentModeType::Build, + "build_from_plan" | "BUILD_FROM_PLAN" => AgentModeType::BuildFromPlan, _ => AgentModeType::Build, } } diff --git a/src/domain/prompting/general.rs b/src/domain/prompting/general.rs index d0f77f6..a33cc55 100644 --- a/src/domain/prompting/general.rs +++ b/src/domain/prompting/general.rs @@ -35,6 +35,9 @@ pub fn get_system_prompt(model_type: ModelType, agent_mode: AgentModeType) -> St if agent_mode == AgentModeType::Plan { return _system_prompt_for_plan(model_type); } + if agent_mode == AgentModeType::BuildFromPlan { + return _system_prompt_for_build_from_plan(model_type); + } if model_type == ModelType::OpenAI { format!( "You are Drastis, a coding agent. \n\ @@ -57,9 +60,11 @@ fn _system_prompt_for_plan(model_type: ModelType) -> String { format!( "You are Drastis, a coding agent. \n\ You're in the Plan Mode! Use tools to gather all required information to make a detailed plan of what user wants to achieve. \n\ -Ask the user, if there any ambiguities, and clarify what needs to be done if the instructions are not clear. \n\ -When you gathered the information, use the `update_todo_list` tool to create the TODO list and ask the user if he would like to make any changes to the TODO list or is he ready to implement the plan. \n\ -You're writing plan for yourself, so make it prompt-like, you will be executing this plan, once the user confirms it. \n\ +Ask the user, if there're any ambiguities, and clarify what needs to be done if the instructions are not clear. \n\ +When you gathered the information, use the `update_todo_list` tool to create the TODO list and ask the user if he would like to make any changes to the TODO list. \n\ +The user can see the TODO list in the interface once `update_todo_list` tool is used, so don't repeat it it response - respond with a general, summarized approach. \n\ +Do not put into the TODO list the discovery and clarification steps, only the actions that need to be taken once the user confirms the plan. \n\ +You're writing plan for yourself, so make it prompt-like, the end-result while you're in the Plan Mode is a comprehensive TODO list. \n\ When using tools, pass JSON arguments that match their parameters. \n\ Runtime: os={}, shell={}.", os_name, shell_name @@ -76,9 +81,35 @@ Runtime: os={}, shell={}.", } } +fn _system_prompt_for_build_from_plan(model_type: ModelType) -> String { + let (os_name, shell_name) = get_runtime_environment(); + if model_type == ModelType::OpenAI { + format!( + "You are Drastis, a coding agent executing a TODO list. \n\ +You will receive one TODO item at a time via the user prompt. \n\ +Before starting, mark the item as `in_progress` if it's still `pending`. Accomplish what is described in the current item using available tools, then mark it as `completed` via `update_todo_list` (preserve other items' statuses). \n\ +After marking an item complete, continue working on the next pending item without stopping. \n\ +When all items are done, stop without calling any more tools - just tell the user that you're done, and if you encountered any detours from the original plan. \n\ +When using tools, pass JSON arguments that match their parameters. \n\ +Runtime: os={}, shell={}.", + os_name, shell_name + ) + } else { + format!( + "You are Drastis, a coding agent executing a TODO list. \n\ +You will receive one TODO item at a time via the user prompt. \n\ +Complete the current item using available tools, then mark it as `completed` via `update_todo_list` (preserve other items' statuses). \n\ +After marking an item complete, continue working on the next pending item without stopping. \n\ +When all items are done, stop without calling any more tools. \n\ +Runtime: os={}, shell={}.", + os_name, shell_name + ) + } +} + pub fn format_todo_list_message(todo_content: &str) -> String { format!( - "## Current Session TODO List\n\nBelow is the current plan/TODO list for this session:\n\n```json\n{}\n```\n\nMake sure to update the TODO list as you make progress.", + "## Current Session TODO List\n\nBelow is the current plan/TODO list for this session:\n\n```json\n{}\n```\n", todo_content ) } diff --git a/src/domain/prompting/user.rs b/src/domain/prompting/user.rs index 5559a95..b966975 100644 --- a/src/domain/prompting/user.rs +++ b/src/domain/prompting/user.rs @@ -1,8 +1,14 @@ +use crate::domain::AgentModeType; use crate::domain::ModelType; use crate::domain::session::Request; +use crate::domain::todo::TodoListStatus; /// Format request history and current request as a complete prompt pub fn get_user_prompt(_model_type: ModelType, request: &dyn Request) -> String { + if request.mode() == AgentModeType::BuildFromPlan { + return _get_build_from_plan_prompt(request); + } + let mut formatted = String::new(); // Add history section @@ -25,4 +31,18 @@ pub fn get_user_prompt(_model_type: ModelType, request: &dyn Request) -> String formatted.push_str(&format!("CURRENT REQUEST: {}", request.current_request())); formatted +} + +fn _get_build_from_plan_prompt(request: &dyn Request) -> String { + if let Some(todo_list) = request.get_session_plan() { + for item in &todo_list.items { + if item.status != TodoListStatus::Completed { + return format!( + "Title: {}\n\nDescription:\n{}", + item.title, item.description + ); + } + } + } + "All TODO items are completed.".to_string() } \ No newline at end of file diff --git a/src/domain/session/service.rs b/src/domain/session/service.rs index 907330f..72533c0 100644 --- a/src/domain/session/service.rs +++ b/src/domain/session/service.rs @@ -8,7 +8,7 @@ use crate::infrastructure::AgentToUiEvent; use crate::infrastructure::InferenceEngine; use crate::repository::{ ModelsRepository, SessionRequestStepsRepository, SessionRequestsRepository, - UserSettingsRepository, + TodoListRepository, UserSettingsRepository, }; use crossbeam_channel::Sender; use std::sync::Arc; @@ -86,10 +86,37 @@ impl SessionService { let request_settings = UserSettings::from(settings_row.clone()).with_current_model_name(model_name); + // Safeguard: if BuildFromPlan is requested but no valid TODO list exists, + // fall back to Build mode to avoid a broken state. + let effective_mode = if mode == crate::domain::AgentModeType::BuildFromPlan { + let has_pending = { + let conn = self.conn.get().map_err(|e| { + ServiceError::Repository(format!("Failed to get database connection: {}", e)) + })?; + let repo = TodoListRepository::new(&*conn); + match repo.get_by_session(session.id()) { + Ok(Some(row)) => { + match serde_json::from_str::(&row.content) { + Ok(todo_list) => !todo_list.is_completed() && !todo_list.items.is_empty(), + Err(_) => false, + } + } + _ => false, + } + }; + if has_pending { + crate::domain::AgentModeType::BuildFromPlan + } else { + crate::domain::AgentModeType::Build + } + } else { + mode + }; + // Create a new session request let requests_repo = SessionRequestsRepository::new(self.conn.clone()); let request_row = requests_repo - .create(session.id(), settings_row.id, prompt, mode) + .create(session.id(), settings_row.id, prompt, effective_mode) .map_err(|e| ServiceError::Repository(e))?; let request_id = request_row.id; @@ -101,7 +128,7 @@ impl SessionService { session_with_request.set_current_request(prompt.to_string()); session_with_request.set_current_images(images.to_vec()); session_with_request.set_current_user_settings(Some(request_settings)); - session_with_request.set_current_mode(mode); + session_with_request.set_current_mode(effective_mode); session_with_request.set_conn(self.conn.clone()); // Run the workflow @@ -110,7 +137,7 @@ impl SessionService { .run_using_bt(&mut session_with_request, cancel) } else { self.workflow - .run(&mut session_with_request, cancel, 128, mode) + .run(&mut session_with_request, cancel, 128, effective_mode) .map_err(ServiceError::Workflow)?; Ok(self.workflow.get_chain().clone()) }; diff --git a/src/domain/session/session.rs b/src/domain/session/session.rs index b395470..ec349e5 100644 --- a/src/domain/session/session.rs +++ b/src/domain/session/session.rs @@ -130,6 +130,12 @@ impl Request for Session { fn get_history_steps(&self) -> Vec { use crate::repository::SessionRequestStepsRepository; + // BuildFromPlan keeps context minimal — TODO list (with statuses) is + // already included as a system message by the chain's todo_list field. + if self.current_mode == AgentModeType::BuildFromPlan { + return Vec::new(); + } + // Return empty if no connection available let conn = match &self.conn { Some(c) => c.clone(), diff --git a/src/domain/tools/update_todo_list.rs b/src/domain/tools/update_todo_list.rs index b5835c8..89ab92e 100644 --- a/src/domain/tools/update_todo_list.rs +++ b/src/domain/tools/update_todo_list.rs @@ -142,8 +142,9 @@ impl Tool for UpdateTodoList { } fn desc(&self) -> String { - "Updates the TODO list for the current session. \ - Use this in Plan mode to create or update TODO items after exploring the codebase. For every use, provide full TODO list, as it will replace the existing list." +"Use this in Plan mode to create or update TODO items after exploring the codebase. For every use, provide full TODO list, as it will replace the existing list.\n\ +If you're not in Plan mode, only update statuses of TODO items as you finish them. +" .to_string() } diff --git a/src/domain/workflow/events.rs b/src/domain/workflow/events.rs deleted file mode 100644 index c54792c..0000000 --- a/src/domain/workflow/events.rs +++ /dev/null @@ -1,8 +0,0 @@ -use crate::domain::tools::FileChange; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FileChangesEvent { - pub request_id: i64, - pub changes: Vec, -} diff --git a/src/domain/workflow/workflow.rs b/src/domain/workflow/workflow.rs index 1c0eb22..e39e848 100644 --- a/src/domain/workflow/workflow.rs +++ b/src/domain/workflow/workflow.rs @@ -15,6 +15,7 @@ use openai_agents_tracing::{SpanKind, TracingFacade}; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use crossbeam_channel::Sender; +use crate::domain::AgentModeType; /// Main workflow orchestrator that runs LLM-driven coding tasks /// Implements an eternal agent loop that: @@ -64,12 +65,13 @@ impl Workflow { request: &mut dyn Request, cancel: &CancellationToken, max_tool_calls: usize, - mode: crate::domain::AgentModeType, + mode: AgentModeType, ) -> Result<(), Error> { // Select toolset based on mode let toolset_type = match mode { - crate::domain::AgentModeType::Build => ToolsetType::All, - crate::domain::AgentModeType::Plan => ToolsetType::Discover, + AgentModeType::Build => ToolsetType::All, + AgentModeType::Plan => ToolsetType::Discover, + AgentModeType::BuildFromPlan => ToolsetType::All, }; // Get session_id for tools that need it diff --git a/src/infrastructure/api_clients/openai/dto/request.rs b/src/infrastructure/api_clients/openai/dto/request.rs index 3d520d7..a627031 100644 --- a/src/infrastructure/api_clients/openai/dto/request.rs +++ b/src/infrastructure/api_clients/openai/dto/request.rs @@ -183,7 +183,7 @@ impl InputDto { content: vec![InputContent::text(todo_message)], role: ROLE_SYSTEM.to_string(), kind: "message".to_string(), - status: "in_progress".to_string(), + status: "completed".to_string(), }; result.push(todo_input); } diff --git a/src/infrastructure/cli/components/bottom_info_panel.rs b/src/infrastructure/cli/components/bottom_info_panel.rs index e5b1d3e..47e14e4 100644 --- a/src/infrastructure/cli/components/bottom_info_panel.rs +++ b/src/infrastructure/cli/components/bottom_info_panel.rs @@ -11,6 +11,7 @@ pub fn render(frame: &mut Frame, area: Rect, state: &UiState, theme: Theme) { let (mode_name, mode_color) = match state.agent_mode { AgentModeType::Build => ("BUILD", theme.info_text), // Blue AgentModeType::Plan => ("PLAN", Color::LightGreen), + AgentModeType::BuildFromPlan => ("BUILD FROM PLAN", Color::LightYellow), }; // Determine hint based on current mode diff --git a/src/infrastructure/cli/components/left_half_body.rs b/src/infrastructure/cli/components/left_container.rs similarity index 100% rename from src/infrastructure/cli/components/left_half_body.rs rename to src/infrastructure/cli/components/left_container.rs diff --git a/src/infrastructure/cli/components/mod.rs b/src/infrastructure/cli/components/mod.rs index 4403339..9624544 100644 --- a/src/infrastructure/cli/components/mod.rs +++ b/src/infrastructure/cli/components/mod.rs @@ -3,10 +3,10 @@ pub mod bottom_info_panel; pub mod commands_menu; pub mod header_panel; pub mod input; -pub mod left_half_body; // Conversation panel (rendered on the left) +pub mod left_container; // Conversation panel (rendered on the left) pub mod loading_bar; pub mod permissions; pub mod popup_container; pub mod request_indicator; -pub mod right_half_body; // TODO/file-changes panel (rendered on the right) +pub mod right_container; // TODO/file-changes panel (rendered on the right) pub mod settings_panel; diff --git a/src/infrastructure/cli/components/right_half_body/container.rs b/src/infrastructure/cli/components/right_container/container.rs similarity index 100% rename from src/infrastructure/cli/components/right_half_body/container.rs rename to src/infrastructure/cli/components/right_container/container.rs diff --git a/src/infrastructure/cli/components/right_half_body/file_changes.rs b/src/infrastructure/cli/components/right_container/file_changes.rs similarity index 100% rename from src/infrastructure/cli/components/right_half_body/file_changes.rs rename to src/infrastructure/cli/components/right_container/file_changes.rs diff --git a/src/infrastructure/cli/components/right_half_body/mod.rs b/src/infrastructure/cli/components/right_container/mod.rs similarity index 100% rename from src/infrastructure/cli/components/right_half_body/mod.rs rename to src/infrastructure/cli/components/right_container/mod.rs diff --git a/src/infrastructure/cli/components/right_half_body/todo_list.rs b/src/infrastructure/cli/components/right_container/todo_list.rs similarity index 100% rename from src/infrastructure/cli/components/right_half_body/todo_list.rs rename to src/infrastructure/cli/components/right_container/todo_list.rs diff --git a/src/infrastructure/cli/controls.rs b/src/infrastructure/cli/controls.rs index 9d81f73..c0f776c 100644 --- a/src/infrastructure/cli/controls.rs +++ b/src/infrastructure/cli/controls.rs @@ -210,7 +210,7 @@ fn handle_normal_key( } } KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => { - // Enter right_half_body review mode for whatever is displayed + // Enter right_container review mode for whatever is displayed let has_file_changes = state.file_changes.is_some() && !state.file_changes.as_ref().unwrap().changes.is_empty(); let has_todo_list = state.todo_list.is_some() @@ -234,7 +234,7 @@ fn handle_normal_key( } KeyCode::BackTab => { // BackTab = Shift+Tab on most terminals - // Enter right_half_body review mode for whatever is displayed + // Enter right_container review mode for whatever is displayed let has_file_changes = state.file_changes.is_some() && !state.file_changes.as_ref().unwrap().changes.is_empty(); let has_todo_list = state.todo_list.is_some() @@ -257,10 +257,26 @@ fn handle_normal_key( return Ok(()); } KeyCode::Tab if !key.modifiers.contains(KeyModifiers::SHIFT) => { - // Toggle agent mode between Build and Plan + // Toggle agent mode. Plan with a pending TODO list → BuildFromPlan; + // BuildFromPlan escapes back to Build. state.agent_mode = match state.agent_mode { AgentModeType::Build => AgentModeType::Plan, - AgentModeType::Plan => AgentModeType::Build, + AgentModeType::Plan => { + let has_pending = state + .todo_list + .as_ref() + .map(|list| { + !list.items.is_empty() + && list.items.iter().any(|item| item.status != "completed") + }) + .unwrap_or(false); + if has_pending { + AgentModeType::BuildFromPlan + } else { + AgentModeType::Build + } + } + AgentModeType::BuildFromPlan => AgentModeType::Build, }; } KeyCode::Esc if !state.attached_images.is_empty() => { @@ -624,6 +640,10 @@ fn handle_file_changes_key( // Return to Normal mode state.mode = UiMode::Normal; } + KeyCode::Left => { + // Return to Normal mode + state.mode = UiMode::Normal; + } KeyCode::Up => { if *selected_file > 0 { *selected_file -= 1; @@ -652,7 +672,7 @@ fn handle_file_changes_key( .unwrap_or(0); match key.code { - KeyCode::Esc | KeyCode::Backspace => { + KeyCode::Esc | KeyCode::Left => { // Return to FileList view *view_mode = FileChangesViewMode::FileList; *scroll_offset = 0; @@ -1252,6 +1272,7 @@ fn open_mode_popup(state: &mut UiState) { let selected = match state.agent_mode { AgentModeType::Build => 0, AgentModeType::Plan => 1, + AgentModeType::BuildFromPlan => 0, }; state.mode = UiMode::Popup(PopupState::ModeSelect { selected }); } diff --git a/src/infrastructure/cli/repl.rs b/src/infrastructure/cli/repl.rs index 6af534e..a93e15d 100644 --- a/src/infrastructure/cli/repl.rs +++ b/src/infrastructure/cli/repl.rs @@ -1,4 +1,4 @@ -use crate::domain::ModelType; +use crate::domain::{AgentModeType, ModelType}; use crate::infrastructure::cli::state::{ FileChangesDisplay, LoadStatus, PopupState, ProgressEntry, ProgressKind, RequestIndicator, RequestStatusDisplay, UiMode, UiState, @@ -242,6 +242,21 @@ fn handle_agent_event( } } } + + // Revert BuildFromPlan → Build when all TODO items are completed + if state.agent_mode == AgentModeType::BuildFromPlan { + let all_done = state + .todo_list + .as_ref() + .map(|list| { + !list.items.is_empty() + && list.items.iter().all(|item| item.status == "completed") + }) + .unwrap_or(true); + if all_done { + state.agent_mode = AgentModeType::Build; + } + } } AgentToUiEvent::FileChangesEvent { request_id, diff --git a/src/infrastructure/cli/views/main_view.rs b/src/infrastructure/cli/views/main_view.rs index 6381a62..90bac7b 100644 --- a/src/infrastructure/cli/views/main_view.rs +++ b/src/infrastructure/cli/views/main_view.rs @@ -1,6 +1,6 @@ use crate::infrastructure::cli::components::{ - attachment_indicator, bottom_info_panel, commands_menu, header_panel, input, left_half_body, - loading_bar, permissions, popup_container, request_indicator, right_half_body, settings_panel, + attachment_indicator, bottom_info_panel, commands_menu, header_panel, input, left_container, + loading_bar, permissions, popup_container, request_indicator, right_container, settings_panel, }; use crate::infrastructure::cli::helpers::{ centered_rect, cursor_position, list_state, panel_block, @@ -79,10 +79,10 @@ pub fn render(frame: &mut Frame, state: &UiState) { .direction(Direction::Horizontal) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) .split(chunks[1]); - left_half_body::render(frame, body_chunks[0], state, theme); // Conversation on left - right_half_body::render(frame, body_chunks[1], state, theme); // TODO/changes on right + left_container::render(frame, body_chunks[0], state, theme); // Conversation on left + right_container::render(frame, body_chunks[1], state, theme); // TODO/changes on right } else { - left_half_body::render(frame, chunks[1], state, theme); + left_container::render(frame, chunks[1], state, theme); } request_indicator::render(frame, chunks[2], state, theme); input::render(frame, chunks[3], state, theme); @@ -94,10 +94,10 @@ pub fn render(frame: &mut Frame, state: &UiState) { .direction(Direction::Horizontal) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) .split(chunks[1]); - left_half_body::render(frame, body_chunks[0], state, theme); // Conversation on left - right_half_body::render(frame, body_chunks[1], state, theme); // TODO/changes on right + left_container::render(frame, body_chunks[0], state, theme); // Conversation on left + right_container::render(frame, body_chunks[1], state, theme); // TODO/changes on right } else { - left_half_body::render(frame, chunks[1], state, theme); + left_container::render(frame, chunks[1], state, theme); } request_indicator::render(frame, chunks[2], state, theme); attachment_indicator::render(frame, chunks[3], state, theme);