Skip to main content

quorum_rs/agents/
user_tools.rs

1use crate::agents::{DeliberationPhase, PendingToolCall, ToolCallStatus, UserToolHandlerTrait};
2use crate::nats_utils::ensure_kv_bucket;
3use anyhow::Result;
4use async_trait::async_trait;
5use futures_util::StreamExt;
6use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
7use tracing::{info, warn};
8use uuid::Uuid;
9
10/// Escape XML special characters to prevent breaking XML-like wrapper elements.
11fn escape_xml(s: &str) -> String {
12    s.replace('&', "&")
13        .replace('<', "&lt;")
14        .replace('>', "&gt;")
15}
16
17/// Escape XML attribute values — additionally escapes quotes to prevent attribute injection.
18fn escape_xml_attr(s: &str) -> String {
19    s.replace('&', "&amp;")
20        .replace('<', "&lt;")
21        .replace('>', "&gt;")
22        .replace('"', "&quot;")
23        .replace('\'', "&apos;")
24}
25
26/// Compute finalization reserve from phase budget and config parameters.
27/// Returns the smaller of ratio-based and fixed-cap reserves.
28/// Inputs are sanitized: NaN, infinite, and negative values are clamped to 0.0,
29/// and `reserve_ratio` is capped at 1.0 to avoid nonsensical reserves.
30fn compute_finalization_reserve(
31    phase_budget: Duration,
32    reserve_secs: f64,
33    reserve_ratio: f64,
34) -> Duration {
35    let safe_secs = if reserve_secs.is_finite() && reserve_secs > 0.0 {
36        reserve_secs
37    } else {
38        0.0
39    };
40    let safe_ratio = if reserve_ratio.is_finite() && reserve_ratio > 0.0 {
41        reserve_ratio.min(1.0)
42    } else {
43        0.0
44    };
45    let ratio_based = Duration::from_secs_f64(phase_budget.as_secs_f64() * safe_ratio);
46    let fixed = Duration::from_secs_f64(safe_secs);
47    ratio_based.min(fixed)
48}
49
50/// Name of the KV bucket carrying one session's pending user tool calls.
51///
52/// The single definition on purpose. The agent writes these records and the
53/// orchestrator polls them, and each used to build the name itself — the reader
54/// with `nsed` hardcoded, the writer from its configured prefix. They agree only
55/// while the prefix is the default: change it and the agent creates a second
56/// bucket, writes a question nobody is watching, waits out the whole deadline,
57/// and the orchestrator's cleanup deletes the other name and leaks this one.
58/// Sharing the function makes that divergence unrepresentable rather than
59/// merely detectable.
60pub fn toolcalls_bucket_name(subject_prefix: &str, session_id: &str) -> String {
61    format!(
62        "{}_toolcalls_{}",
63        crate::nats_utils::sanitize_subject_component(subject_prefix),
64        crate::nats_utils::sanitize_subject_component(session_id)
65    )
66}
67
68/// Encapsulates the NATS state needed to handle user tool calls within the
69/// react loop. Created by the NatsNsedWorker and passed down to the agent.
70#[derive(Clone, Debug)]
71pub struct UserToolHandler {
72    nats_client: async_nats::Client,
73    js_context: async_nats::jetstream::Context,
74    session_id: String,
75    agent_id: String,
76    /// NATS subject/bucket prefix (e.g. "nsed"). Avoids hardcoding.
77    subject_prefix: String,
78    /// Phase start time — used to compute remaining budget dynamically.
79    phase_start: Instant,
80    /// Phase budget as originally allocated.
81    phase_budget: Duration,
82    /// Configurable limits
83    max_pending_per_agent: usize,
84    finalization_reserve_secs: f64,
85    finalization_reserve_ratio: f64,
86}
87
88enum WaitResult {
89    Responded(String),
90    Timeout,
91    Error(String),
92}
93
94impl UserToolHandler {
95    pub fn new(
96        nats_client: async_nats::Client,
97        js_context: async_nats::jetstream::Context,
98        session_id: String,
99        agent_id: String,
100        phase_budget_remaining_secs: f64,
101    ) -> Self {
102        // Sanitize: NaN and infinite values would panic in Duration::from_secs_f64
103        let safe_budget = if phase_budget_remaining_secs.is_finite() {
104            phase_budget_remaining_secs.max(0.0)
105        } else {
106            0.0
107        };
108        Self {
109            nats_client,
110            js_context,
111            session_id,
112            agent_id,
113            subject_prefix: "nsed".to_string(),
114            phase_start: Instant::now(),
115            phase_budget: Duration::from_secs_f64(safe_budget),
116            max_pending_per_agent: 3,
117            finalization_reserve_secs: 30.0,
118            finalization_reserve_ratio: 0.15,
119        }
120    }
121
122    /// Set the NATS subject/bucket prefix (default: "nsed").
123    pub fn with_subject_prefix(mut self, prefix: String) -> Self {
124        self.subject_prefix = prefix;
125        self
126    }
127
128    /// Configure rate limit for pending calls per agent.
129    pub fn with_max_pending_per_agent(mut self, max: usize) -> Self {
130        self.max_pending_per_agent = max;
131        self
132    }
133
134    /// Configure finalization reserve parameters.
135    pub fn with_finalization_reserve(mut self, secs: f64, ratio: f64) -> Self {
136        self.finalization_reserve_secs = secs;
137        self.finalization_reserve_ratio = ratio;
138        self
139    }
140
141    /// Returns the remaining time in this phase, accounting for time already
142    /// spent in the react loop since the handler was created.
143    fn remaining_budget(&self) -> Duration {
144        self.phase_budget.saturating_sub(self.phase_start.elapsed())
145    }
146
147    /// Compute the finalization reserve: the time reserved for the agent to
148    /// produce a proposal after a tool call, even without a user response.
149    fn finalization_reserve(&self) -> Duration {
150        compute_finalization_reserve(
151            self.phase_budget,
152            self.finalization_reserve_secs,
153            self.finalization_reserve_ratio,
154        )
155    }
156
157    fn now_epoch_millis() -> u64 {
158        SystemTime::now()
159            .duration_since(UNIX_EPOCH)
160            .unwrap_or_default()
161            .as_millis() as u64
162    }
163
164    fn bucket_name(&self) -> String {
165        toolcalls_bucket_name(&self.subject_prefix, &self.session_id)
166    }
167
168    /// Handle a user tool call: publish to KV, wait for response, return result.
169    pub async fn handle_call(
170        &self,
171        tool_name: &str,
172        arguments_json: &str,
173        round: u32,
174        phase: DeliberationPhase,
175    ) -> String {
176        // 1. Refuse before touching anything if there is no time to wait for an
177        //    answer. This used to be asked after the bucket, the record and the
178        //    SSE event had all gone out, so a question that was already dead left
179        //    a trail: a poller could only ever catch it Expired, a live client
180        //    rendered and withdrew it in one breath, and an empty bucket outlived
181        //    both. The budget needs no NATS to read, so nothing has to happen
182        //    before this is known.
183        //
184        //    Reachable whenever a call lands inside the finalization reserve —
185        //    `force_finalize` withdraws these tools at three average iterations of
186        //    remaining budget, a shorter window than the reserve when iterations
187        //    are quick.
188        let remaining = self.remaining_budget();
189        let reserve = self.finalization_reserve();
190        if remaining <= reserve {
191            info!(
192                agent = %self.agent_id,
193                tool = %tool_name,
194                remaining_secs = remaining.as_secs_f64(),
195                reserve_secs = reserve.as_secs_f64(),
196                "Not asking: no budget beyond the finalization reserve."
197            );
198            return "[No response — phase budget exhausted. Proceed immediately.]".to_string();
199        }
200
201        // 2. Parse arguments — propagate parse error instead of silently defaulting
202        let arguments: serde_json::Value = match serde_json::from_str(arguments_json) {
203            Ok(v) => v,
204            Err(e) => {
205                return format!("Error: Invalid JSON arguments: {}", e);
206            }
207        };
208
209        // 3. Get or create the toolcalls KV bucket
210        let bucket_name = self.bucket_name();
211        let toolcall_store = match self.get_or_create_bucket(&bucket_name).await {
212            Ok(store) => store,
213            Err(e) => {
214                warn!("Failed to access toolcall bucket: {}", e);
215                return format!("Error: Failed to register tool call: {}", e);
216            }
217        };
218
219        // 3. Rate limiting — count pending calls for this agent
220        match self.count_pending_for_agent(&toolcall_store).await {
221            Ok(count) if count >= self.max_pending_per_agent => {
222                return format!(
223                    "Error: Maximum pending tool calls ({}) reached for this agent. \
224                     Wait for existing calls to be answered before making new ones.",
225                    self.max_pending_per_agent
226                );
227            }
228            Err(e) => {
229                warn!("Failed to check pending count: {}", e);
230                // Continue anyway — rate limiting is best-effort
231            }
232            _ => {}
233        }
234
235        // 4. Create PendingToolCall
236        let call_id = Uuid::new_v4().to_string();
237        let pending_call = PendingToolCall {
238            call_id: call_id.clone(),
239            job_id: self.session_id.clone(),
240            agent_id: self.agent_id.clone(),
241            tool_name: tool_name.to_string(),
242            arguments: arguments.clone(),
243            round,
244            phase,
245            status: ToolCallStatus::Pending,
246            created_at: Self::now_epoch_millis(),
247            responded_at: None,
248            result: None,
249        };
250
251        // 5. Store in KV
252        let key = format!("call_{}", call_id);
253        let data = match serde_json::to_vec(&pending_call) {
254            Ok(d) => d,
255            Err(e) => return format!("Error: Failed to serialize tool call: {}", e),
256        };
257        if let Err(e) = toolcall_store.put(&key, data.into()).await {
258            return format!("Error: Failed to store pending tool call: {}", e);
259        }
260
261        // 6. Publish SSE event
262        self.publish_sse_event(
263            "tool_call_pending",
264            &serde_json::json!({
265                "call_id": &call_id,
266                "agent_id": &self.agent_id,
267                "tool_name": tool_name,
268                "arguments": &arguments,
269                "round": round,
270                "phase": &phase,
271            }),
272        )
273        .await;
274
275        info!(
276            agent = %self.agent_id,
277            tool = %tool_name,
278            call_id = %call_id,
279            "User tool call published. Waiting for response..."
280        );
281
282        // Deadline from the budget measured before publishing. Re-reading it here
283        // would only shave off the microseconds the publish took.
284        let deadline = remaining.saturating_sub(reserve);
285
286        // 8. Watch for response with timeout
287        let result = self
288            .wait_for_response(&toolcall_store, &key, deadline)
289            .await;
290
291        match result {
292            WaitResult::Responded(response_text) => {
293                self.publish_sse_event(
294                    "tool_call_responded",
295                    &serde_json::json!({
296                        "call_id": &call_id,
297                        "agent_id": &self.agent_id,
298                        "tool_name": tool_name,
299                    }),
300                )
301                .await;
302                info!(
303                    agent = %self.agent_id,
304                    call_id = %call_id,
305                    "User tool call responded."
306                );
307                // Wrap in tags to delimit untrusted content; escape to prevent XML breakout.
308                // Attribute values use escape_xml_attr (includes quote escaping).
309                let escaped = escape_xml(&response_text);
310                let safe_tool = escape_xml_attr(tool_name);
311                let safe_call = escape_xml_attr(&call_id);
312                format!(
313                    "<user_tool_result tool=\"{}\" call_id=\"{}\">{}</user_tool_result>",
314                    safe_tool, safe_call, escaped
315                )
316            }
317            WaitResult::Timeout => {
318                self.expire_call(&toolcall_store, &key, &call_id, tool_name)
319                    .await;
320                let remaining_after = self.remaining_budget();
321                format!(
322                    "[No response yet — you have {:.0}s remaining to finalize your proposal \
323                     with your best judgment. The user may respond later and the result will \
324                     be available next round.]",
325                    remaining_after.as_secs_f64()
326                )
327            }
328            WaitResult::Error(e) => {
329                warn!(call_id = %call_id, error = %e, "Error waiting for tool call response");
330                format!("Error waiting for user response: {}", e)
331            }
332        }
333    }
334
335    async fn get_or_create_bucket(
336        &self,
337        bucket_name: &str,
338    ) -> Result<async_nats::jetstream::kv::Store> {
339        ensure_kv_bucket(
340            &self.js_context,
341            async_nats::jetstream::kv::Config {
342                bucket: bucket_name.to_string(),
343                history: 5,
344                max_age: Duration::from_secs(86400 * 3),
345                storage: async_nats::jetstream::stream::StorageType::File,
346                ..Default::default()
347            },
348        )
349        .await
350    }
351
352    async fn count_pending_for_agent(
353        &self,
354        store: &async_nats::jetstream::kv::Store,
355    ) -> Result<usize> {
356        let scan_start = Instant::now();
357        let mut count = 0;
358        let mut total_keys = 0u32;
359        let mut keys = store.keys().await?;
360        while let Some(key_result) = keys.next().await {
361            let Ok(key) = key_result else { continue };
362            if !key.starts_with("call_") {
363                continue;
364            }
365            total_keys += 1;
366            let Ok(Some(entry)) = store.get(&key).await else {
367                continue;
368            };
369            let Ok(call) = serde_json::from_slice::<PendingToolCall>(&entry) else {
370                continue;
371            };
372            if call.agent_id == self.agent_id && call.status == ToolCallStatus::Pending {
373                count += 1;
374            }
375        }
376        let scan_ms = scan_start.elapsed().as_millis();
377        if total_keys > 50 || scan_ms > 100 {
378            warn!(
379                total_keys = total_keys,
380                pending = count,
381                agent = %self.agent_id,
382                scan_ms = scan_ms,
383                "Tool call bucket scan is growing — consider secondary counter if this persists"
384            );
385        }
386        Ok(count)
387    }
388
389    async fn wait_for_response(
390        &self,
391        store: &async_nats::jetstream::kv::Store,
392        key: &str,
393        timeout_duration: Duration,
394    ) -> WaitResult {
395        // Use watch_with_history so the most recent entry is replayed before live updates.
396        // This prevents the race where a response is written between our initial put()
397        // and the watcher subscription being established.
398        let mut watcher = match store.watch_with_history(key).await {
399            Ok(w) => w,
400            Err(e) => return WaitResult::Error(format!("Failed to create KV watcher: {}", e)),
401        };
402
403        tokio::select! {
404            result = async {
405                while let Some(entry) = watcher.next().await {
406                    let Ok(entry) = entry else { continue };
407                    let Ok(call) = serde_json::from_slice::<PendingToolCall>(&entry.value) else {
408                        continue;
409                    };
410                    if call.status == ToolCallStatus::Responded {
411                        return WaitResult::Responded(call.result.unwrap_or_default());
412                    }
413                }
414                WaitResult::Error("KV watcher stream ended unexpectedly".to_string())
415            } => result,
416            _ = tokio::time::sleep(timeout_duration) => {
417                WaitResult::Timeout
418            }
419        }
420    }
421
422    async fn expire_call(
423        &self,
424        store: &async_nats::jetstream::kv::Store,
425        key: &str,
426        call_id: &str,
427        tool_name: &str,
428    ) {
429        // Use entry() to get the revision for CAS, and only expire if still Pending
430        if let Ok(Some(entry)) = store.entry(key).await
431            && let Ok(mut call) = serde_json::from_slice::<PendingToolCall>(&entry.value)
432        {
433            if call.status != ToolCallStatus::Pending {
434                // Already responded or expired concurrently — don't clobber
435                return;
436            }
437            call.status = ToolCallStatus::Expired;
438            match serde_json::to_vec(&call) {
439                Ok(data) => {
440                    // CAS update: only succeeds if entry hasn't been modified since we read it
441                    match store.update(key, data.into(), entry.revision).await {
442                        Ok(_) => {
443                            // CAS succeeded — publish SSE only after confirmed expiration
444                            self.publish_sse_event(
445                                "tool_call_expired",
446                                &serde_json::json!({
447                                    "call_id": call_id,
448                                    "agent_id": &self.agent_id,
449                                    "tool_name": tool_name,
450                                    "timeout_secs": self.phase_budget.as_secs_f64(),
451                                }),
452                            )
453                            .await;
454                        }
455                        Err(e) => {
456                            warn!(
457                                call_id = %call_id,
458                                error = %e,
459                                "CAS update failed for expire_call (concurrent modification?)"
460                            );
461                        }
462                    }
463                }
464                Err(e) => {
465                    warn!(
466                        call_id = %call_id,
467                        error = %e,
468                        "Failed to serialize expired tool call"
469                    );
470                }
471            }
472        }
473    }
474
475    async fn publish_sse_event<T: serde::Serialize>(&self, suffix: &str, payload: &T) {
476        let data = match serde_json::to_vec(payload) {
477            Ok(d) => d,
478            Err(e) => {
479                warn!("Failed to serialize SSE event: {}", e);
480                return;
481            }
482        };
483        let safe_session = crate::nats_utils::sanitize_subject_component(&self.session_id);
484        let safe_prefix = crate::nats_utils::sanitize_subject_component(&self.subject_prefix);
485        let subject = format!("{}.{}.result.event.{}", safe_prefix, safe_session, suffix);
486        if let Err(e) = self.nats_client.publish(subject.clone(), data.into()).await {
487            warn!("Failed to publish SSE event to {}: {}", subject, e);
488        }
489    }
490}
491
492/// Implement the SDK trait so `UserToolHandler` can be stored as `Arc<dyn UserToolHandlerTrait>`.
493#[async_trait]
494impl UserToolHandlerTrait for UserToolHandler {
495    async fn handle_call(
496        &self,
497        tool_name: &str,
498        arguments_json: &str,
499        round: u32,
500        phase: DeliberationPhase,
501    ) -> String {
502        self.handle_call(tool_name, arguments_json, round, phase)
503            .await
504    }
505}
506
507/// Factory that creates [`UserToolHandler`] instances for each task execution.
508///
509/// Reference implementation of the [`UserToolHandlerFactory`](crate::workers::UserToolHandlerFactory)
510/// trait — the worker uses this to materialise a handler per task without
511/// depending on NATS internals at the trait level.
512#[derive(Debug)]
513pub struct NatsUserToolHandlerFactory;
514
515impl crate::workers::UserToolHandlerFactory for NatsUserToolHandlerFactory {
516    fn create(
517        &self,
518        nats: async_nats::Client,
519        js: async_nats::jetstream::Context,
520        session_id: String,
521        agent_id: String,
522        budget_remaining_secs: f64,
523        subject_prefix: String,
524    ) -> std::sync::Arc<dyn UserToolHandlerTrait> {
525        std::sync::Arc::new(
526            UserToolHandler::new(nats, js, session_id, agent_id, budget_remaining_secs)
527                .with_subject_prefix(subject_prefix),
528        )
529    }
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535    use crate::agents::{PendingToolCall, ToolCallStatus, UserToolDefinition};
536
537    #[test]
538    fn test_user_tool_definition_serde_roundtrip() {
539        let def = UserToolDefinition {
540            name: "dm_user".to_string(),
541            description: "Send a DM to the user".to_string(),
542            parameters: Some(serde_json::json!({
543                "type": "object",
544                "properties": {
545                    "message": { "type": "string" }
546                },
547                "required": ["message"]
548            })),
549            strict: Some(true),
550        };
551
552        let json = serde_json::to_string(&def).unwrap();
553        let parsed: UserToolDefinition = serde_json::from_str(&json).unwrap();
554        assert_eq!(parsed.name, "dm_user");
555        assert_eq!(parsed.strict, Some(true));
556        assert!(parsed.parameters.is_some());
557    }
558
559    #[test]
560    fn test_user_tool_definition_minimal() {
561        let json = r#"{"name": "ping", "description": "Ping the user"}"#;
562        let parsed: UserToolDefinition = serde_json::from_str(json).unwrap();
563        assert_eq!(parsed.name, "ping");
564        assert!(parsed.parameters.is_none());
565        assert!(parsed.strict.is_none());
566    }
567
568    #[test]
569    fn test_pending_tool_call_serde_roundtrip() {
570        let call = PendingToolCall {
571            call_id: "abc-123".to_string(),
572            job_id: "job-1".to_string(),
573            agent_id: "agent-1".to_string(),
574            tool_name: "user_dm_user".to_string(),
575            arguments: serde_json::json!({"message": "hello"}),
576            round: 1,
577            phase: DeliberationPhase::Proposing,
578            status: ToolCallStatus::Pending,
579            created_at: 1234567890,
580            responded_at: None,
581            result: None,
582        };
583
584        let json = serde_json::to_string(&call).unwrap();
585        let parsed: PendingToolCall = serde_json::from_str(&json).unwrap();
586        assert_eq!(parsed.call_id, "abc-123");
587        assert_eq!(parsed.status, ToolCallStatus::Pending);
588        assert!(parsed.responded_at.is_none());
589        assert!(parsed.result.is_none());
590    }
591
592    #[test]
593    fn test_pending_tool_call_responded() {
594        let call = PendingToolCall {
595            call_id: "abc-123".to_string(),
596            job_id: "job-1".to_string(),
597            agent_id: "agent-1".to_string(),
598            tool_name: "user_dm_user".to_string(),
599            arguments: serde_json::json!({}),
600            round: 2,
601            phase: DeliberationPhase::Evaluating,
602            status: ToolCallStatus::Responded,
603            created_at: 1234567890,
604            responded_at: Some(1234567900),
605            result: Some("The answer is 42".to_string()),
606        };
607
608        let json = serde_json::to_string(&call).unwrap();
609        let parsed: PendingToolCall = serde_json::from_str(&json).unwrap();
610        assert_eq!(parsed.status, ToolCallStatus::Responded);
611        assert_eq!(parsed.result, Some("The answer is 42".to_string()));
612    }
613
614    #[test]
615    fn test_tool_call_status_all_variants() {
616        for (status, expected) in [
617            (ToolCallStatus::Pending, "\"Pending\""),
618            (ToolCallStatus::Responded, "\"Responded\""),
619            (ToolCallStatus::Expired, "\"Expired\""),
620        ] {
621            let json = serde_json::to_string(&status).unwrap();
622            assert_eq!(json, expected);
623            let parsed: ToolCallStatus = serde_json::from_str(&json).unwrap();
624            assert_eq!(parsed, status);
625        }
626    }
627
628    /// A JetStream context over a live server, or `None` so the test skips.
629    async fn js() -> Option<(async_nats::Client, async_nats::jetstream::Context)> {
630        let url = std::env::var("NATS_URL").unwrap_or_else(|_| "nats://localhost:4222".to_string());
631        let client = async_nats::connect(&url).await.ok()?;
632        let js = async_nats::jetstream::new(client.clone());
633        Some((client, js))
634    }
635
636    #[tokio::test]
637    async fn a_call_with_no_time_to_wait_is_refused_before_anything_is_published() {
638        let Some((client, js)) = js().await else {
639            eprintln!("Skipping: NATS unavailable");
640            return;
641        };
642        let session = format!("test-refuse-{}", Uuid::new_v4());
643        // reserve = min(budget × 1.0, 1000s) = the whole budget, so the call lands
644        // inside the reserve on its first attempt — the same position a call
645        // reaches late in a real phase, without waiting for one.
646        let handler = UserToolHandler::new(
647            client,
648            js.clone(),
649            session.clone(),
650            "ALPHA".to_string(),
651            60.0,
652        )
653        .with_finalization_reserve(1000.0, 1.0);
654
655        let answer = handler
656            .handle_call(
657                "user_dm_user",
658                r#"{"message":"hi"}"#,
659                1,
660                DeliberationPhase::Proposing,
661            )
662            .await;
663        assert!(
664            answer.contains("phase budget exhausted"),
665            "the model is told to proceed: {answer}"
666        );
667
668        // Nothing was published: no bucket, so no record and no pending row for a
669        // client to render and immediately withdraw.
670        let bucket = format!("nsed_toolcalls_{session}");
671        assert!(
672            js.get_key_value(&bucket).await.is_err(),
673            "a question that cannot be waited for must not create {bucket}"
674        );
675    }
676
677    #[test]
678    fn the_bucket_name_follows_the_configured_prefix() {
679        assert_eq!(
680            toolcalls_bucket_name("nsed", "room-f2205792"),
681            "nsed_toolcalls_room-f2205792"
682        );
683        // A non-default prefix must reach the name, or the reader and writer end
684        // up on different buckets.
685        assert_eq!(
686            toolcalls_bucket_name("staging", "room-1"),
687            "staging_toolcalls_room-1"
688        );
689        // Both components are sanitized, so a session id carrying subject
690        // metacharacters cannot escape into the bucket name.
691        assert_eq!(
692            toolcalls_bucket_name("nsed", "room.1 *>"),
693            toolcalls_bucket_name("nsed", "room.1 *>"),
694        );
695        let odd = toolcalls_bucket_name("ns.ed", "a>b");
696        assert!(!odd.contains('.'), "{odd}");
697        assert!(!odd.contains('>'), "{odd}");
698    }
699
700    #[test]
701    fn test_finalization_reserve_computation() {
702        // 200 * 0.15 = 30, min(30, 30) = 30
703        assert_eq!(
704            compute_finalization_reserve(Duration::from_secs(200), 30.0, 0.15),
705            Duration::from_secs(30)
706        );
707    }
708
709    #[test]
710    fn test_finalization_reserve_small_budget() {
711        // 60 * 0.15 = 9, min(9, 30) = 9
712        assert_eq!(
713            compute_finalization_reserve(Duration::from_secs(60), 30.0, 0.15),
714            Duration::from_secs(9)
715        );
716    }
717
718    // ---- Regression tests for PR review fixes ----
719
720    /// escape_xml must escape &, <, > to prevent XML breakout in user_tool_result
721    #[test]
722    fn test_escape_xml_basic_entities() {
723        assert_eq!(escape_xml("hello"), "hello");
724        assert_eq!(escape_xml("<script>"), "&lt;script&gt;");
725        assert_eq!(escape_xml("a & b"), "a &amp; b");
726        assert_eq!(escape_xml(""), "");
727    }
728
729    /// Verifies that the escaped form doesn't break the user_tool_result wrapper
730    #[test]
731    fn test_escape_xml_preserves_wrapper_integrity() {
732        let malicious = "</user_tool_result><injected>evil</injected>";
733        let escaped = escape_xml(malicious);
734        let wrapped = format!(
735            "<user_tool_result tool=\"test\" call_id=\"c1\">{}</user_tool_result>",
736            escaped
737        );
738        // The wrapper tags should remain intact
739        assert!(wrapped.starts_with("<user_tool_result tool=\"test\" call_id=\"c1\">"));
740        assert!(wrapped.ends_with("</user_tool_result>"));
741        // The injected tags should be escaped, not present as raw XML
742        assert!(!wrapped.contains("<injected>"));
743        assert!(wrapped.contains("&lt;injected&gt;"));
744    }
745
746    /// escape_xml handles all three special chars in a single string
747    #[test]
748    fn test_escape_xml_combined() {
749        let input = "x < 5 & y > 3";
750        let expected = "x &lt; 5 &amp; y &gt; 3";
751        assert_eq!(escape_xml(input), expected);
752    }
753
754    /// escape_xml handles ampersand-first correctly (no double-escaping)
755    #[test]
756    fn test_escape_xml_ampersand_first() {
757        // Since & is replaced first, &lt; in input becomes &amp;lt;
758        let input = "&lt;";
759        let expected = "&amp;lt;";
760        assert_eq!(escape_xml(input), expected);
761    }
762
763    // ---- Tests for escape_xml_attr ----
764
765    /// escape_xml_attr escapes quotes in addition to &, <, >
766    #[test]
767    fn test_escape_xml_attr_quotes() {
768        assert_eq!(escape_xml_attr(r#"he said "hi""#), "he said &quot;hi&quot;");
769        assert_eq!(escape_xml_attr("it's"), "it&apos;s");
770    }
771
772    /// escape_xml_attr prevents attribute injection via tool_name
773    #[test]
774    fn test_escape_xml_attr_prevents_attribute_injection() {
775        // An attacker tries to close the tool attr and inject a new one
776        let malicious_tool = r#"evil" onclick="alert(1)"#;
777        let safe = escape_xml_attr(malicious_tool);
778        // The escaped string must not contain unescaped quotes
779        assert!(!safe.contains('"'));
780        assert!(safe.contains("&quot;"));
781    }
782
783    // ---- Tests for compute_finalization_reserve sanitization ----
784
785    /// NaN inputs should not panic, should return Duration::ZERO
786    #[test]
787    fn test_finalization_reserve_nan_inputs() {
788        let result = compute_finalization_reserve(Duration::from_secs(100), f64::NAN, 0.15);
789        assert_eq!(result, Duration::ZERO);
790
791        let result = compute_finalization_reserve(Duration::from_secs(100), 30.0, f64::NAN);
792        assert_eq!(result, Duration::ZERO);
793    }
794
795    /// Negative inputs should not panic, should be treated as zero
796    #[test]
797    fn test_finalization_reserve_negative_inputs() {
798        let result = compute_finalization_reserve(Duration::from_secs(100), -10.0, 0.15);
799        assert_eq!(result, Duration::ZERO);
800
801        let result = compute_finalization_reserve(Duration::from_secs(100), 30.0, -0.5);
802        assert_eq!(result, Duration::ZERO);
803    }
804
805    /// Infinite inputs should not panic
806    #[test]
807    fn test_finalization_reserve_infinite_inputs() {
808        let result = compute_finalization_reserve(Duration::from_secs(100), f64::INFINITY, 0.15);
809        // ratio_based = 100*0.15 = 15, fixed = 0 (inf sanitized to 0), min(15,0) = 0
810        assert_eq!(result, Duration::ZERO);
811
812        let result =
813            compute_finalization_reserve(Duration::from_secs(100), 30.0, f64::NEG_INFINITY);
814        assert_eq!(result, Duration::ZERO);
815    }
816
817    /// reserve_ratio capped at 1.0
818    #[test]
819    fn test_finalization_reserve_ratio_capped() {
820        // ratio 2.0 should be capped to 1.0 → 100*1.0 = 100, min(100, 200) = 100
821        let result = compute_finalization_reserve(Duration::from_secs(100), 200.0, 2.0);
822        assert_eq!(result, Duration::from_secs(100));
823    }
824
825    // ---- Constructor phase_budget sanitization tests ----
826
827    /// The same sanitization pattern used in UserToolHandler::new
828    /// (extracted for direct testing since the constructor requires NATS)
829    #[test]
830    fn test_phase_budget_sanitization_nan() {
831        let val: f64 = f64::NAN;
832        let safe = if val.is_finite() { val.max(0.0) } else { 0.0 };
833        assert_eq!(safe, 0.0);
834        // Must not panic
835        let _ = Duration::from_secs_f64(safe);
836    }
837
838    #[test]
839    fn test_phase_budget_sanitization_infinity() {
840        let val: f64 = f64::INFINITY;
841        let safe = if val.is_finite() { val.max(0.0) } else { 0.0 };
842        assert_eq!(safe, 0.0);
843        let _ = Duration::from_secs_f64(safe);
844    }
845
846    #[test]
847    fn test_phase_budget_sanitization_neg_infinity() {
848        let val: f64 = f64::NEG_INFINITY;
849        let safe = if val.is_finite() { val.max(0.0) } else { 0.0 };
850        assert_eq!(safe, 0.0);
851        let _ = Duration::from_secs_f64(safe);
852    }
853
854    #[test]
855    fn test_phase_budget_sanitization_negative() {
856        let val: f64 = -100.0;
857        let safe = if val.is_finite() { val.max(0.0) } else { 0.0 };
858        assert_eq!(safe, 0.0);
859        let _ = Duration::from_secs_f64(safe);
860    }
861
862    #[test]
863    fn test_phase_budget_sanitization_valid() {
864        let val: f64 = 42.5;
865        let safe = if val.is_finite() { val.max(0.0) } else { 0.0 };
866        assert_eq!(safe, 42.5);
867        assert_eq!(Duration::from_secs_f64(safe), Duration::from_millis(42500));
868    }
869
870    #[test]
871    fn test_publish_sse_event_sanitizes_session_id() {
872        // Verify sanitize_subject_component strips NATS-invalid chars
873        let raw = "my.session>with*wildcards";
874        let safe = crate::nats_utils::sanitize_subject_component(raw);
875        assert!(!safe.contains('.'), "Dots should be removed");
876        assert!(!safe.contains('>'), "Greater-than should be removed");
877        assert!(!safe.contains('*'), "Wildcards should be removed");
878        assert!(!safe.is_empty(), "Sanitized result should not be empty");
879    }
880
881    // =========================================================================
882    // escape_xml — additional edge cases
883    // =========================================================================
884
885    #[test]
886    fn test_escape_xml_empty_string() {
887        assert_eq!(escape_xml(""), "");
888    }
889
890    #[test]
891    fn test_escape_xml_no_special_chars() {
892        let input = "Hello, world! 123 test";
893        assert_eq!(escape_xml(input), input);
894    }
895
896    #[test]
897    fn test_escape_xml_all_special_chars() {
898        let input = "&<>";
899        assert_eq!(escape_xml(input), "&amp;&lt;&gt;");
900    }
901
902    #[test]
903    fn test_escape_xml_preserves_quotes() {
904        // escape_xml does NOT escape quotes (only escape_xml_attr does)
905        let input = "He said \"hello\" and it's fine";
906        assert_eq!(escape_xml(input), "He said \"hello\" and it's fine");
907    }
908
909    #[test]
910    fn test_escape_xml_already_escaped() {
911        // Already-escaped content should be double-escaped
912        let input = "&amp; &lt; &gt;";
913        let result = escape_xml(input);
914        assert_eq!(result, "&amp;amp; &amp;lt; &amp;gt;");
915    }
916
917    #[test]
918    fn test_escape_xml_multiline() {
919        let input = "line1 <b>bold</b>\nline2 & more\nline3 > end";
920        let expected = "line1 &lt;b&gt;bold&lt;/b&gt;\nline2 &amp; more\nline3 &gt; end";
921        assert_eq!(escape_xml(input), expected);
922    }
923
924    // =========================================================================
925    // escape_xml_attr — additional edge cases
926    // =========================================================================
927
928    #[test]
929    fn test_escape_xml_attr_empty() {
930        assert_eq!(escape_xml_attr(""), "");
931    }
932
933    #[test]
934    fn test_escape_xml_attr_all_five_special_chars() {
935        let input = "&<>\"'";
936        assert_eq!(escape_xml_attr(input), "&amp;&lt;&gt;&quot;&apos;");
937    }
938
939    #[test]
940    fn test_escape_xml_attr_no_special_chars() {
941        let input = "simple text 123";
942        assert_eq!(escape_xml_attr(input), input);
943    }
944
945    #[test]
946    fn test_escape_xml_attr_mixed_quotes_and_entities() {
947        let input = "tool_name=\"bad\" & 'evil' <injected>";
948        let expected = "tool_name=&quot;bad&quot; &amp; &apos;evil&apos; &lt;injected&gt;";
949        assert_eq!(escape_xml_attr(input), expected);
950    }
951
952    // =========================================================================
953    // compute_finalization_reserve — additional edge cases
954    // =========================================================================
955
956    #[test]
957    fn test_finalization_reserve_zero_budget() {
958        let result = compute_finalization_reserve(Duration::ZERO, 30.0, 0.15);
959        assert_eq!(result, Duration::ZERO);
960    }
961
962    #[test]
963    fn test_finalization_reserve_both_params_zero() {
964        let result = compute_finalization_reserve(Duration::from_secs(100), 0.0, 0.0);
965        assert_eq!(result, Duration::ZERO);
966    }
967
968    #[test]
969    fn test_finalization_reserve_ratio_exactly_one() {
970        // ratio=1.0 means reserve = entire budget, min(budget, fixed_secs)
971        let result = compute_finalization_reserve(Duration::from_secs(100), 200.0, 1.0);
972        // ratio_based = 100 * 1.0 = 100s, fixed = 200s, min(100, 200) = 100s
973        assert_eq!(result, Duration::from_secs(100));
974    }
975
976    #[test]
977    fn test_finalization_reserve_fixed_smaller_than_ratio() {
978        // fixed = 10s, ratio_based = 100 * 0.5 = 50s → min(50, 10) = 10s
979        let result = compute_finalization_reserve(Duration::from_secs(100), 10.0, 0.5);
980        assert_eq!(result, Duration::from_secs(10));
981    }
982
983    #[test]
984    fn test_finalization_reserve_very_large_budget() {
985        // Large budget with small ratio
986        let result = compute_finalization_reserve(Duration::from_secs(10000), 60.0, 0.01);
987        // ratio_based = 10000 * 0.01 = 100s, fixed = 60s → min(100, 60) = 60s
988        assert_eq!(result, Duration::from_secs(60));
989    }
990
991    #[test]
992    fn test_finalization_reserve_very_small_budget() {
993        // Sub-second budget should still produce a safe reserve
994        let result = compute_finalization_reserve(Duration::from_millis(100), 30.0, 0.15);
995        // ratio_based = 0.1s * 0.15 = 0.015s, fixed = 30, min(0.015, 30) = 0.015s
996        assert_eq!(result, Duration::from_millis(15));
997    }
998
999    #[test]
1000    fn test_finalization_reserve_both_nan() {
1001        let result = compute_finalization_reserve(Duration::from_secs(100), f64::NAN, f64::NAN);
1002        assert_eq!(result, Duration::ZERO);
1003    }
1004
1005    #[test]
1006    fn test_finalization_reserve_both_infinite() {
1007        let result =
1008            compute_finalization_reserve(Duration::from_secs(100), f64::INFINITY, f64::INFINITY);
1009        // Both sanitized to 0 → Duration::ZERO
1010        assert_eq!(result, Duration::ZERO);
1011    }
1012
1013    #[test]
1014    fn test_finalization_reserve_neg_infinity_secs() {
1015        let result =
1016            compute_finalization_reserve(Duration::from_secs(100), f64::NEG_INFINITY, 0.15);
1017        // safe_secs = 0 (neg_inf is not finite), ratio_based = 15, fixed = 0, min(15, 0) = 0
1018        assert_eq!(result, Duration::ZERO);
1019    }
1020
1021    #[test]
1022    fn test_finalization_reserve_fractional_duration() {
1023        // Test with sub-second precision
1024        let result = compute_finalization_reserve(Duration::from_millis(500), 1.0, 0.1);
1025        // ratio_based = 0.5 * 0.1 = 0.05s = 50ms, fixed = 1.0s = 1000ms, min(50, 1000) = 50ms
1026        assert_eq!(result, Duration::from_millis(50));
1027    }
1028}