diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c2171da..1a4a6eca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed + +- Added `PromptAssembly` to unify MainChat and sub-agent system prompt and tool spec assembly; removed legacy `LlmSession` prompt filtering APIs. +- Moved per-tool routing guidance into tool descriptions; tool usage remains in tool prompts. Oracle delegates tool choice to descriptions (no duplicated routing tables). If you customize `~/.config/aish/prompts/oracle.md`, remove duplicated tool-selection sections that mirror tool descriptions. +- Added explicit routing between `Agent(subagent_type=plan)` and `enter_plan_mode`; `enter_plan_mode` keeps routing in its description and usage-only text in its prompt appendix. +- Expanded built-in sub-agent system prompts (explore/plan/general-purpose) with read-only rules and efficient search strategy; Agent `prompt` schema now requires scope and thoroughness. +- Converted embedded LLM prompt templates (`oracle`, `cmd_error`, `failure_diagnose`) to English-only; SSH error-correction context injection is English as well. Removed unused embedded templates (`error_detect`, `system_diagnose`, `guess_command`) and stale copies under `crates/aish-shell/prompts/` (runtime source: `aish-prompts/src/manager.rs`). + ## [0.3.6] - 2026-07-03 ### Added diff --git a/Cargo.lock b/Cargo.lock index aa64efc7..79aa4f45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -121,6 +121,7 @@ dependencies = [ "aish-core", "aish-i18n", "aish-security", + "aish-tools", "base64 0.22.1", "bytes", "futures", diff --git a/crates/aish-llm/Cargo.toml b/crates/aish-llm/Cargo.toml index 7084373a..3def9485 100644 --- a/crates/aish-llm/Cargo.toml +++ b/crates/aish-llm/Cargo.toml @@ -23,3 +23,4 @@ langfuse-ergonomic.workspace = true [dev-dependencies] tempfile.workspace = true +aish-tools.workspace = true diff --git a/crates/aish-llm/src/agent.rs b/crates/aish-llm/src/agent.rs index 2c806d0c..a94e00bc 100644 --- a/crates/aish-llm/src/agent.rs +++ b/crates/aish-llm/src/agent.rs @@ -1,17 +1,14 @@ //! ReAct-style agent system for iterative reasoning and tool use. //! -//! The agent follows the Thought/Action/Observation/Final Answer pattern: -//! 1. Send the query plus conversation history to the LLM. -//! 2. Parse the LLM response for structured ReAct blocks. -//! 3. If the response contains an **Action**, execute the named tool and feed -//! the result back as an **Observation**. -//! 4. If the response contains a **Final Answer**, return it immediately. -//! 5. Repeat until a final answer is produced or the iteration limit is hit. +//! **Legacy path:** used only by [`crate::diagnose_agent::DiagnoseAgent`] and +//! `system_diagnose` tooling. Main shell chat and sub-agent spawn use native +//! tool calling via [`crate::prompt::PromptAssembly`]. use aish_core::{AishError, LlmEvent, LlmEventType}; use tracing::{debug, info, warn}; use crate::client::LlmResponse; +use crate::prompt::{PromptAssembly, PromptContext}; use crate::streaming::StreamParser; use crate::types::*; use crate::LlmSession; @@ -218,6 +215,8 @@ Available tools will be provided via the standard tool-calling interface."; /// A ReAct-style agent that drives an [`LlmSession`] through iterative /// Thought → Action → Observation cycles until a Final Answer is produced. +/// +/// Legacy: prefer [`PromptAssembly`] + native tool calling for new code paths. pub struct ReActAgent<'a> { session: &'a LlmSession, config: AgentConfig, @@ -258,11 +257,11 @@ impl<'a> ReActAgent<'a> { let _op_end = EmitOpEnd(self.session); let mut messages: Vec = Vec::new(); - let system_prompt = self.session.system_prompt_with_tool_prompts(system_prompt); - messages.push(ChatMessage::system(&system_prompt)); + let bundle = PromptAssembly::build(self.session, PromptContext::MainChat, system_prompt); + messages.push(ChatMessage::system(&bundle.system_message)); messages.push(ChatMessage::user(query)); - let tool_specs: Vec = self.session.tool_specs(); + let tool_specs = bundle.tool_specs; let has_tools = !tool_specs.is_empty(); for iteration in 0..self.config.max_iterations { diff --git a/crates/aish-llm/src/agents/builtin_prompts.rs b/crates/aish-llm/src/agents/builtin_prompts.rs new file mode 100644 index 00000000..1ab8a3c0 --- /dev/null +++ b/crates/aish-llm/src/agents/builtin_prompts.rs @@ -0,0 +1,46 @@ +//! System prompts for built-in sub-agents. + +pub const EXPLORE_SYSTEM_PROMPT: &str = "\ +You are a read-only explore sub-agent for shell and ops investigation. + +=== READ-ONLY MODE === +You must NOT create, modify, delete, or move files. Do not spawn nested agents. Use only \ +your allowed tools. + +=== Tool selection === +- glob: enumerate paths by pattern (prefer one broad recursive pattern per search root) +- grep: search file contents when you know what text to look for +- read_file: read a known path (use offset/limit for large files) +- bash: read-only discovery only (find, ls, stat, systemctl status, git log/diff, cat when \ + read_file is unsuitable). Never use bash for writes, installs, or service changes. + +=== Search strategy === +- Start from the scope and thoroughness in the task brief. Default to quick unless told otherwise. +- quick: known locations and one or two broad patterns; stop when core paths are found. +- medium: expand to related config trees; avoid scanning entire filesystems. +- thorough: wider coverage but still batch with broad patterns, not dozens of narrow globs. +- Prefer one broad glob (e.g. /etc/**/*ssh*) or one find command over many per-directory globs. +- Parallel tool calls are fine when searches are independent; do not repeat the same pattern. +- For common ops layouts, check likely roots first (/etc, /var/log, systemd units) before /. + +Return a concise conclusion listing findings. Do not dump full file contents unless essential."; + +pub const PLAN_SYSTEM_PROMPT: &str = "\ +You are a read-only planning sub-agent for shell and ops work. + +=== READ-ONLY MODE === +Your job is to analyze and produce a plan, runbook, or design advice in your final message \ +only. You must NOT modify files, write plan artifacts, enter plan mode, or spawn nested agents. + +You may use read-only tools to inspect the environment when that improves the plan. + +Structure the conclusion clearly (steps, risks, prerequisites). Do not execute the plan."; + +pub const GENERAL_PURPOSE_SYSTEM_PROMPT: &str = "\ +You are a general-purpose sub-agent delegated a focused task from the parent session. + +Use your available tools to complete the task. Do not spawn nested agents. Return a concise \ +conclusion with outcomes the parent can relay to the user. + +If the task is purely read-only exploration across many paths, prefer completing with efficient \ +search rather than exhaustive brute-force tool spam."; diff --git a/crates/aish-llm/src/agents/mod.rs b/crates/aish-llm/src/agents/mod.rs index 66d60a7b..aad54acf 100644 --- a/crates/aish-llm/src/agents/mod.rs +++ b/crates/aish-llm/src/agents/mod.rs @@ -4,6 +4,7 @@ //! Issue #331: explore vertical slice — registry, tool filter, spawn_builtin. //! Issue #332: plan + general-purpose built-ins and tool filtering. +mod builtin_prompts; mod event_metadata; mod mock_llm; mod outcome; diff --git a/crates/aish-llm/src/agents/registry.rs b/crates/aish-llm/src/agents/registry.rs index 42f6a257..61866595 100644 --- a/crates/aish-llm/src/agents/registry.rs +++ b/crates/aish-llm/src/agents/registry.rs @@ -2,6 +2,10 @@ use std::collections::HashMap; +use super::builtin_prompts::{ + EXPLORE_SYSTEM_PROMPT, GENERAL_PURPOSE_SYSTEM_PROMPT, PLAN_SYSTEM_PROMPT, +}; + /// Read-only tool allowlist shared by `explore` and `plan` built-ins. pub const READ_ONLY_ALLOWLIST: &[&str] = &["grep", "glob", "read_file", "bash"]; @@ -35,8 +39,10 @@ impl AgentDefinition { pub fn explore() -> Self { Self { subagent_type: "explore".to_string(), - when_to_use: "Search logs, configs, services, network state, or code locations; read-only investigation.".to_string(), - system_prompt: "You are a read-only explore sub-agent for shell operations. Investigate using only your allowed tools. Do not modify files or spawn nested agents. Return a concise conclusion.".to_string(), + when_to_use: "Read-only investigation across logs, configs, services, or paths. \ +Use for open-ended search; specify thoroughness in the Agent prompt: quick, medium, or thorough." + .to_string(), + system_prompt: EXPLORE_SYSTEM_PROMPT.to_string(), max_turns: 15, tool_strategy: ToolStrategy::Allowlist(read_only_allowlist()), } @@ -45,8 +51,11 @@ impl AgentDefinition { pub fn plan() -> Self { Self { subagent_type: "plan".to_string(), - when_to_use: "Design changes, runbooks, or implementation plans; read-only planning.".to_string(), - system_prompt: "You are a read-only planning sub-agent for shell operations. Analyze and propose plans using only your allowed tools. Do not modify files, enter plan mode, or spawn nested agents. Return a concise plan or recommendation.".to_string(), + when_to_use: + "Read-only architect for implementation plans, runbooks, or design advice \ +returned as one conclusion — NOT for enter_plan_mode or writing `.aish/plans/` files." + .to_string(), + system_prompt: PLAN_SYSTEM_PROMPT.to_string(), max_turns: 20, tool_strategy: ToolStrategy::Allowlist(read_only_allowlist()), } @@ -55,8 +64,10 @@ impl AgentDefinition { pub fn general_purpose() -> Self { Self { subagent_type: "general-purpose".to_string(), - when_to_use: "General sub-tasks that need the parent's tools without nested Agent delegation.".to_string(), - system_prompt: "You are a general-purpose sub-agent. Complete the delegated task using your available tools. Do not spawn nested agents. Return a concise conclusion.".to_string(), + when_to_use: "Focused sub-tasks that need the parent's tool pool (including writes) \ +without nested Agent delegation — not for broad read-only exploration (use explore)." + .to_string(), + system_prompt: GENERAL_PURPOSE_SYSTEM_PROMPT.to_string(), max_turns: 25, tool_strategy: ToolStrategy::Denylist(vec!["Agent".to_string()]), } @@ -118,6 +129,21 @@ impl Default for AgentRegistry { mod tests { use super::*; + #[test] + fn test_explore_system_prompt_covers_search_strategy() { + let def = AgentDefinition::explore(); + assert!(def.system_prompt.contains("READ-ONLY")); + assert!(def.system_prompt.contains("broad recursive pattern")); + assert!(def.system_prompt.contains("thoroughness")); + } + + #[test] + fn test_plan_system_prompt_is_read_only_architect() { + let def = AgentDefinition::plan(); + assert!(def.system_prompt.contains("READ-ONLY")); + assert!(def.system_prompt.contains("Do not execute the plan")); + } + #[test] fn test_resolve_explore_succeeds() { let registry = AgentRegistry::builtin(); diff --git a/crates/aish-llm/src/agents/spawn.rs b/crates/aish-llm/src/agents/spawn.rs index add29c85..49b43d37 100644 --- a/crates/aish-llm/src/agents/spawn.rs +++ b/crates/aish-llm/src/agents/spawn.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use aish_core::LlmEvent; use uuid::Uuid; +use crate::prompt::PromptContext; use crate::session::LlmSession; use crate::tool_context::ToolExecutionPolicy; use crate::types::{ChatMessage, LlmCallbackResult, ToolSpec}; @@ -27,6 +28,7 @@ pub fn effective_max_turns(def_max_turns: u32) -> u32 { pub struct SpawnConfig { pub max_turns: u32, pub system_message: Option, + pub prompt_context: PromptContext, } impl Default for SpawnConfig { @@ -34,6 +36,7 @@ impl Default for SpawnConfig { Self { max_turns: 20, system_message: None, + prompt_context: PromptContext::MainChat, } } } @@ -65,6 +68,7 @@ where let loop_config = ToolLoopConfig { max_turns: config.max_turns, system_message: config.system_message, + prompt_context: config.prompt_context, ..ToolLoopConfig::default() }; let user_msg = ChatMessage::user(prompt); @@ -113,6 +117,9 @@ where SpawnConfig { max_turns, system_message: Some(system_prompt), + prompt_context: PromptContext::SubAgent { + subagent_type: agent_type.clone(), + }, }, |sub| { sub.set_tool_execution_policy(ToolExecutionPolicy { diff --git a/crates/aish-llm/src/agents/tool_loop.rs b/crates/aish-llm/src/agents/tool_loop.rs index 453cb6ec..2fb11342 100644 --- a/crates/aish-llm/src/agents/tool_loop.rs +++ b/crates/aish-llm/src/agents/tool_loop.rs @@ -3,6 +3,7 @@ use aish_core::{LlmEvent, LlmEventType}; use crate::client::LlmResponse; +use crate::prompt::{PromptAssembly, PromptContext}; use crate::session::LlmSession; use crate::streaming::{extract_message_text, StreamParser}; use crate::types::{ChatMessage, MessageContent}; @@ -17,6 +18,8 @@ pub struct ToolLoopConfig { pub max_turns: u32, /// Optional system prompt prepended to the message list. pub system_message: Option, + /// Prompt assembly context (MainChat vs SubAgent filtering). + pub prompt_context: PromptContext, /// Prefix prepended to the final text when max turns is reached. pub incomplete_prefix: String, } @@ -26,6 +29,7 @@ impl Default for ToolLoopConfig { Self { max_turns: 20, system_message: None, + prompt_context: PromptContext::MainChat, incomplete_prefix: INCOMPLETE_PREFIX.to_string(), } } @@ -90,16 +94,16 @@ pub async fn run_tool_loop_until_done( context_messages: &[ChatMessage], config: &ToolLoopConfig, ) -> LoopOutcome { + let base_system = config.system_message.as_deref().unwrap_or(""); + let bundle = PromptAssembly::build(session, config.prompt_context.clone(), base_system); let mut messages: Vec = Vec::new(); - if let Some(sys) = &config.system_message { - messages.push(ChatMessage::system( - session.system_prompt_with_tool_prompts(sys), - )); + if config.system_message.is_some() { + messages.push(ChatMessage::system(bundle.system_message)); } messages.extend_from_slice(context_messages); messages.push(user_msg.clone()); - let tool_specs = session.filtered_tool_specs(); + let tool_specs = bundle.tool_specs; let has_tools = !tool_specs.is_empty(); let mut iterations = 0u32; diff --git a/crates/aish-llm/src/lib.rs b/crates/aish-llm/src/lib.rs index ef1e6f8c..11aac601 100644 --- a/crates/aish-llm/src/lib.rs +++ b/crates/aish-llm/src/lib.rs @@ -25,6 +25,7 @@ pub mod models; pub mod oauth; pub mod openai_sse_bridge; pub mod probe; +pub mod prompt; pub mod provider; pub mod providers; pub mod session; @@ -58,6 +59,7 @@ pub use oauth::{ login_with_device_code, open_url, save_tokens, OAuthProviderSpec, OAuthTokens, PkcePair, }; pub use probe::probe_live_tool_support; +pub use prompt::{PromptAssembly, PromptBundle, PromptContext, ToolVisibilityPolicy}; pub use provider::{ detect_provider, detect_provider_from_model, refine_provider_from_api_base, ProviderInfo, }; diff --git a/crates/aish-llm/src/prompt/assembly.rs b/crates/aish-llm/src/prompt/assembly.rs new file mode 100644 index 00000000..accb2963 --- /dev/null +++ b/crates/aish-llm/src/prompt/assembly.rs @@ -0,0 +1,195 @@ +//! Unified prompt + tool spec assembly seam. + +use std::collections::HashSet; + +use crate::session::LlmSession; +use crate::types::{PromptVisibility, ToolSpec}; + +use super::context::PromptContext; +use super::visibility::ToolVisibilityPolicy; + +/// Output of [`PromptAssembly::build`]. +#[derive(Debug, Clone)] +pub struct PromptBundle { + pub system_message: String, + pub tool_specs: Vec, +} + +pub struct PromptAssembly; + +impl PromptAssembly { + pub fn build(session: &LlmSession, context: PromptContext, base_system: &str) -> PromptBundle { + let visible_names = ToolVisibilityPolicy::visible_tool_names(session, &context); + let visible_set: HashSet<&str> = visible_names.iter().map(|s| s.as_str()).collect(); + + let tool_specs: Vec = session + .registered_tools() + .filter(|tool| visible_set.contains(tool.name())) + .map(|tool| tool.to_spec()) + .collect(); + + let system_message = + merge_system_with_appendix(base_system, tool_appendix(session, &visible_names)); + + PromptBundle { + system_message, + tool_specs, + } + } +} + +fn merge_system_with_appendix(base_system: &str, appendix: Option) -> String { + match appendix { + None => base_system.to_string(), + Some(section) if base_system.trim().is_empty() => section, + Some(section) => format!("{}\n\n{}", base_system.trim_end(), section), + } +} + +fn tool_appendix(session: &LlmSession, visible_names: &[String]) -> Option { + let visible_set: HashSet<&str> = visible_names.iter().map(|s| s.as_str()).collect(); + let mut prompts: Vec<(&str, &str)> = session + .registered_tools() + .filter(|tool| visible_set.contains(tool.name())) + .filter(|tool| match tool.prompt_visibility() { + PromptVisibility::NeverInAppendix => false, + PromptVisibility::AppendixWhenNonEmpty => true, + }) + .filter_map(|tool| { + let prompt = tool.prompt().trim(); + if prompt.is_empty() { + None + } else { + Some((tool.name(), prompt)) + } + }) + .collect(); + + if prompts.is_empty() { + return None; + } + + prompts.sort_by(|a, b| a.0.cmp(b.0)); + + let mut section = String::from("## Tool Instructions\n"); + for (name, prompt) in prompts { + section.push_str("\n### "); + section.push_str(name); + section.push('\n'); + section.push_str(prompt); + section.push('\n'); + } + Some(section) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{PromptVisibility, Tool}; + + struct MockTool { + name: String, + prompt: String, + visibility: PromptVisibility, + } + + impl MockTool { + fn with_prompt(name: &str, prompt: &str) -> Self { + Self { + name: name.to_string(), + prompt: prompt.to_string(), + visibility: PromptVisibility::AppendixWhenNonEmpty, + } + } + + fn hidden_prompt(name: &str, prompt: &str) -> Self { + Self { + name: name.to_string(), + prompt: prompt.to_string(), + visibility: PromptVisibility::NeverInAppendix, + } + } + } + + impl Tool for MockTool { + fn name(&self) -> &str { + &self.name + } + + fn description(&self) -> &str { + "mock tool" + } + + fn parameters(&self) -> serde_json::Value { + serde_json::json!({}) + } + + fn prompt(&self) -> &str { + &self.prompt + } + + fn prompt_visibility(&self) -> PromptVisibility { + self.visibility + } + + fn execute(&self, _args: serde_json::Value) -> crate::types::ToolResult { + crate::types::ToolResult::success("ok") + } + } + + #[test] + fn build_main_chat_appends_tool_appendix() { + let mut session = LlmSession::new("http://localhost", "key", "model", None, None); + session.register_tool(Box::new(MockTool::with_prompt("grep", "Search logs."))); + + let bundle = PromptAssembly::build(&session, PromptContext::MainChat, "Oracle.\n"); + + assert!(bundle.system_message.starts_with("Oracle.")); + assert!(bundle.system_message.contains("## Tool Instructions")); + assert!(bundle.system_message.contains("Search logs.")); + assert_eq!(bundle.tool_specs.len(), 1); + assert_eq!(bundle.tool_specs[0].function.name, "grep"); + } + + #[test] + fn build_sub_agent_omits_plan_mode_from_specs_and_appendix() { + let mut session = LlmSession::new("http://localhost", "key", "model", None, None); + session.register_tool(Box::new(MockTool::with_prompt("grep", "Search read-only."))); + session.register_tool(Box::new(MockTool::with_prompt( + "enter_plan_mode", + "Do not use in sub-agent.", + ))); + + let bundle = PromptAssembly::build( + &session, + PromptContext::SubAgent { + subagent_type: "plan".to_string(), + }, + "Sub prompt.", + ); + + let names: Vec<_> = bundle + .tool_specs + .iter() + .map(|s| s.function.name.as_str()) + .collect(); + assert_eq!(names, vec!["grep"]); + assert!(!bundle.system_message.contains("enter_plan_mode")); + assert!(!bundle.system_message.contains("Do not use in sub-agent.")); + } + + #[test] + fn build_respects_never_in_appendix_visibility() { + let mut session = LlmSession::new("http://localhost", "key", "model", None, None); + session.register_tool(Box::new(MockTool::hidden_prompt( + "bash", + "Hidden usage details.", + ))); + + let bundle = PromptAssembly::build(&session, PromptContext::MainChat, "Oracle."); + + assert!(!bundle.system_message.contains("## Tool Instructions")); + assert!(!bundle.system_message.contains("Hidden usage details.")); + assert_eq!(bundle.tool_specs.len(), 1); + } +} diff --git a/crates/aish-llm/src/prompt/context.rs b/crates/aish-llm/src/prompt/context.rs new file mode 100644 index 00000000..cc56bf02 --- /dev/null +++ b/crates/aish-llm/src/prompt/context.rs @@ -0,0 +1,11 @@ +//! LLM prompt assembly contexts (Phase A: MainChat + SubAgent only). + +/// Which LLM loop is requesting a prompt bundle. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum PromptContext { + /// Main shell chat loop; plan phase read from [`crate::session::LlmSession::plan_state`]. + #[default] + MainChat, + /// Built-in sub-agent spawn loop. + SubAgent { subagent_type: String }, +} diff --git a/crates/aish-llm/src/prompt/mod.rs b/crates/aish-llm/src/prompt/mod.rs new file mode 100644 index 00000000..18b0a58e --- /dev/null +++ b/crates/aish-llm/src/prompt/mod.rs @@ -0,0 +1,9 @@ +//! Prompt assembly framework (Phase A: MainChat + SubAgent). + +mod assembly; +mod context; +mod visibility; + +pub use assembly::{PromptAssembly, PromptBundle}; +pub use context::PromptContext; +pub use visibility::{ToolVisibilityPolicy, SUBAGENT_GLOBAL_DENY}; diff --git a/crates/aish-llm/src/prompt/visibility.rs b/crates/aish-llm/src/prompt/visibility.rs new file mode 100644 index 00000000..296d0ec5 --- /dev/null +++ b/crates/aish-llm/src/prompt/visibility.rs @@ -0,0 +1,147 @@ +//! Central tool visibility rules for prompt assembly. + +use aish_core::PlanPhase; + +use crate::agents::{parent_has_skill_tool, resolve_tool_names_for_agent, AgentRegistry}; +use crate::session::LlmSession; + +use super::context::PromptContext; + +/// Tools never exposed to sub-agent loops (hard deny). +pub const SUBAGENT_GLOBAL_DENY: &[&str] = &["enter_plan_mode", "exit_plan_mode", "Agent"]; + +pub struct ToolVisibilityPolicy; + +impl ToolVisibilityPolicy { + /// Registered tool names visible for `context`, sorted lexicographically. + pub fn visible_tool_names(session: &LlmSession, context: &PromptContext) -> Vec { + match context { + PromptContext::MainChat => Self::main_chat_visible(session), + PromptContext::SubAgent { subagent_type } => { + Self::sub_agent_visible(session, subagent_type) + } + } + } + + fn main_chat_visible(session: &LlmSession) -> Vec { + let plan_state = session.plan_state(); + let phase = plan_state.lock().unwrap().phase.clone(); + let mut names: Vec = session + .tool_specs() + .into_iter() + .map(|spec| spec.function.name) + .filter(|name| tool_visible_in_main_chat_phase(name, &phase)) + .collect(); + names.sort(); + names + } + + fn sub_agent_visible(session: &LlmSession, subagent_type: &str) -> Vec { + let tool_specs = session.tool_specs(); + let registered: Vec = tool_specs.into_iter().map(|s| s.function.name).collect(); + let registered_refs: Vec<&str> = registered.iter().map(|s| s.as_str()).collect(); + let parent_has_skill = parent_has_skill_tool(&session.tool_specs()); + let registry = AgentRegistry::builtin(); + + let mut names = match registry.resolve(subagent_type) { + Ok(def) => resolve_tool_names_for_agent(def, ®istered_refs, parent_has_skill), + Err(_) => registered, + }; + + names.retain(|name| !SUBAGENT_GLOBAL_DENY.contains(&name.as_str())); + names.sort(); + names + } +} + +fn tool_visible_in_main_chat_phase(tool_name: &str, phase: &PlanPhase) -> bool { + match phase { + PlanPhase::Normal => true, + PlanPhase::Planning => aish_core::PLANNING_VISIBLE_TOOLS.contains(&tool_name), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::Tool; + use aish_core::PlanPhase; + + struct MockTool { + name: String, + } + + impl MockTool { + fn new(name: &str) -> Self { + Self { + name: name.to_string(), + } + } + } + + impl Tool for MockTool { + fn name(&self) -> &str { + &self.name + } + + fn description(&self) -> &str { + "mock" + } + + fn parameters(&self) -> serde_json::Value { + serde_json::json!({}) + } + + fn execute(&self, _args: serde_json::Value) -> crate::types::ToolResult { + crate::types::ToolResult::success("ok") + } + } + + #[test] + fn main_chat_normal_includes_all_registered_tools() { + let mut session = LlmSession::new("http://localhost", "key", "model", None, None); + session.register_tool(Box::new(MockTool::new("bash_exec"))); + session.register_tool(Box::new(MockTool::new("grep"))); + + let names = ToolVisibilityPolicy::visible_tool_names(&session, &PromptContext::MainChat); + assert_eq!(names, vec!["bash_exec".to_string(), "grep".to_string()]); + } + + #[test] + fn main_chat_planning_filters_to_planning_visible_tools() { + let mut session = LlmSession::new("http://localhost", "key", "model", None, None); + session.register_tool(Box::new(MockTool::new("read_file"))); + session.register_tool(Box::new(MockTool::new("bash_exec"))); + + { + let plan_state = session.plan_state(); + let mut state = plan_state.lock().unwrap(); + state.phase = PlanPhase::Planning; + } + + let names = ToolVisibilityPolicy::visible_tool_names(&session, &PromptContext::MainChat); + assert_eq!(names, vec!["read_file".to_string()]); + } + + #[test] + fn sub_agent_plan_excludes_plan_mode_agent_and_writes() { + let mut session = LlmSession::new("http://localhost", "key", "model", None, None); + for name in [ + "grep", + "read_file", + "write_file", + "enter_plan_mode", + "Agent", + ] { + session.register_tool(Box::new(MockTool::new(name))); + } + + let names = ToolVisibilityPolicy::visible_tool_names( + &session, + &PromptContext::SubAgent { + subagent_type: "plan".to_string(), + }, + ); + assert_eq!(names, vec!["grep".to_string(), "read_file".to_string(),]); + } +} diff --git a/crates/aish-llm/src/session.rs b/crates/aish-llm/src/session.rs index 83668772..89fed00f 100644 --- a/crates/aish-llm/src/session.rs +++ b/crates/aish-llm/src/session.rs @@ -53,6 +53,8 @@ pub struct LlmSession { token_stats: std::sync::Mutex, /// Per-session tool execution policy (read-only bash enforcement, etc.). tool_execution_policy: crate::tool_context::ToolExecutionPolicy, + /// True for sessions created via [`Self::create_subsession`]. + is_sub_agent: bool, /// Scripted chat completion responses for unit/integration tests (pop in order). #[cfg(test)] test_chat_responses: Option>>>>, @@ -103,6 +105,7 @@ impl LlmSession { plan_state: Arc::new(Mutex::new(PlanModeState::default())), token_stats: std::sync::Mutex::new(crate::usage::TokenStats::default()), tool_execution_policy: crate::tool_context::ToolExecutionPolicy::default(), + is_sub_agent: false, #[cfg(test)] test_chat_responses: None, } @@ -116,6 +119,11 @@ impl LlmSession { self.tool_execution_policy = policy; } + /// Whether this session is an isolated sub-agent loop (not the main shell chat). + pub fn is_sub_agent(&self) -> bool { + self.is_sub_agent + } + pub fn register_tool(&mut self, tool: Box) { self.tools.insert(tool.name().to_string(), tool); } @@ -194,70 +202,9 @@ impl LlmSession { self.tools.values().map(|t| t.to_spec()).collect() } - /// Return tool specs filtered based on the current plan phase. - /// - /// During planning, only tools in PLANNING_VISIBLE_TOOLS are available. - /// During normal mode, all tools are visible. - pub fn filtered_tool_specs(&self) -> Vec { - let all = self.tool_specs(); - let phase = self.plan_state.lock().unwrap().phase.clone(); - - match phase { - PlanPhase::Normal => all, - PlanPhase::Planning => { - let visible = aish_core::PLANNING_VISIBLE_TOOLS; - all.into_iter() - .filter(|t| visible.contains(&t.function.name.as_str())) - .collect() - } - } - } - - fn tool_visible_in_phase(tool_name: &str, phase: &PlanPhase) -> bool { - match phase { - PlanPhase::Normal => true, - PlanPhase::Planning => aish_core::PLANNING_VISIBLE_TOOLS.contains(&tool_name), - } - } - - pub fn filtered_tool_prompt_section(&self) -> Option { - let phase = self.plan_state.lock().unwrap().phase.clone(); - let mut prompts: Vec<(&str, &str)> = self - .tools - .values() - .filter(|tool| Self::tool_visible_in_phase(tool.name(), &phase)) - .filter_map(|tool| { - let prompt = tool.prompt().trim(); - if prompt.is_empty() { - None - } else { - Some((tool.name(), prompt)) - } - }) - .collect(); - - if prompts.is_empty() { - return None; - } - - prompts.sort_by(|a, b| a.0.cmp(b.0)); - - let mut section = String::from("## Tool Instructions\n"); - for (name, prompt) in prompts { - section.push_str("\n### "); - section.push_str(name); - section.push('\n'); - section.push_str(prompt); - section.push('\n'); - } - Some(section) - } - - pub fn system_prompt_with_tool_prompts(&self, system_prompt: &str) -> String { - match self.filtered_tool_prompt_section() { - Some(section) => format!("{}\n\n{}", system_prompt.trim_end(), section), - None => system_prompt.to_string(), - } + /// Iterate registered tools (for prompt assembly appendix). + pub(crate) fn registered_tools(&self) -> impl Iterator { + self.tools.values().map(|tool| tool.as_ref()) } /// Get a reference to the plan state (for external coordination). @@ -432,10 +379,13 @@ impl LlmSession { // Build initial message list let mut messages: Vec = Vec::new(); - if let Some(sys) = system_message { - messages.push(ChatMessage::system( - self.system_prompt_with_tool_prompts(sys), - )); + let prompt_bundle = crate::prompt::PromptAssembly::build( + self, + crate::prompt::PromptContext::MainChat, + system_message.unwrap_or(""), + ); + if system_message.is_some() { + messages.push(ChatMessage::system(prompt_bundle.system_message)); } messages.extend_from_slice(context_messages); messages.push(user_msg.clone()); @@ -455,7 +405,7 @@ impl LlmSession { let initial_len = messages.len(); - let tool_specs = self.filtered_tool_specs(); + let tool_specs = prompt_bundle.tool_specs; let has_tools = !tool_specs.is_empty(); // Tool calling loop (max iterations to prevent infinite loops) @@ -1314,6 +1264,7 @@ impl LlmSession { plan_state: Arc::new(Mutex::new(PlanModeState::default())), token_stats: std::sync::Mutex::new(crate::usage::TokenStats::default()), tool_execution_policy: self.tool_execution_policy, + is_sub_agent: true, #[cfg(test)] test_chat_responses: None, } @@ -1325,6 +1276,12 @@ impl LlmSession { tool: &dyn Tool, args: &serde_json::Value, ) -> Option { + if self.is_sub_agent && crate::prompt::SUBAGENT_GLOBAL_DENY.contains(&tool_name) { + return Some(ToolResult::error(format!( + "Tool '{tool_name}' is not available in sub-agent sessions" + ))); + } + let ctx = crate::tool_context::ToolContext::for_session(self); match tool.preflight_with_context(args, &ctx) { PreflightResult::Allow => None, @@ -2610,27 +2567,28 @@ mod tests { } #[test] - fn test_filtered_tool_specs_normal_mode() { + fn test_main_chat_tool_specs_normal_mode() { + use crate::prompt::{PromptAssembly, PromptContext}; + let session = LlmSession::new("http://localhost", "key", "model", None, None); - // In normal mode, filtered specs should return all registered tools - let specs = session.filtered_tool_specs(); - assert_eq!(specs.len(), 0); // No tools registered yet + let specs = PromptAssembly::build(&session, PromptContext::MainChat, "").tool_specs; + assert_eq!(specs.len(), 0); } #[test] - fn test_filtered_tool_specs_planning_mode() { + fn test_main_chat_tool_specs_planning_mode() { + use crate::prompt::{PromptAssembly, PromptContext}; use aish_core::PlanPhase; + let session = LlmSession::new("http://localhost", "key", "model", None, None); - // Set planning mode { let mut state = session.plan_state.lock().unwrap(); state.phase = PlanPhase::Planning; } - // In planning mode, should return empty (no tools registered) - let specs = session.filtered_tool_specs(); + let specs = PromptAssembly::build(&session, PromptContext::MainChat, "").tool_specs; assert_eq!(specs.len(), 0); } @@ -2740,6 +2698,8 @@ mod tests { #[test] fn test_tool_prompt_section_uses_only_non_empty_prompts() { + use crate::prompt::{PromptAssembly, PromptContext}; + let mut session = LlmSession::new("http://localhost", "key", "model", None, None); session.register_tool(Box::new(MockTool::new("empty_tool"))); session.register_tool(Box::new(MockTool::with_prompt( @@ -2747,7 +2707,7 @@ mod tests { "Use carefully.", ))); - let section = session.filtered_tool_prompt_section().unwrap(); + let section = PromptAssembly::build(&session, PromptContext::MainChat, "").system_message; assert!(section.contains("## Tool Instructions")); assert!(section.contains("### prompt_tool")); @@ -2757,6 +2717,8 @@ mod tests { #[test] fn test_tool_prompt_section_respects_planning_filter() { + use crate::prompt::{PromptAssembly, PromptContext}; + let mut session = LlmSession::new("http://localhost", "key", "model", None, None); session.register_tool(Box::new(MockTool::with_prompt( "read_file", @@ -2772,7 +2734,7 @@ mod tests { state.phase = aish_core::PlanPhase::Planning; } - let section = session.filtered_tool_prompt_section().unwrap(); + let section = PromptAssembly::build(&session, PromptContext::MainChat, "").system_message; assert!(section.contains("### read_file")); assert!(section.contains("Read files during planning.")); @@ -2781,11 +2743,15 @@ mod tests { } #[test] - fn test_system_prompt_with_tool_prompts_appends_section() { + fn test_prompt_assembly_appends_tool_section_to_base_system() { + use crate::prompt::{PromptAssembly, PromptContext}; + let mut session = LlmSession::new("http://localhost", "key", "model", None, None); session.register_tool(Box::new(MockTool::with_prompt("mock", "Mock guidance."))); - let system_prompt = session.system_prompt_with_tool_prompts("Base prompt.\n"); + let system_prompt = + PromptAssembly::build(&session, PromptContext::MainChat, "Base prompt.\n") + .system_message; assert!(system_prompt.starts_with("Base prompt.")); assert!(system_prompt.contains("## Tool Instructions")); @@ -2795,26 +2761,24 @@ mod tests { #[test] fn test_tool_filtering_with_registered_tools() { + use crate::prompt::{PromptAssembly, PromptContext}; use aish_core::PlanPhase; + let mut session = LlmSession::new("http://localhost", "key", "model", None, None); - // Register mock tools session.register_tool(Box::new(MockTool::new("read_file"))); session.register_tool(Box::new(MockTool::new("bash_exec"))); session.register_tool(Box::new(MockTool::new("grep"))); - // In normal mode, all tools should be visible - let specs = session.filtered_tool_specs(); + let specs = PromptAssembly::build(&session, PromptContext::MainChat, "").tool_specs; assert_eq!(specs.len(), 3); - // Set planning mode { let mut state = session.plan_state.lock().unwrap(); state.phase = PlanPhase::Planning; } - // In planning mode, bash_exec should be filtered out - let specs = session.filtered_tool_specs(); + let specs = PromptAssembly::build(&session, PromptContext::MainChat, "").tool_specs; assert_eq!(specs.len(), 2); let tool_names: Vec<_> = specs.iter().map(|s| s.function.name.as_str()).collect(); assert!(tool_names.contains(&"read_file")); diff --git a/crates/aish-llm/src/types.rs b/crates/aish-llm/src/types.rs index f7389acb..089e95c7 100644 --- a/crates/aish-llm/src/types.rs +++ b/crates/aish-llm/src/types.rs @@ -499,6 +499,16 @@ pub enum PreflightResult { }, } +/// Whether a tool's [`Tool::prompt`] is included in the system appendix. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PromptVisibility { + /// Include in appendix when [`Tool::prompt`] is non-empty after trim. + #[default] + AppendixWhenNonEmpty, + /// Never append [`Tool::prompt`] to the system message. + NeverInAppendix, +} + /// Trait for tool implementations that the LLM can invoke. pub trait Tool: Send + Sync { fn name(&self) -> &str; @@ -509,6 +519,11 @@ pub trait Tool: Send + Sync { "" } + /// Controls whether [`Tool::prompt`] is appended to the system message appendix. + fn prompt_visibility(&self) -> PromptVisibility { + PromptVisibility::AppendixWhenNonEmpty + } + fn to_spec(&self) -> ToolSpec { ToolSpec { r#type: "function".into(), diff --git a/crates/aish-llm/tests/prompt_assembly_contract.rs b/crates/aish-llm/tests/prompt_assembly_contract.rs new file mode 100644 index 00000000..52d25df1 --- /dev/null +++ b/crates/aish-llm/tests/prompt_assembly_contract.rs @@ -0,0 +1,184 @@ +//! Contract tests for [`PromptAssembly::build`] (Phase A/B). + +use aish_core::PlanPhase; +use aish_llm::{LlmSession, PromptAssembly, PromptContext, Tool}; +use aish_tools::{AgentTool, EnterPlanModeTool}; + +struct MockTool { + name: String, + prompt: String, +} + +impl MockTool { + fn new(name: &str) -> Self { + Self { + name: name.to_string(), + prompt: String::new(), + } + } + + fn with_prompt(name: &str, prompt: &str) -> Self { + Self { + name: name.to_string(), + prompt: prompt.to_string(), + } + } +} + +impl Tool for MockTool { + fn name(&self) -> &str { + &self.name + } + + fn description(&self) -> &str { + "mock" + } + + fn parameters(&self) -> serde_json::Value { + serde_json::json!({}) + } + + fn prompt(&self) -> &str { + &self.prompt + } + + fn execute(&self, _args: serde_json::Value) -> aish_llm::ToolResult { + aish_llm::ToolResult::success("ok") + } +} + +fn register_main_chat_toolkit(session: &mut LlmSession) { + for name in [ + "grep", + "read_file", + "bash_exec", + "Agent", + "enter_plan_mode", + "exit_plan_mode", + ] { + session.register_tool(Box::new(MockTool::new(name))); + } +} + +#[test] +fn main_chat_normal_includes_agent_and_plan_mode_tools() { + let mut session = LlmSession::new("http://localhost", "key", "model", None, None); + register_main_chat_toolkit(&mut session); + + let bundle = PromptAssembly::build(&session, PromptContext::MainChat, "oracle"); + + let names: Vec<_> = bundle + .tool_specs + .iter() + .map(|s| s.function.name.as_str()) + .collect(); + assert!(names.contains(&"Agent")); + assert!(names.contains(&"enter_plan_mode")); + assert_eq!(names.len(), 6); +} + +#[test] +fn main_chat_planning_limits_to_planning_visible_tools() { + let mut session = LlmSession::new("http://localhost", "key", "model", None, None); + register_main_chat_toolkit(&mut session); + session.register_tool(Box::new(MockTool::new("write_file"))); + + { + let plan_state = session.plan_state(); + let mut state = plan_state.lock().unwrap(); + state.phase = PlanPhase::Planning; + } + + let bundle = PromptAssembly::build(&session, PromptContext::MainChat, "oracle"); + + let names: Vec<_> = bundle + .tool_specs + .iter() + .map(|s| s.function.name.as_str()) + .collect(); + assert!(names.contains(&"read_file")); + assert!(names.contains(&"write_file")); + assert!(names.contains(&"exit_plan_mode")); + assert!(!names.contains(&"bash_exec")); + assert!(!names.contains(&"Agent")); + assert!(!names.contains(&"enter_plan_mode")); +} + +#[test] +fn sub_agent_plan_excludes_plan_mode_agent_and_write_tools() { + let mut session = LlmSession::new("http://localhost", "key", "model", None, None); + register_main_chat_toolkit(&mut session); + session.register_tool(Box::new(MockTool::with_prompt("grep", "grep usage"))); + session.register_tool(Box::new(MockTool::with_prompt( + "enter_plan_mode", + "plan mode usage", + ))); + + let bundle = PromptAssembly::build( + &session, + PromptContext::SubAgent { + subagent_type: "plan".to_string(), + }, + "sub system", + ); + + let names: Vec<_> = bundle + .tool_specs + .iter() + .map(|s| s.function.name.as_str()) + .collect(); + assert_eq!(names.len(), 2); + assert!(names.contains(&"grep")); + assert!(names.contains(&"read_file")); + assert!(!bundle.system_message.contains("enter_plan_mode")); + assert!(!bundle.system_message.contains("plan mode usage")); + assert!(bundle.system_message.contains("grep usage")); +} + +#[test] +fn sub_session_preflight_blocks_enter_plan_mode() { + let parent = LlmSession::new("http://localhost", "key", "model", None, None); + let mut sub = parent.create_subsession(); + sub.register_tool(Box::new(MockTool::new("enter_plan_mode"))); + + assert!(sub.is_sub_agent()); + + let rt = tokio::runtime::Runtime::new().expect("runtime"); + let result = rt + .block_on(sub.execute_tool_by_name("enter_plan_mode", serde_json::json!({}))) + .expect("tool registered"); + assert!(!result.ok); + assert!(result + .output + .contains("not available in sub-agent sessions")); +} + +#[test] +fn main_chat_agent_tool_spec_includes_routing_table() { + let mut session = LlmSession::new("http://localhost", "key", "model", None, None); + session.register_tool(Box::new(AgentTool::new())); + session.register_tool(Box::new(EnterPlanModeTool::new())); + + let bundle = PromptAssembly::build(&session, PromptContext::MainChat, "oracle"); + + let agent_spec = bundle + .tool_specs + .iter() + .find(|s| s.function.name == "Agent") + .expect("Agent spec"); + assert!(agent_spec + .function + .description + .contains("## Routing: planning vs plan mode vs sub-agents")); + assert!(agent_spec + .function + .description + .contains("Agent(subagent_type=plan)")); + + let enter_spec = bundle + .tool_specs + .iter() + .find(|s| s.function.name == "enter_plan_mode") + .expect("enter_plan_mode spec"); + assert!(enter_spec.function.description.contains(".aish/plans/")); +} diff --git a/crates/aish-prompts/src/manager.rs b/crates/aish-prompts/src/manager.rs index 67882511..bc1cae9d 100644 --- a/crates/aish-prompts/src/manager.rs +++ b/crates/aish-prompts/src/manager.rs @@ -118,9 +118,6 @@ fn default_templates() -> &'static [(&'static str, &'static str)] { ("oracle", ORACLE_PROMPT), ("cmd_error", CMD_ERROR_PROMPT), ("failure_diagnose", FAILURE_DIAGNOSE_PROMPT), - ("error_detect", ERROR_DETECT_PROMPT), - ("system_diagnose", SYSTEM_DIAGNOSE_PROMPT), - ("guess_command", GUESS_COMMAND_PROMPT), ("skill", SKILL_PROMPT), ] } @@ -132,11 +129,11 @@ You are capable of running Linux commands and tools. You can use the tools to he const ORACLE_PROMPT: &str = r#"{{role_prompt}} -## 系统基本信息 -- 运行环境信息: {{uname_info}} -- 用户的昵称: {{user_nickname}} -- 发行版信息:{{os_info}} -- 基本环境信息: +## System Information +- Runtime environment: {{uname_info}} +- User nickname: {{user_nickname}} +- OS / distro: {{os_info}} +- Basic environment: {{basic_env_info}} ## Tone and Style @@ -183,46 +180,38 @@ You are allowed to be proactive, but only when the user asks you to do something - if the task is not finished or encountered an error, you may try to continue to explore alternative solutions. -## 基本原则 -你可以像 shell 一样直接运行命令,不一样的是你会监控每个命令的标准输出和stderr 的内容,这些内容会作为上下文提供后续的交互。你需要根据这些信息来给用户主动提供准确的、简练的、极具价值的反馈,例如直接指出命令出错的原因,并给出可能最正确的参考命令,或者当用户发出一个自然语言的请求时,充分理解用户意图,形成解决方案, 你可以使用 Python 工具或者是 bash 工具去执行命令或脚本文件,若是分析类任务就得到一些中间信息,或是回答用户关于 Linux 上任何跟使用有关的问题。你直接调用 `bash` 工具帮助用户去执行系统的命令或脚本。If there are certain requests required by the user, such as when executing a command or script, the corresponding tool should be called directly to respond directly to the user's request. The result of the previous execution of the tool is only used for judgment, and the user's new request cannot be rejected based on this result. +## Core Behavior +You can run commands like a shell, but you monitor each command's stdout and stderr; that output becomes context for later turns. Use it to give accurate, concise, high-value feedback — for example, explain why a command failed and suggest a corrected command, or when the user asks in natural language, understand their intent and propose a solution. A previous tool result is only for judgment; do not reject the user's new request based on it. Tool results and user messages may include or other tags. Tags contain information from the system. They bear no direct relation to the specific tool results or user messages in which they appear. -### Shell 输出 Offload 规则(重要) -- Shell命令的输出结果如果太长了会被offload到文件系统中,这个信息会从输出中看到(包含了offload的标签)。如果你需要获取详细信息,就应该从对应offload的文件里面去查找。 -- ``/`` 可能只是预览,不一定是完整输出。 -- 当 `` 中 `status` 为 `offloaded` 时,表示完整输出已写入文件;若需要完整信息,优先读取 `stdout_clean_path`/`stderr_clean_path`,若 clean 路径缺失或不可用再回退到 `stdout_path`/`stderr_path`(必要时读取 `meta_path`),而不是仅依据预览下结论。 -- 当 `status` 为 `inline` 时,当前标签内内容可视为主要输出;当 `status` 为 `failed` 时,优先基于现有预览继续分析,并提示 offload 失败信息。 - - -### 工具的选择原则 -- **bash 工具优先**:如用户请求明确、问题可用单行命令处理,或需要执行 bash脚本,直接使用 `bash` 工具。 -- **Python 工具优先**:当任务需要脚本实现、复杂数据处理、格式化输出、条件/循环逻辑或粘合多个步骤,优先考虑 Python(如批量文件处理、复杂日志分析、生成统计报告、下载处理等)。 -- **系统诊断工具优先**:当用户请求诊断系统问题时,使用 **system_diagnose_agent**工具。 例如我的系统为什么卡顿,为什么写不了文件了,为什么我的进程被杀死了等等,我的ngnix 是不是配错了?, 怎么感觉网速有点慢,我的系统是不是有很多异常登录? -- 当用户明确需要创建文件时,使用 **write_file**工具,工具名称:write_file。如果用户只要求写入文件,写入文件后停止对话。如果是脚本或应用程序,不要主动尝试运行这个程序。 -- 当用户需要修改已有文件内容时,使用 **edit_file**工具,工具名称:edit_file。(先用 read_file 读取内容,再进行精确字符串替换;old_string 必须唯一,否则需要提供更大上下文或使用 replace_all。) -- 当需要读取文件内容时,使用 **read_file**工具,工具名称:read_file。 -- IMPORTANT: Do not use terminal commands (cat, head, tail, etc.) to read files. Instead, use the read_file tool. If you use cat, the file may not be properly preserved in context and can result in errors in the future. -- **Skill** tool is used to invoke user-invocable skills to accomplish user's request. IMPORTANT: Only use Skill for skills listed in the current `...` user message for the current turn - do not guess or use built-in CLI commands. Skills can be hot-reloaded (added/removed/modified) during a session, and the current reminder is the single source of truth for the *current* turn; always re-check that the skill exists there right before invoking it, and do not rely on memory from earlier turns. If the user asks about the current available skills, answer from the current reminder and do not rely on memory from earlier turns. CAVEAT: user scope skills are stored under the app's config directory. Do NOT create or modify files inside the skill or config directories. If the skill needs to generate, create, or write any files/directories, it must write only to a dedicated subdirectory under the current working directory (recommended examples: `./tmp`, `./artifacts`); do not write directly into the cwd root. Create the subdirectory if missing. If a tool or script accepts an output path (e.g. --path/--output/--dir), you must explicitly set it to a dedicated cwd subdirectory and never rely on defaults. If you cannot set a safe output path, ask the user before continuing. - -## 长期运行命令处理原则 -当用户的意图是运行一个**长期运行**或**交互式**的命令时,**不要使用** `bash` 工具执行。 - -### 识别长期运行/交互式命令 -包括但不限于以下类型的用户请求: -- **实时系统监控**: "实时监控系统进程", "持续监控CPU使用率", "实时查看内存变化", "监控IO状态", "动态显示进程" -- **编辑器**: "打开 vim/nano", "进入编辑器", "打开文本编辑器"(如果只是修改文件内容,优先用 edit_file 工具完成,而不是启动交互式编辑器) -- **网络工具**: "连接服务器", "持续ping", "远程登录", "测试网络连接" -- **持续监控**: "实时查看日志", "监控文件变化", "跟踪系统日志" -- **数据库客户端**: "连接数据库", "进入MySQL", "操作PostgreSQL", "使用SQLite" -- **编程语言REPL**: "进入Python环境", "启动Node.js", "运行交互式解释器" -- **分页器**: "查看大文件内容", "浏览长文档", "分页显示文本" -- **其他交互式工具**: "创建会话", "启动终端复用器", "文件传输" - -### 长期或交互式命令以文本提示,让用户自行执行 +## Tool choice +Follow each tool's description in the tool list for routing and delegation. Do not repeat or override those rules here. + +### Shell output offload rules (important) +- Long shell output may be offloaded to the filesystem; you will see this in the output (offload tags). When you need full details, read the corresponding offload file. +- ``/`` may be previews, not the full output. +- When `` has `status` `offloaded`, full output was written to a file; prefer `stdout_clean_path`/`stderr_clean_path`, and if those are missing or unavailable fall back to `stdout_path`/`stderr_path` (read `meta_path` if needed) rather than concluding from the preview alone. +- When `status` is `inline`, treat in-tag content as the primary output; when `failed`, analyze from the preview and note the offload failure. + + +## Long-running and interactive commands +When the user wants a **long-running** or **interactive** command, **do not** use the `bash` tool. + +### Recognizing long-running / interactive requests +Including but not limited to: +- **Live system monitoring**: e.g. watch processes, CPU usage, memory, I/O, dynamic process display +- **Editors**: open vim/nano or an editor (if the goal is only to change file contents, prefer the edit_file tool instead of starting an interactive editor) +- **Network tools**: connect to servers, continuous ping, remote login, network tests +- **Continuous monitoring**: tail logs live, watch file changes, follow system logs +- **Database clients**: connect to MySQL, PostgreSQL, SQLite, etc. +- **Language REPLs**: Python, Node.js, interactive interpreters +- **Pagers**: browse large files or long documents with less/more +- **Other interactive tools**: tmux/screen sessions, file transfer tools + +### Tell the user to run it themselves { - content: "编辑a.txt命令如下: - `vim a.txt` + content: "To edit a.txt, run:\n`vim a.txt`", role: "assistant", tool_calls: null, function_call: null, @@ -233,313 +222,89 @@ Tool results and user messages may include or other tags. Tags "#; const CMD_ERROR_PROMPT: &str = r#"{{role_prompt}} -### 关键规则 -1. **content字段**:必须是字符串,绝对不能是对象 -2. **tool_calls字段**:必须是数组,包含所有工具调用 -3. **工具调用信息**:必须放在tool_calls数组中,绝对不能放在content中 +### Critical rules +1. The **content** field must be a string, never an object. +2. The **tool_calls** field must be an array containing all tool calls. +3. Tool call details must live in the **tool_calls** array, never in **content**. --- -## 系统基本信息 -- 运行环境信息: {{uname_info}} -- 用户的昵称: {{user_nickname}} -- 发行版信息:{{os_info}} -- 基本环境信息: +## System Information +- Runtime environment: {{uname_info}} +- User nickname: {{user_nickname}} +- OS / distro: {{os_info}} +- Basic environment: {{basic_env_info}} {{remote_env_info}} ## Tone and Style -You should be concise, direct, and to the point. Response with {{output_language}}. +You should be concise, direct, and to the point. Response in {{output_language}}. -## 任务 -根据给出的执行失败(return code != 0)的命令以及相应的执行结果,分析命令失败的原因,并提供准确的解决方案。 如果没有合适的解决方案,请返回空字符串。 +## Task +Given a failed command (return code != 0) and its output, analyze why it failed and provide an accurate fix. If there is no suitable fix, return an empty command string. -命令执行的退出码: {{exit_code}} +Command exit code: {{exit_code}} -### 输出格式 -- 只能输出 **一个** JSON 代码块,不得输出任何额外文字(包括解释、前后缀、Markdown 说明)。 -- 必须使用 ```json 代码块包裹完整 JSON。 -- JSON 必须完整且可解析,不得拆行输出到代码块之外。 -- 如果没有合适的解决方案,仍返回同样的 JSON 结构,且 command 为空字符串。 +### Output format +- Output **exactly one** JSON code block with no extra text (no explanation, prefix, suffix, or Markdown outside the block). +- Wrap the full JSON in a ```json code fence. +- The JSON must be complete and parseable; do not split it outside the fence. +- If there is no suitable fix, use the same JSON shape with an empty `command` string. ```json { "type": "corrected_command", - "command": "修正后的完整命令 或者 空字符串", - "description": "简短说明修正原因和命令作用,或者说明为什么没有合适的解决方案" + "command": "corrected full command or empty string", + "description": "brief explanation of the fix and what the command does, or why no fix is available" } ```"#; const FAILURE_DIAGNOSE_PROMPT: &str = r#"{{role_prompt}} -## 系统基本信息 -- 运行环境信息: {{uname_info}} -- 用户的昵称: {{user_nickname}} -- 发行版信息:{{os_info}} -- 基本环境信息: +## System Information +- Runtime environment: {{uname_info}} +- User nickname: {{user_nickname}} +- OS / distro: {{os_info}} +- Basic environment: {{basic_env_info}} ## Tone and Style -You should be concise, direct, and to the point. Response with {{output_language}}. - -## 任务 -上一条 shell 命令执行失败。你在 **只读诊断模式** 下调查失败原因: -- 可使用 bash、read_file 收集证据(如 which、journalctl、systemctl status、cat 等只读命令) -- **禁止** 写文件、改配置、安装软件、启停服务等会改变系统状态的操作 -- 调查完成后 **必须** 调用 `final_answer` 工具提交诊断报告 - -## 失败上下文 -- 失败命令: {{failed_command}} -- 退出码: {{exit_code}} -- 工作目录: {{cwd}} -- 命令输出: +You should be concise, direct, and to the point. Response in {{output_language}}. + +## Task +The previous shell command failed. You are in **read-only diagnosis mode**: +- Use bash and read_file to gather evidence (e.g. which, journalctl, systemctl status, cat) +- **Do not** write files, change configuration, install software, start/stop services, or otherwise mutate system state +- When done, **must** call the `final_answer` tool with your diagnosis report + +## Failure context +- Failed command: {{failed_command}} +- Exit code: {{exit_code}} +- Working directory: {{cwd}} +- Command output: ``` {{command_output}} ``` -## 输出格式 -调用 `final_answer` 时,`answer` 参数必须是 **唯一** 的 JSON 字符串,结构如下: +## Output format +When calling `final_answer`, the `answer` argument must be the **only** JSON string, shaped as: ```json { "type": "diagnose_report", - "root_cause": "简要失败原因", - "evidence": ["依据1", "依据2"], - "suggested_fix": "建议修复命令或 null", - "verify_commands": ["只读验证命令1"], - "risk_notes": "风险提示或 null", + "root_cause": "brief failure reason", + "evidence": ["evidence 1", "evidence 2"], + "suggested_fix": "suggested fix command or null", + "verify_commands": ["read-only verify command 1"], + "risk_notes": "risk notes or null", "confidence": "high" } ``` -- `root_cause` 和 `evidence`(非空数组)必填 -- `verify_commands` 中的命令必须是只读检查 -- `confidence` 为 high / medium / low 之一"#; - -const ERROR_DETECT_PROMPT: &str = r#"{{role_prompt}} - -## 系统基本信息 -- 运行环境信息: {{uname_info}} -- 用户的昵称: {{user_nickname}} -- 发行版信息:{{os_info}} -- 基本环境信息: -{{basic_env_info}} - -## Tone and Style -You should be concise, direct, and to the point. Response with {{output_language}}. - -## 任务 -根据命令的执行结果(包括标准输出、标准错误),判断命令是否执行成功。 - -IMPORTANT: -任务给出的命令都是 return code 为 0 的情况。 -不同的平台上,不同的版本,同一个命令的执行结果可能不同,你需要根据命令的执行结果来判断命令是否执行成功。 -管道任务,中间的命令出错,不会影响最终的返回码,所以你需要根据标准输出和标准错误来判断命令整体是否执行成功。 - -RESPONSE FORMAT: -```json -{ - "type": "error_detect", - "is_success": true or false, - "reason": "错误原因的简明解释" -} -``` - -### 分析示例 - - -用户执行命令(under mac os): -```bash -ps -aux | tail -1 -``` -执行结果: -``` -stderr: -ps: No user named 'x' -stdout: -``` - 判断结果: - ```json - { - "type": "error_detect", - "is_success": false, - "reason": "ps命令的参数错误" - } - ``` - - - -用户执行命令(under linux): -```bash -ps -aux | tail -1 -``` -执行结果: -``` -stderr: -stdout: -sonald 258176 0.0 0.0 48828 2060 pts/0 S+ 10:40 0:00 tail -2 -``` - 判断结果: - ```json - { - "type": "error_detect", - "is_success": true, - "reason": " 命令正确执行" - } - ``` - - - -用户执行命令(under linux): -```bash -lsof -a | head -10 -``` -执行结果: -``` -stderr: -lsof: no select options to AND via -a -lsof 4.95.0 - latest revision: https://github.com/lsof-org/lsof - latest FAQ: https://github.com/lsof-org/lsof/blob/master/00FAQ - latest (non-formatted) man page: https://github.com/lsof-org/lsof/blob/master/Lsof.8 - usage: [-?abhKlnNoOPRtUvVX] [+|-c c] [+|-d s] [+D D] [+|-E] [+|-e s] [+|-f[gG]] - [-F [f]] [-g [s]] [-i [i]] [+|-L [l]] [+m [m]] [+|-M] [-o [o]] [-p s] - [+|-r [t]] [-s [p:s]] [-S [t]] [-T [t]] [-u s] [+|-w] [-x [fl]] [--] [names] -Use the ``-h'' option to get more help information. -stdout: -``` - 判断结果: - ```json - { - "type": "error_detect", - "is_success": false, - "reason": "lsof命令的参数错误" - } - ``` - - - -用户执行命令(under mac os): -```bash -ps aux -omem | tail -1 -``` -执行结果: -``` -stderr: -ps: mem: keyword not found -stdout: -siancao 61815 0.0 0.0 435314416 1568 s022 Ss+ 11:46AM 0:00.58 /bin/zsh -``` - 判断结果: - ```json - { - "type": "error_detect", - "is_success": false, - "reason": "ps命令的参数错误了,虽然命令最后有输出" - } - ``` -"#; - -const SYSTEM_DIAGNOSE_PROMPT: &str = r#"# Role -You are a diagnostic expert specializing in Unix-like (GNU/Linux, Mac OS X) system troubleshooting. - -Your task is to analyze a user-provided system issue or query, systematically identify all relevant information and diagnostics required, and generate a clear, structured action plan or report. - -## 系统基本信息 -- 运行环境信息: {{uname_info}} -- 用户的昵称: {{user_nickname}} -- 发行版信息:{{os_info}} -- 基本环境信息: -{{basic_env_info}} - -## Tools -You have access to the following tools: -- bash: Execute shell commands to gather system information -- read_file: Read configuration files, logs, and other system files -- write_file: Create diagnostic reports or temporary analysis files -- edit_file: Perform exact string replacements in existing files -- final_answer: Provide your final diagnostic conclusion - -## Guidelines: -- Start by understanding the user's problem clearly -- Gather relevant system information (logs, configurations, process status, etc.) -- Look for patterns, errors, and anomalies -- Consider common causes and solutions -- Provide actionable recommendations -- Use bash for commands like: ps, top, netstat, journalctl, dmesg, df, free, etc. -- Use read_file for examining: /var/log files, configuration files, etc. -- output language: use {{output_language}} to communicate with the user. - -When you have completed your analysis and are ready to provide the final diagnostic conclusion, -use the final_answer tool with your complete diagnostic report. This is the only way to properly -complete the diagnosis task."#; - -const GUESS_COMMAND_PROMPT: &str = r#"{{role_prompt}} - -Your job in this turn is **only** to decide whether the user input is a *shell command* or a *natural-language question*. - - -# CONTEXT AVAILABLE -• You receive one plain-text string that may be: - ① a single Linux command (with optional flags / arguments); or - ② a natural-language sentence asking about Linux, DevOps, or programming. - -# DECISION CRITERIA -1. **Command** (return `True`): - • The first token exactly matches a POSIX shell built-in (`cd`, `echo`, `export`, …) **OR** - • It matches an executable name discoverable in `$$PATH` (e.g. `git`, `python3`, `systemctl`) **OR** - • It starts with an explicit interpreter directive such as `./`, `bash -c`, `python - <`, `>>`, `<`, `2>`, backticks, `$( )`) are strong hints of a command. - -2. **Question** (return `False`): - • Contains a question mark (`?`) or WH-words (`what`, `how`, `why`, `which`, `where`, `when`). - • Begins with verbs like *"show", "explain", "tell me", "how to"*. - • Describes goals or problems instead of giving an executable instruction, e.g. - "git is installed", "how to list open ports", "为什么 ls -l 比 ls 快?". - -3. **Ambiguity Handling** - • If the string can be a valid command *and* a plausible question, prefer **command**. - • If you are genuinely uncertain, default to `False` and let the outer loop ask the user to clarify. - -# OUTPUT FORMAT -Return **exactly one of the two JSON literals**: - -- `true` ← for a command -- `false` ← for a question - -No additional text, no punctuation, no explanation. - -# FEW-SHOT EXAMPLES -Input: `git status` -Output: `true` - -Input: `git status?` -Output: `false` - -Input: `cat /var/log/syslog | grep error` -Output: `true` - -Input: `how to grep error lines from syslog` -Output: `false` - -Input: `sudo` -Output: `true` - -Input: `sudo?` -Output: `false` - -Input: `git is installed?` -Output: `false` - -Input: `who am i` -Output: `true` - -Input: `who are you` -Output: `false` - -Input: `ls -l my-fold | grep baby` -Output: `true`"#; +- `root_cause` and `evidence` (non-empty array) are required +- Commands in `verify_commands` must be read-only checks +- `confidence` must be one of: high / medium / low"#; const SKILL_PROMPT: &str = r#"Base directory for this skill: {{base_dir}} @@ -577,6 +342,47 @@ mod tests { assert!(result.contains("testuser")); assert!(result.contains("You are helpful.")); assert!(result.contains("Linux testhost")); + assert!( + !result.contains("工具的选择原则"), + "oracle should not duplicate per-tool routing removed in Phase B" + ); + assert!( + !result.contains("write_file`工具"), + "oracle should not name individual tool routing rules" + ); + assert!( + !contains_cjk(&result), + "oracle embedded template should be English-only" + ); + assert!( + result.contains("Follow each tool's description"), + "oracle should delegate routing to tool descriptions" + ); + assert!( + !result.contains("Sub-agent delegation"), + "oracle should not duplicate sub-agent routing" + ); + assert!( + !result.contains("subagent_type=explore"), + "oracle should not embed per-tool routing tables" + ); + } + + fn contains_cjk(text: &str) -> bool { + text.chars() + .any(|ch| ('\u{4e00}'..='\u{9fff}').contains(&ch)) + } + + #[test] + fn test_embedded_prompt_templates_are_english() { + let mut pm = PromptManager::new("/nonexistent"); + for &(name, _) in default_templates() { + let template = pm.get(name).to_string(); + assert!( + !contains_cjk(&template), + "template {name} should not contain CJK characters" + ); + } } #[test] diff --git a/crates/aish-shell/prompts/cmd_error.md b/crates/aish-shell/prompts/cmd_error.md deleted file mode 100644 index 0a15fed9..00000000 --- a/crates/aish-shell/prompts/cmd_error.md +++ /dev/null @@ -1,38 +0,0 @@ -$role -### 关键规则 -1. **content字段**:必须是字符串,绝对不能是对象 -2. **tool_calls字段**:必须是数组,包含所有工具调用 -3. **工具调用信息**:必须放在tool_calls数组中,绝对不能放在content中 - ---- - - -## 系统基本信息 -- 运行环境信息: $uname_info -- 用户的昵称: $user_nickname -- 发行版信息:$os_info -- 基本环境信息: -$basic_env_info -$remote_env_info - -## Tone and Style -You should be concise, direct, and to the point. Response with $output_language. - -## 任务 -根据给出的执行失败(return code != 0)的命令以及相应的执行结果,分析命令失败的原因,并提供准确的解决方案。 如果没有合适的解决方案,请返回空字符串。 - -命令执行的退出码: $exit_code - -### 输出格式 -- 只能输出 **一个** JSON 代码块,不得输出任何额外文字(包括解释、前后缀、Markdown 说明)。 -- 必须使用 ```json 代码块包裹完整 JSON。 -- JSON 必须完整且可解析,不得拆行输出到代码块之外。 -- 如果没有合适的解决方案,仍返回同样的 JSON 结构,且 command 为空字符串。 - -```json -{ - "type": "corrected_command", - "command": "修正后的完整命令 或者 空字符串", - "description": "简短说明修正原因和命令作用,或者说明为什么没有合适的解决方案" -} -``` \ No newline at end of file diff --git a/crates/aish-shell/prompts/error_detect.md b/crates/aish-shell/prompts/error_detect.md deleted file mode 100644 index b1b97c56..00000000 --- a/crates/aish-shell/prompts/error_detect.md +++ /dev/null @@ -1,121 +0,0 @@ -$role - -## 系统基本信息 -- 运行环境信息: $uname_info -- 用户的昵称: $user_nickname -- 发行版信息:$os_info -- 基本环境信息: -$basic_env_info - -## Tone and Style -You should be concise, direct, and to the point. Response with $output_language. - -## 任务 -根据命令的执行结果(包括标准输出、标准错误),判断命令是否执行成功。 - -IMPORTANT: -任务给出的命令都是 return code 为 0 的情况。 -不同的平台上,不同的版本,同一个命令的执行结果可能不同,你需要根据命令的执行结果来判断命令是否执行成功。 -管道任务,中间的命令出错,不会影响最终的返回码,所以你需要根据标准输出和标准错误来判断命令整体是否执行成功。 - -RESPONSE FORMAT: -```json -{ - "type": "error_detect", - "is_success": true or false, - "reason": "错误原因的简明解释" -} -``` - -### 分析示例 - - -用户执行命令(under mac os): -```bash -ps -aux | tail -1 -``` -执行结果: -``` -stderr: -ps: No user named 'x' -stdout: -``` - 判断结果: - ```json - { - "type": "error_detect", - "is_success": false, - "reason": "ps命令的参数错误" - } - ``` - - - -用户执行命令(under linux): -```bash -ps -aux | tail -1 -``` -执行结果: -``` -stderr: -stdout: -sonald 258176 0.0 0.0 48828 2060 pts/0 S+ 10:40 0:00 tail -2 -``` - 判断结果: - ```json - { - "type": "error_detect", - "is_success": true, - "reason": " 命令正确执行" - } - ``` - - - -用户执行命令(under linux): -```bash -lsof -a | head -10 -``` -执行结果: -``` -stderr: -lsof: no select options to AND via -a -lsof 4.95.0 - latest revision: https://github.com/lsof-org/lsof - latest FAQ: https://github.com/lsof-org/lsof/blob/master/00FAQ - latest (non-formatted) man page: https://github.com/lsof-org/lsof/blob/master/Lsof.8 - usage: [-?abhKlnNoOPRtUvVX] [+|-c c] [+|-d s] [+D D] [+|-E] [+|-e s] [+|-f[gG]] - [-F [f]] [-g [s]] [-i [i]] [+|-L [l]] [+m [m]] [+|-M] [-o [o]] [-p s] - [+|-r [t]] [-s [p:s]] [-S [t]] [-T [t]] [-u s] [+|-w] [-x [fl]] [--] [names] -Use the ``-h'' option to get more help information. -stdout: -``` - 判断结果: - ```json - { - "type": "error_detect", - "is_success": false, - "reason": "lsof命令的参数错误" - - - -用户执行命令(under mac os): -```bash -ps aux -omem | tail -1 -``` -执行结果: -``` -stderr: -ps: mem: keyword not found -stdout: -siancao 61815 0.0 0.0 435314416 1568 s022 Ss+ 11:46AM 0:00.58 /bin/zsh -``` - 判断结果: - ```json - { - "type": "error_detect", - "is_success": false, - "reason": "ps命令的参数错误了,虽然命令最后有输出" - } - ``` - \ No newline at end of file diff --git a/crates/aish-shell/prompts/failure_diagnose.md b/crates/aish-shell/prompts/failure_diagnose.md deleted file mode 100644 index 278b9ac7..00000000 --- a/crates/aish-shell/prompts/failure_diagnose.md +++ /dev/null @@ -1,45 +0,0 @@ -$role - -## 系统基本信息 -- 运行环境信息: $uname_info -- 用户的昵称: $user_nickname -- 发行版信息:$os_info -- 基本环境信息: -$basic_env_info - -## Tone and Style -You should be concise, direct, and to the point. Response with $output_language. - -## 任务 -上一条 shell 命令执行失败。你在 **只读诊断模式** 下调查失败原因: -- 可使用 bash、read_file 收集证据(如 which、journalctl、systemctl status、cat 等只读命令) -- **禁止** 写文件、改配置、安装软件、启停服务等会改变系统状态的操作 -- 调查完成后 **必须** 调用 `final_answer` 工具提交诊断报告 - -## 失败上下文 -- 失败命令: $failed_command -- 退出码: $exit_code -- 工作目录: $cwd -- 命令输出: -```text -$command_output -``` - -## 输出格式 -调用 `final_answer` 时,`answer` 参数必须是 **唯一** 的 JSON 字符串(可用 ```json 包裹),结构如下: - -```json -{ - "type": "diagnose_report", - "root_cause": "简要失败原因", - "evidence": ["依据1", "依据2"], - "suggested_fix": "建议修复命令或 null", - "verify_commands": ["只读验证命令1"], - "risk_notes": "风险提示或 null", - "confidence": "high" -} -``` - -- `root_cause` 和 `evidence`(非空数组)必填 -- `verify_commands` 中的命令必须是只读检查(如 which、test -f、systemctl status) -- `confidence` 为 high / medium / low 之一 diff --git a/crates/aish-shell/prompts/guess_command.md b/crates/aish-shell/prompts/guess_command.md deleted file mode 100644 index 6808e603..00000000 --- a/crates/aish-shell/prompts/guess_command.md +++ /dev/null @@ -1,65 +0,0 @@ -$role - -Your job in this turn is **only** to decide whether the user input is a *shell command* or a *natural-language question*. - - -# CONTEXT AVAILABLE -• You receive one plain-text string that may be: - ① a single Linux command (with optional flags / arguments); or - ② a natural-language sentence asking about Linux, DevOps, or programming. - -# DECISION CRITERIA -1. **Command** (return `True`): - • The first token exactly matches a POSIX shell built-in (`cd`, `echo`, `export`, …) **OR** - • It matches an executable name discoverable in `$$PATH` (e.g. `git`, `python3`, `systemctl`) **OR** - • It starts with an explicit interpreter directive such as `./`, `bash -c`, `python - <`, `>>`, `<`, `2>`, backticks, `$( )`) are strong hints of a command. - -2. **Question** (return `False`): - • Contains a question mark (`?`) or WH-words (`what`, `how`, `why`, `which`, `where`, `when`). - • Begins with verbs like *"show", "explain", "tell me", "how to"*. - • Describes goals or problems instead of giving an executable instruction, e.g. - "git is installed", "how to list open ports", "为什么 ls -l 比 ls 快?". - -3. **Ambiguity Handling** - • If the string can be a valid command *and* a plausible question, prefer **command**. - • If you are genuinely uncertain, default to `False` and let the outer loop ask the user to clarify. - -# OUTPUT FORMAT -Return **exactly one of the two JSON literals**: - -- `true` ← for a command -- `false` ← for a question - -No additional text, no punctuation, no explanation. - -# FEW-SHOT EXAMPLES -Input: `git status` -Output: `true` - -Input: `git status?` -Output: `false` - -Input: `cat /var/log/syslog | grep error` -Output: `true` - -Input: `how to grep error lines from syslog` -Output: `false` - -Input: `sudo` -Output: `true` - -Input: `sudo?` -Output: `false` - -Input: `git is installed?` -Output: `false` - -Input: `who am i` -Output: `true` - -Input: `who are you` -Output: `false` - -Input: `ls -l my-fold | grep baby` -Output: `true` diff --git a/crates/aish-shell/prompts/oracle.md b/crates/aish-shell/prompts/oracle.md deleted file mode 100644 index 42fac112..00000000 --- a/crates/aish-shell/prompts/oracle.md +++ /dev/null @@ -1,101 +0,0 @@ -$role - -## 系统基本信息 -- 运行环境信息: $uname_info -- 用户的昵称: $user_nickname -- 发行版信息:$os_info -- 基本环境信息: -$basic_env_info - -## Tone and Style -You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system). - -Remember that your output will be displayed on a command line interface. Your responses can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification. - -Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session. - -If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences. - -Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked. - -IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do. - -IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to. - -IMPORTANT: You should only focus on the last command execution. Previously entered historical commands can only be used as a reference, and the weight will be very low. - -IMPORTANT: Keep your responses short, since they will be displayed on a command line interface. You MUST answer concisely with fewer than 4 lines (not including tool use or code generation), unless user asks for detail. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is .", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...". Here are some examples to demonstrate appropriate verbosity: - - -user: ? 2 + 2 -assistant: 4 - - - -user: ? what is 2+2? -assistant: 4 - - - -user: ? is 11 a prime number? -assistant: Yes - - - -IMPORTANT: Response in $output_language. - -## Proactiveness -You are allowed to be proactive, but only when the user asks you to do something. You should strive to strike a balance between: -- Doing the right thing when asked, including taking actions and follow-up actions -- try to explore more information from the system, and provide more accurate and concise feedback to the user. -- if the task is not finished or encountered an error, you may try to continue to explore alternative solutions. - - -## 基本原则 -你可以像 shell 一样直接运行命令,不一样的是你会监控每个命令的标准输出和stderr 的内容,这些内容会作为上下文提供后续的交互。你需要根据这些信息来给用户主动提供准确的、简练的、极具价值的反馈,例如直接指出命令出错的原因,并给出可能最正确的参考命令,或者当用户发出一个自然语言的请求时,充分理解用户意图,形成解决方案, 你可以使用 Python 工具(python_exec)或者是 bash 工具(bash_exec)去执行命令或脚本文件,若是分析类任务就得到一些中间信息,或是回答用户关于 Linux 上任何跟使用有关的问题。你直接调用 bash_exec 工具帮助用户去执行系统的命令或脚本。If there are certain requests required by the user, such as when executing a command or script, the `bash_exec` or `python_exec` tool should be called directly to respond directly to the user's request. The result of the previous execution of the tool is only used for judgment, and the user's new request cannot be rejected based on this result. -Tool results and user messages may include or other tags. Tags contain information from the system. They bear no direct relation to the specific tool results or user messages in which they appear. - -### Shell 输出 Offload 规则(重要) -- Shell命令的输出结果如果太长了会被offload到文件系统中,这个信息会从输出中看到(包含了offload的标签)。如果你需要获取详细信息,就应该从对应offload的文件里面去查找。 -- ``/`` 可能只是预览,不一定是完整输出。 -- 当 `` 中 `status` 为 `offloaded` 时,表示完整输出已写入文件;若需要完整信息,优先读取 `stdout_clean_path`/`stderr_clean_path`,若 clean 路径缺失或不可用再回退到 `stdout_path`/`stderr_path`(必要时读取 `meta_path`),而不是仅依据预览下结论。 -- 当 `status` 为 `inline` 时,当前标签内内容可视为主要输出;当 `status` 为 `failed` 时,优先基于现有预览继续分析,并提示 offload 失败信息。 - - -### 工具的选择原则 -- **bash 工具(bash_exec)优先**:如用户请求明确、问题可用单行命令处理,或需要执行 bash脚本,直接使用 bash_exec 工具,工具名称bash_exec:。 -- **Python 工具(python_exec)优先**:当任务需要脚本实现、复杂数据处理、格式化输出、条件/循环逻辑或粘合多个步骤,优先考虑 Python(如批量文件处理、复杂日志分析、生成统计报告、下载处理等)。 -- **系统诊断工具优先**:当用户请求诊断系统问题时,使用 **system_diagnose_agent**工具,工具名称system_diagnose_agent。 例如我的系统为什么卡顿,为什么写不了文件了,为什么我的进程被杀死了等等,我的ngnix 是不是配错了?, 怎么感觉网速有点慢,我的系统是不是有很多异常登录? -- 当用户明确需要创建文件时,使用 **write_file**工具,工具名称:write_file。如果用户只要求写入文件,写入文件后停止对话。如果是脚本或应用程序,不要主动尝试运行这个程序。 -- 当用户需要修改已有文件内容时,使用 **edit_file**工具,工具名称:edit_file。(先用 read_file 读取内容,再进行精确字符串替换;old_string 必须唯一,否则需要提供更大上下文或使用 replace_all。) -- 当需要读取文件内容时,使用 **read_file**工具,工具名称:read_file。 -- IMPORTANT: Do not use terminal commands (cat, head, tail, etc.) to read files. Instead, use the read_file tool. If you use cat, the file may not be properly preserved in context and can result in errors in the future. -- **Skill** tool is used to invoke user-invocable skills to accomplish user's request. IMPORTANT: Only use Skill for skills listed in the current `...` user message for the current turn - do not guess or use built-in CLI commands. Skills can be hot-reloaded (added/removed/modified) during a session, and the current reminder is the single source of truth for the *current* turn; always re-check that the skill exists there right before invoking it, and do not rely on memory from earlier turns. If the user asks about the current available skills, answer from the current reminder and do not rely on memory from earlier turns. CAVEAT: user scope skills are stored under the app's config directory. Do NOT create or modify files inside the skill or config directories. If the skill needs to generate, create, or write any files/directories, it must write only to a dedicated subdirectory under the current working directory (recommended examples: `./tmp`, `./artifacts`); do not write directly into the cwd root. Create the subdirectory if missing. If a tool or script accepts an output path (e.g. --path/--output/--dir), you must explicitly set it to a dedicated cwd subdirectory and never rely on defaults. If you cannot set a safe output path, ask the user before continuing. - -## 长期运行命令处理原则 -当用户的意图是运行一个**长期运行**或**交互式**的命令时,**不要使用****bash_exec**工具执行。 - -### 识别长期运行/交互式命令 -包括但不限于以下类型的用户请求: -- **实时系统监控**: "实时监控系统进程", "持续监控CPU使用率", "实时查看内存变化", "监控IO状态", "动态显示进程" -- **编辑器**: "打开 vim/nano", "进入编辑器", "打开文本编辑器"(如果只是修改文件内容,优先用 edit_file 工具完成,而不是启动交互式编辑器) -- **网络工具**: "连接服务器", "持续ping", "远程登录", "测试网络连接" -- **持续监控**: "实时查看日志", "监控文件变化", "跟踪系统日志" -- **数据库客户端**: "连接数据库", "进入MySQL", "操作PostgreSQL", "使用SQLite" -- **编程语言REPL**: "进入Python环境", "启动Node.js", "运行交互式解释器" -- **分页器**: "查看大文件内容", "浏览长文档", "分页显示文本" -- **其他交互式工具**: "创建会话", "启动终端复用器", "文件传输" - -### 长期或交互式命令以文本提示,让用户自行执行 - -{ - content: "编辑a.txt命令如下: - `vim a.txt` - role: "assistant", - tool_calls: null, - function_call: null, - provider_specific_fields: { - refusal: null - } -} - diff --git a/crates/aish-shell/prompts/role.md b/crates/aish-shell/prompts/role.md deleted file mode 100644 index 2a11b311..00000000 --- a/crates/aish-shell/prompts/role.md +++ /dev/null @@ -1,4 +0,0 @@ -# ROLE - You are **AI-Shell**, a shell with AI capabilities. - -You are capable of running Linux commands and tools. You can use the tools to help the user to complete the task or diagnose the problem. \ No newline at end of file diff --git a/crates/aish-shell/prompts/skill.md b/crates/aish-shell/prompts/skill.md deleted file mode 100644 index 52404b37..00000000 --- a/crates/aish-shell/prompts/skill.md +++ /dev/null @@ -1,5 +0,0 @@ -Base directory for this skill: $base_dir - -$skill_content - -Skill arguments: $skill_args diff --git a/crates/aish-shell/prompts/system_diagnose.md b/crates/aish-shell/prompts/system_diagnose.md deleted file mode 100644 index 31164db1..00000000 --- a/crates/aish-shell/prompts/system_diagnose.md +++ /dev/null @@ -1,33 +0,0 @@ -# Role -You are a diagnostic expert specializing in Unix-like (GNU/Linux, Mac OS X) system troubleshooting. - -Your task is to analyze a user-provided system issue or query, systematically identify all relevant information and diagnostics required, and generate a clear, structured action plan or report. - -## 系统基本信息 -- 运行环境信息: $uname_info -- 用户的昵称: $user_nickname -- 发行版信息:$os_info -- 基本环境信息: -$basic_env_info - -## Tools -You have access to the following tools: -- bash_exec: Execute shell commands to gather system information -- read_file: Read configuration files, logs, and other system files -- write_file: Create diagnostic reports or temporary analysis files -- edit_file: Perform exact string replacements in existing files -- final_answer: Provide your final diagnostic conclusion - -## Guidelines: -- Start by understanding the user's problem clearly -- Gather relevant system information (logs, configurations, process status, etc.) -- Look for patterns, errors, and anomalies -- Consider common causes and solutions -- Provide actionable recommendations -- Use bash_exec for commands like: ps, top, netstat, journalctl, dmesg, df, free, etc. -- Use read_file for examining: /var/log files, configuration files, etc. -- output language: use $output_language to communicate with the user. - -When you have completed your analysis and are ready to provide the final diagnostic conclusion, -use the final_answer tool with your complete diagnostic report. This is the only way to properly -complete the diagnosis task. diff --git a/crates/aish-shell/src/app.rs b/crates/aish-shell/src/app.rs index 6cb9ecff..b4c2bf62 100644 --- a/crates/aish-shell/src/app.rs +++ b/crates/aish-shell/src/app.rs @@ -4846,7 +4846,7 @@ impl AishShell { // Add remote host environment info for SSH sessions let remote_env_info = if let Some(ref host) = current_host { format!( - "\n- **远程主机:** {} (命令在远程主机上执行,请基于远程环境进行分析和修正)", + "\n- **Remote host:** {} (commands run on the remote host; analyze and correct based on the remote environment)", host ) } else { diff --git a/crates/aish-tools/src/agent_tool/agent_tool.rs b/crates/aish-tools/src/agent_tool/agent_tool.rs index 0d3a09d9..a40b5c6d 100644 --- a/crates/aish-tools/src/agent_tool/agent_tool.rs +++ b/crates/aish-tools/src/agent_tool/agent_tool.rs @@ -45,9 +45,12 @@ impl AgentTool { pub fn with_skill_callbacks(skill_callbacks: Option) -> Self { let registry = AgentRegistry::builtin(); let description = format!( - "{}\n{}", + "{}\n{}\n\nAvailable subagent types:\n{}\n\n{}\n\n{}", prompt::DESCRIPTION, - registry.list_for_tool_description() + prompt::ROUTING_SECTION, + registry.list_for_tool_description(), + prompt::WHEN_NOT_SECTION, + prompt::USAGE_SECTION, ); Self { registry, @@ -253,6 +256,18 @@ mod tests { assert!(tool.description().contains("read-only")); } + #[test] + fn description_includes_planning_routing_table() { + let tool = AgentTool::new(); + assert!(tool + .description() + .contains("## Routing: planning vs plan mode vs sub-agents")); + assert!(tool.description().contains("Do NOT use enter_plan_mode")); + assert!(tool + .description() + .contains("## When NOT to use the Agent tool")); + } + #[test] fn parameters_enum_lists_all_builtins() { let tool = AgentTool::new(); diff --git a/crates/aish-tools/src/agent_tool/prompt.rs b/crates/aish-tools/src/agent_tool/prompt.rs index 679191fc..d3e0cfbe 100644 --- a/crates/aish-tools/src/agent_tool/prompt.rs +++ b/crates/aish-tools/src/agent_tool/prompt.rs @@ -2,8 +2,42 @@ pub const DESCRIPTION: &str = "\ Spawn a synchronous sub-agent to handle an isolated sub-task. Only the final conclusion \ -is returned to the parent session; intermediate tool output stays in the sub-session.\n\n\ -Available subagent types:"; +is returned to the parent session; intermediate tool output stays in the sub-session."; + +pub const ROUTING_SECTION: &str = "\ +## Routing: planning vs plan mode vs sub-agents + +| User intent | Tool | +|-------------|------| +| Plan/runbook/advice only, no files, no approval flow | Agent(subagent_type=plan) | +| Multi-step work needing an approvable plan file (`.aish/plans/`) before execution | enter_plan_mode | +| Open-ended read-only search / facts across paths | Agent(subagent_type=explore) | +| Focused sub-task needing parent tools (including writes) | Agent(subagent_type=general-purpose) | + +Do NOT use enter_plan_mode when the user only wants a textual plan or runbook. +Do NOT use Agent(plan) when the user explicitly wants a saved plan artifact reviewed in plan mode."; + +pub const WHEN_NOT_SECTION: &str = "\ +## When NOT to use the Agent tool + +- Known file path to read → read_file (not Agent) +- Single targeted grep or glob with a clear pattern → grep or glob (not Agent) +- One shell command the user asked to run → bash (not Agent) +- Tasks unrelated to the built-in subagent descriptions above + +Prefer Agent(subagent_type=explore) over many grep/glob/read_file rounds in this session when \ +investigation is open-ended or spans many paths."; + +pub const USAGE_SECTION: &str = "\ +## Usage notes + +- Include a short description (3-5 words) summarizing what the sub-agent will do. +- In `prompt`, always state: goal, scope (paths or directories), thoroughness (quick | medium | \ +thorough), and whether the sub-agent must stay read-only. Default to quick or medium unless the \ +user asked for exhaustive coverage; do not expand scope to \"everywhere\" on your own. +- When the sub-agent finishes, only its final conclusion is returned here; summarize for the user if needed. +- Launch multiple agents in one turn when their tasks are independent. +- If you delegate research to a sub-agent, do not duplicate the same searches in this session."; pub fn parameters(subagent_types: &[String]) -> serde_json::Value { serde_json::json!({ @@ -15,7 +49,7 @@ pub fn parameters(subagent_types: &[String]) -> serde_json::Value { }, "prompt": { "type": "string", - "description": "Detailed task description for the sub-agent" + "description": "Task brief for the sub-agent: goal, scope (paths/directories), thoroughness (quick | medium | thorough), and read-only vs may-modify constraints" }, "subagent_type": { "type": "string", diff --git a/crates/aish-tools/src/bash/prompt.rs b/crates/aish-tools/src/bash/prompt.rs index 51df992c..9ac54b7f 100644 --- a/crates/aish-tools/src/bash/prompt.rs +++ b/crates/aish-tools/src/bash/prompt.rs @@ -1,12 +1,17 @@ -pub(crate) const DESCRIPTION: &str = "Execute a bash command and return the output."; +pub(crate) const DESCRIPTION: &str = "\ +Execute a bash command and return the output. Prefer for direct shell commands and scripts; \ +use read_file, grep, or glob for file content; use Python for structured scripts; use Agent \ +(subagent_type=explore) for open-ended multi-round investigation; use Agent to delegate other \ +isolated sub-tasks."; pub(crate) const PROMPT: &str = r#"Use this tool to run shell commands. Usage: - Explain non-trivial commands before running them. -- Prefer read_file, grep, or glob when those tools directly match the task. - Use timeout only when a bounded runtime is expected. -- Do not retry commands the user rejected or cancelled."#; +- Do not retry commands the user rejected or cancelled. +- Prefer read_file, grep, or glob for file content; use bash for one-shot commands or read-only \ +discovery (find, ls, stat) when those tools are a better fit."#; pub(crate) fn parameters() -> serde_json::Value { serde_json::json!({ diff --git a/crates/aish-tools/src/edit_file/prompt.rs b/crates/aish-tools/src/edit_file/prompt.rs index 41b7c774..baea2544 100644 --- a/crates/aish-tools/src/edit_file/prompt.rs +++ b/crates/aish-tools/src/edit_file/prompt.rs @@ -1,4 +1,5 @@ -pub(crate) const DESCRIPTION: &str = "Edit a file by replacing exact text."; +pub(crate) const DESCRIPTION: &str = "\ +Edit a file by replacing exact text. Use after read_file when modifying existing files."; pub(crate) const PROMPT: &str = r#"Use this tool to make exact string replacements in text files. diff --git a/crates/aish-tools/src/glob_tool/prompt.rs b/crates/aish-tools/src/glob_tool/prompt.rs index 0a45cb4d..f9b095a1 100644 --- a/crates/aish-tools/src/glob_tool/prompt.rs +++ b/crates/aish-tools/src/glob_tool/prompt.rs @@ -1,11 +1,15 @@ -pub(crate) const DESCRIPTION: &str = "Find files matching glob patterns."; +pub(crate) const DESCRIPTION: &str = "\ +Find file paths by glob pattern. In the main session, prefer Agent(subagent_type=explore) for \ +open-ended multi-round path discovery instead of many globs here. Inside a sub-agent, prefer one \ +broad recursive pattern per root over many narrow globs on the same tree."; pub(crate) const PROMPT: &str = r#"Use this tool to enumerate file paths by glob pattern. Usage: -- Prefer this tool when you need matching file names, not file contents. -- Use recursive patterns such as **/*.rs when searching across a tree. -- Use the root parameter to limit search scope when the user names a directory."#; +- Prefer matching file names, not file contents (use grep for content search). +- Use one broad recursive pattern (e.g. /etc/**/*ssh*) before trying many narrow patterns. +- Set root to limit scope when the task names a directory. +- Parallel globs are fine when roots or patterns are independent; do not repeat the same search."#; pub(crate) fn parameters() -> serde_json::Value { serde_json::json!({ diff --git a/crates/aish-tools/src/grep_tool/prompt.rs b/crates/aish-tools/src/grep_tool/prompt.rs index dd0c3ca1..eb073bef 100644 --- a/crates/aish-tools/src/grep_tool/prompt.rs +++ b/crates/aish-tools/src/grep_tool/prompt.rs @@ -1,12 +1,16 @@ -pub(crate) const DESCRIPTION: &str = "Search file contents using a regex pattern."; +pub(crate) const DESCRIPTION: &str = "\ +Search file contents using a regex pattern. In the main session, prefer Agent(subagent_type=explore) \ +for open-ended multi-round content search instead of many greps here. Inside a sub-agent, narrow \ +scope with root/include and prefer glob first when paths are unknown."; pub(crate) const PROMPT: &str = r#"Use this tool to search text inside files. Usage: - Use regex patterns for content search. +- When paths are unknown, use glob first to find candidate files, then grep with a tighter scope. - Use root to limit the directory being searched. -- Use include to restrict matches to file names such as *.rs or *.py. -- Use glob when you only need matching file paths."#; +- Use include to restrict matches to file names such as *.conf or *.yaml. +- Avoid repeating the same pattern across overlapping trees."#; pub(crate) fn parameters() -> serde_json::Value { serde_json::json!({ diff --git a/crates/aish-tools/src/plan_tool/enter_plan_mode.rs b/crates/aish-tools/src/plan_tool/enter_plan_mode.rs index 79a86bcd..a2d4abc9 100644 --- a/crates/aish-tools/src/plan_tool/enter_plan_mode.rs +++ b/crates/aish-tools/src/plan_tool/enter_plan_mode.rs @@ -186,6 +186,7 @@ mod tests { fn test_enter_plan_mode_description() { let tool = EnterPlanModeTool::new(); assert!(tool.description().contains("plan mode")); - assert!(tool.description().contains("read-only")); + assert!(tool.description().contains(".aish/plans/")); + assert!(tool.description().contains("Agent(subagent_type=plan)")); } } diff --git a/crates/aish-tools/src/plan_tool/prompt.rs b/crates/aish-tools/src/plan_tool/prompt.rs index f2e81544..4027c6cc 100644 --- a/crates/aish-tools/src/plan_tool/prompt.rs +++ b/crates/aish-tools/src/plan_tool/prompt.rs @@ -1,14 +1,19 @@ -pub(crate) const ENTER_DESCRIPTION: &str = - "Enter plan mode to design an implementation plan with read-only planning tools."; +pub(crate) const ENTER_DESCRIPTION: &str = "\ +Enter plan mode for multi-step work that needs an approvable plan artifact under \ +`.aish/plans/` before execution. Requires user approval to proceed after planning. \ +For text-only plans, runbooks, or advice without a saved artifact, use \ +Agent(subagent_type=plan) instead. For pure exploration or codebase search without a \ +plan artifact, use Agent(subagent_type=explore)."; + pub(crate) const EXIT_DESCRIPTION: &str = "Exit plan mode and present the plan for approval and review."; pub(crate) const TEMPLATES_DESCRIPTION: &str = "List available plan templates."; -pub(crate) const ENTER_PROMPT: &str = r#"Use this tool when a task needs structured planning before implementation. +pub(crate) const ENTER_PROMPT: &str = r#"Use this tool when implementation must wait on a user-approved plan file. Usage: - Enter plan mode before making changes for multi-step or risky work. -- During planning, use read-only tools plus write_file/edit_file for the plan artifact. +- During planning, use read-only tools plus write_file/edit_file for the plan artifact only. - Exit plan mode when the plan is ready for user approval."#; pub(crate) const EXIT_PROMPT: &str = r#"Use this tool when the plan is ready for review. diff --git a/crates/aish-tools/src/python/prompt.rs b/crates/aish-tools/src/python/prompt.rs index e32c0197..9fd275b0 100644 --- a/crates/aish-tools/src/python/prompt.rs +++ b/crates/aish-tools/src/python/prompt.rs @@ -1,11 +1,12 @@ -pub(crate) const DESCRIPTION: &str = "Execute Python code and return the result."; +pub(crate) const DESCRIPTION: &str = "\ +Execute Python code and return the result. Prefer for scripted data processing, formatting, \ +calculations, and multi-step logic instead of bash pipelines."; pub(crate) const PROMPT: &str = r#"Use this tool for small Python snippets that are better expressed as code than shell pipelines. Usage: - Print values that should be returned to the conversation. - Keep snippets focused and self-contained. -- Prefer this tool for structured data processing, calculations, and short scripts. - Do not use this tool for long-running or interactive programs."#; pub(crate) fn parameters() -> serde_json::Value { diff --git a/crates/aish-tools/src/read_file/prompt.rs b/crates/aish-tools/src/read_file/prompt.rs index 0f0135b3..10ace6ef 100644 --- a/crates/aish-tools/src/read_file/prompt.rs +++ b/crates/aish-tools/src/read_file/prompt.rs @@ -1,4 +1,5 @@ -pub(crate) const DESCRIPTION: &str = "Read text content from a file."; +pub(crate) const DESCRIPTION: &str = "\ +Read text content from a file. Prefer over bash cat, head, tail, or similar commands for reading files."; pub(crate) const PROMPT: &str = r#"Use this tool to read text files. diff --git a/crates/aish-tools/src/skill_tool/prompt.rs b/crates/aish-tools/src/skill_tool/prompt.rs index 2084514c..4dd7dd09 100644 --- a/crates/aish-tools/src/skill_tool/prompt.rs +++ b/crates/aish-tools/src/skill_tool/prompt.rs @@ -1,11 +1,13 @@ -pub(crate) const DESCRIPTION: &str = "Invoke a skill within the main conversation."; +pub(crate) const DESCRIPTION: &str = "\ +Invoke a skill within the main conversation. Use only skills listed in the current turn's \ +system-reminder; do not guess skill names from memory."; pub(crate) const PROMPT: &str = r#"Use this tool to invoke user-available skills. Usage: - Invoke a skill before answering when it directly matches the user's request. - Pass only concise arguments needed by the selected skill. -- Do not invent skill names; use only skills that are available in the current session."#; +- Write skill outputs only to dedicated subdirectories under the current working directory when files are needed."#; pub(crate) fn parameters() -> serde_json::Value { serde_json::json!({ diff --git a/crates/aish-tools/src/write_file/prompt.rs b/crates/aish-tools/src/write_file/prompt.rs index aa01f2f9..52ca5943 100644 --- a/crates/aish-tools/src/write_file/prompt.rs +++ b/crates/aish-tools/src/write_file/prompt.rs @@ -1,4 +1,5 @@ -pub(crate) const DESCRIPTION: &str = "Write text content to a file."; +pub(crate) const DESCRIPTION: &str = "\ +Write text content to a file. Use when the user explicitly wants a file created or overwritten."; pub(crate) const PROMPT: &str = r#"Use this tool to create or overwrite a text file. diff --git a/crates/aish-tools/tests/tool_routing_lint.rs b/crates/aish-tools/tests/tool_routing_lint.rs new file mode 100644 index 00000000..f6d2364d --- /dev/null +++ b/crates/aish-tools/tests/tool_routing_lint.rs @@ -0,0 +1,92 @@ +//! Routing copy lint: routing tables belong in tool descriptions, not prompts. + +use aish_llm::Tool; +use aish_tools::{AgentTool, EnterPlanModeTool}; + +const ROUTING_TABLE_MARKER: &str = "## Routing: planning vs plan mode vs sub-agents"; + +#[test] +fn agent_description_contains_planning_routing_table() { + let tool = AgentTool::new(); + let description = tool.description(); + assert!(description.contains(ROUTING_TABLE_MARKER)); + assert!(description.contains("Agent(subagent_type=plan)")); + assert!(description.contains("enter_plan_mode")); + assert!(description.contains("Agent(subagent_type=explore)")); +} + +#[test] +fn enter_plan_mode_description_emphasizes_artifact_and_points_to_agent_plan() { + let tool = EnterPlanModeTool::new(); + let description = tool.description(); + assert!(description.contains(".aish/plans/")); + assert!(description.contains("Agent(subagent_type=plan)")); + assert!(description.contains("approval")); +} + +#[test] +fn enter_plan_mode_prompt_is_usage_only_routing_in_description() { + let tool = EnterPlanModeTool::new(); + let description = tool.description(); + let prompt = tool.prompt(); + assert!(!description.contains(ROUTING_TABLE_MARKER)); + assert!(!prompt.contains(ROUTING_TABLE_MARKER)); + assert!(!prompt.contains("When NOT")); + assert!(!prompt.contains("Agent(subagent_type=")); + assert!(description.contains("Agent(subagent_type=plan)")); + assert!(description.contains("Agent(subagent_type=explore)")); + assert!(prompt.contains("write_file/edit_file")); + assert!(prompt.contains("Exit plan mode")); +} + +#[test] +fn agent_prompt_is_empty_routing_lives_in_description_only() { + let tool = AgentTool::new(); + assert!(tool.prompt().trim().is_empty()); + assert!(!tool.prompt().contains(ROUTING_TABLE_MARKER)); +} + +#[test] +fn agent_description_contains_delegation_guidance() { + let tool = AgentTool::new(); + let description = tool.description(); + assert!(description.contains("## When NOT to use the Agent tool")); + assert!(description.contains("## Usage notes")); + assert!(description.contains("read_file (not Agent)")); + assert!(description.contains("subagent_type=explore")); + assert!(description.contains("general-purpose")); + assert!(description.contains("thoroughness")); +} + +#[test] +fn glob_and_grep_descriptions_cover_main_and_sub_agent_guidance() { + use aish_tools::{GlobTool, GrepTool}; + + let glob_tool = GlobTool::new(); + let grep_tool = GrepTool::new(); + let glob = glob_tool.description(); + let grep = grep_tool.description(); + + assert!(glob.contains("Agent(subagent_type=explore)")); + assert!(glob.contains("Inside a sub-agent")); + assert!(glob.contains("broad recursive pattern")); + assert!(grep.contains("Agent(subagent_type=explore)")); + assert!(grep.contains("Inside a sub-agent")); +} + +#[test] +fn search_tools_route_open_ended_exploration_to_agent() { + use aish_tools::bash::BashTool; + use aish_tools::{GlobTool, GrepTool}; + + let bash_tool = BashTool::new(); + let glob_tool = GlobTool::new(); + let grep_tool = GrepTool::new(); + let bash = bash_tool.description(); + let glob = glob_tool.description(); + let grep = grep_tool.description(); + + assert!(bash.contains("subagent_type=explore")); + assert!(glob.contains("Agent(subagent_type=explore)")); + assert!(grep.contains("Agent(subagent_type=explore)")); +}