nexo-core 0.1.19

Agent runtime: event bus, sessions, plugin trait, heartbeat, A2A delegation.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
//! Link understanding.
//!
//! When a user message contains URLs, the runtime fetches each one
//! once per turn, extracts a short text summary, and renders a
//! `# LINK CONTEXT` system block so the LLM has something to reason
//! over instead of saying "I can't see what's at that link".
//!
//! Scope guarantees:
//!
//! - **Per-agent kill switch.** `agents.<id>.link_understanding.enabled`
//!   defaults to `false`. Operators opt in.
//! - **Hard caps everywhere.** `max_links_per_turn`, `max_bytes`,
//!   request timeout, cache TTL, plus a privacy denylist of host
//!   patterns the fetcher refuses outright.
//! - **In-memory cache.** Keyed by URL, LRU with TTL. Cache hits
//!   bypass network; misses race a single in-flight fetch.
//! - **Naïve text extraction.** Strips HTML tags + collapses
//!   whitespace + truncates. No DOM library — keeps the dep
//!   surface small. A future revision can swap in `scraper` /
//!   `readability`-style heuristics behind the same trait.
//! - **Failure mode = silence.** Any fetch error (timeout, 4xx,
//!   too big, blocked host) drops the URL from the rendered
//!   block. The agent still sees the original message.

use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

use lru::LruCache;
use serde::Deserialize;

/// YAML schema. Lives under `agents.<id>.link_understanding`.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LinkUnderstandingConfig {
    /// Master switch. `false` (default) = the runtime never fetches
    /// anything; the agent sees URLs as plain text.
    #[serde(default)]
    pub enabled: bool,
    /// Maximum URLs honoured per turn. Extras are silently dropped
    /// (the agent still sees them in the original text).
    #[serde(default = "default_max_links")]
    pub max_links_per_turn: usize,
    /// Hard cap on the response body. The fetcher streams until this
    /// many bytes and then aborts the request — protects against a
    /// hostile server feeding gigabytes of `/dev/random`.
    #[serde(default = "default_max_bytes")]
    pub max_bytes: usize,
    /// Per-request HTTP timeout in milliseconds. Includes connection
    /// + body read.
    #[serde(default = "default_timeout_ms")]
    pub timeout_ms: u64,
    /// In-memory cache TTL in seconds. `0` = no caching (every link
    /// hits the network, debugging only).
    #[serde(default = "default_cache_ttl_secs")]
    pub cache_ttl_secs: u64,
    /// Host-suffix denylist. The fetcher refuses URLs whose host
    /// ends in any of these (case-insensitive). Defaults block the
    /// most common privacy footguns: localhost, link-local, RFC1918.
    #[serde(default = "default_deny_hosts")]
    pub deny_hosts: Vec<String>,
}

impl Default for LinkUnderstandingConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            max_links_per_turn: default_max_links(),
            max_bytes: default_max_bytes(),
            timeout_ms: default_timeout_ms(),
            cache_ttl_secs: default_cache_ttl_secs(),
            deny_hosts: default_deny_hosts(),
        }
    }
}

fn default_max_links() -> usize {
    3
}
fn default_max_bytes() -> usize {
    1024 * 256 // 256 KiB — enough for a long article, not enough to DoS
}
fn default_timeout_ms() -> u64 {
    8_000
}
fn default_cache_ttl_secs() -> u64 {
    600
}
fn default_deny_hosts() -> Vec<String> {
    vec![
        "localhost".into(),
        "127.0.0.1".into(),
        "0.0.0.0".into(),
        "169.254.0.0".into(), // AWS metadata link-local
        "metadata.google.internal".into(),
    ]
}

/// Cached extract entry.
#[derive(Clone)]
struct CacheEntry {
    summary: Arc<str>,
    inserted_at: Instant,
}

