Skip to content

Commit f0ad638

Browse files
committed
refactor and deduplicate
1 parent 393578d commit f0ad638

14 files changed

Lines changed: 324 additions & 227 deletions

File tree

Cargo.lock

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

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ reqwest = { version = "0.13", features = ["json"] }
4040
schemars = "1"
4141
serde.workspace = true
4242
serde_json.workspace = true
43-
serde_yml = "0.0.12"
43+
serde_yaml = "0.9"
4444
strum = { version = "0.28", features = ["derive"] }
4545
tachyonfx = "0.25"
4646
tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "io-util", "io-std", "sync"] }

src/agent/definition.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ fn parse_agent(raw: &str, filename: &str) -> Option<Agent> {
7474
let meta: AgentFrontmatter = if yaml.is_empty() {
7575
AgentFrontmatter::default()
7676
} else {
77-
serde_yml::from_str(&yaml).unwrap_or_default()
77+
serde_yaml::from_str(&yaml).unwrap_or_default()
7878
};
7979
let name = meta.name.map_or_else(
8080
|| {

src/agent/engine.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,10 @@ pub fn extract_output_text(text: &str, tool_results: Option<&[ToolResultInfo]>)
430430
let subagent_res = tool_results.and_then(|results| {
431431
results
432432
.iter()
433-
.rfind(|r| r.tool.name == "subagent")
433+
.rfind(|r| {
434+
crate::tools::ToolName::from_str_lossy(&r.tool.name)
435+
== Some(crate::tools::ToolName::Subagent)
436+
})
434437
.and_then(|r| r.output.as_ref().ok()?.as_str())
435438
});
436439

@@ -446,7 +449,10 @@ pub fn extract_output_text(text: &str, tool_results: Option<&[ToolResultInfo]>)
446449
.and_then(|results| {
447450
results
448451
.iter()
449-
.rfind(|r| r.tool.name == "shell")
452+
.rfind(|r| {
453+
crate::tools::ToolName::from_str_lossy(&r.tool.name)
454+
== Some(crate::tools::ToolName::Shell)
455+
})
450456
.or_else(|| results.last())?
451457
.output
452458
.as_ref()
@@ -542,7 +548,8 @@ impl<'a> StreamProcessor<'a> {
542548
.to_string();
543549
let output_str = anonymize_path(&output_str);
544550

545-
let is_plan_tool = name == "plan_set" || name == "plan_step_update";
551+
let tool = crate::tools::ToolName::from_str_lossy(&name);
552+
let is_plan_tool = tool.is_some_and(crate::tools::ToolName::is_plan_tool);
546553
if is_plan_tool {
547554
let _ = self.event_tx.send(AgentEvent::PlanUpdate);
548555
}

src/config/loader.rs

Lines changed: 3 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -65,60 +65,9 @@ pub fn load_config() -> anyhow::Result<PieConfig> {
6565
.extract()
6666
.map_err(|e| anyhow::anyhow!("config parse error: {e}"))?;
6767

68-
// Modular Hook Loading from plugins directory
69-
let mut scan_dirs = Vec::new();
70-
scan_dirs.push(global_home.join("plugins"));
71-
if let Some(root) = &project_root {
72-
scan_dirs.push(root.join(".pie").join("plugins"));
73-
}
74-
75-
for dir in scan_dirs {
76-
if !dir.exists() {
77-
continue;
78-
}
79-
80-
if let Ok(entries) = std::fs::read_dir(&dir) {
81-
for entry in entries.flatten() {
82-
let path = entry.path();
83-
84-
if path.is_dir() {
85-
let plugin_toml = path.join("plugin.toml");
86-
if plugin_toml.exists()
87-
&& let Ok(content) = std::fs::read_to_string(&plugin_toml)
88-
&& let Ok(mut plugin_config) = Figment::new()
89-
.merge(Toml::string(&content))
90-
.extract::<PieConfig>()
91-
{
92-
let plugin_dir_str = path.to_string_lossy().to_string();
93-
for hook in &mut plugin_config.hooks {
94-
hook.plugin_dir = Some(plugin_dir_str.clone());
95-
if hook.handler.starts_with("./") {
96-
let abs_handler = path
97-
.join(&hook.handler)
98-
.canonicalize()
99-
.unwrap_or_else(|_| path.join(&hook.handler));
100-
hook.handler = abs_handler.to_string_lossy().to_string();
101-
}
102-
}
103-
pie_config.hooks.extend(plugin_config.hooks);
104-
if let Some(to) = plugin_config.hooks_timeout_ms {
105-
pie_config.hooks_timeout_ms = Some(to);
106-
}
107-
}
108-
} else if path.extension().and_then(|s| s.to_str()) == Some("toml")
109-
&& let Ok(content) = std::fs::read_to_string(&path)
110-
&& let Ok(plugin_config) = Figment::new()
111-
.merge(Toml::string(&content))
112-
.extract::<PieConfig>()
113-
{
114-
pie_config.hooks.extend(plugin_config.hooks);
115-
if let Some(to) = plugin_config.hooks_timeout_ms {
116-
pie_config.hooks_timeout_ms = Some(to);
117-
}
118-
}
119-
}
120-
}
121-
}
68+
// Load hooks from plugin directories.
69+
let (plugin_hooks, _) = crate::plugin::scan_plugins();
70+
pie_config.hooks.extend(plugin_hooks);
12271

12372
Ok(pie_config)
12473
}

src/hook/types.rs

Lines changed: 71 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ impl Hook {
254254
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
255255

256256
Ok(HookOutcome::from_cmd(
257-
&hook_name, exit_code, stdout, stderr, context,
257+
&hook_name, exit_code, &stdout, &stderr, context,
258258
))
259259
}
260260

@@ -501,85 +501,98 @@ impl HookOutcome {
501501
fn from_cmd(
502502
name: &str,
503503
exit_code: Option<i32>,
504-
stdout: String,
505-
stderr: String,
504+
stdout: &str,
505+
stderr: &str,
506506
context: &HookContext,
507507
) -> Self {
508-
if !stdout.is_empty()
509-
&& let Ok(json_val) = serde_json::from_str::<serde_json::Value>(&stdout)
510-
{
511-
let action_res = serde_json::from_value::<ActionOutput>(json_val.clone());
512-
if let Ok(action) = action_res {
513-
if let Some(decision) = &action.decision {
514-
match decision {
515-
ActionDecision::Block | ActionDecision::Deny => {
516-
return HookOutcome::Error {
517-
name: name.to_string(),
518-
exit_code,
519-
message: format!(
520-
"Operation blocked by decision:\n{}",
521-
action.message.unwrap_or(stdout)
522-
),
523-
};
524-
}
525-
ActionDecision::Allow if action.updated_input.is_none() => {
526-
return HookOutcome::Success;
527-
}
528-
_ => {}
529-
}
530-
}
531-
532-
if let Some(data) = action.updated_input {
533-
return HookOutcome::Transformed {
534-
name: name.to_string(),
535-
data,
536-
};
537-
}
538-
539-
// Return raw JSON as delta — the pipeline handles merging.
540-
if context.data.is_tool() || action.decision.is_none() {
541-
return HookOutcome::Transformed {
542-
name: name.to_string(),
543-
data: json_val,
544-
};
545-
}
546-
547-
if matches!(action.decision, Some(ActionDecision::Allow)) {
548-
return HookOutcome::Success;
549-
}
550-
}
508+
// Try parsing stdout as structured action output.
509+
if let Some(outcome) = Self::parse_action_response(name, exit_code, stdout, context) {
510+
return outcome;
551511
}
552512

553-
// Exit-code based fallback
513+
// Exit-code based fallback.
554514
if exit_code == Some(0) {
555515
return HookOutcome::Success;
556516
}
557517

558-
let combined_output = if stderr.is_empty() {
559-
stdout
560-
} else if stdout.is_empty() {
561-
stderr
562-
} else {
563-
format!("{stdout}\n{stderr}")
518+
let combined = match (stdout, stderr) {
519+
("", s) | (s, "") => s.to_string(),
520+
(s, e) => format!("{s}\n{e}"),
564521
};
565522

566-
let is_rejection = matches!(exit_code, Some(2 | 64 | 65 | 77));
567-
568-
if is_rejection {
523+
if matches!(exit_code, Some(2 | 64 | 65 | 77)) {
569524
HookOutcome::Error {
570525
name: name.to_string(),
571526
exit_code,
572-
message: format!("Operation blocked:\n{combined_output}"),
527+
message: format!("Operation blocked:\n{combined}"),
573528
}
574529
} else {
575530
HookOutcome::Warning {
576531
name: name.to_string(),
577532
exit_code,
578-
message: combined_output,
533+
message: combined,
579534
}
580535
}
581536
}
582537

538+
/// Try to interpret stdout as a structured action response.
539+
fn parse_action_response(
540+
name: &str,
541+
exit_code: Option<i32>,
542+
stdout: &str,
543+
context: &HookContext,
544+
) -> Option<Self> {
545+
if stdout.is_empty() {
546+
return None;
547+
}
548+
let json_val: serde_json::Value = serde_json::from_str(stdout).ok()?;
549+
let action: ActionOutput = serde_json::from_value(json_val.clone()).ok()?;
550+
551+
// Decision-based handling.
552+
if let Some(ref decision) = action.decision {
553+
match decision {
554+
ActionDecision::Block | ActionDecision::Deny => {
555+
return Some(HookOutcome::Error {
556+
name: name.to_string(),
557+
exit_code,
558+
message: format!(
559+
"Operation blocked by decision:\n{}",
560+
action.message.as_deref().unwrap_or(stdout)
561+
),
562+
});
563+
}
564+
ActionDecision::Allow if action.updated_input.is_none() => {
565+
return Some(HookOutcome::Success);
566+
}
567+
ActionDecision::Allow => {
568+
return action.updated_input.map(|data| HookOutcome::Transformed {
569+
name: name.to_string(),
570+
data,
571+
});
572+
}
573+
ActionDecision::Ask => {}
574+
}
575+
}
576+
577+
// Explicit data transform takes priority.
578+
if let Some(data) = action.updated_input {
579+
return Some(HookOutcome::Transformed {
580+
name: name.to_string(),
581+
data,
582+
});
583+
}
584+
585+
// Return raw JSON as delta for tool contexts or when no decision was made.
586+
if context.data.is_tool() || action.decision.is_none() {
587+
return Some(HookOutcome::Transformed {
588+
name: name.to_string(),
589+
data: json_val,
590+
});
591+
}
592+
593+
None
594+
}
595+
583596
pub fn format(&self) -> String {
584597
match self {
585598
HookOutcome::Success => String::new(),

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ mod handler;
1616
mod hook;
1717
mod instructions;
1818
mod output;
19+
mod plugin;
1920
mod prompt;
2021
mod providers;
2122
mod registry;

0 commit comments

Comments
 (0)