Skip to content

Commit 63d2647

Browse files
Lixttclaude
andauthored
Fix planning prompt preservation (#76)
* feat: allow disabling delegation tools * fix planning prompt preservation * refactor delegation tool gating into config --------- Co-authored-by: Claude <claude@anthropic.com>
1 parent afc429e commit 63d2647

16 files changed

Lines changed: 244 additions & 27 deletions

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,11 @@ fields, new `SessionStore` trait methods with default no-op impls).
459459
global `auto_parallel` kill switch. Setting `auto_parallel = false` disables
460460
automatic parallel child-agent fan-out while keeping manual `parallel_task`
461461
available.
462+
- Added `auto_delegation.allow_manual_delegation` and
463+
`SessionOptions::with_manual_delegation_enabled(...)` so hosts can hide the
464+
model-visible `task` / `parallel_task` tools per session while preserving the
465+
child-agent registry for introspection and worker registration. This is an
466+
operational cost/debug control, not a security sandbox.
462467
- Added `max_parallel_tasks` as the shared sibling fan-out limit for
463468
`parallel_task`, delegated plan waves, and safe parallel write batches.
464469
- Added a reusable ordered parallel executor so concurrent child results remain

README.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1685,10 +1685,11 @@ skill_dirs = ["./skills"]
16851685
mcp_servers = []
16861686
16871687
auto_delegation {
1688-
enabled = false
1689-
auto_parallel = false
1690-
min_confidence = 0.72
1691-
max_tasks = 4
1688+
enabled = false
1689+
auto_parallel = false
1690+
allow_manual_delegation = true
1691+
min_confidence = 0.72
1692+
max_tasks = 4
16921693
}
16931694
16941695
ahp = {
@@ -1707,6 +1708,11 @@ safe parallel write batches.
17071708
`auto_delegation.enabled` controls Claude Code-style automatic subagent
17081709
delegation. `auto_parallel = false` is a global kill switch for automatic
17091710
parallel child-agent fan-out; manual `parallel_task` remains available.
1711+
Set `allow_manual_delegation = false` to hide the model-visible `task` and
1712+
`parallel_task` tools for cost control or debugging while preserving the child
1713+
agent registry for introspection and host-managed worker registration. This is
1714+
not a security sandbox: the parent agent may still use other registered tools,
1715+
MCP servers, or skills.
17101716

17111717
---
17121718

core/prompts/analysis/pre_analysis_system.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Schema:
2323
],
2424
"required_tools": ["tool_name"]
2525
},
26-
"optimized_input": "the same request with references resolved, in the user's language"
26+
"optimized_input": "the full original request with references resolved, in the user's language"
2727
}
2828

2929
Rules:
@@ -43,4 +43,7 @@ Rules:
4343
VeryComplex = release, migration, security-sensitive, or broad architecture work.
4444
- Prefer `program` for repeated structured repository analysis; prefer
4545
`task`/`parallel_task` for delegated agent work.
46+
- `optimized_input` must preserve every concrete constraint, path, name, branch,
47+
environment variable, metric, and negative instruction from the original user
48+
message. Do not replace the task with a short summary.
4649
- Respond with valid JSON only. No markdown fences, comments, or explanation.

core/src/agent/execution_mode.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,26 @@ struct ExecutionRoute {
1414
}
1515