/// One link's extracted form, ready to render into the prompt.
#[derive(Debug, Clone)]
pub struct LinkSummary {
    pub url: String,
    pub title: Option<String>,
    pub body: String,
}

/// In-memory cache + HTTP client. Held by the runtime as
/// `Arc<LinkExtractor>` and shared across sessions; the extractor
/// owns its rate limiter so concurrent turns don't stampede.
pub struct LinkExtractor {
    http: reqwest::Client,
    cache: Mutex<LruCache<String, CacheEntry>>,
    cache_ttl: Duration,
    cache_capacity: usize,
}

const DEFAULT_CACHE_CAPACITY: usize = 256;

impl LinkExtractor {
    pub fn new(cfg: &LinkUnderstandingConfig) -> Self {
        let http = reqwest::Client::builder()
            .timeout(Duration::from_millis(cfg.timeout_ms))
            .redirect(reqwest::redirect::Policy::limited(5))
            .user_agent("nexo-link-understanding/0.1")
            .build()
            .unwrap_or_else(|e| {
                tracing::warn!(error = %e, "link extractor: reqwest build failed; using default");
                reqwest::Client::new()
            });
        Self {
            http,
            cache: Mutex::new(LruCache::new(
                std::num::NonZeroUsize::new(DEFAULT_CACHE_CAPACITY).expect("cap > 0"),
            )),
            cache_ttl: Duration::from_secs(cfg.cache_ttl_secs),
            cache_capacity: DEFAULT_CACHE_CAPACITY,
        }
    }

    /// Capacity of the in-memory cache (for tests / diagnostics).
    pub fn cache_capacity(&self) -> usize {
        self.cache_capacity
    }

    /// Fetch + extract, honouring the cache. Returns `None` on any
    /// error — the caller (llm_behavior) drops the URL from the
    /// rendered block silently.
    pub async fn fetch(&self, url: &str, cfg: &LinkUnderstandingConfig) -> Option<LinkSummary> {
        if !cfg.enabled {
            return None;
        }
        if !host_allowed(url, &cfg.deny_hosts) {
            crate::telemetry::inc_link_fetch("blocked");
            return None;
        }

        // Cache lookup with TTL check. We don't dedupe in-flight
        // requests for the same URL — concurrent fetches are rare
        // (one user, one turn) and adding an in-flight map would
        // double the lock cost on the common path.
        if cfg.cache_ttl_secs > 0 {
            let mut cache = self.cache.lock().ok()?;
            if let Some(entry) = cache.get(url) {
                if entry.inserted_at.elapsed() < self.cache_ttl {
                    crate::telemetry::inc_link_cache(true);
                    return Some(LinkSummary {
                        url: url.to_string(),
                        title: None,
                        body: entry.summary.to_string(),
                    });
                }
            }
            crate::telemetry::inc_link_cache(false);
        }

        let started = std::time::Instant::now();
        let resp = match self.http.get(url).send().await {
            Ok(r) => r,
            Err(e) => {
                let result = if e.is_timeout() { "timeout" } else { "error" };
                crate::telemetry::inc_link_fetch(result);
                crate::telemetry::observe_link_fetch_ms(started.elapsed().as_millis() as u64);
                return None;
            }
        };
        if !resp.status().is_success() {
            crate::telemetry::inc_link_fetch("error");
            crate::telemetry::observe_link_fetch_ms(started.elapsed().as_millis() as u64);
            return None;
        }
        let content_type = resp
            .headers()
            .get(reqwest::header::CONTENT_TYPE)
            .and_then(|v| v.to_str().ok())
            .unwrap_or("")
            .to_lowercase();
        // Fetcher only understands HTML / plain text. PDFs / images
        // / video are out of scope.
        if !content_type.contains("text/html")
            && !content_type.contains("text/plain")
            && !content_type.is_empty()
        {
            crate::telemetry::inc_link_fetch("non_html");
            crate::telemetry::observe_link_fetch_ms(started.elapsed().as_millis() as u64);
            return None;
        }

        let body = match read_capped(resp, cfg.max_bytes).await {
            Ok(b) => b,
            Err(_) => {
                crate::telemetry::inc_link_fetch("error");
                crate::telemetry::observe_link_fetch_ms(started.elapsed().as_millis() as u64);
                return None;
            }
        };
        let truncated = body.len() >= cfg.max_bytes;
        let extracted = extract_main_text(&body, cfg.max_bytes);
        if extracted.is_empty() {
            let result = if truncated { "too_big" } else { "non_html" };
            crate::telemetry::inc_link_fetch(result);
            crate::telemetry::observe_link_fetch_ms(started.elapsed().as_millis() as u64);
            return None;
        }

        if cfg.cache_ttl_secs > 0 {
            if let Ok(mut cache) = self.cache.lock() {
                cache.put(
                    url.to_string(),
                    CacheEntry {
                        summary: Arc::from(extracted.as_str()),
                        inserted_at: Instant::now(),
                    },
                );
            }
        }
        crate::telemetry::inc_link_fetch("ok");
        crate::telemetry::observe_link_fetch_ms(started.elapsed().as_millis() as u64);
        Some(LinkSummary {
            url: url.to_string(),
            title: extract_title(&body),
            body: extracted,
        })
    }
}

