Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 15 additions & 34 deletions crates/loopal-agent-server/src/agent_setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,6 @@ use loopal_runtime::frontend::traits::AgentFrontend;

use crate::params::StartParams;

const SCHEDULER_PROMPT: &str = "\n\n# Scheduled Messages\n\
Messages prefixed with `[scheduled]` are injected by the cron scheduler, \
not typed by the user. Treat them as automated prompts and execute the \
requested action without asking for confirmation. \
Use CronCreate/CronDelete/CronList tools to manage scheduled jobs.";

/// Build `AgentLoopParams` with a pre-constructed frontend (HubFrontend or IpcFrontend).
///
/// The caller provides the frontend and interrupt signal, decoupling agent setup
Expand Down Expand Up @@ -120,6 +114,19 @@ pub fn build_with_frontend(
let skills: Vec<_> = config.skills.values().map(|e| e.skill.clone()).collect();
let skills_summary = loopal_config::format_skills_summary(&skills);
let tool_defs = kernel.tool_definitions();

let mut features = Vec::new();
if config.settings.memory.enabled && memory_channel.is_some() {
features.push("memory".into());
}
if !config.settings.hooks.is_empty() {
features.push("hooks".into());
}
features.push("subagent".into());
if !config.settings.output_style.is_empty() {
features.push(format!("style_{}", config.settings.output_style));
}

let mut system_prompt = build_system_prompt(
&config.instructions,
&tool_defs,
Expand All @@ -128,37 +135,11 @@ pub fn build_with_frontend(
&skills_summary,
&config.memory,
start.agent_type.as_deref(),
features,
);

// Append MCP server instructions (from initialize handshake).
let mcp_instructions = kernel.mcp_instructions();
if !mcp_instructions.is_empty() {
system_prompt.push_str("\n\n# MCP Server Instructions\n");
for (server_name, instructions) in mcp_instructions {
system_prompt.push_str(&format!("\n## {server_name}\n{instructions}\n"));
}
}

// Append scheduler instructions (every agent has cron capability).
system_prompt.push_str(SCHEDULER_PROMPT);
crate::prompt_post::append_runtime_sections(&mut system_prompt, &kernel);

// Append MCP resource and prompt summaries so the LLM knows what's available.
let mcp_resources = kernel.mcp_resources();
if !mcp_resources.is_empty() {
system_prompt.push_str("\n\n# Available MCP Resources\n");
for (server, res) in mcp_resources {
let desc = res.description.as_deref().unwrap_or("");
system_prompt.push_str(&format!("\n- `{}` ({server}): {desc}", res.uri));
}
}
let mcp_prompts = kernel.mcp_prompts();
if !mcp_prompts.is_empty() {
system_prompt.push_str("\n\n# Available MCP Prompts\n");
for (server, p) in mcp_prompts {
let desc = p.description.as_deref().unwrap_or("");
system_prompt.push_str(&format!("\n- `{}` ({server}): {desc}", p.name));
}
}
let mut messages = resume_messages;
if let Some(prompt) = &start.prompt {
messages.push(loopal_message::Message::user(prompt));
Expand Down
1 change: 1 addition & 0 deletions crates/loopal-agent-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ mod ipc_frontend;
mod memory_adapter;
mod mock_loader;
mod params;
mod prompt_post;
mod server;
pub mod server_info;
mod server_init;
Expand Down
40 changes: 40 additions & 0 deletions crates/loopal-agent-server/src/prompt_post.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//! System prompt post-processing: appends MCP, scheduler, and resource sections.

use loopal_kernel::Kernel;

const SCHEDULER_PROMPT: &str = "\n\n# Scheduled Messages\n\
Messages prefixed with `[scheduled]` are injected by the cron scheduler, \
not typed by the user. Treat them as automated prompts and execute the \
requested action without asking for confirmation. \
Use CronCreate/CronDelete/CronList tools to manage scheduled jobs.";

/// Append MCP instructions, scheduler guidance, and resource/prompt summaries.
pub fn append_runtime_sections(prompt: &mut String, kernel: &Kernel) {
let mcp_instructions = kernel.mcp_instructions();
if !mcp_instructions.is_empty() {
prompt.push_str("\n\n# MCP Server Instructions\n");
for (server_name, instructions) in mcp_instructions {
prompt.push_str(&format!("\n## {server_name}\n{instructions}\n"));
}
}

prompt.push_str(SCHEDULER_PROMPT);

let mcp_resources = kernel.mcp_resources();
if !mcp_resources.is_empty() {
prompt.push_str("\n\n# Available MCP Resources\n");
for (server, res) in mcp_resources {
let desc = res.description.as_deref().unwrap_or("");
prompt.push_str(&format!("\n- `{}` ({server}): {desc}", res.uri));
}
}

let mcp_prompts = kernel.mcp_prompts();
if !mcp_prompts.is_empty() {
prompt.push_str("\n\n# Available MCP Prompts\n");
for (server, p) in mcp_prompts {
let desc = p.description.as_deref().unwrap_or("");
prompt.push_str(&format!("\n- `{}` ({server}): {desc}", p.name));
}
}
}
5 changes: 5 additions & 0 deletions crates/loopal-config/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ pub struct Settings {
/// Harness control parameters — configurable thresholds for the agent control loop.
#[serde(default)]
pub harness: HarnessConfig,

/// Output style override (e.g. "explanatory", "learning"). Empty = default.
#[serde(default)]
pub output_style: String,
}

impl Default for Settings {
Expand All @@ -74,6 +78,7 @@ impl Default for Settings {
thinking: ThinkingConfig::default(),
memory: MemoryConfig::default(),
harness: HarnessConfig::default(),
output_style: String::new(),
}
}
}
Expand Down
4 changes: 3 additions & 1 deletion crates/loopal-context/src/system_prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use loopal_prompt_system::system_fragments;
use loopal_tool_api::ToolDefinition;

/// Build a full system prompt using the fragment-based prompt system.
#[allow(clippy::too_many_arguments)]
pub fn build_system_prompt(
instructions: &str,
tools: &[ToolDefinition],
Expand All @@ -13,6 +14,7 @@ pub fn build_system_prompt(
skills_summary: &str,
memory: &str,
agent_type: Option<&str>,
features: Vec<String>,
) -> String {
let mut registry = FragmentRegistry::new(system_fragments());

Expand Down Expand Up @@ -49,7 +51,7 @@ pub fn build_system_prompt(
instructions: instructions.to_string(),
memory: memory.to_string(),
skills_summary: skills_summary.to_string(),
features: Vec::new(),
features,
agent_name: None,
agent_type: agent_type.map(String::from),
};
Expand Down
7 changes: 4 additions & 3 deletions crates/loopal-context/tests/suite/system_prompt_agent_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ fn explore_subagent_full_prompt() {
"",
"",
Some("explore"),
vec![],
);

// Explore-specific content present
Expand Down Expand Up @@ -61,7 +62,7 @@ fn explore_subagent_full_prompt() {

#[test]
fn root_agent_excludes_agent_fragments() {
let result = build_system_prompt("Base", &[], "act", "/workspace", "", "", None);
let result = build_system_prompt("Base", &[], "act", "/workspace", "", "", None, vec![]);
// No agent fragments in root prompt
assert!(
!result.contains("sub-agent named"),
Expand All @@ -77,7 +78,7 @@ fn root_agent_excludes_agent_fragments() {

#[test]
fn plan_subagent_gets_plan_fragment() {
let result = build_system_prompt("", &[], "act", "/work", "", "", Some("plan"));
let result = build_system_prompt("", &[], "act", "/work", "", "", Some("plan"), vec![]);
assert!(
result.contains("software architect"),
"plan fragment should be included"
Expand All @@ -94,7 +95,7 @@ fn plan_subagent_gets_plan_fragment() {

#[test]
fn general_subagent_gets_default_fragment() {
let result = build_system_prompt("", &[], "act", "/work", "", "", Some("general"));
let result = build_system_prompt("", &[], "act", "/work", "", "", Some("general"), vec![]);
// Default sub-agent fragment (fallback for unknown types)
assert!(
result.contains("sub-agent named"),
Expand Down
102 changes: 90 additions & 12 deletions crates/loopal-context/tests/suite/system_prompt_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use loopal_tool_api::ToolDefinition;

#[test]
fn includes_instructions() {
let result = build_system_prompt("You are helpful.", &[], "act", "/tmp", "", "", None);
let result = build_system_prompt("You are helpful.", &[], "act", "/tmp", "", "", None, vec![]);
assert!(result.contains("You are helpful."));
}

Expand All @@ -14,7 +14,7 @@ fn tool_schemas_not_in_system_prompt() {
description: "Read a file".into(),
input_schema: serde_json::json!({"type": "object"}),
}];
let result = build_system_prompt("Base", &tools, "act", "/workspace", "", "", None);
let result = build_system_prompt("Base", &tools, "act", "/workspace", "", "", None, vec![]);
// Tool schemas should NOT appear in system prompt — they go via ChatParams.tools
assert!(!result.contains("# Available Tools"));
assert!(!result.contains("## read"));
Expand All @@ -24,7 +24,7 @@ fn tool_schemas_not_in_system_prompt() {

#[test]
fn includes_fragments() {
let result = build_system_prompt("Base", &[], "act", "/workspace", "", "", None);
let result = build_system_prompt("Base", &[], "act", "/workspace", "", "", None, vec![]);
// Core fragments should be present
assert!(
result.contains("Output Efficiency"),
Expand All @@ -47,7 +47,8 @@ fn cwd_available_in_subagent_prompt() {
"/Users/dev/project",
"",
"",
Some("general"), // any agent_type makes is_subagent() true
Some("general"),
vec![],
);
assert!(
result.contains("/Users/dev/project"),
Expand All @@ -58,7 +59,7 @@ fn cwd_available_in_subagent_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, "", None);
let result = build_system_prompt("Base", &[], "act", "/workspace", skills, "", None, vec![]);
assert!(result.contains("Available Skills"));
assert!(result.contains("/commit"));
}
Expand All @@ -73,14 +74,15 @@ fn includes_memory() {
"",
"## Key Patterns\n- Use DI",
None,
vec![],
);
assert!(result.contains("# Project Memory"));
assert!(result.contains("Key Patterns"));
}

#[test]
fn empty_memory_no_section() {
let result = build_system_prompt("Base", &[], "act", "/workspace", "", "", None);
let result = build_system_prompt("Base", &[], "act", "/workspace", "", "", None, vec![]);
assert!(!result.contains("Project Memory"));
}

Expand All @@ -92,20 +94,95 @@ fn tool_conditional_fragments() {
description: "Execute commands".into(),
input_schema: serde_json::json!({"type": "object"}),
}];
let result = build_system_prompt("Base", &tools, "act", "/workspace", "", "", None);
let result = build_system_prompt("Base", &tools, "act", "/workspace", "", "", None, vec![]);
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", "", "", None);
let result_no_bash =
build_system_prompt("Base", &[], "act", "/workspace", "", "", None, vec![]);
assert!(
!result_no_bash.contains("Bash Tool Guidelines"),
"bash guidelines should not appear without Bash"
);
}

#[test]
fn feature_conditional_fragments() {
// With "memory" feature → memory guidance should appear
let with_memory = build_system_prompt(
"Base",
&[],
"act",
"/workspace",
"",
"",
None,
vec!["memory".into()],
);
assert!(
with_memory.contains("Memory System"),
"memory guidance fragment missing when memory feature enabled"
);

// Without "memory" feature → no memory guidance
let without = build_system_prompt("Base", &[], "act", "/workspace", "", "", None, vec![]);
assert!(
!without.contains("Memory System"),
"memory guidance should not appear without memory feature"
);

// With "hooks" feature → hooks guidance should appear
let with_hooks = build_system_prompt(
"Base",
&[],
"act",
"/workspace",
"",
"",
None,
vec!["hooks".into()],
);
assert!(
with_hooks.contains("hooks"),
"hooks fragment missing when hooks feature enabled"
);

// With style feature → style fragment should appear
let with_style = build_system_prompt(
"Base",
&[],
"act",
"/workspace",
"",
"",
None,
vec!["style_explanatory".into()],
);
assert!(
with_style.contains("Explanatory"),
"explanatory style fragment missing when style feature enabled"
);

// With "subagent" feature → agent guidelines should appear
let with_subagent = build_system_prompt(
"Base",
&[],
"act",
"/workspace",
"",
"",
None,
vec!["subagent".into()],
);
assert!(
with_subagent.contains("Sub-Agent Usage"),
"agent guidelines fragment missing when subagent feature enabled"
);
}

#[test]
fn report_token_usage() {
use loopal_context::estimate_tokens;
Expand Down Expand Up @@ -152,10 +229,11 @@ 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", "", "", 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 bare = build_system_prompt("", &[], "act", "/project", "", "", None, vec![]);
let with_tools = build_system_prompt("", &tools, "act", "/project", "", "", None, vec![]);
let full_act = build_system_prompt(instr, &tools, "act", "/project", skills, mem, None, vec![]);
let full_plan =
build_system_prompt(instr, &tools, "plan", "/project", skills, mem, None, vec![]);

let t_bare = estimate_tokens(&bare);
let t_tools = estimate_tokens(&with_tools);
Expand Down
Loading
Loading