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
51 changes: 28 additions & 23 deletions crates/loopal-agent-client/src/client.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! IPC client — wraps `Connection` with agent protocol methods.

use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;

use serde_json::Value;
Expand All @@ -12,6 +12,22 @@ use loopal_ipc::protocol::methods;
use loopal_ipc::transport::Transport;
use loopal_protocol::{AgentEvent, ControlCommand, Envelope, UserQuestionResponse};

/// Parameters for `agent/start` IPC request.
#[derive(Debug, Default)]
pub struct StartAgentParams {
pub cwd: PathBuf,
pub model: Option<String>,
pub mode: Option<String>,
pub prompt: Option<String>,
pub permission_mode: Option<String>,
pub no_sandbox: bool,
pub resume: Option<String>,
pub lifecycle: Option<String>,
pub agent_type: Option<String>,
/// Nesting depth (0 = root). Propagated from parent.
pub depth: Option<u32>,
}

/// High-level agent IPC client.
pub struct AgentClient {
connection: Arc<Connection>,
Expand Down Expand Up @@ -44,29 +60,18 @@ impl AgentClient {
}

/// Send `agent/start` to begin the agent loop.
#[allow(clippy::too_many_arguments)]
pub async fn start_agent(
&self,
cwd: &Path,
model: Option<&str>,
mode: Option<&str>,
prompt: Option<&str>,
permission_mode: Option<&str>,
no_sandbox: bool,
resume: Option<&str>,
lifecycle: Option<&str>,
agent_type: Option<&str>,
) -> anyhow::Result<String> {
pub async fn start_agent(&self, p: &StartAgentParams) -> anyhow::Result<String> {
let params = serde_json::json!({
"cwd": cwd.to_string_lossy(),
"model": model,
"mode": mode,
"prompt": prompt,
"permission_mode": permission_mode,
"no_sandbox": no_sandbox,
"resume": resume,
"lifecycle": lifecycle,
"agent_type": agent_type,
"cwd": p.cwd.to_string_lossy(),
"model": p.model,
"mode": p.mode,
"prompt": p.prompt,
"permission_mode": p.permission_mode,
"no_sandbox": p.no_sandbox,
"resume": p.resume,
"lifecycle": p.lifecycle,
"agent_type": p.agent_type,
"depth": p.depth,
});
let result = self
.connection
Expand Down
2 changes: 1 addition & 1 deletion crates/loopal-agent-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@ mod client;
mod process;

pub use bridge::{BridgeHandles, start_bridge};
pub use client::{AgentClient, AgentClientEvent};
pub use client::{AgentClient, AgentClientEvent, StartAgentParams};
pub use process::AgentProcess;
2 changes: 2 additions & 0 deletions crates/loopal-agent-hub/src/dispatch/dispatch_handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ pub async fn handle_spawn_agent(
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);
let depth = params["depth"].as_u64().map(|v| v as u32);

// Parent: use explicit "parent" field from params if present (cross-hub),
// otherwise use from_agent (local spawn).
Expand All @@ -182,6 +183,7 @@ pub async fn handle_spawn_agent(
parent,
permission_mode,
agent_type,
depth,
)
.await
});
Expand Down
26 changes: 14 additions & 12 deletions crates/loopal-agent-hub/src/spawn_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pub async fn spawn_and_register(
parent: Option<String>,
permission_mode: Option<String>,
agent_type: Option<String>,
depth: Option<u32>,
) -> Result<String, String> {
info!(agent = %name, parent = ?parent, "spawn: forking process");
let agent_proc = loopal_agent_client::AgentProcess::spawn(None)
Expand All @@ -36,18 +37,19 @@ pub async fn spawn_and_register(
return Err(format!("agent initialize failed: {e}"));
}
info!(agent = %name, "spawn: starting agent");
let model_for_registry = model.clone();
let session_id = match client
.start_agent(
std::path::Path::new(&cwd),
model.as_deref(),
Some("act"),
prompt.as_deref(),
permission_mode.as_deref(),
false,
None,
Some("ephemeral"), // sub-agents always exit on idle
agent_type.as_deref(),
)
.start_agent(&loopal_agent_client::StartAgentParams {
cwd: std::path::PathBuf::from(&cwd),
model,
mode: Some("act".to_string()),
prompt,
permission_mode,
lifecycle: Some("ephemeral".to_string()), // sub-agents always exit on idle
agent_type,
depth,
..Default::default()
})
.await
{
Ok(sid) => Some(sid),
Expand All @@ -65,7 +67,7 @@ pub async fn spawn_and_register(
conn,
incoming_rx,
parent.as_deref(),
model.as_deref(),
model_for_registry.as_deref(),
session_id.as_deref(),
)
.await;
Expand Down
17 changes: 6 additions & 11 deletions crates/loopal-agent-hub/tests/suite/e2e_bootstrap_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,17 +50,12 @@ async fn full_bootstrap_hub_to_agent_roundtrip() {

let cwd = std::env::temp_dir();
client
.start_agent(
&cwd,
None, // use default model
Some("act"),
None, // no initial prompt
None,
true, // no sandbox
None,
None, // lifecycle: default
None, // agent_type
)
.start_agent(&loopal_agent_client::StartAgentParams {
cwd: cwd.clone(),
mode: Some("act".to_string()),
no_sandbox: true,
..Default::default()
})
.await
.expect("start_agent should work");

Expand Down
4 changes: 2 additions & 2 deletions crates/loopal-agent-server/src/agent_setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,7 @@ pub fn build_with_frontend(
task_store: Arc::new(TaskStore::new(tasks_dir)),
hub_connection,
cwd: cwd.to_path_buf(),
depth: 0,
max_depth: 3,
depth: start.depth.unwrap_or(0),
agent_name: "main".into(),
parent_event_tx: Some(event_tx),
cancel_token: None,
Expand Down Expand Up @@ -136,6 +135,7 @@ pub fn build_with_frontend(
&config.memory,
start.agent_type.as_deref(),
features,
start.depth.unwrap_or(0),
);

crate::prompt_post::append_runtime_sections(&mut system_prompt, &kernel);
Expand Down
1 change: 1 addition & 0 deletions crates/loopal-agent-server/src/memory_adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ impl MemoryProcessor for ServerMemoryProcessor {
permission_mode: None,
target_hub: None,
agent_type: None,
depth: self.shared.depth + 1,
};
spawn_agent(&self.shared, params).await?;
info!("memory-maintainer agent spawned via Hub");
Expand Down
2 changes: 2 additions & 0 deletions crates/loopal-agent-server/src/params.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ pub struct StartParams {
pub lifecycle: loopal_runtime::LifecycleMode,
/// Agent type for fragment selection (e.g. "explore", "plan").
pub agent_type: Option<String>,
/// Nesting depth (0 = root). Propagated from parent via IPC.
pub depth: Option<u32>,
}

/// Build a Kernel from config (production path: MCP, tools).
Expand Down
1 change: 1 addition & 0 deletions crates/loopal-agent-server/src/session_start.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ pub(crate) async fn start_session(
resume: params["resume"].as_str().map(String::from),
lifecycle,
agent_type: params["agent_type"].as_str().map(String::from),
depth: params["depth"].as_u64().map(|v| v as u32),
};

let mut config = load_config(&cwd)?;
Expand Down
1 change: 1 addition & 0 deletions crates/loopal-agent-server/tests/suite/hub_harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ pub async fn build_hub_harness_with(
resume: None,
lifecycle: loopal_runtime::LifecycleMode::Persistent,
agent_type: None,
depth: None,
};
let (hub_conn, _hub_peer) = loopal_ipc::duplex_pair();
let hub_connection = std::sync::Arc::new(loopal_ipc::Connection::new(hub_conn));
Expand Down
5 changes: 2 additions & 3 deletions crates/loopal-agent/src/shared.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,9 @@ pub struct AgentShared {
pub hub_connection: Arc<Connection>,
/// Initial working directory. Immutable after construction.
pub cwd: PathBuf,
/// Current nesting depth (0 = root agent).
/// Current nesting depth (0 = root agent). Propagated via IPC so agents
/// can sense how deep they are in the delegation chain.
pub depth: u32,
/// Maximum allowed nesting depth.
pub max_depth: u32,
/// Name of the current agent.
pub agent_name: String,
/// Event sender for forwarding sub-agent events up the chain.
Expand Down
3 changes: 3 additions & 0 deletions crates/loopal-agent/src/spawn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ pub struct SpawnParams {
pub target_hub: Option<String>,
/// Agent type for fragment selection (e.g. "explore", "plan").
pub agent_type: Option<String>,
/// Nesting depth of the child agent (parent depth + 1).
pub depth: u32,
}

/// Result returned from Hub after spawning.
Expand Down Expand Up @@ -48,6 +50,7 @@ pub async fn spawn_agent(
"prompt": params.prompt,
"permission_mode": params.permission_mode,
"agent_type": params.agent_type,
"depth": params.depth,
});
if let Some(ref hub) = params.target_hub {
request["target_hub"] = json!(hub);
Expand Down
7 changes: 1 addition & 6 deletions crates/loopal-agent/src/tools/collaboration/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,6 @@ async fn action_spawn(
.get("target_hub")
.and_then(|v| v.as_str())
.map(String::from);
if shared.depth >= shared.max_depth {
return Ok(ToolResult::error(format!(
"Maximum nesting depth ({}) reached",
shared.max_depth
)));
}

let mut config = subagent_type
.and_then(|t| load_agent_configs(&shared.cwd).remove(t))
Expand Down Expand Up @@ -131,6 +125,7 @@ async fn action_spawn(
permission_mode: Some(perm_mode.to_string()),
target_hub,
agent_type: subagent_type.map(String::from),
depth: shared.depth + 1,
},
)
.await;
Expand Down
16 changes: 5 additions & 11 deletions crates/loopal-agent/tests/suite/bridge_chain_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,11 @@ async fn full_chain_sub_agent_result_delivered_to_parent() {
let client = AgentClient::new(client_t);
client.initialize().await.expect("initialize");
client
.start_agent(
fixture.path(),
None,
None,
Some("research this project"),
None,
false,
None,
None,
None,
)
.start_agent(&loopal_agent_client::StartAgentParams {
cwd: fixture.path().to_path_buf(),
prompt: Some("research this project".to_string()),
..Default::default()
})
.await
.expect("start_agent");

Expand Down
32 changes: 10 additions & 22 deletions crates/loopal-agent/tests/suite/bridge_child_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,17 +57,11 @@ pub(crate) async fn start_bridge_client(
let client = AgentClient::new(client_t);
client.initialize().await.expect("initialize");
client
.start_agent(
fixture.path(),
None,
None,
Some("work"),
None,
false,
None,
None,
None,
)
.start_agent(&loopal_agent_client::StartAgentParams {
cwd: fixture.path().to_path_buf(),
prompt: Some("work".to_string()),
..Default::default()
})
.await
.expect("start_agent");

Expand Down Expand Up @@ -148,17 +142,11 @@ async fn bridge_cancel_sends_shutdown() {
let client = AgentClient::new(client_t);
client.initialize().await.unwrap();
client
.start_agent(
fixture.path(),
None,
None,
Some("slow task"),
None,
false,
None,
None,
None,
)
.start_agent(&loopal_agent_client::StartAgentParams {
cwd: fixture.path().to_path_buf(),
prompt: Some("slow task".to_string()),
..Default::default()
})
.await
.unwrap();

Expand Down
2 changes: 2 additions & 0 deletions crates/loopal-context/src/system_prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pub fn build_system_prompt(
memory: &str,
agent_type: Option<&str>,
features: Vec<String>,
agent_depth: u32,
) -> String {
let mut registry = FragmentRegistry::new(system_fragments());

Expand Down Expand Up @@ -54,6 +55,7 @@ pub fn build_system_prompt(
features,
agent_name: None,
agent_type: agent_type.map(String::from),
agent_depth,
};

builder.build(&ctx)
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 @@ -29,6 +29,7 @@ fn explore_subagent_full_prompt() {
"",
Some("explore"),
vec![],
0,
);

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

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

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

#[test]
fn general_subagent_gets_default_fragment() {
let result = build_system_prompt("", &[], "act", "/work", "", "", Some("general"), vec![]);
let result = build_system_prompt("", &[], "act", "/work", "", "", Some("general"), vec![], 0);
// Default sub-agent fragment (fallback for unknown types)
assert!(
result.contains("sub-agent named"),
Expand Down
Loading
Loading