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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,11 @@ fields, new `SessionStore` trait methods with default no-op impls).
global `auto_parallel` kill switch. Setting `auto_parallel = false` disables
automatic parallel child-agent fan-out while keeping manual `parallel_task`
available.
- Added `auto_delegation.allow_manual_delegation` and
`SessionOptions::with_manual_delegation_enabled(...)` so hosts can hide the
model-visible `task` / `parallel_task` tools per session while preserving the
child-agent registry for introspection and worker registration. This is an
operational cost/debug control, not a security sandbox.
- Added `max_parallel_tasks` as the shared sibling fan-out limit for
`parallel_task`, delegated plan waves, and safe parallel write batches.
- Added a reusable ordered parallel executor so concurrent child results remain
Expand Down
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1685,10 +1685,11 @@ skill_dirs = ["./skills"]
mcp_servers = []

auto_delegation {
enabled = false
auto_parallel = false
min_confidence = 0.72
max_tasks = 4
enabled = false
auto_parallel = false
allow_manual_delegation = true
min_confidence = 0.72
max_tasks = 4
}

ahp = {
Expand All @@ -1707,6 +1708,11 @@ safe parallel write batches.
`auto_delegation.enabled` controls Claude Code-style automatic subagent
delegation. `auto_parallel = false` is a global kill switch for automatic
parallel child-agent fan-out; manual `parallel_task` remains available.
Set `allow_manual_delegation = false` to hide the model-visible `task` and
`parallel_task` tools for cost control or debugging while preserving the child
agent registry for introspection and host-managed worker registration. This is
not a security sandbox: the parent agent may still use other registered tools,
MCP servers, or skills.

---

Expand Down
5 changes: 4 additions & 1 deletion core/prompts/analysis/pre_analysis_system.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Schema:
],
"required_tools": ["tool_name"]
},
"optimized_input": "the same request with references resolved, in the user's language"
"optimized_input": "the full original request with references resolved, in the user's language"
}

Rules:
Expand All @@ -43,4 +43,7 @@ Rules:
VeryComplex = release, migration, security-sensitive, or broad architecture work.
- Prefer `program` for repeated structured repository analysis; prefer
`task`/`parallel_task` for delegated agent work.
- `optimized_input` must preserve every concrete constraint, path, name, branch,
environment variable, metric, and negative instruction from the original user
message. Do not replace the task with a short summary.
- Respond with valid JSON only. No markdown fences, comments, or explanation.
24 changes: 23 additions & 1 deletion core/src/agent/execution_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,26 @@ struct ExecutionRoute {
}

