Skip to content

Commit 777655b

Browse files
committed
Add governed planning hooks
1 parent 17655b4 commit 777655b

8 files changed

Lines changed: 497 additions & 19 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,28 @@ session limiting sibling fan-out through `max_parallel_tasks`. `ultracode` adds
202202
automatic planning, goal tracking, and dynamic-workflow guidance, but the
203203
pre-analysis gate still decides whether a turn actually needs a plan or fan-out.
204204

205+
## Planning Hooks
206+
207+
Planning is a governed runtime phase, not just prompt text. Hosts that attach a
208+
`HookExecutor` receive:
209+
210+
- `PrePlanning` before plan generation. This hook includes the session id, task
211+
description, available planning strategies, tool names, goal-tracking flag,
212+
and `max_parallel_tasks`. Returning `Block`, `Retry`, or `Escalate` stops the
213+
planning phase before `PlanningStart` is emitted. Returning modified data with
214+
`modified_task`, `task_description`, or `prompt` changes the planner input;
215+
`selected_strategy`, `planning_template`, and `hints` are appended as planning
216+
guidance. If an auto pre-analysis plan was already available, a modified
217+
planning task discards that candidate plan and forces planning from the
218+
modified input.
219+
- `PostPlanning` after a plan is generated or planning fails. This hook reports
220+
the strategy used, generated subtasks, success flag, and error text when
221+
available.
222+
223+
The normal event stream still emits `PlanningStart`, `PlanningEnd`,
224+
`TaskUpdated`, `StepStart`, and `StepEnd` for UI rendering and replay. Hooks are
225+
for host policy, supervision, and observability around that same lifecycle.
226+
205227
## Dynamic Workflows Vs `/flow`
206228

207229
A3S Code has two workflow concepts and they are intentionally different:

core/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ a3s-common = { version = "0.1.1", path = "../../common" }
1717
a3s-memory = { version = "0.1.2", path = "../../memory" }
1818
a3s-lane = { version = "0.4", path = "../../lane" }
1919
a3s-search = { version = "1.2.3", path = "../../search", default-features = false, features = ["lightpanda"] }
20-
a3s-flow = { version = "0.4.0", path = "../../flow" }
20+
a3s-flow = { version = "0.4.1", path = "../../flow" }
2121

