Skip to content

Commit f6dc77a

Browse files
author
Yogthos
committed
feat(agent_loop): phase 5 — Janet plugin hook surface for pi-loop hooks
Surface every pi loop hook (prepareNextTurn, shouldStopAfterTurn, getSteeringMessages, getFollowUpMessages) to Janet plugins via dedicated slots. Auto-wired into spawn_loop_runner when a plugin manager is supplied. **Janet helpers added** (plugin/worker.rs): ; prepareNextTurn → next-turn config swap (harness/set-next-thinking-level "high") ; low/medium/high/xhigh/off/minimal ; harness/set-next-model already exists — repurposed ; shouldStopAfterTurn → graceful exit (harness/request-stop-after-turn) ; getSteeringMessages → mid-run user injection (harness/add-steering "wait, also do X") ; getFollowUpMessages → outer-loop continuation (harness/add-followup "do this next") **Rust slot accessors** (plugin/mod.rs): - take_pending_next_thinking_level() -> Option<String> - take_pending_stop_after_turn() -> bool - drain_steering_messages() -> Vec<String> - drain_followup_messages() -> Vec<String> Steering + followup use a newline-blob format (`msg\n`) so a single eval round-trip drains the queue cleanly. **Hook factories** (plugin_hooks.rs): - prepare_next_turn_from_plugin_manager(pm) -> PrepareNextTurnFn - should_stop_after_turn_from_plugin_manager(pm) -> ShouldStopAfterTurnFn - get_steering_messages_from_plugin_manager(pm) -> GetSteeringMessagesFn - get_followup_messages_from_plugin_manager(pm) -> GetFollowupMessagesFn Each follows the same lock-then-sync pattern as before/after_tool_call hooks: acquire mutex, eval slot, release. No `.await` while held. **Wired into spawn_loop_runner** (integration.rs): when `cfg.plugin_mgr` is set, all four hooks are installed alongside the existing before/after_tool_call. Caller-provided steering_queue still wins if both are set (explicit beats global). **Tests** (6 new integration tests with real Janet VM): - prepare_next_turn_reads_thinking_level - prepare_next_turn_returns_none_when_no_slot_set - prepare_next_turn_ignores_unknown_thinking_level (typo safety) - should_stop_after_turn_drains_slot - get_steering_messages_drains_queue (multiple add + drain) - get_followup_messages_drains_queue **Pi reference**: PLAN.md phase 5. Each slot maps 1:1 to a pi hook from runLoop. Slot mechanism (Janet `var` + `defn` helper + Rust `take_*` reader) was already established by the pre-existing harness-next-model / harness-block / harness-mutate-input slots; phase 5 extends the pattern to the remaining pi hooks. **Composition with phase 4.6**: prepareNextTurn's thinking_level field is now actively populated by plugins. The full chain works: plugin sets harness-next-thinking-level "high" in on-tool-end ↓ loop polls prepare_next_turn between turns ↓ TurnUpdate.thinking_level = Some(High) ↓ (currently surfaces tracing warn — code review #3; full model-swap apply pending rig API growth, see h-7 deferred) Gates: - cargo build (default) clean - cargo build --features plugin clean - cargo build --all-features clean - cargo test (default 846) green - cargo test --features plugin (985 = 979 pre-existing + 6 new phase 5 tests) green - cargo test --ignored 6 h-7 still green (no regression after Janet slot additions) - cargo fmt clean Phase 6 next: recovery / interjection / abort hardening under the new loop. Or phase 7 (custom message types). Phase 5 risk was low as predicted; landed clean.
1 parent c5dea06 commit f6dc77a

4 files changed

Lines changed: 404 additions & 3 deletions

File tree