/// Detect URLs in arbitrary text. Returns deduped, in-order, capped
/// at `max`. Tolerant of trailing punctuation in messages
/// ("see https://x.com/a, then ..." drops the comma).
pub fn detect_urls(text: &str, max: usize) -> Vec<String> {
    // Hand-rolled scan instead of a heavy regex — `regex = "1"` is
    // already in the dep tree, but a literal scan is faster on
    // short user messages and avoids the catastrophic-backtracking
    // risk of a complex URL regex on hostile input.
    let mut out: Vec<String> = Vec::new();
    let mut seen = std::collections::HashSet::new();
    let mut i = 0;
    let bytes = text.as_bytes();
    while i < bytes.len() {
        let rest = &text[i..];
        let start_https = rest.find("https://");
        let start_http = rest.find("http://");
        let start = match (start_https, start_http) {
            (Some(a), Some(b)) => Some(a.min(b)),
            (a, b) => a.or(b),
        };
        let Some(rel) = start else { break };
        let abs_start = i + rel;
        let after = &text[abs_start..];
        let end = after
            .find(|c: char| c.is_whitespace() || c == '<' || c == '>' || c == '"' || c == '\'')
            .unwrap_or(after.len());
        let mut url = &after[..end];
        // Strip trailing sentence punctuation that almost never
        // belongs to the URL.
        while let Some(stripped) = url
            .strip_suffix(',')
            .or_else(|| url.strip_suffix('.'))
            .or_else(|| url.strip_suffix(';'))
            .or_else(|| url.strip_suffix(':'))
            .or_else(|| url.strip_suffix(')'))
            .or_else(|| url.strip_suffix(']'))
            .or_else(|| url.strip_suffix('}'))
            .or_else(|| url.strip_suffix('?'))
            .or_else(|| url.strip_suffix('!'))
        {
            url = stripped;
        }
        if url.len() > 2048 {
            // Reject absurdly long URLs to keep the system block small.
            i = abs_start + end;
            continue;
        }
        if seen.insert(url.to_string()) {
            out.push(url.to_string());
            if out.len() >= max {
                break;
            }
        }
        i = abs_start + end;
    }
    out
}

fn host_allowed(url: &str, deny: &[String]) -> bool {
    // Cheap lower-case host extraction without pulling in a full URL parser.
    let after_scheme = url
        .strip_prefix("https://")
        .or_else(|| url.strip_prefix("http://"))
        .unwrap_or(url);
    let host = after_scheme
        .split(['/', '?', '#'])
        .next()
        .unwrap_or("")
        .split('@')
        .next_back()
        .unwrap_or("")
        .split(':')
        .next()
        .unwrap_or("")
        .to_lowercase();
    if host.is_empty() {
        return false;
    }
    !deny.iter().any(|pat| {
        host == pat.to_lowercase() || host.ends_with(&format!(".{}", pat.to_lowercase()))
    })
}