impl AgentLoop {
pub(super) fn preserve_original_prompt_for_execution(
original_prompt: &str,
optimized_input: &str,
) -> String {
let original = original_prompt.trim();
let optimized = optimized_input.trim();

if original.is_empty() {
return optimized.to_string();
}
if optimized.is_empty() || optimized == original {
return original.to_string();
}
if optimized.contains(original) {
return optimized.to_string();
}

format!("Original user request:\n{original}\n\nPlanner-optimized request:\n{optimized}")
}

pub(super) fn should_run_pre_analysis(&self) -> bool {
match self.config.planning_mode {
PlanningMode::Disabled => false,
Expand Down Expand Up @@ -90,7 +110,9 @@ impl AgentLoop {
let use_planning = self.resolve_planning_decision(style, pre_analysis.as_ref());
let effective_prompt = pre_analysis
.as_ref()
.map(|analysis| analysis.optimized_input.clone())
.map(|analysis| {
Self::preserve_original_prompt_for_execution(prompt, &analysis.optimized_input)
})
.unwrap_or_else(|| prompt.to_string());

ExecutionRoute {
Expand Down
40 changes: 30 additions & 10 deletions core/src/agent/plan_execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,15 +109,22 @@ impl AgentLoop {
}
}

pub(super) fn delegated_prompt_for_step(
pub(super) fn delegated_prompt_for_step_with_goal(
plan_goal: Option<&str>,
step: &Task,
step_number: usize,
total_steps: usize,
) -> String {
let mut prompt = format!(
let mut prompt = String::new();
if let Some(goal) = plan_goal.map(str::trim).filter(|goal| !goal.is_empty()) {
prompt.push_str("Plan goal/context:\n");
prompt.push_str(goal);
prompt.push_str("\n\n");
}
prompt.push_str(&format!(
"Execute plan step {}/{}.\n\nTask:\n{}\n",
step_number, total_steps, step.content
);
));
if let Some(criteria) = step
.success_criteria
.as_deref()
Expand All @@ -131,25 +138,29 @@ impl AgentLoop {
prompt
}

pub(super) fn delegated_task_args(
pub(super) fn delegated_task_args_with_goal(
plan_goal: Option<&str>,
step: &Task,
step_number: usize,
total_steps: usize,
) -> Value {
json!({
"agent": Self::delegated_agent_for_step(step),
"description": step.content,
"prompt": Self::delegated_prompt_for_step(step, step_number, total_steps),
"prompt": Self::delegated_prompt_for_step_with_goal(plan_goal, step, step_number, total_steps),
})
}

pub(super) fn parallel_delegated_task_args(
pub(super) fn parallel_delegated_task_args_with_goal(
plan_goal: Option<&str>,
steps: &[(Task, usize)],
total_steps: usize,
) -> Value {
let tasks = steps
.iter()
.map(|(step, step_number)| Self::delegated_task_args(step, *step_number, total_steps))
.map(|(step, step_number)| {
Self::delegated_task_args_with_goal(plan_goal, step, *step_number, total_steps)
})
.collect::<Vec<_>>();
json!({ "tasks": tasks })
}
Expand Down Expand Up @@ -275,9 +286,14 @@ impl AgentLoop {
_ => "task",
};
let args = if tool_name == "parallel_task" {
json!({ "tasks": [Self::delegated_task_args(&step, step_number, total_steps)] })
json!({ "tasks": [Self::delegated_task_args_with_goal(Some(&plan.goal), &step, step_number, total_steps)] })
} else {
Self::delegated_task_args(&step, step_number, total_steps)
Self::delegated_task_args_with_goal(
Some(&plan.goal),
&step,
step_number,
total_steps,
)
};
let (output, _exit_code, is_error, _metadata) = self
.execute_delegated_plan_tool(tool_name, &args, session_id, &event_tx)
Expand Down Expand Up @@ -429,7 +445,11 @@ impl AgentLoop {
.iter()
.all(|(step, _)| Self::should_delegate_plan_step(step))
{
let args = Self::parallel_delegated_task_args(&ready_steps, total_steps);
let args = Self::parallel_delegated_task_args_with_goal(
Some(&plan.goal),
&ready_steps,
total_steps,
);
let (output, _exit_code, is_error, metadata) = self
.execute_delegated_plan_tool("parallel_task", &args, session_id, &event_tx)
.await;
Expand Down
22 changes: 21 additions & 1 deletion core/src/agent/planning_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ use anyhow::Result;
use tokio::sync::mpsc;

impl AgentLoop {
pub(super) fn preserve_plan_goal_context(
mut plan: ExecutionPlan,
execution_prompt: &str,
) -> ExecutionPlan {
let context = execution_prompt.trim();
let goal = plan.goal.trim();

if context.is_empty() || goal == context || goal.contains(context) {
return plan;
}

plan.goal = format!(
"Original user request and planning context:\n{context}\n\nPlanner goal:\n{goal}"
);
plan
}

pub(super) async fn emit_task_updated(
&self,
event_tx: &Option<mpsc::Sender<AgentEvent>>,
Expand Down Expand Up @@ -60,7 +77,10 @@ impl AgentLoop {

// Use pre-analysis result if available (goal + plan already computed in one LLM call).
let (goal, plan) = if let Some(analysis) = pre_analysis {
(Some(analysis.goal.clone()), analysis.execution_plan.clone())
(
Some(analysis.goal.clone()),
Self::preserve_plan_goal_context(analysis.execution_plan.clone(), prompt),
)
} else {
// Fall back: extract goal and create plan via separate LLM calls.
let g = if self.config.goal_tracking {
Expand Down
53 changes: 51 additions & 2 deletions core/src/agent/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ fn test_delegated_task_args_include_prompt_contract() {
let task = Task::new("s1", "验证 program 工具")
.with_tool("task")
.with_success_criteria("All integration checks pass.");
let args = AgentLoop::delegated_task_args(&task, 2, 5);
let args = AgentLoop::delegated_task_args_with_goal(None, &task, 2, 5);

assert_eq!(args["agent"], "verification");
assert_eq!(args["description"], "验证 program 工具");
Expand All @@ -81,14 +81,63 @@ fn test_parallel_delegated_task_args_preserve_order() {
(Task::new("s1", "Find docs").with_tool("task"), 1),
(Task::new("s2", "Run tests").with_tool("task"), 2),
];
let args = AgentLoop::parallel_delegated_task_args(&steps, 2);
let args = AgentLoop::parallel_delegated_task_args_with_goal(None, &steps, 2);
let tasks = args["tasks"].as_array().unwrap();

assert_eq!(tasks.len(), 2);
assert_eq!(tasks[0]["agent"], "explore");
assert_eq!(tasks[1]["agent"], "verification");
}

#[test]
fn test_preserve_original_prompt_for_planning_execution() {
let original =
"Fix planning mode. Preserve /tmp/task.txt and do not drop negative instructions.";
let optimized = "Fix planning mode.";

let preserved = AgentLoop::preserve_original_prompt_for_execution(original, optimized);

assert!(preserved.contains("Original user request"));
assert!(preserved.contains("/tmp/task.txt"));
assert!(preserved.contains("do not drop negative instructions"));
assert!(preserved.contains("Planner-optimized request"));
assert!(preserved.contains(optimized));
}

#[test]
fn test_preserve_plan_goal_context_keeps_original_request_visible() {
use crate::planning::{Complexity, ExecutionPlan};

let plan = ExecutionPlan::new("Fix planning mode".to_string(), Complexity::Medium);
let execution_prompt =
"Original user request:\nFix planning mode for /workspace/app; do not change API.";

let preserved = AgentLoop::preserve_plan_goal_context(plan, execution_prompt);

assert!(preserved.goal.contains("/workspace/app"));
assert!(preserved.goal.contains("do not change API"));
assert!(preserved.goal.contains("Planner goal"));
}

#[test]
fn test_delegated_plan_step_prompt_includes_plan_goal_context() {
use crate::planning::Task;

let task = Task::new("s1", "Implement the first step").with_tool("task");
let args = AgentLoop::delegated_task_args_with_goal(
Some("Original request: update /workspace/app and keep API stable."),
&task,
1,
1,
);
let prompt = args["prompt"].as_str().unwrap();

assert!(prompt.contains("Plan goal/context"));
assert!(prompt.contains("/workspace/app"));
assert!(prompt.contains("keep API stable"));
assert!(prompt.contains("Implement the first step"));
}

#[test]
fn test_memory_items_become_context_result() {
let item = a3s_memory::MemoryItem::new("Use focused regression tests for context changes.")
Expand Down
6 changes: 6 additions & 0 deletions core/src/agent_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,12 @@ pub struct SessionOptions {
pub max_parallel_tasks: Option<usize>,
/// Per-session automatic subagent delegation override.
pub auto_delegation: Option<crate::config::AutoDelegationConfig>,
/// Per-session switch for model-visible manual child-agent tools.
///
/// This overlays the effective automatic delegation config instead of
/// replacing it, so callers can hide `task` / `parallel_task` while
/// preserving other delegation settings.
pub manual_delegation_enabled: Option<bool>,
/// Per-session kill switch for automatic parallel child-agent fan-out.
///
/// This overlays the effective automatic delegation config instead of
Expand Down
7 changes: 7 additions & 0 deletions core/src/agent_api/capabilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ fn register_task_capability(
use crate::tools::register_task_with_mcp;

let registry = AgentRegistry::new();
let auto_delegation = super::session_config::resolve_auto_delegation_config(code_config, opts);
let built_in_agent_dirs = built_in_agent_dirs(workspace);
for dir in code_config
.agent_dirs
Expand All @@ -178,6 +179,12 @@ fn register_task_capability(
registry.register_worker(worker.clone());
}

if !auto_delegation.allow_manual_delegation {
// Keep the registry populated for introspection and host-managed worker
// registration even when the model-visible delegation tools are hidden.
return Arc::new(registry);
}

let parent_context = ChildRunContext {
security_provider: opts.security_provider.clone(),
hook_engine: None,
Expand Down
12 changes: 4 additions & 8 deletions core/src/agent_api/session_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ use std::sync::{Arc, RwLock};
use super::capabilities::{
build_session_capabilities, register_skill_capability, SessionCapabilityInput,
};
use super::session_config::{resolve_session_memory, resolve_session_store};
use super::session_config::{
resolve_auto_delegation_config, resolve_session_memory, resolve_session_store,
};
use super::session_runtime::{build_session_runtime, SessionRuntimeInput};

pub(super) fn prepare_session_options(agent: &Agent, opts: SessionOptions) -> SessionOptions {
Expand Down Expand Up @@ -135,13 +137,7 @@ pub(super) fn build_agent_session(
let init_warning = resolved_memory.init_warning;

let base = agent.config.clone();
let mut auto_delegation = opts
.auto_delegation
.clone()
.unwrap_or_else(|| base.auto_delegation.clone());
if let Some(auto_parallel) = opts.auto_parallel_delegation {
auto_delegation.auto_parallel = auto_parallel;
}
let auto_delegation = resolve_auto_delegation_config(&agent.code_config, opts);
let config = AgentConfig {
prompt_slots,
tools: tool_defs,
Expand Down
23 changes: 23 additions & 0 deletions core/src/agent_api/session_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,29 @@ use crate::llm::LlmClient;
use anyhow::Context;
use std::sync::Arc;

pub(super) fn resolve_auto_delegation_config(
code_config: &CodeConfig,
opts: &SessionOptions,
) -> crate::config::AutoDelegationConfig {
let mut auto_delegation = if let Some(config) = opts.auto_delegation.clone() {
config
} else {
let mut config = code_config.auto_delegation.clone();
if let Some(auto_parallel) = code_config.auto_parallel {
config.auto_parallel = auto_parallel;
}
config
};
if let Some(enabled) = opts.manual_delegation_enabled {
auto_delegation.allow_manual_delegation = enabled;
}
if let Some(auto_parallel) = opts.auto_parallel_delegation {
auto_delegation.auto_parallel = auto_parallel;
}

auto_delegation
}

pub(super) fn resolve_session_llm_client(
code_config: &CodeConfig,
opts: &SessionOptions,
Expand Down
Loading
Loading