1#![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
13const MAX_AGENT_TURNS: usize = 50;
15
16pub 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
38pub struct AgentHook {
40 pub prompt: String,
42 pub timeout: Option<u64>,
44 pub model: Option<String>,
46}
47
48pub 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 let transcript_path = format!("session_{}_transcript.json", tool_use_context.session_id);
64
65 let hook_start = std::time::Instant::now();
66
67 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 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 let hook_timeout_ms = hook.timeout.map_or(60_000, |t| t * 1000);
85
86 let (abort_tx, abort_rx) = tokio::sync::watch::channel(false);
88
89 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 let model = hook.model.clone().unwrap_or_else(get_small_fast_model);
98
99 let hook_agent_id = format!("hook-agent-{}", Uuid::new_v4());
101
102 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()), }),
110 });
111
112 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 for message in simulate_query_loop(&agent_messages, &transcript_path, &model).await {
123 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 if message.get("type") == Some(&serde_json::json!("assistant")) {
132 turn_count += 1;
133
134 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 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 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 let _ = abort_tx.send(true);
163 break;
164 }
165 }
166 }
167 }
168 }
169
170 if *abort_rx.borrow() {
172 break;
173 }
174 }
175
176 timeout_handle.abort();
177
178 clear_session_hooks_impl(&hook_agent_id);
180
181 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, "agent_name": agent_name.unwrap_or("unknown"),
207 }),
208 );
209 return HookResult::Cancelled;
210 }
211
212 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 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
250fn create_user_message(content: &str) -> serde_json::Value {
252 serde_json::json!({
253 "type": "user",
254 "message": {
255 "content": content
256 }
257 })
258}
259
260fn get_small_fast_model() -> String {
262 "claude-3-haiku-20240307".to_string()
263}
264
265async 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 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 let transcript_content = tokio::fs::read_to_string(transcript_path)
298 .await
299 .unwrap_or_default();
300
301 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 let user_msg = serde_json::json!({
316 "role": "user",
317 "content": prompt
318 });
319 let query_messages = vec![user_msg];
320
321 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 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 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 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
417fn extract_text(response: &serde_json::Value) -> String {
419 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 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
442fn noop_set_app_state(_updater: &dyn Fn(&mut serde_json::Value)) {
446 }
448
449fn 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
458fn clear_session_hooks_impl(session_id: &str) {
461 clear_session_hooks(&noop_set_app_state, session_id);
462}
463
464fn log_event(event_name: &str, _metadata: &serde_json::Value) {
466 log::debug!("Analytics event: {}", event_name);
467}
468
469fn log_for_debugging(msg: &str) {
471 log::debug!("{}", msg);
472}