async fn read_capped(resp: reqwest::Response, cap: usize) -> Result<String, reqwest::Error> {
    use futures::stream::StreamExt;
    let mut stream = resp.bytes_stream();
    let mut buf: Vec<u8> = Vec::with_capacity(cap.min(64 * 1024));
    while let Some(chunk) = stream.next().await {
        let chunk = chunk?;
        let remaining = cap.saturating_sub(buf.len());
        if remaining == 0 {
            break;
        }
        let take = remaining.min(chunk.len());
        buf.extend_from_slice(&chunk[..take]);
        if buf.len() >= cap {
            break;
        }
    }
    Ok(String::from_utf8_lossy(&buf).into_owned())
}

/// Strip HTML tags, collapse whitespace, truncate. Naïve on purpose —
/// works for ~80% of articles, fails gracefully on the rest. A
/// future revision can replace this with a real readability pass.
pub fn extract_main_text(html: &str, max_bytes: usize) -> String {
    // Drop content-irrelevant tags. The set covers the universal
    // boilerplate (`<script>`, `<style>`, `<noscript>`, `<head>`)
    // plus the structural-but-not-article tags that sites emit
    // around the main content (`<nav>`, `<header>`, `<footer>`,
    // `<aside>`, `<form>`, `<button>`, `<menu>`, `<iframe>`,
    // `<svg>`, `<dialog>`, `<template>`). Dropping them shrinks the
    // prompt budget and stops the agent from anchoring on cookie
    // banners / share-buttons / nav menus.
    //
    // The extractor is a readability-shaped boilerplate dropper
    // rather than a naive HTML stripper, and avoids pulling in the
    // `scraper` crate. Real DOM-walk readability is the next-step
    // upgrade if this still leaves noise on a specific site shape.
    let mut cleaned = String::from(html);
    for tag in [
        "script", "style", "noscript", "head", "nav", "header", "footer", "aside", "form",
        "button", "menu", "iframe", "svg", "dialog", "template",
    ] {
        cleaned = strip_block(&cleaned, tag);
    }
    // Class-based dropper: nuke `<div class="sidebar | comments |
    // advert | ad | share | social | cookie | popup | newsletter |
    // related-articles">` etc. Catches sites that put boilerplate
    // in `<div>`s instead of semantic tags.
    let cleaned = strip_blocks_by_class_keyword(
        &cleaned,
        &[
            "sidebar",
            "side-bar",
            "comment",
            "advert",
            "advertisement",
            "share",
            "social",
            "cookie",
            "popup",
            "newsletter",
            "related-article",
            "related-posts",
            "navigation",
            "breadcrumb",
            "promo",
            "subscribe",
        ],
    );

    // Replace common block-level tags with newlines so paragraph
    // breaks survive the tag strip.
    let mut buf = String::with_capacity(cleaned.len());
    for token in tokenize(&cleaned) {
        match token {
            Token::Text(s) => buf.push_str(s),
            Token::Tag(name) => {
                let lname = name.trim_start_matches('/').to_ascii_lowercase();
                if matches!(
                    lname.as_str(),
                    "p" | "br"
                        | "div"
                        | "li"
                        | "h1"
                        | "h2"
                        | "h3"
                        | "h4"
                        | "h5"
                        | "h6"
                        | "tr"
                        | "section"
                ) {
                    buf.push('\n');
                }
            }
        }
    }

    // Decode the four common HTML entities. A full entity table is
    // overkill here.
    let buf = buf
        .replace("&nbsp;", " ")
        .replace("&amp;", "&")
        .replace("&lt;", "<")
        .replace("&gt;", ">")
        .replace("&quot;", "\"")
        .replace("&#39;", "'");

    // Collapse whitespace runs into single spaces / single \n.
    let mut out = String::with_capacity(buf.len());
    let mut prev_blank = true;
    let mut blank_run = 0;
    for line in buf.lines() {
        let trimmed = line.trim();
        if trimmed.is_empty() {
            blank_run += 1;
            if blank_run <= 1 && !prev_blank {
                out.push('\n');
            }
            continue;
        }
        blank_run = 0;
        if !prev_blank {
            out.push('\n');
        }
        // Collapse interior whitespace runs.
        let mut last_space = false;
        for c in trimmed.chars() {
            if c.is_whitespace() {
                if !last_space {
                    out.push(' ');
                }
                last_space = true;
            } else {
                out.push(c);
                last_space = false;
            }
        }
        prev_blank = false;
    }

    // Hard truncate by char (not byte) to avoid splitting UTF-8.
    let max_chars = max_bytes / 2; // each char ≤ 4 bytes; conservative
    if out.chars().count() > max_chars {
        out = out.chars().take(max_chars).collect::<String>() + "…";
    }
    out
}

