Skip to main content

ai_agent/utils/hooks/
exec_agent_hook.rs

1// Source: ~/claudecode/openclaudecode/src/utils/hooks/execAgentHook.ts
2#![allow(dead_code)]
3
4use std::collections::HashSet;
5use std::sync::Arc;
6use uuid::Uuid;
7
8use crate::types::Message;
9use crate::utils::hooks::helpers::{add_arguments_to_prompt, hook_response_json_schema};
10use crate::utils::hooks::hook_helpers::SYNTHETIC_OUTPUT_TOOL_NAME;
11use crate::utils::hooks::session_hooks::clear_session_hooks;
12
13/// Maximum number of turns for an agent hook
14const MAX_AGENT_TURNS: usize = 50;
15
16/// Result of a hook execution
17pub enum HookResult {
18    Success {
19        hook_name: String,
20        hook_event: String,
21        tool_use_id: String,
22    },
23    Blocking {
24        blocking_error: String,
25        command: String,
26    },
27    Cancelled,
28    NonBlockingError {
29        hook_name: String,
30        hook_event: String,
31        tool_use_id: String,
32        stderr: String,
33        stdout: String,
34        exit_code: i32,
35    },
36}
37
38/// Represents an agent hook configuration
39pub struct AgentHook {
40    /// The prompt to send to the agent
41    pub prompt: String,
42    /// Optional timeout in seconds
43    pub timeout: Option<u64>,
44    /// Optional model override
45    pub model: Option<String>,
46}
47
48/// Execute an agent-based hook using a multi-turn LLM query
49pub async fn exec_agent_hook(
50    hook: &AgentHook,
51    hook_name: &str,
52    hook_event: &str,
53    json_input: &str,
54    signal: tokio::sync::watch::Receiver<bool>,
55    tool_use_context: Arc<crate::utils::hooks::can_use_tool::ToolUseContext>,
56    tool_use_id: Option<String>,
57    _messages: &[Message],
58    agent_name: Option<&str>,
59) -> HookResult {
60    let effective_tool_use_id = tool_use_id.unwrap_or_else(|| format!("hook-{}", Uuid::new_v4()));
61
62    // Get transcript path from context
63    let transcript_path = format!("session_{}_transcript.json", tool_use_context.session_id);
64
65    let hook_start = std::time::Instant::now();
66
67    // Replace $ARGUMENTS with the JSON input
68    let processed_prompt = add_arguments_to_prompt(&hook.prompt, json_input);
69    log_for_debugging(&format!(
70        "Hooks: Processing agent hook with prompt: {}",
71        processed_prompt.chars().take(200).collect::<String>()
72    ));
73
74    // Create user message
75    let user_message = create_user_message(&processed_prompt);
76    let mut agent_messages = vec![user_message];
77
78    log_for_debugging(&format!(
79        "Hooks: Starting agent query with {} messages",
80        agent_messages.len()
81    ));
82
83    // Setup timeout
84    let hook_timeout_ms = hook.timeout.map_or(60_000, |t| t * 1000);
85
86    // Create abort controller
87    let (abort_tx, abort_rx) = tokio::sync::watch::channel(false);
88
89    // Combine parent signal with timeout
90    let abort_tx_clone = abort_tx.clone();
91    let timeout_handle = tokio::spawn(async move {
92        tokio::time::sleep(tokio::time::Duration::from_millis(hook_timeout_ms)).await;
93        let _ = abort_tx_clone.send(true);
94    });
95
96    // Get model
97    let model = hook.model.clone().unwrap_or_else(get_small_fast_model);
98
99    // Create unique agent ID for this hook agent
100    let hook_agent_id = format!("hook-agent-{}", Uuid::new_v4());
101
102    // Create a modified tool use context for the agent
103    let agent_tool_use_context = Arc::new(crate::utils::hooks::can_use_tool::ToolUseContext {
104        session_id: format!("{}-{}", tool_use_context.session_id, hook_agent_id),
105        cwd: tool_use_context.cwd.clone(),
106        is_non_interactive_session: true,
107        options: Some(crate::utils::hooks::can_use_tool::ToolUseContextOptions {
108            tools: Some(Vec::new()), // Would include filtered tools + structured output tool
109        }),
110    });
111
112    // Register a session-level stop hook to enforce structured output
113    register_structured_output_enforcement_impl(&hook_agent_id);
114
115    let mut structured_output_result: Option<serde_json::Value> = None;
116    let mut turn_count = 0;
117    let mut hit_max_turns = false;
118
119    // Simulate multi-turn query loop
120    // In the TS version, this uses query() for multi-turn execution
121    // Here we'd use the crate's query function
122    for message in simulate_query_loop(&agent_messages, &transcript_path, &model).await {
123        // Skip streaming events
124        if message.get("type") == Some(&serde_json::json!("stream_event"))
125            || message.get("type") == Some(&serde_json::json!("stream_request_start"))
126        {
127            continue;
128        }
129
130        // Count assistant turns
131        if message.get("type") == Some(&serde_json::json!("assistant")) {
132            turn_count += 1;
133
134            // Check if we've hit the turn limit
135            if turn_count >= MAX_AGENT_TURNS {
136                hit_max_turns = true;
137                log_for_debugging(&format!(
138                    "Hooks: Agent turn {} hit max turns, aborting",
139                    turn_count
140                ));
141                let _ = abort_tx.send(true);
142                break;
143            }
144        }
145
146        // Check for structured output in attachments
147        if let Some(attachment) = message.get("attachment") {
148            if let Some(attachment_type) = attachment.get("type") {
149                if attachment_type == "structured_output" {
150                    if let Some(data) = attachment.get("data") {
151                        // Validate against hook response schema
152                        if let Ok(parsed) = serde_json::from_value::<
153                            crate::utils::hooks::hook_helpers::HookResponse,
154                        >(data.clone())
155                        {
156                            structured_output_result = Some(data.clone());
157                            log_for_debugging(&format!(
158                                "Hooks: Got structured output: {}",
159                                serde_json::to_string(data).unwrap_or_default()
160                            ));
161                            // Got structured output, abort and exit
162                            let _ = abort_tx.send(true);
163                            break;
164                        }
165                    }
166                }
167            }
168        }
169
170        // Check abort signal
171        if *abort_rx.borrow() {
172            break;
173        }
174    }
175
176    timeout_handle.abort();
177
178    // Clean up the session hook we registered for this agent
179    clear_session_hooks_impl(&hook_agent_id);
180
181    // Check if we got a result
182    if structured_output_result.is_none() {
183        if hit_max_turns {
184            log_for_debugging(&format!(
185                "Hooks: Agent hook did not complete within {} turns",
186                MAX_AGENT_TURNS
187            ));
188            log_event(
189                "tengu_agent_stop_hook_max_turns",
190                &serde_json::json!({
191                    "duration_ms": hook_start.elapsed().as_millis(),
192                    "turn_count": turn_count,
193                    "agent_name": agent_name.unwrap_or("unknown"),
194                }),
195            );
196            return HookResult::Cancelled;
197        }
198
199        log_for_debugging("Hooks: Agent hook did not return structured output");
200        log_event(
201            "tengu_agent_stop_hook_error",
202            &serde_json::json!({
203                "duration_ms": hook_start.elapsed().as_millis(),
204                "turn_count": turn_count,
205                "error_type": 1, // 1 = no structured output
206                "agent_name": agent_name.unwrap_or("unknown"),
207            }),
208        );
209        return HookResult::Cancelled;
210    }
211
212    // Return result based on structured output
213    let result = structured_output_result.unwrap();
214    if let Some(ok) = result.get("ok").and_then(|v| v.as_bool()) {
215        if !ok {
216            let reason = result
217                .get("reason")
218                .and_then(|v| v.as_str())
219                .unwrap_or("unknown");
220            log_for_debugging(&format!(
221                "Hooks: Agent hook condition was not met: {}",
222                reason
223            ));
224            return HookResult::Blocking {
225                blocking_error: format!("Agent hook condition was not met: {}", reason),
226                command: hook.prompt.clone(),
227            };
228        }
229
230        // Condition was met
231        log_for_debugging("Hooks: Agent hook condition was met");
232        log_event(
233            "tengu_agent_stop_hook_success",
234            &serde_json::json!({
235                "duration_ms": hook_start.elapsed().as_millis(),
236                "turn_count": turn_count,
237                "agent_name": agent_name.unwrap_or("unknown"),
238            }),
239        );
240        return HookResult::Success {
241            hook_name: hook_name.to_string(),
242            hook_event: hook_event.to_string(),
243            tool_use_id: effective_tool_use_id,
244        };
245    }
246
247    HookResult::Cancelled
248}
249
250/// Create a user message with the given content
251fn create_user_message(content: &str) -> serde_json::Value {
252    serde_json::json!({
253        "type": "user",
254        "message": {
255            "content": content
256        }
257    })
258}
259
260/// Get the small fast model (simplified)
261fn get_small_fast_model() -> String {
262    "claude-3-haiku-20240307".to_string()
263}
264
265/// Execute a multi-turn agent query loop using direct API calls.
266/// Avoids importing from crate::query_engine and crate::agent to prevent
267/// a compile-time type cycle: hooks -> exec_agent_hook -> query_engine -> hooks.
268///
269/// For each turn:
270/// 1. Read the transcript file if it exists and prepend to context
271/// 2. Call the Anthropic API with JSON schema output
272/// 3. Parse the response for structured output (ok/reason)
273/// 4. Return events compatible with the exec_agent_hook consumer
274async fn simulate_query_loop(
275    messages: &[serde_json::Value],
276    transcript_path: &str,
277    model: &str,
278) -> Vec<serde_json::Value> {
279    use crate::utils::hooks::hook_helpers::hook_response_schema;
280
281    // Extract prompt text from input messages.
282    let prompt = messages
283        .iter()
284        .filter_map(|m| {
285            Some(
286                m.get("message")
287                    .and_then(|msg| msg.get("content"))
288                    .or_else(|| m.get("content"))?
289                    .as_str()?
290                    .to_string(),
291            )
292        })
293        .collect::<Vec<String>>()
294        .join("\n");
295
296    // Read transcript file content if available
297    let transcript_content = tokio::fs::read_to_string(transcript_path)
298        .await
299        .unwrap_or_default();
300
301    // Build system prompt with transcript context
302    let system_prompt = format!(
303        "You are verifying a stop condition in Claude Code. Your task is to verify that \
304         the agent completed the given plan.\n\nConversation transcript:{}\n\n\
305         Use the transcript above to analyze the conversation history.\
306         Return your verification result as JSON.",
307        if transcript_content.is_empty() {
308            " (not available)".to_string()
309        } else {
310            format!("\n---\n{}\n---", transcript_content.chars().take(50000).collect::<String>())
311        }
312    );
313
314    // Build the query messages
315    let user_msg = serde_json::json!({
316        "role": "user",
317        "content": prompt
318    });
319    let query_messages = vec![user_msg];
320
321    // Resolve API credentials
322    let base_url = std::env::var("AI_API_BASE_URL")
323        .unwrap_or_else(|_| "https://api.anthropic.com".to_string());
324    let api_key = std::env::var("AI_AUTH_TOKEN")
325        .or_else(|_| std::env::var("ANTHROPIC_API_KEY"))
326        .or_else(|_| std::env::var("ANTHROPIC_AUTH_TOKEN"))
327        .ok();
328
329    if api_key.is_none() {
330        log_for_debugging("Hooks: No API key available, skipping agent query");
331        return Vec::new();
332    }
333    let api_key = api_key.unwrap();
334
335    let url = format!("{}/v1/messages", base_url);
336    let request_body = serde_json::json!({
337        "model": model,
338        "max_tokens": 4096,
339        "system": [{"type": "text", "text": system_prompt}],
340        "messages": query_messages,
341        "temperature": 0.0,
342        "output": {
343            "type": "json_schema",
344            "name": "hook_response",
345            "schema": hook_response_schema(),
346            "strict": true
347        }
348    });
349
350    let client = reqwest::Client::new();
351    let mut req_builder = client.post(&url)
352        .json(&request_body)
353        .header("Content-Type", "application/json");
354
355    if base_url.contains("anthropic.com") {
356        req_builder = req_builder
357            .header("x-api-key", &api_key)
358            .header("anthropic-version", "2023-06-01");
359    } else {
360        req_builder = req_builder.header("Authorization", format!("Bearer {}", api_key));
361    }
362
363    let mut result = Vec::new();
364    // Emit assistant turn event for turn counting
365    result.push(serde_json::json!({ "type": "assistant" }));
366
367    match req_builder.send().await {
368        Ok(response) => {
369            let status = response.status();
370            let body = response.text().await.unwrap_or_default();
371
372            if !status.is_success() {
373                log_for_debugging(&format!("Hooks: API error {}: {}", status, body));
374                result.push(serde_json::json!({ "type": "done" }));
375                return result;
376            }
377
378            let parsed: serde_json::Value = match serde_json::from_str(&body) {
379                Ok(v) => v,
380                Err(e) => {
381                    log_for_debugging(&format!("Hooks: Failed to parse API response: {}", e));
382                    result.push(serde_json::json!({ "type": "done" }));
383                    return result;
384                }
385            };
386
387            // Extract text from response (supports both Anthropic and OpenAI formats)
388            let text = extract_text(&parsed);
389            if text.is_empty() {
390                log_for_debugging("Hooks: Empty response from model");
391                result.push(serde_json::json!({ "type": "done" }));
392                return result;
393            }
394
395            log_for_debugging(&format!("Hooks: Model response: {}", text));
396
397            // Emit structured output attachment so the caller detects it
398            result.push(serde_json::json!({
399                "type": "attachment",
400                "attachment": {
401                    "type": "structured_output",
402                    "data": serde_json::from_str::<serde_json::Value>(&text).unwrap_or_else(|_| {
403                        serde_json::json!({"ok": false, "reason": "Failed to parse model response"})
404                    })
405                }
406            }));
407        }
408        Err(e) => {
409            log_for_debugging(&format!("Hooks: Request failed: {}", e));
410        }
411    }
412
413    result.push(serde_json::json!({ "type": "done" }));
414    result
415}
416
417/// Extract text content from an API response (supports both Anthropic and OpenAI formats)
418fn extract_text(response: &serde_json::Value) -> String {
419    // OpenAI format: choices[].message.content
420    if let Some(content) = response.get("choices").and_then(|c| c.as_array())
421        .and_then(|c| c.first())
422        .and_then(|c| c.get("message"))
423        .and_then(|m| m.get("content"))
424        .and_then(|c| c.as_str()) {
425        return content.to_string();
426    }
427    // Anthropic format: content[].text
428    if let Some(blocks) = response.get("content").and_then(|c| c.as_array()) {
429        let mut texts = Vec::new();
430        for block in blocks {
431            if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
432                texts.push(text.to_string());
433            }
434        }
435        if !texts.is_empty() {
436            return texts.join("\n");
437        }
438    }
439    String::new()
440}
441
442/// No-op set_app_state for use with session hook functions that require a
443/// state setter.  The real session-hook state lives in an internal static,
444/// so this placeholder is sufficient.
445fn noop_set_app_state(_updater: &dyn Fn(&mut serde_json::Value)) {
446    // No-op — internal SESSION_HOOKS_STATE handles the actual storage.
447}
448
449/// Register structured output enforcement for the given session/agent ID.
450/// Wraps the hook_helpers function with a no-op set_app_state.
451fn register_structured_output_enforcement_impl(session_id: &str) {
452    crate::utils::hooks::hook_helpers::register_structured_output_enforcement(
453        &noop_set_app_state,
454        session_id,
455    );
456}
457
458/// Clear session hooks for the given session/agent ID.
459/// Wraps the session_hooks function with a no-op set_app_state.
460fn clear_session_hooks_impl(session_id: &str) {
461    clear_session_hooks(&noop_set_app_state, session_id);
462}
463
464/// Log event for analytics (simplified)
465fn log_event(event_name: &str, _metadata: &serde_json::Value) {
466    log::debug!("Analytics event: {}", event_name);
467}
468
469/// Log for debugging
470fn log_for_debugging(msg: &str) {
471    log::debug!("{}", msg);
472}