use serde::{Deserialize, Serialize};
use crate::{ApiDiscovery, ApiEndpoint};
use std::fmt::Write as _;
fn default_get_method() -> String {
"GET".to_string()
}
#[must_use]
pub fn discover_apis(raw_html: &str) -> Vec<DiscoveredApi> {
if raw_html.is_empty() {
return Vec::new();
}
match ApiDiscovery::new() {
Ok(d) => d
.discover_from_html(raw_html)
.into_iter()
.map(DiscoveredApi::from)
.collect(),
Err(_) => Vec::new(),
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum TaskAction {
ApiCall {
url: String,
#[serde(default = "default_get_method")]
method: String,
#[serde(default)]
headers: Vec<(String, String)>,
#[serde(default)]
body: Option<String>,
#[serde(default)]
extract_query: Option<String>,
},
JsEval { url: String, script: String },
Submit {
url: String,
fields: Vec<(String, String)>,
},
Extract { extract_query: String },
NeedsBrowser {
reason: String,
#[serde(default)]
url: Option<String>,
},
Done {
#[serde(default)]
summary: Option<String>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskStatus {
Done,
Incomplete,
NeedsHuman,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiscoveredApi {
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub method: Option<String>,
pub source: String,
}
impl From<ApiEndpoint> for DiscoveredApi {
fn from(e: ApiEndpoint) -> Self {
Self {
url: e.url,
method: e.method,
source: e.source,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TaskOutcome {
pub goal: String,
pub url: String,
pub rung: u8,
pub status: TaskStatus,
pub content: String,
#[serde(default)]
pub discovered_apis: Vec<DiscoveredApi>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ActionObservation {
pub rung: u8,
pub status: TaskStatus,
pub content: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FetchRequest {
pub url: String,
pub method: String,
pub headers: Vec<(String, String)>,
pub body: Option<String>,
}
#[allow(async_fn_in_trait)]
pub trait TaskFetcher {
async fn fetch(&self, req: FetchRequest) -> anyhow::Result<String>;
async fn fetch_raw(&self, url: &str) -> anyhow::Result<String> {
self.fetch(FetchRequest {
url: url.to_string(),
method: "GET".to_string(),
headers: Vec::new(),
body: None,
})
.await
}
}
pub async fn execute_action<F: TaskFetcher>(
action: &TaskAction,
fetcher: &F,
) -> anyhow::Result<ActionObservation> {
match action {
TaskAction::ApiCall {
url,
method,
headers,
body,
extract_query,
} => {
let req = FetchRequest {
url: url.clone(),
method: method.clone(),
headers: headers.clone(),
body: body.clone(),
};
match fetcher.fetch(req).await {
Ok(content) => Ok(ActionObservation {
rung: 1,
status: TaskStatus::Done,
content: shape_api_response(&content, extract_query.as_deref()),
error: None,
}),
Err(e) => Ok(ActionObservation {
rung: 1,
status: TaskStatus::Incomplete,
content: String::new(),
error: Some(e.to_string()),
}),
}
}
TaskAction::Done { .. } => Ok(ActionObservation {
rung: 0,
status: TaskStatus::Done,
content: String::new(),
error: None,
}),
TaskAction::Submit { url, fields } => Ok(submit_form(url, fields, fetcher).await),
TaskAction::JsEval { .. } => Ok(deferred(1, "js_eval lands in a later slice")),
TaskAction::Extract { .. } => Ok(deferred(
0,
"extract is a loop-level action (shapes trajectory state); \
not available in stateless single-action mode",
)),
TaskAction::NeedsBrowser { reason, .. } => Ok(deferred(
3,
&format!(
"browser rung is loop-level (needs an injected browser backend); \
not available in stateless single-action mode: {reason}"
),
)),
}
}
fn deferred(rung: u8, why: &str) -> ActionObservation {
ActionObservation {
rung,
status: TaskStatus::Incomplete,
content: String::new(),
error: Some(why.to_string()),
}
}
const API_RESPONSE_TOKEN_BUDGET: usize = 4_000;
fn shape_api_response(content: &str, extract_query: Option<&str>) -> String {
let focused = match extract_query {
Some(q) if !q.is_empty() => crate::content::focus::extract_focused(content, q).markdown,
_ => content.to_string(),
};
let budgeted =
crate::content::budget::truncate_to_budget(&focused, Some(API_RESPONSE_TOKEN_BUDGET))
.markdown;
let hard_cap = API_RESPONSE_TOKEN_BUDGET * 4;
if budgeted.len() > hard_cap {
let mut end = hard_cap;
while end > 0 && !budgeted.is_char_boundary(end) {
end -= 1;
}
let mut cut = budgeted;
cut.truncate(end);
cut.push_str(
"\n…[Truncated: API response exceeded the token budget — \
narrow the request or use extract_query]",
);
cut
} else {
budgeted
}
}
pub fn plan_form_submission(
page_html: &str,
page_url: &str,
fields: &[(String, String)],
) -> anyhow::Result<FetchRequest> {
use std::collections::HashMap;
let mut forms = crate::Form::parse_all(page_html)?;
if forms.is_empty() {
anyhow::bail!("no forms found on page");
}
let mut form = forms.remove(0);
let user: HashMap<String, String> = fields.iter().cloned().collect();
form.merge_fields(&user);
let action_url = form.resolve_action(page_url)?;
let encoded = form.encode_urlencoded();
if form.method.eq_ignore_ascii_case("GET") {
let url = if action_url.contains('?') {
format!("{action_url}&{encoded}")
} else {
format!("{action_url}?{encoded}")
};
Ok(FetchRequest {
url,
method: "GET".to_string(),
headers: Vec::new(),
body: None,
})
} else {
Ok(FetchRequest {
url: action_url,
method: "POST".to_string(),
headers: vec![("Content-Type".to_string(), form.content_type().to_string())],
body: Some(encoded),
})
}
}
async fn submit_form<F: TaskFetcher>(
url: &str,
fields: &[(String, String)],
fetcher: &F,
) -> ActionObservation {
let page = match fetcher.fetch_raw(url).await {
Ok(p) => p,
Err(e) => return deferred(2, &format!("submit: fetching the form page failed: {e}")),
};
let req = match plan_form_submission(&page, url, fields) {
Ok(r) => r,
Err(e) => return deferred(2, &format!("submit: {e}")),
};
match fetcher.fetch(req).await {
Ok(content) => ActionObservation {
rung: 2,
status: TaskStatus::Done,
content,
error: None,
},
Err(e) => deferred(2, &format!("submit: posting the form failed: {e}")),
}
}
fn extract_from_content(prior: &str, extract_query: &str) -> ActionObservation {
if prior.is_empty() {
return ActionObservation {
rung: 0,
status: TaskStatus::Incomplete,
content: String::new(),
error: Some("extract: no prior content available to shape".to_string()),
};
}
let focused = crate::content::focus::extract_focused(prior, extract_query);
ActionObservation {
rung: 0,
status: TaskStatus::Done,
content: focused.markdown,
error: None,
}
}
#[derive(Debug, Clone)]
pub struct LoopBounds {
pub max_steps: usize,
pub max_wall_clock: std::time::Duration,
pub max_total_content_chars: usize,
}
impl Default for LoopBounds {
fn default() -> Self {
Self {
max_steps: 12,
max_wall_clock: std::time::Duration::from_mins(2),
max_total_content_chars: 32_000,
}
}
}
#[allow(async_fn_in_trait)]
pub trait Sampler {
async fn next_action(&self, prompt: &str) -> anyhow::Result<String>;
}
#[allow(async_fn_in_trait)]
pub trait BrowserBackend {
async fn render(&self, url: &str) -> anyhow::Result<String>;
}
pub struct NoBrowser;
impl BrowserBackend for NoBrowser {
async fn render(&self, _url: &str) -> anyhow::Result<String> {
anyhow::bail!(
"browser rung unavailable: build with --features browser and inject a \
CDP backend (or set NAB_BROWSER_CDP_WS) to enable rung 3"
)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TrajectoryStep {
pub action: TaskAction,
pub observation: ActionObservation,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LoopStop {
Done,
MaxSteps,
Timeout,
Budget,
SamplerError,
ParseError,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoopOutcome {
pub goal: String,
pub stop: LoopStop,
pub status: TaskStatus,
pub steps: Vec<TrajectoryStep>,
pub final_content: String,
}
fn parse_action(reply: &str) -> anyhow::Result<TaskAction> {
let trimmed = reply.trim();
let body = if let Some(rest) = trimmed
.strip_prefix("```json")
.or_else(|| trimmed.strip_prefix("```"))
{
rest.trim()
.strip_suffix("```")
.map_or(rest, str::trim)
.trim()
} else {
trimmed
};
serde_json::from_str(body).map_err(|e| anyhow::anyhow!("could not parse action JSON: {e}"))
}
fn build_prompt(
goal: &str,
seed: &str,
discovered: &[DiscoveredApi],
steps: &[TrajectoryStep],
) -> String {
let mut p = String::new();
p.push_str("Goal: ");
p.push_str(goal);
p.push_str("\n\n");
p.push_str("Seed page (markdown, truncated):\n");
let seed_cap = 4000;
if seed.len() > seed_cap {
p.push_str(&seed[..seed_cap]);
p.push_str("\n…(truncated)\n\n");
} else {
p.push_str(seed);
p.push_str("\n\n");
}
if !discovered.is_empty() {
p.push_str("Discovered API endpoints (rung-1 leads):\n");
for d in discovered {
writeln!(p, "- {} {}", d.method.as_deref().unwrap_or("GET"), d.url).unwrap();
}
p.push('\n');
}
if !steps.is_empty() {
p.push_str("Trajectory so far:\n");
for (i, s) in steps.iter().enumerate() {
writeln!(
p,
"Step {}: rung {} {:?}",
i + 1,
s.observation.rung,
s.observation.status
)
.unwrap();
}
p.push('\n');
}
p.push_str(
"Reply with the NEXT action as a single JSON object (TaskAction schema), e.g.\n\
{\"kind\":\"api_call\",\"url\":\"https://...\",\"method\":\"GET\"} \
or {\"kind\":\"done\",\"summary\":\"...\"}.\n",
);
p
}
pub async fn run_task_loop<S: Sampler, F: TaskFetcher>(
goal: &str,
seed: &str,
discovered: &[DiscoveredApi],
sampler: &S,
fetcher: &F,
bounds: &LoopBounds,
) -> LoopOutcome {
run_task_loop_with_browser(goal, seed, discovered, sampler, fetcher, &NoBrowser, bounds).await
}
pub async fn run_task_loop_with_browser<S: Sampler, F: TaskFetcher, B: BrowserBackend>(
goal: &str,
seed: &str,
discovered: &[DiscoveredApi],
sampler: &S,
fetcher: &F,
browser: &B,
bounds: &LoopBounds,
) -> LoopOutcome {
let start = std::time::Instant::now();
let mut steps: Vec<TrajectoryStep> = Vec::new();
let mut content_chars: usize = 0;
let finish = |stop: LoopStop, status: TaskStatus, steps: Vec<TrajectoryStep>| {
let final_content = steps
.last()
.map(|s| s.observation.content.clone())
.unwrap_or_default();
LoopOutcome {
goal: goal.to_string(),
stop,
status,
steps,
final_content,
}
};
while steps.len() < bounds.max_steps {
if start.elapsed() > bounds.max_wall_clock {
return finish(LoopStop::Timeout, TaskStatus::Incomplete, steps);
}
let prompt = build_prompt(goal, seed, discovered, &steps);
let Ok(reply) = sampler.next_action(&prompt).await else {
return finish(LoopStop::SamplerError, TaskStatus::Incomplete, steps);
};
let Ok(action) = parse_action(&reply) else {
return finish(LoopStop::ParseError, TaskStatus::Incomplete, steps);
};
if let TaskAction::Done { summary } = &action {
let final_content = summary.clone().unwrap_or_else(|| {
steps
.last()
.map(|s| s.observation.content.clone())
.unwrap_or_default()
});
return LoopOutcome {
goal: goal.to_string(),
stop: LoopStop::Done,
status: TaskStatus::Done,
steps,
final_content,
};
}
let observation = if let TaskAction::Extract { extract_query } = &action {
let prior = steps
.last()
.map_or(seed, |s| s.observation.content.as_str());
extract_from_content(prior, extract_query)
} else if let TaskAction::NeedsBrowser { url, .. } = &action {
browser_step(url.as_deref(), browser).await
} else {
execute_action(&action, fetcher)
.await
.unwrap_or_else(|e| ActionObservation {
rung: 0,
status: TaskStatus::Incomplete,
content: String::new(),
error: Some(e.to_string()),
})
};
content_chars += observation.content.len();
steps.push(TrajectoryStep {
action,
observation,
});
if content_chars > bounds.max_total_content_chars {
return finish(LoopStop::Budget, TaskStatus::Incomplete, steps);
}
}
finish(LoopStop::MaxSteps, TaskStatus::Incomplete, steps)
}
async fn browser_step<B: BrowserBackend>(url: Option<&str>, browser: &B) -> ActionObservation {
let Some(url) = url.filter(|u| !u.is_empty()) else {
return deferred(3, "needs_browser requires a url to render");
};
match browser.render(url).await {
Ok(content) => ActionObservation {
rung: 3,
status: TaskStatus::Done,
content: shape_api_response(&content, None),
error: None,
},
Err(e) => deferred(3, &format!("delegate_to_browser: {e}")),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TokenGap {
pub nab_tokens: usize,
pub browser_tokens: usize,
}
impl TokenGap {
#[must_use]
pub fn ratio(&self) -> f64 {
if self.nab_tokens == 0 {
return f64::INFINITY;
}
self.browser_tokens as f64 / self.nab_tokens as f64
}
#[must_use]
pub fn nab_wins(&self) -> bool {
self.nab_tokens < self.browser_tokens
}
}
#[must_use]
pub fn token_gap(seed_html: &str, api_response: &str) -> TokenGap {
let nab_seed = crate::content::html::html_to_markdown_with_readability(seed_html);
let nab_tokens = crate::content::budget::estimate_tokens(&nab_seed)
+ crate::content::budget::estimate_tokens(api_response);
let browser_tokens = crate::content::budget::estimate_tokens(seed_html);
TokenGap {
nab_tokens,
browser_tokens,
}
}
#[must_use]
pub fn median_ratio(gaps: &[TokenGap]) -> Option<f64> {
if gaps.is_empty() {
return None;
}
let mut ratios: Vec<f64> = gaps.iter().map(TokenGap::ratio).collect();
ratios.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let mid = ratios.len() / 2;
Some(if ratios.len().is_multiple_of(2) {
f64::midpoint(ratios[mid - 1], ratios[mid])
} else {
ratios[mid]
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn task_action_roundtrips_through_json() {
let actions = vec![
TaskAction::ApiCall {
url: "https://x/api".into(),
method: "POST".into(),
headers: vec![("A".into(), "B".into())],
body: Some("{}".into()),
extract_query: Some("q".into()),
},
TaskAction::JsEval {
url: "https://x".into(),
script: "1".into(),
},
TaskAction::Submit {
url: "https://x".into(),
fields: vec![("f".into(), "v".into())],
},
TaskAction::Extract {
extract_query: "title".into(),
},
TaskAction::NeedsBrowser {
reason: "captcha".into(),
url: None,
},
TaskAction::Done {
summary: Some("ok".into()),
},
];
for a in actions {
let s = serde_json::to_string(&a).unwrap();
let back: TaskAction = serde_json::from_str(&s).unwrap();
assert_eq!(a, back);
}
}
#[test]
fn api_call_defaults_method_to_get() {
let a: TaskAction =
serde_json::from_str(r#"{"kind":"api_call","url":"https://x"}"#).unwrap();
match a {
TaskAction::ApiCall { method, .. } => assert_eq!(method, "GET"),
other => panic!("expected api_call, got {other:?}"),
}
}
#[test]
fn outcome_serializes_rung_and_status() {
let o = TaskOutcome {
goal: "g".into(),
url: "u".into(),
rung: 0,
status: TaskStatus::Done,
content: "c".into(),
discovered_apis: vec![],
};
let s = serde_json::to_string(&o).unwrap();
assert!(s.contains("\"rung\":0"));
assert!(s.contains("\"status\":\"done\""));
}
#[test]
fn discovered_api_maps_from_endpoint_and_roundtrips() {
let ep = ApiEndpoint {
url: "/api/x".into(),
method: Some("POST".into()),
source: "script-fetch".into(),
};
let d: DiscoveredApi = ep.into();
assert_eq!(d.url, "/api/x");
let s = serde_json::to_string(&d).unwrap();
let back: DiscoveredApi = serde_json::from_str(&s).unwrap();
assert_eq!(d, back);
}
#[test]
fn action_observation_serializes_and_omits_absent_error() {
let obs = ActionObservation {
rung: 1,
status: TaskStatus::Done,
content: "ok".into(),
error: None,
};
let s = serde_json::to_string(&obs).unwrap();
assert!(s.contains("\"rung\":1"));
assert!(s.contains("\"status\":\"done\""));
assert!(!s.contains("error"));
let back: ActionObservation = serde_json::from_str(&s).unwrap();
assert_eq!(obs, back);
}
#[test]
fn discover_apis_finds_endpoints_and_skips_empty() {
assert!(discover_apis("").is_empty());
let html = r#"<html><body>
<script>fetch("/api/v1/users")</script>
<a href="/graphql">gql</a>
</body></html>"#;
let found = discover_apis(html);
assert!(
found.iter().any(|a| a.url.contains("/api/v1/users")),
"expected the /api/v1/users endpoint, got {found:?}"
);
}
struct MockFetcher {
reply: anyhow::Result<String>,
last: std::sync::Mutex<Option<FetchRequest>>,
form_page: Option<String>,
}
impl MockFetcher {
fn ok(body: &str) -> Self {
Self {
reply: Ok(body.to_string()),
last: std::sync::Mutex::new(None),
form_page: None,
}
}
fn err(msg: &str) -> Self {
Self {
reply: Err(anyhow::anyhow!("{msg}")),
last: std::sync::Mutex::new(None),
form_page: None,
}
}
fn with_form_page(mut self, html: &str) -> Self {
self.form_page = Some(html.to_string());
self
}
}
impl TaskFetcher for MockFetcher {
async fn fetch(&self, req: FetchRequest) -> anyhow::Result<String> {
*self.last.lock().unwrap() = Some(req);
match &self.reply {
Ok(s) => Ok(s.clone()),
Err(e) => Err(anyhow::anyhow!("{e}")),
}
}
async fn fetch_raw(&self, url: &str) -> anyhow::Result<String> {
if let Some(page) = &self.form_page {
return Ok(page.clone());
}
self.fetch(FetchRequest {
url: url.to_string(),
method: "GET".to_string(),
headers: Vec::new(),
body: None,
})
.await
}
}
fn f_last_url(f: &MockFetcher) -> Option<String> {
f.last.lock().unwrap().as_ref().map(|r| r.url.clone())
}
#[tokio::test]
async fn execute_action_routes_api_call_through_the_fetcher() {
let f = MockFetcher::ok("{\"ok\":true}");
let action = TaskAction::ApiCall {
url: "https://api/x".into(),
method: "POST".into(),
headers: vec![("Accept".into(), "application/json".into())],
body: Some("{}".into()),
extract_query: None,
};
let obs = execute_action(&action, &f).await.unwrap();
assert_eq!(obs.rung, 1);
assert_eq!(obs.status, TaskStatus::Done);
assert_eq!(obs.content, "{\"ok\":true}");
assert!(obs.error.is_none());
let req = f.last.lock().unwrap().clone().unwrap();
assert_eq!(req.url, "https://api/x");
assert_eq!(req.method, "POST");
assert_eq!(req.body.as_deref(), Some("{}"));
assert_eq!(
req.headers,
vec![("Accept".to_string(), "application/json".to_string())]
);
}
#[tokio::test]
async fn execute_action_caps_oversized_api_response() {
let huge = "x".repeat(API_RESPONSE_TOKEN_BUDGET * 4 * 10); let f = MockFetcher::ok(&huge);
let action = TaskAction::ApiCall {
url: "https://api/big".into(),
method: "GET".into(),
headers: vec![],
body: None,
extract_query: None,
};
let obs = execute_action(&action, &f).await.unwrap();
assert_eq!(obs.status, TaskStatus::Done);
let tokens = crate::content::budget::estimate_tokens(&obs.content);
assert!(
tokens <= API_RESPONSE_TOKEN_BUDGET + 64,
"api response not capped: {tokens} tokens"
);
assert!(
obs.content.contains("Truncated"),
"capped response must carry the truncation marker so the brain knows"
);
}
#[test]
fn shape_api_response_passes_small_bodies_through() {
assert_eq!(shape_api_response("{\"ok\":true}", None), "{\"ok\":true}");
}
#[tokio::test]
async fn execute_action_maps_fetcher_error_to_incomplete() {
let f = MockFetcher::err("boom");
let action = TaskAction::ApiCall {
url: "https://api/x".into(),
method: "GET".into(),
headers: vec![],
body: None,
extract_query: None,
};
let obs = execute_action(&action, &f).await.unwrap();
assert_eq!(obs.rung, 1);
assert_eq!(obs.status, TaskStatus::Incomplete);
assert!(obs.content.is_empty());
assert!(obs.error.unwrap().contains("boom"));
}
#[tokio::test]
async fn execute_action_defers_unsupported_and_terminates_done() {
let f = MockFetcher::ok("unused");
let cases = vec![
(
TaskAction::JsEval {
url: "https://x".into(),
script: "1".into(),
},
1u8,
),
(
TaskAction::Extract {
extract_query: "t".into(),
},
0,
),
(
TaskAction::NeedsBrowser {
reason: "captcha".into(),
url: None,
},
3,
),
];
for (action, want_rung) in cases {
let obs = execute_action(&action, &f).await.unwrap();
assert_eq!(obs.rung, want_rung, "rung for {action:?}");
assert_eq!(obs.status, TaskStatus::Incomplete);
assert!(obs.error.is_some());
assert!(obs.content.is_empty());
}
let obs = execute_action(&TaskAction::Done { summary: None }, &f)
.await
.unwrap();
assert_eq!(obs.status, TaskStatus::Done);
assert!(obs.error.is_none());
}
const POST_FORM_HTML: &str = r#"<html><body>
<form method="post" action="/login">
<input type="hidden" name="csrf" value="tok123">
<input type="text" name="email">
<input type="password" name="password">
</form></body></html>"#;
#[test]
fn plan_form_submission_builds_post_with_merged_and_carried_fields() {
let req = plan_form_submission(
POST_FORM_HTML,
"https://site.test/login-page",
&[
("email".to_string(), "a@b.com".to_string()),
("password".to_string(), "pw".to_string()),
],
)
.unwrap();
assert_eq!(req.method, "POST");
assert!(req.url.contains("site.test"), "url: {}", req.url);
assert!(
req.url.ends_with("/login"),
"action not resolved: {}",
req.url
);
assert_eq!(
req.headers
.iter()
.find(|(n, _)| n == "Content-Type")
.map(|(_, v)| v.as_str()),
Some("application/x-www-form-urlencoded")
);
let body = req.body.as_deref().unwrap();
assert!(body.contains("csrf=tok123"), "csrf not carried: {body}");
assert!(
body.contains("password=pw"),
"user field not merged: {body}"
);
assert!(body.contains("email="), "email field missing: {body}");
}
#[test]
fn plan_form_submission_builds_get_query() {
let html = r#"<form method="get" action="/search"><input name="q"></form>"#;
let req = plan_form_submission(
html,
"https://site.test/",
&[("q".to_string(), "rust".to_string())],
)
.unwrap();
assert_eq!(req.method, "GET");
assert!(req.body.is_none(), "GET must not carry a body");
assert!(
req.url.contains("q=rust"),
"query not appended: {}",
req.url
);
assert!(req.url.contains("/search"), "action missing: {}", req.url);
}
#[test]
fn plan_form_submission_errors_on_no_form() {
let err = plan_form_submission("<html>nothing here</html>", "https://x/", &[]).unwrap_err();
assert!(err.to_string().contains("no forms"), "got: {err}");
}
#[tokio::test]
async fn execute_action_submit_posts_form_through_fetcher() {
let f = MockFetcher::ok("welcome back").with_form_page(POST_FORM_HTML);
let action = TaskAction::Submit {
url: "https://site.test/login-page".into(),
fields: vec![
("email".into(), "a@b.com".into()),
("password".into(), "pw".into()),
],
};
let obs = execute_action(&action, &f).await.unwrap();
assert_eq!(obs.rung, 2, "submit is rung 2");
assert_eq!(obs.status, TaskStatus::Done);
assert_eq!(obs.content, "welcome back");
assert!(obs.error.is_none());
let req = f.last.lock().unwrap().clone().unwrap();
assert_eq!(req.method, "POST");
assert!(req.url.ends_with("/login"));
let body = req.body.as_deref().unwrap();
assert!(body.contains("csrf=tok123"));
assert!(body.contains("password=pw"));
}
#[tokio::test]
async fn execute_action_submit_incomplete_on_no_form() {
let f = MockFetcher::ok("unused").with_form_page("<html>no form here</html>");
let action = TaskAction::Submit {
url: "https://x/".into(),
fields: vec![],
};
let obs = execute_action(&action, &f).await.unwrap();
assert_eq!(obs.rung, 2);
assert_eq!(obs.status, TaskStatus::Incomplete);
assert!(obs.content.is_empty());
assert!(obs.error.unwrap().contains("no forms"));
}
struct ScriptedSampler {
replies: Vec<String>,
idx: std::sync::Mutex<usize>,
}
impl ScriptedSampler {
fn new(replies: &[&str]) -> Self {
Self {
replies: replies.iter().map(|s| (*s).to_string()).collect(),
idx: std::sync::Mutex::new(0),
}
}
}
impl Sampler for ScriptedSampler {
async fn next_action(&self, _prompt: &str) -> anyhow::Result<String> {
let mut i = self.idx.lock().unwrap();
if *i >= self.replies.len() {
anyhow::bail!("script exhausted");
}
let r = self.replies[*i].clone();
*i += 1;
Ok(r)
}
}
#[test]
fn parse_action_strips_json_fences() {
let a = parse_action("```json\n{\"kind\":\"done\",\"summary\":\"ok\"}\n```").unwrap();
assert!(matches!(a, TaskAction::Done { .. }));
let b = parse_action("{\"kind\":\"api_call\",\"url\":\"https://x\"}").unwrap();
assert!(matches!(b, TaskAction::ApiCall { .. }));
assert!(parse_action("not json").is_err());
}
#[tokio::test]
async fn loop_runs_api_call_then_done() {
let sampler = ScriptedSampler::new(&[
"{\"kind\":\"api_call\",\"url\":\"https://api/x\",\"method\":\"GET\"}",
"```json\n{\"kind\":\"done\",\"summary\":\"found it\"}\n```",
]);
let fetcher = MockFetcher::ok("{\"result\":42}");
let out = run_task_loop(
"find the answer",
"seed page",
&[],
&sampler,
&fetcher,
&LoopBounds::default(),
)
.await;
assert_eq!(out.stop, LoopStop::Done);
assert_eq!(out.status, TaskStatus::Done);
assert_eq!(out.steps.len(), 1, "one api_call executed before done");
assert_eq!(out.steps[0].observation.content, "{\"result\":42}");
assert_eq!(out.final_content, "found it");
}
#[tokio::test]
async fn route_1_stays_at_lowest_rung_when_api_completes() {
let sampler = ScriptedSampler::new(&[
"{\"kind\":\"api_call\",\"url\":\"https://api/x\"}",
"{\"kind\":\"done\",\"summary\":\"ok\"}",
]);
let fetcher = MockFetcher::ok("data");
let out = run_task_loop("g", "seed", &[], &sampler, &fetcher, &LoopBounds::default()).await;
assert_eq!(out.stop, LoopStop::Done);
assert!(
out.steps.iter().all(|s| s.observation.rung <= 1),
"router escalated above rung 1 when an API path completed: {:?}",
out.steps
);
assert!(
!out.steps.iter().any(|s| s.observation.rung == 3),
"rung 3 (browser) fired despite an available API path"
);
}
const MULTI_SECTION_MD: &str = "# Intro\n\nWelcome.\n\n## Auth\n\nBearer tokens and \
authentication flow.\n\n## Styling\n\nCSS rules.\n\n## Deploy\n\nDocker deploy.\n\n\
## Logging\n\nJSON logs.\n\n## Metrics\n\nProm metrics.";
#[tokio::test]
async fn loop_extract_shapes_prior_step_content() {
let sampler = ScriptedSampler::new(&[
"{\"kind\":\"api_call\",\"url\":\"https://api/doc\"}",
"{\"kind\":\"extract\",\"extract_query\":\"authentication bearer tokens\"}",
"{\"kind\":\"done\"}",
]);
let fetcher = MockFetcher::ok(MULTI_SECTION_MD);
let out = run_task_loop("g", "seed", &[], &sampler, &fetcher, &LoopBounds::default()).await;
assert_eq!(out.stop, LoopStop::Done);
assert_eq!(
out.steps.len(),
2,
"api_call + extract executed before done"
);
let extract_step = &out.steps[1];
assert!(
matches!(extract_step.action, TaskAction::Extract { .. }),
"second step should be the extract action"
);
assert_eq!(extract_step.observation.rung, 0, "extract is rung 0");
assert_eq!(extract_step.observation.status, TaskStatus::Done);
assert!(extract_step.observation.error.is_none());
assert!(
extract_step.observation.content.contains("## Auth"),
"extract dropped the relevant section: {}",
extract_step.observation.content
);
assert!(
extract_step.observation.content.contains("omitted —"),
"extract did not filter — no omitted-section marker"
);
assert!(
extract_step.observation.content.len() < MULTI_SECTION_MD.len(),
"focused content should be shorter than the source"
);
assert_eq!(
f_last_url(&fetcher),
Some("https://api/doc".to_string()),
"extract must not hit the fetcher"
);
}
#[tokio::test]
async fn loop_extract_falls_back_to_seed_when_no_prior_step() {
let sampler = ScriptedSampler::new(&[
"{\"kind\":\"extract\",\"extract_query\":\"authentication bearer tokens\"}",
"{\"kind\":\"done\"}",
]);
let fetcher = MockFetcher::ok("unused");
let out = run_task_loop(
"g",
MULTI_SECTION_MD,
&[],
&sampler,
&fetcher,
&LoopBounds::default(),
)
.await;
assert_eq!(out.stop, LoopStop::Done);
assert_eq!(out.steps.len(), 1);
let obs = &out.steps[0].observation;
assert_eq!(obs.rung, 0);
assert_eq!(obs.status, TaskStatus::Done);
assert!(obs.content.contains("## Auth"));
assert!(obs.content.contains("omitted —"));
assert!(f_last_url(&fetcher).is_none());
}
#[tokio::test]
async fn loop_extract_incomplete_when_no_content_to_shape() {
let sampler = ScriptedSampler::new(&[
"{\"kind\":\"extract\",\"extract_query\":\"anything\"}",
"{\"kind\":\"done\"}",
]);
let fetcher = MockFetcher::ok("unused");
let out = run_task_loop("g", "", &[], &sampler, &fetcher, &LoopBounds::default()).await;
assert_eq!(out.stop, LoopStop::Done);
let obs = &out.steps[0].observation;
assert_eq!(obs.status, TaskStatus::Incomplete);
assert!(obs.content.is_empty());
assert!(obs.error.as_deref().unwrap().contains("no prior content"));
}
#[tokio::test]
async fn loop_stops_at_max_steps() {
let sampler = ScriptedSampler::new(&[
"{\"kind\":\"api_call\",\"url\":\"https://a\"}",
"{\"kind\":\"api_call\",\"url\":\"https://b\"}",
"{\"kind\":\"api_call\",\"url\":\"https://c\"}",
]);
let fetcher = MockFetcher::ok("x");
let bounds = LoopBounds {
max_steps: 2,
..LoopBounds::default()
};
let out = run_task_loop("g", "s", &[], &sampler, &fetcher, &bounds).await;
assert_eq!(out.stop, LoopStop::MaxSteps);
assert_eq!(out.status, TaskStatus::Incomplete);
assert_eq!(out.steps.len(), 2);
}
#[tokio::test]
async fn loop_reports_parse_error_on_garbage_reply() {
let sampler = ScriptedSampler::new(&["this is not json"]);
let fetcher = MockFetcher::ok("x");
let out = run_task_loop("g", "s", &[], &sampler, &fetcher, &LoopBounds::default()).await;
assert_eq!(out.stop, LoopStop::ParseError);
assert!(out.steps.is_empty());
}
#[tokio::test]
async fn loop_reports_sampler_error_when_brain_fails() {
let sampler = ScriptedSampler::new(&[]); let fetcher = MockFetcher::ok("x");
let out = run_task_loop("g", "s", &[], &sampler, &fetcher, &LoopBounds::default()).await;
assert_eq!(out.stop, LoopStop::SamplerError);
}
struct MockBrowser {
reply: anyhow::Result<String>,
last: std::sync::Mutex<Option<String>>,
}
impl MockBrowser {
fn ok(body: &str) -> Self {
Self {
reply: Ok(body.to_string()),
last: std::sync::Mutex::new(None),
}
}
fn err(msg: &str) -> Self {
Self {
reply: Err(anyhow::anyhow!("{msg}")),
last: std::sync::Mutex::new(None),
}
}
}
impl BrowserBackend for MockBrowser {
async fn render(&self, url: &str) -> anyhow::Result<String> {
*self.last.lock().unwrap() = Some(url.to_string());
match &self.reply {
Ok(s) => Ok(s.clone()),
Err(e) => Err(anyhow::anyhow!("{e}")),
}
}
}
#[tokio::test]
async fn no_browser_backend_defers_render() {
assert!(NoBrowser.render("https://x").await.is_err());
}
#[tokio::test]
async fn loop_drives_browser_on_needs_browser_with_url() {
let sampler = ScriptedSampler::new(&[
"{\"kind\":\"needs_browser\",\"reason\":\"spa\",\"url\":\"https://app/dash\"}",
"{\"kind\":\"done\",\"summary\":\"read the dashboard\"}",
]);
let fetcher = MockFetcher::ok("unused");
let browser = MockBrowser::ok("# Dashboard\n\nBalance: 42");
let out = run_task_loop_with_browser(
"g",
"seed",
&[],
&sampler,
&fetcher,
&browser,
&LoopBounds::default(),
)
.await;
assert_eq!(out.stop, LoopStop::Done);
let step = &out.steps[0];
assert_eq!(step.observation.rung, 3, "browser step is rung 3");
assert_eq!(step.observation.status, TaskStatus::Done);
assert!(step.observation.content.contains("Balance: 42"));
assert_eq!(
browser.last.lock().unwrap().clone(),
Some("https://app/dash".to_string())
);
}
#[tokio::test]
async fn loop_defers_needs_browser_without_backend() {
let sampler = ScriptedSampler::new(&[
"{\"kind\":\"needs_browser\",\"reason\":\"spa\",\"url\":\"https://app/x\"}",
"{\"kind\":\"done\"}",
]);
let fetcher = MockFetcher::ok("unused");
let out = run_task_loop("g", "seed", &[], &sampler, &fetcher, &LoopBounds::default()).await;
assert_eq!(out.stop, LoopStop::Done);
let obs = &out.steps[0].observation;
assert_eq!(obs.rung, 3);
assert_eq!(obs.status, TaskStatus::Incomplete);
assert!(
obs.error
.as_deref()
.unwrap()
.contains("delegate_to_browser")
);
}
#[tokio::test]
async fn loop_defers_needs_browser_without_url() {
let sampler = ScriptedSampler::new(&[
"{\"kind\":\"needs_browser\",\"reason\":\"spa\"}",
"{\"kind\":\"done\"}",
]);
let fetcher = MockFetcher::ok("unused");
let browser = MockBrowser::err("should not be called");
let out = run_task_loop_with_browser(
"g",
"seed",
&[],
&sampler,
&fetcher,
&browser,
&LoopBounds::default(),
)
.await;
let obs = &out.steps[0].observation;
assert_eq!(obs.rung, 3);
assert_eq!(obs.status, TaskStatus::Incomplete);
assert!(obs.error.as_deref().unwrap().contains("requires a url"));
assert!(
browser.last.lock().unwrap().is_none(),
"backend must not be called"
);
}
fn chrome_heavy_html() -> String {
let junk_script = "var x=0;".repeat(400); let junk_style = "a{color:red}".repeat(100);
format!(
"<html><head><style>{junk_style}</style>\
<script>{junk_script}</script></head><body>\
<nav><a href=/>home</a><a href=/about>about</a></nav>\
<article><h1>The Answer</h1>\
<p>The result you asked for is forty-two.</p></article>\
<footer>(c) 2026 example</footer></body></html>"
)
}
#[test]
fn token_gap_nab_beats_raw_dom_on_chrome_heavy_page() {
let html = chrome_heavy_html();
let gap = token_gap(&html, r#"{"answer":42}"#);
assert!(
gap.nab_wins(),
"nab ({}) should cost fewer tokens than the raw DOM ({})",
gap.nab_tokens,
gap.browser_tokens
);
assert!(gap.ratio() > 1.0, "ratio {} should exceed 1", gap.ratio());
assert!(gap.browser_tokens > gap.nab_tokens);
}
#[test]
fn token_gap_includes_the_api_response_additively() {
let html = chrome_heavy_html();
let base = token_gap(&html, "");
let with_api = token_gap(&html, "0123456789"); assert_eq!(
with_api.nab_tokens,
base.nab_tokens + crate::content::budget::estimate_tokens("0123456789"),
"api response tokens must add to nab_tokens"
);
assert_eq!(with_api.browser_tokens, base.browser_tokens);
}
#[test]
fn ratio_is_infinite_for_zero_nab_tokens() {
let gap = TokenGap {
nab_tokens: 0,
browser_tokens: 5,
};
assert!(gap.ratio().is_infinite());
}
#[test]
fn median_ratio_handles_empty_odd_and_even() {
assert!(median_ratio(&[]).is_none());
let odd = [
TokenGap {
nab_tokens: 10,
browser_tokens: 20,
},
TokenGap {
nab_tokens: 10,
browser_tokens: 40,
},
TokenGap {
nab_tokens: 10,
browser_tokens: 80,
},
];
assert!((median_ratio(&odd).unwrap() - 4.0).abs() < f64::EPSILON);
let even = [
TokenGap {
nab_tokens: 10,
browser_tokens: 20,
},
TokenGap {
nab_tokens: 10,
browser_tokens: 40,
},
];
assert!((median_ratio(&even).unwrap() - 3.0).abs() < f64::EPSILON);
}
}