fn extract_title(html: &str) -> Option<String> {
    let lower = html.to_ascii_lowercase();
    let start = lower.find("<title")?;
    let end_open = lower[start..].find('>')?;
    let body_start = start + end_open + 1;
    let close = lower[body_start..].find("</title")?;
    let raw = &html[body_start..body_start + close];
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        None
    } else {
        Some(trimmed.chars().take(160).collect())
    }
}

/// Drops every `<TAG class="…X…">…</TAG>` whose `class`, `id`,
/// or `role` attribute contains any of the supplied keywords
/// (case-insensitive). Walks until end-of-document; nested
/// elements with non-matching classes that sit inside a stripped
/// block are dropped along with the parent.
///
/// Tag-agnostic by design — sites use both `<div class="sidebar">`
/// and `<aside class="sidebar">`. The earlier tag-name strip
/// handles semantic tags; this catches the `<div>`s that should
/// have been semantic but aren't.
fn strip_blocks_by_class_keyword(html: &str, keywords: &[&str]) -> String {
    let lower = html.to_ascii_lowercase();
    let mut out = String::with_capacity(html.len());
    let mut cursor = 0usize;

    while cursor < html.len() {
        // Find the next opening tag with a `class` / `id` / `role`
        // attribute matching one of our keywords.
        let Some(open_rel) = lower[cursor..].find('<') else {
            out.push_str(&html[cursor..]);
            break;
        };
        let open_abs = cursor + open_rel;
        let Some(end_rel) = lower[open_abs..].find('>') else {
            out.push_str(&html[cursor..]);
            break;
        };
        let tag_end = open_abs + end_rel + 1;
        let tag_chunk = &lower[open_abs..tag_end];

        // Skip if this is a closing tag — we deal with those when
        // we eat a stripped block.
        if tag_chunk.starts_with("</") {
            out.push_str(&html[cursor..tag_end]);
            cursor = tag_end;
            continue;
        }

        // Detect tag name (the substring between `<` and the first
        // whitespace or `>`).
        let after_lt = &tag_chunk[1..];
        let name_end = after_lt
            .find(|c: char| c.is_whitespace() || c == '>' || c == '/')
            .unwrap_or(after_lt.len());
        let tag_name = &after_lt[..name_end];
        if tag_name.is_empty() {
            out.push_str(&html[cursor..tag_end]);
            cursor = tag_end;
            continue;
        }

        // Test the keyword set against `class`, `id`, `role`.
        let mut matched = false;
        for attr in ["class", "id", "role"] {
            // Look for `attr="…"` or `attr='…'` inside the tag chunk.
            if let Some(attr_pos) = tag_chunk.find(&format!(" {attr}=")) {
                let after = &tag_chunk[attr_pos + attr.len() + 2..];
                let quote = after.chars().next().unwrap_or('"');
                if quote != '"' && quote != '\'' {
                    continue;
                }
                let value_start = 1usize;
                let value_end = after[value_start..]
                    .find(quote)
                    .map(|p| value_start + p)
                    .unwrap_or(after.len());
                let value = &after[value_start..value_end];
                for kw in keywords {
                    if value.contains(kw) {
                        matched = true;
                        break;
                    }
                }
                if matched {
                    break;
                }
            }
        }

        if !matched {
            out.push_str(&html[cursor..tag_end]);
            cursor = tag_end;
            continue;
        }

        // Eat content up to the matching close tag for `tag_name`.
        // Handle nesting: count opens minus closes of the same tag.
        out.push_str(&html[cursor..open_abs]);
        let close_pat = format!("</{tag_name}");
        let open_pat_nested = format!("<{tag_name}");
        let mut depth: i32 = 1;
        let mut scan = tag_end;
        while scan < html.len() && depth > 0 {
            // Find next < of either same-tag-open or same-tag-close.
            let next_close = lower[scan..].find(&close_pat).map(|p| scan + p);
            let next_open = lower[scan..].find(&open_pat_nested).map(|p| scan + p);
            match (next_open, next_close) {
                (Some(o), Some(c)) if o < c => {
                    let open_end = lower[o..]
                        .find('>')
                        .map(|p| o + p + 1)
                        .unwrap_or(html.len());
                    depth += 1;
                    scan = open_end;
                }
                (_, Some(c)) => {
                    let close_end = lower[c..]
                        .find('>')
                        .map(|p| c + p + 1)
                        .unwrap_or(html.len());
                    depth -= 1;
                    scan = close_end;
                }
                _ => break,
            }
        }
        cursor = scan;
    }

    out
}

