use std::collections::HashSet;
use std::path::Path;
use std::process::Command;
use std::time::Instant;
use chrono::Utc;
use serde_json::{Value, json};
use super::provider::{AgentProvider, ProviderError};
use super::schema::{Action, LoopResponse};
use super::svg_safety;
use crate::catalog;
use crate::fetch::browser_v2;
use crate::session::event::{FactCheckOutcome, SessionEvent};
use crate::session::{config, layout, log};
pub const DEFAULT_ITERATIONS: u32 = 5;
pub const DEFAULT_MAX_ACTIONS: u32 = 20;
pub const DIVERGENCE_THRESHOLD: u32 = 3;
pub const MAX_ACTIONBOOK_SEARCH_PER_ITER: u32 = 5;
pub const MAX_ACTIONBOOK_MANUAL_PER_ITER: u32 = 5;
pub const MAX_ACTIONBOOK_RUNCODE_PER_ITER: u32 = 3;
const ACTIONBOOK_SEARCH_BUDGET_BYTES: usize = 2 * 1024;
const ACTIONBOOK_MANUAL_BUDGET_BYTES: usize = 8 * 1024;
const ACTIONBOOK_RUNCODE_TEXT_BUDGET_BYTES: usize = 16 * 1024;
const ACTIONBOOK_RUNCODE_RESULT_JSON_BUDGET_BYTES: usize = 4 * 1024;
const ACTIONBOOK_RUNCODE_TIMEOUT_DEFAULT_MS: u64 = 30_000;
const ACTIONBOOK_RUNCODE_TIMEOUT_MIN_MS: u64 = 5_000;
const ACTIONBOOK_RUNCODE_TIMEOUT_MAX_MS: u64 = 60_000;
const ACTIONBOOK_OUTER_TIMEOUT_MS: u64 = 15_000;
#[derive(Debug, Clone)]
pub struct LoopConfig {
pub iterations: u32,
pub max_actions: u32,
pub dry_run: bool,
}
impl Default for LoopConfig {
fn default() -> Self {
Self {
iterations: DEFAULT_ITERATIONS,
max_actions: DEFAULT_MAX_ACTIONS,
dry_run: false,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum TerminationReason {
ReportReady,
IterationsExhausted,
MaxActionsExhausted,
ProviderDone,
Diverged,
ProviderUnavailable,
}
impl TerminationReason {
pub fn as_str(&self) -> &'static str {
match self {
TerminationReason::ReportReady => "report_ready",
TerminationReason::IterationsExhausted => "iterations_exhausted",
TerminationReason::MaxActionsExhausted => "max_actions_exhausted",
TerminationReason::ProviderDone => "provider_done",
TerminationReason::Diverged => "diverged",
TerminationReason::ProviderUnavailable => "provider_unavailable",
}
}
}
#[derive(Debug, Clone)]
pub struct LoopReport {
pub provider: String,
pub iterations_run: u32,
pub actions_executed: u32,
pub actions_rejected: u32,
pub termination_reason: TerminationReason,
pub final_coverage: Value,
pub duration_ms: u64,
pub warnings: Vec<String>,
}
pub async fn run(
provider: &dyn AgentProvider,
slug: &str,
cfg: LoopConfig,
research_bin: &Path,
) -> LoopReport {
let start = Instant::now();
let provider_name = provider.name().to_string();
let mut warnings: Vec<String> = Vec::new();
let _ = log::append(
slug,
&SessionEvent::LoopStarted {
timestamp: Utc::now(),
provider: provider_name.clone(),
iterations: cfg.iterations,
max_actions: cfg.max_actions,
dry_run: cfg.dry_run,
note: None,
},
);
let mut actions_executed_total: u32 = 0;
let mut actions_rejected_total: u32 = 0;
let mut iterations_run: u32 = 0;
let mut termination = TerminationReason::IterationsExhausted;
let mut coverage_history: Vec<String> = Vec::new();
let mut pending_actionbook_results: Vec<Value> = Vec::new();
for iter in 1..=cfg.iterations {
iterations_run = iter;
let iter_start = Instant::now();
let coverage_before = coverage_json(slug, research_bin);
let unread = collect_unread_sources(slug, 3, 2000);
let system = system_prompt(slug);
let user = user_prompt(
slug,
&coverage_before,
&unread,
iter,
cfg.iterations,
&pending_actionbook_results,
);
pending_actionbook_results.clear();
let raw = match provider.ask(&system, &user).await {
Ok(s) => s,
Err(ProviderError::NotAvailable(msg)) => {
warnings.push(format!("provider_unavailable: {msg}"));
termination = TerminationReason::ProviderUnavailable;
break;
}
Err(e) => {
warnings.push(format!("provider_call_failed_iter_{iter}: {e}"));
append_step(
slug,
iter,
"(provider error)",
0,
0,
0,
iter_start.elapsed().as_millis() as u64,
);
continue;
}
};
let response: LoopResponse = match parse_response(&raw) {
Ok(r) => r,
Err(e) => {
let snippet: String = raw
.chars()
.take(160)
.collect::<String>()
.replace('\n', "\\n");
warnings.push(format!(
"schema_violation_iter_{iter}: {e}; raw[0..160]={snippet}"
));
append_step(
slug,
iter,
"(schema violation)",
0,
0,
0,
iter_start.elapsed().as_millis() as u64,
);
continue;
}
};
let requested = response.actions.len() as u32;
let mut executed_this_round: u32 = 0;
let mut rejected_this_round: u32 = 0;
let mut plan_required = iter == 1 && !session_has_plan(slug);
let mut diagrams_this_iter: u32 = 0;
let unread_at_turn_start = unread.len();
let mut actionbook_search_count: u32 = 0;
let mut actionbook_manual_count: u32 = 0;
let mut actionbook_runcode_count: u32 = 0;
for action in &response.actions {
if actions_executed_total + executed_this_round >= cfg.max_actions {
termination = TerminationReason::MaxActionsExhausted;
break;
}
if plan_required && !matches!(action, Action::WritePlan { .. }) {
warnings.push(format!(
"action_rejected_iter_{iter}: plan_required — first iteration must emit a write_plan before any other action"
));
rejected_this_round += 1;
continue;
}
if unread_at_turn_start > 0
&& matches!(action, Action::Add { .. } | Action::Batch { .. })
{
warnings.push(format!(
"action_rejected_iter_{iter}: unread_queue_nonempty — {unread_at_turn_start} accepted source(s) still undigested; digest those before fetching more"
));
rejected_this_round += 1;
continue;
}
if matches!(action, Action::WriteDiagram { .. }) && diagrams_this_iter >= 3 {
warnings.push(format!(
"action_rejected_iter_{iter}: diagram_rate_limit — max 3 write_diagram per iteration"
));
rejected_this_round += 1;
continue;
}
if let Some(ab_outcome) = handle_actionbook_action(
action,
slug,
iter,
cfg.dry_run,
&mut actionbook_search_count,
&mut actionbook_manual_count,
&mut actionbook_runcode_count,
) {
if let Some(payload) = ab_outcome.result_payload {
pending_actionbook_results.push(payload);
}
if ab_outcome.cap_exceeded {
warnings.push(format!(
"action_rejected_iter_{iter}: actionbook_per_loop_cap_exceeded ({})",
ab_outcome.action_type
));
rejected_this_round += 1;
} else if ab_outcome.fail_soft {
warnings.push(format!(
"actionbook_action_failed_{} (iter {iter})",
ab_outcome
.error_code
.as_deref()
.unwrap_or("unknown")
));
rejected_this_round += 1;
} else {
executed_this_round += 1;
}
continue;
}
match dispatch_action(action, slug, iter, cfg.dry_run, research_bin) {
Ok(()) => {
executed_this_round += 1;
if matches!(action, Action::WritePlan { .. }) {
plan_required = false;
}
if matches!(action, Action::WriteDiagram { .. }) {
diagrams_this_iter += 1;
}
}
Err(reason) => {
warnings.push(format!("action_rejected_iter_{iter}: {reason}"));
rejected_this_round += 1;
}
}
}
actions_executed_total += executed_this_round;
actions_rejected_total += rejected_this_round;
let iter_ms = iter_start.elapsed().as_millis() as u64;
append_step(
slug,
iter,
&response.reasoning,
requested,
executed_this_round,
rejected_this_round,
iter_ms,
);
if matches!(termination, TerminationReason::MaxActionsExhausted) {
break;
}
if response.done {
termination = TerminationReason::ProviderDone;
break;
}
let coverage_after = coverage_json(slug, research_bin);
if coverage_after["report_ready"] == json!(true) {
termination = TerminationReason::ReportReady;
break;
}
let sig = coverage_signature(&coverage_after);
coverage_history.push(sig.clone());
if coverage_history.len() >= DIVERGENCE_THRESHOLD as usize {
let tail_start = coverage_history.len() - DIVERGENCE_THRESHOLD as usize;
if coverage_history[tail_start..]
.iter()
.all(|s| s == &coverage_history[tail_start])
{
termination = TerminationReason::Diverged;
break;
}
}
}
let final_coverage = coverage_json(slug, research_bin);
let report_ready = final_coverage["report_ready"] == json!(true);
let _ = log::append(
slug,
&SessionEvent::LoopCompleted {
timestamp: Utc::now(),
reason: termination.as_str().to_string(),
iterations_run,
actions_executed_total,
report_ready,
note: None,
},
);
LoopReport {
provider: provider_name,
iterations_run,
actions_executed: actions_executed_total,
actions_rejected: actions_rejected_total,
termination_reason: termination,
final_coverage,
duration_ms: start.elapsed().as_millis() as u64,
warnings,
}
}
fn parse_response(raw: &str) -> Result<LoopResponse, String> {
let trimmed = raw.trim();
let candidate = if let Some(stripped) = trimmed.strip_prefix("```json") {
stripped.trim_end_matches("```").trim()
} else if let Some(stripped) = trimmed.strip_prefix("```") {
stripped.trim_end_matches("```").trim()
} else {
trimmed
};
serde_json::from_str::<LoopResponse>(candidate).map_err(|e| format!("serde: {e}"))
}
fn system_prompt(slug: &str) -> String {
let schema_extra = crate::session::schema::prompt_body(slug);
let session_cfg = config::read(slug).ok();
let preset = session_cfg.as_ref().map(|cfg| cfg.preset.as_str());
let fact_check = session_cfg
.as_ref()
.map(|cfg| cfg.tags.iter().any(|tag| tag == "fact-check"))
.unwrap_or(false);
system_prompt_from_context(schema_extra, preset, fact_check)
}
fn system_prompt_from_context(
schema_extra: Option<String>,
preset: Option<&str>,
fact_check: bool,
) -> String {
let mut prompt = base_system_prompt();
if let Some(guidance) = preset_source_guidance(preset, fact_check) {
prompt.push_str("\n\n── Preset-specific source guidance ──\n");
prompt.push_str(&guidance);
prompt.push('\n');
}
if let Some(extra) = schema_extra {
prompt.push_str("\n\n── Session-specific schema guidance (from <session>/SCHEMA.md) ──\n");
prompt.push_str(&extra);
prompt.push('\n');
}
prompt
}
fn preset_source_guidance(preset: Option<&str>, fact_check: bool) -> Option<String> {
if preset != Some("sports") {
return None;
}
let mut guidance = r#"Sports/current-roster source plan:
- Seed official roster/current-status sources before writing concrete roster claims.
- Preferred NBA roster URLs:
- https://www.nba.com/<team>/roster
- https://www.basketball-reference.com/teams/<TEAM>/<YEAR>.html
- https://www.espn.com/nba/team/roster/_/name/<abbr>/<team>
- Treat these as source patterns only, not facts. Do not infer a player is on
or off a roster from prior knowledge."#
.to_string();
if fact_check {
guidance.push_str(
"\n- This session has `fact-check`: roster/current-status claims require an accepted + digested source and a matching `fact_check` event before final synthesis.",
);
}
Some(guidance)
}
fn base_system_prompt() -> String {
r###"You drive a research CLI. Each turn respond with STRICT JSON matching
this exact schema — no prose before or after, no code fences, nothing but
the JSON object:
{
"reasoning": "<one or two sentences>",
"actions": [ ...action objects... ],
"done": false,
"reason": null
}
Set "done": true and a non-null "reason" string when the coverage blockers
are cleared or no further action is useful.
Valid action shapes (each is an object with a "type" field):
{ "type": "add", "url": "https://example.com/..." }
{ "type": "batch", "urls": ["https://a.test/", "https://b.test/"], "concurrency": 4 }
{ "type": "write_section", "heading": "## 01 · WHY", "body": "markdown body..." }
{ "type": "write_overview", "body": "2-4 paragraph markdown overview" }
{ "type": "write_aside", "body": "short italic epigraph text" }
{ "type": "note_diagram_needed", "name": "axis.svg", "hint": "what the diagram should show" }
{ "type": "digest_source", "url": "https://...", "into_section": "## 02 · WHAT" }
{ "type": "fact_check", "claim": "specific claim text", "query": "search/query used to verify it",
"sources": ["https://accepted-source.test/..."], "outcome": "supported|refuted|uncertain",
"into_section": "## 02 · WHAT", "note": "short evidence note" }
{ "type": "write_plan", "body": "Goal: …\nSources: arxiv+github+HN\nMilestones: iter 2 → fetch; iter 4 → draft" }
{ "type": "write_diagram", "path": "axis.svg", "alt": "philosophy axis",
"svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 920 380\">…</svg>" }
{ "type": "write_wiki_page", "slug": "scheduler", "body": "---\nkind: concept\nsources: [https://...]\n---\n# Scheduler\n..." }
{ "type": "write_wiki_page", "slug": "scheduler", "body": "...", "replace": true }
{ "type": "append_wiki_page", "slug": "scheduler", "body": "new finding from iter 5..." }
Rules:
- "batch" requires a JSON array of URL strings named "urls" (plural). Even
if you want one URL, use { "type": "add", "url": "..." } instead, never
{ "type": "batch", "url": "..." }.
- "concurrency" in batch is optional; default is 4 if omitted.
- Section headings must use "## NN · TITLE" format (two-digit number,
space, middle dot U+00B7, space, TITLE in uppercase).
- Never propose types outside the list above OR the v4 actionbook tools
section near the bottom of this prompt. Destructive operations
(rm, close, delete) are not available.
- `write_diagram` SVG constraints (enforced — rejection costs a warning):
size ≤ 512 KB; must start with `<svg` and declare
`xmlns="http://www.w3.org/2000/svg"`; must NOT contain `<script>`,
`<foreignObject>`, `on*=` handlers, or `javascript:` URLs. Max 3
`write_diagram` per turn. `path` is a bare filename ending in `.svg`
(no slashes, no `..`). The CLI writes to `<session>/diagrams/<path>`
but does NOT auto-insert the reference — you must also emit a
`write_section` whose body contains ``.
FIGURE-RICH CONTRACT (non-negotiable, v3):
* A report with no diagrams is INCOMPLETE. Target: ≥ 1 diagram per
numbered section; at minimum ≥ 1 diagram before `report_ready`.
The coverage blocker `diagrams_referenced < 1` enforces the floor
and WILL keep the loop running past "prose feels done."
* BIDIRECTIONAL RULE: every `` markdown
reference you write MUST be paired with a `write_diagram` action
(path=x.svg) in the SAME turn or an earlier turn. An orphan
reference renders as a broken "diagram pending" placeholder in
the report and blocks `report_ready` via
`diagrams_resolved < diagrams_referenced`.
* Every `write_diagram` MUST be paired with a matching
`write_section` whose body contains the reference — a dangling
SVG file on disk with no reference is also incomplete.
* If you find yourself writing "(see diagram above)" or "imagine a
flow chart here" instead of emitting a diagram, STOP and emit the
diagram instead. Hand-drawn SVG is part of the expected output,
not a bonus.
* If a previous turn left a dangling `diagrams/x.svg` reference,
the user prompt will surface it as an "⚠ UNRESOLVED DIAGRAM
REFERENCE" block — fix it THAT TURN, before any other action.
* NEVER drop a diagram reference when overwriting a section. If a
section body currently contains `` and you are
rewriting that section, EITHER keep the reference in place OR
relocate it to another section in the same turn. Silently
overwriting a section-with-reference is what creates orphan SVGs
that the reader can't find near the relevant prose.
* If a previous turn orphaned an SVG (file on disk, no reference
anywhere), the user prompt will surface it as an "⚠ ORPHAN
DIAGRAM FILE" block — use `write_section` to insert the reference
into a semantically relevant section, don't emit a new
`write_diagram` for the same path.
Workflow: plan → fetch → digest + write → mark diagrams.
- First-iteration contract: on a FRESH session with no `## Plan` section
yet, the loop accepts ONLY a `write_plan` action. Any other action is
auto-rejected with `plan_required`. Keep the plan tight — one
paragraph covering goal, source mix (arxiv + github + HN/blog),
estimated iteration count, and 2-3 milestones.
- IMPORTANT: once the plan exists (visible as a `# Plan` block at the
top of the user prompt — it appears from iteration 2 onward), DO NOT
emit `write_plan` again. The plan is there as a north star, not as a
prompt for you to re-author. Move to fetch/digest/write phases per
your own plan milestones. If the plan needs material revision emit
`write_plan` once with a full replacement; otherwise never.
- The user prompt shows up to 3 `unread sources` (raw content truncated).
Pick ONE per turn, write a section body that explains what the source
says (with the URL as a markdown link), then emit a matching
`digest_source` action so the next turn's prompt excludes it. Without
a `digest_source`, the same source will keep reappearing.
- EVERY accepted source MUST be digested. You do NOT have authority to
skip a URL the human added (e.g. by labeling it "low signal" or
"JS-only shell"). That judgment belongs to the human. If the raw
snippet looks thin, look harder: grep the raw file for titles, links,
headings, and github references before giving up. The loop enforces
this via a `sources_unused > 0` coverage blocker — you cannot reach
`report_ready` while any accepted source is missing from the body.
- `into_section` must match the `heading` of a WriteSection you just
wrote (or an existing section). Use this to link the source to its
landing place in the narrative.
- For live/current/dynamic facts, emit `fact_check` before the report
depends on the claim. Its `sources` must be accepted source URLs from
this session, and `outcome` must be exactly `supported`, `refuted`, or
`uncertain`. Use `uncertain` when evidence is insufficient or stale;
do not convert uncertainty into a confident report sentence.
GROUNDING CONTRACT (non-negotiable):
- Any statement naming a specific person, team, date, or number must be
supported by a digested source URL already accepted in this session.
If no digested source supports a claim, do not write it.
- If the session requires fact-checking, any concrete person, team, date,
number, price, roster, standing, release version, or current-status
claim must also have a matching `fact_check` action in the event log
before final synthesis.
- Do NOT rely on prior knowledge for rosters, standings, prices,
release versions, dates, or "everybody knows" facts. Fetch and digest
a current source first.
- If sources conflict or look stale, say so explicitly and fetch a
corroborating source instead of picking one silently.
Wiki pages — the PREFERRED ingest surface (v3).
When a source maps cleanly to a durable named thing — a library
component, a protocol, a paper, a dataset, a framework — write a wiki
page rather than adding another numbered section. Durable entities
accumulate across runs; numbered sections are report-shaped and get
overwritten.
Page slug rules: `[a-z0-9_-]{1,64}`. Convention:
- entity pages: `<name>` (e.g. `scheduler`, `openviking`)
- concept pages: `concept-<name>` (e.g. `concept-work-stealing`)
- source summaries: `source-<domain>-<hash>` (e.g. `source-arxiv-2410-04444`)
- comparisons: `cmp-<a>-vs-<b>`
Required frontmatter for new pages:
---
kind: concept | entity | source-summary | comparison
sources: [https://...] # every URL the page draws from
related: [other-slug, ...] # cross-references
updated: YYYY-MM-DD # today
---
Workflow per source:
1. If the source is a named thing the session will return to → emit
`write_wiki_page` with a fresh slug. Include the source URL in
`sources:` and cite it in the body as `[...](URL)`.
2. If the source extends a page that already exists (see the
"Existing wiki pages" block in the user prompt) → emit
`append_wiki_page` instead of re-writing the whole body. Keep
appends focused: one new finding per append.
3. Always pair with `digest_source` so the URL leaves the unread
queue. The `into_section` field for wiki-backed digests should
be the wiki page itself, e.g. `into_section: "wiki:scheduler"`.
4. Cross-link aggressively. Use `[[slug]]` in prose whenever you
reference another wiki page. The renderer turns these into
anchor links; broken links surface in coverage + warnings.
When NOT to use wiki: pure narrative (overview, plan, editorial
aside), one-shot findings that don't warrant their own page, and
transient lint comments. Those belong in numbered sections or the
overview.
Mental model shift: the numbered sections are the report's narrative
spine. The wiki is the durable knowledge graph the narrative draws
from. Build the wiki first, let the numbered sections cite `[[slug]]`
pages instead of repeating their content.
Source diversity. The CLI routes these kinds efficiently without a browser:
- arxiv.org/abs/{id} → paper abstract (fast)
- github.com/{owner}/{repo} → README via API
- github.com/{owner}/{repo}/blob/{ref}/{path} → raw file content
- github.com/{owner}/{repo}/tree/{ref}/{path} → directory listing
- news.ycombinator.com/item?id={N} → HN item JSON
- anything else → browser fallback (slower)
For "survey" or "ecosystem" topics, diversify: propose URLs spanning
≥ 3 of the above kinds. Specifically consider top github repos
(trending/starred) and HN discussion threads, not only papers. Papers
alone produce a thin report.
"###
.to_string()
+ "\n\n── Actionbook MCP tools (v4 — autoresearch-actionbook-tools) ──\n\n"
+ include_str!("prompts/actionbook_tools.md")
}
fn user_prompt(
slug: &str,
coverage: &Value,
unread: &[UnreadSource],
iter: u32,
total_iters: u32,
recent_actionbook_results: &[Value],
) -> String {
let mut out = String::new();
if !recent_actionbook_results.is_empty() {
out.push_str(
"recent_actionbook_results (results of last turn's actionbook_* actions — review BEFORE emitting more):\n",
);
let block = serde_json::to_string_pretty(recent_actionbook_results)
.unwrap_or_else(|_| "[]".to_string());
out.push_str(&block);
out.push_str("\n\n");
}
if let Some(plan) = read_plan_body(slug) {
out.push_str(
"# Plan (ALREADY WRITTEN — north star, do NOT emit write_plan again unless materially revising; fetch / digest / write per milestones instead)\n\n",
);
out.push_str(&plan);
out.push_str("\n\n---\n\n");
}
out.push_str(&format!("iteration: {iter} of {total_iters}\n"));
out.push_str(&format!("session: {slug}\n\n"));
out.push_str("coverage:\n");
out.push_str(&serde_json::to_string_pretty(coverage).unwrap_or_default());
out.push_str("\n\n");
let unresolved = unresolved_diagram_refs(slug);
if !unresolved.is_empty() {
out.push_str(&format!(
"⚠ {} UNRESOLVED DIAGRAM REFERENCE(S) — emit `write_diagram` THIS TURN for each path below. Every `` you wrote is currently pointing at a missing file; the report renders a 'diagram pending' placeholder in its place. This is a coverage blocker. Do NOT start new numbered sections until every referenced diagram has a matching `write_diagram` with an inline SVG body.\n\n",
unresolved.len()
));
for (path, alt) in &unresolved {
let alt_display = if alt.is_empty() { "(no alt text)" } else { alt };
out.push_str(&format!(" - path: {path} alt: {alt_display}\n"));
}
out.push_str(
"\nEmit shape (one per path above):\n { \"type\": \"write_diagram\", \"path\": \"<path>\", \"alt\": \"<alt>\", \"svg\": \"<svg xmlns=\\\"http://www.w3.org/2000/svg\\\" viewBox=\\\"0 0 800 400\\\">…</svg>\" }\n\n",
);
}
let orphans = orphan_diagram_files(slug);
if !orphans.is_empty() {
out.push_str(&format!(
"⚠ {} ORPHAN DIAGRAM FILE(S) — these SVG files are on disk but NOT referenced from session.md or any wiki page. Emit `write_section` THIS TURN to insert `` into a relevant numbered section (or edit an existing section). Do NOT emit a new `write_diagram` for these paths — they already exist; just add the markdown reference.\n\n",
orphans.len()
));
for fname in &orphans {
out.push_str(&format!(" - diagrams/{fname}\n"));
}
out.push('\n');
}
let existing_pages = crate::session::wiki::list_pages(slug);
if !existing_pages.is_empty() {
out.push_str(&format!(
"existing wiki pages ({}) — prefer `append_wiki_page` over creating a near-duplicate:\n",
existing_pages.len()
));
for page_slug in &existing_pages {
let kind_hint = crate::session::wiki::read_page(slug, page_slug)
.ok()
.map(|body| {
let (fm, _rest) = crate::session::wiki::split_frontmatter(&body);
fm.kind.unwrap_or_else(|| "—".to_string())
})
.unwrap_or_else(|| "—".to_string());
out.push_str(&format!(" - {page_slug} [{kind_hint}]\n"));
}
out.push('\n');
}
if !unread.is_empty() {
out.push_str(&format!(
"⚠ {} unread accepted source(s) below — DIGEST ONE NOW. Do NOT emit an `add` or `batch` action until the unread queue is empty; the sources are already on disk and fetching more is wasted work. The raw snippet may look thin but it's real HTML/JSON — grep it for titles, links, headings, and github references before concluding it's unusable. You have no authority to skip a URL.\n\n",
unread.len()
));
out.push_str("unread sources (fetched but not yet digested — pick one per turn,\n");
out.push_str("write a finding that cites the URL, and emit a `digest_source` action):\n\n");
for (i, u) in unread.iter().enumerate() {
out.push_str(&format!("--- {} / {} ---\n", i + 1, unread.len()));
out.push_str(&format!("url: {}\nkind: {}\n", u.url, u.kind));
out.push_str("raw (truncated):\n");
out.push_str(&u.snippet);
out.push_str("\n\n");
}
} else {
out.push_str("(no unread sources — all accepted sources have been digested)\n\n");
}
out.push_str("Decide the next actions.\n");
out
}
fn unresolved_diagram_refs(slug: &str) -> Vec<(String, String)> {
let md = std::fs::read_to_string(layout::session_md(slug)).unwrap_or_default();
crate::commands::coverage::diagram_refs_with_alt(&md)
.into_iter()
.filter(|(path, _alt)| !crate::commands::coverage::diagram_path_resolved(slug, path))
.collect()
}
fn orphan_diagram_files(slug: &str) -> Vec<String> {
let diagrams_dir = layout::session_dir(slug).join("diagrams");
let Ok(entries) = std::fs::read_dir(&diagrams_dir) else {
return Vec::new();
};
let mut on_disk: Vec<String> = entries
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("svg"))
.filter_map(|e| {
e.path()
.file_name()
.and_then(|s| s.to_str())
.map(str::to_string)
})
.collect();
on_disk.sort();
if on_disk.is_empty() {
return Vec::new();
}
let mut corpus = std::fs::read_to_string(layout::session_md(slug)).unwrap_or_default();
let wiki_dir = layout::session_dir(slug).join("wiki");
if let Ok(entries) = std::fs::read_dir(&wiki_dir) {
for e in entries.flatten() {
if e.path().extension().and_then(|s| s.to_str()) == Some("md")
&& let Ok(body) = std::fs::read_to_string(e.path())
{
corpus.push('\n');
corpus.push_str(&body);
}
}
}
on_disk
.into_iter()
.filter(|fname| !corpus.contains(&format!("diagrams/{fname}")))
.collect()
}
#[derive(Debug, Clone)]
struct UnreadSource {
url: String,
kind: String,
snippet: String,
}
fn collect_unread_sources(slug: &str, limit: usize, max_bytes: usize) -> Vec<UnreadSource> {
let events = log::read_all(slug).unwrap_or_default();
let mut digested: std::collections::HashSet<String> = std::collections::HashSet::new();
for e in &events {
if let SessionEvent::SourceDigested { url, .. } = e {
digested.insert(url.clone());
}
}
let mut out = Vec::new();
for e in &events {
if let SessionEvent::SourceAccepted {
url,
kind,
raw_path,
..
} = e
{
if digested.contains(url) {
continue;
}
let full_path = layout::session_dir(slug).join(raw_path);
let snippet = match std::fs::read_to_string(&full_path) {
Ok(s) => truncate_utf8_safe(&s, max_bytes),
Err(_) => "(raw file not readable)".to_string(),
};
out.push(UnreadSource {
url: url.clone(),
kind: kind.clone(),
snippet,
});
if out.len() >= limit {
break;
}
}
}
out
}
fn truncate_utf8_safe(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let mut end = max;
while !s.is_char_boundary(end) && end > 0 {
end -= 1;
}
format!("{}\n… [truncated at {} chars]", &s[..end], end)
}
fn coverage_json(slug: &str, research_bin: &Path) -> Value {
let out = Command::new(research_bin)
.args(["coverage", slug, "--json"])
.env(
"ACTIONBOOK_RESEARCH_HOME",
std::env::var("ACTIONBOOK_RESEARCH_HOME").unwrap_or_default(),
)
.output();
let Ok(out) = out else {
return json!({"error": "failed to run coverage"});
};
let stdout = String::from_utf8_lossy(&out.stdout);
serde_json::from_str::<Value>(stdout.lines().find(|l| l.starts_with('{')).unwrap_or("{}"))
.ok()
.and_then(|v| v.get("data").cloned())
.unwrap_or_else(|| json!({}))
}
fn coverage_signature(coverage: &Value) -> String {
let keys = [
"overview_chars",
"numbered_sections_count",
"aside_count",
"diagrams_referenced",
"diagrams_resolved",
"sources_accepted",
"source_kind_diversity",
"sources_referenced_in_body",
"sources_unused",
"sources_hallucinated",
"wiki_pages",
"wiki_pages_with_frontmatter",
"wiki_total_bytes",
];
keys.iter()
.map(|k| format!("{k}={}", coverage.get(k).unwrap_or(&Value::Null)))
.collect::<Vec<_>>()
.join("|")
}
fn dispatch_action(
action: &Action,
slug: &str,
iteration: u32,
dry_run: bool,
research_bin: &Path,
) -> Result<(), String> {
if dry_run {
return Ok(());
}
match action {
Action::Add { url } => run_add(research_bin, slug, url),
Action::Batch { urls, concurrency } => run_batch(research_bin, slug, urls, *concurrency),
Action::WriteOverview { body } => write_section(slug, "## Overview", body),
Action::WriteSection { heading, body } => {
if !heading.starts_with("## ") {
return Err(format!("heading '{heading}' is not an H2 section"));
}
write_section(slug, heading, body)
}
Action::WriteAside { body } => write_aside(slug, body),
Action::NoteDiagramNeeded { name, hint } => append_diagram_todo(slug, name, hint),
Action::DigestSource { url, into_section } => {
digest_source(slug, iteration, url, into_section)
}
Action::FactCheck {
claim,
query,
sources,
outcome,
into_section,
note,
} => fact_check(FactCheckInput {
slug,
iteration,
claim,
query,
sources,
outcome: *outcome,
into_section,
note: note.as_deref(),
}),
Action::WritePlan { body } => write_plan(slug, iteration, body),
Action::WriteDiagram { path, alt, svg } => write_diagram(slug, iteration, path, alt, svg),
Action::WriteWikiPage {
slug: page_slug,
body,
replace,
} => write_wiki_page(slug, iteration, page_slug, body, *replace),
Action::AppendWikiPage {
slug: page_slug,
body,
} => append_wiki_page(slug, iteration, page_slug, body),
Action::ActionbookSearch { .. }
| Action::ActionbookManual { .. }
| Action::ActionbookRunCode { .. } => Err(
"internal: actionbook action reached dispatch_action — should have been intercepted by handle_actionbook_action".into(),
),
}
}
fn write_wiki_page(
session_slug: &str,
iteration: u32,
page_slug: &str,
body: &str,
replace: bool,
) -> Result<(), String> {
use crate::session::wiki;
let result = if replace {
wiki::replace_page(session_slug, page_slug, body).map(|_| "replace")
} else {
wiki::create_page(session_slug, page_slug, body).map(|_| "create")
};
match result {
Ok(mode) => {
let _ = log::append(
session_slug,
&SessionEvent::WikiPageWritten {
timestamp: Utc::now(),
iteration,
slug: page_slug.to_string(),
mode: mode.to_string(),
body_chars: body.chars().count() as u32,
note: None,
},
);
Ok(())
}
Err(wiki::WikiError::AlreadyExists(_)) => Err(format!(
"wiki_page_exists: '{page_slug}' exists — set replace:true or use append_wiki_page"
)),
Err(e) => Err(e.to_string()),
}
}
fn append_wiki_page(
session_slug: &str,
iteration: u32,
page_slug: &str,
body: &str,
) -> Result<(), String> {
use crate::session::wiki;
let stamp = Utc::now().format("%Y-%m-%d").to_string();
match wiki::append_page(session_slug, page_slug, body, &stamp) {
Ok(_) => {
let _ = log::append(
session_slug,
&SessionEvent::WikiPageWritten {
timestamp: Utc::now(),
iteration,
slug: page_slug.to_string(),
mode: "append".to_string(),
body_chars: body.chars().count() as u32,
note: None,
},
);
Ok(())
}
Err(e) => Err(e.to_string()),
}
}
fn digest_source(slug: &str, iteration: u32, url: &str, into_section: &str) -> Result<(), String> {
let events = log::read_all(slug).unwrap_or_default();
let known = events.iter().any(|e| {
matches!(
e,
SessionEvent::SourceAccepted { url: u, .. } if u == url
)
});
if !known {
return Err(format!(
"digest_source for '{url}' but that URL is not in source_accepted events"
));
}
let already = events.iter().any(|e| {
matches!(
e,
SessionEvent::SourceDigested { url: u, .. } if u == url
)
});
if already {
return Err(format!("source_already_digested: {url}"));
}
log::append(
slug,
&SessionEvent::SourceDigested {
timestamp: Utc::now(),
iteration,
url: url.to_string(),
into_section: into_section.to_string(),
note: None,
},
)
.map_err(|e| format!("append SourceDigested: {e}"))
}
struct FactCheckInput<'a> {
slug: &'a str,
iteration: u32,
claim: &'a str,
query: &'a str,
sources: &'a [String],
outcome: FactCheckOutcome,
into_section: &'a str,
note: Option<&'a str>,
}
fn fact_check(input: FactCheckInput<'_>) -> Result<(), String> {
let FactCheckInput {
slug,
iteration,
claim,
query,
sources,
outcome,
into_section,
note,
} = input;
if claim.trim().is_empty() || query.trim().is_empty() || sources.is_empty() {
return Err("fact_check_invalid: claim, query, and sources must be non-empty".into());
}
let events = log::read_all(slug).unwrap_or_default();
let accepted: HashSet<String> = events
.iter()
.filter_map(|e| match e {
SessionEvent::SourceAccepted { url, .. } => Some(url.clone()),
_ => None,
})
.collect();
if let Some(missing) = sources.iter().find(|url| !accepted.contains(*url)) {
return Err(format!("fact_check_unknown_source: {missing}"));
}
let digested: HashSet<String> = events
.iter()
.filter_map(|e| match e {
SessionEvent::SourceDigested { url, .. } => Some(url.clone()),
_ => None,
})
.collect();
if let Some(undigested) = sources.iter().find(|url| !digested.contains(*url)) {
return Err(format!("fact_check_undigested_source: {undigested}"));
}
log::append(
slug,
&SessionEvent::FactChecked {
timestamp: Utc::now(),
iteration,
claim: claim.trim().to_string(),
query: query.trim().to_string(),
sources: sources.to_vec(),
outcome,
into_section: into_section.to_string(),
note: note.map(str::to_string),
},
)
.map_err(|e| format!("append FactChecked: {e}"))
}
fn run_add(research_bin: &Path, slug: &str, url: &str) -> Result<(), String> {
let out = Command::new(research_bin)
.args(["add", url, "--slug", slug, "--json"])
.output()
.map_err(|e| format!("spawn research add: {e}"))?;
if out.status.success() {
Ok(())
} else {
Err(format!(
"research add exit {}: {}",
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stderr)
.lines()
.next()
.unwrap_or("")
))
}
}
fn run_batch(
research_bin: &Path,
slug: &str,
urls: &[String],
concurrency: Option<usize>,
) -> Result<(), String> {
let mut args: Vec<String> = vec!["batch".into()];
for u in urls {
args.push(u.clone());
}
args.extend(["--slug".into(), slug.into(), "--json".into()]);
if let Some(c) = concurrency {
args.extend(["--concurrency".into(), c.to_string()]);
}
let out = Command::new(research_bin)
.args(&args)
.output()
.map_err(|e| format!("spawn research batch: {e}"))?;
if out.status.success() {
Ok(())
} else {
Err(format!(
"research batch exit {}: {}",
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stderr)
.lines()
.next()
.unwrap_or("")
))
}
}
fn write_section(slug: &str, heading: &str, body: &str) -> Result<(), String> {
let path = layout::session_md(slug);
let md = std::fs::read_to_string(&path).map_err(|e| format!("read session.md: {e}"))?;
let body = preserve_diagram_refs(&md, heading, body);
let new_md = replace_or_insert_section(&md, heading, &body);
std::fs::write(&path, new_md).map_err(|e| format!("write session.md: {e}"))
}
fn preserve_diagram_refs(md: &str, heading: &str, new_body: &str) -> String {
let Some(old_body) = extract_section_body(md, heading) else {
return new_body.to_string();
};
let old_refs = crate::commands::coverage::diagram_refs_with_alt(&old_body);
if old_refs.is_empty() {
return new_body.to_string();
}
let mut out = new_body.to_string();
let mut appended: Vec<String> = Vec::new();
for (path, alt) in old_refs {
let marker = format!("diagrams/{path}");
if out.contains(&marker) {
continue;
}
let line = if alt.is_empty() {
format!("")
} else {
format!("")
};
appended.push(line);
}
if !appended.is_empty() {
if !out.ends_with('\n') {
out.push('\n');
}
out.push('\n');
for line in appended {
out.push_str(&line);
out.push_str("\n\n");
}
}
out
}
fn extract_section_body(md: &str, heading: &str) -> Option<String> {
let needle = format!("{heading}\n");
let start = md.find(&needle)?;
let body_start = start + needle.len();
let tail = &md[body_start..];
let body_end = tail
.find("\n## ")
.map(|i| body_start + i + 1)
.unwrap_or(md.len());
Some(md[body_start..body_end].to_string())
}
fn write_diagram(
slug: &str,
iteration: u32,
path: &str,
_alt: &str,
svg: &str,
) -> Result<(), String> {
let reject = |reason: &str| {
let _ = log::append(
slug,
&SessionEvent::DiagramRejected {
timestamp: Utc::now(),
iteration,
path: path.to_string(),
reason: reason.to_string(),
note: None,
},
);
};
if path.is_empty()
|| path.contains("..")
|| path.contains('/')
|| path.contains('\\')
|| path.starts_with('.')
{
let reason = "path_escapes_diagrams_dir";
reject(reason);
return Err(format!("svg_path_rejected: {reason} (path={path})"));
}
if !path.to_lowercase().ends_with(".svg") {
let reason = "path_not_svg";
reject(reason);
return Err(format!("svg_path_rejected: {reason} (path={path})"));
}
if let Err(rej) = svg_safety::validate(svg) {
let reason = rej.to_string();
reject(&reason);
return Err(format!("svg_schema_violation: {reason} (path={path})"));
}
let diagrams_dir = layout::session_dir(slug).join("diagrams");
std::fs::create_dir_all(&diagrams_dir).map_err(|e| format!("mkdir diagrams: {e}"))?;
let target = diagrams_dir.join(path);
std::fs::write(&target, svg).map_err(|e| format!("write svg: {e}"))?;
log::append(
slug,
&SessionEvent::DiagramAuthored {
timestamp: Utc::now(),
iteration,
path: path.to_string(),
bytes: svg.len() as u32,
note: None,
},
)
.map_err(|e| format!("append DiagramAuthored: {e}"))
}
fn write_plan(slug: &str, iteration: u32, body: &str) -> Result<(), String> {
let path = layout::session_md(slug);
let md = std::fs::read_to_string(&path).map_err(|e| format!("read session.md: {e}"))?;
let new_md = if session_md_has_plan(&md) {
replace_or_insert_section(&md, "## Plan", body)
} else if let Some(overview_end) = find_overview_body_end(&md) {
let mut out = String::with_capacity(md.len() + body.len() + 16);
out.push_str(&md[..overview_end]);
if !md[..overview_end].ends_with("\n\n") {
out.push('\n');
}
out.push_str("## Plan\n");
out.push_str(body);
if !body.ends_with('\n') {
out.push('\n');
}
out.push('\n');
out.push_str(&md[overview_end..]);
out
} else {
replace_or_insert_section(&md, "## Plan", body)
};
std::fs::write(&path, new_md).map_err(|e| format!("write session.md: {e}"))?;
log::append(
slug,
&SessionEvent::PlanWritten {
timestamp: Utc::now(),
iteration,
body_chars: body.chars().count() as u32,
note: None,
},
)
.map_err(|e| format!("append PlanWritten: {e}"))
}
fn session_has_plan(slug: &str) -> bool {
let md = std::fs::read_to_string(layout::session_md(slug)).unwrap_or_default();
session_md_has_plan(&md)
}
fn session_md_has_plan(md: &str) -> bool {
md.lines().any(|l| l.trim() == "## Plan")
}
fn read_plan_body(slug: &str) -> Option<String> {
let md = std::fs::read_to_string(layout::session_md(slug)).ok()?;
let marker = "## Plan\n";
let start = md.find(marker)?;
let body_start = start + marker.len();
let tail = &md[body_start..];
let end = tail
.find("\n## ")
.map(|i| body_start + i + 1)
.unwrap_or(md.len());
Some(md[body_start..end].trim_end().to_string())
}
fn replace_or_insert_section(md: &str, heading: &str, body: &str) -> String {
let needle = format!("{heading}\n");
if let Some(start) = md.find(&needle) {
let body_start = start + needle.len();
let tail = &md[body_start..];
let body_end = tail
.find("\n## ")
.map(|i| body_start + i + 1) .unwrap_or(md.len());
let mut out = String::with_capacity(md.len() + body.len());
out.push_str(&md[..body_start]);
out.push_str(body);
if !body.ends_with('\n') {
out.push('\n');
}
out.push('\n');
out.push_str(&md[body_end..]);
out
} else {
let mut out = md.to_string();
if !out.ends_with("\n\n") {
if !out.ends_with('\n') {
out.push('\n');
}
out.push('\n');
}
out.push_str(heading);
out.push('\n');
out.push_str(body);
if !body.ends_with('\n') {
out.push('\n');
}
out
}
}
fn write_aside(slug: &str, body: &str) -> Result<(), String> {
let path = layout::session_md(slug);
let md = std::fs::read_to_string(&path).map_err(|e| format!("read session.md: {e}"))?;
let aside_line = format!("> **aside:** {body}");
let new_md = if let Some(existing) = find_aside(&md) {
replace_range(&md, existing, &aside_line)
} else if let Some(overview_end) = find_overview_body_end(&md) {
let mut out = String::with_capacity(md.len() + aside_line.len() + 4);
out.push_str(&md[..overview_end]);
if !md[..overview_end].ends_with("\n\n") {
out.push('\n');
}
out.push_str(&aside_line);
out.push_str("\n\n");
out.push_str(&md[overview_end..]);
out
} else {
let mut out = md.clone();
if !out.ends_with('\n') {
out.push('\n');
}
out.push('\n');
out.push_str(&aside_line);
out.push('\n');
out
};
std::fs::write(&path, new_md).map_err(|e| format!("write session.md: {e}"))
}
fn find_aside(md: &str) -> Option<std::ops::Range<usize>> {
let marker = "> **aside:**";
let start = md.find(marker)?;
let line_end = md[start..]
.find('\n')
.map(|i| start + i)
.unwrap_or(md.len());
Some(start..line_end)
}
fn find_overview_body_end(md: &str) -> Option<usize> {
let h = md.find("## Overview\n")?;
let body_start = h + "## Overview\n".len();
let next = md[body_start..]
.find("\n## ")
.map(|i| body_start + i + 1)
.unwrap_or(md.len());
Some(next)
}
fn replace_range(s: &str, r: std::ops::Range<usize>, replacement: &str) -> String {
let mut out = String::with_capacity(s.len() + replacement.len());
out.push_str(&s[..r.start]);
out.push_str(replacement);
out.push_str(&s[r.end..]);
out
}
fn append_diagram_todo(slug: &str, name: &str, hint: &str) -> Result<(), String> {
let path = layout::session_md(slug);
let md = std::fs::read_to_string(&path).map_err(|e| format!("read session.md: {e}"))?;
let todo = format!("\n<!-- research-loop: diagram needed — {name} — {hint} -->\n");
let mut new_md = md.clone();
if !new_md.ends_with('\n') {
new_md.push('\n');
}
new_md.push_str(&todo);
std::fs::write(&path, new_md).map_err(|e| format!("write session.md: {e}"))
}
fn append_step(
slug: &str,
iteration: u32,
reasoning: &str,
requested: u32,
executed: u32,
rejected: u32,
duration_ms: u64,
) {
let _ = log::append(
slug,
&SessionEvent::LoopStep {
timestamp: Utc::now(),
iteration,
reasoning: reasoning.to_string(),
actions_requested: requested,
actions_executed: executed,
actions_rejected: rejected,
duration_ms,
note: None,
},
);
}
struct ActionbookOutcome {
action_type: &'static str,
result_payload: Option<Value>,
cap_exceeded: bool,
fail_soft: bool,
error_code: Option<String>,
}
fn handle_actionbook_action(
action: &Action,
slug: &str,
iter: u32,
dry_run: bool,
search_count: &mut u32,
manual_count: &mut u32,
runcode_count: &mut u32,
) -> Option<ActionbookOutcome> {
match action {
Action::ActionbookSearch { query, host } => {
*search_count += 1;
Some(dispatch_actionbook_search(
slug,
iter,
dry_run,
query,
host.as_deref(),
*search_count,
))
}
Action::ActionbookManual { site, group, action } => {
*manual_count += 1;
Some(dispatch_actionbook_manual(
slug,
iter,
dry_run,
site,
group.as_deref(),
action.as_deref(),
*manual_count,
))
}
Action::ActionbookRunCode { url, script, timeout_ms } => {
*runcode_count += 1;
Some(dispatch_actionbook_runcode(
slug,
iter,
dry_run,
url,
script,
*timeout_ms,
*runcode_count,
))
}
_ => None,
}
}
fn dispatch_actionbook_search(
slug: &str,
iter: u32,
dry_run: bool,
query: &str,
host: Option<&str>,
invocation_count: u32,
) -> ActionbookOutcome {
let action_type = "actionbook_search";
let cmd = build_search_cmd(query, host);
let cmd_summary = cmd.trim_start_matches("actionbook ").to_string();
if invocation_count > MAX_ACTIONBOOK_SEARCH_PER_ITER {
return cap_exceeded_outcome(slug, iter, action_type, &cmd_summary);
}
if dry_run {
return dry_run_outcome(slug, iter, action_type, &cmd_summary);
}
if let Some(err) = preflight_actionbook() {
return fail_soft_outcome(slug, iter, action_type, &cmd_summary, &err.code, &err.message);
}
match browser_v2::call_actionbook_tool(&cmd, slug, ACTIONBOOK_OUTER_TIMEOUT_MS) {
Ok(text) => {
let hits = extract_text_payload(&text);
let trimmed = hits.trim();
if trimmed.is_empty() || trimmed == "[]" {
return fail_soft_outcome(
slug,
iter,
action_type,
&cmd_summary,
"search_zero_hits",
"catalog search returned 0 hits",
);
}
let (truncated_body, truncated) = truncate_with_marker(&hits, ACTIONBOOK_SEARCH_BUDGET_BYTES);
let payload = json!({
"action_type": action_type,
"ok": true,
"cmd_summary": cmd_summary,
"hits": truncated_body,
"truncated": truncated,
});
log_actionbook_called(
slug,
iter,
action_type,
&cmd_summary,
"ok",
truncated_body.len() as u64,
truncated,
Vec::new(),
None,
);
ActionbookOutcome {
action_type,
result_payload: Some(payload),
cap_exceeded: false,
fail_soft: false,
error_code: None,
}
}
Err(e) => {
let code = classify_mcp_error(&e, "search");
fail_soft_outcome(slug, iter, action_type, &cmd_summary, &code, &e)
}
}
}
fn dispatch_actionbook_manual(
slug: &str,
iter: u32,
dry_run: bool,
site: &str,
group: Option<&str>,
action_name: Option<&str>,
invocation_count: u32,
) -> ActionbookOutcome {
let action_type = "actionbook_manual";
let cmd = build_manual_cmd(site, group, action_name);
let cmd_summary = cmd.trim_start_matches("actionbook ").to_string();
if invocation_count > MAX_ACTIONBOOK_MANUAL_PER_ITER {
return cap_exceeded_outcome(slug, iter, action_type, &cmd_summary);
}
if dry_run {
return dry_run_outcome(slug, iter, action_type, &cmd_summary);
}
if let Some(err) = preflight_actionbook() {
return fail_soft_outcome(slug, iter, action_type, &cmd_summary, &err.code, &err.message);
}
match browser_v2::call_actionbook_tool(&cmd, slug, ACTIONBOOK_OUTER_TIMEOUT_MS) {
Ok(text) => {
let body = strip_mcp_envelope(&text);
if body.trim().is_empty() {
return fail_soft_outcome(
slug,
iter,
action_type,
&cmd_summary,
"manual_not_found",
"catalog manual returned empty body",
);
}
let (truncated_body, truncated) =
truncate_with_marker(&body, ACTIONBOOK_MANUAL_BUDGET_BYTES);
let host = site.replace('_', ".");
let wiki_dir = layout::session_wiki_dir(slug);
let seeded_pages: Vec<String> = match catalog::seed_explicit(
slug,
&wiki_dir,
&host,
site,
group,
action_name,
&body,
catalog::SeedOpts::default(),
) {
Some(seeded) => vec![seeded.page_slug],
None => Vec::new(),
};
let payload = json!({
"action_type": action_type,
"ok": true,
"cmd_summary": cmd_summary,
"site": site,
"group": group,
"action": action_name,
"body": truncated_body,
"truncated": truncated,
});
log_actionbook_called(
slug,
iter,
action_type,
&cmd_summary,
"ok",
truncated_body.len() as u64,
truncated,
seeded_pages,
None,
);
ActionbookOutcome {
action_type,
result_payload: Some(payload),
cap_exceeded: false,
fail_soft: false,
error_code: None,
}
}
Err(e) => {
let code = classify_mcp_error(&e, "manual");
fail_soft_outcome(slug, iter, action_type, &cmd_summary, &code, &e)
}
}
}
fn dispatch_actionbook_runcode(
slug: &str,
iter: u32,
dry_run: bool,
url: &str,
script: &str,
timeout_ms: Option<u64>,
invocation_count: u32,
) -> ActionbookOutcome {
let action_type = "actionbook_run_code";
let clamped = clamp_runcode_timeout(timeout_ms);
let handle = browser_v2::handle_for(slug, iter);
let cmd_summary = format!(
"run-code url={url} timeout={clamped} script_len={}",
script.chars().count()
);
if invocation_count > MAX_ACTIONBOOK_RUNCODE_PER_ITER {
return cap_exceeded_outcome(slug, iter, action_type, &cmd_summary);
}
if dry_run {
return dry_run_outcome(slug, iter, action_type, &cmd_summary);
}
if let Some(err) = preflight_actionbook() {
return fail_soft_outcome(slug, iter, action_type, &cmd_summary, &err.code, &err.message);
}
let goto_cmd = browser_v2::build_new_tab_cmd(url, &handle);
let run_cmd = build_user_runcode_cmd(&handle, script, clamped);
let close_cmd = browser_v2::build_close_cmd(&handle);
if let Err(e) = browser_v2::call_actionbook_tool(&goto_cmd, slug, ACTIONBOOK_OUTER_TIMEOUT_MS) {
let code = classify_mcp_error(&e, "run_code");
return fail_soft_outcome(slug, iter, action_type, &cmd_summary, &code, &e);
}
let run_text = match browser_v2::call_actionbook_tool(
&run_cmd,
slug,
clamped + 5_000,
) {
Ok(t) => t,
Err(e) => {
let _ = browser_v2::call_actionbook_tool(&close_cmd, slug, ACTIONBOOK_OUTER_TIMEOUT_MS);
let code = classify_mcp_error(&e, "run_code");
return fail_soft_outcome(slug, iter, action_type, &cmd_summary, &code, &e);
}
};
let _ = browser_v2::call_actionbook_tool(&close_cmd, slug, ACTIONBOOK_OUTER_TIMEOUT_MS);
let payload_text = strip_mcp_envelope(&run_text);
let parsed: Value = serde_json::from_str(payload_text.trim())
.unwrap_or_else(|_| json!({ "text": payload_text }));
let text_field = parsed
.get("text")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let (truncated_text, text_truncated) =
truncate_with_marker(&text_field, ACTIONBOOK_RUNCODE_TEXT_BUDGET_BYTES);
let mut result_json = parsed.clone();
if let Some(obj) = result_json.as_object_mut() {
obj.remove("text");
}
let result_json_str = serde_json::to_string(&result_json).unwrap_or_else(|_| "{}".to_string());
let (truncated_result_json, _json_truncated) =
truncate_with_marker(&result_json_str, ACTIONBOOK_RUNCODE_RESULT_JSON_BUDGET_BYTES);
let result_url = parsed.get("url").and_then(Value::as_str).unwrap_or(url);
let result_title = parsed.get("title").and_then(Value::as_str).unwrap_or("");
let payload = json!({
"action_type": action_type,
"ok": true,
"cmd_summary": cmd_summary,
"url": result_url,
"title": result_title,
"text": truncated_text,
"result_json": truncated_result_json,
"truncated": text_truncated,
});
log_actionbook_called(
slug,
iter,
action_type,
&cmd_summary,
"ok",
truncated_text.len() as u64,
text_truncated,
Vec::new(),
None,
);
ActionbookOutcome {
action_type,
result_payload: Some(payload),
cap_exceeded: false,
fail_soft: false,
error_code: None,
}
}
struct PreflightError {
code: String,
message: String,
}
fn preflight_actionbook() -> Option<PreflightError> {
if std::env::var("ACTIONBOOK_BACKEND").as_deref() == Ok("v1-cli") {
return Some(PreflightError {
code: "v1_backend_no_mcp".to_string(),
message: "actionbook backend is v1 cli (set ACTIONBOOK_BACKEND=v2-mcp or unset)"
.to_string(),
});
}
if !browser_v2::is_api_key_set() {
return Some(PreflightError {
code: "api_key_missing".to_string(),
message: "actionbook api key not set (set ACTIONBOOK_API_KEY=ak_...)".to_string(),
});
}
None
}
fn classify_mcp_error(raw: &str, op: &str) -> String {
let s = raw.to_ascii_uppercase();
if s.contains("EXTENSION_OFFLINE") {
"extension_offline".to_string()
} else if s.contains("SESSION_LOST") || s.contains("TAB_NOT_FOUND") {
"session_lost".to_string()
} else if s.contains("EVAL_FAILED") && op == "run_code" {
"runcode_eval_failed".to_string()
} else if s.contains("TIMEOUT") && op == "run_code" {
"runcode_timeout".to_string()
} else {
"mcp_transport_error".to_string()
}
}
fn cap_exceeded_outcome(
slug: &str,
iter: u32,
action_type: &'static str,
cmd_summary: &str,
) -> ActionbookOutcome {
let payload = json!({
"action_type": action_type,
"ok": false,
"error": "cap_exceeded",
"recoverable": true,
"cmd_summary": cmd_summary,
});
log_actionbook_called(
slug,
iter,
action_type,
cmd_summary,
"cap_exceeded",
0,
false,
Vec::new(),
Some("cap_exceeded".to_string()),
);
ActionbookOutcome {
action_type,
result_payload: Some(payload),
cap_exceeded: true,
fail_soft: false,
error_code: Some("cap_exceeded".to_string()),
}
}
fn dry_run_outcome(
slug: &str,
iter: u32,
action_type: &'static str,
cmd_summary: &str,
) -> ActionbookOutcome {
println!("[dry-run] {action_type} {cmd_summary}");
log_actionbook_called(
slug,
iter,
action_type,
cmd_summary,
"dry_run",
0,
false,
Vec::new(),
None,
);
ActionbookOutcome {
action_type,
result_payload: None,
cap_exceeded: false,
fail_soft: false,
error_code: None,
}
}
fn fail_soft_outcome(
slug: &str,
iter: u32,
action_type: &'static str,
cmd_summary: &str,
error_code: &str,
message: &str,
) -> ActionbookOutcome {
let friendly = friendly_error_message(error_code, message);
let payload = json!({
"action_type": action_type,
"ok": false,
"error": friendly,
"error_code": error_code,
"recoverable": true,
"cmd_summary": cmd_summary,
});
log_actionbook_called(
slug,
iter,
action_type,
cmd_summary,
"fail_soft",
0,
false,
Vec::new(),
Some(error_code.to_string()),
);
ActionbookOutcome {
action_type,
result_payload: Some(payload),
cap_exceeded: false,
fail_soft: true,
error_code: Some(error_code.to_string()),
}
}
fn friendly_error_message(error_code: &str, fallback: &str) -> String {
match error_code {
"extension_offline" => "chrome extension offline".to_string(),
"api_key_missing" => "actionbook api key not set".to_string(),
"v1_backend_no_mcp" => "actionbook backend is v1 cli (no MCP)".to_string(),
"search_zero_hits" => "catalog search returned 0 hits".to_string(),
"manual_not_found" => "catalog manual not found for that site/group/action".to_string(),
"runcode_eval_failed" => "run-code script evaluation failed in the page".to_string(),
"runcode_timeout" => "run-code script timed out".to_string(),
"session_lost" => "actionbook session lost; retry next iteration".to_string(),
"mcp_transport_error" => format!("mcp transport error: {}", fallback.to_ascii_lowercase()),
_ => fallback.to_string(),
}
}
#[allow(clippy::too_many_arguments)]
fn log_actionbook_called(
slug: &str,
iter: u32,
action_type: &str,
cmd_summary: &str,
outcome: &str,
result_bytes: u64,
result_truncated: bool,
wiki_seeded_pages: Vec<String>,
error_code: Option<String>,
) {
let _ = log::append(
slug,
&SessionEvent::ActionbookCalled {
timestamp: Utc::now(),
iteration: iter,
action_type: action_type.to_string(),
cmd_summary: cmd_summary.to_string(),
outcome: outcome.to_string(),
result_bytes,
result_truncated,
wiki_seeded_pages,
error_code,
note: None,
},
);
}
fn build_search_cmd(query: &str, host: Option<&str>) -> String {
let mut s = format!("actionbook search \"{query}\"");
if let Some(h) = host {
s.push_str(&format!(" --host {h}"));
}
s
}
fn build_manual_cmd(site: &str, group: Option<&str>, action: Option<&str>) -> String {
let mut s = format!("actionbook manual {site}");
if let Some(g) = group {
s.push(' ');
s.push_str(g);
}
if let Some(a) = action {
s.push(' ');
s.push_str(a);
}
s
}
pub fn clamp_runcode_timeout(requested: Option<u64>) -> u64 {
let raw = requested.unwrap_or(ACTIONBOOK_RUNCODE_TIMEOUT_DEFAULT_MS);
raw.clamp(
ACTIONBOOK_RUNCODE_TIMEOUT_MIN_MS,
ACTIONBOOK_RUNCODE_TIMEOUT_MAX_MS,
)
}
pub fn build_user_runcode_cmd(handle: &str, script: &str, timeout_ms: u64) -> String {
let escaped = script.replace('\'', "\\'");
format!("browser run-code --tab {handle} --timeout {timeout_ms} '{escaped}'")
}
fn truncate_with_marker(s: &str, max: usize) -> (String, bool) {
if s.len() <= max {
return (s.to_string(), false);
}
let kb = max / 1024;
let marker = format!("\n\n[…truncated to {kb}KB…]");
let budget = max.saturating_sub(marker.len());
let mut end = budget.min(s.len());
while !s.is_char_boundary(end) && end > 0 {
end -= 1;
}
let mut out = String::with_capacity(max);
out.push_str(&s[..end]);
out.push_str(&marker);
(out, true)
}
fn strip_mcp_envelope(text: &str) -> String {
let lines: Vec<&str> = text.lines().collect();
if lines.len() >= 3
&& lines[0].starts_with('[')
&& lines[0].ends_with(']')
&& (lines[1].starts_with("ok ") || lines[1].starts_with("error "))
{
return lines[2..].join("\n");
}
text.to_string()
}
fn extract_text_payload(text: &str) -> String {
strip_mcp_envelope(text)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_response_accepts_raw_json() {
let s = r#"{"reasoning":"x","actions":[],"done":false}"#;
let r = parse_response(s).unwrap();
assert_eq!(r.reasoning, "x");
}
#[test]
fn parse_response_strips_json_code_fence() {
let s = "```json\n{\"reasoning\":\"x\",\"actions\":[],\"done\":false}\n```";
let r = parse_response(s).unwrap();
assert_eq!(r.reasoning, "x");
}
#[test]
fn parse_response_strips_plain_code_fence() {
let s = "```\n{\"reasoning\":\"y\",\"actions\":[],\"done\":false}\n```";
let r = parse_response(s).unwrap();
assert_eq!(r.reasoning, "y");
}
#[test]
fn parse_response_rejects_prose_before_json() {
let s = "Here's my answer: {\"reasoning\":\"x\",\"actions\":[],\"done\":false}";
assert!(parse_response(s).is_err());
}
#[test]
fn coverage_signature_is_stable_for_same_numbers() {
let a = json!({
"overview_chars": 100,
"numbered_sections_count": 3,
"aside_count": 1,
"diagrams_referenced": 0,
"diagrams_resolved": 0,
"sources_accepted": 5,
"sources_referenced_in_body": 3,
"sources_unused": 2,
"sources_hallucinated": 0,
"report_ready": false,
});
let b = a.clone();
assert_eq!(coverage_signature(&a), coverage_signature(&b));
}
#[test]
fn coverage_signature_differs_when_any_field_changes() {
let a = json!({"overview_chars": 100, "numbered_sections_count": 3});
let b = json!({"overview_chars": 200, "numbered_sections_count": 3});
assert_ne!(coverage_signature(&a), coverage_signature(&b));
}
#[test]
fn coverage_signature_tracks_wiki_total_bytes_for_append_progress() {
let a = json!({"wiki_pages": 14, "wiki_total_bytes": 40000});
let b = json!({"wiki_pages": 14, "wiki_total_bytes": 42500});
assert_ne!(coverage_signature(&a), coverage_signature(&b));
}
#[test]
fn coverage_signature_tracks_wiki_pages_so_wiki_writes_count_as_progress() {
let a = json!({"wiki_pages": 1, "overview_chars": 0, "numbered_sections_count": 0});
let b = json!({"wiki_pages": 2, "overview_chars": 0, "numbered_sections_count": 0});
assert_ne!(coverage_signature(&a), coverage_signature(&b));
}
#[test]
fn replace_or_insert_section_replaces_existing() {
let md = "# X\n\n## Overview\nold body\n\n## 01 · WHY\nbody\n";
let out = replace_or_insert_section(md, "## Overview", "new body");
assert!(out.contains("new body"));
assert!(!out.contains("old body"));
assert!(out.contains("## 01 · WHY"));
}
#[test]
fn replace_or_insert_section_inserts_when_missing() {
let md = "# X\n\n## Overview\nbody\n";
let out = replace_or_insert_section(md, "## 01 · NEW", "fresh body");
assert!(out.contains("## 01 · NEW"));
assert!(out.contains("fresh body"));
}
#[test]
fn termination_reason_str() {
assert_eq!(TerminationReason::ReportReady.as_str(), "report_ready");
assert_eq!(TerminationReason::Diverged.as_str(), "diverged");
}
#[test]
fn base_system_prompt_includes_grounding_guardrail() {
let prompt = base_system_prompt();
assert!(
prompt.contains("specific person, team, date, or number"),
"prompt must forbid unsupported concrete facts, got:\n{prompt}"
);
assert!(
prompt.contains("digested source"),
"prompt must anchor concrete facts to digested sources, got:\n{prompt}"
);
}
#[test]
fn sports_system_prompt_includes_roster_source_guidance() {
let prompt = system_prompt_from_context(None, Some("sports"), true);
assert!(prompt.contains("https://www.nba.com/<team>/roster"));
assert!(prompt.contains("https://www.basketball-reference.com/teams/<TEAM>/<YEAR>.html"));
assert!(prompt.contains("https://www.espn.com/nba/team/roster/_/name/<abbr>/<team>"));
assert!(prompt.contains("fact_check"));
assert!(prompt.contains("accepted + digested source"));
}
#[test]
fn tech_system_prompt_omits_sports_roster_guidance() {
let prompt = system_prompt_from_context(None, Some("tech"), false);
assert!(prompt.contains("github.com/{owner}/{repo}"));
assert!(prompt.contains("arxiv.org/abs/{id}"));
assert!(!prompt.contains("https://www.nba.com/<team>/roster"));
}
#[test]
fn system_prompt_reads_sports_session_config() {
let prompt = system_prompt_from_context(
Some("Prefer official sources from SCHEMA.md".to_string()),
Some("sports"),
true,
);
assert!(prompt.contains("You drive a research CLI"));
assert!(prompt.contains("https://www.nba.com/<team>/roster"));
assert!(prompt.contains("Session-specific schema guidance"));
assert!(prompt.contains("Prefer official sources from SCHEMA.md"));
}
#[test]
fn system_prompt_missing_config_falls_back_to_base() {
let prompt = system_prompt("__missing_session_for_prompt_fallback__");
assert!(prompt.contains("GROUNDING CONTRACT"));
assert!(prompt.contains("specific person, team, date, or number"));
assert!(!prompt.contains("https://www.nba.com/<team>/roster"));
}
#[test]
fn preserve_diagram_refs_keeps_existing_figure_when_overwrite_omits_it() {
let md = r"# X
## 01 · WHY
Prose explaining the scheduler.

More prose.
## 02 · HOW
body
";
let new_body =
"Rewritten prose, only the new figure.\n\n\n";
let out = preserve_diagram_refs(md, "## 01 · WHY", new_body);
assert!(out.contains("Rewritten prose"));
assert!(out.contains("task-lifecycle.svg"));
assert!(
out.contains("scheduler-flow.svg"),
"preserve_diagram_refs must retain the original figure, got:\n{out}"
);
}
#[test]
fn preserve_diagram_refs_is_idempotent_when_refs_match() {
let md = "## 01 · WHY\n\n\n## 02 · NEXT\n";
let new_body = "New prose\n\n\n";
let out = preserve_diagram_refs(md, "## 01 · WHY", new_body);
assert_eq!(out.matches("diagrams/x.svg").count(), 1);
}
#[test]
fn preserve_diagram_refs_noop_when_heading_absent() {
let md = "## Overview\nbody\n";
let out = preserve_diagram_refs(md, "## 01 · NEW", "fresh");
assert_eq!(out, "fresh");
}
}