diff --git a/.claude/commands/github-pr.md b/.claude/commands/github-pr.md index ccc26ce3..e15e9c6d 100644 --- a/.claude/commands/github-pr.md +++ b/.claude/commands/github-pr.md @@ -1,6 +1,6 @@ --- description: Commit, create PR, monitor CI, fix failures, merge -allowed-tools: Bash(git status:*), Bash(git diff:*), Bash(git add:*), Bash(git commit:*), Bash(git push:*), Bash(git branch:*), Bash(git checkout:*), Bash(git log:*), Bash(gh pr:*), Bash(gh run:*), Bash(cargo check:*), Bash(cargo test:*), Bash(cargo clippy:*), Read, Edit, Write, Grep, Glob +allowed-tools: Bash(git status:*), Bash(git diff:*), Bash(git add:*), Bash(git commit:*), Bash(git push:*), Bash(git branch:*), Bash(git checkout:*), Bash(git log:*), Bash(git fetch:*), Bash(git merge:*), Bash(git rebase:*), Bash(git cherry-pick:*), Bash(git reset:*), Bash(gh pr:*), Bash(gh run:*), Bash(gh api:*), Bash(cargo check:*), Bash(cargo test:*), Bash(cargo clippy:*), Bash(bazel:*), Bash(rustfmt:*), Bash(sleep:*), Read, Edit, Write, Grep, Glob --- # GitHub PR 全流程 @@ -54,6 +54,43 @@ allowed-tools: Bash(git status:*), Bash(git diff:*), Bash(git add:*), Bash(git c 4. 记录 PR 编号,后续阶段会用到。 +## Phase 2.5: Conflict Detection + +CI 不触发的常见原因是 PR 有 conflict。创建 PR 后立即检查: + +``` +gh pr view --json mergeable,mergeStateStatus +``` + +- **mergeStateStatus: "CLEAN"** → 无冲突,继续 Phase 3 +- **mergeStateStatus: "DIRTY" / mergeable: "CONFLICTING"** → 需要解决冲突: + +**解决流程:** +1. `git fetch origin main` 获取最新 main +2. `git log --oneline origin/main..HEAD` 查看当前分支比 main 多了哪些 commit +3. 判断多余 commit 是否已经在 main 上(通过其他 PR squash 合并过): + - `git log --oneline ..origin/main` 对比 main 上的新 commit + - 如果当前分支的旧 commit 已在 main 上 squash 合并,只需保留本次 PR 的 commit +4. 解决方案(按优先级): + - **Cherry-pick 法**(推荐,当旧 commit 已在 main 上): + ``` + git reset --hard origin/main + git cherry-pick <本次PR的commit> + git push --force-with-lease + ``` + - **Rebase 法**(通用): + ``` + git rebase origin/main + # 逐个解决冲突 + git push --force-with-lease + ``` +5. 推送后重新检查 `gh pr view --json mergeable,mergeStateStatus` + +**注意**:worktree 中 `gh pr merge` 可能因为无法 checkout main 而失败,此时用 API 合并: +``` +gh api repos/{owner}/{repo}/pulls/{PR#}/merge -f merge_method=squash +``` + ## Phase 3: Monitor CI 1. 等待 10 秒让 CI 启动,然后开始轮询: @@ -115,6 +152,7 @@ allowed-tools: Bash(git status:*), Bash(git diff:*), Bash(git add:*), Bash(git c - 每个阶段完成后简要汇报进度 - 遇到不确定的决策(如 CI 失败原因不明)时询问用户 -- 绝不 force push - 绝不直接 push 到 main/master +- Force push 仅限 `--force-with-lease` 且仅在 rebase/cherry-pick 解决冲突后使用 - 如果 PR 需要 review approval 才能 merge,报告并等待用户指示 +- 在 worktree 中合并 PR 时,优先使用 `gh api` 而非 `gh pr merge`(避免 checkout main 失败) diff --git a/crates/loopal-agent-client/src/client.rs b/crates/loopal-agent-client/src/client.rs index 896931ed..8e366fba 100644 --- a/crates/loopal-agent-client/src/client.rs +++ b/crates/loopal-agent-client/src/client.rs @@ -55,6 +55,7 @@ impl AgentClient { no_sandbox: bool, resume: Option<&str>, lifecycle: Option<&str>, + agent_type: Option<&str>, ) -> anyhow::Result { let params = serde_json::json!({ "cwd": cwd.to_string_lossy(), @@ -65,6 +66,7 @@ impl AgentClient { "no_sandbox": no_sandbox, "resume": resume, "lifecycle": lifecycle, + "agent_type": agent_type, }); let result = self .connection diff --git a/crates/loopal-agent-hub/src/dispatch/dispatch_handlers.rs b/crates/loopal-agent-hub/src/dispatch/dispatch_handlers.rs index 8b13bedb..4e97c542 100644 --- a/crates/loopal-agent-hub/src/dispatch/dispatch_handlers.rs +++ b/crates/loopal-agent-hub/src/dispatch/dispatch_handlers.rs @@ -160,6 +160,7 @@ pub async fn handle_spawn_agent( let model = params["model"].as_str().map(String::from); let prompt = params["prompt"].as_str().map(String::from); let permission_mode = params["permission_mode"].as_str().map(String::from); + let agent_type = params["agent_type"].as_str().map(String::from); // Parent: use explicit "parent" field from params if present (cross-hub), // otherwise use from_agent (local spawn). @@ -180,6 +181,7 @@ pub async fn handle_spawn_agent( prompt, parent, permission_mode, + agent_type, ) .await }); diff --git a/crates/loopal-agent-hub/src/spawn_manager.rs b/crates/loopal-agent-hub/src/spawn_manager.rs index 06d1df5d..a573e6ca 100644 --- a/crates/loopal-agent-hub/src/spawn_manager.rs +++ b/crates/loopal-agent-hub/src/spawn_manager.rs @@ -12,6 +12,7 @@ use loopal_protocol::{AgentEvent, AgentEventPayload, Envelope}; use crate::hub::Hub; /// Spawn a real agent process, initialize, start, and register in Hub. +#[allow(clippy::too_many_arguments)] pub async fn spawn_and_register( hub: Arc>, name: String, @@ -20,6 +21,7 @@ pub async fn spawn_and_register( prompt: Option, parent: Option, permission_mode: Option, + agent_type: Option, ) -> Result { info!(agent = %name, parent = ?parent, "spawn: forking process"); let agent_proc = loopal_agent_client::AgentProcess::spawn(None) @@ -44,6 +46,7 @@ pub async fn spawn_and_register( false, None, Some("ephemeral"), // sub-agents always exit on idle + agent_type.as_deref(), ) .await { diff --git a/crates/loopal-agent-hub/tests/suite/e2e_bootstrap_test.rs b/crates/loopal-agent-hub/tests/suite/e2e_bootstrap_test.rs index 203795ca..4788d610 100644 --- a/crates/loopal-agent-hub/tests/suite/e2e_bootstrap_test.rs +++ b/crates/loopal-agent-hub/tests/suite/e2e_bootstrap_test.rs @@ -59,6 +59,7 @@ async fn full_bootstrap_hub_to_agent_roundtrip() { true, // no sandbox None, None, // lifecycle: default + None, // agent_type ) .await .expect("start_agent should work"); diff --git a/crates/loopal-agent-server/src/agent_setup.rs b/crates/loopal-agent-server/src/agent_setup.rs index 2eb7e80d..92914960 100644 --- a/crates/loopal-agent-server/src/agent_setup.rs +++ b/crates/loopal-agent-server/src/agent_setup.rs @@ -123,6 +123,7 @@ pub fn build_with_frontend( &cwd.to_string_lossy(), &skills_summary, &config.memory, + start.agent_type.as_deref(), ); // Append MCP server instructions (from initialize handshake). diff --git a/crates/loopal-agent-server/src/memory_adapter.rs b/crates/loopal-agent-server/src/memory_adapter.rs index b630fa6a..a71e4d4f 100644 --- a/crates/loopal-agent-server/src/memory_adapter.rs +++ b/crates/loopal-agent-server/src/memory_adapter.rs @@ -49,6 +49,7 @@ impl MemoryProcessor for ServerMemoryProcessor { cwd_override: None, permission_mode: None, target_hub: None, + agent_type: None, }; spawn_agent(&self.shared, params).await?; info!("memory-maintainer agent spawned via Hub"); diff --git a/crates/loopal-agent-server/src/params.rs b/crates/loopal-agent-server/src/params.rs index a40744f6..b69ef24d 100644 --- a/crates/loopal-agent-server/src/params.rs +++ b/crates/loopal-agent-server/src/params.rs @@ -16,6 +16,8 @@ pub struct StartParams { pub resume: Option, /// Explicit lifecycle mode. Ephemeral exits on idle, Persistent waits. pub lifecycle: loopal_runtime::LifecycleMode, + /// Agent type for fragment selection (e.g. "explore", "plan"). + pub agent_type: Option, } /// Build a Kernel from config (production path: MCP, tools). diff --git a/crates/loopal-agent-server/src/session_start.rs b/crates/loopal-agent-server/src/session_start.rs index 3dac01e9..8ff6e915 100644 --- a/crates/loopal-agent-server/src/session_start.rs +++ b/crates/loopal-agent-server/src/session_start.rs @@ -62,6 +62,7 @@ pub(crate) async fn start_session( no_sandbox: params["no_sandbox"].as_bool().unwrap_or(false), resume: params["resume"].as_str().map(String::from), lifecycle, + agent_type: params["agent_type"].as_str().map(String::from), }; let mut config = load_config(&cwd)?; diff --git a/crates/loopal-agent-server/tests/suite/hub_harness.rs b/crates/loopal-agent-server/tests/suite/hub_harness.rs index a7ea2761..70aae7c1 100644 --- a/crates/loopal-agent-server/tests/suite/hub_harness.rs +++ b/crates/loopal-agent-server/tests/suite/hub_harness.rs @@ -130,6 +130,7 @@ pub async fn build_hub_harness_with( no_sandbox: true, resume: None, lifecycle: loopal_runtime::LifecycleMode::Persistent, + agent_type: None, }; let (hub_conn, _hub_peer) = loopal_ipc::duplex_pair(); let hub_connection = std::sync::Arc::new(loopal_ipc::Connection::new(hub_conn)); diff --git a/crates/loopal-agent/src/spawn.rs b/crates/loopal-agent/src/spawn.rs index a7a191fb..4e840f64 100644 --- a/crates/loopal-agent/src/spawn.rs +++ b/crates/loopal-agent/src/spawn.rs @@ -19,6 +19,8 @@ pub struct SpawnParams { pub permission_mode: Option, /// Target hub for cross-hub spawn (e.g. "hub-b"). None = local hub. pub target_hub: Option, + /// Agent type for fragment selection (e.g. "explore", "plan"). + pub agent_type: Option, } /// Result returned from Hub after spawning. @@ -45,6 +47,7 @@ pub async fn spawn_agent( "model": params.model, "prompt": params.prompt, "permission_mode": params.permission_mode, + "agent_type": params.agent_type, }); if let Some(ref hub) = params.target_hub { request["target_hub"] = json!(hub); diff --git a/crates/loopal-agent/src/tools/collaboration/agent.rs b/crates/loopal-agent/src/tools/collaboration/agent.rs index d96da663..e1831e2b 100644 --- a/crates/loopal-agent/src/tools/collaboration/agent.rs +++ b/crates/loopal-agent/src/tools/collaboration/agent.rs @@ -130,6 +130,7 @@ async fn action_spawn( cwd_override, permission_mode: Some(perm_mode.to_string()), target_hub, + agent_type: subagent_type.map(String::from), }, ) .await; diff --git a/crates/loopal-agent/tests/suite/bridge_chain_test.rs b/crates/loopal-agent/tests/suite/bridge_chain_test.rs index 36560f1d..9e7df193 100644 --- a/crates/loopal-agent/tests/suite/bridge_chain_test.rs +++ b/crates/loopal-agent/tests/suite/bridge_chain_test.rs @@ -49,6 +49,7 @@ async fn full_chain_sub_agent_result_delivered_to_parent() { false, None, None, + None, ) .await .expect("start_agent"); diff --git a/crates/loopal-agent/tests/suite/bridge_child_test.rs b/crates/loopal-agent/tests/suite/bridge_child_test.rs index 9bd602b2..52350afd 100644 --- a/crates/loopal-agent/tests/suite/bridge_child_test.rs +++ b/crates/loopal-agent/tests/suite/bridge_child_test.rs @@ -66,6 +66,7 @@ pub(crate) async fn start_bridge_client( false, None, None, + None, ) .await .expect("start_agent"); @@ -156,6 +157,7 @@ async fn bridge_cancel_sends_shutdown() { false, None, None, + None, ) .await .unwrap(); diff --git a/crates/loopal-context/src/system_prompt.rs b/crates/loopal-context/src/system_prompt.rs index 0ddf0c25..faa463fc 100644 --- a/crates/loopal-context/src/system_prompt.rs +++ b/crates/loopal-context/src/system_prompt.rs @@ -12,6 +12,7 @@ pub fn build_system_prompt( cwd: &str, skills_summary: &str, memory: &str, + agent_type: Option<&str>, ) -> String { let mut registry = FragmentRegistry::new(system_fragments()); @@ -23,26 +24,15 @@ pub fn build_system_prompt( let builder = PromptBuilder::new(registry); + // Tool names/descriptions feed Minijinja conditionals in fragments + // (e.g. `{% if "Bash" in tool_names %}`). Full JSON schemas are sent + // separately via ChatParams.tools — no need to duplicate them here. let tool_names: Vec = tools.iter().map(|t| t.name.clone()).collect(); let tool_descriptions: HashMap = tools .iter() .map(|t| (t.name.clone(), t.description.clone())) .collect(); - // Build tool schema section (kept as-is for LLM function calling) - let tools_section = if tools.is_empty() { - String::new() - } else { - let mut s = String::from("# Available Tools\n"); - for tool in tools { - s.push_str(&format!( - "\n## {}\n{}\nParameters: {}\n", - tool.name, tool.description, tool.input_schema - )); - } - s - }; - let ctx = PromptContext { cwd: cwd.to_string(), platform: std::env::consts::OS.to_string(), @@ -61,18 +51,10 @@ pub fn build_system_prompt( skills_summary: skills_summary.to_string(), features: Vec::new(), agent_name: None, - agent_type: None, + agent_type: agent_type.map(String::from), }; - let mut prompt = builder.build(&ctx); - - // Append tool schemas after fragments (LLM needs the JSON schemas) - if !tools_section.is_empty() { - prompt.push_str("\n\n"); - prompt.push_str(&tools_section); - } - - prompt + builder.build(&ctx) } fn today() -> String { diff --git a/crates/loopal-context/tests/suite.rs b/crates/loopal-context/tests/suite.rs index ea44a364..9f73f891 100644 --- a/crates/loopal-context/tests/suite.rs +++ b/crates/loopal-context/tests/suite.rs @@ -13,6 +13,8 @@ mod pipeline_test; mod smart_compact_test; #[path = "suite/store_test.rs"] mod store_test; +#[path = "suite/system_prompt_agent_test.rs"] +mod system_prompt_agent_test; #[path = "suite/system_prompt_test.rs"] mod system_prompt_test; #[path = "suite/token_counter_test.rs"] diff --git a/crates/loopal-context/tests/suite/system_prompt_agent_test.rs b/crates/loopal-context/tests/suite/system_prompt_agent_test.rs new file mode 100644 index 00000000..66fcc623 --- /dev/null +++ b/crates/loopal-context/tests/suite/system_prompt_agent_test.rs @@ -0,0 +1,108 @@ +use loopal_context::build_system_prompt; +use loopal_tool_api::ToolDefinition; + +#[test] +fn explore_subagent_full_prompt() { + let tools = vec![ + ToolDefinition { + name: "Read".into(), + description: "Read a file".into(), + input_schema: serde_json::json!({"type": "object"}), + }, + ToolDefinition { + name: "Grep".into(), + description: "Search file contents".into(), + input_schema: serde_json::json!({"type": "object"}), + }, + ToolDefinition { + name: "Bash".into(), + description: "Execute commands".into(), + input_schema: serde_json::json!({"type": "object"}), + }, + ]; + let result = build_system_prompt( + "Project rules", + &tools, + "act", + "/project", + "", + "", + Some("explore"), + ); + + // Explore-specific content present + assert!( + result.contains("READ-ONLY MODE"), + "explore fragment should be included" + ); + // Default sub-agent fragment excluded (explore is specific) + assert!( + !result.contains("sub-agent named"), + "default-subagent should be excluded when explore matches" + ); + // Core fragments still present + assert!( + result.contains("Output Efficiency"), + "core fragments should be included for sub-agents" + ); + // Tool usage policy still present + assert!( + result.contains("Tool Usage Policy"), + "tool usage policy should be included for sub-agents" + ); + // User instructions still present + assert!(result.contains("Project rules"), "instructions missing"); + // Tool schemas NOT in prompt + assert!( + !result.contains("# Available Tools"), + "tool schemas should not be in system prompt" + ); +} + +#[test] +fn root_agent_excludes_agent_fragments() { + let result = build_system_prompt("Base", &[], "act", "/workspace", "", "", None); + // No agent fragments in root prompt + assert!( + !result.contains("sub-agent named"), + "default-subagent should not appear for root agent" + ); + assert!( + !result.contains("READ-ONLY MODE"), + "explore fragment should not appear for root agent" + ); + // Core fragments present + assert!(result.contains("Output Efficiency")); +} + +#[test] +fn plan_subagent_gets_plan_fragment() { + let result = build_system_prompt("", &[], "act", "/work", "", "", Some("plan")); + assert!( + result.contains("software architect"), + "plan fragment should be included" + ); + assert!( + !result.contains("sub-agent named"), + "default-subagent excluded when plan matches" + ); + assert!( + !result.contains("READ-ONLY MODE"), + "explore fragment should not be included for plan agent" + ); +} + +#[test] +fn general_subagent_gets_default_fragment() { + let result = build_system_prompt("", &[], "act", "/work", "", "", Some("general")); + // Default sub-agent fragment (fallback for unknown types) + assert!( + result.contains("sub-agent named"), + "default-subagent should be included for unknown agent types" + ); + // Specialized fragments excluded + assert!( + !result.contains("READ-ONLY MODE"), + "explore fragment should not appear for general" + ); +} diff --git a/crates/loopal-context/tests/suite/system_prompt_test.rs b/crates/loopal-context/tests/suite/system_prompt_test.rs index 433455b8..620f9f0d 100644 --- a/crates/loopal-context/tests/suite/system_prompt_test.rs +++ b/crates/loopal-context/tests/suite/system_prompt_test.rs @@ -3,26 +3,28 @@ use loopal_tool_api::ToolDefinition; #[test] fn includes_instructions() { - let result = build_system_prompt("You are helpful.", &[], "act", "/tmp", "", ""); + let result = build_system_prompt("You are helpful.", &[], "act", "/tmp", "", "", None); assert!(result.contains("You are helpful.")); } #[test] -fn includes_tool_schemas() { +fn tool_schemas_not_in_system_prompt() { let tools = vec![ToolDefinition { name: "read".into(), description: "Read a file".into(), input_schema: serde_json::json!({"type": "object"}), }]; - let result = build_system_prompt("Base", &tools, "act", "/workspace", "", ""); - assert!(result.contains("# Available Tools")); - assert!(result.contains("## read")); - assert!(result.contains("Read a file")); + let result = build_system_prompt("Base", &tools, "act", "/workspace", "", "", None); + // Tool schemas should NOT appear in system prompt — they go via ChatParams.tools + assert!(!result.contains("# Available Tools")); + assert!(!result.contains("## read")); + // But tool names should still feed fragment conditionals + assert!(result.contains("Base")); // instructions still present } #[test] fn includes_fragments() { - let result = build_system_prompt("Base", &[], "act", "/workspace", "", ""); + let result = build_system_prompt("Base", &[], "act", "/workspace", "", "", None); // Core fragments should be present assert!( result.contains("Output Efficiency"), @@ -35,15 +37,28 @@ fn includes_fragments() { } #[test] -fn includes_environment() { - let result = build_system_prompt("Base", &[], "act", "/Users/dev/project", "", ""); - assert!(result.contains("/Users/dev/project"), "cwd not rendered"); +fn cwd_available_in_subagent_prompt() { + // Root agent: cwd is injected per-turn via env_context, not in static prompt. + // Sub-agent: cwd appears in default-subagent fragment template. + let result = build_system_prompt( + "Base", + &[], + "act", + "/Users/dev/project", + "", + "", + Some("general"), // any agent_type makes is_subagent() true + ); + assert!( + result.contains("/Users/dev/project"), + "cwd not rendered in sub-agent prompt" + ); } #[test] fn includes_skills() { let skills = "# Available Skills\n- /commit: Generate a git commit message"; - let result = build_system_prompt("Base", &[], "act", "/workspace", skills, ""); + let result = build_system_prompt("Base", &[], "act", "/workspace", skills, "", None); assert!(result.contains("Available Skills")); assert!(result.contains("/commit")); } @@ -57,6 +72,7 @@ fn includes_memory() { "/workspace", "", "## Key Patterns\n- Use DI", + None, ); assert!(result.contains("# Project Memory")); assert!(result.contains("Key Patterns")); @@ -64,7 +80,7 @@ fn includes_memory() { #[test] fn empty_memory_no_section() { - let result = build_system_prompt("Base", &[], "act", "/workspace", "", ""); + let result = build_system_prompt("Base", &[], "act", "/workspace", "", "", None); assert!(!result.contains("Project Memory")); } @@ -76,14 +92,14 @@ fn tool_conditional_fragments() { description: "Execute commands".into(), input_schema: serde_json::json!({"type": "object"}), }]; - let result = build_system_prompt("Base", &tools, "act", "/workspace", "", ""); + let result = build_system_prompt("Base", &tools, "act", "/workspace", "", "", None); assert!( result.contains("Bash Tool Guidelines"), "bash guidelines missing when Bash tool present" ); // Without Bash tool → no bash guidelines - let result_no_bash = build_system_prompt("Base", &[], "act", "/workspace", "", ""); + let result_no_bash = build_system_prompt("Base", &[], "act", "/workspace", "", "", None); assert!( !result_no_bash.contains("Bash Tool Guidelines"), "bash guidelines should not appear without Bash" @@ -136,23 +152,22 @@ fn report_token_usage() { let mem = "## Architecture\n- 17 Rust crates\n- 200-line limit"; let skills = "# Available Skills\n- /commit: Git commit\n- /review-pr: Review PR"; - let bare = build_system_prompt("", &[], "act", "/project", "", ""); - let with_tools = build_system_prompt("", &tools, "act", "/project", "", ""); - let full_act = build_system_prompt(instr, &tools, "act", "/project", skills, mem); - let full_plan = build_system_prompt(instr, &tools, "plan", "/project", skills, mem); + let bare = build_system_prompt("", &[], "act", "/project", "", "", None); + let with_tools = build_system_prompt("", &tools, "act", "/project", "", "", None); + let full_act = build_system_prompt(instr, &tools, "act", "/project", skills, mem, None); + let full_plan = build_system_prompt(instr, &tools, "plan", "/project", skills, mem, None); let t_bare = estimate_tokens(&bare); let t_tools = estimate_tokens(&with_tools); let t_act = estimate_tokens(&full_act); let t_plan = estimate_tokens(&full_plan); - eprintln!("\n=== System Prompt Token Analysis ==="); eprintln!( "Fragments only: {} tokens ({} chars)", t_bare, bare.len() ); - eprintln!("Fragments + 21 tool schemas: {t_tools} tokens"); + eprintln!("Fragments + 21 tools (cond): {t_tools} tokens"); eprintln!( "Full (act, 21 tools): {} tokens ({} chars)", t_act, @@ -166,7 +181,7 @@ fn report_token_usage() { eprintln!("Plan overhead: +{} tokens", t_plan - t_act); eprintln!("--- Breakdown ---"); eprintln!(" Behavior fragments: {t_bare} tokens"); - eprintln!(" Tool schemas: {} tokens", t_tools - t_bare); + eprintln!(" Tool-conditional: {} tokens", t_tools - t_bare); eprintln!(" Instructions: {} tokens", estimate_tokens(instr)); eprintln!(" Skills: {} tokens", estimate_tokens(skills)); eprintln!( diff --git a/crates/loopal-meta-hub/tests/e2e/cluster_harness.rs b/crates/loopal-meta-hub/tests/e2e/cluster_harness.rs index 10790cc4..475b7abd 100644 --- a/crates/loopal-meta-hub/tests/e2e/cluster_harness.rs +++ b/crates/loopal-meta-hub/tests/e2e/cluster_harness.rs @@ -106,7 +106,7 @@ impl HubHandle { client.initialize().await.expect("initialize"); let cwd = std::env::temp_dir(); client - .start_agent(&cwd, None, Some("act"), None, None, true, None, None) + .start_agent(&cwd, None, Some("act"), None, None, true, None, None, None) .await .expect("start_agent"); diff --git a/crates/loopal-prompt-system/prompts/agents/default-subagent.md b/crates/loopal-prompt-system/prompts/agents/default-subagent.md index 5dc4b7a8..e0562179 100644 --- a/crates/loopal-prompt-system/prompts/agents/default-subagent.md +++ b/crates/loopal-prompt-system/prompts/agents/default-subagent.md @@ -5,10 +5,19 @@ priority: 100 --- You are a sub-agent named '{{ agent_name | default("sub-agent") }}'. Your working directory is: {{ cwd }}. -Complete the task given to you. When done, output your findings or results directly as a clear summary. +Complete the task assigned to you and report your results. -Guidelines: -- Be thorough but efficient. -- Return file paths as absolute paths. -- If you encounter issues, report them clearly rather than guessing. -- Do not make changes outside the scope of your assigned task. +## Rules + +1. **Stay in scope**: Do not make changes outside your assigned task. +2. **Read before modifying**: Always read a file's current contents before editing it. +3. **Verify your work**: If you modify code, confirm it compiles or passes basic checks before reporting success. +4. **Report results, not process**: Focus your output on what you found or accomplished. Skip narrating each step. +5. **Use absolute paths**: Always reference files with their full absolute path. + +## Output + +When done, provide a clear summary of: +- What was accomplished (or what was found, for research tasks) +- Any issues encountered or decisions made +- File paths of modified or relevant files diff --git a/crates/loopal-prompt-system/prompts/agents/explore.md b/crates/loopal-prompt-system/prompts/agents/explore.md index 3920d3fa..357dd3bb 100644 --- a/crates/loopal-prompt-system/prompts/agents/explore.md +++ b/crates/loopal-prompt-system/prompts/agents/explore.md @@ -1,35 +1,46 @@ --- name: Explore Agent category: agents +condition: agent +condition_value: explore priority: 100 --- -You are a file search specialist. You excel at thoroughly navigating and exploring codebases. +You are a codebase exploration specialist. Your sole purpose is to search, read, and analyze existing code — nothing else. === CRITICAL: READ-ONLY MODE — NO FILE MODIFICATIONS === -This is a READ-ONLY exploration task. You are STRICTLY PROHIBITED from: -- Creating new files (no Write, touch, or file creation of any kind) -- Modifying existing files (no Edit operations) -- Deleting files (no rm or deletion) -- Moving or copying files (no mv or cp) +You are STRICTLY PROHIBITED from: +- Creating, modifying, deleting, moving, or copying files - Using redirect operators (>, >>, |) or heredocs to write to files -- Running ANY commands that change system state +- Running commands that change system or repository state (git add, git commit, npm install, etc.) -Your role is EXCLUSIVELY to search and analyze existing code. +## Search Strategy -Your strengths: -- Rapidly finding files using glob patterns -- Searching code with powerful regex patterns -- Reading and analyzing file contents +1. **Start broad**: Use Glob to understand directory structure and find files by name patterns. +2. **Narrow down**: Use Grep with regex to locate specific code patterns, function definitions, or string literals. +3. **Read specifics**: Use Read when you know the exact file path and need full context. +4. **Maximize parallelism**: Launch up to 5 independent tool calls in a single turn. Do not serialize searches that can run concurrently. -Guidelines: -- Use Glob for file pattern matching -- Use Grep for content search with regex -- Use Read when you know the specific file path -- Use Bash ONLY for read-only operations (ls, git status, git log, git diff, find, cat, head, tail) -- NEVER use Bash for: mkdir, touch, rm, cp, mv, git add, git commit, npm install, or any modification -- Return file paths as absolute paths -- Do not use emojis +## Tool Preferences -NOTE: You are meant to be a fast agent. Make efficient use of tools and spawn multiple parallel tool calls wherever possible. +- **Glob** for file pattern matching (`**/*.rs`, `src/**/mod.rs`) +- **Grep** for content search with regex (function signatures, imports, error patterns) +- **Read** for reading known files (always use absolute paths) +- **Bash** ONLY for: `ls`, `git log`, `git diff`, `git blame`, `wc`, `find` (read-only operations) +- NEVER use Bash for: `mkdir`, `touch`, `rm`, `cp`, `mv`, `git add`, `git commit` -Complete the search request efficiently and report findings clearly. +## Output Requirements + +Structure your findings clearly: + +1. **Files found**: List relevant file paths with one-line descriptions of their role. +2. **Key code excerpts**: Include the most relevant code snippets with `file_path:line_number` format. +3. **Patterns observed**: Architectural decisions, naming conventions, or recurring patterns you noticed. +4. **Not found / Uncertain**: Explicitly state what you searched for but could not find. Never fabricate results. + +## Guidelines + +- Return all file paths as absolute paths. +- When a search term has multiple possible spellings or conventions (snake_case, camelCase, kebab-case), try all variations. +- If the scope is unclear, ask for clarification rather than guessing. +- Keep your response focused and avoid unnecessary commentary. +- Do not use emojis. diff --git a/crates/loopal-prompt-system/prompts/agents/plan.md b/crates/loopal-prompt-system/prompts/agents/plan.md index b02cf190..49447b86 100644 --- a/crates/loopal-prompt-system/prompts/agents/plan.md +++ b/crates/loopal-prompt-system/prompts/agents/plan.md @@ -1,22 +1,41 @@ --- name: Plan Agent category: agents +condition: agent +condition_value: plan priority: 100 --- -You are a software architect agent. Your job is to design implementation approaches based on exploration results. - -Guidelines: -- Analyze the provided context thoroughly before proposing solutions. -- Consider multiple approaches and evaluate trade-offs (simplicity, performance, maintainability). -- Identify critical files that will need modification. -- Note existing patterns and utilities that should be reused. -- Flag potential risks, edge cases, and breaking changes. -- Provide a concrete, step-by-step implementation plan. - -Your output should include: -1. **Approach summary** — one paragraph describing the chosen strategy and why. -2. **Files to modify** — list with file paths and what changes are needed. -3. **Implementation steps** — ordered list of specific changes. -4. **Risks and considerations** — anything the implementer should watch out for. - -Keep your plan actionable and specific. Avoid vague suggestions like "refactor as needed." +You are a software architect agent. Your job is to explore the codebase, understand existing patterns, and design a concrete implementation plan. + +## Critical Rules + +1. **Read before designing**: You MUST read the critical files yourself. Never design based on assumptions or summaries — verify by reading the actual code. +2. **Reference existing code**: Identify reusable functions, utilities, types, and patterns. Always cite them with `file_path:line_number`. +3. **Be specific**: Your plan must be detailed enough for an implementation agent that has never seen this codebase to execute it directly. +4. **One recommended approach**: Present your best approach with clear reasoning. Do not list multiple alternatives without a recommendation. + +## Tools Available + +You have read-only access. Use: +- **Glob** to find files by pattern +- **Grep** to search for code patterns and references +- **Read** to examine file contents +- **Bash** for read-only commands only (ls, git log, git diff, wc) + +## Output Format + +### Approach Summary +One paragraph: what you propose, why this approach over alternatives, and the key design decision. + +### Critical Files +List every file that will be modified or created, with a one-line description of the change: +``` +- `path/to/file.rs` — add new_function() for X +- `path/to/test.rs` — add test coverage for Y +``` + +### Implementation Steps +Numbered, ordered list of specific changes. Each step should reference exact file paths and function names. + +### Risks and Considerations +Potential issues: breaking changes, edge cases, performance concerns, or dependencies that need attention. diff --git a/crates/loopal-prompt-system/tests/suite.rs b/crates/loopal-prompt-system/tests/suite.rs index f8f5223f..85948d82 100644 --- a/crates/loopal-prompt-system/tests/suite.rs +++ b/crates/loopal-prompt-system/tests/suite.rs @@ -1,2 +1,4 @@ +#[path = "suite/fragments_agent_test.rs"] +mod fragments_agent_test; #[path = "suite/fragments_test.rs"] mod fragments_test; diff --git a/crates/loopal-prompt-system/tests/suite/fragments_agent_test.rs b/crates/loopal-prompt-system/tests/suite/fragments_agent_test.rs new file mode 100644 index 00000000..3364a0f1 --- /dev/null +++ b/crates/loopal-prompt-system/tests/suite/fragments_agent_test.rs @@ -0,0 +1,90 @@ +use loopal_prompt::{Condition, FragmentRegistry, PromptBuilder, PromptContext}; +use loopal_prompt_system::system_fragments; + +#[test] +fn agent_fragments_have_correct_conditions() { + let frags = system_fragments(); + let explore = frags.iter().find(|f| f.id == "agents/explore").unwrap(); + assert_eq!( + explore.condition, + Condition::Agent("explore".into()), + "explore.md should have Agent(\"explore\") condition" + ); + + let plan = frags.iter().find(|f| f.id == "agents/plan").unwrap(); + assert_eq!( + plan.condition, + Condition::Agent("plan".into()), + "plan.md should have Agent(\"plan\") condition" + ); + + let default = frags + .iter() + .find(|f| f.id == "agents/default-subagent") + .unwrap(); + assert_eq!( + default.condition, + Condition::Always, + "default-subagent.md should have Always condition (fallback)" + ); +} + +#[test] +fn explore_subagent_gets_explore_fragment_and_core() { + let frags = system_fragments(); + let registry = FragmentRegistry::new(frags); + + let ctx = PromptContext { + agent_type: Some("explore".into()), + cwd: "/project".into(), + tool_names: vec!["Read".into(), "Grep".into(), "Glob".into(), "Bash".into()], + ..Default::default() + }; + let selected = registry.select(&ctx); + let ids: Vec<&str> = selected.iter().map(|f| f.id.as_str()).collect(); + + // Gets explore-specific fragment + assert!(ids.contains(&"agents/explore"), "explore fragment missing"); + // Does NOT get default fallback + assert!( + !ids.contains(&"agents/default-subagent"), + "default should be excluded when explore matches" + ); + // Still gets core/tasks/tools fragments + assert!( + ids.contains(&"core/identity"), + "core identity should be included for sub-agents" + ); + assert!( + ids.contains(&"tools/usage-policy"), + "usage-policy should be included for sub-agents" + ); +} + +#[test] +fn subagent_prompt_includes_core_plus_agent_fragment() { + let frags = system_fragments(); + let registry = FragmentRegistry::new(frags); + let builder = PromptBuilder::new(registry); + + let ctx = PromptContext { + agent_type: Some("general".into()), + cwd: "/work".into(), + tool_names: vec!["Read".into(), "Bash".into()], + ..Default::default() + }; + let prompt = builder.build(&ctx); + + // Core behavioral fragments present + assert!( + prompt.contains("Output Efficiency"), + "core fragment missing" + ); + // Default sub-agent fragment present (fallback for "general") + assert!( + prompt.contains("sub-agent"), + "default-subagent fragment missing" + ); + // cwd rendered in default-subagent template + assert!(prompt.contains("/work"), "cwd not rendered"); +} diff --git a/crates/loopal-prompt-system/tests/suite/fragments_test.rs b/crates/loopal-prompt-system/tests/suite/fragments_test.rs index 5c1c07ad..1500c3a0 100644 --- a/crates/loopal-prompt-system/tests/suite/fragments_test.rs +++ b/crates/loopal-prompt-system/tests/suite/fragments_test.rs @@ -93,7 +93,8 @@ fn full_prompt_build() { prompt.contains("Executing Actions with Care"), "safety fragment missing" ); - assert!(prompt.contains("/home/user/project"), "cwd not rendered"); + // cwd is injected per-turn via env_context for root agent, not in static prompt. + // Sub-agent fragments (which do use cwd) are excluded when is_subagent=false. } #[test] diff --git a/crates/loopal-prompt/src/builder.rs b/crates/loopal-prompt/src/builder.rs index d7525116..82d58a2d 100644 --- a/crates/loopal-prompt/src/builder.rs +++ b/crates/loopal-prompt/src/builder.rs @@ -13,20 +13,15 @@ impl PromptBuilder { /// Build the full system prompt for the given context. /// - /// Assembly order: - /// 1. User instructions (raw, highest priority) - /// 2. Matched & rendered fragments (sorted by priority) + /// Assembly order (identity-first for stronger LLM attention): + /// 1. Matched & rendered fragments (sorted by priority: core → tasks → tools → modes) + /// 2. User instructions (project-specific, after core behavioral rules) /// 3. Skills summary /// 4. Project memory (tail) pub fn build(&self, ctx: &PromptContext) -> String { let mut parts = Vec::new(); - // 1. User instructions (injected raw) - if !ctx.instructions.is_empty() { - parts.push(ctx.instructions.clone()); - } - - // 2. Fragments: select → sort → render → collect + // 1. Fragments: select → sort → render → collect for frag in self.registry.select(ctx) { let rendered = self.registry.render(frag, ctx); let trimmed = rendered.trim(); @@ -35,6 +30,11 @@ impl PromptBuilder { } } + // 2. User instructions (after fragments so identity/core rules come first) + if !ctx.instructions.is_empty() { + parts.push(ctx.instructions.clone()); + } + // 3. Skills summary if !ctx.skills_summary.is_empty() { parts.push(ctx.skills_summary.clone()); @@ -48,30 +48,8 @@ impl PromptBuilder { parts.join("\n\n") } - /// Build a prompt for a specific sub-agent type. - /// - /// Looks for a fragment with id "agents/{agent_type}" and renders it. - /// Falls back to the default sub-agent prompt if not found. - pub fn build_agent_prompt(&self, agent_type: &str, ctx: &PromptContext) -> String { - let agent_id = format!("agents/{agent_type}"); - if let Some(frag) = self.registry.fragments().iter().find(|f| f.id == agent_id) { - self.registry.render(frag, ctx) - } else { - default_agent_prompt(ctx) - } - } - /// Access the underlying registry. pub fn registry(&self) -> &FragmentRegistry { &self.registry } } - -fn default_agent_prompt(ctx: &PromptContext) -> String { - let name = ctx.agent_name.as_deref().unwrap_or("sub-agent"); - format!( - "You are a sub-agent named '{name}'. Your working directory is: {}. \ - Complete the task given to you and report your findings.", - ctx.cwd - ) -} diff --git a/crates/loopal-prompt/src/context.rs b/crates/loopal-prompt/src/context.rs index e9021345..c3b049af 100644 --- a/crates/loopal-prompt/src/context.rs +++ b/crates/loopal-prompt/src/context.rs @@ -37,10 +37,18 @@ pub struct PromptContext { // -- Sub-agent context -- #[serde(skip_serializing_if = "Option::is_none")] pub agent_name: Option, + /// Agent type for fragment selection. Some(_) implies this is a sub-agent. #[serde(skip_serializing_if = "Option::is_none")] pub agent_type: Option, } +impl PromptContext { + /// Whether this context is for a sub-agent (determined by agent_type presence). + pub fn is_subagent(&self) -> bool { + self.agent_type.is_some() + } +} + impl Default for PromptContext { fn default() -> Self { Self { diff --git a/crates/loopal-prompt/src/fragment.rs b/crates/loopal-prompt/src/fragment.rs index 98ab2e50..995043c4 100644 --- a/crates/loopal-prompt/src/fragment.rs +++ b/crates/loopal-prompt/src/fragment.rs @@ -37,6 +37,8 @@ pub enum Condition { Feature(String), /// Only when the specified tool is available. Tool(String), + /// Only when spawned as the specified agent type ("explore", "plan"). + Agent(String), } // -- Frontmatter deserialization -- @@ -128,6 +130,7 @@ fn parse_condition(kind: Option<&str>, value: Option<&str>) -> Condition { Some("mode") => Condition::Mode(value.unwrap_or("plan").to_string()), Some("feature") => Condition::Feature(value.unwrap_or("").to_string()), Some("tool") => Condition::Tool(value.unwrap_or("").to_string()), + Some("agent") => Condition::Agent(value.unwrap_or("").to_string()), Some(other) => { tracing::warn!( condition = other, diff --git a/crates/loopal-prompt/src/registry.rs b/crates/loopal-prompt/src/registry.rs index 7ddeee8b..fe0c9458 100644 --- a/crates/loopal-prompt/src/registry.rs +++ b/crates/loopal-prompt/src/registry.rs @@ -1,7 +1,7 @@ use std::path::Path; use crate::context::PromptContext; -use crate::fragment::{Condition, Fragment, parse_fragment, parse_fragments_from_dir}; +use crate::fragment::{Category, Condition, Fragment, parse_fragment, parse_fragments_from_dir}; use crate::render::PromptRenderer; /// Manages a collection of prompt fragments with selection, rendering, @@ -41,11 +41,37 @@ impl FragmentRegistry { } /// Select fragments that match the given context, sorted by priority. + /// + /// Agents-category fragments are excluded for the root agent (non-subagent). + /// When a specific Agent condition matches (e.g. "explore"), the default + /// fallback agent fragment (Always + Agents) is excluded to avoid overlap. pub fn select<'a>(&'a self, ctx: &PromptContext) -> Vec<&'a Fragment> { + let is_subagent = ctx.is_subagent(); + + // Check if any Agent(type) condition matches for this context. + let has_specific_agent = is_subagent + && self.fragments.iter().any(|f| { + f.category == Category::Agents + && matches!(&f.condition, Condition::Agent(t) if ctx.agent_type.as_deref() == Some(t.as_str())) + }); + let mut matched: Vec<&Fragment> = self .fragments .iter() - .filter(|f| condition_matches(&f.condition, ctx)) + .filter(|f| { + // Agents category only relevant for sub-agents + if f.category == Category::Agents && !is_subagent { + return false; + } + // If a specific agent fragment matches, skip the Always fallback + if has_specific_agent + && f.category == Category::Agents + && f.condition == Condition::Always + { + return false; + } + condition_matches(&f.condition, ctx) + }) .collect(); matched.sort_by_key(|f| f.priority); matched @@ -68,6 +94,7 @@ fn condition_matches(cond: &Condition, ctx: &PromptContext) -> bool { Condition::Mode(m) => ctx.mode == *m, Condition::Feature(f) => ctx.features.contains(f), Condition::Tool(t) => ctx.tool_names.contains(t), + Condition::Agent(t) => ctx.agent_type.as_deref() == Some(t.as_str()), } } diff --git a/crates/loopal-prompt/tests/suite.rs b/crates/loopal-prompt/tests/suite.rs index 0e4d555f..7dee5b07 100644 --- a/crates/loopal-prompt/tests/suite.rs +++ b/crates/loopal-prompt/tests/suite.rs @@ -2,6 +2,8 @@ mod builder_test; #[path = "suite/fragment_test.rs"] mod fragment_test; +#[path = "suite/registry_agent_test.rs"] +mod registry_agent_test; #[path = "suite/registry_test.rs"] mod registry_test; #[path = "suite/render_test.rs"] diff --git a/crates/loopal-prompt/tests/suite/builder_test.rs b/crates/loopal-prompt/tests/suite/builder_test.rs index 08eb1f31..2fc4fd7a 100644 --- a/crates/loopal-prompt/tests/suite/builder_test.rs +++ b/crates/loopal-prompt/tests/suite/builder_test.rs @@ -1,13 +1,11 @@ -use loopal_prompt::{ - Category, Condition, Fragment, FragmentRegistry, PromptBuilder, PromptContext, -}; +use loopal_prompt::{FragmentRegistry, PromptBuilder, PromptContext}; -fn frag(id: &str, priority: u16, content: &str) -> Fragment { - Fragment { +fn frag(id: &str, priority: u16, content: &str) -> loopal_prompt::Fragment { + loopal_prompt::Fragment { id: id.to_string(), name: id.to_string(), - category: Category::Core, - condition: Condition::Always, + category: loopal_prompt::Category::Core, + condition: loopal_prompt::Condition::Always, priority, content: content.to_string(), } @@ -36,7 +34,7 @@ fn build_includes_instructions_and_memory() { ..Default::default() }; let prompt = builder.build(&ctx); - assert!(prompt.starts_with("Be helpful.")); + assert!(prompt.contains("Be helpful.")); assert!(prompt.contains("# Project Memory")); assert!(prompt.contains("User prefers Rust.")); } @@ -54,33 +52,18 @@ fn build_skips_empty_renders() { } #[test] -fn build_agent_prompt_fallback() { - let builder = PromptBuilder::new(FragmentRegistry::new(vec![])); - let ctx = PromptContext { - agent_name: Some("explorer".into()), - cwd: "/work".into(), - ..Default::default() - }; - let prompt = builder.build_agent_prompt("nonexistent", &ctx); - assert!(prompt.contains("explorer")); - assert!(prompt.contains("/work")); -} - -#[test] -fn build_agent_prompt_uses_fragment() { - let frags = vec![Fragment { - id: "agents/explore".into(), - name: "Explore".into(), - category: Category::Agents, - condition: Condition::Always, - priority: 100, - content: "You are an explorer in {{ cwd }}.".into(), - }]; +fn fragments_come_before_instructions() { + let frags = vec![frag("core/id", 100, "## Identity")]; let builder = PromptBuilder::new(FragmentRegistry::new(frags)); let ctx = PromptContext { - cwd: "/project".into(), + instructions: "## User Instructions".into(), ..Default::default() }; - let prompt = builder.build_agent_prompt("explore", &ctx); - assert_eq!(prompt, "You are an explorer in /project."); + let prompt = builder.build(&ctx); + let frag_pos = prompt.find("## Identity").unwrap(); + let instr_pos = prompt.find("## User Instructions").unwrap(); + assert!( + frag_pos < instr_pos, + "fragments ({frag_pos}) should come before instructions ({instr_pos})" + ); } diff --git a/crates/loopal-prompt/tests/suite/fragment_test.rs b/crates/loopal-prompt/tests/suite/fragment_test.rs index 93348004..a2b823b1 100644 --- a/crates/loopal-prompt/tests/suite/fragment_test.rs +++ b/crates/loopal-prompt/tests/suite/fragment_test.rs @@ -67,3 +67,21 @@ fn returns_none_without_frontmatter() { let raw = "Just plain text, no frontmatter."; assert!(parse_fragment("bad", raw).is_none()); } + +#[test] +fn parse_agent_condition() { + let raw = "\ +--- +name: Explore Agent +category: agents +condition: agent +condition_value: explore +priority: 100 +--- +Explore instructions. +"; + let frag = parse_fragment("agents/explore", raw).unwrap(); + assert_eq!(frag.category, Category::Agents); + assert_eq!(frag.condition, Condition::Agent("explore".to_string())); + assert_eq!(frag.priority, 100); +} diff --git a/crates/loopal-prompt/tests/suite/registry_agent_test.rs b/crates/loopal-prompt/tests/suite/registry_agent_test.rs new file mode 100644 index 00000000..1e3f222a --- /dev/null +++ b/crates/loopal-prompt/tests/suite/registry_agent_test.rs @@ -0,0 +1,141 @@ +use loopal_prompt::{Category, Condition, Fragment, FragmentRegistry, PromptContext}; + +fn agent_frag( + id: &str, + priority: u16, + condition: Condition, + category: Category, + content: &str, +) -> Fragment { + Fragment { + id: id.to_string(), + name: id.to_string(), + category, + condition, + priority, + content: content.to_string(), + } +} + +#[test] +fn agent_condition_matches_only_when_type_set() { + let frags = vec![agent_frag( + "agents/explore", + 100, + Condition::Agent("explore".into()), + Category::Agents, + "explore prompt", + )]; + let registry = FragmentRegistry::new(frags); + + // Root agent (no agent_type) → agents excluded + let root_ctx = PromptContext::default(); + assert!(registry.select(&root_ctx).is_empty()); + + // Sub-agent with wrong type → Agent condition doesn't match + let wrong_type = PromptContext { + agent_type: Some("plan".into()), + ..Default::default() + }; + assert!(registry.select(&wrong_type).is_empty()); + + // Sub-agent with correct type → matches + let correct_type = PromptContext { + agent_type: Some("explore".into()), + ..Default::default() + }; + let selected: Vec<&str> = registry + .select(&correct_type) + .iter() + .map(|f| f.id.as_str()) + .collect(); + assert_eq!(selected, vec!["agents/explore"]); +} + +#[test] +fn default_agent_excluded_when_specific_agent_matches() { + let frags = vec![ + agent_frag( + "agents/default", + 100, + Condition::Always, + Category::Agents, + "default sub-agent", + ), + agent_frag( + "agents/explore", + 100, + Condition::Agent("explore".into()), + Category::Agents, + "explore prompt", + ), + ]; + let registry = FragmentRegistry::new(frags); + + // Sub-agent "general" (no specific match) → gets default only + let general = PromptContext { + agent_type: Some("general".into()), + ..Default::default() + }; + let ids: Vec<&str> = registry + .select(&general) + .iter() + .map(|f| f.id.as_str()) + .collect(); + assert_eq!(ids, vec!["agents/default"]); + + // Sub-agent "explore" → gets explore, NOT default + let explore = PromptContext { + agent_type: Some("explore".into()), + ..Default::default() + }; + let ids: Vec<&str> = registry + .select(&explore) + .iter() + .map(|f| f.id.as_str()) + .collect(); + assert_eq!(ids, vec!["agents/explore"]); +} + +#[test] +fn agents_category_excluded_for_root() { + let frags = vec![ + Fragment { + id: "core/identity".into(), + name: "core/identity".into(), + category: Category::Core, + condition: Condition::Always, + priority: 100, + content: "identity".into(), + }, + agent_frag( + "agents/default", + 200, + Condition::Always, + Category::Agents, + "default agent", + ), + ]; + let registry = FragmentRegistry::new(frags); + + // Root agent → only core, no agents + let root_ctx = PromptContext::default(); + let ids: Vec<&str> = registry + .select(&root_ctx) + .iter() + .map(|f| f.id.as_str()) + .collect(); + assert_eq!(ids, vec!["core/identity"]); + + // Sub-agent → both + let sub_ctx = PromptContext { + agent_type: Some("general".into()), + ..Default::default() + }; + let ids: Vec<&str> = registry + .select(&sub_ctx) + .iter() + .map(|f| f.id.as_str()) + .collect(); + assert_eq!(ids, vec!["core/identity", "agents/default"]); +} diff --git a/crates/loopal-runtime/tests/agent_loop/llm_test.rs b/crates/loopal-runtime/tests/agent_loop/llm_test.rs index 39f59c70..9f98ce22 100644 --- a/crates/loopal-runtime/tests/agent_loop/llm_test.rs +++ b/crates/loopal-runtime/tests/agent_loop/llm_test.rs @@ -205,6 +205,7 @@ fn report_real_system_prompt_tokens() { "/Users/dev/project", "", "", + None, ); runner.params.config.system_prompt = real_prompt.clone(); let params = runner @@ -221,6 +222,7 @@ fn report_real_system_prompt_tokens() { "/Users/dev/project", "", "", + None, ); let fragment_tokens = loopal_context::estimate_tokens(&prompt_no_tools); diff --git a/crates/loopal-test-support/src/ipc_harness.rs b/crates/loopal-test-support/src/ipc_harness.rs index f3c383fe..d945b2e1 100644 --- a/crates/loopal-test-support/src/ipc_harness.rs +++ b/crates/loopal-test-support/src/ipc_harness.rs @@ -77,6 +77,7 @@ pub async fn build_ipc_harness( false, None, None, + None, ) .await .expect("agent/start failed"); diff --git a/src/bootstrap/hub_bootstrap.rs b/src/bootstrap/hub_bootstrap.rs index c5eb93ad..8df4a4b9 100644 --- a/src/bootstrap/hub_bootstrap.rs +++ b/src/bootstrap/hub_bootstrap.rs @@ -67,6 +67,7 @@ pub async fn bootstrap_hub_and_agent( cli.no_sandbox, cli.resume.as_deref(), lifecycle_str, + None, // root agent has no agent_type ) .await?;