fn strip_block(html: &str, tag: &str) -> String {
    let lower = html.to_ascii_lowercase();
    let open_pat = format!("<{tag}");
    let close_pat = format!("</{tag}");
    let mut out = String::with_capacity(html.len());
    let mut cursor = 0;
    while cursor < html.len() {
        let Some(open_rel) = lower[cursor..].find(&open_pat) else {
            out.push_str(&html[cursor..]);
            break;
        };
        let open_abs = cursor + open_rel;
        out.push_str(&html[cursor..open_abs]);
        let after_open = lower[open_abs..].find('>').map(|p| open_abs + p + 1);
        let Some(after) = after_open else { break };
        let Some(close_rel) = lower[after..].find(&close_pat) else {
            break;
        };
        let close_abs = after + close_rel;
        let close_end = lower[close_abs..]
            .find('>')
            .map(|p| close_abs + p + 1)
            .unwrap_or(html.len());
        cursor = close_end;
    }
    out
}

enum Token<'a> {
    Text(&'a str),
    Tag(&'a str),
}

fn tokenize(html: &str) -> Vec<Token<'_>> {
    let mut out = Vec::new();
    let mut cursor = 0;
    while cursor < html.len() {
        let Some(open) = html[cursor..].find('<') else {
            out.push(Token::Text(&html[cursor..]));
            break;
        };
        if open > 0 {
            out.push(Token::Text(&html[cursor..cursor + open]));
        }
        let tag_start = cursor + open + 1;
        let Some(close) = html[tag_start..].find('>') else {
            break;
        };
        let tag_end = tag_start + close;
        // Tag name = up to the first whitespace or '>' or '/'.
        let tag_slice = &html[tag_start..tag_end];
        let name_end = tag_slice
            .find(|c: char| c.is_whitespace())
            .unwrap_or(tag_slice.len());
        out.push(Token::Tag(&tag_slice[..name_end]));
        cursor = tag_end + 1;
    }
    out
}