‎src/agent/agent_loop/integration.rs‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -384,11 +384,37 @@ pub fn spawn_loop_runner(cfg: LoopSpawnConfig) -> LoopRunner {
384384
#[cfg(feature = "plugin")]
385385
{
386386
if let Some(pm) = cfg.plugin_mgr {
387+
// Phase 4.5d: before/after tool call hooks.
387388
loop_config.before_tool_call = Some(
388389
super::plugin_hooks::before_hook_from_plugin_manager(pm.clone()),
389390
);
390-
loop_config.after_tool_call =
391-
Some(super::plugin_hooks::after_hook_from_plugin_manager(pm));
391+
loop_config.after_tool_call = Some(
392+
super::plugin_hooks::after_hook_from_plugin_manager(pm.clone()),
393+
);
394+
// Phase 5: pi-loop hook surface for plugins.
395+
// Each polls a dedicated Janet slot the plugin sets
396+
// via harness/* helpers. Hooks fire at the right
397+
// loop points (prepareNextTurn between turns;
398+
// shouldStopAfterTurn after every turn;
399+
// getSteeringMessages per turn boundary;
400+
// getFollowUpMessages at outer-loop boundary).
401+
loop_config.prepare_next_turn = Some(
402+
super::plugin_hooks::prepare_next_turn_from_plugin_manager(pm.clone()),
403+
);
404+
loop_config.should_stop_after_turn =
405+
Some(super::plugin_hooks::should_stop_after_turn_from_plugin_manager(pm.clone()));
406+
// Compose with caller-provided steering queue: if
407+
// BOTH are present, prefer the plugin one (plugin
408+
// hooks compose at runtime; the explicit
409+
// steering_queue was for legacy / test usage). Real
410+
// production wires one or the other.
411+
if loop_config.get_steering_messages.is_none() {
412+
loop_config.get_steering_messages = Some(
413+
super::plugin_hooks::get_steering_messages_from_plugin_manager(pm.clone()),
414+
);
415+
}
416+
loop_config.get_followup_messages =
417+
Some(super::plugin_hooks::get_followup_messages_from_plugin_manager(pm));
392418
}
393419
}
394420

‎src/agent/agent_loop/plugin_hooks.rs‎

Lines changed: 265 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,12 @@ use crate::plugin::{PluginManager, escape_janet_string};
3434

3535
use super::hooks::{
3636
AfterToolCallContext, AfterToolCallFn, BeforeToolCallContext, BeforeToolCallFn,
37-
BeforeToolCallReturn,
37+
BeforeToolCallReturn, GetFollowupMessagesFn, GetSteeringMessagesFn, PrepareNextTurnFn,
38+
ShouldStopAfterTurnFn,
3839
};
40+
use super::message::{LoopMessage, UserMessage};
3941
use super::result::{AfterToolCallResult, BeforeToolCallResult};
42+
use super::types::{ThinkingLevel, TurnUpdate};
4043

4144
/// Build a `BeforeToolCallFn` that dispatches `on-tool-start`
4245
/// through the shared `PluginManager`.
@@ -224,6 +227,127 @@ fn flatten_text(content: &[serde_json::Value]) -> String {
224227
out
225228
}
226229

230+
// ============================================================
231+
// Phase 5 — pi-loop hook factories
232+
// ============================================================
233+
234+
/// Build a `PrepareNextTurnFn` that reads the
235+
/// `harness-next-thinking-level` slot (and `harness-next-model`)
236+
/// from the plugin manager. Plugins set these slots via
237+
/// `harness/set-next-thinking-level` / `harness/set-next-model`
238+
/// inside `on-tool-end` (or any hook firing between turns).
239+
///
240+
/// Returns `Some(TurnUpdate)` with the requested fields when any
241+
/// slot was set; `None` otherwise. Context is never mutated by
242+
/// this factory — separate hooks compose if a plugin wants to
243+
/// rewrite the transcript.
244+
///
245+
/// Locking pattern matches before/after_hook_from_plugin_manager:
246+
/// acquire-read-release synchronously per call.
247+
pub fn prepare_next_turn_from_plugin_manager(pm: Arc<Mutex<PluginManager>>) -> PrepareNextTurnFn {
248+
Arc::new(move |_ctx| {
249+
let pm = pm.clone();
250+
Box::pin(async move {
251+
let (thinking, model) = {
252+
let mut mgr = pm.lock().unwrap_or_else(|e| e.into_inner());
253+
let t = mgr.take_pending_next_thinking_level();
254+
let m = mgr.take_pending_next_model();
255+
(t, m)
256+
};
257+
let thinking_level = thinking.and_then(parse_thinking_level);
258+
if thinking_level.is_none() && model.is_none() {
259+
return None;
260+
}
261+
Some(TurnUpdate {
262+
context: None,
263+
model,
264+
thinking_level,
265+
})
266+
})
267+
})
268+
}
269+
270+
/// Build a `ShouldStopAfterTurnFn` that reads the
271+
/// `harness-stop-after-turn` flag. Plugins call
272+
/// `harness/request-stop-after-turn` from any per-turn hook
273+
/// (`on-tool-end`, etc.) to ask the loop to exit gracefully
274+
/// after the current turn.
275+
///
276+
/// Returns `true` once per slot-set; the slot is cleared on
277+
/// read so subsequent turns see the default (don't stop).
278+
pub fn should_stop_after_turn_from_plugin_manager(
279+
pm: Arc<Mutex<PluginManager>>,
280+
) -> ShouldStopAfterTurnFn {
281+
Arc::new(move |_ctx| {
282+
let pm = pm.clone();
283+
Box::pin(async move {
284+
let mut mgr = pm.lock().unwrap_or_else(|e| e.into_inner());
285+
mgr.take_pending_stop_after_turn()
286+
})
287+
})
288+
}
289+
290+
/// Build a `GetSteeringMessagesFn` that drains the plugin's
291+
/// `harness-steering-messages` queue. Plugins call
292+
/// `harness/add-steering` to inject mid-run user turns.
293+
///
294+
/// Returns a (possibly empty) Vec of `LoopMessage::User`s.
295+
pub fn get_steering_messages_from_plugin_manager(
296+
pm: Arc<Mutex<PluginManager>>,
297+
) -> GetSteeringMessagesFn {
298+
Arc::new(move || {
299+
let pm = pm.clone();
300+
Box::pin(async move {
301+
let drained: Vec<String> = {
302+
let mut mgr = pm.lock().unwrap_or_else(|e| e.into_inner());
303+
mgr.drain_steering_messages()
304+
};
305+
drained
306+
.into_iter()
307+
.map(|content| LoopMessage::User(UserMessage { content }))
308+
.collect()
309+
})
310+
})
311+
}
312+
313+
/// Build a `GetFollowupMessagesFn` that drains the plugin's
314+
/// `harness-followup-messages` queue. Plugins call
315+
/// `harness/add-followup` to add post-stop user turns; the
316+
/// outer loop re-enters with them as the next pending batch.
317+
pub fn get_followup_messages_from_plugin_manager(
318+
pm: Arc<Mutex<PluginManager>>,
319+
) -> GetFollowupMessagesFn {
320+
Arc::new(move || {
321+
let pm = pm.clone();
322+
Box::pin(async move {
323+
let drained: Vec<String> = {
324+
let mut mgr = pm.lock().unwrap_or_else(|e| e.into_inner());
325+
mgr.drain_followup_messages()
326+
};
327+
drained
328+
.into_iter()
329+
.map(|content| LoopMessage::User(UserMessage { content }))
330+
.collect()
331+
})
332+
})
333+
}
334+
335+
/// Parse a Janet-side level string into `ThinkingLevel`. Pi
336+
/// values: `"off"`, `"minimal"`, `"low"`, `"medium"`, `"high"`,
337+
/// `"xhigh"`. Unknown values produce None (plugin's typo is
338+
/// silently ignored rather than crashing the run).
339+
fn parse_thinking_level(s: String) -> Option<ThinkingLevel> {
340+
match s.as_str() {
341+
"off" => Some(ThinkingLevel::Off),
342+
"minimal" => Some(ThinkingLevel::Minimal),
343+
"low" => Some(ThinkingLevel::Low),
344+
"medium" => Some(ThinkingLevel::Medium),
345+
"high" => Some(ThinkingLevel::High),
346+
"xhigh" => Some(ThinkingLevel::Xhigh),
347+
_ => None,
348+
}
349+
}
350+
227351
#[cfg(test)]
228352
mod tests {
229353
use super::*;
@@ -450,4 +574,144 @@ mod tests {
450574
let out = flatten_text(&blocks);
451575
assert!(out.contains("image"));
452576
}
577+
578+
// ============================================================
579+
// Phase 5 — pi-loop hook factory tests
580+
// ============================================================
581+
582+
use crate::agent::agent_loop::hooks::TurnHookContext;
583+
use crate::agent::agent_loop::message::AssistantMessage as AM;
584+
585+
fn turn_ctx() -> TurnHookContext {
586+
TurnHookContext {
587+
message: AM::new(vec![], super::super::message::StopReason::Stop),
588+
tool_results: Vec::new(),
589+
context: crate::agent::agent_loop::types::Context::default(),
590+
new_messages: Vec::new(),
591+
}
592+
}
593+
594+
/// prepareNextTurn returns Some(TurnUpdate) with the
595+
/// requested thinking_level when a plugin set the slot.
596+
#[tokio::test]
597+
async fn prepare_next_turn_reads_thinking_level() {
598+
let Some(pm) = try_pm() else { return };
599+
{
600+
let mut mgr = pm.lock().unwrap();
601+
mgr.eval(r#"(defn bump [_ctx] (harness/set-next-thinking-level "high"))"#)
602+
.unwrap();
603+
mgr.register("on-tool-end", "bump");
604+
// Fire on-tool-end so the slot gets set.
605+
mgr.dispatch_tool_hook("on-tool-end", "@{:tool \"t\" :output \"x\"}")
606+
.unwrap();
607+
}
608+
let hook = prepare_next_turn_from_plugin_manager(pm);
609+
let out = hook(turn_ctx()).await;
610+
assert!(out.is_some(), "expected TurnUpdate");
611+
let upd = out.unwrap();
612+
assert_eq!(upd.thinking_level, Some(ThinkingLevel::High));
613+
assert!(upd.model.is_none());
614+
}
615+
616+
/// prepareNextTurn returns None when no slot was set.
617+
#[tokio::test]
618+
async fn prepare_next_turn_returns_none_when_no_slot_set() {
619+
let Some(pm) = try_pm() else { return };
620+
let hook = prepare_next_turn_from_plugin_manager(pm);
621+
assert!(hook(turn_ctx()).await.is_none());
622+
}
623+
624+
/// shouldStopAfterTurn returns true once after a plugin
625+
/// calls request-stop-after-turn, then false on subsequent
626+
/// reads (slot drained).
627+
#[tokio::test]
628+
async fn should_stop_after_turn_drains_slot() {
629+
let Some(pm) = try_pm() else { return };
630+
{
631+
let mut mgr = pm.lock().unwrap();
632+
mgr.eval(r#"(defn stop [_ctx] (harness/request-stop-after-turn))"#)
633+
.unwrap();
634+
mgr.register("on-tool-end", "stop");
635+
mgr.dispatch_tool_hook("on-tool-end", "@{:tool \"t\" :output \"x\"}")
636+
.unwrap();
637+
}
638+
let hook = should_stop_after_turn_from_plugin_manager(pm);
639+
assert!(hook(turn_ctx()).await, "first read should return true");
640+
assert!(
641+
!hook(turn_ctx()).await,
642+
"second read should be false (slot drained)"
643+
);
644+
}
645+
646+
/// getSteeringMessages drains the slot — each
647+
/// harness/add-steering call appears as a LoopMessage::User
648+
/// once; subsequent polls see only newly-added messages.
649+
#[tokio::test]
650+
async fn get_steering_messages_drains_queue() {
651+
let Some(pm) = try_pm() else { return };
652+
{
653+
let mut mgr = pm.lock().unwrap();
654+
mgr.eval(
655+
r#"(defn add [_ctx] (harness/add-steering "first") (harness/add-steering "second"))"#,
656+
)
657+
.unwrap();
658+
mgr.register("on-tool-end", "add");
659+
mgr.dispatch_tool_hook("on-tool-end", "@{:tool \"t\" :output \"x\"}")
660+
.unwrap();
661+
}
662+
let hook = get_steering_messages_from_plugin_manager(pm.clone());
663+
let messages = hook().await;
664+
assert_eq!(messages.len(), 2);
665+
let texts: Vec<String> = messages
666+
.iter()
667+
.filter_map(|m| match m {
668+
LoopMessage::User(u) => Some(u.content.clone()),
669+
_ => None,
670+
})
671+
.collect();
672+
assert_eq!(texts, vec!["first", "second"]);
673+
// Second poll: empty (drained).
674+
assert!(hook().await.is_empty());
675+
}
676+
677+
/// getFollowupMessages mirrors steering but reads its own
678+
/// independent slot.
679+
#[tokio::test]
680+
async fn get_followup_messages_drains_queue() {
681+
let Some(pm) = try_pm() else { return };
682+
{
683+
let mut mgr = pm.lock().unwrap();
684+
mgr.eval(r#"(defn add [_ctx] (harness/add-followup "next turn"))"#)
685+
.unwrap();
686+
mgr.register("on-tool-end", "add");
687+
mgr.dispatch_tool_hook("on-tool-end", "@{:tool \"t\" :output \"x\"}")
688+
.unwrap();
689+
}
690+
let hook = get_followup_messages_from_plugin_manager(pm);
691+
let messages = hook().await;
692+
assert_eq!(messages.len(), 1);
693+
match &messages[0] {
694+
LoopMessage::User(u) => assert_eq!(u.content, "next turn"),
695+
_ => panic!("expected User"),
696+
}
697+
}
698+
699+
/// Unknown thinking-level strings get filtered out — a
700+
/// plugin typo doesn't crash the run.
701+
#[tokio::test]
702+
async fn prepare_next_turn_ignores_unknown_thinking_level() {
703+
let Some(pm) = try_pm() else { return };
704+
{
705+
let mut mgr = pm.lock().unwrap();
706+
mgr.eval(r#"(defn bad [_ctx] (harness/set-next-thinking-level "supercritical"))"#)
707+
.unwrap();
708+
mgr.register("on-tool-end", "bad");
709+
mgr.dispatch_tool_hook("on-tool-end", "@{:tool \"t\" :output \"x\"}")
710+
.unwrap();
711+
}
712+
let hook = prepare_next_turn_from_plugin_manager(pm);
713+
// "supercritical" doesn't parse → thinking_level None
714+
// → no model set either → None overall.
715+
assert!(hook(turn_ctx()).await.is_none());
716+
}
453717
}

‎src/plugin/mod.rs‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2010,6 +2010,71 @@ impl PluginManager {
20102010
self.take_string_slot("harness-next-model")
20112011
}
20122012

2013+
// ============================================================
2014+
// Phase 5 — pi-loop hook slots
2015+
// ============================================================
2016+
2017+
/// Read and clear the `harness-next-thinking-level` slot.
2018+
/// Set by plugins via `harness/set-next-thinking-level` to
2019+
/// request a reasoning-level change for the next turn. The
2020+
/// new agent_loop path consults this from its
2021+
/// `prepareNextTurn` hook.
2022+
///
2023+
/// Returns the raw string ("low" | "medium" | "high" |
2024+
/// "xhigh" | "off" | "minimal"); the caller maps it to
2025+
/// `ThinkingLevel`.
2026+
pub fn take_pending_next_thinking_level(&mut self) -> Option<String> {
2027+
self.take_string_slot("harness-next-thinking-level")
2028+
}
2029+
2030+
/// Read and clear the `harness-stop-after-turn` flag. Set
2031+
/// by plugins via `harness/request-stop-after-turn` to ask
2032+
/// the loop to exit gracefully after the current turn.
2033+
/// Polled by the agent_loop `shouldStopAfterTurn` hook.
2034+
pub fn take_pending_stop_after_turn(&mut self) -> bool {
2035+
// The slot is `nil` initially and `true` once set. Eval
2036+
// returns "true" or "false" as text.
2037+
let was_set = self
2038+
.worker
2039+
.eval("(if harness-stop-after-turn true false)")
2040+
.map(|s| s == "true")
2041+
.unwrap_or(false);
2042+
if was_set {
2043+
let _ = self.worker.eval("(set harness-stop-after-turn nil)");
2044+
}
2045+
was_set
2046+
}
2047+
2048+
/// Drain the `harness-steering-messages` blob — a newline-
2049+
/// separated list of strings each plugins added via
2050+
/// `harness/add-steering`. Returns one entry per message;
2051+
/// empty Vec if no plugin added any.
2052+
pub fn drain_steering_messages(&mut self) -> Vec<String> {
2053+
self.drain_newline_blob("harness-steering-messages")
2054+
}
2055+
2056+
/// Drain the `harness-followup-messages` blob. Same shape
2057+
/// as steering; read at the outer-loop boundary by the new
2058+
/// loop's `getFollowUpMessages` hook.
2059+
pub fn drain_followup_messages(&mut self) -> Vec<String> {
2060+
self.drain_newline_blob("harness-followup-messages")
2061+
}
2062+
2063+
/// Shared body for `drain_*_messages` — read the slot's
2064+
/// string contents, split on newline, filter empty entries,
2065+
/// clear the slot to `""`.
2066+
fn drain_newline_blob(&mut self, var: &str) -> Vec<String> {
2067+
let raw = self
2068+
.worker
2069+
.eval(&format!("(if (string? {var}) {var} \"\")"))
2070+
.unwrap_or_default();
2071+
let _ = self.worker.eval(&format!("(set {var} \"\")"));
2072+
raw.lines()
2073+
.map(|s| s.to_string())
2074+
.filter(|s| !s.is_empty())
2075+
.collect()
2076+
}
2077+
20132078
/// Read and clear the `harness-prompt-replace` slot. Set by plugins
20142079
/// from `on-prompt` to rewrite the user turn before the agent runs.
20152080
/// Distinct from `take_pending_prompt`, which carries the

0 commit comments

Comments
 (0)