use std::cell::RefCell;
use std::collections::HashSet;
use std::hash::{DefaultHasher, Hash, Hasher};
use crate::error::Result;
use crate::hooks::{OperationContext, PostToolCallHook, PreToolCallDecideHook};
use crate::types::{HookResult, ToolCall, ToolResult};
const FRESH_HASH_KEY: &str = "app::dedup::fresh_hash";
thread_local! {
static RUN_HASHES: RefCell<HashSet<u64>> = RefCell::new(HashSet::new());
}
const GUARDED: &[&str] = &[
"notify",
"record_lesson",
"set_lessons",
"send_lh",
"batch_send_lh",
"post_bounty",
"submit_feedback",
"create_subdomain",
"create_and_publish_app",
"claim_bounty",
"submit_result",
"accept_result",
"create_guild",
"invite_to_guild",
"fund_guild",
"spend_treasury",
"propose_measure",
"cast_vote",
"execute_proposal",
"release_subdomain",
];
pub(crate) fn reset_run() {
RUN_HASHES.with(|h| h.borrow_mut().clear());
}
fn call_hash(call: &ToolCall) -> u64 {
let mut hasher = DefaultHasher::new();
call.name.hash(&mut hasher);
call.args.to_string().hash(&mut hasher);
hasher.finish()
}
pub(crate) struct DuplicateActionGuard;
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
impl PreToolCallDecideHook for DuplicateActionGuard {
fn name(&self) -> &str {
"app::duplicate_action_guard"
}
async fn run(&self, ctx: &OperationContext, call: &ToolCall) -> Result<HookResult> {
if !GUARDED.contains(&call.name.as_str()) {
return Ok(HookResult::allow());
}
let h = call_hash(call);
let already_ran = RUN_HASHES.with(|s| !s.borrow_mut().insert(h));
if already_ran {
return Ok(HookResult::deny(format!(
"duplicate suppressed: `{}` with these exact arguments already \
executed during this request — do NOT repeat side-effecting \
actions. If the user explicitly wants it again, vary the \
arguments; if the request is fulfilled, call `finish` now.",
call.name
)));
}
ctx.set(FRESH_HASH_KEY, serde_json::Value::String(h.to_string()));
Ok(HookResult::allow())
}
}
pub(crate) struct DuplicateActionGuardCleanup;
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
impl PostToolCallHook for DuplicateActionGuardCleanup {
fn name(&self) -> &str {
"app::duplicate_action_guard_cleanup"
}
async fn run(&self, ctx: &OperationContext, result: &ToolResult) -> Result<()> {
if result.error.is_none() {
return Ok(());
}
let Some(h) = ctx
.get(FRESH_HASH_KEY)
.and_then(|v| v.as_str().and_then(|s| s.parse::<u64>().ok()))
else {
return Ok(());
};
RUN_HASHES.with(|s| s.borrow_mut().remove(&h));
Ok(())
}
}