/// Render the `# LINK CONTEXT` system block. Empty `Vec` = empty
/// string; caller must check before pushing into `system_parts`.
pub fn render_block(summaries: &[LinkSummary]) -> String {
    if summaries.is_empty() {
        return String::new();
    }
    let mut out = String::from("# LINK CONTEXT\n\n");
    out.push_str(
        "The user's message included the following links. The runtime fetched each one and \
         extracted a text summary so you can answer with grounded facts. Cite the link if you \
         use it; do not invent details that aren't in the summary.\n\n",
    );
    for (idx, s) in summaries.iter().enumerate() {
        out.push_str(&format!("## [{}] {}\n", idx + 1, s.url));
        if let Some(title) = s.title.as_deref() {
            out.push_str(&format!("Title: {title}\n"));
        }
        out.push('\n');
        out.push_str(&s.body);
        out.push_str("\n\n");
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn detect_picks_https_and_http_in_order_dedup() {
        let txt = "see https://a.com/x and http://b.com? and https://a.com/x again";
        let urls = detect_urls(txt, 10);
        assert_eq!(urls, vec!["https://a.com/x", "http://b.com"]);
    }

    #[test]
    fn detect_strips_trailing_punctuation() {
        let urls = detect_urls("ok https://example.com/foo, bye.", 10);
        assert_eq!(urls, vec!["https://example.com/foo"]);
    }

    #[test]
    fn detect_caps_at_max() {
        let urls = detect_urls("https://a.com https://b.com https://c.com https://d.com", 2);
        assert_eq!(urls.len(), 2);
        assert_eq!(urls[0], "https://a.com");
    }

    #[test]
    fn detect_skips_hostile_long_urls() {
        let huge = format!("https://a.com/{}", "x".repeat(3000));
        let urls = detect_urls(&format!("look {huge} thanks"), 10);
        assert!(urls.is_empty(), "URL > 2048 chars must be dropped");
    }

    #[test]
    fn host_denylist_blocks_localhost_and_metadata() {
        let deny = default_deny_hosts();
        assert!(!host_allowed("http://localhost:8080/x", &deny));
        assert!(!host_allowed("http://127.0.0.1/", &deny));
        assert!(!host_allowed("http://metadata.google.internal/x", &deny));
        assert!(!host_allowed(
            "http://api.metadata.google.internal/x",
            &deny
        ));
        assert!(host_allowed("https://example.com/x", &deny));
    }

    #[test]
    fn extract_strips_scripts_and_styles() {
        let html = "<html><head><title>T</title></head>\
                    <body><script>alert(1)</script><p>Hello</p>\
                    <style>.x{}</style><p>World</p></body></html>";
        let out = extract_main_text(html, 4096);
        assert!(out.contains("Hello"));
        assert!(out.contains("World"));
        assert!(!out.contains("alert"));
        assert!(!out.contains(".x{}"));
    }

    // readability-shaped boilerplate dropper.

    #[test]
    fn extract_drops_semantic_boilerplate_tags() {
        let html = r#"<html><body>
            <header>SiteName · Login · Cart</header>
            <nav>Home | Blog | Contact</nav>
            <main><article>
                <h1>The Article</h1>
                <p>Real content lives here.</p>
            </article></main>
            <aside>Related links sidebar noise</aside>
            <footer>Copyright 2026 · privacy · cookies</footer>
        </body></html>"#;
        let out = extract_main_text(html, 4096);
        assert!(out.contains("The Article"));
        assert!(out.contains("Real content lives here"));
        assert!(!out.contains("SiteName"), "stripped <header>");
        assert!(!out.contains("Home | Blog"), "stripped <nav>");
        assert!(!out.contains("Related links sidebar"), "stripped <aside>");
        assert!(!out.contains("Copyright"), "stripped <footer>");
    }

    #[test]
    fn extract_drops_class_marked_sidebars() {
        let html = r#"<html><body>
            <article><p>Article body.</p></article>
            <div class="sidebar widget">Newsletter signup form</div>
            <div class="related-articles">More to read</div>
            <div id="comments-section">User comments here</div>
        </body></html>"#;
        let out = extract_main_text(html, 4096);
        assert!(out.contains("Article body"));
        assert!(!out.contains("Newsletter signup"));
        assert!(!out.contains("More to read"));
        assert!(!out.contains("User comments here"));
    }

    #[test]
    fn extract_drops_role_navigation_blocks() {
        let html = r#"<html><body>
            <div role="navigation"><a href=/>Home</a></div>
            <p>Main paragraph.</p>
        </body></html>"#;
        let out = extract_main_text(html, 4096);
        assert!(out.contains("Main paragraph"));
        assert!(!out.contains("Home"));
    }

    #[test]
    fn extract_keeps_class_when_no_keyword_match() {
        // Make sure the class-based stripper doesn't over-eagerly
        // drop `<div>`s with innocent class names.
        let html = r#"<html><body>
            <div class="content article-body">The actual article.</div>
            <div class="byline">By Author</div>
        </body></html>"#;
        let out = extract_main_text(html, 4096);
        assert!(out.contains("The actual article"));
        assert!(out.contains("By Author"));
    }

    #[test]
    fn extract_drops_button_and_form_clutter() {
        let html = r#"<html><body>
            <form><input/><button>Subscribe</button></form>
            <p>Article opener.</p>
            <button>Share</button>
        </body></html>"#;
        let out = extract_main_text(html, 4096);
        assert!(out.contains("Article opener"));
        assert!(!out.contains("Subscribe"));
        assert!(!out.contains("Share"));
    }

    #[test]
    fn extract_title_from_head() {
        let html = "<html><head><title>My Page</title></head><body>x</body></html>";
        assert_eq!(extract_title(html).as_deref(), Some("My Page"));
    }

    #[test]
    fn extract_handles_missing_title() {
        let html = "<html><body>no title here</body></html>";
        assert!(extract_title(html).is_none());
    }

    #[test]
    fn render_block_lists_summaries() {
        let s = vec![
            LinkSummary {
                url: "https://a.com".into(),
                title: Some("A".into()),
                body: "alpha body".into(),
            },
            LinkSummary {
                url: "https://b.com".into(),
                title: None,
                body: "bravo body".into(),
            },
        ];
        let out = render_block(&s);
        assert!(out.contains("# LINK CONTEXT"));
        assert!(out.contains("[1] https://a.com"));
        assert!(out.contains("Title: A"));
        assert!(out.contains("alpha body"));
        assert!(out.contains("[2] https://b.com"));
        assert!(out.contains("bravo body"));
    }

    #[test]
    fn render_block_empty_yields_empty_string() {
        assert_eq!(render_block(&[]), "");
    }

    #[test]
    fn config_disabled_by_default() {
        let cfg = LinkUnderstandingConfig::default();
        assert!(!cfg.enabled);
        assert_eq!(cfg.max_links_per_turn, 3);
        assert_eq!(cfg.max_bytes, 256 * 1024);
        assert!(cfg.deny_hosts.iter().any(|d| d == "localhost"));
    }

    #[tokio::test]
    async fn fetch_skips_when_disabled() {
        let cfg = LinkUnderstandingConfig::default(); // enabled = false
        let ext = LinkExtractor::new(&cfg);
        let r = ext.fetch("https://example.com/", &cfg).await;
        assert!(r.is_none(), "must short-circuit when disabled");
    }

    #[tokio::test]
    async fn fetch_skips_denylisted_host() {
        let cfg = LinkUnderstandingConfig {
            enabled: true,
            ..LinkUnderstandingConfig::default()
        };
        let ext = LinkExtractor::new(&cfg);
        // Even with enabled = true, localhost is on the deny list
        // and we never attempt the fetch (so this test does not
        // require a running server).
        let r = ext.fetch("http://localhost:65530/", &cfg).await;
        assert!(r.is_none());
    }
}