Add installable agent lifecycle extensions

This commit is contained in:
Georg Bauer
2026-08-30 16:05:09 +02:00
parent 1ac559bbd0
commit 3977261bd3
10 changed files with 2838 additions and 61 deletions

View File

@@ -40,10 +40,10 @@ struct AgentSkillMetadata {
description: String,
}
struct AgentSkill {
name: String,
description: String,
path: PathBuf,
pub(crate) struct AgentSkill {
pub(crate) name: String,
pub(crate) description: String,
pub(crate) path: PathBuf,
}
fn agent_skills_root(home: Option<&Path>) -> Option<PathBuf> {
@@ -76,7 +76,7 @@ fn skill_frontmatter(content: &str) -> Option<&str> {
Some(content[..end].trim_end_matches('\r'))
}
fn discover_agent_skills(root: &Path) -> Vec<AgentSkill> {
pub(crate) fn discover_agent_skills(root: &Path) -> Vec<AgentSkill> {
let Ok(root) = root.canonicalize() else {
return Vec::new();
};
@@ -109,8 +109,17 @@ fn discover_agent_skills(root: &Path) -> Vec<AgentSkill> {
skills
}
#[cfg(test)]
fn agent_skills_prompt_for(root: &Path) -> Option<String> {
let skills = discover_agent_skills(root);
agent_skills_prompt_for_roots(std::iter::once(root))
}
fn agent_skills_prompt_for_roots<'a>(roots: impl IntoIterator<Item = &'a Path>) -> Option<String> {
let mut skills = roots
.into_iter()
.flat_map(discover_agent_skills)
.collect::<Vec<_>>();
skills.sort_by(|left, right| left.name.cmp(&right.name));
if skills.is_empty() {
return None;
}
@@ -131,9 +140,14 @@ fn agent_skills_prompt_for(root: &Path) -> Option<String> {
))
}
pub(crate) fn agent_skills_prompt() -> Option<String> {
let root = agent_skills_root(std::env::var_os("HOME").as_deref().map(Path::new))?;
agent_skills_prompt_for(&root)
pub(crate) fn agent_skills_prompt_with(extension_roots: &[PathBuf]) -> Option<String> {
let standard = agent_skills_root(std::env::var_os("HOME").as_deref().map(Path::new));
agent_skills_prompt_for_roots(
standard
.iter()
.map(PathBuf::as_path)
.chain(extension_roots.iter().map(PathBuf::as_path)),
)
}
fn user_shell() -> OsString {
@@ -1526,6 +1540,8 @@ struct RalphRuntime {
engine: EngineSettings,
turn: TurnSettings,
idle_timeout: Duration,
extensions: crate::extensions::ExtensionRegistry,
session_id: i32,
}
#[cfg(target_os = "macos")]
@@ -1612,6 +1628,17 @@ impl Tools {
Ok(())
}
pub(crate) fn enable_agent_skill_roots(&mut self, roots: &[PathBuf]) {
self.agent_skill_roots.extend(
roots
.iter()
.flat_map(|root| discover_agent_skills(root))
.filter_map(|skill| skill.path.parent().map(Path::to_owned)),
);
self.agent_skill_roots.sort();
self.agent_skill_roots.dedup();
}
#[cfg(target_os = "macos")]
pub(crate) fn enable_ralph(
&mut self,
@@ -1620,13 +1647,17 @@ impl Tools {
engine: EngineSettings,
turn: TurnSettings,
idle_timeout: Duration,
extension_context: (crate::extensions::ExtensionRegistry, i32),
) {
let (extensions, session_id) = extension_context;
self.ralph = Some(RalphRuntime {
service,
model,
engine,
turn,
idle_timeout,
extensions,
session_id,
});
}
@@ -1786,6 +1817,53 @@ impl Tools {
// A unique cache namespace preserves tool continuations inside this
// round while preventing parent or earlier-round KV restoration.
let directory = RalphRoundDirectory::create(round)?;
let mut round_turn = runtime.turn.clone();
let hooks = runtime.extensions.dispatch(
&[crate::extensions::HookEvent::SubagentStart {
session_id: runtime.session_id,
round,
}],
&self.root,
&runtime.model.to_string(),
cancel,
);
let _ = runtime.extensions.persist_hook_results(&hooks);
if let Some(status) = hooks.status() {
send_state(
events,
index,
ToolLifecycle::Running,
Some(format!("Ralph round {round}/{max_rounds} · {status}\n")),
);
}
if !hooks.errors.is_empty() {
send_state(
events,
index,
ToolLifecycle::Running,
Some(format!(
"Ralph round {round}/{max_rounds} · extension hook warning: {}\n",
hooks
.errors
.iter()
.map(|(id, error)| format!("{id}: {error}"))
.collect::<Vec<_>>()
.join("; ")
)),
);
}
for output in &hooks.outputs {
if let Some(context) = &output.additional_context {
round_turn.system_prompt.push_str("\n\n");
round_turn
.system_prompt
.push_str(&crate::extensions::wrap_context(
&output.extension_id,
&output.event,
context,
));
}
}
let mut messages = vec![ChatTurn {
user: true,
tool: false,
@@ -1804,7 +1882,8 @@ impl Tools {
"Ralph round {round}/{max_rounds} · child generation {step}\n"
)),
);
let output = run_ralph_generation(runtime, &messages, &directory.0, cancel)?;
let output =
run_ralph_generation(runtime, &round_turn, &messages, &directory.0, cancel)?;
let calls = parse_tool_calls(runtime.model, &output.message.content)
.map_err(|error| format!("malformed child tool syntax: {error}"))?
.1;
@@ -2622,13 +2701,14 @@ impl Tools {
#[cfg(target_os = "macos")]
fn run_ralph_generation(
runtime: &RalphRuntime,
turn: &TurnSettings,
messages: &[ChatTurn],
checkpoint: &Path,
cancel: &AtomicBool,
) -> Result<crate::engine::GenerationOutput, String> {
let active = runtime.service.generate(
runtime.engine.clone(),
runtime.turn.clone(),
turn.clone(),
messages.to_vec(),
CheckpointTarget::Transient(checkpoint.to_owned()),
WorkSource::LocalChat,