2222
# Async runtime
2323
tokio = { version = "1.35", features = [

core/src/agent/extra_agent_tests.rs

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,262 @@ fn test_disabled_planning_never_runs_pre_analysis() {
7474
assert!(!agent.should_run_pre_analysis());
7575
}
7676

77+
#[derive(Debug)]
78+
struct PlanningHookRecorder {
79+
events: Arc<std::sync::Mutex<Vec<crate::hooks::HookEvent>>>,
80+
result: crate::hooks::HookResult,
81+
}
82+
83+
#[async_trait::async_trait]
84+
impl crate::hooks::HookExecutor for PlanningHookRecorder {
85+
async fn fire(&self, event: &crate::hooks::HookEvent) -> crate::hooks::HookResult {
86+
self.events.lock().unwrap().push(event.clone());
87+
self.result.clone()
88+
}
89+
}
90+
91+
fn planning_hook_recorder(
92+
result: crate::hooks::HookResult,
93+
) -> (
94+
Arc<PlanningHookRecorder>,
95+
Arc<std::sync::Mutex<Vec<crate::hooks::HookEvent>>>,
96+
) {
97+
let events = Arc::new(std::sync::Mutex::new(Vec::new()));
98+
(
99+
Arc::new(PlanningHookRecorder {
100+
events: Arc::clone(&events),
101+
result,
102+
}),
103+
events,
104+
)
105+
}
106+
107+
// ========================================================================
108+
// Planning hooks
109+
// ========================================================================
110+
111+
#[tokio::test]
112+
async fn test_execute_with_planning_fires_pre_and_post_planning_hooks() {
113+
let mock_client = Arc::new(MockLlmClient::new(vec![
114+
MockLlmClient::text_response(
115+
r#"{
116+
"goal": "Build planning hooks",
117+
"complexity": "Simple",
118+
"steps": [
119+
{
120+
"id": "step-1",
121+
"description": "Wire planning hooks into the runtime",
122+
"dependencies": [],
123+
"success_criteria": "Pre and post hooks are emitted"
124+
}
125+
],
126+
"required_tools": []
127+
}"#,
128+
),
129+
MockLlmClient::text_response("Planning hooks wired."),
130+
]));
131+
let tool_executor = Arc::new(ToolExecutor::new("/tmp".to_string()));
132+
let (hook, events) = planning_hook_recorder(crate::hooks::HookResult::Continue(None));
133+
let config = AgentConfig {
134+
hook_engine: Some(hook),
135+
..AgentConfig::default()
136+
};
137+
let agent = AgentLoop::new(mock_client, tool_executor, test_tool_context(), config);
138+
139+
let result = agent
140+
.execute_with_planning(
141+
&[],
142+
"Build planning hooks",
143+
Some("planning-hooks-session"),
144+
None,
145+
None,
146+
)
147+
.await
148+
.unwrap();
149+
150+
assert_eq!(result.text, "Planning hooks wired.");
151+
152+
let planning_events = events
153+
.lock()
154+
.unwrap()
155+
.iter()
156+
.filter_map(|event| match event {
157+
crate::hooks::HookEvent::PrePlanning(event) => Some((
158+
"pre",
159+
event.session_id.clone(),
160+
event.task_description.clone(),
161+
None,
162+
)),
163+
crate::hooks::HookEvent::PostPlanning(event) => Some((
164+
"post",
165+
event.session_id.clone(),
166+
event.task_description.clone(),
167+
Some((event.success, event.subtasks.clone())),
168+
)),
169+
_ => None,
170+
})
171+
.collect::<Vec<_>>();
172+
173+
assert_eq!(planning_events.len(), 2);
174+
assert_eq!(planning_events[0].0, "pre");
175+
assert_eq!(planning_events[0].1, "planning-hooks-session");
176+
assert_eq!(planning_events[0].2, "Build planning hooks");
177+
assert_eq!(planning_events[1].0, "post");
178+
assert_eq!(planning_events[1].1, "planning-hooks-session");
179+
assert_eq!(planning_events[1].2, "Build planning hooks");
180+
assert_eq!(
181+
planning_events[1].3.as_ref().unwrap().1,
182+
vec!["Wire planning hooks into the runtime".to_string()]
183+
);
184+
assert!(planning_events[1].3.as_ref().unwrap().0);
185+
}
186+
187+
#[tokio::test]
188+
async fn test_pre_planning_hook_can_block_planning_before_start_event() {
189+
let mock_client = Arc::new(MockLlmClient::new(vec![]));
190+
let tool_executor = Arc::new(ToolExecutor::new("/tmp".to_string()));
191+
let (hook, events) =
192+
planning_hook_recorder(crate::hooks::HookResult::block("policy denied planning"));
193+
let config = AgentConfig {
194+
hook_engine: Some(hook),
195+
..AgentConfig::default()
196+
};
197+
let agent = AgentLoop::new(mock_client, tool_executor, test_tool_context(), config);
198+
199+
let (tx, mut rx) = mpsc::channel(100);
200+
let error = agent
201+
.execute_with_planning(
202+
&[],
203+
"Plan a blocked change",
204+
Some("blocked-planning-session"),
205+
Some(tx),
206+
None,
207+
)
208+
.await
209+
.unwrap_err();
210+
211+
assert!(error
212+
.to_string()
213+
.contains("Planning blocked by hook: policy denied planning"));
214+
215+
{
216+
let recorded = events.lock().unwrap();
217+
assert_eq!(recorded.len(), 1);
218+
assert!(matches!(
219+
recorded.first().unwrap(),
220+
crate::hooks::HookEvent::PrePlanning(event)
221+
if event.session_id == "blocked-planning-session"
222+
&& event.task_description == "Plan a blocked change"
223+
));
224+
}
225+
226+
rx.close();
227+
while let Some(event) = rx.recv().await {
228+
assert!(
229+
!matches!(event, AgentEvent::PlanningStart { .. }),
230+
"PlanningStart must not be emitted after a blocking PrePlanning hook"
231+
);
232+
}
233+
}
234+
235+
#[tokio::test]
236+
async fn test_pre_planning_hook_modification_updates_planner_input() {
237+
let mock_client = Arc::new(MockLlmClient::new(vec![
238+
MockLlmClient::text_response(
239+
r#"{
240+
"goal": "Implement the policy-refined planning task",
241+
"complexity": "Simple",
242+
"steps": [
243+
{
244+
"id": "step-1",
245+
"description": "Follow the hook-modified task",
246+
"dependencies": [],
247+
"success_criteria": "The modified planning task is used"
248+
}
249+
],
250+
"required_tools": []
251+
}"#,
252+
),
253+
MockLlmClient::text_response("Modified planning complete."),
254+
]));
255+
let tool_executor = Arc::new(ToolExecutor::new("/tmp".to_string()));
256+
let (hook, events) =
257+
planning_hook_recorder(crate::hooks::HookResult::continue_with(serde_json::json!({
258+
"modified_task": "Use the hook-modified planning task",
259+
"selected_strategy": "step_by_step",
260+
"planning_template": "Break the work into observable, testable steps.",
261+
"hints": ["Preserve the original user request", "Keep changes scoped"]
262+
})));
263+
let config = AgentConfig {
264+
hook_engine: Some(hook),
265+
..AgentConfig::default()
266+
};
267+
let agent = AgentLoop::new(
268+
mock_client.clone(),
269+
tool_executor,
270+
test_tool_context(),
271+
config,
272+
);
273+
274+
let (tx, mut rx) = mpsc::channel(100);
275+
let result = agent
276+
.execute_with_planning(
277+
&[],
278+
"Original planning request",
279+
Some("modified-planning-session"),
280+
Some(tx),
281+
None,
282+
)
283+
.await
284+
.unwrap();
285+
286+
assert_eq!(result.text, "Modified planning complete.");
287+
288+
let request_texts = mock_client.request_texts.lock().unwrap().clone();
289+
assert!(
290+
request_texts[0].contains("Original user request:\nOriginal planning request"),
291+
"planner input should preserve original request: {}",
292+
request_texts[0]
293+
);
294+
assert!(
295+
request_texts[0]
296+
.contains("Hook-modified planning task:\nUse the hook-modified planning task"),
297+
"planner input should include hook-modified task: {}",
298+
request_texts[0]
299+
);
300+
assert!(
301+
request_texts[0].contains("Break the work into observable, testable steps."),
302+
"planner input should include hook planning template: {}",
303+
request_texts[0]
304+
);
305+
assert!(
306+
request_texts[0].contains("- Preserve the original user request"),
307+
"planner input should include hook hints: {}",
308+
request_texts[0]
309+
);
310+
311+
let mut planning_start_prompt = None;
312+
while let Ok(event) = rx.try_recv() {
313+
if let AgentEvent::PlanningStart { prompt } = event {
314+
planning_start_prompt = Some(prompt);
315+
break;
316+
}
317+
}
318+
let planning_start_prompt = planning_start_prompt.expect("PlanningStart should be emitted");
319+
assert!(planning_start_prompt.contains("Hook-modified planning task"));
320+
321+
let post_planning_task = events
322+
.lock()
323+
.unwrap()
324+
.iter()
325+
.find_map(|event| match event {
326+
crate::hooks::HookEvent::PostPlanning(event) => Some(event.task_description.clone()),
327+
_ => None,
328+
})
329+
.expect("PostPlanning should be emitted");
330+
assert!(post_planning_task.contains("Hook-modified planning task"));
331+
}
332+
77333
// ========================================================================
78334
// AgentEvent serialization
79335
// ========================================================================

0 commit comments

Comments
 (0)