1616
impl AgentLoop {
17+
pub(super) fn preserve_original_prompt_for_execution(
18+
original_prompt: &str,
19+
optimized_input: &str,
20+
) -> String {
21+
let original = original_prompt.trim();
22+
let optimized = optimized_input.trim();
23+
24+
if original.is_empty() {
25+
return optimized.to_string();
26+
}
27+
if optimized.is_empty() || optimized == original {
28+
return original.to_string();
29+
}
30+
if optimized.contains(original) {
31+
return optimized.to_string();
32+
}
33+
34+
format!("Original user request:\n{original}\n\nPlanner-optimized request:\n{optimized}")
35+
}
36+
1737
pub(super) fn should_run_pre_analysis(&self) -> bool {
1838
match self.config.planning_mode {
1939
PlanningMode::Disabled => false,
@@ -90,7 +110,9 @@ impl AgentLoop {
90110
let use_planning = self.resolve_planning_decision(style, pre_analysis.as_ref());
91111
let effective_prompt = pre_analysis
92112
.as_ref()
93-
.map(|analysis| analysis.optimized_input.clone())
113+
.map(|analysis| {
114+
Self::preserve_original_prompt_for_execution(prompt, &analysis.optimized_input)
115+
})
94116
.unwrap_or_else(|| prompt.to_string());
95117

96118
ExecutionRoute {

core/src/agent/plan_execution.rs

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -109,15 +109,22 @@ impl AgentLoop {
109109
}
110110
}
111111

112-
pub(super) fn delegated_prompt_for_step(
112+
pub(super) fn delegated_prompt_for_step_with_goal(
113+
plan_goal: Option<&str>,
113114
step: &Task,
114115
step_number: usize,
115116
total_steps: usize,
116117
) -> String {
117-
let mut prompt = format!(
118+
let mut prompt = String::new();
119+
if let Some(goal) = plan_goal.map(str::trim).filter(|goal| !goal.is_empty()) {
120+
prompt.push_str("Plan goal/context:\n");
121+
prompt.push_str(goal);
122+
prompt.push_str("\n\n");
123+
}
124+
prompt.push_str(&format!(
118125
"Execute plan step {}/{}.\n\nTask:\n{}\n",
119126
step_number, total_steps, step.content
120-
);
127+
));
121128
if let Some(criteria) = step
122129
.success_criteria
123130
.as_deref()
@@ -131,25 +138,29 @@ impl AgentLoop {
131138
prompt
132139
}
133140

134-
pub(super) fn delegated_task_args(
141+
pub(super) fn delegated_task_args_with_goal(
142+
plan_goal: Option<&str>,
135143
step: &Task,
136144
step_number: usize,
137145
total_steps: usize,
138146
) -> Value {
139147
json!({
140148
"agent": Self::delegated_agent_for_step(step),
141149
"description": step.content,
142-
"prompt": Self::delegated_prompt_for_step(step, step_number, total_steps),
150+
"prompt": Self::delegated_prompt_for_step_with_goal(plan_goal, step, step_number, total_steps),
143151
})
144152
}
145153

146-
pub(super) fn parallel_delegated_task_args(
154+
pub(super) fn parallel_delegated_task_args_with_goal(
155+
plan_goal: Option<&str>,
147156
steps: &[(Task, usize)],
148157
total_steps: usize,
149158
) -> Value {
150159
let tasks = steps
151160
.iter()
152-
.map(|(step, step_number)| Self::delegated_task_args(step, *step_number, total_steps))
161+
.map(|(step, step_number)| {
162+
Self::delegated_task_args_with_goal(plan_goal, step, *step_number, total_steps)
163+
})
153164
.collect::<Vec<_>>();
154165
json!({ "tasks": tasks })
155166
}
@@ -275,9 +286,14 @@ impl AgentLoop {
275286
_ => "task",
276287
};
277288
let args = if tool_name == "parallel_task" {
278-
json!({ "tasks": [Self::delegated_task_args(&step, step_number, total_steps)] })
289+
json!({ "tasks": [Self::delegated_task_args_with_goal(Some(&plan.goal), &step, step_number, total_steps)] })
279290
} else {
280-
Self::delegated_task_args(&step, step_number, total_steps)
291+
Self::delegated_task_args_with_goal(
292+
Some(&plan.goal),
293+
&step,
294+
step_number,
295+
total_steps,
296+
)
281297
};
282298
let (output, _exit_code, is_error, _metadata) = self
283299
.execute_delegated_plan_tool(tool_name, &args, session_id, &event_tx)
@@ -429,7 +445,11 @@ impl AgentLoop {
429445
.iter()
430446
.all(|(step, _)| Self::should_delegate_plan_step(step))
431447
{
432-
let args = Self::parallel_delegated_task_args(&ready_steps, total_steps);
448+
let args = Self::parallel_delegated_task_args_with_goal(
449+
Some(&plan.goal),
450+
&ready_steps,
451+
total_steps,
452+
);
433453
let (output, _exit_code, is_error, metadata) = self
434454
.execute_delegated_plan_tool("parallel_task", &args, session_id, &event_tx)
435455
.await;

core/src/agent/planning_runtime.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,23 @@ use anyhow::Result;
55
use tokio::sync::mpsc;
66

77
impl AgentLoop {
8+
pub(super) fn preserve_plan_goal_context(
9+
mut plan: ExecutionPlan,
10+
execution_prompt: &str,
11+
) -> ExecutionPlan {
12+
let context = execution_prompt.trim();
13+
let goal = plan.goal.trim();
14+
15+
if context.is_empty() || goal == context || goal.contains(context) {
16+
return plan;
17+
}
18+
19+
plan.goal = format!(
20+
"Original user request and planning context:\n{context}\n\nPlanner goal:\n{goal}"
21+
);
22+
plan
23+
}
24+
825
pub(super) async fn emit_task_updated(
926
&self,
1027
event_tx: &Option<mpsc::Sender<AgentEvent>>,
@@ -60,7 +77,10 @@ impl AgentLoop {
6077

6178
// Use pre-analysis result if available (goal + plan already computed in one LLM call).
6279
let (goal, plan) = if let Some(analysis) = pre_analysis {
63-
(Some(analysis.goal.clone()), analysis.execution_plan.clone())
80+
(
81+
Some(analysis.goal.clone()),
82+
Self::preserve_plan_goal_context(analysis.execution_plan.clone(), prompt),
83+
)
6484
} else {
6585
// Fall back: extract goal and create plan via separate LLM calls.
6686
let g = if self.config.goal_tracking {

core/src/agent/tests.rs

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ fn test_delegated_task_args_include_prompt_contract() {
6262
let task = Task::new("s1", "验证 program 工具")
6363
.with_tool("task")
6464
.with_success_criteria("All integration checks pass.");
65-
let args = AgentLoop::delegated_task_args(&task, 2, 5);
65+
let args = AgentLoop::delegated_task_args_with_goal(None, &task, 2, 5);
6666

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

8787
assert_eq!(tasks.len(), 2);
8888
assert_eq!(tasks[0]["agent"], "explore");
8989
assert_eq!(tasks[1]["agent"], "verification");
9090
}
9191

92+
#[test]
93+
fn test_preserve_original_prompt_for_planning_execution() {
94+
let original =
95+
"Fix planning mode. Preserve /tmp/task.txt and do not drop negative instructions.";
96+
let optimized = "Fix planning mode.";
97+
98+
let preserved = AgentLoop::preserve_original_prompt_for_execution(original, optimized);
99+
100+
assert!(preserved.contains("Original user request"));
101+
assert!(preserved.contains("/tmp/task.txt"));
102+
assert!(preserved.contains("do not drop negative instructions"));
103+
assert!(preserved.contains("Planner-optimized request"));
104+
assert!(preserved.contains(optimized));
105+
}
106+
107+
#[test]
108+
fn test_preserve_plan_goal_context_keeps_original_request_visible() {
109+
use crate::planning::{Complexity, ExecutionPlan};
110+
111+
let plan = ExecutionPlan::new("Fix planning mode".to_string(), Complexity::Medium);
112+
let execution_prompt =
113+
"Original user request:\nFix planning mode for /workspace/app; do not change API.";
114+
115+
let preserved = AgentLoop::preserve_plan_goal_context(plan, execution_prompt);
116+
117+
assert!(preserved.goal.contains("/workspace/app"));
118+
assert!(preserved.goal.contains("do not change API"));
119+
assert!(preserved.goal.contains("Planner goal"));
120+
}
121+
122+
#[test]
123+
fn test_delegated_plan_step_prompt_includes_plan_goal_context() {
124+
use crate::planning::Task;
125+
126+
let task = Task::new("s1", "Implement the first step").with_tool("task");
127+
let args = AgentLoop::delegated_task_args_with_goal(
128+
Some("Original request: update /workspace/app and keep API stable."),
129+
&task,
130+
1,
131+
1,
132+
);
133+
let prompt = args["prompt"].as_str().unwrap();
134+
135+
assert!(prompt.contains("Plan goal/context"));
136+
assert!(prompt.contains("/workspace/app"));
137+
assert!(prompt.contains("keep API stable"));
138+
assert!(prompt.contains("Implement the first step"));
139+
}
140+
92141
#[test]
93142
fn test_memory_items_become_context_result() {
94143
let item = a3s_memory::MemoryItem::new("Use focused regression tests for context changes.")

core/src/agent_api.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,12 @@ pub struct SessionOptions {
272272
pub max_parallel_tasks: Option<usize>,
273273
/// Per-session automatic subagent delegation override.
274274
pub auto_delegation: Option<crate::config::AutoDelegationConfig>,
275+
/// Per-session switch for model-visible manual child-agent tools.
276+
///
277+
/// This overlays the effective automatic delegation config instead of
278+
/// replacing it, so callers can hide `task` / `parallel_task` while
279+
/// preserving other delegation settings.
280+
pub manual_delegation_enabled: Option<bool>,
275281
/// Per-session kill switch for automatic parallel child-agent fan-out.
276282
///
277283
/// This overlays the effective automatic delegation config instead of

core/src/agent_api/capabilities.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ fn register_task_capability(
163163
use crate::tools::register_task_with_mcp;
164164

165165
let registry = AgentRegistry::new();
166+
let auto_delegation = super::session_config::resolve_auto_delegation_config(code_config, opts);
166167
let built_in_agent_dirs = built_in_agent_dirs(workspace);
167168
for dir in code_config
168169
.agent_dirs
@@ -178,6 +179,12 @@ fn register_task_capability(
178179
registry.register_worker(worker.clone());
179180
}
180181

182+
if !auto_delegation.allow_manual_delegation {
183+
// Keep the registry populated for introspection and host-managed worker
184+
// registration even when the model-visible delegation tools are hidden.
185+
return Arc::new(registry);
186+
}
187+
181188
let parent_context = ChildRunContext {
182189
security_provider: opts.security_provider.clone(),
183190
hook_engine: None,

core/src/agent_api/session_builder.rs

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ use std::sync::{Arc, RwLock};
1515
use super::capabilities::{
1616
build_session_capabilities, register_skill_capability, SessionCapabilityInput,
1717
};
18-
use super::session_config::{resolve_session_memory, resolve_session_store};
18+
use super::session_config::{
19+
resolve_auto_delegation_config, resolve_session_memory, resolve_session_store,
20+
};
1921
use super::session_runtime::{build_session_runtime, SessionRuntimeInput};
2022

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

137139
let base = agent.config.clone();
138-
let mut auto_delegation = opts
139-
.auto_delegation
140-
.clone()
141-
.unwrap_or_else(|| base.auto_delegation.clone());
142-
if let Some(auto_parallel) = opts.auto_parallel_delegation {
143-
auto_delegation.auto_parallel = auto_parallel;
144-
}
140+
let auto_delegation = resolve_auto_delegation_config(&agent.code_config, opts);
145141
let config = AgentConfig {
146142
prompt_slots,
147143
tools: tool_defs,

0 commit comments

Comments
 (0)