Skip to content

Commit 1e737c6

Browse files
committed
Expose planning hooks in Node SDK
1 parent 1980d95 commit 1e737c6

4 files changed

Lines changed: 157 additions & 24 deletions

File tree

sdk/node/Cargo.lock

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

sdk/node/README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,29 @@ When streaming, `task_updated` is the authoritative task-list snapshot for UI
124124
rendering. `planning_end` contains the initial plan, while `step_start` and
125125
`step_end` are fine-grained progress events.
126126

127+
Planning can also be governed through hooks. `pre_planning` runs before the
128+
planning phase chooses a plan, and a hook can block or modify the planner input:
129+
130+
```js
131+
session.registerHook(
132+
'planning-policy',
133+
'pre_planning',
134+
null,
135+
null,
136+
(event) => ({
137+
action: 'continue',
138+
modified: {
139+
modified_task: `${event.payload.task_description}\n\nKeep changes scoped and testable.`,
140+
selected_strategy: 'step_by_step',
141+
hints: ['Preserve the original user request'],
142+
},
143+
}),
144+
)
145+
```
146+
147+
Use `post_planning` to observe the selected strategy, generated subtasks,
148+
success flag, and planning error text when available.
149+
127150
## Durable Request Shape
128151

129152
`send(...)` and `stream(...)` accept either a prompt string or an object-shaped

sdk/node/generated.d.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1635,19 +1635,19 @@ export declare class Session {
16351635
* without requiring explicit registration on each sub-agent session.
16361636
*
16371637
* @param hookId - Unique hook identifier
1638-
* @param eventType - Event type: "pre_tool_use", "post_tool_use", "generate_start",
1639-
* "generate_end", "session_start", "session_end", "skill_load", "skill_unload",
1640-
* "pre_prompt", "post_response", "on_error"
1638+
* @param eventType - Event type such as "pre_tool_use", "post_tool_use",
1639+
* "pre_prompt", "post_response", "pre_planning", or "post_planning".
16411640
* @param matcher - Optional matcher: { tool?, pathPattern?, commandPattern?, sessionId?, skill? }
16421641
* @param config - Optional config: { priority?, timeoutMs?, asyncExecution?, maxRetries? }
1643-
* @param handler - Optional callback `(event: any) => { action: 'continue' | 'block' | 'skip',
1644-
* reason?: string } | null`. When provided, the function is called for every matching event
1645-
* and its return value controls execution. Return `{ action: 'block', reason: '...' }` to
1646-
* cancel the operation, `{ action: 'skip' }` to skip remaining hooks, or `null`/`undefined`
1647-
* for continue. Hooks with no handler still fire (observable via stream events) but always
1648-
* continue.
1649-
*/
1650-
registerHook(hookId: string, eventType: string, matcher?: HookMatcherObject | undefined | null, config?: HookConfigObject | undefined | null, handler?: ((event: Record<string, unknown>) => { action: string; reason?: string } | null | undefined) | null | undefined): void
1642+
* @param handler - Optional callback `(event: any) => { action: 'continue' | 'block' | 'skip' | 'retry',
1643+
* reason?: string, delayMs?: number, modified?: any } | null`. When provided, the function is
1644+
* called for every matching event and its return value controls execution. Return
1645+
* `{ action: 'block', reason: '...' }` to cancel the operation, `{ action: 'skip' }` to skip
1646+
* remaining hooks, `{ action: 'retry', delayMs: 1000 }` to request a retry, or
1647+
* `{ action: 'continue', modified: {...} }` to continue with modified data. Hooks with no
1648+
* handler still fire (observable via stream events) but always continue.
1649+
*/
1650+
registerHook(hookId: string, eventType: string, matcher?: HookMatcherObject | undefined | null, config?: HookConfigObject | undefined | null, handler?: ((event: Record<string, unknown>) => { action: string; reason?: string; delayMs?: number; modified?: any } | null | undefined) | null | undefined): void
16511651
/**
16521652
* Unregister a hook by ID.
16531653
*

sdk/node/src/lib.rs

Lines changed: 107 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4426,17 +4426,17 @@ impl Session {
44264426
/// without requiring explicit registration on each sub-agent session.
44274427
///
44284428
/// @param hookId - Unique hook identifier
4429-
/// @param eventType - Event type: "pre_tool_use", "post_tool_use", "generate_start",
4430-
/// "generate_end", "session_start", "session_end", "skill_load", "skill_unload",
4431-
/// "pre_prompt", "post_response", "on_error"
4429+
/// @param eventType - Event type such as "pre_tool_use", "post_tool_use",
4430+
/// "pre_prompt", "post_response", "pre_planning", or "post_planning".
44324431
/// @param matcher - Optional matcher: { tool?, pathPattern?, commandPattern?, sessionId?, skill? }
44334432
/// @param config - Optional config: { priority?, timeoutMs?, asyncExecution?, maxRetries? }
4434-
/// @param handler - Optional callback `(event: any) => { action: 'continue' | 'block' | 'skip',
4435-
/// reason?: string } | null`. When provided, the function is called for every matching event
4436-
/// and its return value controls execution. Return `{ action: 'block', reason: '...' }` to
4437-
/// cancel the operation, `{ action: 'skip' }` to skip remaining hooks, or `null`/`undefined`
4438-
/// for continue. Hooks with no handler still fire (observable via stream events) but always
4439-
/// continue.
4433+
/// @param handler - Optional callback `(event: any) => { action: 'continue' | 'block' | 'skip' | 'retry',
4434+
/// reason?: string, delayMs?: number, modified?: any } | null`. When provided, the function is
4435+
/// called for every matching event and its return value controls execution. Return
4436+
/// `{ action: 'block', reason: '...' }` to cancel the operation, `{ action: 'skip' }` to skip
4437+
/// remaining hooks, `{ action: 'retry', delayMs: 1000 }` to request a retry, or
4438+
/// `{ action: 'continue', modified: {...} }` to continue with modified data. Hooks with no
4439+
/// handler still fire (observable via stream events) but always continue.
44404440
#[napi]
44414441
pub fn register_hook(
44424442
&self,
@@ -4445,7 +4445,7 @@ impl Session {
44454445
matcher: Option<HookMatcherObject>,
44464446
config: Option<HookConfigObject>,
44474447
#[napi(
4448-
ts_arg_type = "((event: Record<string, unknown>) => { action: string; reason?: string } | null | undefined) | null | undefined"
4448+
ts_arg_type = "((event: Record<string, unknown>) => { action: string; reason?: string; delayMs?: number; modified?: any } | null | undefined) | null | undefined"
44494449
)]
44504450
handler: Option<napi::JsFunction>,
44514451
) -> napi::Result<()> {
@@ -5465,10 +5465,25 @@ fn parse_hook_event_type(event_type: &str) -> napi::Result<RustHookEventType> {
54655465
"pre_prompt" => Ok(RustHookEventType::PrePrompt),
54665466
"post_response" => Ok(RustHookEventType::PostResponse),
54675467
"on_error" => Ok(RustHookEventType::OnError),
5468+
"pre_context_perception" => Ok(RustHookEventType::PreContextPerception),
5469+
"post_context_perception" => Ok(RustHookEventType::PostContextPerception),
5470+
"on_success" => Ok(RustHookEventType::OnSuccess),
5471+
"pre_memory_recall" => Ok(RustHookEventType::PreMemoryRecall),
5472+
"post_memory_recall" => Ok(RustHookEventType::PostMemoryRecall),
5473+
"pre_planning" => Ok(RustHookEventType::PrePlanning),
5474+
"post_planning" => Ok(RustHookEventType::PostPlanning),
5475+
"pre_reasoning" => Ok(RustHookEventType::PreReasoning),
5476+
"post_reasoning" => Ok(RustHookEventType::PostReasoning),
5477+
"on_rate_limit" => Ok(RustHookEventType::OnRateLimit),
5478+
"on_confirmation" => Ok(RustHookEventType::OnConfirmation),
5479+
"intent_detection" => Ok(RustHookEventType::IntentDetection),
54685480
_ => Err(napi::Error::from_reason(format!(
54695481
"Invalid hook event type: '{}'. Expected one of: pre_tool_use, post_tool_use, \
54705482
generate_start, generate_end, session_start, session_end, skill_load, \
5471-
skill_unload, pre_prompt, post_response, on_error",
5483+
skill_unload, pre_prompt, post_response, on_error, pre_context_perception, \
5484+
post_context_perception, on_success, pre_memory_recall, post_memory_recall, \
5485+
pre_planning, post_planning, pre_reasoning, post_reasoning, on_rate_limit, \
5486+
on_confirmation, intent_detection",
54725487
event_type
54735488
))),
54745489
}
@@ -5526,6 +5541,7 @@ impl RustHookHandler for NodeCallbackHandler {
55265541
/// - `{ action: 'block', reason: '…' }` → block
55275542
/// - `{ action: 'skip' }` → skip
55285543
/// - `{ action: 'retry', delayMs: N }` → retry after N ms
5544+
/// - `{ action: 'continue', modified: {...} }` → continue with modified data
55295545
fn parse_js_hook_response(val: napi::JsUnknown) -> napi::Result<RustHookResponse> {
55305546
use napi::{JsObject, ValueType};
55315547

@@ -5558,14 +5574,68 @@ fn parse_js_hook_response(val: napi::JsUnknown) -> napi::Result<RustHookResponse
55585574
.unwrap_or(1000) as u64;
55595575
Ok(RustHookResponse::retry(delay_ms))
55605576
}
5561-
// "continue" or any other value → continue
5562-
_ => Ok(RustHookResponse::continue_()),
5577+
// "continue" or any other value → continue, optionally with modified data.
5578+
_ => {
5579+
if let Ok(modified) = obj.get_named_property::<napi::JsUnknown>("modified") {
5580+
if !matches!(modified.get_type()?, ValueType::Null | ValueType::Undefined) {
5581+
let modified = js_unknown_to_json(modified)?;
5582+
return Ok(RustHookResponse::continue_with(modified));
5583+
}
5584+
}
5585+
Ok(RustHookResponse::continue_())
5586+
}
55635587
}
55645588
}
55655589
_ => Ok(RustHookResponse::continue_()),
55665590
}
55675591
}
55685592

5593+
fn js_unknown_to_json(value: napi::JsUnknown) -> napi::Result<serde_json::Value> {
5594+
use napi::{JsBoolean, JsNumber, JsObject, JsString, ValueType};
5595+
5596+
match value.get_type()? {
5597+
ValueType::Null | ValueType::Undefined => Ok(serde_json::Value::Null),
5598+
ValueType::Boolean => {
5599+
let value = unsafe { value.cast::<JsBoolean>() };
5600+
Ok(serde_json::Value::Bool(value.get_value()?))
5601+
}
5602+
ValueType::Number => {
5603+
let value = unsafe { value.cast::<JsNumber>() };
5604+
let number = serde_json::Number::from_f64(value.get_double()?)
5605+
.unwrap_or_else(|| serde_json::Number::from(0));
5606+
Ok(serde_json::Value::Number(number))
5607+
}
5608+
ValueType::String => {
5609+
let value = unsafe { value.cast::<JsString>() };
5610+
Ok(serde_json::Value::String(value.into_utf8()?.into_owned()?))
5611+
}
5612+
ValueType::Object => {
5613+
let object = unsafe { value.cast::<JsObject>() };
5614+
if object.is_array()? {
5615+
let mut values = Vec::new();
5616+
for index in 0..object.get_array_length()? {
5617+
let item = object.get_element::<napi::JsUnknown>(index)?;
5618+
values.push(js_unknown_to_json(item)?);
5619+
}
5620+
return Ok(serde_json::Value::Array(values));
5621+
}
5622+
5623+
let names = object.get_property_names()?;
5624+
let mut map = serde_json::Map::new();
5625+
for index in 0..names.get_array_length()? {
5626+
let key = names
5627+
.get_element::<JsString>(index)?
5628+
.into_utf8()?
5629+
.into_owned()?;
5630+
let item = object.get_named_property::<napi::JsUnknown>(&key)?;
5631+
map.insert(key, js_unknown_to_json(item)?);
5632+
}
5633+
Ok(serde_json::Value::Object(map))
5634+
}
5635+
_ => Ok(serde_json::Value::Null),
5636+
}
5637+
}
5638+
55695639
// ============================================================================
55705640
// SkillInfo
55715641
// ============================================================================
@@ -6043,6 +6113,30 @@ mod tests {
60436113
assert!(matches!(opts.planning_mode, RustPlanningMode::Enabled));
60446114
}
60456115

6116+
#[test]
6117+
fn hook_event_type_parser_accepts_harness_control_points() {
6118+
assert!(matches!(
6119+
parse_hook_event_type("pre_planning").unwrap(),
6120+
RustHookEventType::PrePlanning
6121+
));
6122+
assert!(matches!(
6123+
parse_hook_event_type("post_planning").unwrap(),
6124+
RustHookEventType::PostPlanning
6125+
));
6126+
assert!(matches!(
6127+
parse_hook_event_type("pre_memory_recall").unwrap(),
6128+
RustHookEventType::PreMemoryRecall
6129+
));
6130+
assert!(matches!(
6131+
parse_hook_event_type("intent_detection").unwrap(),
6132+
RustHookEventType::IntentDetection
6133+
));
6134+
6135+
let error = parse_hook_event_type("planning").unwrap_err().to_string();
6136+
assert!(error.contains("pre_planning"));
6137+
assert!(error.contains("post_planning"));
6138+
}
6139+
60466140
#[test]
60476141
fn session_options_maps_parallel_delegation_controls() {
60486142
let opts = js_session_options_to_rust(Some(SessionOptions {

0 commit comments

Comments
 (0)