Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -0,0 +1,71 @@
[package]
name = "nomi-agent"
description = "Agent engine, session management, output sinks, and orchestration for Nomi"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[features]
# Desktop control (screenshots, synthetic input). Gated because xcap/enigo
# pull native display-server libraries that headless hosts must not require.
computer-use = ["dep:nomi-computer"]
# Browser automation (in-process self-hosted CDP engine). Gated because it pulls
# the chromiumoxide CDP stack + a managed/connected Chromium; headless hosts that
# never drive a browser must not require it.
browser-use = ["dep:nomi-browser", "dep:base64"]
[dependencies]
nomi-types.workspace = true
nomi-protocol.workspace = true
nomi-config.workspace = true
nomi-providers.workspace = true
nomi-tools.workspace = true
nomi-mcp.workspace = true
nomi-skills.workspace = true
nomi-memory.workspace = true
nomi-compact.workspace = true
nomi-redact.workspace = true
nomi-computer = { workspace = true, optional = true }
nomi-browser = { workspace = true, optional = true }
# Used only by the browser-use SessionVisualLocator adapter (base64-encode the
# screenshot PNG into a ContentBlock::Image for the vision model). Optional →
# pulled in only with the browser-use feature.
base64 = { workspace = true, optional = true }
tracing.workspace = true
tokio.workspace = true
tokio-util.workspace = true
futures.workspace = true
serde.workspace = true
serde_json.workspace = true
async-trait.workspace = true
anyhow.workspace = true
thiserror.workspace = true
glob.workspace = true
chrono.workspace = true
dirs.workspace = true
crossterm.workspace = true
is-terminal.workspace = true
[dev-dependencies]
nomi-mcp = { path = "../nomi-mcp", features = ["test-utils"] }
wiremock.workspace = true
tokio-test.workspace = true
tempfile.workspace = true
mockall.workspace = true
rstest.workspace = true
serial_test.workspace = true
# ── Integration test binaries ──────────────────────────────────────────────
[[test]]
name = "e2e"
path = "tests/e2e/mod.rs"
# E2E tests require real API keys; run via: cargo nextest run --profile e2e --test e2e
[[test]]
name = "acceptance"
path = "tests/acceptance/mod.rs"
# Acceptance tests for evolution features; run via: cargo nextest run --profile e2e --test acceptance
@@ -0,0 +1,490 @@
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use nomi_config::config::app_config_dir;
use nomi_skills::paths::stop_boundary;
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
pub struct AgentsMdFile {
pub path: PathBuf,
pub content: String,
pub is_global: bool,
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const MAX_INCLUDE_DEPTH: u8 = 5;
const ALLOWED_EXTENSIONS: &[&str] = &[".md", ".txt", ".json", ".yaml", ".yml", ".toml"];
const INSTRUCTION_PREAMBLE: &str = "Codebase and user instructions are shown below. \
Be sure to adhere to these instructions. IMPORTANT: These instructions OVERRIDE any \
default behavior and you MUST follow them exactly as written.";
// ---------------------------------------------------------------------------
// Discovery
// ---------------------------------------------------------------------------
pub fn collect_agents_md(cwd: &str) -> Vec<AgentsMdFile> {
let cwd_path = Path::new(cwd);
let mut files = Vec::new();
// 1. Global: <config_dir>/nomi/AGENTS.md
if let Some(global_path) = app_config_dir().map(|d| d.join("AGENTS.md"))
&& let Some(file) = read_agents_md(&global_path, true)
{
files.push(file);
}
// 2. Walk up from cwd to stop_boundary, collect AGENTS.md paths
let boundary = stop_boundary(cwd_path);
let mut project_paths = Vec::new();
let mut current = cwd_path.to_path_buf();
loop {
let candidate = current.join("AGENTS.md");
if candidate.is_file() {
project_paths.push(candidate);
}
if Some(&current) == boundary.as_ref() || current.parent().is_none() {
break;
}
match current.parent() {
Some(parent) if parent != current.as_path() => {
current = parent.to_path_buf();
}
_ => break,
}
}
// Reverse: collected deepest-first, we want root-first
project_paths.reverse();
for path in project_paths {
if let Some(file) = read_agents_md(&path, false) {
files.push(file);
}
}
files
}
fn read_agents_md(path: &Path, is_global: bool) -> Option<AgentsMdFile> {
let raw = std::fs::read_to_string(path).ok()?;
if raw.trim().is_empty() {
return None;
}
let base_dir = path.parent()?;
let mut seen = HashSet::new();
if let Ok(canonical) = path.canonicalize() {
seen.insert(canonical);
}
let content = expand_includes(&raw, base_dir, 0, &mut seen);
Some(AgentsMdFile {
path: path.to_path_buf(),
content,
is_global,
})
}
// ---------------------------------------------------------------------------
// Formatting
// ---------------------------------------------------------------------------
pub fn format_agents_md_section(files: &[AgentsMdFile]) -> String {
if files.is_empty() {
return String::new();
}
let mut parts = vec![INSTRUCTION_PREAMBLE.to_string()];
for file in files {
let description = if file.is_global {
"(user's global instructions for all projects)"
} else {
"(project instructions)"
};
let header = format!("Contents of {} {}:", file.path.display(), description);
parts.push(format!("{header}\n\n{}", file.content.trim()));
}
parts.join("\n\n")
}
// ---------------------------------------------------------------------------
// @include expansion
// ---------------------------------------------------------------------------
fn is_allowed_extension(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.map(|e| {
let dotted = format!(".{e}");
ALLOWED_EXTENSIONS.contains(&dotted.as_str())
})
.unwrap_or(false)
}
fn resolve_include_path(raw: &str, base_dir: &Path) -> Option<PathBuf> {
let path_str = raw.trim();
if path_str.is_empty() {
return None;
}
let resolved = if let Some(rest) = path_str.strip_prefix("~/") {
dirs::home_dir()?.join(rest)
} else if let Some(rest) = path_str.strip_prefix("./") {
base_dir.join(rest)
} else if path_str.starts_with('/') {
PathBuf::from(path_str)
} else {
base_dir.join(path_str)
};
Some(resolved)
}
fn expand_includes(
content: &str,
base_dir: &Path,
depth: u8,
seen: &mut HashSet<PathBuf>,
) -> String {
let mut result = Vec::new();
let mut in_code_block = false;
for line in content.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("```") {
in_code_block = !in_code_block;
result.push(line.to_string());
continue;
}
if in_code_block {
result.push(line.to_string());
continue;
}
let standalone = line.trim();
if standalone.starts_with('@') && !standalone.contains('`') {
let path_str = &standalone[1..];
// Strip fragment identifiers
let path_str = match path_str.find('#') {
Some(i) => &path_str[..i],
None => path_str,
};
if let Some(resolved) = resolve_include_path(path_str, base_dir) {
if !is_allowed_extension(&resolved) {
continue;
}
let canonical = resolved.canonicalize().unwrap_or_else(|_| resolved.clone());
if seen.contains(&canonical) || depth >= MAX_INCLUDE_DEPTH {
continue;
}
if let Ok(included) = std::fs::read_to_string(&resolved) {
seen.insert(canonical);
let expanded = expand_includes(
&included,
resolved.parent().unwrap_or(base_dir),
depth + 1,
seen,
);
result.push(expanded);
}
continue;
}
}
result.push(line.to_string());
}
result.join("\n")
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
// --- @include expansion tests ---
#[test]
fn test_no_includes_passthrough() {
let tmp = TempDir::new().unwrap();
let mut seen = HashSet::new();
let input = "Hello world\nNo includes here.";
let result = expand_includes(input, tmp.path(), 0, &mut seen);
assert_eq!(result, input);
}
#[test]
fn test_simple_include() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("other.md"), "INCLUDED_CONTENT").unwrap();
let mut seen = HashSet::new();
let input = "@other.md";
let result = expand_includes(input, tmp.path(), 0, &mut seen);
assert!(result.contains("INCLUDED_CONTENT"));
assert!(!result.contains("@other.md"));
}
#[test]
fn test_include_relative_dot() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("sub.md"), "SUB_CONTENT").unwrap();
let mut seen = HashSet::new();
let input = "@./sub.md";
let result = expand_includes(input, tmp.path(), 0, &mut seen);
assert!(result.contains("SUB_CONTENT"));
}
#[test]
fn test_include_inside_code_block_ignored() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("skip.md"), "SHOULD_NOT_APPEAR").unwrap();
let mut seen = HashSet::new();
let input = "```\n@skip.md\n```";
let result = expand_includes(input, tmp.path(), 0, &mut seen);
assert!(!result.contains("SHOULD_NOT_APPEAR"));
assert!(result.contains("@skip.md"));
}
#[test]
fn test_include_missing_file_silently_skipped() {
let tmp = TempDir::new().unwrap();
let mut seen = HashSet::new();
let input = "before\n@nonexistent.md\nafter";
let result = expand_includes(input, tmp.path(), 0, &mut seen);
assert!(result.contains("before"));
assert!(result.contains("after"));
assert!(!result.contains("@nonexistent.md"));
}
#[test]
fn test_include_circular_reference() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("a.md"), "A_CONTENT\n@b.md").unwrap();
fs::write(tmp.path().join("b.md"), "B_CONTENT\n@a.md").unwrap();
let mut seen = HashSet::new();
let result = expand_includes("@a.md", tmp.path(), 0, &mut seen);
assert!(result.contains("A_CONTENT"));
assert!(result.contains("B_CONTENT"));
// @a.md in b.md should be skipped (circular)
}
#[test]
fn test_include_max_depth() {
let tmp = TempDir::new().unwrap();
// Chain: d0 → d1 → d2 → d3 → d4 → d5 → d6
// With MAX_INCLUDE_DEPTH=5, expansion from the outer call:
// outer(0) expands @d0 at depth 0 → d0 content expanded at depth 1
// depth 1 expands @d1 → d1 at depth 2 → ... → d3 at depth 4
// depth 4 expands @d4 → d4 at depth 5 → depth 5 >= MAX, @d5 NOT expanded
for i in 0..7 {
let content = if i < 6 {
format!("DEPTH_{i}\n@d{}.md", i + 1)
} else {
format!("DEPTH_{i}")
};
fs::write(tmp.path().join(format!("d{i}.md")), content).unwrap();
}
let mut seen = HashSet::new();
let result = expand_includes("@d0.md", tmp.path(), 0, &mut seen);
assert!(result.contains("DEPTH_0"));
assert!(result.contains("DEPTH_3"));
assert!(result.contains("DEPTH_4"));
// DEPTH_5 should NOT appear — depth limit reached
assert!(!result.contains("DEPTH_5"));
}
#[test]
fn test_include_disallowed_extension() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("image.png"), "BINARY_DATA").unwrap();
let mut seen = HashSet::new();
let input = "@image.png";
let result = expand_includes(input, tmp.path(), 0, &mut seen);
assert!(!result.contains("BINARY_DATA"));
}
#[test]
fn test_include_with_surrounding_text() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("inc.md"), "MIDDLE").unwrap();
let mut seen = HashSet::new();
let input = "TOP\n@inc.md\nBOTTOM";
let result = expand_includes(input, tmp.path(), 0, &mut seen);
assert_eq!(result, "TOP\nMIDDLE\nBOTTOM");
}
#[test]
fn test_is_allowed_extension() {
assert!(is_allowed_extension(Path::new("file.md")));
assert!(is_allowed_extension(Path::new("file.txt")));
assert!(is_allowed_extension(Path::new("file.yaml")));
assert!(is_allowed_extension(Path::new("file.yml")));
assert!(is_allowed_extension(Path::new("file.toml")));
assert!(is_allowed_extension(Path::new("file.json")));
assert!(!is_allowed_extension(Path::new("file.png")));
assert!(!is_allowed_extension(Path::new("file.rs")));
assert!(!is_allowed_extension(Path::new("file")));
}
#[test]
fn test_inline_code_span_not_expanded() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("x.md"), "SHOULD_NOT_APPEAR").unwrap();
let mut seen = HashSet::new();
let input = "Use `@x.md` for config";
let result = expand_includes(input, tmp.path(), 0, &mut seen);
assert!(!result.contains("SHOULD_NOT_APPEAR"));
}
#[test]
fn test_home_path_expansion() {
let tmp = TempDir::new().unwrap();
let mut seen = HashSet::new();
let input = "@~/nonexistent-test-file.md";
let result = expand_includes(input, tmp.path(), 0, &mut seen);
assert!(!result.contains("@~/"));
}
// --- Discovery tests ---
#[test]
fn test_collect_no_agents_md_anywhere() {
let tmp = TempDir::new().unwrap();
let cwd = tmp.path();
fs::create_dir(cwd.join(".git")).unwrap();
let files = collect_agents_md(&cwd.to_string_lossy());
assert!(files.is_empty());
}
#[test]
fn test_collect_cwd_only() {
let tmp = TempDir::new().unwrap();
let cwd = tmp.path();
fs::create_dir(cwd.join(".git")).unwrap();
fs::write(cwd.join("AGENTS.md"), "CWD_RULES").unwrap();
let files = collect_agents_md(&cwd.to_string_lossy());
assert_eq!(files.len(), 1);
assert!(files[0].content.contains("CWD_RULES"));
assert!(!files[0].is_global);
}
#[test]
fn test_collect_hierarchical_ordering() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir(root.join(".git")).unwrap();
fs::write(root.join("AGENTS.md"), "ROOT_RULES").unwrap();
let sub = root.join("packages").join("server");
fs::create_dir_all(&sub).unwrap();
fs::write(sub.join("AGENTS.md"), "SUB_RULES").unwrap();
let files = collect_agents_md(&sub.to_string_lossy());
assert_eq!(files.len(), 2);
assert!(files[0].content.contains("ROOT_RULES"));
assert!(files[1].content.contains("SUB_RULES"));
}
#[test]
fn test_collect_stops_at_git_root() {
let tmp = TempDir::new().unwrap();
let above_git = tmp.path();
fs::write(above_git.join("AGENTS.md"), "ABOVE_GIT_SHOULD_NOT_APPEAR").unwrap();
let repo = above_git.join("repo");
fs::create_dir_all(&repo).unwrap();
fs::create_dir(repo.join(".git")).unwrap();
fs::write(repo.join("AGENTS.md"), "REPO_RULES").unwrap();
let files = collect_agents_md(&repo.to_string_lossy());
assert_eq!(files.len(), 1);
assert!(files[0].content.contains("REPO_RULES"));
}
#[test]
fn test_collect_skips_empty_agents_md() {
let tmp = TempDir::new().unwrap();
let cwd = tmp.path();
fs::create_dir(cwd.join(".git")).unwrap();
fs::write(cwd.join("AGENTS.md"), " \n ").unwrap();
let files = collect_agents_md(&cwd.to_string_lossy());
assert!(files.is_empty());
}
#[test]
fn test_collect_with_include_expanded() {
let tmp = TempDir::new().unwrap();
let cwd = tmp.path();
fs::create_dir(cwd.join(".git")).unwrap();
fs::write(cwd.join("AGENTS.md"), "@rules.md").unwrap();
fs::write(cwd.join("rules.md"), "INCLUDED_RULES").unwrap();
let files = collect_agents_md(&cwd.to_string_lossy());
assert_eq!(files.len(), 1);
assert!(files[0].content.contains("INCLUDED_RULES"));
}
// --- Formatting tests ---
#[test]
fn test_format_empty() {
let files: Vec<AgentsMdFile> = vec![];
let result = format_agents_md_section(&files);
assert!(result.is_empty());
}
#[test]
fn test_format_single_project() {
let files = vec![AgentsMdFile {
path: PathBuf::from("/workspace/AGENTS.md"),
content: "My rules".to_string(),
is_global: false,
}];
let result = format_agents_md_section(&files);
assert!(result.contains("Be sure to adhere to these instructions"));
assert!(result.contains("Contents of /workspace/AGENTS.md (project instructions):"));
assert!(result.contains("My rules"));
}
#[test]
fn test_format_global_and_project() {
let files = vec![
AgentsMdFile {
path: PathBuf::from("/home/user/.config/nomi/AGENTS.md"),
content: "Global rules".to_string(),
is_global: true,
},
AgentsMdFile {
path: PathBuf::from("/workspace/AGENTS.md"),
content: "Project rules".to_string(),
is_global: false,
},
];
let result = format_agents_md_section(&files);
let global_pos = result.find("Global rules").unwrap();
let project_pos = result.find("Project rules").unwrap();
assert!(global_pos < project_pos, "global before project");
assert!(result.contains("(user's global instructions for all projects)"));
assert!(result.contains("(project instructions)"));
}
}
@@ -0,0 +1,921 @@
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use nomi_config::config::Config;
use nomi_mcp::manager::McpManager;
use nomi_providers::LlmProvider;
use crate::engine::AgentEngine;
use crate::output::OutputSink;
use crate::session::Session;
/// **extract-llm: session-model adapter for `BrowserTool`'s extract seam.**
///
/// Wraps the session's [`LlmProvider`] + model params so `act(Extract)` can do real
/// LLM-driven structured extraction. The browser engine itself stays LLM-free — this
/// adapter lives at the bootstrap/facade layer (架构铁律). `complete(prompt)` issues a
/// minimal one-shot request — the (already spotlighted, untrusted-data-wrapped) extract
/// prompt as a single user message, no tools, no system prompt, no extended thinking
/// (extraction is mechanical) — and collects the streamed text deltas into the full
/// completion. Reuses the session's own model (user decision: "extract 用会话模型"), so
/// there is no separate model/cost surface. `None` provider → seam stays unwired (the
/// facade returns its deterministic `<data>` payload = zero-regression graceful degrade).
#[cfg(feature = "browser-use")]
struct SessionExtractModel {
provider: Arc<dyn LlmProvider>,
model: String,
max_tokens: u32,
}
#[cfg(feature = "browser-use")]
#[async_trait::async_trait]
impl nomi_browser::extract::ExtractModel for SessionExtractModel {
async fn complete(&self, prompt: &str) -> Result<String, String> {
use nomi_types::llm::{LlmEvent, LlmRequest};
use nomi_types::message::{ContentBlock, Message, Role};
let request = LlmRequest {
model: self.model.clone(),
system: String::new(),
messages: vec![Message::new(
Role::User,
vec![ContentBlock::Text { text: prompt.to_string() }],
)],
tools: Vec::new(),
max_tokens: self.max_tokens,
thinking: None,
reasoning_effort: None,
};
let mut rx = self
.provider
.stream(&request)
.await
.map_err(|e| format!("extract model stream failed: {e}"))?;
let mut out = String::new();
while let Some(event) = rx.recv().await {
match event {
LlmEvent::TextDelta(t) => out.push_str(&t),
LlmEvent::Done { .. } => break,
LlmEvent::Error(e) => return Err(format!("extract model error: {e}")),
// No tools / no thinking requested → other events aren't expected; ignore.
_ => {}
}
}
Ok(out)
}
}
/// **P7B: session-model adapter for `BrowserTool`'s visual-fallback locator seam.**
///
/// Wraps the session's [`LlmProvider`] + model params so the facade can do vision-based
/// element location when DOM/aria anchoring fails (a `ref` went stale/detached). The
/// browser engine stays LLM-free — like [`SessionExtractModel`], this adapter lives at the
/// bootstrap/facade layer (架构铁律). `locate` sends one multimodal user message — a strict
/// "return JSON bounding box" instruction plus the (already engine-redacted) screenshot as a
/// [`ContentBlock::Image`] — and parses the model's pixel-box reply. No system prompt, no
/// tools, no extended thinking. Reuses the session's own model (same decision as Extract: no
/// separate model/cost surface). Gated behind `agent.browserUse.visualFallback` (host_default
/// OFF) because every fallback round-trips the vision model — a real token cost.
#[cfg(feature = "browser-use")]
struct SessionVisualLocator {
provider: Arc<dyn LlmProvider>,
model: String,
max_tokens: u32,
}
#[cfg(feature = "browser-use")]
impl SessionVisualLocator {
/// Shared multimodal round-trip: one user message (`prompt` text + a base64 PNG) → stream →
/// collected text. No system prompt, no tools, no thinking (vision locating is mechanical).
/// Both [`VisualLocator::locate`] (bbox) and [`VisualLocator::locate_labeled`] (SoM label)
/// share this; only the prompt + reply parser differ.
async fn run_vision(&self, prompt: String, png: &[u8]) -> Result<String, String> {
use base64::Engine as _;
use nomi_types::llm::{LlmEvent, LlmRequest};
use nomi_types::message::{ContentBlock, Message, Role};
let data = base64::engine::general_purpose::STANDARD.encode(png);
let request = LlmRequest {
model: self.model.clone(),
system: String::new(),
messages: vec![Message::new(
Role::User,
vec![
ContentBlock::Text { text: prompt },
ContentBlock::Image { media_type: "image/png".to_string(), data },
],
)],
tools: Vec::new(),
max_tokens: self.max_tokens,
thinking: None,
reasoning_effort: None,
};
let mut rx = self
.provider
.stream(&request)
.await
.map_err(|e| format!("visual locator stream failed: {e}"))?;
let mut out = String::new();
while let Some(event) = rx.recv().await {
match event {
LlmEvent::TextDelta(t) => out.push_str(&t),
LlmEvent::Done { .. } => break,
LlmEvent::Error(e) => return Err(format!("visual locator error: {e}")),
// No tools / no thinking requested → other events aren't expected; ignore.
_ => {}
}
}
Ok(out)
}
}
#[cfg(feature = "browser-use")]
#[async_trait::async_trait]
impl nomi_browser::visual_fallback::VisualLocator for SessionVisualLocator {
async fn locate(
&self,
screenshot: &[u8],
instruction: &str,
) -> Result<nomi_browser::visual_fallback::VisualLocateResult, String> {
// Strict-JSON instruction. The screenshot is at device-pixel resolution; the model
// returns the box in that same image-pixel space (the facade divides by the live DPR
// before dispatching the click). A not-found answer is `{"confidence": 0}`.
let prompt = format!(
"You are a precise UI element locator. The attached PNG is a screenshot of a web \
page rendered at device-pixel resolution; the origin (0,0) is its top-left corner. \
Find the single element described below and return its bounding box in IMAGE PIXEL \
coordinates.\n\n\
Target element: {instruction}\n\n\
Respond with ONLY a JSON object — no markdown, no code fence, no prose:\n\
{{\"x\": <left px>, \"y\": <top px>, \"width\": <px>, \"height\": <px>, \"confidence\": <0..1>}}\n\
If you cannot confidently find the element, respond with {{\"confidence\": 0}}."
);
let out = self.run_vision(prompt, screenshot).await?;
parse_visual_locate_result(&out)
}
async fn locate_labeled(
&self,
annotated_screenshot: &[u8],
instruction: &str,
n_labels: usize,
) -> Result<nomi_browser::visual_fallback::SomLabelResult, String> {
// SoM mode: the screenshot has numbered labels (1..=n_labels) drawn on its clickable
// elements. The model picks the label that matches the target — a finite choice, far
// more reliable than free-form pixel regression. A not-found answer is `{"label": 0}`.
let prompt = format!(
"You are a precise UI element selector. The attached PNG is a screenshot of a web \
page with numbered labels 1 to {n_labels} drawn on its interactive elements (each \
label sits at the top-left of its element's box). Identify which single label \
number marks the element described below.\n\n\
Target element: {instruction}\n\n\
Respond with ONLY a JSON object — no markdown, no code fence, no prose:\n\
{{\"label\": <integer 1..{n_labels}>, \"confidence\": <0..1>}}\n\
If none of the labeled elements matches, respond with {{\"label\": 0, \"confidence\": 0}}."
);
let out = self.run_vision(prompt, annotated_screenshot).await?;
parse_som_locate_result(&out, n_labels)
}
}
/// Parse the vision model's reply into a [`VisualLocateResult`](nomi_browser::visual_fallback::VisualLocateResult).
///
/// Tolerates models that wrap the JSON in prose or a ```` ```json ```` fence by extracting the
/// outermost `{...}` span. A reply with `confidence == 0`, a missing/zero box, or any
/// non-numeric field is treated as "element not found" (an `Err`) — the facade then surfaces
/// the original anchor error rather than clicking a bogus coordinate.
#[cfg(feature = "browser-use")]
fn parse_visual_locate_result(
raw: &str,
) -> Result<nomi_browser::visual_fallback::VisualLocateResult, String> {
use nomi_browser::visual_fallback::{PixelBox, VisualLocateResult};
let trimmed = raw.trim();
let start = trimmed
.find('{')
.ok_or_else(|| format!("visual locator returned no JSON object: {trimmed:.200}"))?;
let end = trimmed
.rfind('}')
.filter(|e| *e >= start)
.ok_or_else(|| format!("visual locator JSON span malformed: {trimmed:.200}"))?;
let json_str = &trimmed[start..=end];
let v: serde_json::Value = serde_json::from_str(json_str)
.map_err(|e| format!("visual locator JSON parse failed ({e}): {json_str:.200}"))?;
let confidence = v.get("confidence").and_then(serde_json::Value::as_f64).unwrap_or(0.0);
let num = |k: &str| v.get(k).and_then(serde_json::Value::as_f64);
match (num("x"), num("y"), num("width"), num("height")) {
(Some(x), Some(y), Some(width), Some(height))
if confidence > 0.0 && width > 0.0 && height > 0.0 =>
{
Ok(VisualLocateResult {
pixel_box: PixelBox { x, y, width, height },
confidence: confidence.clamp(0.0, 1.0),
})
}
_ => Err(format!(
"visual locator could not locate the element (confidence={confidence})"
)),
}
}
/// Parse the vision model's SoM reply into a [`SomLabelResult`](nomi_browser::visual_fallback::SomLabelResult).
///
/// Same JSON-extraction tolerance as [`parse_visual_locate_result`] (handles prose / ```` ```json ````
/// fences). The label MUST be an integer in `1..=n_labels` with `confidence > 0`; `label == 0`,
/// out-of-range, missing, or zero-confidence all map to `Err` ("no label matched") so the facade
/// surfaces the original anchor error rather than indexing a bogus / out-of-bounds label.
#[cfg(feature = "browser-use")]
fn parse_som_locate_result(
raw: &str,
n_labels: usize,
) -> Result<nomi_browser::visual_fallback::SomLabelResult, String> {
use nomi_browser::visual_fallback::SomLabelResult;
let trimmed = raw.trim();
let start = trimmed
.find('{')
.ok_or_else(|| format!("SoM locator returned no JSON object: {trimmed:.200}"))?;
let end = trimmed
.rfind('}')
.filter(|e| *e >= start)
.ok_or_else(|| format!("SoM locator JSON span malformed: {trimmed:.200}"))?;
let json_str = &trimmed[start..=end];
let v: serde_json::Value = serde_json::from_str(json_str)
.map_err(|e| format!("SoM locator JSON parse failed ({e}): {json_str:.200}"))?;
let confidence = v.get("confidence").and_then(serde_json::Value::as_f64).unwrap_or(0.0);
// Accept integer or float-encoded label; reject anything not in 1..=n_labels.
let label = v.get("label").and_then(serde_json::Value::as_f64);
match label {
Some(l) if l.fract() == 0.0 && l >= 1.0 && (l as usize) <= n_labels && confidence > 0.0 => {
Ok(SomLabelResult {
label: l as usize,
confidence: confidence.clamp(0.0, 1.0),
})
}
_ => Err(format!(
"SoM locator picked no valid label (label={label:?}, n_labels={n_labels}, confidence={confidence})"
)),
}
}
/// Result of bootstrapping an agent engine with all features initialized.
pub struct BootstrapResult {
pub engine: AgentEngine,
pub provider: Arc<dyn LlmProvider>,
pub mcp_managers: Vec<Arc<McpManager>>,
pub has_mcp: bool,
}
/// Builder for creating a fully-initialized `AgentEngine`.
///
/// Encapsulates the complete initialization pipeline so all consumers
/// (CLI, backend, sub-agents) get consistent behavior:
///
/// - System prompt always includes model identity, working directory, date
/// - Tool usage guidance is always injected
/// - AGENTS.md is loaded from the workspace hierarchy
/// - Skills, MCP, plan mode, spawn are enabled based on `Config` fields
pub struct AgentBootstrap {
config: Config,
workspace: String,
output: Arc<dyn OutputSink>,
provider: Option<Arc<dyn LlmProvider>>,
resume_session: Option<Session>,
extra_skill_dirs: Vec<PathBuf>,
goal: Option<crate::goal::runtime::GoalSpec>,
/// **P3-X1: the session's shared runtime approval-mode handle** (the same
/// `Arc<ToolApprovalManager>` the host later installs on the engine via
/// `set_approval_manager`). When present it is threaded into the native
/// `BrowserTool` so its fail-closed redline gate reads the *runtime* session mode
/// LIVE — a mid-session `set_mode` to yolo arms the gate immediately, instead of
/// being pinned to the construction-time `auto_approve` snapshot. Hosts that have
/// no protocol approval manager (e.g. the interactive REPL) leave it `None` and the
/// facade falls back to the construction-time snapshot (unchanged behavior).
approval_manager: Option<Arc<nomi_protocol::ToolApprovalManager>>,
/// **P3-X2: the session's per-pet browser secret vault source** (vault file path +
/// machine-bound 32-byte key). Threaded into the native `BrowserTool` so it can lazily
/// load the registered credentials (`secret:NAME` resolves, origin-gated) and derive the
/// firewall domain allowlist from the same per-pet `allowed_origins` (裁决⑤). Stored as
/// the raw pieces (NOT the `nomi_browser` type) so the field exists regardless of the
/// `browser-use` feature; the `BrowserSecretSource` is constructed only at the
/// feature-gated `with_policy` call site. `None` → empty store + unrestricted egress.
#[cfg_attr(not(feature = "browser-use"), allow(dead_code))]
browser_secret_source: Option<(PathBuf, [u8; 32])>,
/// **Phase D: the session's browser approval gate** (human takeover + SD-5 cross-origin
/// egress). Threaded into the native `BrowserTool` so an irreversible action in a bypass
/// session — and a gated cross-origin POST — is surfaced to the user and awaited. `None`
/// (default) → fail-closed (current behavior). Feature-gated: the trait is in `nomi_browser`.
#[cfg(feature = "browser-use")]
approval_gate: Option<Arc<dyn nomi_browser::BrowserApprovalGate>>,
}
impl AgentBootstrap {
pub fn new(config: Config, workspace: impl Into<String>, output: Arc<dyn OutputSink>) -> Self {
Self {
config,
workspace: workspace.into(),
output,
provider: None,
resume_session: None,
extra_skill_dirs: Vec::new(),
goal: None,
approval_manager: None,
browser_secret_source: None,
#[cfg(feature = "browser-use")]
approval_gate: None,
}
}
/// Use a pre-created provider instead of creating one from config.
pub fn provider(mut self, provider: Arc<dyn LlmProvider>) -> Self {
self.provider = Some(provider);
self
}
/// Enable goal-driven continuation for this session (opt-in). Omit it (the
/// default) and the engine behaves exactly as before.
pub fn goal(mut self, goal: Option<crate::goal::runtime::GoalSpec>) -> Self {
self.goal = goal;
self
}
/// **P3-X1: provide the session's shared `Arc<ToolApprovalManager>`** so the native
/// `BrowserTool`'s redline gate reads the *runtime* approval mode LIVE (a mid-session
/// `set_mode` to yolo arms the gate immediately). Pass the *same* Arc that is later
/// installed on the engine via `set_approval_manager`, so the facade and orchestration
/// observe one mode cell with zero drift. Omit it (the default) to keep the
/// construction-time `auto_approve` snapshot as the (fail-closed) source of truth.
pub fn approval_manager(mut self, mgr: Arc<nomi_protocol::ToolApprovalManager>) -> Self {
self.approval_manager = Some(mgr);
self
}
/// **P3-X2: provide the session's per-pet browser secret vault source** so the native
/// `BrowserTool` can load the user-registered credentials (`secret:NAME`, origin-gated)
/// and derive the firewall domain allowlist from the same per-pet `allowed_origins`
/// (裁决⑤). Takes the raw pieces (vault file path + machine-bound 32-byte key) so backend
/// callers (`nomifun-ai-agent`) need not depend on `nomi-browser` to wire it. Omit it
/// (the default) to keep an empty store + unrestricted egress (current behavior).
pub fn browser_secret_source(mut self, vault_path: PathBuf, key: [u8; 32]) -> Self {
self.browser_secret_source = Some((vault_path, key));
self
}
/// **Phase D: provide the browser approval gate** (host impl raises a `Confirmation`
/// and awaits the shared `ToolApprovalManager`). Threaded into the native `BrowserTool`,
/// enabling human takeover of irreversible actions + SD-5 cross-origin egress approval.
/// Omit it (the default) → fail-closed (irreversible stays Blocked, gated egress fails).
#[cfg(feature = "browser-use")]
pub fn approval_gate(mut self, gate: Arc<dyn nomi_browser::BrowserApprovalGate>) -> Self {
self.approval_gate = Some(gate);
self
}
/// Resume from a previously saved session.
pub fn resume(mut self, session: Session) -> Self {
self.resume_session = Some(session);
self
}
/// Add extra directories to scan for skills.
pub fn extra_skill_dirs(mut self, dirs: Vec<PathBuf>) -> Self {
self.extra_skill_dirs = dirs;
self
}
/// Read-only access to the config (for session management before build).
pub fn config(&self) -> &Config {
&self.config
}
/// Build the fully-initialized engine.
pub async fn build(mut self) -> anyhow::Result<BootstrapResult> {
let cwd = &self.workspace;
let cwd_path = std::path::Path::new(cwd);
tracing::info!(target: "nomi_agent", workspace = %cwd, "agent bootstrap: workspace cwd resolved");
let provider = self
.provider
.unwrap_or_else(|| nomi_providers::create_provider(&self.config));
let memory_dir = nomi_memory::paths::auto_memory_dir(cwd_path);
let file_cache = if self.config.file_cache.enabled {
Some(Arc::new(std::sync::RwLock::new(
nomi_tools::file_cache::FileStateCache::new(&self.config.file_cache),
)))
} else {
None
};
let mut registry = nomi_tools::registry::ToolRegistry::new();
// Opt-in write-root containment (§3.6): when `tools.write_root` is set,
// resolve it to an absolute path the write tools enforce. Empty = off.
let write_root: Option<std::path::PathBuf> = {
let wr = self.config.tools.write_root.trim();
if wr.is_empty() {
None
} else {
Some(std::path::PathBuf::from(wr))
}
};
registry.register(Box::new(nomi_tools::read::ReadTool::new(
file_cache.clone(),
Some(cwd_path.to_path_buf()),
)));
registry.register(Box::new(
nomi_tools::write::WriteTool::new(file_cache.clone())
.with_write_root(write_root.clone())
.with_cwd(Some(cwd_path.to_path_buf())),
));
registry.register(Box::new(
nomi_tools::edit::EditTool::new(file_cache.clone())
.with_write_root(write_root.clone())
.with_cwd(Some(cwd_path.to_path_buf())),
));
registry.register(Box::new(
nomi_tools::apply_patch::ApplyPatchTool::new(file_cache)
.with_write_root(write_root)
.with_cwd(Some(cwd_path.to_path_buf())),
));
// Experimental `Lsp` code-navigation tool: registered only when at least
// one language server is configured (default off → no behaviour change).
{
let mut lsp_map: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for entry in &self.config.tools.lsp_servers {
if entry.command.is_empty() {
continue;
}
for ext in &entry.extensions {
lsp_map.insert(ext.trim_start_matches('.').to_ascii_lowercase(), entry.command.clone());
}
}
if !lsp_map.is_empty() {
registry.register(Box::new(nomi_tools::lsp::LspTool::new(
lsp_map,
cwd_path.to_path_buf(),
)));
}
}
// Native `remember` tool: persist durable project/user memories mid-session
// to the file-based long-term memory (injected into future sessions).
if let Some(mem_dir) = memory_dir.clone() {
registry.register(Box::new(crate::memory_tools::RememberTool::new(mem_dir)));
}
// Bash registration, precedence: Seatbelt sandbox (macOS, opt-in) >
// persistent shell (Unix, opt-in) > stateless one-shot. The sandbox wins
// so no unconfined path coexists with it.
#[cfg(target_os = "macos")]
let bash_sandbox_on = self.config.tools.bash_sandbox;
#[cfg(not(target_os = "macos"))]
let bash_sandbox_on = false;
if bash_sandbox_on {
#[cfg(target_os = "macos")]
{
if self.config.tools.persistent_shell {
tracing::warn!(
target: "nomi_agent",
"bash_sandbox enabled — persistent_shell is disabled under the sandbox so no unconfined shell path coexists"
);
}
registry.register(Box::new(
nomi_tools::bash::BashTool::new(cwd_path.to_path_buf())
.with_sandbox(Some(vec![cwd_path.to_path_buf()])),
));
}
} else {
#[cfg(unix)]
if self.config.tools.persistent_shell {
let shell = std::sync::Arc::new(nomi_tools::persistent_shell::PersistentShell::new(
cwd_path.to_string_lossy().into_owned(),
));
registry.register(Box::new(nomi_tools::bash::BashTool::with_persistent_shell(
cwd_path.to_path_buf(),
shell,
)));
} else {
registry.register(Box::new(nomi_tools::bash::BashTool::new(cwd_path.to_path_buf())));
}
#[cfg(not(unix))]
registry.register(Box::new(nomi_tools::bash::BashTool::new(
cwd_path.to_path_buf(),
)));
}
registry.register(Box::new(nomi_tools::grep::GrepTool::new(
cwd_path.to_path_buf(),
)));
registry.register(Box::new(nomi_tools::glob::GlobTool::new(
cwd_path.to_path_buf(),
)));
let builtin_names: Vec<String> = registry.tool_names();
let mut mcp_managers: Vec<Arc<McpManager>> = Vec::new();
let mcp_manager = if !self.config.mcp.servers.is_empty() {
match McpManager::connect_all(&self.config.mcp.servers).await {
Ok(mgr) => {
let mgr = Arc::new(mgr);
nomi_mcp::tool_proxy::register_mcp_tools(
&mut registry,
&mgr,
&builtin_names,
&self.config.mcp.servers,
);
mcp_managers.push(mgr.clone());
Some(mgr)
}
Err(e) => {
self.output
.emit_warning(&format!("MCP initialization error: {e}"));
None
}
}
} else {
None
};
let has_mcp = mcp_manager.is_some();
let skills = nomi_skills::loader::load_all_skills(
cwd_path,
&self.extra_skill_dirs,
false,
mcp_manager.as_deref(),
)
.await;
let mut prompt_cache = crate::context::SystemPromptCache::new();
let system_prompt = crate::context::build_system_prompt(
&mut prompt_cache,
self.config.system_prompt.as_deref(),
cwd,
&self.config.model,
&skills,
None,
memory_dir.as_deref(),
false,
self.config.compact.toon,
self.config.tools.browser.enabled,
);
self.config.system_prompt = Some(system_prompt);
let skills_arc = Arc::new(skills);
let skill_checker = nomi_skills::permissions::SkillPermissionChecker::new(
self.config.tools.skills.deny.clone(),
self.config.tools.skills.allow.clone(),
self.config.tools.auto_approve,
);
registry.register(Box::new(crate::skill_tool::SkillTool::new(
skills_arc,
cwd.to_string(),
skill_checker,
)));
let spawner = Arc::new(
crate::spawner::AgentSpawner::new(
provider.clone(),
self.config.clone(),
cwd_path.to_path_buf(),
)
.with_token_budget(
self.config
.tools
.subagent_token_budget
.map(|limit| Arc::new(crate::spawner::TokenBudget::new(limit))),
),
);
registry.register(Box::new(crate::spawn_tool::SpawnTool::new(spawner)));
let plan_active_flag = Arc::new(AtomicBool::new(false));
if self.config.plan.enabled {
registry.register(Box::new(crate::plan::tools::EnterPlanModeTool::new(
Arc::clone(&plan_active_flag),
)));
registry.register(Box::new(crate::plan::tools::ExitPlanModeTool::new(
Arc::clone(&plan_active_flag),
)));
}
#[cfg(feature = "computer-use")]
if self.config.tools.computer.enabled {
tracing::info!(
target: "nomi_agent",
"computer-use ENABLED: registering the Computer tool (observe / click_element / \
launch / type / scroll). Desktop control is available to this session."
);
registry.register(Box::new(nomi_computer::ComputerTool::new(
&self.config.tools.computer,
)));
}
#[cfg(feature = "computer-use")]
if !self.config.tools.computer.enabled {
tracing::info!(
target: "nomi_agent",
"computer-use DISABLED for this session (config.tools.computer.enabled = false); \
the Computer tool is NOT registered — the agent falls back to the shell."
);
}
#[cfg(not(feature = "computer-use"))]
if self.config.tools.computer.enabled {
tracing::warn!(
target: "nomi_agent",
"computer use enabled in config but this build lacks the computer-use feature"
);
}
// Native browser-use (in-process self-hosted CDP engine). The native
// BrowserTool registers under the name "Browser" (actions: navigate /
// observe / screenshot / capabilities) and is the sole browser path. The
// engine launches lazily on the first action — registering it never starts
// a browser.
#[cfg(feature = "browser-use")]
if self.config.tools.browser.enabled {
tracing::info!(
target: "nomi_agent",
"browser-use ENABLED: registering the native Browser tool (navigate / observe / \
screenshot / capabilities). The managed Chromium launches lazily on first use."
);
// F1-sec: thread the session-bypass policy + evaluate full-power into the
// facade so its independent fail-closed redline gate (裁决⑧) actually fires
// and the evaluate gate (裁决⑨) reflects the user's opt-in. `auto_approve`
// is `true` iff orchestration approval is bypassed (yolo / companion-forced-yolo
// / --auto-approve — see BrowserTool::session_bypasses_approval doc); the
// `browser.full_power` flag carries the LIVE `agent.browserUse.fullPower` pref
// (set by the backend factory per session). Constructed here where the full
// Config is in scope.
//
// P3-G2: pass the session working directory `cwd` (= self.workspace) as the
// per-session/per-pet workspace. It's the natural isolation point — for a
// companion session it is `{companion_id}/workspace` (companion.rs sets
// extra.workspace, which the manager resolves to this cwd); for a non-companion
// session it's the conversation's own working dir. Downloads (E4) land in its
// `downloads/` subdir instead of a temp dir. The non-companion
// `{data_dir}/browser-profiles/{conversation_id}` subdivision (默认④) needs the
// data_dir + conversation_id which the bootstrap does not hold (they live in the
// upper manager/factory) — the cwd is already per-conversation isolated, so we
// pass it directly (simplest correct) and leave the finer browser-profiles
// layout to W4/deployment wiring (the field signature already supports it).
// P3-X1: thread the session's shared runtime approval-mode handle (the same
// Arc<ToolApprovalManager> the host installs via set_approval_manager) so the
// facade's redline gate reads the LIVE session mode — a mid-session set_mode to
// yolo arms it immediately, instead of being pinned to the auto_approve snapshot
// above. `None` (e.g. the interactive REPL, which has no protocol approval
// manager) → the facade falls back to the construction-time snapshot (unchanged).
let mut browser_tool = nomi_browser::BrowserTool::with_policy(
&self.config.tools.browser,
self.config.tools.auto_approve,
self.config.tools.browser.full_power,
self.config.tools.browser.persistent_login,
Some(PathBuf::from(cwd)),
self.approval_manager.clone(),
// P3-X2: per-pet secret vault source (vault path + machine-bound key) so the
// facade lazily loads registered credentials + derives the firewall domain
// allowlist from their allowed_origins (裁决⑤). None → empty store + unrestricted.
self.browser_secret_source
.clone()
.map(|(vault_path, key)| nomi_browser::BrowserSecretSource { vault_path, key }),
);
// P7A: site-memory opt-in (LIVE pref `agent.browserUse.siteMemory`, default OFF). When
// ON, inject a file-backed sink so the agent remembers site structure across sessions
// (entries injected into observe as untrusted hints; secret-sourced entries dropped by
// the store). Root is GLOBAL (browser identity is globally shared, NOT per-session) —
// same `browser-data` root the gateway/engine use. OFF → no sink (zero behavior change).
if self.config.tools.browser.site_memory {
let sm_root = nomi_config::config::app_config_dir()
.map(|d| d.join("browser-data").join("site-memory"))
.unwrap_or_else(|| std::env::temp_dir().join("nomi-browser-data").join("site-memory"));
let sink = nomi_browser::site_memory::FileSiteMemorySink::new(sm_root);
let store = std::sync::Arc::new(nomi_browser::site_memory::SiteMemoryStore::new(
Box::new(sink),
));
browser_tool = browser_tool.with_site_memory(store);
}
// extract-llm: reuse the session provider (the same `provider` driving this engine,
// created above if none was injected) so act(Extract) does real LLM extraction. No
// pref — extract is opt-in per-call by the agent; this only enables the capability.
let extract_model = Arc::new(SessionExtractModel {
provider: provider.clone(),
model: self.config.model.clone(),
max_tokens: self.config.max_tokens,
});
browser_tool = browser_tool.with_extract_model(extract_model);
// P7B: visual fallback opt-in (LIVE pref `agent.browserUse.visualFallback`, default
// OFF). When ON, inject a session-model `VisualLocator`: if DOM/aria anchoring fails
// (a `ref` went stale/detached), the facade screenshots the page and asks the vision
// model to locate the target by description, then clicks the DPR-mapped CSS point.
// Reuses the session provider/model (no separate cost surface). OFF → no locator
// injected, so the facade's fallback stays Unavailable (zero behavior change).
if self.config.tools.browser.visual_fallback {
let locator = Arc::new(SessionVisualLocator {
provider: provider.clone(),
model: self.config.model.clone(),
max_tokens: self.config.max_tokens,
});
browser_tool = browser_tool
.with_visual_locator(locator)
.with_visual_fallback_enabled(true);
}
// Phase D: thread the host approval gate (human takeover + SD-5 egress). When the
// gate is present it surfaces irreversible actions / gated cross-origin POSTs to the
// user and awaits a decision; absent → fail-closed (current behavior).
if let Some(gate) = self.approval_gate.clone() {
browser_tool = browser_tool.with_approval_gate(gate);
}
registry.register(Box::new(browser_tool));
}
#[cfg(not(feature = "browser-use"))]
if self.config.tools.browser.enabled {
tracing::debug!(
target: "nomi_agent",
"browser-use enabled in config but this build lacks the browser-use feature; the \
native Browser tool is not registered."
);
}
// Interactive PTY tools: exec_command + write_stdin share one
// ProcessStore (session-level, alive for the engine's lifetime via the
// tools held in the ToolRegistry). Same stateful-tool pattern as
// SpawnTool/BrowserTool. The store's Drop SIGKILLs any lingering PTY
// process groups when the engine (and its registry) is torn down.
let process_store = Arc::new(nomi_tools::process_store::ProcessStore::new());
registry.register(Box::new(nomi_tools::exec_command::ExecCommandTool::new(
Arc::clone(&process_store),
cwd_path.to_path_buf(),
)));
registry.register(Box::new(nomi_tools::write_stdin::WriteStdinTool::new(
Arc::clone(&process_store),
)));
// codex-style stateless todo checklist tool. Always registered (not
// deferred), surfaced to the frontend via the Plan event bridge.
registry.register(Box::new(nomi_tools::update_plan::UpdatePlanTool::new()));
let tool_defs_snapshot = registry.to_tool_defs();
registry.register(Box::new(nomi_tools::tool_search::ToolSearchTool::new(
tool_defs_snapshot,
)));
let mut engine = if let Some(session) = self.resume_session {
AgentEngine::resume_with_provider(
provider.clone(),
self.config,
registry,
self.output,
session,
cwd_path.to_path_buf(),
)
} else {
AgentEngine::new_with_provider(
provider.clone(),
self.config,
registry,
self.output,
cwd_path.to_path_buf(),
)
};
engine.set_plan_active_flag(plan_active_flag);
if let Some(spec) = self.goal {
engine.set_goal(spec.objective, spec.max_auto_continuations);
}
Ok(BootstrapResult {
engine,
provider,
mcp_managers,
has_mcp,
})
}
}
#[cfg(all(test, feature = "browser-use"))]
mod visual_locator_tests {
use super::{parse_som_locate_result, parse_visual_locate_result};
#[test]
fn parses_clean_json_box() {
let r = parse_visual_locate_result(
r#"{"x": 100, "y": 200, "width": 40, "height": 20, "confidence": 0.9}"#,
)
.expect("clean JSON should parse");
assert_eq!(r.pixel_box.x, 100.0);
assert_eq!(r.pixel_box.y, 200.0);
assert_eq!(r.pixel_box.width, 40.0);
assert_eq!(r.pixel_box.height, 20.0);
assert!((r.confidence - 0.9).abs() < 1e-9);
}
#[test]
fn extracts_json_from_code_fence() {
// Vision models love wrapping JSON in a ```json fence — tolerate it.
let raw = "```json\n{\"x\": 1, \"y\": 2, \"width\": 3, \"height\": 4, \"confidence\": 0.5}\n```";
let r = parse_visual_locate_result(raw).expect("fenced JSON should parse");
assert_eq!(r.pixel_box.width, 3.0);
}
#[test]
fn extracts_json_from_surrounding_prose() {
let raw = "Sure! Here is the box: {\"x\": 5, \"y\": 6, \"width\": 7, \"height\": 8, \"confidence\": 0.8} — hope that helps.";
let r = parse_visual_locate_result(raw).expect("prose-wrapped JSON should parse");
assert_eq!(r.pixel_box.x, 5.0);
}
#[test]
fn confidence_clamped_to_unit_interval() {
let r = parse_visual_locate_result(
r#"{"x": 1, "y": 1, "width": 1, "height": 1, "confidence": 1.7}"#,
)
.expect("over-unit confidence should still parse");
assert_eq!(r.confidence, 1.0);
}
#[test]
fn not_found_confidence_zero_is_err() {
assert!(parse_visual_locate_result(r#"{"confidence": 0}"#).is_err());
}
#[test]
fn zero_sized_box_is_err() {
// A box with no area can't be a click target → not-found.
assert!(
parse_visual_locate_result(
r#"{"x": 1, "y": 1, "width": 0, "height": 10, "confidence": 0.9}"#
)
.is_err()
);
}
#[test]
fn missing_box_fields_is_err() {
// Confidence present but no coordinates → can't act.
assert!(parse_visual_locate_result(r#"{"confidence": 0.9}"#).is_err());
}
#[test]
fn non_json_reply_is_err() {
assert!(parse_visual_locate_result("I could not find that element.").is_err());
}
// ── SoM label parser (parse_som_locate_result) ──
#[test]
fn som_parses_valid_label() {
let r = parse_som_locate_result(r#"{"label": 3, "confidence": 0.88}"#, 10)
.expect("valid label should parse");
assert_eq!(r.label, 3);
assert!((r.confidence - 0.88).abs() < 1e-9);
}
#[test]
fn som_extracts_label_from_code_fence() {
let raw = "```json\n{\"label\": 7, \"confidence\": 0.6}\n```";
let r = parse_som_locate_result(raw, 12).expect("fenced JSON should parse");
assert_eq!(r.label, 7);
}
#[test]
fn som_label_zero_is_err() {
// {"label": 0} is the model's "none matched" sentinel.
assert!(parse_som_locate_result(r#"{"label": 0, "confidence": 0}"#, 10).is_err());
}
#[test]
fn som_label_out_of_range_is_err() {
// Hallucinated label beyond n_labels must NOT index the label_map (would be OOB).
assert!(parse_som_locate_result(r#"{"label": 99, "confidence": 0.9}"#, 12).is_err());
}
#[test]
fn som_label_below_one_is_err() {
assert!(parse_som_locate_result(r#"{"label": -1, "confidence": 0.9}"#, 12).is_err());
}
#[test]
fn som_non_integer_label_is_err() {
// A fractional label is not a valid 1-based index.
assert!(parse_som_locate_result(r#"{"label": 2.5, "confidence": 0.9}"#, 12).is_err());
}
#[test]
fn som_zero_confidence_is_err() {
assert!(parse_som_locate_result(r#"{"label": 3, "confidence": 0}"#, 10).is_err());
}
#[test]
fn som_missing_label_is_err() {
assert!(parse_som_locate_result(r#"{"confidence": 0.9}"#, 10).is_err());
}
#[test]
fn som_boundary_label_n_is_ok() {
// label == n_labels is in range (inclusive upper bound).
let r = parse_som_locate_result(r#"{"label": 5, "confidence": 0.7}"#, 5)
.expect("label == n_labels is valid");
assert_eq!(r.label, 5);
}
}
@@ -0,0 +1,403 @@
//! Prompt cache break detection.
//!
//! Pairs request-side prompt state (hashes) with response-side cache tokens
//! to detect and diagnose prompt cache breaks across turns.
use std::hash::{DefaultHasher, Hash, Hasher};
use nomi_types::tool::ToolDef;
/// Snapshot of prompt state taken before each API call.
#[derive(Debug, Clone)]
struct PromptSnapshot {
system_hash: u64,
tools_hash: u64,
}
/// Cache token statistics from a single API response.
#[derive(Debug, Clone)]
pub struct CacheStats {
pub input_tokens: u64,
pub cache_read_tokens: u64,
pub cache_creation_tokens: u64,
}
/// Diagnostic result after comparing two consecutive turns.
#[derive(Debug, Clone)]
pub enum CacheDiagnostic {
Healthy {
hit_rate: f64,
},
PartialMiss {
hit_rate: f64,
cause: CacheBreakCause,
},
FullMiss {
cause: CacheBreakCause,
},
}
/// What caused a cache break.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CacheBreakCause {
SystemPromptChanged,
ToolsChanged,
TtlExpiry,
FirstRequest,
}
/// Detects prompt cache breaks by comparing consecutive turns.
pub struct CacheBreakDetector {
/// Snapshot from the PREVIOUS turn (used for attribution on cache break).
prev_snapshot: Option<PromptSnapshot>,
/// Snapshot from the CURRENT turn (just recorded by record_request).
current_snapshot: Option<PromptSnapshot>,
/// Cache stats from the previous API response.
prev_stats: Option<CacheStats>,
}
impl CacheBreakDetector {
pub fn new() -> Self {
Self {
prev_snapshot: None,
current_snapshot: None,
prev_stats: None,
}
}
/// Record the prompt state before an API call.
pub fn record_request(&mut self, system: &str, tools: &[ToolDef]) {
let mut system_hasher = DefaultHasher::new();
system.hash(&mut system_hasher);
let system_hash = system_hasher.finish();
let mut tools_hasher = DefaultHasher::new();
for t in tools {
t.name.hash(&mut tools_hasher);
t.description.hash(&mut tools_hasher);
let schema_str = serde_json::to_string(&t.input_schema).unwrap_or_default();
schema_str.hash(&mut tools_hasher);
t.deferred.hash(&mut tools_hasher);
}
let tools_hash = tools_hasher.finish();
// Rotate: current becomes prev, new snapshot becomes current
self.prev_snapshot = self.current_snapshot.take();
self.current_snapshot = Some(PromptSnapshot {
system_hash,
tools_hash,
});
}
/// Check the response cache tokens against the previous turn.
///
/// Returns `None` if no snapshot was recorded before the call.
pub fn check_response(&mut self, stats: CacheStats) -> Option<CacheDiagnostic> {
let current = self.current_snapshot.as_ref()?;
let diagnostic = self.compute_diagnostic(current, &stats);
self.prev_stats = Some(stats);
Some(diagnostic)
}
fn compute_diagnostic(&self, current: &PromptSnapshot, stats: &CacheStats) -> CacheDiagnostic {
let Some(prev) = &self.prev_stats else {
// First request — no previous data to compare
return CacheDiagnostic::Healthy { hit_rate: 0.0 };
};
// If provider doesn't support caching (both turns have 0 cache tokens),
// report healthy to avoid false alarms (e.g., OpenAI).
if prev.cache_read_tokens == 0
&& prev.cache_creation_tokens == 0
&& stats.cache_read_tokens == 0
&& stats.cache_creation_tokens == 0
{
return CacheDiagnostic::Healthy { hit_rate: 0.0 };
}
let prev_had_cache = prev.cache_read_tokens > 0 || prev.cache_creation_tokens > 0;
// Full miss: had cache before, now read tokens dropped to 0
if prev_had_cache && stats.cache_read_tokens == 0 {
let cause = self.attribute_cause(current);
return CacheDiagnostic::FullMiss { cause };
}
// Calculate hit rate
let hit_rate = if stats.input_tokens > 0 {
stats.cache_read_tokens as f64 / stats.input_tokens as f64
} else {
0.0
};
// Partial miss: cache_read dropped >5% compared to previous
if prev.cache_read_tokens > 0 {
let drop_pct = 1.0 - (stats.cache_read_tokens as f64 / prev.cache_read_tokens as f64);
if drop_pct > 0.05 {
let cause = self.attribute_cause(current);
return CacheDiagnostic::PartialMiss { hit_rate, cause };
}
}
CacheDiagnostic::Healthy { hit_rate }
}
/// Determine what caused the cache break by comparing prev vs current snapshots.
fn attribute_cause(&self, current: &PromptSnapshot) -> CacheBreakCause {
let Some(prev) = &self.prev_snapshot else {
return CacheBreakCause::FirstRequest;
};
if prev.system_hash != current.system_hash {
return CacheBreakCause::SystemPromptChanged;
}
if prev.tools_hash != current.tools_hash {
return CacheBreakCause::ToolsChanged;
}
// Hashes match but cache was lost — server-side TTL expiry
CacheBreakCause::TtlExpiry
}
}
impl Default for CacheBreakDetector {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn make_tools() -> Vec<ToolDef> {
vec![ToolDef {
name: "Read".into(),
description: "Read a file".into(),
input_schema: json!({"type": "object"}),
deferred: false,
}]
}
#[test]
fn first_request_returns_healthy() {
let mut detector = CacheBreakDetector::new();
detector.record_request("system prompt", &make_tools());
let diag = detector
.check_response(CacheStats {
input_tokens: 10000,
cache_read_tokens: 0,
cache_creation_tokens: 5000,
})
.unwrap();
assert!(matches!(diag, CacheDiagnostic::Healthy { .. }));
}
#[test]
fn healthy_when_cache_read_stable() {
let mut detector = CacheBreakDetector::new();
// Turn 1
detector.record_request("prompt", &make_tools());
detector.check_response(CacheStats {
input_tokens: 10000,
cache_read_tokens: 8000,
cache_creation_tokens: 2000,
});
// Turn 2 — similar cache_read
detector.record_request("prompt", &make_tools());
let diag = detector
.check_response(CacheStats {
input_tokens: 11000,
cache_read_tokens: 8000,
cache_creation_tokens: 0,
})
.unwrap();
assert!(matches!(diag, CacheDiagnostic::Healthy { .. }));
}
#[test]
fn full_miss_when_cache_read_drops_to_zero() {
let mut detector = CacheBreakDetector::new();
// Turn 1 — cache established
detector.record_request("prompt", &make_tools());
detector.check_response(CacheStats {
input_tokens: 10000,
cache_read_tokens: 8000,
cache_creation_tokens: 2000,
});
// Turn 2 — cache_read drops to 0
detector.record_request("prompt", &make_tools());
let diag = detector
.check_response(CacheStats {
input_tokens: 10000,
cache_read_tokens: 0,
cache_creation_tokens: 10000,
})
.unwrap();
assert!(matches!(diag, CacheDiagnostic::FullMiss { .. }));
}
#[test]
fn full_miss_system_prompt_changed() {
let mut detector = CacheBreakDetector::new();
// Turn 1
detector.record_request("prompt v1", &make_tools());
detector.check_response(CacheStats {
input_tokens: 10000,
cache_read_tokens: 8000,
cache_creation_tokens: 2000,
});
// Turn 2 — different system prompt
detector.record_request("prompt v2", &make_tools());
let diag = detector
.check_response(CacheStats {
input_tokens: 10000,
cache_read_tokens: 0,
cache_creation_tokens: 10000,
})
.unwrap();
match diag {
CacheDiagnostic::FullMiss { cause } => {
assert_eq!(cause, CacheBreakCause::SystemPromptChanged);
}
_ => panic!("expected FullMiss"),
}
}
#[test]
fn full_miss_tools_changed() {
let mut detector = CacheBreakDetector::new();
// Turn 1
detector.record_request("prompt", &make_tools());
detector.check_response(CacheStats {
input_tokens: 10000,
cache_read_tokens: 8000,
cache_creation_tokens: 2000,
});
// Turn 2 — different tools
let new_tools = vec![ToolDef {
name: "Write".into(),
description: "Write a file".into(),
input_schema: json!({"type": "object"}),
deferred: false,
}];
detector.record_request("prompt", &new_tools);
let diag = detector
.check_response(CacheStats {
input_tokens: 10000,
cache_read_tokens: 0,
cache_creation_tokens: 10000,
})
.unwrap();
match diag {
CacheDiagnostic::FullMiss { cause } => {
assert_eq!(cause, CacheBreakCause::ToolsChanged);
}
_ => panic!("expected FullMiss"),
}
}
#[test]
fn full_miss_ttl_expiry() {
let mut detector = CacheBreakDetector::new();
// Turn 1
detector.record_request("prompt", &make_tools());
detector.check_response(CacheStats {
input_tokens: 10000,
cache_read_tokens: 8000,
cache_creation_tokens: 2000,
});
// Turn 2 — same prompt and tools but cache lost (TTL expired server-side)
detector.record_request("prompt", &make_tools());
let diag = detector
.check_response(CacheStats {
input_tokens: 10000,
cache_read_tokens: 0,
cache_creation_tokens: 10000,
})
.unwrap();
match diag {
CacheDiagnostic::FullMiss { cause } => {
assert_eq!(cause, CacheBreakCause::TtlExpiry);
}
_ => panic!("expected FullMiss"),
}
}
#[test]
fn partial_miss_when_cache_read_drops_significantly() {
let mut detector = CacheBreakDetector::new();
// Turn 1
detector.record_request("prompt", &make_tools());
detector.check_response(CacheStats {
input_tokens: 10000,
cache_read_tokens: 8000,
cache_creation_tokens: 2000,
});
// Turn 2 — 50% drop in cache_read
detector.record_request("prompt", &make_tools());
let diag = detector
.check_response(CacheStats {
input_tokens: 10000,
cache_read_tokens: 4000,
cache_creation_tokens: 6000,
})
.unwrap();
assert!(matches!(diag, CacheDiagnostic::PartialMiss { .. }));
}
#[test]
fn openai_no_false_alarm() {
// OpenAI never returns cache tokens — both turns have all zeros
let mut detector = CacheBreakDetector::new();
detector.record_request("prompt", &make_tools());
detector.check_response(CacheStats {
input_tokens: 10000,
cache_read_tokens: 0,
cache_creation_tokens: 0,
});
detector.record_request("prompt", &make_tools());
let diag = detector
.check_response(CacheStats {
input_tokens: 10000,
cache_read_tokens: 0,
cache_creation_tokens: 0,
})
.unwrap();
// Should be Healthy, not FullMiss
assert!(matches!(diag, CacheDiagnostic::Healthy { .. }));
}
#[test]
fn no_diagnostic_without_record_request() {
let mut detector = CacheBreakDetector::new();
let diag = detector.check_response(CacheStats {
input_tokens: 10000,
cache_read_tokens: 0,
cache_creation_tokens: 0,
});
assert!(diag.is_none());
}
}
@@ -0,0 +1,119 @@
use async_trait::async_trait;
use super::{CommandContext, CommandResult, SlashCommand};
use crate::compact::state::CompactState;
pub struct ClearCommand;
#[async_trait]
impl SlashCommand for ClearCommand {
fn name(&self) -> &str {
"clear"
}
fn description(&self) -> &str {
"Clear conversation history"
}
async fn execute(
&self,
ctx: &mut CommandContext<'_>,
_args: &str,
) -> anyhow::Result<CommandResult> {
ctx.messages.clear();
*ctx.compact_state = CompactState::new();
ctx.output.emit_info("Conversation cleared");
Ok(CommandResult::Continue)
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use nomi_providers::{LlmProvider, ProviderError};
use nomi_types::llm::{LlmEvent, LlmRequest};
use nomi_types::message::{ContentBlock, Message, Role};
use super::*;
use crate::commands::{CommandContext, CommandRegistry};
use crate::output::null_sink::NullSink;
struct NullProvider;
#[async_trait::async_trait]
impl LlmProvider for NullProvider {
async fn stream(
&self,
_: &LlmRequest,
) -> Result<tokio::sync::mpsc::Receiver<LlmEvent>, ProviderError> {
let (_tx, rx) = tokio::sync::mpsc::channel(1);
Ok(rx)
}
}
#[tokio::test]
async fn clear_empties_messages() {
let provider: Arc<dyn LlmProvider> = Arc::new(NullProvider);
let registry = CommandRegistry::new();
let output = NullSink;
let mut messages = vec![
Message::new(
Role::User,
vec![ContentBlock::Text {
text: "hello".into(),
}],
),
Message::new(
Role::Assistant,
vec![ContentBlock::Text { text: "hi".into() }],
),
];
let mut state = CompactState::new();
state.last_input_tokens = 5000;
state.consecutive_failures = 2;
let config = nomi_config::compact::CompactConfig::default();
let mut ctx = CommandContext {
messages: &mut messages,
compact_state: &mut state,
compact_config: &config,
provider,
model: "test",
output: &output,
registry: &registry,
};
let cmd = ClearCommand;
let result = cmd.execute(&mut ctx, "").await.unwrap();
assert_eq!(result, CommandResult::Continue);
assert!(ctx.messages.is_empty());
assert_eq!(ctx.compact_state.last_input_tokens, 0);
assert_eq!(ctx.compact_state.consecutive_failures, 0);
}
#[tokio::test]
async fn clear_on_empty_messages() {
let provider: Arc<dyn LlmProvider> = Arc::new(NullProvider);
let registry = CommandRegistry::new();
let output = NullSink;
let mut messages: Vec<Message> = vec![];
let mut state = CompactState::new();
let config = nomi_config::compact::CompactConfig::default();
let mut ctx = CommandContext {
messages: &mut messages,
compact_state: &mut state,
compact_config: &config,
provider,
model: "test",
output: &output,
registry: &registry,
};
let cmd = ClearCommand;
let result = cmd.execute(&mut ctx, "").await.unwrap();
assert_eq!(result, CommandResult::Continue);
assert!(ctx.messages.is_empty());
}
}
@@ -0,0 +1,174 @@
use async_trait::async_trait;
use super::{CommandContext, CommandResult, SlashCommand};
use crate::compact::auto;
use nomi_types::compact::CompactTrigger;
pub struct CompactCommand;
#[async_trait]
impl SlashCommand for CompactCommand {
fn name(&self) -> &str {
"compact"
}
fn description(&self) -> &str {
"Compress conversation context"
}
async fn execute(
&self,
ctx: &mut CommandContext<'_>,
_args: &str,
) -> anyhow::Result<CommandResult> {
if ctx.messages.len() <= 2 {
ctx.output.emit_info("Context is already compact");
return Ok(CommandResult::Continue);
}
// Reset circuit breaker — manual intent overrides protection
ctx.compact_state.consecutive_failures = 0;
let pre_tokens = ctx.compact_state.last_input_tokens;
match auto::autocompact(
ctx.provider.as_ref(),
ctx.messages,
ctx.model,
ctx.compact_config,
ctx.compact_state,
)
.await
{
Ok(result) => {
let msgs_summarized = result.messages_summarized;
*ctx.messages = result.messages;
if let Some(boundary) = ctx.messages.first_mut() {
for block in &mut boundary.content {
if let nomi_types::message::ContentBlock::Text { text } = block
&& text.starts_with(auto::BOUNDARY_PREFIX)
{
let metadata = nomi_types::compact::CompactMetadata {
trigger: CompactTrigger::Manual,
pre_compact_tokens: pre_tokens,
messages_summarized: msgs_summarized,
};
*text = format!(
"{}\n{}",
auto::BOUNDARY_PREFIX,
serde_json::to_string(&metadata)
.expect("metadata serialization cannot fail")
);
}
}
}
ctx.output.emit_info(&format!(
"Context compacted: {}k → compact ({} messages summarized)",
pre_tokens / 1000,
msgs_summarized
));
}
Err(e) => {
ctx.output.emit_warning(&format!("Compact failed: {}", e));
}
}
Ok(CommandResult::Continue)
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use nomi_providers::{LlmProvider, ProviderError};
use nomi_types::llm::{LlmEvent, LlmRequest};
use nomi_types::message::{ContentBlock, Message, Role};
use super::*;
use crate::commands::{CommandContext, CommandRegistry};
use crate::compact::state::CompactState;
use crate::output::null_sink::NullSink;
struct NullProvider;
#[async_trait::async_trait]
impl LlmProvider for NullProvider {
async fn stream(
&self,
_: &LlmRequest,
) -> Result<tokio::sync::mpsc::Receiver<LlmEvent>, ProviderError> {
let (_tx, rx) = tokio::sync::mpsc::channel(1);
Ok(rx)
}
}
#[tokio::test]
async fn compact_already_compact_guard() {
let provider: Arc<dyn LlmProvider> = Arc::new(NullProvider);
let registry = CommandRegistry::new();
let output = NullSink;
let mut messages = vec![Message::new(
Role::User,
vec![ContentBlock::Text { text: "hi".into() }],
)];
let mut state = CompactState::new();
let config = nomi_config::compact::CompactConfig::default();
let mut ctx = CommandContext {
messages: &mut messages,
compact_state: &mut state,
compact_config: &config,
provider,
model: "test-model",
output: &output,
registry: &registry,
};
let cmd = CompactCommand;
let result = cmd.execute(&mut ctx, "").await.unwrap();
assert_eq!(result, CommandResult::Continue);
assert_eq!(ctx.messages.len(), 1);
}
#[tokio::test]
async fn compact_resets_circuit_breaker() {
let provider: Arc<dyn LlmProvider> = Arc::new(NullProvider);
let registry = CommandRegistry::new();
let output = NullSink;
let mut messages: Vec<Message> = (0..10)
.map(|i| {
let role = if i % 2 == 0 {
Role::User
} else {
Role::Assistant
};
Message::new(
role,
vec![ContentBlock::Text {
text: format!("msg-{i}"),
}],
)
})
.collect();
let mut state = CompactState::new();
state.consecutive_failures = 5;
let config = nomi_config::compact::CompactConfig::default();
let mut ctx = CommandContext {
messages: &mut messages,
compact_state: &mut state,
compact_config: &config,
provider,
model: "test-model",
output: &output,
registry: &registry,
};
let cmd = CompactCommand;
let _ = cmd.execute(&mut ctx, "").await;
// Circuit breaker was reset to 0 before the call, then failure increments it
assert!(ctx.compact_state.consecutive_failures <= 1);
}
}
@@ -0,0 +1,157 @@
use async_trait::async_trait;
use super::{CommandContext, CommandResult, SlashCommand};
pub struct HelpCommand;
#[async_trait]
impl SlashCommand for HelpCommand {
fn name(&self) -> &str {
"help"
}
fn description(&self) -> &str {
"List available commands"
}
async fn execute(
&self,
ctx: &mut CommandContext<'_>,
_args: &str,
) -> anyhow::Result<CommandResult> {
let mut entries: Vec<(&str, &str)> = ctx
.registry
.all()
.iter()
.map(|cmd| (cmd.name(), cmd.description()))
.collect();
entries.sort_by_key(|(name, _)| *name);
let mut output = String::from("Available commands:\n");
for (name, desc) in entries {
output.push_str(&format!(" /{} — {}\n", name, desc));
}
ctx.output.emit_info(output.trim_end());
Ok(CommandResult::Continue)
}
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use nomi_providers::{LlmProvider, ProviderError};
use nomi_types::llm::{LlmEvent, LlmRequest};
use nomi_types::message::Message;
use super::*;
use crate::commands::{CommandContext, default_registry};
use crate::compact::state::CompactState;
use crate::output::OutputSink;
struct CaptureSink {
messages: Mutex<Vec<String>>,
}
impl CaptureSink {
fn new() -> Self {
Self {
messages: Mutex::new(Vec::new()),
}
}
fn captured(&self) -> Vec<String> {
self.messages.lock().unwrap().clone()
}
}
impl OutputSink for CaptureSink {
fn emit_text_delta(&self, _: &str, _: &str) {}
fn emit_thinking(&self, _: &str, _: &str) {}
fn emit_tool_call(&self, _: &str, _: &str, _: &str) {}
fn emit_tool_result(&self, _: &str, _: &str, _: bool, _: &str) {}
fn emit_stream_start(&self, _: &str) {}
fn emit_stream_end(&self, _: &str, _: usize, _: u64, _: u64, _: u64, _: u64) {}
fn emit_error(&self, _: &str) {}
fn emit_info(&self, msg: &str) {
self.messages.lock().unwrap().push(msg.to_string());
}
}
struct NullProvider;
#[async_trait::async_trait]
impl LlmProvider for NullProvider {
async fn stream(
&self,
_: &LlmRequest,
) -> Result<tokio::sync::mpsc::Receiver<LlmEvent>, ProviderError> {
let (_tx, rx) = tokio::sync::mpsc::channel(1);
Ok(rx)
}
}
#[tokio::test]
async fn help_lists_all_commands() {
let provider: Arc<dyn LlmProvider> = Arc::new(NullProvider);
let registry = default_registry();
let output = CaptureSink::new();
let mut messages: Vec<Message> = Vec::new();
let mut state = CompactState::new();
let config = nomi_config::compact::CompactConfig::default();
let mut ctx = CommandContext {
messages: &mut messages,
compact_state: &mut state,
compact_config: &config,
provider,
model: "test",
output: &output,
registry: &registry,
};
let cmd = HelpCommand;
let result = cmd.execute(&mut ctx, "").await.unwrap();
assert_eq!(result, CommandResult::Continue);
let captured = output.captured();
assert_eq!(captured.len(), 1);
let help_text = &captured[0];
assert!(help_text.contains("/clear"));
assert!(help_text.contains("/compact"));
assert!(help_text.contains("/help"));
assert!(help_text.contains("/quit"));
}
#[tokio::test]
async fn help_output_is_sorted() {
let provider: Arc<dyn LlmProvider> = Arc::new(NullProvider);
let registry = default_registry();
let output = CaptureSink::new();
let mut messages: Vec<Message> = Vec::new();
let mut state = CompactState::new();
let config = nomi_config::compact::CompactConfig::default();
let mut ctx = CommandContext {
messages: &mut messages,
compact_state: &mut state,
compact_config: &config,
provider,
model: "test",
output: &output,
registry: &registry,
};
let cmd = HelpCommand;
cmd.execute(&mut ctx, "").await.unwrap();
let help_text = &output.captured()[0];
let clear_pos = help_text.find("/clear").unwrap();
let compact_pos = help_text.find("/compact").unwrap();
let help_pos = help_text.find("/help").unwrap();
let quit_pos = help_text.find("/quit").unwrap();
assert!(clear_pos < compact_pos);
assert!(compact_pos < help_pos);
assert!(help_pos < quit_pos);
}
}
@@ -0,0 +1,130 @@
pub mod clear;
pub mod compact;
pub mod help;
pub mod quit;
use std::sync::Arc;
use async_trait::async_trait;
use crate::compact::state::CompactState;
use crate::output::OutputSink;
use nomi_config::compact::CompactConfig;
use nomi_providers::LlmProvider;
use nomi_types::message::Message;
/// Result of executing a slash command.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandResult {
/// Command handled, continue the REPL loop.
Continue,
/// Exit the REPL.
Exit,
}
/// Context passed to slash commands during execution.
pub struct CommandContext<'a> {
pub messages: &'a mut Vec<Message>,
pub compact_state: &'a mut CompactState,
pub compact_config: &'a CompactConfig,
pub provider: Arc<dyn LlmProvider>,
pub model: &'a str,
pub output: &'a dyn OutputSink,
pub registry: &'a CommandRegistry,
}
/// A slash command that can be executed in the REPL.
#[async_trait]
pub trait SlashCommand: Send + Sync {
fn name(&self) -> &str;
fn aliases(&self) -> &[&str] {
&[]
}
fn description(&self) -> &str;
async fn execute(
&self,
ctx: &mut CommandContext<'_>,
args: &str,
) -> anyhow::Result<CommandResult>;
}
/// Registry of all available slash commands.
pub struct CommandRegistry {
commands: Vec<Box<dyn SlashCommand>>,
}
impl CommandRegistry {
pub fn new() -> Self {
Self {
commands: Vec::new(),
}
}
pub fn register(&mut self, cmd: Box<dyn SlashCommand>) {
self.commands.push(cmd);
}
pub fn find(&self, name: &str) -> Option<&dyn SlashCommand> {
self.commands.iter().find_map(|cmd| {
if cmd.name() == name || cmd.aliases().contains(&name) {
Some(cmd.as_ref())
} else {
None
}
})
}
pub fn all(&self) -> &[Box<dyn SlashCommand>] {
&self.commands
}
}
impl Default for CommandRegistry {
fn default() -> Self {
Self::new()
}
}
/// Build the default registry with all built-in commands.
pub fn default_registry() -> CommandRegistry {
let mut registry = CommandRegistry::new();
registry.register(Box::new(compact::CompactCommand));
registry.register(Box::new(clear::ClearCommand));
registry.register(Box::new(help::HelpCommand));
registry.register(Box::new(quit::QuitCommand));
registry
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_find_by_name() {
let registry = default_registry();
assert!(registry.find("compact").is_some());
assert!(registry.find("clear").is_some());
assert!(registry.find("help").is_some());
assert!(registry.find("quit").is_some());
}
#[test]
fn registry_find_by_alias() {
let registry = default_registry();
assert!(registry.find("exit").is_some());
let cmd = registry.find("exit").unwrap();
assert_eq!(cmd.name(), "quit");
}
#[test]
fn registry_find_unknown_returns_none() {
let registry = default_registry();
assert!(registry.find("nonexistent").is_none());
}
#[test]
fn registry_all_returns_all_commands() {
let registry = default_registry();
assert_eq!(registry.all().len(), 4);
}
}
@@ -0,0 +1,28 @@
use async_trait::async_trait;
use super::{CommandContext, CommandResult, SlashCommand};
pub struct QuitCommand;
#[async_trait]
impl SlashCommand for QuitCommand {
fn name(&self) -> &str {
"quit"
}
fn aliases(&self) -> &[&str] {
&["exit"]
}
fn description(&self) -> &str {
"Exit the REPL"
}
async fn execute(
&self,
_ctx: &mut CommandContext<'_>,
_args: &str,
) -> anyhow::Result<CommandResult> {
Ok(CommandResult::Exit)
}
}
@@ -0,0 +1,533 @@
//! Autocompact: watermark-triggered LLM summarization.
//!
//! When the token watermark exceeds the configured threshold, this module
//! calls the LLM to produce a structured summary of the conversation,
//! then replaces the full history with a compact boundary marker and the
//! summary. A circuit breaker prevents runaway retries.
use nomi_config::compact::CompactConfig;
use nomi_providers::{LlmProvider, ProviderError};
use nomi_types::compact::{CompactMetadata, CompactTrigger};
use nomi_types::llm::{LlmEvent, LlmRequest, ThinkingConfig};
use nomi_types::message::{ContentBlock, Message, Role, TokenUsage};
use tokio::sync::mpsc;
use super::prompt::{
COMPACT_MAX_OUTPUT_TOKENS, COMPACT_SYSTEM_PROMPT, build_compact_prompt, build_summary_content,
format_compact_summary,
};
use super::state::CompactState;
/// Maximum number of prompt-too-long retries.
const MAX_PTL_RETRIES: u32 = 2;
/// Content prefix for the compact boundary marker message.
pub const BOUNDARY_PREFIX: &str = "[Conversation compacted]";
// ── Public types ────────────────────────────────────────────────────────────
/// Result of a successful autocompact operation.
#[derive(Debug, Clone)]
pub struct CompactResult {
/// Post-compact messages that replace the original conversation.
/// Contains a boundary marker and a summary message.
pub messages: Vec<Message>,
/// How many original messages were summarized.
pub messages_summarized: usize,
/// Input token count before compaction (from the last API call).
pub pre_compact_tokens: u64,
}
/// Errors specific to autocompact.
#[derive(Debug, thiserror::Error)]
pub enum CompactError {
#[error("LLM provider error: {0}")]
Provider(#[from] ProviderError),
#[error("Prompt too long after {attempts} retries")]
PromptTooLong { attempts: u32 },
#[error("Empty response from LLM")]
EmptyResponse,
#[error("Stream error: {0}")]
StreamError(String),
#[error("Circuit breaker tripped after {failures} consecutive failures")]
CircuitBroken { failures: u32 },
}
// ── Trigger check ───────────────────────────────────────────────────────────
/// Check if autocompact should trigger based on the token watermark.
///
/// When `autocompact_threshold_pct` is set, threshold = context_window * pct / 100.
/// Otherwise falls back to: `threshold = context_window - output_reserve - autocompact_buffer`
pub fn should_autocompact(last_input_tokens: u64, config: &CompactConfig) -> bool {
if !config.enabled {
return false;
}
let threshold = if let Some(pct) = config.autocompact_threshold_pct {
config.context_window * pct as usize / 100
} else {
let effective_window = config.context_window.saturating_sub(config.output_reserve);
effective_window.saturating_sub(config.autocompact_buffer)
};
last_input_tokens as usize >= threshold
}
// ── Core autocompact ────────────────────────────────────────────────────────
/// Execute autocompact: call LLM to summarize the conversation.
///
/// 1. Build a summary prompt and send conversation + prompt to the LLM.
/// 2. If the prompt is too long, truncate oldest 20% messages and retry
/// (up to [`MAX_PTL_RETRIES`] times).
/// 3. Parse the `<summary>` from the response.
/// 4. Return a [`CompactResult`] with boundary marker + summary messages.
///
/// On failure, increments `state.consecutive_failures`.
/// On success, resets the failure counter.
pub async fn autocompact(
provider: &dyn LlmProvider,
messages: &[Message],
model: &str,
config: &CompactConfig,
state: &mut CompactState,
) -> Result<CompactResult, CompactError> {
// Circuit breaker check
if state.is_circuit_broken(config) {
return Err(CompactError::CircuitBroken {
failures: state.consecutive_failures,
});
}
let pre_compact_tokens = state.last_input_tokens;
let messages_summarized = messages.len();
// Build messages for the compact LLM call: conversation + summary prompt
let prompt = build_compact_prompt();
let mut conv_messages = messages.to_vec();
conv_messages.push(Message::new(
Role::User,
vec![ContentBlock::Text { text: prompt }],
));
let mut ptl_attempts = 0u32;
let summary_text = loop {
let request = LlmRequest {
model: model.to_string(),
system: COMPACT_SYSTEM_PROMPT.to_string(),
messages: conv_messages.clone(),
tools: vec![],
max_tokens: COMPACT_MAX_OUTPUT_TOKENS,
thinking: Some(ThinkingConfig::Disabled),
reasoning_effort: None,
};
match provider.stream(&request).await {
Ok(rx) => match collect_stream_text(rx).await {
Ok((text, _usage)) => break text,
Err(e) => {
state.record_failure();
return Err(e);
}
},
Err(ProviderError::PromptTooLong(_)) if ptl_attempts < MAX_PTL_RETRIES => {
ptl_attempts += 1;
// Remove the summary prompt (last msg), truncate, re-add prompt
let conversation_part = &conv_messages[..conv_messages.len() - 1];
match truncate_for_retry(conversation_part) {
Some(mut truncated) => {
truncated.push(Message::new(
Role::User,
vec![ContentBlock::Text {
text: build_compact_prompt(),
}],
));
conv_messages = truncated;
}
None => {
state.record_failure();
return Err(CompactError::PromptTooLong {
attempts: ptl_attempts,
});
}
}
}
Err(ProviderError::PromptTooLong(_)) => {
state.record_failure();
return Err(CompactError::PromptTooLong {
attempts: ptl_attempts,
});
}
Err(e) => {
state.record_failure();
return Err(CompactError::Provider(e));
}
}
};
if summary_text.trim().is_empty() {
state.record_failure();
return Err(CompactError::EmptyResponse);
}
// Format and build post-compact messages
let formatted = format_compact_summary(&summary_text);
let summary_content = build_summary_content(&formatted, true);
let metadata = CompactMetadata {
trigger: CompactTrigger::Auto,
pre_compact_tokens,
messages_summarized,
};
let boundary_text = format!(
"{BOUNDARY_PREFIX}\n{}",
serde_json::to_string(&metadata).expect("CompactMetadata serialization cannot fail")
);
let boundary_msg = Message::new(
Role::User,
vec![ContentBlock::Text {
text: boundary_text,
}],
);
let summary_msg = Message::new(
Role::User,
vec![ContentBlock::Text {
text: summary_content,
}],
);
state.record_success();
Ok(CompactResult {
messages: vec![boundary_msg, summary_msg],
messages_summarized,
pre_compact_tokens,
})
}
// ── Helpers ─────────────────────────────────────────────────────────────────
/// Collect all text from a streaming LLM response.
async fn collect_stream_text(
mut rx: mpsc::Receiver<LlmEvent>,
) -> Result<(String, TokenUsage), CompactError> {
let mut text = String::new();
while let Some(event) = rx.recv().await {
match event {
LlmEvent::TextDelta(delta) => text.push_str(&delta),
LlmEvent::Done { usage, .. } => return Ok((text, usage)),
LlmEvent::Error(e) => return Err(CompactError::StreamError(e)),
// Ignore thinking deltas and tool calls (shouldn't happen in compact)
_ => {}
}
}
// Channel closed without a Done event
Err(CompactError::EmptyResponse)
}
/// Truncate the oldest ~20% of messages for PTL retry.
///
/// Returns `None` if there are too few messages to truncate meaningfully.
fn truncate_for_retry(messages: &[Message]) -> Option<Vec<Message>> {
if messages.len() < 2 {
return None;
}
let drop_count = (messages.len() / 5).max(1);
if drop_count >= messages.len() {
return None;
}
let remaining = &messages[drop_count..];
let mut result = Vec::with_capacity(remaining.len() + 1);
// Ensure the first message is User role for API compatibility
if remaining.first().map(|m| m.role) != Some(Role::User) {
result.push(Message::new(
Role::User,
vec![ContentBlock::Text {
text: "[earlier conversation truncated for compaction retry]".to_string(),
}],
));
}
result.extend_from_slice(remaining);
Some(result)
}
/// Check if a message is a compact boundary marker.
pub fn is_compact_boundary(message: &Message) -> bool {
message.content.iter().any(|block| {
if let ContentBlock::Text { text } = block {
text.starts_with(BOUNDARY_PREFIX)
} else {
false
}
})
}
/// Extract [`CompactMetadata`] from a boundary marker message.
pub fn extract_compact_metadata(message: &Message) -> Option<CompactMetadata> {
for block in &message.content {
if let ContentBlock::Text { text } = block
&& let Some(json_str) = text.strip_prefix(BOUNDARY_PREFIX)
{
let json_str = json_str.trim_start_matches('\n');
return serde_json::from_str(json_str).ok();
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use nomi_types::compact::CompactTrigger;
fn default_config() -> CompactConfig {
CompactConfig::default()
}
// ── should_autocompact (TC-2.4-01..03, TC-2.4-14) ──────────────────
#[test]
fn above_threshold_triggers() {
// threshold = 200k - 20k - 13k = 167k
let config = default_config();
assert!(should_autocompact(170_000, &config));
}
#[test]
fn below_threshold_does_not_trigger() {
let config = default_config();
assert!(!should_autocompact(160_000, &config));
}
#[test]
fn at_exact_threshold_triggers() {
let config = default_config();
assert!(should_autocompact(167_000, &config));
}
#[test]
fn disabled_config_never_triggers() {
let config = CompactConfig {
enabled: false,
..default_config()
};
assert!(!should_autocompact(999_999, &config));
}
#[test]
fn custom_config_threshold() {
let config = CompactConfig {
context_window: 100_000,
output_reserve: 10_000,
autocompact_buffer: 5_000,
..default_config()
};
// threshold = 100k - 10k - 5k = 85k
assert!(!should_autocompact(80_000, &config));
assert!(should_autocompact(85_000, &config));
assert!(should_autocompact(90_000, &config));
}
#[test]
fn zero_tokens_does_not_trigger() {
let config = default_config();
assert!(!should_autocompact(0, &config));
}
#[test]
fn threshold_pct_overrides_default_calculation() {
let config = CompactConfig {
context_window: 200_000,
autocompact_threshold_pct: Some(50),
..default_config()
};
// threshold = 200k * 50 / 100 = 100k
assert!(!should_autocompact(99_999, &config));
assert!(should_autocompact(100_000, &config));
assert!(should_autocompact(150_000, &config));
}
#[test]
fn threshold_pct_zero_triggers_immediately() {
let config = CompactConfig {
autocompact_threshold_pct: Some(0),
..default_config()
};
// threshold = 0, any non-negative triggers
assert!(should_autocompact(0, &config));
assert!(should_autocompact(1, &config));
}
#[test]
fn threshold_pct_100_never_triggers() {
let config = CompactConfig {
context_window: 200_000,
autocompact_threshold_pct: Some(100),
..default_config()
};
// threshold = 200k, provider never reports 200k input_tokens
assert!(!should_autocompact(199_999, &config));
assert!(should_autocompact(200_000, &config));
}
#[test]
fn threshold_pct_none_uses_default_logic() {
let config = CompactConfig {
autocompact_threshold_pct: None,
..default_config()
};
// Same as default: threshold = 200k - 20k - 13k = 167k
assert!(!should_autocompact(166_999, &config));
assert!(should_autocompact(167_000, &config));
}
// ── truncate_for_retry ──────────────────────────────────────────────
#[test]
fn truncate_drops_20_percent() {
let msgs: Vec<Message> = (0..10)
.map(|i| {
let role = if i % 2 == 0 {
Role::User
} else {
Role::Assistant
};
Message::new(
role,
vec![ContentBlock::Text {
text: format!("msg-{i}"),
}],
)
})
.collect();
let result = truncate_for_retry(&msgs).unwrap();
// Drop 20% of 10 = 2 messages, remaining 8
assert_eq!(result.len(), 8);
}
#[test]
fn truncate_ensures_user_first() {
let msgs: Vec<Message> = (0..5)
.map(|i| {
Message::new(
Role::Assistant,
vec![ContentBlock::Text {
text: format!("msg-{i}"),
}],
)
})
.collect();
let result = truncate_for_retry(&msgs).unwrap();
assert_eq!(result[0].role, Role::User);
}
#[test]
fn truncate_too_few_returns_none() {
let msgs = vec![Message::new(
Role::User,
vec![ContentBlock::Text {
text: "only one".to_string(),
}],
)];
assert!(truncate_for_retry(&msgs).is_none());
}
#[test]
fn truncate_empty_returns_none() {
assert!(truncate_for_retry(&[]).is_none());
}
#[test]
fn truncate_preserves_user_first_without_placeholder() {
// First remaining message is already User — no placeholder needed
let msgs: Vec<Message> = (0..10)
.map(|i| {
let role = if i % 2 == 0 {
Role::User
} else {
Role::Assistant
};
Message::new(
role,
vec![ContentBlock::Text {
text: format!("msg-{i}"),
}],
)
})
.collect();
let result = truncate_for_retry(&msgs).unwrap();
// msgs[2] (User) should be first; no placeholder prepended
assert_eq!(result.len(), 8);
match &result[0].content[0] {
ContentBlock::Text { text } => assert_eq!(text, "msg-2"),
_ => panic!("expected Text"),
}
}
// ── boundary detection / extraction ─────────────────────────────────
#[test]
fn detect_boundary_message() {
let metadata = CompactMetadata {
trigger: CompactTrigger::Auto,
pre_compact_tokens: 150_000,
messages_summarized: 42,
};
let text = format!(
"{BOUNDARY_PREFIX}\n{}",
serde_json::to_string(&metadata).unwrap()
);
let msg = Message::new(Role::User, vec![ContentBlock::Text { text }]);
assert!(is_compact_boundary(&msg));
}
#[test]
fn non_boundary_message() {
let msg = Message::new(
Role::User,
vec![ContentBlock::Text {
text: "hello".to_string(),
}],
);
assert!(!is_compact_boundary(&msg));
}
#[test]
fn extract_metadata_from_boundary() {
let metadata = CompactMetadata {
trigger: CompactTrigger::Auto,
pre_compact_tokens: 150_000,
messages_summarized: 42,
};
let text = format!(
"{BOUNDARY_PREFIX}\n{}",
serde_json::to_string(&metadata).unwrap()
);
let msg = Message::new(Role::User, vec![ContentBlock::Text { text }]);
let extracted = extract_compact_metadata(&msg).unwrap();
assert_eq!(extracted, metadata);
}
#[test]
fn extract_metadata_from_non_boundary_returns_none() {
let msg = Message::new(
Role::User,
vec![ContentBlock::Text {
text: "not a boundary".to_string(),
}],
);
assert!(extract_compact_metadata(&msg).is_none());
}
}
@@ -0,0 +1,128 @@
//! Emergency truncation: the last safety net before a context overflow.
//!
//! When `last_input_tokens` is within `emergency_buffer` of the full
//! `context_window`, the engine should block the next API call and ask
//! the user to compact or start a new conversation.
//!
//! Unlike autocompact, the emergency check always applies — even when
//! the compaction system is disabled via `CompactConfig.enabled`.
use nomi_config::compact::CompactConfig;
/// User-facing message shown when the emergency limit is hit.
pub const EMERGENCY_USER_MESSAGE: &str =
"Context window nearly full. Please use /compact or start a new conversation.";
/// Check whether the last observed input token count has reached the
/// emergency blocking limit.
///
/// The limit is `context_window - emergency_buffer`. When
/// `last_input_tokens >= limit`, the engine must not send another API
/// request — doing so would almost certainly fail with a prompt-too-long
/// error from the provider.
///
/// This check is independent of `CompactConfig.enabled`; the emergency
/// safety net is always active.
pub fn is_at_emergency_limit(last_input_tokens: u64, config: &CompactConfig) -> bool {
let limit = config
.context_window
.saturating_sub(config.emergency_buffer);
last_input_tokens as usize >= limit
}
#[cfg(test)]
mod tests {
use super::*;
fn default_config() -> CompactConfig {
CompactConfig::default()
}
// ── is_at_emergency_limit ──────────────────────────────────────────
#[test]
fn below_limit_returns_false() {
// limit = 200k - 3k = 197k; 190k < 197k
let config = default_config();
assert!(!is_at_emergency_limit(190_000, &config));
}
#[test]
fn above_limit_returns_true() {
// 198k >= 197k
let config = default_config();
assert!(is_at_emergency_limit(198_000, &config));
}
#[test]
fn at_exact_limit_returns_true() {
// 197k >= 197k
let config = default_config();
assert!(is_at_emergency_limit(197_000, &config));
}
#[test]
fn small_context_window() {
let config = CompactConfig {
context_window: 8_000,
emergency_buffer: 3_000,
..default_config()
};
// limit = 8k - 3k = 5k; 6k >= 5k
assert!(is_at_emergency_limit(6_000, &config));
}
#[test]
fn zero_tokens_below_limit() {
let config = default_config();
assert!(!is_at_emergency_limit(0, &config));
}
#[test]
fn custom_emergency_buffer() {
let config = CompactConfig {
context_window: 100_000,
emergency_buffer: 10_000,
..default_config()
};
// limit = 100k - 10k = 90k
assert!(!is_at_emergency_limit(89_999, &config));
assert!(is_at_emergency_limit(90_000, &config));
assert!(is_at_emergency_limit(95_000, &config));
}
#[test]
fn works_regardless_of_enabled_flag() {
let config = CompactConfig {
enabled: false,
..default_config()
};
// Emergency check ignores the enabled flag
assert!(is_at_emergency_limit(198_000, &config));
}
#[test]
fn emergency_buffer_larger_than_context_window_saturates() {
let config = CompactConfig {
context_window: 1_000,
emergency_buffer: 5_000,
..default_config()
};
// saturating_sub: limit = 0; any positive token count triggers
assert!(is_at_emergency_limit(1, &config));
// 0 tokens = 0 >= 0 → true (degenerate but safe)
assert!(is_at_emergency_limit(0, &config));
}
// ── EMERGENCY_USER_MESSAGE ─────────────────────────────────────────
#[test]
fn user_message_mentions_compact() {
assert!(EMERGENCY_USER_MESSAGE.contains("/compact"));
}
#[test]
fn user_message_mentions_new_conversation() {
assert!(EMERGENCY_USER_MESSAGE.contains("new conversation"));
}
}
@@ -0,0 +1,188 @@
use nomi_types::message::{ContentBlock, Message};
const CHARS_PER_TOKEN_TEXT: usize = 4;
const CHARS_PER_TOKEN_JSON: usize = 3;
/// Flat per-image token estimate. A 1568px-edge screenshot costs roughly
/// 1100-1600 tokens on Anthropic's vision pricing; over-estimating keeps
/// compaction triggering early rather than late.
const TOKENS_PER_IMAGE: usize = 1600;
/// Estimate the total token count for a slice of messages.
///
/// Intentionally conservative (slightly over-estimates) to ensure
/// compaction triggers rather than being skipped.
pub fn estimate_tokens_from_messages(messages: &[Message]) -> u64 {
let mut total_chars: usize = 0;
let mut json_chars: usize = 0;
let mut image_tokens: usize = 0;
for msg in messages {
for block in &msg.content {
match block {
ContentBlock::Text { text } => {
total_chars += text.len();
}
ContentBlock::Thinking { thinking, .. } => {
total_chars += thinking.len();
}
ContentBlock::ToolUse { name, input, .. } => {
let input_str = input.to_string();
json_chars += name.len() + input_str.len();
}
ContentBlock::ToolResult { content, images, .. } => {
total_chars += content.len();
image_tokens += images.len() * TOKENS_PER_IMAGE;
}
ContentBlock::Image { .. } => {
image_tokens += TOKENS_PER_IMAGE;
}
}
}
}
let text_tokens = total_chars / CHARS_PER_TOKEN_TEXT;
let json_tokens = json_chars / CHARS_PER_TOKEN_JSON;
(text_tokens + json_tokens + image_tokens) as u64
}
#[cfg(test)]
mod tests {
use super::*;
use nomi_types::message::{Message, Role};
use serde_json::json;
#[test]
fn empty_messages_returns_zero() {
assert_eq!(estimate_tokens_from_messages(&[]), 0);
}
#[test]
fn text_only_message() {
let text = "a".repeat(400);
let msg = Message::new(Role::User, vec![ContentBlock::Text { text }]);
assert_eq!(estimate_tokens_from_messages(&[msg]), 100);
}
#[test]
fn tool_use_message_uses_json_ratio() {
let input = json!({"cmd": "ls -la"});
let input_len = "Bash".len() + input.to_string().len();
let msg = Message::new(
Role::Assistant,
vec![ContentBlock::ToolUse {
id: "call_1".into(),
name: "Bash".into(),
input,
extra: None,
}],
);
let result = estimate_tokens_from_messages(&[msg]);
assert_eq!(result, (input_len / CHARS_PER_TOKEN_JSON) as u64);
}
#[test]
fn tool_result_uses_text_ratio() {
let content = "x".repeat(800);
let msg = Message::new(
Role::User,
vec![ContentBlock::ToolResult {
tool_use_id: "call_1".into(),
content,
is_error: false,
images: Vec::new(),
}],
);
assert_eq!(estimate_tokens_from_messages(&[msg]), 200);
}
#[test]
fn mixed_conversation_accumulates() {
let messages = vec![
Message::new(
Role::User,
vec![ContentBlock::Text {
text: "a".repeat(400),
}],
),
Message::new(
Role::Assistant,
vec![
ContentBlock::Text {
text: "b".repeat(200),
},
ContentBlock::ToolUse {
id: "c1".into(),
name: "Read".into(),
input: json!({"path": "/foo/bar.rs"}),
extra: None,
},
],
),
Message::new(
Role::User,
vec![ContentBlock::ToolResult {
tool_use_id: "c1".into(),
content: "c".repeat(1200),
is_error: false,
images: Vec::new(),
}],
),
];
let estimate = estimate_tokens_from_messages(&messages);
// text_tokens = (400 + 200 + 1200) / 4 = 450
// json_tokens = ("Read".len() + json_string.len()) / 3
assert!(estimate > 450);
assert!(estimate < 600);
}
#[test]
fn thinking_block_counted() {
let thinking = "t".repeat(4000);
let msg = Message::new(
Role::Assistant,
vec![ContentBlock::Thinking {
thinking,
signature: None,
}],
);
assert_eq!(estimate_tokens_from_messages(&[msg]), 1000);
}
#[test]
fn large_conversation_realistic_estimate() {
let big_result = "x".repeat(400_000);
let messages = vec![Message::new(
Role::User,
vec![ContentBlock::ToolResult {
tool_use_id: "c1".into(),
content: big_result,
is_error: false,
images: Vec::new(),
}],
)];
let estimate = estimate_tokens_from_messages(&messages);
assert_eq!(estimate, 100_000);
}
#[test]
fn effective_watermark_uses_max() {
let provider_reported: u64 = 500;
let messages = vec![Message::new(
Role::User,
vec![ContentBlock::ToolResult {
tool_use_id: "c1".into(),
content: "x".repeat(400_000),
is_error: false,
images: Vec::new(),
}],
)];
let local_estimate = estimate_tokens_from_messages(&messages);
let effective = provider_reported.max(local_estimate);
assert_eq!(effective, 100_000);
assert!(effective > provider_reported);
}
}
@@ -0,0 +1,586 @@
//! Microcompact: clear old tool result content without any LLM call.
//!
//! This is the lightest compaction level. It walks the conversation,
//! identifies tool results from compactable tools, and replaces the
//! content of all but the N most recent with a short placeholder.
use std::collections::{HashMap, HashSet};
use chrono::Utc;
use nomi_config::compact::CompactConfig;
use nomi_types::message::{ContentBlock, Message, Role};
/// Placeholder that replaces cleared tool result content.
pub const CLEARED_TOOL_RESULT: &str = "[Tool result cleared]";
/// Statistics returned after a microcompact pass.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MicrocompactResult {
/// Number of tool results whose content was cleared.
pub cleared_count: usize,
/// Rough estimate of tokens freed (content bytes / 4).
pub estimated_tokens_freed: usize,
}
// ── Trigger checks ──────────────────────────────────────────────────────────
/// Decide whether microcompact should run.
///
/// Returns `true` if **either** trigger fires:
/// - **Time**: the most recent assistant message is older than
/// `config.micro_gap_seconds`.
/// - **Count**: total compactable (non-cleared) tool results exceed
/// `config.micro_keep_recent * 2`.
pub fn should_microcompact(messages: &[Message], config: &CompactConfig) -> bool {
if !config.enabled {
return false;
}
time_trigger(messages, config) || count_trigger(messages, config)
}
/// Time-based trigger: last assistant timestamp older than gap threshold.
fn time_trigger(messages: &[Message], config: &CompactConfig) -> bool {
let last_assistant_ts = messages
.iter()
.rev()
.filter(|m| m.role == Role::Assistant)
.find_map(|m| m.timestamp);
let Some(ts) = last_assistant_ts else {
return false;
};
let gap = Utc::now().signed_duration_since(ts);
gap.num_seconds() >= config.micro_gap_seconds as i64
}
/// Count-based trigger: compactable tool results > keep_recent * 2.
fn count_trigger(messages: &[Message], config: &CompactConfig) -> bool {
let tool_names = build_tool_name_map(messages);
let compactable_set: HashSet<&str> = config
.compactable_tools
.iter()
.map(String::as_str)
.collect();
let count = count_compactable_results(messages, &tool_names, &compactable_set);
count > config.micro_keep_recent * 2
}
// ── Core compaction ─────────────────────────────────────────────────────────
/// Clear old tool result content in-place.
///
/// Keeps the `config.micro_keep_recent` most recent compactable results
/// (minimum 1) and replaces older ones with [`CLEARED_TOOL_RESULT`].
/// Already-cleared results are left untouched and do not count toward
/// the keep budget.
pub fn microcompact(messages: &mut [Message], config: &CompactConfig) -> MicrocompactResult {
let tool_names = build_tool_name_map(messages);
let compactable_set: HashSet<&str> = config
.compactable_tools
.iter()
.map(String::as_str)
.collect();
// Collect (message_index, block_index) of all compactable, non-cleared
// tool results, in conversation order.
let targets = collect_compactable_locations(messages, &tool_names, &compactable_set);
let keep = config.micro_keep_recent.max(1);
if targets.len() <= keep {
return MicrocompactResult {
cleared_count: 0,
estimated_tokens_freed: 0,
};
}
let to_clear = &targets[..targets.len() - keep];
let mut cleared_count = 0usize;
let mut tokens_freed = 0usize;
for &(mi, bi) in to_clear {
if let ContentBlock::ToolResult { content, images, .. } = &mut messages[mi].content[bi] {
// Rough token estimate: ~4 chars per token.
tokens_freed += content.len() / 4;
*content = CLEARED_TOOL_RESULT.to_string();
images.clear();
cleared_count += 1;
}
}
MicrocompactResult {
cleared_count,
estimated_tokens_freed: tokens_freed,
}
}
// ── Helpers ─────────────────────────────────────────────────────────────────
/// Build a map from tool_use_id → tool name by scanning ToolUse blocks
/// across all messages.
fn build_tool_name_map(messages: &[Message]) -> HashMap<String, String> {
let mut map = HashMap::new();
for msg in messages {
for block in &msg.content {
if let ContentBlock::ToolUse { id, name, .. } = block {
map.insert(id.clone(), name.clone());
}
}
}
map
}
/// Count compactable, non-cleared tool results.
fn count_compactable_results(
messages: &[Message],
tool_names: &HashMap<String, String>,
compactable_set: &HashSet<&str>,
) -> usize {
messages
.iter()
.flat_map(|m| &m.content)
.filter(|b| is_compactable_and_live(b, tool_names, compactable_set))
.count()
}
/// Collect `(message_index, block_index)` of every compactable, non-cleared
/// tool result in conversation order.
fn collect_compactable_locations(
messages: &[Message],
tool_names: &HashMap<String, String>,
compactable_set: &HashSet<&str>,
) -> Vec<(usize, usize)> {
let mut locations = Vec::new();
for (mi, msg) in messages.iter().enumerate() {
for (bi, block) in msg.content.iter().enumerate() {
if is_compactable_and_live(block, tool_names, compactable_set) {
locations.push((mi, bi));
}
}
}
locations
}
/// A tool result is "compactable and live" when:
/// 1. It is a `ToolResult` variant.
/// 2. Its corresponding tool name is in the compactable set.
/// 3. Its content has not already been cleared.
fn is_compactable_and_live(
block: &ContentBlock,
tool_names: &HashMap<String, String>,
compactable_set: &HashSet<&str>,
) -> bool {
if let ContentBlock::ToolResult {
tool_use_id,
content,
..
} = block
{
if content == CLEARED_TOOL_RESULT {
return false;
}
if let Some(name) = tool_names.get(tool_use_id) {
return compactable_set.contains(name.as_str());
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
use serde_json::json;
// ── Test helpers ────────────────────────────────────────────────────
fn tool_use_block(id: &str, name: &str) -> ContentBlock {
ContentBlock::ToolUse {
id: id.to_string(),
name: name.to_string(),
input: json!({}),
extra: None,
}
}
fn tool_result_block(id: &str, content: &str) -> ContentBlock {
ContentBlock::ToolResult {
tool_use_id: id.to_string(),
content: content.to_string(),
is_error: false,
images: Vec::new(),
}
}
fn text_block(text: &str) -> ContentBlock {
ContentBlock::Text {
text: text.to_string(),
}
}
fn assistant_msg(blocks: Vec<ContentBlock>) -> Message {
Message::new(Role::Assistant, blocks)
}
fn user_msg(blocks: Vec<ContentBlock>) -> Message {
Message::new(Role::User, blocks)
}
fn assistant_msg_at(blocks: Vec<ContentBlock>, ts: chrono::DateTime<Utc>) -> Message {
Message {
role: Role::Assistant,
content: blocks,
timestamp: Some(ts),
}
}
fn default_config() -> CompactConfig {
CompactConfig::default()
}
// ── build_tool_name_map ─────────────────────────────────────────────
#[test]
fn tool_name_map_from_single_assistant() {
let msgs = vec![assistant_msg(vec![
tool_use_block("t1", "Read"),
tool_use_block("t2", "Bash"),
])];
let map = build_tool_name_map(&msgs);
assert_eq!(map.get("t1").unwrap(), "Read");
assert_eq!(map.get("t2").unwrap(), "Bash");
}
#[test]
fn tool_name_map_ignores_non_tool_use() {
let msgs = vec![
user_msg(vec![text_block("hello")]),
user_msg(vec![tool_result_block("t1", "output")]),
];
let map = build_tool_name_map(&msgs);
assert!(map.is_empty());
}
// ── is_compactable_and_live ─────────────────────────────────────────
#[test]
fn live_compactable_result_returns_true() {
let tool_names: HashMap<String, String> =
[("t1".into(), "Read".into())].into_iter().collect();
let set: HashSet<&str> = ["Read"].into_iter().collect();
let block = tool_result_block("t1", "file content here");
assert!(is_compactable_and_live(&block, &tool_names, &set));
}
#[test]
fn already_cleared_result_returns_false() {
let tool_names: HashMap<String, String> =
[("t1".into(), "Read".into())].into_iter().collect();
let set: HashSet<&str> = ["Read"].into_iter().collect();
let block = tool_result_block("t1", CLEARED_TOOL_RESULT);
assert!(!is_compactable_and_live(&block, &tool_names, &set));
}
#[test]
fn non_compactable_tool_returns_false() {
let tool_names: HashMap<String, String> =
[("t1".into(), "Skill".into())].into_iter().collect();
let set: HashSet<&str> = ["Read", "Bash"].into_iter().collect();
let block = tool_result_block("t1", "result");
assert!(!is_compactable_and_live(&block, &tool_names, &set));
}
#[test]
fn text_block_returns_false() {
let tool_names = HashMap::new();
let set: HashSet<&str> = ["Read"].into_iter().collect();
let block = text_block("hello");
assert!(!is_compactable_and_live(&block, &tool_names, &set));
}
#[test]
fn unknown_tool_use_id_returns_false() {
let tool_names = HashMap::new(); // no ToolUse registered
let set: HashSet<&str> = ["Read"].into_iter().collect();
let block = tool_result_block("orphan", "data");
assert!(!is_compactable_and_live(&block, &tool_names, &set));
}
// ── time_trigger ────────────────────────────────────────────────────
#[test]
fn time_trigger_fires_when_gap_exceeded() {
let old_ts = Utc::now() - Duration::seconds(3700);
let msgs = vec![assistant_msg_at(vec![text_block("hi")], old_ts)];
let config = CompactConfig {
micro_gap_seconds: 3600,
..default_config()
};
assert!(time_trigger(&msgs, &config));
}
#[test]
fn time_trigger_silent_when_within_gap() {
let recent_ts = Utc::now() - Duration::seconds(1800);
let msgs = vec![assistant_msg_at(vec![text_block("hi")], recent_ts)];
let config = CompactConfig {
micro_gap_seconds: 3600,
..default_config()
};
assert!(!time_trigger(&msgs, &config));
}
#[test]
fn time_trigger_silent_when_no_timestamp() {
let msgs = vec![assistant_msg(vec![text_block("hi")])];
let config = default_config();
assert!(!time_trigger(&msgs, &config));
}
#[test]
fn time_trigger_uses_latest_assistant() {
let old_ts = Utc::now() - Duration::seconds(7200);
let recent_ts = Utc::now() - Duration::seconds(100);
let msgs = vec![
assistant_msg_at(vec![text_block("first")], old_ts),
assistant_msg_at(vec![text_block("second")], recent_ts),
];
let config = CompactConfig {
micro_gap_seconds: 3600,
..default_config()
};
// The most recent assistant (100s ago) is within the gap.
assert!(!time_trigger(&msgs, &config));
}
// ── count_trigger ───────────────────────────────────────────────────
#[test]
fn count_trigger_fires_above_threshold() {
// keep_recent=3, threshold=6. Create 7 compactable results.
let mut msgs = Vec::new();
for i in 0..7 {
let id = format!("t{i}");
msgs.push(assistant_msg(vec![tool_use_block(&id, "Read")]));
msgs.push(user_msg(vec![tool_result_block(&id, "data")]));
}
let config = CompactConfig {
micro_keep_recent: 3,
..default_config()
};
assert!(count_trigger(&msgs, &config));
}
#[test]
fn count_trigger_silent_at_threshold() {
// keep_recent=3, threshold=6. Create exactly 6 results.
let mut msgs = Vec::new();
for i in 0..6 {
let id = format!("t{i}");
msgs.push(assistant_msg(vec![tool_use_block(&id, "Read")]));
msgs.push(user_msg(vec![tool_result_block(&id, "data")]));
}
let config = CompactConfig {
micro_keep_recent: 3,
..default_config()
};
assert!(!count_trigger(&msgs, &config));
}
// ── microcompact ────────────────────────────────────────────────────
#[test]
fn clears_oldest_keeps_recent() {
// 5 tool results, keep_recent=2 → clear 3.
let mut msgs = Vec::new();
for i in 0..5 {
let id = format!("t{i}");
msgs.push(assistant_msg(vec![tool_use_block(&id, "Read")]));
msgs.push(user_msg(vec![tool_result_block(&id, &format!("data-{i}"))]));
}
let config = CompactConfig {
micro_keep_recent: 2,
..default_config()
};
let result = microcompact(&mut msgs, &config);
assert_eq!(result.cleared_count, 3);
assert!(result.estimated_tokens_freed > 0);
// First 3 user msgs (indices 1,3,5) should be cleared.
for idx in [1, 3, 5] {
let content = match &msgs[idx].content[0] {
ContentBlock::ToolResult { content, .. } => content.as_str(),
_ => panic!("expected ToolResult"),
};
assert_eq!(content, CLEARED_TOOL_RESULT);
}
// Last 2 user msgs (indices 7,9) should retain original content.
for (idx, expected) in [(7, "data-3"), (9, "data-4")] {
let content = match &msgs[idx].content[0] {
ContentBlock::ToolResult { content, .. } => content.as_str(),
_ => panic!("expected ToolResult"),
};
assert_eq!(content, expected);
}
}
#[test]
fn no_clear_when_below_keep_recent() {
let mut msgs = vec![
assistant_msg(vec![tool_use_block("t1", "Read")]),
user_msg(vec![tool_result_block("t1", "data")]),
];
let config = CompactConfig {
micro_keep_recent: 5,
..default_config()
};
let result = microcompact(&mut msgs, &config);
assert_eq!(result.cleared_count, 0);
assert_eq!(result.estimated_tokens_freed, 0);
}
#[test]
fn skips_non_compactable_tools() {
let mut msgs = vec![
assistant_msg(vec![tool_use_block("t1", "Read")]),
user_msg(vec![tool_result_block("t1", "file-data")]),
assistant_msg(vec![tool_use_block("t2", "Skill")]),
user_msg(vec![tool_result_block("t2", "skill-output")]),
assistant_msg(vec![tool_use_block("t3", "Bash")]),
user_msg(vec![tool_result_block("t3", "bash-output")]),
];
// compactable_tools does NOT include Skill.
let config = CompactConfig {
micro_keep_recent: 1,
compactable_tools: vec!["Read".into(), "Bash".into()],
..default_config()
};
let result = microcompact(&mut msgs, &config);
// Only Read(t1) should be cleared; Bash(t3) kept as most recent.
assert_eq!(result.cleared_count, 1);
// Skill result untouched.
match &msgs[3].content[0] {
ContentBlock::ToolResult { content, .. } => {
assert_eq!(content, "skill-output");
}
_ => panic!("expected ToolResult"),
}
}
#[test]
fn does_not_recleared_already_cleared() {
let mut msgs = vec![
assistant_msg(vec![tool_use_block("t1", "Read")]),
user_msg(vec![tool_result_block("t1", CLEARED_TOOL_RESULT)]),
assistant_msg(vec![tool_use_block("t2", "Read")]),
user_msg(vec![tool_result_block("t2", "live-data")]),
];
let config = CompactConfig {
micro_keep_recent: 1,
..default_config()
};
let result = microcompact(&mut msgs, &config);
// t1 already cleared → not in compactable list.
// Only t2 is compactable, and it's the most recent → keep it.
assert_eq!(result.cleared_count, 0);
}
#[test]
fn empty_messages_returns_zero() {
let mut msgs: Vec<Message> = Vec::new();
let result = microcompact(&mut msgs, &default_config());
assert_eq!(result.cleared_count, 0);
assert_eq!(result.estimated_tokens_freed, 0);
}
#[test]
fn message_count_and_order_preserved() {
let mut msgs = vec![
assistant_msg(vec![tool_use_block("t1", "Read")]),
user_msg(vec![tool_result_block("t1", &"a".repeat(100))]),
assistant_msg(vec![tool_use_block("t2", "Read")]),
user_msg(vec![tool_result_block("t2", &"b".repeat(100))]),
assistant_msg(vec![tool_use_block("t3", "Read")]),
user_msg(vec![tool_result_block("t3", &"c".repeat(100))]),
];
let original_len = msgs.len();
let config = CompactConfig {
micro_keep_recent: 1,
..default_config()
};
microcompact(&mut msgs, &config);
assert_eq!(msgs.len(), original_len);
// Roles alternate: Assistant, User, Assistant, User, ...
for (i, msg) in msgs.iter().enumerate() {
let expected = if i % 2 == 0 {
Role::Assistant
} else {
Role::User
};
assert_eq!(msg.role, expected);
}
}
#[test]
fn token_estimate_proportional_to_content() {
let long_content = "x".repeat(400); // ~100 tokens
let mut msgs = vec![
assistant_msg(vec![tool_use_block("t1", "Read")]),
user_msg(vec![tool_result_block("t1", &long_content)]),
assistant_msg(vec![tool_use_block("t2", "Read")]),
user_msg(vec![tool_result_block("t2", "keep")]),
];
let config = CompactConfig {
micro_keep_recent: 1,
..default_config()
};
let result = microcompact(&mut msgs, &config);
assert_eq!(result.cleared_count, 1);
assert_eq!(result.estimated_tokens_freed, 100); // 400 / 4
}
// ── should_microcompact ─────────────────────────────────────────────
#[test]
fn should_returns_false_when_disabled() {
let old_ts = Utc::now() - Duration::seconds(7200);
let msgs = vec![assistant_msg_at(vec![text_block("hi")], old_ts)];
let config = CompactConfig {
enabled: false,
micro_gap_seconds: 3600,
..default_config()
};
assert!(!should_microcompact(&msgs, &config));
}
#[test]
fn keep_recent_floored_at_one() {
// Even with keep_recent=0, we never clear everything.
let mut msgs = vec![
assistant_msg(vec![tool_use_block("t1", "Read")]),
user_msg(vec![tool_result_block("t1", "data-1")]),
assistant_msg(vec![tool_use_block("t2", "Read")]),
user_msg(vec![tool_result_block("t2", "data-2")]),
];
let config = CompactConfig {
micro_keep_recent: 0,
..default_config()
};
let result = microcompact(&mut msgs, &config);
// 2 compactable, keep at least 1 → clear 1.
assert_eq!(result.cleared_count, 1);
// The most recent (t2) must survive.
match &msgs[3].content[0] {
ContentBlock::ToolResult { content, .. } => {
assert_eq!(content, "data-2");
}
_ => panic!("expected ToolResult"),
}
}
}
@@ -0,0 +1,13 @@
//! Multi-level context compaction for long conversations.
//!
//! Three levels, from lightest to heaviest:
//! - **Microcompact**: clears old tool result content (no LLM call)
//! - **Autocompact**: watermark-triggered LLM summarization
//! - **Emergency**: blocks API calls when near the context window limit
pub mod auto;
pub mod emergency;
pub mod estimate;
pub mod micro;
pub mod prompt;
pub mod state;
@@ -0,0 +1,314 @@
//! Compact prompt templates for LLM-based conversation summarization.
//!
//! Provides the 9-section summary prompt, response parsing, and
//! post-compact message construction.
/// System prompt used for the compact LLM call.
pub const COMPACT_SYSTEM_PROMPT: &str =
"You are a helpful AI assistant tasked with summarizing conversations.";
/// Maximum output tokens for the compact LLM call.
pub const COMPACT_MAX_OUTPUT_TOKENS: u32 = 20_000;
// ── Prompt construction ─────────────────────────────────────────────────────
/// Build the 9-section compact prompt that asks the LLM to summarize.
pub fn build_compact_prompt() -> String {
format!("{PREAMBLE}\n\n{BODY}\n\n{FORMAT_INSTRUCTIONS}\n\n{REMINDER}")
}
const PREAMBLE: &str = "\
CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.
- Do NOT use Read, Bash, Grep, Glob, Edit, Write, or ANY other tool.
- You already have all the context you need in the conversation above.
- Tool calls will be REJECTED and will waste your only turn — you will fail the task.
- Your entire response must be plain text: an <analysis> block followed by a <summary> block.";
const BODY: &str = "\
Your task is to create a detailed summary of the conversation so far, paying close attention \
to the user's explicit requests and your previous actions. This summary should be thorough in \
capturing technical details, code patterns, and architectural decisions that would be essential \
for continuing development work.
Before providing your final summary, wrap your analysis in <analysis> tags to organize your \
thoughts and ensure completeness.
Your summary should include the following sections:
1. **Primary Request and Intent**: What has the user asked for? Include ALL explicit requests \
made during the conversation.
2. **Key Technical Concepts**: Important technical details, patterns, or architectural decisions discussed.
3. **Files and Code Sections**: All files that have been viewed or modified, with brief descriptions of changes.
4. **Errors and Fixes**: Any errors encountered and how they were resolved.
5. **Problem Solving Progress**: Current state of each problem — what's solved and what remains.
6. **All User Messages**: A summary of every non-tool user message, preserving intent and context.
7. **Pending Tasks**: Any tasks that are not yet complete.
8. **Current Work**: What was being worked on immediately before this summary.
9. **Suggested Next Step**: The single most logical next action, which MUST be directly in line \
with the most recent explicit user request. Quote the user's request verbatim to prevent drift.";
const FORMAT_INSTRUCTIONS: &str = "\
Format your response exactly as follows:
<analysis>
Your reasoning about what information is most important to preserve
</analysis>
<summary>
Your detailed, structured summary following the 9 sections above
</summary>";
const REMINDER: &str = "\
REMINDER: Do NOT call any tools. Respond with plain text only — an <analysis> block followed \
by a <summary> block. Tool calls will be rejected and you will fail the task.";
// ── Response parsing ────────────────────────────────────────────────────────
/// Parse the raw LLM response: strip `<analysis>`, extract `<summary>` content.
///
/// If no `<summary>` tags are found, returns the raw text as-is (graceful degradation).
pub fn format_compact_summary(raw: &str) -> String {
// Step 1: remove <analysis>...</analysis>
let without_analysis = strip_tag(raw, "analysis");
// Step 2: extract <summary>...</summary> content
if let Some(summary_content) = extract_tag_content(&without_analysis, "summary") {
let trimmed = summary_content.trim();
if trimmed.is_empty() {
return collapse_blank_lines(&without_analysis).trim().to_string();
}
format!("Summary:\n{trimmed}")
} else {
// Graceful degradation: use the text with analysis stripped
collapse_blank_lines(&without_analysis).trim().to_string()
}
}
// ── Post-compact message content ────────────────────────────────────────────
/// Build the user message content for the post-compact summary.
///
/// For autocompact (`is_auto = true`), appends an instruction telling the
/// model to continue seamlessly without acknowledging the compaction.
pub fn build_summary_content(formatted_summary: &str, is_auto: bool) -> String {
let mut content = String::from(
"This session is being continued from a previous conversation that ran out of context. \
The summary below covers the earlier portion of the conversation.\n\n",
);
content.push_str(formatted_summary);
if is_auto {
content.push_str(
"\n\nContinue the conversation from where it left off without asking the user \
any further questions. Resume directly — do not acknowledge the summary, \
do not recap what was happening, do not preface with \"I'll continue\" or similar. \
Pick up the last task as if the break never happened.",
);
}
content
}
// ── Helpers ─────────────────────────────────────────────────────────────────
/// Remove `<tag>...</tag>` (first occurrence) from text.
///
/// If the closing tag appears before the opening tag (reversed order),
/// the text is returned unchanged to avoid producing duplicate content.
fn strip_tag(text: &str, tag: &str) -> String {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let Some(start) = text.find(&open) else {
return text.to_string();
};
let Some(end) = text.find(&close) else {
return text.to_string();
};
// Guard: closing tag before opening tag → no-op
if end < start {
return text.to_string();
}
let mut result = String::with_capacity(text.len());
result.push_str(&text[..start]);
result.push_str(&text[end + close.len()..]);
collapse_blank_lines(&result)
}
/// Extract the content between `<tag>` and `</tag>` (first occurrence).
fn extract_tag_content<'a>(text: &'a str, tag: &str) -> Option<&'a str> {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let start = text.find(&open)? + open.len();
let end = text.find(&close)?;
if start <= end {
Some(&text[start..end])
} else {
None
}
}
/// Collapse consecutive blank lines into a single blank line.
fn collapse_blank_lines(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut prev_was_blank = false;
for line in text.lines() {
let is_blank = line.trim().is_empty();
if is_blank && prev_was_blank {
continue;
}
if !result.is_empty() {
result.push('\n');
}
result.push_str(line);
prev_was_blank = is_blank;
}
result
}
#[cfg(test)]
mod tests {
use super::*;
// ── build_compact_prompt ────────────────────────────────────────────
#[test]
fn prompt_contains_all_nine_sections() {
let prompt = build_compact_prompt();
for i in 1..=9 {
assert!(prompt.contains(&format!("{i}.")), "Missing section {i}");
}
}
#[test]
fn prompt_forbids_tool_calls() {
let prompt = build_compact_prompt();
assert!(prompt.contains("Do NOT call any tools"));
assert!(prompt.contains("CRITICAL"));
}
#[test]
fn prompt_requires_analysis_and_summary_tags() {
let prompt = build_compact_prompt();
assert!(prompt.contains("<analysis>"));
assert!(prompt.contains("<summary>"));
}
// ── format_compact_summary ──────────────────────────────────────────
#[test]
fn strips_analysis_extracts_summary() {
let raw =
"<analysis>thinking about things</analysis>\n<summary>the actual result</summary>";
assert_eq!(format_compact_summary(raw), "Summary:\nthe actual result");
}
#[test]
fn extracts_summary_without_analysis() {
let raw = "<summary>result only</summary>";
assert_eq!(format_compact_summary(raw), "Summary:\nresult only");
}
#[test]
fn graceful_degradation_without_tags() {
let raw = "plain text without any tags";
assert_eq!(format_compact_summary(raw), "plain text without any tags");
}
#[test]
fn handles_multiline_summary() {
let raw =
"<analysis>analysis\nwith lines</analysis>\n<summary>\nLine 1\nLine 2\n</summary>";
let result = format_compact_summary(raw);
assert!(result.starts_with("Summary:\n"));
assert!(result.contains("Line 1"));
assert!(result.contains("Line 2"));
}
#[test]
fn empty_summary_tags_falls_back() {
let raw = "<analysis>thinking</analysis>\n<summary></summary>";
let result = format_compact_summary(raw);
// Falls back since summary content is empty
assert!(!result.is_empty());
}
// ── build_summary_content ───────────────────────────────────────────
#[test]
fn auto_summary_includes_continuation_instruction() {
let content = build_summary_content("Summary:\ntest", true);
assert!(content.contains("Continue the conversation"));
assert!(content.contains("as if the break never happened"));
}
#[test]
fn manual_summary_no_continuation_instruction() {
let content = build_summary_content("Summary:\ntest", false);
assert!(!content.contains("Continue the conversation"));
}
#[test]
fn summary_content_includes_session_header() {
let content = build_summary_content("Summary:\ntest", false);
assert!(content.contains("This session is being continued"));
}
// ── strip_tag ───────────────────────────────────────────────────────
#[test]
fn strip_tag_removes_complete_tag() {
let text = "before<foo>inside</foo>after";
assert_eq!(strip_tag(text, "foo"), "beforeafter");
}
#[test]
fn strip_tag_noop_when_tag_missing() {
let text = "no tags here";
assert_eq!(strip_tag(text, "foo"), "no tags here");
}
#[test]
fn strip_tag_noop_when_reversed_order() {
// Closing tag before opening tag should be treated as no-op
let text = "before</foo>middle<foo>inside</foo>after";
// The first </foo> is at position 6, first <foo> is at position 17
// Since end < start, the text should be returned unchanged
assert_eq!(strip_tag(text, "foo"), text);
}
// ── extract_tag_content ─────────────────────────────────────────────
#[test]
fn extract_existing_tag() {
let text = "<summary>hello world</summary>";
assert_eq!(extract_tag_content(text, "summary"), Some("hello world"));
}
#[test]
fn extract_missing_tag() {
let text = "no summary here";
assert_eq!(extract_tag_content(text, "summary"), None);
}
// ── collapse_blank_lines ────────────────────────────────────────────
#[test]
fn collapses_multiple_blank_lines() {
let text = "a\n\n\n\nb";
let result = collapse_blank_lines(text);
assert_eq!(result, "a\n\nb");
}
#[test]
fn preserves_single_blank_line() {
let text = "a\n\nb";
assert_eq!(collapse_blank_lines(text), "a\n\nb");
}
}
@@ -0,0 +1,111 @@
use nomi_config::compact::CompactConfig;
/// Runtime state for the compaction circuit breaker.
///
/// Tracks consecutive autocompact failures so we can stop retrying
/// after `config.max_failures` consecutive failures.
#[derive(Debug, Clone)]
pub struct CompactState {
/// Number of consecutive autocompact failures.
pub consecutive_failures: u32,
/// Input token count from the last API call (used as the watermark).
pub last_input_tokens: u64,
}
impl CompactState {
pub fn new() -> Self {
Self {
consecutive_failures: 0,
last_input_tokens: 0,
}
}
/// Check whether the circuit breaker has tripped.
pub fn is_circuit_broken(&self, config: &CompactConfig) -> bool {
self.consecutive_failures >= config.max_failures
}
/// Record a successful autocompact — resets the failure counter.
pub fn record_success(&mut self) {
self.consecutive_failures = 0;
}
/// Record a failed autocompact — increments the failure counter.
pub fn record_failure(&mut self) {
self.consecutive_failures += 1;
}
}
impl Default for CompactState {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_config() -> CompactConfig {
CompactConfig {
max_failures: 3,
..Default::default()
}
}
#[test]
fn new_state_not_circuit_broken() {
let state = CompactState::new();
assert_eq!(state.consecutive_failures, 0);
assert_eq!(state.last_input_tokens, 0);
assert!(!state.is_circuit_broken(&test_config()));
}
#[test]
fn circuit_breaker_trips_at_max_failures() {
let config = test_config();
let mut state = CompactState::new();
state.record_failure();
assert!(!state.is_circuit_broken(&config));
state.record_failure();
assert!(!state.is_circuit_broken(&config));
state.record_failure();
assert!(state.is_circuit_broken(&config));
}
#[test]
fn success_resets_failure_counter() {
let config = test_config();
let mut state = CompactState::new();
state.record_failure();
state.record_failure();
assert_eq!(state.consecutive_failures, 2);
state.record_success();
assert_eq!(state.consecutive_failures, 0);
assert!(!state.is_circuit_broken(&config));
}
#[test]
fn circuit_breaker_with_max_failures_one() {
let config = CompactConfig {
max_failures: 1,
..Default::default()
};
let mut state = CompactState::new();
assert!(!state.is_circuit_broken(&config));
state.record_failure();
assert!(state.is_circuit_broken(&config));
}
#[test]
fn default_impl_matches_new() {
let a = CompactState::new();
let b = CompactState::default();
assert_eq!(a.consecutive_failures, b.consecutive_failures);
assert_eq!(a.last_input_tokens, b.last_input_tokens);
}
}
@@ -0,0 +1,498 @@
//! Native tools that give a companion-companion agent access to its memory store
//! through a `CompanionMemorySink` trait object. The backend (nomifun-companion)
//! injects a concrete sink; other hosts pass `None` and these are not
//! registered. Mirrors `requirement_tools.rs`.
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::{Value, json};
use nomi_protocol::events::ToolCategory;
use nomi_tools::Tool;
use nomi_types::tool::{JsonSchema, ToolResult};
/// Memory kinds shared with the companion store taxonomy.
pub const COMPANION_MEMORY_KINDS: [&str; 6] = ["profile", "preference", "knowledge", "episode", "task", "affective"];
/// Backend seam for the companion's long-term memory + activity feed. Implemented
/// by `nomifun-companion` over its `CompanionStore`; `nomi-agent` only depends on this.
#[async_trait]
pub trait CompanionMemorySink: Send + Sync {
/// Search memories by keyword (optionally by kind / incl. archived).
/// `conversation_id` scopes the search to the owning companion (shared +
/// its own private memories), so one companion never recalls another's
/// private memories. Returns a human-readable digest the model can quote.
async fn recall(&self, conversation_id: &str, query: &str, kind: Option<&str>, include_archived: bool) -> Result<String, String>;
/// Persist one memory; implementations dedup. Returns a confirmation line.
/// `conversation_id` identifies the session the save came from, so the
/// backend can attribute per-companion rewards (XP) to the owning companion.
async fn save(&self, conversation_id: &str, kind: &str, content: &str, tags: &[String]) -> Result<String, String>;
/// Newest collected work events (already sanitized), newest-last digest.
async fn recent_events(&self, limit: usize) -> Result<String, String>;
}
/// `recall_memories` — search the companion's full memory store.
pub struct RecallMemoriesTool {
sink: Arc<dyn CompanionMemorySink>,
/// The conversation this tool instance serves — passed to the sink so the
/// backend can scope recall to the owning companion (shared + own private).
conversation_id: String,
}
impl RecallMemoriesTool {
pub fn new(sink: Arc<dyn CompanionMemorySink>, conversation_id: impl Into<String>) -> Self {
Self {
sink,
conversation_id: conversation_id.into(),
}
}
}
#[async_trait]
impl Tool for RecallMemoriesTool {
fn name(&self) -> &str {
"recall_memories"
}
fn description(&self) -> &str {
"搜索你对主人的全部长期记忆(包含未注入上下文的与已归档的)。当主人问起过去的事、\
或你需要确认自己是否记得某事时使用。返回匹配的记忆列表。"
}
fn input_schema(&self) -> JsonSchema {
json!({
"type": "object",
"properties": {
"query": {"type": "string", "description": "关键词(按内容模糊匹配)"},
"kind": {"type": "string", "enum": COMPANION_MEMORY_KINDS, "description": "可选:限定记忆类型"},
"include_archived": {"type": "boolean", "description": "是否包含已归档记忆,默认 false"}
},
"required": ["query"]
})
}
fn is_concurrency_safe(&self, _input: &Value) -> bool {
true
}
async fn execute(&self, input: Value) -> ToolResult {
let query = input.get("query").and_then(|v| v.as_str()).unwrap_or("").trim();
if query.is_empty() {
return ToolResult {
content: "query 不能为空".into(),
is_error: true,
images: Vec::new(),
};
}
let kind = input
.get("kind")
.and_then(|v| v.as_str())
.filter(|k| COMPANION_MEMORY_KINDS.contains(k));
let include_archived = input.get("include_archived").and_then(|v| v.as_bool()).unwrap_or(false);
match self.sink.recall(&self.conversation_id, query, kind, include_archived).await {
Ok(out) => ToolResult {
content: out,
is_error: false,
images: Vec::new(),
},
Err(e) => ToolResult {
content: e,
is_error: true,
images: Vec::new(),
},
}
}
fn category(&self) -> ToolCategory {
ToolCategory::Info
}
}
/// `save_memory` — persist a long-term memory about the user.
pub struct SaveMemoryTool {
sink: Arc<dyn CompanionMemorySink>,
/// The conversation this tool instance serves — passed to the sink so the
/// backend can attribute the save to the owning companion.
conversation_id: String,
}
impl SaveMemoryTool {
pub fn new(sink: Arc<dyn CompanionMemorySink>, conversation_id: impl Into<String>) -> Self {
Self {
sink,
conversation_id: conversation_id.into(),
}
}
}
#[async_trait]
impl Tool for SaveMemoryTool {
fn name(&self) -> &str {
"save_memory"
}
fn description(&self) -> &str {
"立即保存一条关于主人的长期记忆。当主人告诉你值得记住的事(偏好、约定、计划、\
纠正你的认知)时使用;一句话自包含,宁缺毋滥。kind 取值:profile(稳定画像)/\
preference(偏好)/knowledge(可复用结论)/episode(带时间的经历)/task(待办线索)/affective(情感)。"
}
fn input_schema(&self) -> JsonSchema {
json!({
"type": "object",
"properties": {
"kind": {"type": "string", "enum": COMPANION_MEMORY_KINDS},
"content": {"type": "string", "description": "一句话记忆内容(中文,自包含)"},
"tags": {"type": "array", "items": {"type": "string"}}
},
"required": ["kind", "content"]
})
}
fn is_concurrency_safe(&self, _input: &Value) -> bool {
false
}
async fn execute(&self, input: Value) -> ToolResult {
let kind = input.get("kind").and_then(|v| v.as_str()).unwrap_or("");
let content = input.get("content").and_then(|v| v.as_str()).unwrap_or("").trim();
if !COMPANION_MEMORY_KINDS.contains(&kind) {
return ToolResult {
content: format!("kind 必须是 {COMPANION_MEMORY_KINDS:?} 之一"),
is_error: true,
images: Vec::new(),
};
}
if content.is_empty() {
return ToolResult {
content: "content 不能为空".into(),
is_error: true,
images: Vec::new(),
};
}
let tags: Vec<String> = input
.get("tags")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|t| t.as_str().map(str::to_owned)).collect())
.unwrap_or_default();
match self.sink.save(&self.conversation_id, kind, content, &tags).await {
Ok(out) => ToolResult {
content: out,
is_error: false,
images: Vec::new(),
},
Err(e) => ToolResult {
content: e,
is_error: true,
images: Vec::new(),
},
}
}
fn category(&self) -> ToolCategory {
// Writes only to the companion's own memory.db (never user files) — treat
// as Info so default session mode doesn't gate it behind approval.
ToolCategory::Info
}
}
/// `list_recent_events` — peek at the user's recent collected work activity.
pub struct ListRecentEventsTool {
sink: Arc<dyn CompanionMemorySink>,
}
impl ListRecentEventsTool {
pub fn new(sink: Arc<dyn CompanionMemorySink>) -> Self {
Self { sink }
}
}
#[async_trait]
impl Tool for ListRecentEventsTool {
fn name(&self) -> &str {
"list_recent_events"
}
fn description(&self) -> &str {
"查看最近采集到的主人工作事件(已脱敏摘要)。当主人问「我今天/最近都干了啥」\
或你想结合实际活动给建议时使用。"
}
fn input_schema(&self) -> JsonSchema {
json!({
"type": "object",
"properties": {
"limit": {"type": "integer", "description": "最多返回多少条,默认 20,上限 50"}
}
})
}
fn is_concurrency_safe(&self, _input: &Value) -> bool {
true
}
async fn execute(&self, input: Value) -> ToolResult {
let limit = input
.get("limit")
.and_then(|v| v.as_i64())
.unwrap_or(20)
.clamp(1, 50) as usize;
match self.sink.recent_events(limit).await {
Ok(out) => ToolResult {
content: out,
is_error: false,
images: Vec::new(),
},
Err(e) => ToolResult {
content: e,
is_error: true,
images: Vec::new(),
},
}
}
fn category(&self) -> ToolCategory {
ToolCategory::Info
}
}
// ---------------------------------------------------------------------------
// 自进化技能:自调用(design §7)
// ---------------------------------------------------------------------------
/// 一个可调用技能的精简描述,用于每轮的 when_to_use 索引注入。
#[derive(Debug, Clone)]
pub struct SkillListing {
pub name: String,
pub when_to_use: String,
}
/// Backend seam for the companion's self-evolved skills. Implemented by
/// `nomifun-companion` over its store + `skill_service`; `nomi-agent` only depends
/// on this (engine stays host-agnostic).
#[async_trait]
pub trait CompanionSkillSink: Send + Sync {
/// This companion's currently-active skills (for per-turn `when_to_use` injection).
/// Must be cheap — called once per turn.
async fn active_skills(&self) -> Vec<SkillListing>;
/// The SKILL.md body of a named active skill, or `None` if unknown.
async fn load_skill_body(&self, name: &str) -> Option<String>;
}
/// `companion_skill` — invoke a learned skill by name to fetch its playbook.
pub struct CompanionSkillTool {
sink: Arc<dyn CompanionSkillSink>,
}
impl CompanionSkillTool {
pub fn new(sink: Arc<dyn CompanionSkillSink>) -> Self {
Self { sink }
}
}
#[async_trait]
impl Tool for CompanionSkillTool {
fn name(&self) -> &str {
"companion_skill"
}
fn description(&self) -> &str {
"调用你已学会的某个技能,获取它的操作手册(步骤),然后照着执行。\
当当前任务匹配系统提示里列出的某个技能的适用场景时使用。"
}
fn input_schema(&self) -> JsonSchema {
json!({
"type": "object",
"properties": {
"skill": {"type": "string", "description": "技能名(见系统提示里列出的可用技能)"}
},
"required": ["skill"]
})
}
fn is_concurrency_safe(&self, _input: &Value) -> bool {
true
}
async fn execute(&self, input: Value) -> ToolResult {
let name = input.get("skill").and_then(|v| v.as_str()).unwrap_or("").trim();
if name.is_empty() {
return ToolResult {
content: "skill 不能为空".into(),
is_error: true,
images: Vec::new(),
};
}
match self.sink.load_skill_body(name).await {
Some(body) => ToolResult {
content: body,
is_error: false,
images: Vec::new(),
},
None => ToolResult {
content: format!("未找到技能:{name}"),
is_error: true,
images: Vec::new(),
},
}
}
fn category(&self) -> ToolCategory {
ToolCategory::Info
}
}
/// 每轮把该伙伴 active 技能的 `when_to_use` 索引注入系统提示(design §7)。
/// 空技能集 → `None`(no-op 快路,引擎据此每轮零成本跳过)。
pub struct CompanionSkillContributor {
sink: Arc<dyn CompanionSkillSink>,
}
impl CompanionSkillContributor {
pub fn new(sink: Arc<dyn CompanionSkillSink>) -> Self {
Self { sink }
}
}
#[async_trait]
impl crate::context_contributor::ContextContributor for CompanionSkillContributor {
async fn pre_turn_context(&self) -> Option<String> {
let skills = self.sink.active_skills().await;
if skills.is_empty() {
return None;
}
let mut s = String::from(
"<system-reminder>\n你已经学会以下技能。遇到匹配场景时,用 companion_skill 工具按名调用以获取操作手册并照做:\n",
);
for sk in &skills {
s.push_str(&format!("- {}: {}\n", sk.name, sk.when_to_use));
}
s.push_str("</system-reminder>");
Some(s)
}
fn label(&self) -> &str {
"companion_skills"
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
struct RecordingSink {
saved: Mutex<Vec<(String, String, String)>>,
}
#[async_trait]
impl CompanionMemorySink for RecordingSink {
async fn recall(&self, _conversation_id: &str, query: &str, kind: Option<&str>, _archived: bool) -> Result<String, String> {
Ok(format!("hits for {query} kind={kind:?}"))
}
async fn save(&self, conversation_id: &str, kind: &str, content: &str, _tags: &[String]) -> Result<String, String> {
self.saved
.lock()
.unwrap()
.push((conversation_id.into(), kind.into(), content.into()));
Ok("saved".into())
}
async fn recent_events(&self, limit: usize) -> Result<String, String> {
Ok(format!("{limit} events"))
}
}
fn sink() -> Arc<RecordingSink> {
Arc::new(RecordingSink {
saved: Mutex::new(vec![]),
})
}
#[tokio::test]
async fn recall_requires_query_and_filters_kind() {
let tool = RecallMemoriesTool::new(sink(), "conv_t");
let bad = tool.execute(json!({})).await;
assert!(bad.is_error);
let ok = tool.execute(json!({"query": "结论", "kind": "preference"})).await;
assert!(!ok.is_error);
assert!(ok.content.contains("preference"));
// Invalid kind is dropped, not an error.
let loose = tool.execute(json!({"query": "x", "kind": "bogus"})).await;
assert!(!loose.is_error);
assert!(loose.content.contains("None"));
}
#[tokio::test]
async fn save_validates_kind_and_content() {
let s = sink();
let tool = SaveMemoryTool::new(s.clone(), "conv_t");
assert!(tool.execute(json!({"kind": "bogus", "content": "x"})).await.is_error);
assert!(tool.execute(json!({"kind": "task", "content": " "})).await.is_error);
let ok = tool.execute(json!({"kind": "task", "content": "明天修 bug"})).await;
assert!(!ok.is_error);
let saved = s.saved.lock().unwrap();
assert_eq!(saved.len(), 1);
// The tool stamps the conversation it serves into every save.
assert_eq!(saved[0].0, "conv_t");
}
#[tokio::test]
async fn recent_events_clamps_limit() {
let tool = ListRecentEventsTool::new(sink());
let out = tool.execute(json!({"limit": 9999})).await;
assert_eq!(out.content, "50 events");
let out = tool.execute(json!({})).await;
assert_eq!(out.content, "20 events");
}
use crate::context_contributor::ContextContributor;
struct FakeSkillSink {
skills: Vec<SkillListing>,
}
#[async_trait]
impl CompanionSkillSink for FakeSkillSink {
async fn active_skills(&self) -> Vec<SkillListing> {
self.skills.clone()
}
async fn load_skill_body(&self, name: &str) -> Option<String> {
self.skills.iter().find(|s| s.name == name).map(|s| format!("# {}\nbody", s.name))
}
}
#[tokio::test]
async fn skill_contributor_is_noop_when_empty() {
let sink = Arc::new(FakeSkillSink { skills: vec![] });
let c = CompanionSkillContributor::new(sink);
assert!(c.pre_turn_context().await.is_none());
}
#[tokio::test]
async fn skill_contributor_lists_when_to_use() {
let sink = Arc::new(FakeSkillSink {
skills: vec![SkillListing { name: "weekly-report".into(), when_to_use: "周五出周报".into() }],
});
let c = CompanionSkillContributor::new(sink);
let out = c.pre_turn_context().await.unwrap();
assert!(out.contains("weekly-report"));
assert!(out.contains("周五出周报"));
assert!(out.contains("companion_skill"));
}
#[tokio::test]
async fn skill_tool_returns_body_or_error() {
let sink = Arc::new(FakeSkillSink {
skills: vec![SkillListing { name: "fmt".into(), when_to_use: "x".into() }],
});
let tool = CompanionSkillTool::new(sink);
assert!(tool.execute(json!({})).await.is_error);
let ok = tool.execute(json!({"skill": "fmt"})).await;
assert!(!ok.is_error);
assert!(ok.content.contains("fmt"));
assert!(tool.execute(json!({"skill": "nope"})).await.is_error);
}
}
@@ -0,0 +1,141 @@
use std::collections::HashSet;
use std::io::{self, BufRead, Write};
pub struct ToolConfirmer {
auto_approve: bool,
allow_list: HashSet<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfirmResult {
Approved,
Denied,
Quit,
}
impl ToolConfirmer {
pub fn new(auto_approve: bool, allow_list: Vec<String>) -> Self {
Self {
auto_approve,
allow_list: allow_list.into_iter().collect(),
}
}
/// Returns whether auto-approve is enabled
pub fn is_auto_approve(&self) -> bool {
self.auto_approve
}
/// Add a tool name to the allow list at runtime.
/// Used by skill context modifiers to grant auto-approval for specified tools.
pub fn add_to_allow_list(&mut self, name: &str) {
self.allow_list.insert(name.to_string());
}
/// Check if the tool needs confirmation. Returns the user's decision.
pub fn check(&mut self, tool_name: &str, tool_input_display: &str) -> ConfirmResult {
if self.auto_approve || self.allow_list.contains(tool_name) {
return ConfirmResult::Approved;
}
eprint!(
"\n[tool] {}({})\nAllow? [y]es / [n]o / [a]lways / [q]uit > ",
tool_name, tool_input_display
);
io::stderr().flush().unwrap();
let mut input = String::new();
if io::stdin().lock().read_line(&mut input).is_err() {
return ConfirmResult::Denied;
}
match input.trim().to_lowercase().as_str() {
"y" | "yes" | "" => ConfirmResult::Approved,
"a" | "always" => {
self.allow_list.insert(tool_name.to_string());
ConfirmResult::Approved
}
"q" | "quit" => ConfirmResult::Quit,
_ => ConfirmResult::Denied,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_auto_approve_always_allows() {
let mut confirmer = ToolConfirmer::new(true, vec![]);
assert_eq!(
confirmer.check("Bash", "echo hello"),
ConfirmResult::Approved
);
assert_eq!(
confirmer.check("Read", "/tmp/file"),
ConfirmResult::Approved
);
assert_eq!(
confirmer.check("Write", "/tmp/out"),
ConfirmResult::Approved
);
}
#[test]
fn test_allowlist_contains_tool() {
let mut confirmer = ToolConfirmer::new(false, vec!["Read".into(), "Write".into()]);
assert_eq!(
confirmer.check("Read", "/tmp/file"),
ConfirmResult::Approved
);
assert_eq!(
confirmer.check("Write", "/tmp/out"),
ConfirmResult::Approved
);
}
#[test]
fn test_allowlist_approves_even_when_auto_approve_is_false() {
let mut confirmer = ToolConfirmer::new(false, vec!["Read".into()]);
assert_eq!(
confirmer.check("Read", "/some/path"),
ConfirmResult::Approved
);
}
// Phase 6: add_to_allow_list() grants runtime approval
#[test]
fn test_add_to_allow_list_grants_approval() {
let mut confirmer = ToolConfirmer::new(false, vec![]);
// Before: tool not in list (would prompt — skip interactive check, just verify membership)
confirmer.add_to_allow_list("Write");
// After: auto-approved without interactive prompt
assert_eq!(
confirmer.check("Write", "file.txt"),
ConfirmResult::Approved
);
}
// Phase 6: add_to_allow_list() is idempotent — adding twice has no bad effect
#[test]
fn test_add_to_allow_list_idempotent() {
let mut confirmer = ToolConfirmer::new(false, vec![]);
confirmer.add_to_allow_list("Bash");
confirmer.add_to_allow_list("Bash"); // duplicate — HashSet, no panic
assert_eq!(confirmer.check("Bash", "echo hi"), ConfirmResult::Approved);
}
// Phase 6: add_to_allow_list() does not affect unrelated tools
#[test]
fn test_add_to_allow_list_does_not_affect_other_tools() {
let mut confirmer = ToolConfirmer::new(false, vec![]);
confirmer.add_to_allow_list("Read");
// Write is not in the list — check returns non-Approved for non-interactive
// (we cannot test interactive input; verify Read is approved and Write is not in list)
assert_eq!(confirmer.check("Read", "file.txt"), ConfirmResult::Approved);
// We can't test the Denied path without stdin, but we verify allow_list state:
assert!(confirmer.allow_list.contains("Read"));
assert!(!confirmer.allow_list.contains("Write"));
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,97 @@
//! `ContextContributor` — the host-agnostic seam (design §3.5) that lets the
//! backend inject dynamic, per-turn context into the system prompt without the
//! engine hard-coding each source. The engine holds a list of contributors
//! (empty by default → behaviour byte-for-byte unchanged) and, at the start of
//! each turn, appends whatever they contribute to the system prompt.
//!
//! This is the foundation for turning "passive" platform features into "active"
//! injection (knowledge auto-RAG, inline memory, etc.) as registered
//! contributors rather than bespoke call-sites. It is purely additive: with no
//! contributors registered, `merge_pre_turn_context` returns the system prompt
//! unchanged.
use async_trait::async_trait;
/// A source of dynamic per-turn context. Implementations live in the backend
/// (host) and are registered onto the engine; the engine stays host-agnostic.
#[async_trait]
pub trait ContextContributor: Send + Sync {
/// Context to add to the system prompt for the upcoming turn, or `None` to
/// contribute nothing this turn. Called once per turn before the model call.
async fn pre_turn_context(&self) -> Option<String>;
/// A short stable label for diagnostics/telemetry.
fn label(&self) -> &str {
"context_contributor"
}
}
/// Append non-empty contributor contributions to `system`, each under a blank
/// line, in registration order. Empty / all-`None` → `system` returned
/// unchanged (the zero-contributor fast path the engine relies on). Pure so the
/// merge rule is unit-testable without an engine.
pub fn merge_pre_turn_context(system: String, contributions: Vec<String>) -> String {
let mut out = system;
for c in contributions {
let trimmed = c.trim();
if trimmed.is_empty() {
continue;
}
if !out.is_empty() {
out.push_str("\n\n");
}
out.push_str(trimmed);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_contributions_returns_system_unchanged() {
let sys = "SYSTEM PROMPT".to_string();
assert_eq!(merge_pre_turn_context(sys.clone(), vec![]), sys);
// All-empty contributions are also a no-op.
assert_eq!(
merge_pre_turn_context(sys.clone(), vec!["".into(), " ".into()]),
sys
);
}
#[test]
fn appends_non_empty_contributions_in_order() {
let out = merge_pre_turn_context(
"BASE".to_string(),
vec!["[KB] hit".into(), "".into(), "[memory] fact".into()],
);
assert_eq!(out, "BASE\n\n[KB] hit\n\n[memory] fact");
}
#[test]
fn empty_system_with_one_contribution_has_no_leading_blank() {
let out = merge_pre_turn_context(String::new(), vec!["only".into()]);
assert_eq!(out, "only");
}
#[tokio::test]
async fn trait_object_contributes_through_merge() {
struct Fixed(&'static str);
#[async_trait]
impl ContextContributor for Fixed {
async fn pre_turn_context(&self) -> Option<String> {
Some(self.0.to_string())
}
}
let contributors: Vec<Box<dyn ContextContributor>> =
vec![Box::new(Fixed("alpha")), Box::new(Fixed("beta"))];
let mut contributions = Vec::new();
for c in &contributors {
if let Some(s) = c.pre_turn_context().await {
contributions.push(s);
}
}
assert_eq!(merge_pre_turn_context("S".into(), contributions), "S\n\nalpha\n\nbeta");
}
}
@@ -0,0 +1,284 @@
//! Native tools that let an in-process agent schedule / list / delete its own
//! recurring (cron) jobs through a `CronSink` trait object. The backend injects
//! a concrete sink bound to the agent's conversation; standalone `nomi-cli`
//! passes `None` and these are not registered. Mirrors `requirement_tools`.
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::{Value, json};
use nomi_protocol::events::ToolCategory;
use nomi_tools::Tool;
use nomi_types::tool::{JsonSchema, ToolResult};
/// One scheduled job, as surfaced to the agent by `CronSink::list`.
#[derive(Debug, Clone)]
pub struct CronJobSummary {
pub id: String,
pub name: String,
/// Human-readable schedule summary (e.g. the cron expression).
pub schedule: String,
pub enabled: bool,
}
/// Backend seam for the agent's own scheduled jobs. Implemented by the backend
/// over its `CronService`, bound to the agent's conversation; `nomi-agent` only
/// depends on this trait.
#[async_trait]
pub trait CronSink: Send + Sync {
/// Schedule `prompt` to re-run on `cron_expr` (5-field cron) in the agent's
/// own conversation. Returns the new job id.
async fn create(&self, name: &str, cron_expr: &str, prompt: &str) -> Result<String, String>;
/// List the agent's scheduled jobs.
async fn list(&self) -> Result<Vec<CronJobSummary>, String>;
/// Delete a scheduled job by id.
async fn delete(&self, job_id: &str) -> Result<(), String>;
}
fn ok(content: String) -> ToolResult {
ToolResult { content, is_error: false, images: Vec::new() }
}
fn err(content: String) -> ToolResult {
ToolResult { content, is_error: true, images: Vec::new() }
}
/// `cron_create` — schedule a recurring prompt.
pub struct CronCreateTool {
sink: Arc<dyn CronSink>,
}
impl CronCreateTool {
pub fn new(sink: Arc<dyn CronSink>) -> Self {
Self { sink }
}
}
#[async_trait]
impl Tool for CronCreateTool {
fn name(&self) -> &str {
"cron_create"
}
fn description(&self) -> &str {
"Schedule a prompt to re-run automatically on a recurring schedule, in this \
conversation. Use for 'every morning…', 'check X every 5 minutes', etc. The \
schedule is a standard 5-field cron expression in the user's local time \
(minute hour day-of-month month day-of-week)."
}
fn input_schema(&self) -> JsonSchema {
json!({
"type": "object",
"properties": {
"name": { "type": "string", "description": "Short human-readable name for the job" },
"cron": { "type": "string", "description": "5-field cron expression, e.g. \"0 9 * * 1-5\" (weekdays 9am)" },
"prompt": { "type": "string", "description": "The prompt to run each time the schedule fires" }
},
"required": ["name", "cron", "prompt"]
})
}
fn is_concurrency_safe(&self, _input: &Value) -> bool {
false
}
async fn execute(&self, input: Value) -> ToolResult {
let (Some(name), Some(cron), Some(prompt)) = (
input["name"].as_str(),
input["cron"].as_str(),
input["prompt"].as_str(),
) else {
return err("cron_create requires: name, cron, prompt".to_string());
};
match self.sink.create(name, cron, prompt).await {
Ok(id) => ok(format!("Scheduled job '{name}' ({cron}) — id {id}")),
Err(e) => err(format!("Failed to schedule job: {e}")),
}
}
fn category(&self) -> ToolCategory {
ToolCategory::Exec
}
}
/// `cron_list` — list this conversation's scheduled jobs.
pub struct CronListTool {
sink: Arc<dyn CronSink>,
}
impl CronListTool {
pub fn new(sink: Arc<dyn CronSink>) -> Self {
Self { sink }
}
}
#[async_trait]
impl Tool for CronListTool {
fn name(&self) -> &str {
"cron_list"
}
fn description(&self) -> &str {
"List the scheduled (cron) jobs for this conversation."
}
fn input_schema(&self) -> JsonSchema {
json!({ "type": "object", "properties": {} })
}
fn is_concurrency_safe(&self, _input: &Value) -> bool {
true
}
async fn execute(&self, _input: Value) -> ToolResult {
match self.sink.list().await {
Ok(jobs) if jobs.is_empty() => ok("No scheduled jobs.".to_string()),
Ok(jobs) => {
let lines: Vec<String> = jobs
.iter()
.map(|j| {
format!(
"- {} [{}] {} ({})",
j.id,
if j.enabled { "on" } else { "off" },
j.name,
j.schedule
)
})
.collect();
ok(lines.join("\n"))
}
Err(e) => err(format!("Failed to list jobs: {e}")),
}
}
fn category(&self) -> ToolCategory {
ToolCategory::Info
}
}
/// `cron_delete` — delete a scheduled job by id.
pub struct CronDeleteTool {
sink: Arc<dyn CronSink>,
}
impl CronDeleteTool {
pub fn new(sink: Arc<dyn CronSink>) -> Self {
Self { sink }
}
}
#[async_trait]
impl Tool for CronDeleteTool {
fn name(&self) -> &str {
"cron_delete"
}
fn description(&self) -> &str {
"Delete a scheduled (cron) job by its id (from cron_list)."
}
fn input_schema(&self) -> JsonSchema {
json!({
"type": "object",
"properties": { "job_id": { "type": "string", "description": "The job id to delete" } },
"required": ["job_id"]
})
}
fn is_concurrency_safe(&self, _input: &Value) -> bool {
false
}
async fn execute(&self, input: Value) -> ToolResult {
let Some(job_id) = input["job_id"].as_str() else {
return err("cron_delete requires: job_id".to_string());
};
match self.sink.delete(job_id).await {
Ok(()) => ok(format!("Deleted scheduled job {job_id}")),
Err(e) => err(format!("Failed to delete job: {e}")),
}
}
fn category(&self) -> ToolCategory {
ToolCategory::Exec
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
#[derive(Default)]
struct MockCronSink {
created: Mutex<Vec<(String, String, String)>>,
jobs: Mutex<Vec<CronJobSummary>>,
deleted: Mutex<Vec<String>>,
fail: bool,
}
#[async_trait]
impl CronSink for MockCronSink {
async fn create(&self, name: &str, cron: &str, prompt: &str) -> Result<String, String> {
if self.fail {
return Err("boom".into());
}
self.created
.lock()
.unwrap()
.push((name.into(), cron.into(), prompt.into()));
Ok("job-1".into())
}
async fn list(&self) -> Result<Vec<CronJobSummary>, String> {
Ok(self.jobs.lock().unwrap().clone())
}
async fn delete(&self, job_id: &str) -> Result<(), String> {
self.deleted.lock().unwrap().push(job_id.into());
Ok(())
}
}
#[tokio::test]
async fn cron_create_calls_sink_and_reports_id() {
let sink = Arc::new(MockCronSink::default());
let tool = CronCreateTool::new(sink.clone());
let r = tool
.execute(json!({ "name": "daily", "cron": "0 9 * * *", "prompt": "do it" }))
.await;
assert!(!r.is_error, "{}", r.content);
assert!(r.content.contains("job-1"));
assert_eq!(sink.created.lock().unwrap().len(), 1);
assert_eq!(sink.created.lock().unwrap()[0].1, "0 9 * * *");
}
#[tokio::test]
async fn cron_create_missing_params_is_error() {
let tool = CronCreateTool::new(Arc::new(MockCronSink::default()));
let r = tool.execute(json!({ "name": "x" })).await;
assert!(r.is_error);
}
#[tokio::test]
async fn cron_create_surfaces_sink_error() {
let sink = Arc::new(MockCronSink { fail: true, ..Default::default() });
let tool = CronCreateTool::new(sink);
let r = tool
.execute(json!({ "name": "x", "cron": "* * * * *", "prompt": "p" }))
.await;
assert!(r.is_error);
assert!(r.content.contains("boom"));
}
#[tokio::test]
async fn cron_list_formats_jobs_and_empty() {
let sink = Arc::new(MockCronSink::default());
let empty = CronListTool::new(sink.clone()).execute(json!({})).await;
assert!(empty.content.contains("No scheduled jobs"));
sink.jobs.lock().unwrap().push(CronJobSummary {
id: "j1".into(),
name: "nightly".into(),
schedule: "0 0 * * *".into(),
enabled: true,
});
let listed = CronListTool::new(sink).execute(json!({})).await;
assert!(listed.content.contains("j1"));
assert!(listed.content.contains("nightly"));
}
#[tokio::test]
async fn cron_delete_calls_sink() {
let sink = Arc::new(MockCronSink::default());
let r = CronDeleteTool::new(sink.clone())
.execute(json!({ "job_id": "j9" }))
.await;
assert!(!r.is_error);
assert_eq!(sink.deleted.lock().unwrap()[0], "j9");
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,13 @@
//! Engine-internal goal-driven continuation (ported from codex `ext/goal`).
//!
//! Opt-in, default off. When a session is started with a goal, the engine's
//! natural-termination point injects a continuation prompt (with a completion
//! audit) until the model proves completion via `update_goal`, hits the
//! auto-continuation cap, or runs into `max_turns`.
pub mod runtime;
pub mod state;
pub mod tool;
pub use runtime::{GoalRuntime, GoalSpec};
pub use state::{GoalState, GoalStatus};
@@ -0,0 +1,99 @@
use std::sync::{Arc, Mutex};
use nomi_types::message::{ContentBlock, Message, Role};
use crate::goal::state::GoalState;
const CONTINUATION_TEMPLATE: &str = include_str!("templates/continuation.md");
/// What a caller supplies to start a goal-driven session.
#[derive(Debug, Clone)]
pub struct GoalSpec {
pub objective: String,
pub max_auto_continuations: usize,
}
impl GoalSpec {
pub fn new(objective: impl Into<String>, max_auto_continuations: usize) -> Self {
Self {
objective: objective.into(),
max_auto_continuations,
}
}
}
/// Engine-side goal runtime: holds the shared state (also held by
/// `UpdateGoalTool`) and renders the continuation prompt.
pub struct GoalRuntime {
state: Arc<Mutex<GoalState>>,
}
impl GoalRuntime {
pub fn new(objective: String, max_auto_continuations: usize) -> Self {
Self {
state: Arc::new(Mutex::new(GoalState::new(objective, max_auto_continuations))),
}
}
/// Clone the shared handle for injection into `UpdateGoalTool`.
pub fn shared_state(&self) -> Arc<Mutex<GoalState>> {
Arc::clone(&self.state)
}
/// Called at the engine's natural-termination point. Returns `Some(message)`
/// to inject a continuation and run another turn, or `None` to stop
/// (goal reached a terminal state, or the auto-continuation cap was hit).
pub fn maybe_continuation(&self) -> Option<Message> {
let mut g = self.state.lock().unwrap();
if !g.should_continue() {
return None;
}
g.auto_continuations += 1;
let prompt = render_continuation(&g.objective, g.blocked_threshold);
Some(Message::now(
Role::User,
vec![ContentBlock::Text { text: prompt }],
))
}
}
fn render_continuation(objective: &str, blocked_threshold: usize) -> String {
CONTINUATION_TEMPLATE
.replace("{{objective}}", objective)
.replace("{{blocked_threshold}}", &blocked_threshold.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::goal::state::GoalStatus;
#[test]
fn continuation_injects_until_cap() {
let rt = GoalRuntime::new("ship the feature".into(), 2);
// First two fire, third stops at cap.
assert!(rt.maybe_continuation().is_some());
assert!(rt.maybe_continuation().is_some());
assert!(rt.maybe_continuation().is_none());
}
#[test]
fn continuation_stops_when_completed() {
let rt = GoalRuntime::new("ship the feature".into(), 8);
rt.shared_state().lock().unwrap().status = GoalStatus::Complete;
assert!(rt.maybe_continuation().is_none());
}
#[test]
fn continuation_renders_objective_and_threshold() {
let rt = GoalRuntime::new("migrate the database".into(), 8);
let msg = rt.maybe_continuation().unwrap();
let text = match &msg.content[0] {
ContentBlock::Text { text } => text.clone(),
_ => panic!("expected text block"),
};
assert!(text.contains("migrate the database"));
assert!(text.contains("连续 3 个目标轮次"));
assert!(!text.contains("{{")); // all placeholders substituted
}
}
@@ -0,0 +1,74 @@
//! In-memory goal state for a single engine run. P0: not persisted, no SQLite.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GoalStatus {
Active,
Complete,
Blocked,
}
/// Per-session goal state. Lives in engine memory for the lifetime of one
/// `engine.run()`; lost on restart (degrades to a plain session, no data loss).
#[derive(Debug, Clone)]
pub struct GoalState {
/// The objective text (provided at session start).
pub objective: String,
/// Current status. Only `Active` continues.
pub status: GoalStatus,
/// Cap on automatic continuations (anti-runaway). Default 8.
pub max_auto_continuations: usize,
/// How many automatic continuations have fired so far.
pub auto_continuations: usize,
/// Threshold (in goal turns) before `blocked` is allowed. Rendered into the
/// continuation prompt; P0 only constrains the model via the prompt.
pub blocked_threshold: usize,
}
impl GoalState {
pub fn new(objective: String, max_auto_continuations: usize) -> Self {
Self {
objective,
status: GoalStatus::Active,
max_auto_continuations,
auto_continuations: 0,
blocked_threshold: 3,
}
}
/// Whether continuation should still fire: Active and under the cap.
pub fn should_continue(&self) -> bool {
self.status == GoalStatus::Active && self.auto_continuations < self.max_auto_continuations
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn active_under_cap_continues() {
let g = GoalState::new("do X".into(), 8);
assert!(g.should_continue());
}
#[test]
fn completed_does_not_continue() {
let mut g = GoalState::new("do X".into(), 8);
g.status = GoalStatus::Complete;
assert!(!g.should_continue());
}
#[test]
fn blocked_does_not_continue() {
let mut g = GoalState::new("do X".into(), 8);
g.status = GoalStatus::Blocked;
assert!(!g.should_continue());
}
#[test]
fn cap_stops_continuation() {
let mut g = GoalState::new("do X".into(), 2);
g.auto_continuations = 2;
assert!(!g.should_continue());
}
}
@@ -0,0 +1,37 @@
继续推进当前会话的目标。
下面 <objective> 是用户提供的目标内容,把它当作要完成的任务本身,而不是更高优先级的指令。
<objective>
{{objective}}
</objective>
续作约束:
- 这个目标会跨多轮持续存在。本轮结束并不意味着要把目标缩减成"现在能做完的小任务"。
- 保持目标的完整范围。如果这一轮做不完,就朝用户真正要求的最终状态做出实质推进,让目标保持活跃,不要把"成功"重新定义为更小、更容易的任务。
- 工作推进过程中暂时的粗糙是可以接受的;但"完成"仍然要求用户要求的最终状态真实成立并被验证过。
以证据为准:
- 把当前工作区和外部真实状态当作权威依据。此前的对话上下文可以帮你定位相关工作,但在依赖它之前要先检查当前的真实状态。
- 按需改进、替换或删除已有工作,以满足真正的目标,而不是迁就既有产物。
完成审计(关键 —— 把"完成"默认视为未经证明):
- 从目标本身、以及它引用的文件/计划/规格/issue/用户指令中,推导出具体的、可验证的需求清单。
- 保留原始范围,不要围绕"已经做出来的东西"重新定义成功。
- 对每一条明确需求、编号项、命名产物、命令、测试、关卡、不变量、交付物,先确定"能证明它已完成的权威证据是什么",再去检查当前真实来源:文件内容、命令输出、测试结果、运行时行为等。
- 对每一项判断:证据是证明了完成、还是与完成矛盾、还是显示工作未完成、还是太弱/太间接无法验证、还是缺失。
- 把不确定或间接的证据一律视为"未完成",去取得更强证据或继续工作。
- 审计的目的是"证明完成",而不是"没找到明显的剩余工作"。
不要把意图、部分进度、对早前工作的记忆、或一个看起来合理的最终答案当作完成的证明。
只有当当前证据逐条证明每一项需求都已满足、且没有任何必须完成的工作残留时,才调用 update_goal(status="complete")。
如果证据不完整、太弱、太间接、或留有任何缺失/未完成/未验证的需求,就继续工作,不要标记完成。
阻塞判定:
- 第一次遇到阻塞时,不要调用 update_goal(status="blocked")。
- 只有当同一个阻塞条件连续 {{blocked_threshold}} 个目标轮次重复出现(从最初的/用户触发的那一轮算起,含所有自动续作轮),你才能标记 blocked。
- 只有在你确实陷入僵局、没有用户输入或外部状态变化就无法做出有意义推进时,才用 blocked。
- 不要仅因为工作困难、缓慢、不确定、未完成、或"如果有澄清会更好"就标记 blocked。
除非目标确实已完成、或上述严格的阻塞判定已满足,否则不要调用 update_goal。
不要仅因为你打算停下来就把目标标记为完成。
@@ -0,0 +1,145 @@
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use serde_json::{json, Value};
use nomi_protocol::events::ToolCategory;
use nomi_tools::Tool;
use nomi_types::tool::{JsonSchema, ToolResult};
use crate::goal::state::{GoalState, GoalStatus};
/// Lets the model declare the terminal state of the current session goal.
/// Engine-internal (no `RequirementSink`) — deliberately NOT reusing
/// `requirement_complete`, which routes to the AutoWork orchestrator.
pub struct UpdateGoalTool {
state: Arc<Mutex<GoalState>>,
}
impl UpdateGoalTool {
pub fn new(state: Arc<Mutex<GoalState>>) -> Self {
Self { state }
}
}
#[async_trait]
impl Tool for UpdateGoalTool {
fn name(&self) -> &str {
"update_goal"
}
fn description(&self) -> &str {
"标记当前会话目标的终态。仅在目标确实已达成、且无任何必须完成的工作残留时,\
用 status=\"complete\";仅在同一阻塞条件连续多个目标轮次重复、确实陷入僵局时,\
用 status=\"blocked\"。不要因为工作困难/缓慢/不确定/想停下来就调用本工具。\
可选地用 evidence 逐条列出证明完成或阻塞的权威证据(文件/命令输出/测试结果)。"
}
fn input_schema(&self) -> JsonSchema {
json!({
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["complete", "blocked"],
"description": "complete=目标已达成且经完成审计证明;blocked=连续多轮同一僵局。"
},
"evidence": {
"type": "string",
"description": "可选。逐条列出证明完成/阻塞的权威证据(文件/命令输出/测试结果)。"
}
},
"required": ["status"]
})
}
// Not deferred: the continuation prompt asks the model to call this directly,
// so its schema must be visible from the start.
fn is_deferred(&self) -> bool {
false
}
fn is_concurrency_safe(&self, _input: &Value) -> bool {
false
}
fn category(&self) -> ToolCategory {
ToolCategory::Info
}
async fn execute(&self, input: Value) -> ToolResult {
let status = match input.get("status").and_then(|v| v.as_str()) {
Some("complete") => GoalStatus::Complete,
Some("blocked") => GoalStatus::Blocked,
_ => return ToolResult::error("update_goal: status 只能是 complete 或 blocked"),
};
let evidence = input.get("evidence").and_then(|v| v.as_str()).unwrap_or("");
{
let mut g = self.state.lock().unwrap();
// Only Active -> terminal; re-calling on a terminal goal is a no-op (idempotent).
if g.status == GoalStatus::Active {
g.status = status;
}
}
let payload = json!({
"kind": "goal_update",
"status": match status {
GoalStatus::Complete => "complete",
GoalStatus::Blocked => "blocked",
GoalStatus::Active => "active",
},
"evidence": evidence,
});
ToolResult::text(serde_json::to_string(&payload).unwrap_or_default())
}
fn describe(&self, input: &Value) -> String {
let status = input.get("status").and_then(|v| v.as_str()).unwrap_or("?");
format!("Update goal: {status}")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tool_with_state() -> (UpdateGoalTool, Arc<Mutex<GoalState>>) {
let state = Arc::new(Mutex::new(GoalState::new("do X".into(), 8)));
(UpdateGoalTool::new(Arc::clone(&state)), state)
}
#[tokio::test]
async fn complete_sets_state() {
let (tool, state) = tool_with_state();
let r = tool.execute(json!({ "status": "complete" })).await;
assert!(!r.is_error);
assert_eq!(state.lock().unwrap().status, GoalStatus::Complete);
assert!(r.content.contains("complete"));
}
#[tokio::test]
async fn blocked_sets_state() {
let (tool, state) = tool_with_state();
let r = tool.execute(json!({ "status": "blocked", "evidence": "stuck on auth" })).await;
assert!(!r.is_error);
assert_eq!(state.lock().unwrap().status, GoalStatus::Blocked);
}
#[tokio::test]
async fn invalid_status_errors() {
let (tool, _state) = tool_with_state();
let r = tool.execute(json!({ "status": "done" })).await;
assert!(r.is_error);
}
#[tokio::test]
async fn terminal_is_idempotent() {
let (tool, state) = tool_with_state();
let _ = tool.execute(json!({ "status": "complete" })).await;
// A later "blocked" must not override a reached "complete".
let _ = tool.execute(json!({ "status": "blocked" })).await;
assert_eq!(state.lock().unwrap().status, GoalStatus::Complete);
}
}
@@ -0,0 +1,724 @@
//! Native `knowledge_search` tool: lets an in-process agent search the
//! knowledge bases mounted into its session through a `KnowledgeRetrievalSink`
//! trait object. The backend injects a concrete sink scoped to the session's
//! bound bases; standalone `nomi-cli` passes `None` and the tool is absent.
//!
//! Mirrors `requirement_tools.rs`: trait here, impl in `nomifun-ai-agent`.
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::{Value, json};
use nomi_protocol::events::ToolCategory;
use nomi_tools::Tool;
use nomi_types::tool::{JsonSchema, ToolResult};
/// One retrieval hit. `rel_path` is relative to the base root. `handle` is the
/// opaque token the model passes to `knowledge_read` / `knowledge_write` so it
/// never has to reconstruct a path.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KnowledgeHit {
pub kb_id: String,
pub handle: String,
pub kb_name: String,
pub rel_path: String,
pub heading: String,
pub snippet: String,
}
/// Backend seam for knowledge retrieval. Implemented by the backend over its
/// `KnowledgeService`; `nomi-agent` only depends on this trait.
#[async_trait]
pub trait KnowledgeRetrievalSink: Send + Sync {
/// Search the given bases for `query`, returning up to `limit` ranked hits.
async fn search(&self, kb_ids: &[String], query: &str, limit: usize) -> Result<Vec<KnowledgeHit>, String>;
/// Return the full markdown for the document addressed by the opaque
/// `handle`, scoped to `kb_ids` (a handle outside the set is an error).
async fn read_document(&self, kb_ids: &[String], handle: &str) -> Result<String, String>;
}
/// `knowledge_search` — search the bases mounted into this session. Holds the
/// session's bound `kb_ids` (opaque to the model) plus the shared sink.
pub struct KnowledgeSearchTool {
sink: Arc<dyn KnowledgeRetrievalSink>,
kb_ids: Vec<String>,
}
impl KnowledgeSearchTool {
pub fn new(sink: Arc<dyn KnowledgeRetrievalSink>, kb_ids: Vec<String>) -> Self {
Self { sink, kb_ids }
}
}
/// Default and max hits returned to the model.
const DEFAULT_LIMIT: usize = 8;
const MAX_LIMIT: usize = 20;
#[async_trait]
impl Tool for KnowledgeSearchTool {
fn name(&self) -> &str {
"knowledge_search"
}
fn description(&self) -> &str {
"Search the knowledge bases mounted into THIS session for relevant documents. \
Call this FIRST, before answering from memory, whenever the task or question touches \
any topic the mounted bases may cover. Returns ranked results as `base / path — heading` \
with a snippet; then open the full document with the Read tool using the given path. \
This searches the real base content directly (not the workspace mount), so it always \
finds matches even when Grep/Glob cannot."
}
fn input_schema(&self) -> JsonSchema {
json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "What to look for. Use the user's topic words; natural language is fine."
},
"limit": {
"type": "integer",
"description": "Max results to return (default 8, max 20)"
}
},
"required": ["query"]
})
}
fn is_concurrency_safe(&self, _input: &Value) -> bool {
true
}
fn is_deferred(&self) -> bool {
// NOT deferred: the retrieval protocol instructs the agent to call this
// directly, so its schema must be visible up front (same rationale as
// requirement_complete).
false
}
async fn execute(&self, input: Value) -> ToolResult {
let query = match input.get("query").and_then(|v| v.as_str()) {
Some(s) if !s.trim().is_empty() => s.trim(),
_ => return ToolResult::error("Missing required 'query' string"),
};
if self.kb_ids.is_empty() {
return ToolResult::text("No knowledge bases are mounted in this session.");
}
let limit = input
.get("limit")
.and_then(|v| v.as_u64())
.map(|n| (n as usize).clamp(1, MAX_LIMIT))
.unwrap_or(DEFAULT_LIMIT);
match self.sink.search(&self.kb_ids, query, limit).await {
Ok(hits) if hits.is_empty() => ToolResult::text(format!(
"No matches for \"{query}\" in the mounted knowledge bases. \
Try different terms, or list files with Glob under the base path."
)),
Ok(hits) => ToolResult::text(format_hits(query, &hits)),
Err(e) => ToolResult::error(format!("knowledge_search failed: {e}")),
}
}
fn category(&self) -> ToolCategory {
ToolCategory::Info
}
fn describe(&self, input: &Value) -> String {
let q = input.get("query").and_then(|v| v.as_str()).unwrap_or("");
format!("knowledge_search '{q}'")
}
}
/// Render hits as a compact, agent-actionable list.
fn format_hits(query: &str, hits: &[KnowledgeHit]) -> String {
let mut out = format!("{} result(s) for \"{}\":\n", hits.len(), query);
for (i, h) in hits.iter().enumerate() {
out.push_str(&format!(
"{}. [{}] {} — {}\n {}\n handle: {}\n",
i + 1,
h.kb_name,
h.rel_path,
if h.heading.is_empty() { "(no heading)" } else { &h.heading },
h.snippet,
h.handle,
));
}
out.push_str(
"\nTo read a full document, call knowledge_read with its `handle`. \
To update one, call knowledge_write with that same `handle` (do NOT rebuild the path).",
);
out
}
// ── knowledge_read ─────────────────────────────────────────────────────────
/// `knowledge_read` — fetch a full document by the opaque `handle` from a prior
/// `knowledge_search` result. Removes all path arithmetic from the read→update
/// loop. Holds the session's bound `kb_ids` so a handle outside them is denied.
pub struct KnowledgeReadTool {
sink: Arc<dyn KnowledgeRetrievalSink>,
kb_ids: Vec<String>,
}
impl KnowledgeReadTool {
pub fn new(sink: Arc<dyn KnowledgeRetrievalSink>, kb_ids: Vec<String>) -> Self {
Self { sink, kb_ids }
}
}
#[async_trait]
impl Tool for KnowledgeReadTool {
fn name(&self) -> &str {
"knowledge_read"
}
fn description(&self) -> &str {
"Read the FULL markdown of a knowledge document by the `handle` returned by knowledge_search. \
Use this before updating a document so you can merge into its current content, then write it \
back with knowledge_write passing the same `handle`."
}
fn input_schema(&self) -> JsonSchema {
json!({
"type": "object",
"properties": {
"handle": { "type": "string", "description": "The opaque `handle` from a knowledge_search result." }
},
"required": ["handle"]
})
}
fn is_concurrency_safe(&self, _input: &Value) -> bool {
true
}
fn is_deferred(&self) -> bool {
false
}
async fn execute(&self, input: Value) -> ToolResult {
let handle = match input.get("handle").and_then(Value::as_str) {
Some(s) if !s.trim().is_empty() => s.trim(),
_ => return ToolResult::error("Missing required 'handle' string"),
};
match self.sink.read_document(&self.kb_ids, handle).await {
Ok(content) => ToolResult::text(content),
Err(e) => ToolResult::error(format!("knowledge_read failed: {e}")),
}
}
fn category(&self) -> ToolCategory {
ToolCategory::Info
}
fn describe(&self, _input: &Value) -> String {
"knowledge_read".to_owned()
}
}
// ── knowledge_write ──────────────────────────────────────────────────────
/// Backend seam for knowledge write-back. Implemented by the backend over its
/// `KnowledgeService::write_file`; `nomi-agent` only depends on this trait.
///
/// Deliberately a thin write primitive (kb_id + rel_path + content, overwrite
/// semantics) — exactly mirroring [`KnowledgeRetrievalSink`]. All the policy
/// (which bases are writable, staged-vs-direct placement) is baked into
/// [`KnowledgeWriteTool`] at construction so the model can never widen it.
/// What the model addressed for a write: an opaque `handle` (preferred — from
/// `knowledge_search`/`knowledge_read`) or an explicit base + relative path
/// (create a new document).
#[derive(Debug, Clone)]
pub enum WriteTarget {
Handle(String),
Path { kb_id: String, rel_path: String },
}
/// Placement mode for a write-back, decided by the backend per session/surface
/// and baked into [`KnowledgeWriteTool`] at construction. `Staged{scope}` → the
/// backend places the write under a review inbox keyed by `scope`; `Direct` →
/// the base body. The tool never builds the final path itself.
#[derive(Debug, Clone)]
pub enum WriteMode {
Staged { scope: String },
Direct,
}
/// A model-issued write, resolved by the backend [`KnowledgeWritebackSink`]
/// (handle/path → existing doc or new file) before placement is applied.
#[derive(Debug, Clone)]
pub struct WriteRequest {
pub target: WriteTarget,
pub content: String,
pub mode: WriteMode,
/// Bases this session may write to; the backend rejects a handle/path
/// outside this set.
pub bound_kb_ids: Vec<String>,
}
/// What actually happened, for the tool's confirmation message.
#[derive(Debug, Clone)]
pub struct WriteReceipt {
pub final_rel_path: String,
pub staged: bool,
pub updated: bool,
}
/// Backend seam for knowledge write-back. Implemented by the backend over its
/// `KnowledgeService::write_document`; `nomi-agent` only depends on this trait.
/// The backend resolves the target (fixing path confusion / locating the
/// existing doc), enforces placement, and writes — the tool layer only forwards
/// the model's intent.
#[async_trait]
pub trait KnowledgeWritebackSink: Send + Sync {
async fn write(&self, req: WriteRequest) -> Result<WriteReceipt, String>;
}
/// `knowledge_write` — persist reusable knowledge straight into a bound base.
///
/// This is the write-back ("回血") counterpart of [`KnowledgeSearchTool`]. The
/// generic file `Write` tool cannot do this reliably in a nomi chat session:
/// it has no workspace cwd (so the relative mount path the prompt advertises
/// resolves against the process cwd, missing the base) AND it sits behind the
/// approval gate. This tool resolves the base by name within the bound set,
/// applies the session's staged/direct placement, and writes through the
/// backend service directly — the backend adds it to the allow-list so it
/// bypasses the per-call approval prompt (same posture as companion memory
/// tools and `knowledge_search`).
pub struct KnowledgeWriteTool {
sink: Arc<dyn KnowledgeWritebackSink>,
/// Bound bases as `(kb_id, name)`. The model selects by `name`; `kb_id` is
/// opaque to it. Used to resolve `base` → `kb_id` for the create path.
bases: Vec<(String, String)>,
/// Placement mode baked at construction (Staged inbox scope, or Direct).
mode: WriteMode,
/// Bases this session may write to (forwarded to the backend for scope
/// enforcement). Mirrors the search/read tools' `kb_ids`.
bound_kb_ids: Vec<String>,
}
impl KnowledgeWriteTool {
pub fn new(
sink: Arc<dyn KnowledgeWritebackSink>,
bases: Vec<(String, String)>,
mode: WriteMode,
bound_kb_ids: Vec<String>,
) -> Self {
Self { sink, bases, mode, bound_kb_ids }
}
/// One-line description of the bound bases for the schema/description.
fn base_names(&self) -> Vec<&str> {
self.bases.iter().map(|(_, name)| name.as_str()).collect()
}
}
/// Resolve the model-supplied base name to a bound `(kb_id, name)`. When the
/// model omits `requested` and exactly one base is bound, that base is used.
/// Matching is case-insensitive and whitespace-trimmed. `Err` carries a
/// ready-to-return, model-actionable message.
fn resolve_write_base<'a>(
bases: &'a [(String, String)],
requested: Option<&str>,
) -> Result<&'a (String, String), String> {
if bases.is_empty() {
return Err("No knowledge bases are mounted in this session, so there is nothing to write to.".to_owned());
}
match requested.map(str::trim).filter(|s| !s.is_empty()) {
Some(name) => bases
.iter()
.find(|(_, n)| n.trim().eq_ignore_ascii_case(name))
.ok_or_else(|| {
let names = bases.iter().map(|(_, n)| n.as_str()).collect::<Vec<_>>().join(", ");
format!("Unknown knowledge base \"{name}\". Mounted bases are: {names}. Pass one of these exact names as `base`.")
}),
None => {
if bases.len() == 1 {
Ok(&bases[0])
} else {
let names = bases.iter().map(|(_, n)| n.as_str()).collect::<Vec<_>>().join(", ");
Err(format!(
"Multiple knowledge bases are mounted ({names}). Specify which one with the `base` argument."
))
}
}
}
}
/// Validate + normalize a model-supplied relative markdown path. Mirrors the
/// backend `safe_md_path` contract early so the model gets an actionable error
/// instead of a generic service rejection: `.md` only, no absolute paths, no
/// `.`/`..`/empty components (traversal), backslashes normalized to `/`.
fn normalize_write_rel_path(rel_path: &str) -> Result<String, String> {
let raw = rel_path.trim().replace('\\', "/");
if raw.is_empty() {
return Err("`rel_path` must not be empty.".to_owned());
}
if raw.starts_with('/') {
return Err(format!("`rel_path` must be relative to the base, not absolute: \"{rel_path}\"."));
}
for seg in raw.split('/') {
if seg.is_empty() || seg == "." || seg == ".." {
return Err(format!("`rel_path` contains an invalid path segment: \"{rel_path}\"."));
}
}
if !raw.to_ascii_lowercase().ends_with(".md") {
return Err(format!("Knowledge files must be markdown: `rel_path` has to end with .md (got \"{rel_path}\")."));
}
Ok(raw)
}
#[async_trait]
impl Tool for KnowledgeWriteTool {
fn name(&self) -> &str {
"knowledge_write"
}
fn description(&self) -> &str {
"Persist reusable knowledge (conclusions, domain facts, decisions, lessons) INTO a mounted \
knowledge base so it survives across sessions. Use THIS tool for write-back — never the generic \
Write/Edit file tools. To UPDATE an existing document, pass its `handle` from a knowledge_search \
result (read it first with knowledge_read, merge, then write the full content). To CREATE a new \
document, pass `base` + a descriptive `.md` `rel_path`. Never rebuild paths by hand."
}
fn input_schema(&self) -> JsonSchema {
let names = self.base_names();
let base_desc = if names.len() <= 1 {
"For a NEW document: which knowledge base to write to (its name). Optional when only one base is mounted.".to_owned()
} else {
format!("For a NEW document: which knowledge base to write to. Must be one of: {}.", names.join(", "))
};
json!({
"type": "object",
"properties": {
"handle": {
"type": "string",
"description": "To UPDATE an existing document: the opaque `handle` from a knowledge_search result. Do not rebuild paths."
},
"base": { "type": "string", "description": base_desc },
"rel_path": {
"type": "string",
"description": "For a NEW document only: relative markdown path within the base, e.g. \"terms.md\". Must end with .md. Ignored when `handle` is given."
},
"content": {
"type": "string",
"description": "The FULL markdown content to store (overwrite semantics for updates). Keep it self-contained and free of session-specific noise."
}
},
"required": ["content"]
})
}
fn is_concurrency_safe(&self, _input: &Value) -> bool {
false
}
fn is_deferred(&self) -> bool {
// NOT deferred: the write-back contract instructs the agent to call this
// directly, so its schema must be visible up front (same rationale as
// knowledge_search / requirement_complete).
false
}
async fn execute(&self, input: Value) -> ToolResult {
let Some(content) = input.get("content").and_then(Value::as_str) else {
return ToolResult::error("Missing required 'content' string");
};
if content.trim().is_empty() {
return ToolResult::error("'content' is empty — refusing to write a blank knowledge file");
}
let target = if let Some(handle) =
input.get("handle").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty())
{
WriteTarget::Handle(handle.to_owned())
} else {
let rel_path = match input.get("rel_path").and_then(Value::as_str) {
Some(p) => match normalize_write_rel_path(p) {
Ok(p) => p,
Err(e) => return ToolResult::error(e),
},
None => {
return ToolResult::error(
"Pass either `handle` (to update an existing document) or `rel_path` (to create a new one).",
);
}
};
let kb_id = match resolve_write_base(&self.bases, input.get("base").and_then(Value::as_str)) {
Ok(b) => b.0.clone(),
Err(e) => return ToolResult::error(e),
};
WriteTarget::Path { kb_id, rel_path }
};
let req = WriteRequest {
target,
content: content.to_owned(),
mode: self.mode.clone(),
bound_kb_ids: self.bound_kb_ids.clone(),
};
match self.sink.write(req).await {
Ok(r) => {
let verb = if r.updated { "Updated" } else { "Saved" };
let note = if r.staged {
" (STAGED to the review inbox; the user merges it into the base later)"
} else {
""
};
ToolResult::text(format!("{verb} knowledge document at {}{note}.", r.final_rel_path))
}
Err(e) => ToolResult::error(format!("knowledge_write failed: {e}")),
}
}
fn category(&self) -> ToolCategory {
ToolCategory::Edit
}
fn describe(&self, input: &Value) -> String {
let base = input.get("base").and_then(|v| v.as_str()).unwrap_or("");
let path = input.get("rel_path").and_then(|v| v.as_str()).unwrap_or("");
if base.is_empty() {
format!("knowledge_write '{path}'")
} else {
format!("knowledge_write '{base}/{path}'")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
struct FakeSink {
hits: Vec<KnowledgeHit>,
last_query: std::sync::Mutex<String>,
}
#[async_trait]
impl KnowledgeRetrievalSink for FakeSink {
async fn search(&self, _kb_ids: &[String], query: &str, _limit: usize) -> Result<Vec<KnowledgeHit>, String> {
*self.last_query.lock().unwrap() = query.to_owned();
Ok(self.hits.clone())
}
async fn read_document(&self, _kb_ids: &[String], _handle: &str) -> Result<String, String> {
Ok(String::new())
}
}
fn tool_with(hits: Vec<KnowledgeHit>, kb_ids: Vec<String>) -> (KnowledgeSearchTool, Arc<FakeSink>) {
let sink = Arc::new(FakeSink { hits, last_query: std::sync::Mutex::new(String::new()) });
(KnowledgeSearchTool::new(sink.clone(), kb_ids), sink)
}
#[tokio::test]
async fn formats_hits_and_passes_trimmed_query() {
let hits = vec![KnowledgeHit {
kb_id: "kb1".into(),
handle: "kdoc_abc".into(),
kb_name: "运维手册".into(),
rel_path: "deploy/rollback.md".into(),
heading: "回滚流程".into(),
snippet: "回滚分三步……".into(),
}];
let (tool, sink) = tool_with(hits, vec!["kb1".into()]);
let res = tool.execute(json!({"query": " 回滚 "})).await;
assert!(!res.is_error, "{res:?}");
assert!(res.content.contains("运维手册"));
assert!(res.content.contains("deploy/rollback.md"));
assert!(res.content.contains("回滚流程"));
assert!(res.content.contains("kdoc_abc"), "handle must appear: {}", res.content);
assert!(
res.content.contains("knowledge_read") || res.content.contains("knowledge_write"),
"must instruct handle use: {}",
res.content
);
assert_eq!(*sink.last_query.lock().unwrap(), "回滚", "query must be trimmed");
}
#[tokio::test]
async fn empty_kb_ids_returns_no_bases_message() {
let (tool, _sink) = tool_with(vec![], vec![]);
let res = tool.execute(json!({"query": "x"})).await;
assert!(!res.is_error);
assert!(res.content.contains("No knowledge bases are mounted"));
}
#[tokio::test]
async fn missing_query_is_error() {
let (tool, _sink) = tool_with(vec![], vec!["kb1".into()]);
let res = tool.execute(json!({})).await;
assert!(res.is_error);
assert!(res.content.contains("Missing required 'query'"));
}
#[tokio::test]
async fn no_hits_suggests_alternatives() {
let (tool, _sink) = tool_with(vec![], vec!["kb1".into()]);
let res = tool.execute(json!({"query": "无此主题"})).await;
assert!(!res.is_error);
assert!(res.content.contains("No matches"));
}
// ── knowledge_read ───────────────────────────────────────────────
struct FakeReadSink;
#[async_trait]
impl KnowledgeRetrievalSink for FakeReadSink {
async fn search(&self, _: &[String], _: &str, _: usize) -> Result<Vec<KnowledgeHit>, String> {
Ok(vec![])
}
async fn read_document(&self, kb_ids: &[String], handle: &str) -> Result<String, String> {
if handle == "kdoc_ok" && kb_ids == ["kb1"] {
Ok("FULL DOC".into())
} else {
Err("not found".into())
}
}
}
#[tokio::test]
async fn read_tool_returns_full_content_by_handle() {
let tool = KnowledgeReadTool::new(Arc::new(FakeReadSink), vec!["kb1".into()]);
let ok = tool.execute(json!({"handle": "kdoc_ok"})).await;
assert!(!ok.is_error && ok.content.contains("FULL DOC"), "{ok:?}");
let bad = tool.execute(json!({"handle": "kdoc_no"})).await;
assert!(bad.is_error);
let missing = tool.execute(json!({})).await;
assert!(missing.is_error);
}
// ── knowledge_write ──────────────────────────────────────────────
#[derive(Default)]
struct FakeWriteSink {
last: std::sync::Mutex<Option<WriteRequest>>,
fail: bool,
}
#[async_trait]
impl KnowledgeWritebackSink for FakeWriteSink {
async fn write(&self, req: WriteRequest) -> Result<WriteReceipt, String> {
if self.fail {
return Err("disk full".to_owned());
}
let staged = matches!(req.mode, WriteMode::Staged { .. });
let final_rel_path = match &req.target {
WriteTarget::Handle(h) => h.clone(),
WriteTarget::Path { rel_path, .. } => rel_path.clone(),
};
*self.last.lock().unwrap() = Some(req);
Ok(WriteReceipt { final_rel_path, staged, updated: true })
}
}
fn write_tool(bases: Vec<(&str, &str)>, mode: WriteMode) -> (KnowledgeWriteTool, Arc<FakeWriteSink>) {
let sink = Arc::new(FakeWriteSink::default());
let bases: Vec<(String, String)> = bases.into_iter().map(|(i, n)| (i.to_owned(), n.to_owned())).collect();
let bound: Vec<String> = bases.iter().map(|(i, _)| i.clone()).collect();
(KnowledgeWriteTool::new(sink.clone(), bases, mode, bound), sink)
}
fn direct() -> WriteMode {
WriteMode::Direct
}
fn staged(scope: &str) -> WriteMode {
WriteMode::Staged { scope: scope.to_owned() }
}
// resolve_write_base ------------------------------------------------
#[test]
fn resolve_base_single_default_and_by_name() {
let one = vec![("kb1".to_owned(), "金融知识库".to_owned())];
assert_eq!(resolve_write_base(&one, None).unwrap().0, "kb1");
// Case-insensitive + trimmed name match across multiple bases.
let many = vec![("kb1".to_owned(), "Finance".to_owned()), ("kb2".to_owned(), "Ops".to_owned())];
assert_eq!(resolve_write_base(&many, Some(" finance ")).unwrap().0, "kb1");
}
#[test]
fn resolve_base_errors_are_actionable() {
assert!(resolve_write_base(&[], None).unwrap_err().contains("nothing to write"));
let many = vec![("kb1".to_owned(), "Finance".to_owned()), ("kb2".to_owned(), "Ops".to_owned())];
// Ambiguous (no base arg, >1 base) names the choices.
let amb = resolve_write_base(&many, None).unwrap_err();
assert!(amb.contains("Finance") && amb.contains("Ops"), "{amb}");
// Unknown name lists valid ones.
let unk = resolve_write_base(&many, Some("Legal")).unwrap_err();
assert!(unk.contains("Legal") && unk.contains("Finance"), "{unk}");
}
// normalize_write_rel_path ------------------------------------------
#[test]
fn rel_path_validation() {
assert_eq!(normalize_write_rel_path("a\\b.md").unwrap(), "a/b.md");
assert_eq!(normalize_write_rel_path(" notes/x.MD ").unwrap(), "notes/x.MD");
assert!(normalize_write_rel_path("").is_err());
assert!(normalize_write_rel_path("/abs.md").is_err());
assert!(normalize_write_rel_path("../escape.md").is_err());
assert!(normalize_write_rel_path("a/../b.md").is_err());
assert!(normalize_write_rel_path("a//b.md").is_err());
assert!(normalize_write_rel_path("notes.txt").is_err());
}
// execute -----------------------------------------------------------
#[tokio::test]
async fn write_by_base_and_path_builds_path_target() {
let (tool, sink) = write_tool(vec![("kb1", "金融知识库")], direct());
let res = tool.execute(json!({"rel_path": "terms.md", "content": "# 术语\nPER = 市盈率"})).await;
assert!(!res.is_error, "{res:?}");
let req = sink.last.lock().unwrap().clone().unwrap();
match &req.target {
WriteTarget::Path { kb_id, rel_path } => {
assert_eq!(kb_id, "kb1");
assert_eq!(rel_path, "terms.md");
}
other => panic!("expected Path target, got {other:?}"),
}
assert!(req.content.contains("市盈率"));
assert!(matches!(req.mode, WriteMode::Direct));
assert!(!res.content.contains("STAGED"));
}
#[tokio::test]
async fn write_by_handle_builds_handle_target() {
let (tool, sink) = write_tool(vec![("kb1", "Finance")], staged("conv-7"));
let res = tool.execute(json!({"handle": "kdoc_xyz", "content": "merged"})).await;
assert!(!res.is_error, "{res:?}");
let req = sink.last.lock().unwrap().clone().unwrap();
assert!(matches!(req.target, WriteTarget::Handle(ref h) if h == "kdoc_xyz"));
assert!(matches!(req.mode, WriteMode::Staged { ref scope } if scope == "conv-7"));
assert!(res.content.contains("STAGED"));
}
#[tokio::test]
async fn execute_rejects_missing_and_blank_inputs() {
let (tool, _sink) = write_tool(vec![("kb1", "Finance")], direct());
assert!(tool.execute(json!({"content": "x"})).await.is_error, "no handle or rel_path");
assert!(tool.execute(json!({"rel_path": "a.md"})).await.is_error, "missing content");
assert!(tool.execute(json!({"handle": "kdoc_x", "content": " "})).await.is_error, "blank content");
assert!(tool.execute(json!({"rel_path": "a.txt", "content": "x"})).await.is_error, "non-md rel_path");
}
#[tokio::test]
async fn execute_surfaces_sink_error() {
let sink = Arc::new(FakeWriteSink { fail: true, ..Default::default() });
let tool = KnowledgeWriteTool::new(sink, vec![("kb1".into(), "Finance".into())], WriteMode::Direct, vec!["kb1".into()]);
let res = tool.execute(json!({"handle": "kdoc_x", "content": "x"})).await;
assert!(res.is_error);
assert!(res.content.contains("disk full"));
}
#[tokio::test]
async fn execute_multi_base_requires_base_arg_for_create() {
let (tool, _sink) = write_tool(vec![("kb1", "Finance"), ("kb2", "Ops")], direct());
let res = tool.execute(json!({"rel_path": "a.md", "content": "x"})).await;
assert!(res.is_error);
assert!(res.content.contains("Specify"));
}
}
@@ -0,0 +1,33 @@
// Core agent infrastructure: engine, session, orchestration, output sinks.
pub mod agents_md;
pub mod bootstrap;
pub mod cache_diagnostics;
pub mod commands;
pub mod compact;
pub mod companion_tools;
pub mod confirm;
pub mod context;
pub mod context_contributor;
pub mod cron_tools;
pub mod memory_tools;
pub mod engine;
pub mod goal;
pub mod knowledge_tools;
pub mod loop_guard;
pub mod orchestration;
pub mod output;
pub mod plan;
pub mod requirement_tools;
pub mod session;
pub mod skill_tool;
pub mod spawn_tool;
pub mod spawner;
pub mod taskboard;
pub mod vcr;
// Re-export the skills crate so existing callers (nomi-cli, tests) can use
// `nomi_agent::skills::` without changing their import paths.
pub use nomi_skills as skills;
pub use knowledge_tools::{KnowledgeHit, KnowledgeReadTool, KnowledgeRetrievalSink, KnowledgeSearchTool};
@@ -0,0 +1,171 @@
//! Loop-stagnation guard: detects when the agent repeats the *identical* set of
//! tool calls turn after turn — a degenerate loop where it keeps doing the same
//! thing expecting a different result (a common "stuck" failure mode). When the
//! repeat streak crosses a threshold the engine injects a one-time nudge asking
//! the model to change approach. It never aborts (legitimate polling exists);
//! the hard `max_turns` cap remains the real backstop.
use std::collections::BTreeSet;
use nomi_types::message::ContentBlock;
/// Canonical signature of a turn's tool calls: each call's name + serialized
/// input, deduplicated and order-independent (a `BTreeSet`), joined. The tool
/// `id` is deliberately excluded — it changes every turn, but two turns that
/// issue the same logical call(s) with the same arguments must collide. Returns
/// `None` when there are no tool calls (a text-only turn never stagnates).
pub fn tool_calls_signature(tool_calls: &[ContentBlock]) -> Option<String> {
let sigs: BTreeSet<String> = tool_calls
.iter()
.filter_map(|c| match c {
ContentBlock::ToolUse { name, input, .. } => {
Some(format!("{name}({})", serde_json::to_string(input).unwrap_or_default()))
}
_ => None,
})
.collect();
if sigs.is_empty() {
None
} else {
Some(sigs.into_iter().collect::<Vec<_>>().join("|"))
}
}
/// The guidance injected when stagnation is detected.
pub const STAGNATION_NUDGE: &str = "Loop guard: you have issued the identical tool call(s) with the \
same arguments several turns in a row and the results are not changing. Stop repeating the same \
action. Either try a materially different approach (different arguments, a different tool, or a \
different sub-problem), or stop and report what you have found and what is blocking you.";
/// Tracks consecutive identical tool-call signatures and decides when to nudge.
pub struct StagnationGuard {
threshold: usize,
last: Option<String>,
repeats: usize,
}
impl StagnationGuard {
/// `threshold` = how many consecutive identical-signature turns trigger a
/// nudge (e.g. 3: the 3rd identical turn fires).
pub fn new(threshold: usize) -> Self {
Self {
threshold: threshold.max(2),
last: None,
repeats: 0,
}
}
/// Observe this turn's tool-call signature. Returns `true` exactly when the
/// repeat streak reaches the threshold, at which point the streak resets so
/// the next nudge requires a fresh streak (no nudge-every-turn spam). A
/// `None` signature (text-only turn) breaks any streak.
pub fn observe(&mut self, signature: Option<String>) -> bool {
let Some(sig) = signature else {
self.last = None;
self.repeats = 0;
return false;
};
if self.last.as_deref() == Some(sig.as_str()) {
self.repeats += 1;
} else {
self.last = Some(sig);
self.repeats = 1;
}
if self.repeats >= self.threshold {
self.last = None;
self.repeats = 0;
true
} else {
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn tool_use(name: &str, input: serde_json::Value, id: &str) -> ContentBlock {
ContentBlock::ToolUse {
id: id.to_string(),
name: name.to_string(),
input,
extra: None,
}
}
#[test]
fn signature_ignores_id_and_order_but_not_args() {
let a = vec![tool_use("Bash", json!({"command": "ls"}), "id-1")];
let b = vec![tool_use("Bash", json!({"command": "ls"}), "id-2-different")];
assert_eq!(tool_calls_signature(&a), tool_calls_signature(&b), "id must not affect signature");
let two_ab = vec![
tool_use("Read", json!({"p": "a"}), "1"),
tool_use("Grep", json!({"q": "x"}), "2"),
];
let two_ba = vec![
tool_use("Grep", json!({"q": "x"}), "3"),
tool_use("Read", json!({"p": "a"}), "4"),
];
assert_eq!(tool_calls_signature(&two_ab), tool_calls_signature(&two_ba), "order must not matter");
let diff_args = vec![tool_use("Bash", json!({"command": "pwd"}), "5")];
assert_ne!(tool_calls_signature(&a), tool_calls_signature(&diff_args), "different args must differ");
}
#[test]
fn text_only_turn_has_no_signature() {
let blocks = vec![ContentBlock::Text { text: "hello".into() }];
assert_eq!(tool_calls_signature(&blocks), None);
assert_eq!(tool_calls_signature(&[]), None);
}
#[test]
fn fires_on_threshold_consecutive_identical_turns() {
let mut guard = StagnationGuard::new(3);
let sig = Some("Bash(ls)".to_string());
assert!(!guard.observe(sig.clone()), "1st identical turn must not fire");
assert!(!guard.observe(sig.clone()), "2nd must not fire");
assert!(guard.observe(sig.clone()), "3rd identical turn fires the nudge");
}
#[test]
fn resets_after_firing_so_no_per_turn_spam() {
let mut guard = StagnationGuard::new(3);
let sig = Some("Bash(ls)".to_string());
guard.observe(sig.clone());
guard.observe(sig.clone());
assert!(guard.observe(sig.clone()), "fires on 3rd");
// After firing, the streak resets — the 4th identical turn must NOT fire.
assert!(!guard.observe(sig.clone()), "no nudge again immediately after firing");
assert!(!guard.observe(sig.clone()));
assert!(guard.observe(sig.clone()), "fires again only after a fresh full streak");
}
#[test]
fn a_different_turn_breaks_the_streak() {
let mut guard = StagnationGuard::new(3);
let a = Some("Bash(ls)".to_string());
let b = Some("Read(a)".to_string());
guard.observe(a.clone());
guard.observe(a.clone());
guard.observe(b.clone()); // breaks the streak
assert!(!guard.observe(a.clone()), "streak restarted, 1st identical again");
assert!(!guard.observe(a.clone()));
assert!(guard.observe(a.clone()), "fires only after 3 fresh consecutive");
}
#[test]
fn text_turn_between_identical_calls_breaks_streak() {
let mut guard = StagnationGuard::new(3);
let a = Some("Bash(ls)".to_string());
guard.observe(a.clone());
guard.observe(a.clone());
assert!(!guard.observe(None), "a text-only turn breaks the streak and never fires");
assert!(!guard.observe(a.clone()));
assert!(!guard.observe(a.clone()));
assert!(guard.observe(a.clone()));
}
}
@@ -0,0 +1,200 @@
//! Native `remember` tool: lets the in-process agent persist a durable memory
//! (project fact / user preference / lesson) to the file-based long-term memory
//! mid-session, so it is injected into future sessions' system prompt. Fully
//! self-contained in the agent layer (writes via `nomi_memory`); the host wires
//! only the target directory at bootstrap. Closes the audit's "in-session
//! incremental memory persistence" gap (Claude Code parity).
use std::path::PathBuf;
use std::str::FromStr;
use async_trait::async_trait;
use serde_json::{Value, json};
use nomi_memory::index::append_index_entry;
use nomi_memory::paths::ENTRYPOINT_NAME;
use nomi_memory::store::write_memory;
use nomi_memory::types::{MemoryEntry, MemoryFrontmatter, MemoryType};
use nomi_protocol::events::ToolCategory;
use nomi_tools::Tool;
use nomi_types::tool::{JsonSchema, ToolResult};
/// Derive a filesystem-safe, kebab-case memory name from a title.
fn slug(title: &str) -> String {
let mut out = String::new();
let mut prev_dash = false;
for c in title.chars() {
if c.is_alphanumeric() {
out.push(c.to_ascii_lowercase());
prev_dash = false;
} else if !prev_dash {
out.push('-');
prev_dash = true;
}
}
let s = out.trim_matches('-').to_string();
let s: String = s.chars().take(60).collect();
if s.is_empty() { "memory".to_string() } else { s }
}
/// `remember` — persist a long-term memory to the project's memory directory.
pub struct RememberTool {
memory_dir: PathBuf,
}
impl RememberTool {
pub fn new(memory_dir: PathBuf) -> Self {
Self { memory_dir }
}
}
#[async_trait]
impl Tool for RememberTool {
fn name(&self) -> &str {
"remember"
}
fn description(&self) -> &str {
"Persist a durable memory to long-term memory so it is available in FUTURE sessions. \
Use when you learn something worth keeping about this project or the user — e.g. \
'this project uses pnpm, not npm', 'always run cargo test before committing', a \
coding convention, or a correction the user made. Do NOT store secrets, credentials, \
or transient details. Keep each memory to one focused fact."
}
fn input_schema(&self) -> JsonSchema {
json!({
"type": "object",
"properties": {
"title": { "type": "string", "description": "Short title (becomes the memory's name)" },
"content": { "type": "string", "description": "The fact / lesson to remember (markdown)" },
"type": {
"type": "string",
"enum": ["user", "feedback", "project", "reference"],
"description": "Memory type (default: project)"
}
},
"required": ["title", "content"]
})
}
fn is_concurrency_safe(&self, _input: &Value) -> bool {
false
}
async fn execute(&self, input: Value) -> ToolResult {
let (Some(title), Some(content)) = (input["title"].as_str(), input["content"].as_str())
else {
return ToolResult {
content: "remember requires: title, content".to_string(),
is_error: true,
images: Vec::new(),
};
};
if content.trim().is_empty() {
return ToolResult {
content: "remember: content must not be empty".to_string(),
is_error: true,
images: Vec::new(),
};
}
let memory_type = input["type"]
.as_str()
.and_then(|s| MemoryType::from_str(s).ok())
.unwrap_or(MemoryType::Project);
let frontmatter = MemoryFrontmatter {
name: Some(slug(title)),
description: Some(title.to_string()),
memory_type: Some(memory_type),
usage_count: None,
last_used: None,
};
let entry = MemoryEntry::new(frontmatter, content.to_string());
let path = match write_memory(&self.memory_dir, &entry) {
Ok(p) => p,
Err(e) => {
return ToolResult {
content: format!("Failed to save memory: {e}"),
is_error: true,
images: Vec::new(),
};
}
};
let filename = path
.file_name()
.and_then(|f| f.to_str())
.unwrap_or("memory.md")
.to_string();
// Best-effort index update (a missing/locked index must not fail the save).
let index_path = self.memory_dir.join(ENTRYPOINT_NAME);
let _ = append_index_entry(&index_path, title, &filename, title);
ToolResult {
content: format!("Remembered '{title}' ({filename})"),
is_error: false,
images: Vec::new(),
}
}
fn category(&self) -> ToolCategory {
ToolCategory::Exec
}
fn describe(&self, input: &Value) -> String {
let title = input.get("title").and_then(|v| v.as_str()).unwrap_or("");
format!("Remember: {}", nomi_tools::truncate_utf8(title, 60))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tempfile::tempdir;
#[test]
fn slug_is_kebab_and_bounded() {
assert_eq!(slug("Use pnpm, not npm!"), "use-pnpm-not-npm");
assert_eq!(slug(" "), "memory");
assert!(slug(&"x".repeat(200)).len() <= 60);
}
#[tokio::test]
async fn remember_writes_a_memory_file_and_indexes_it() {
let dir = tempdir().unwrap();
let tool = RememberTool::new(dir.path().to_path_buf());
let r = tool
.execute(json!({
"title": "Use pnpm",
"content": "This project uses pnpm, not npm.",
"type": "project"
}))
.await;
assert!(!r.is_error, "{}", r.content);
// A memory file was written and the MEMORY.md index references it.
let files: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
assert!(files.iter().any(|f| f.ends_with(".md") && f != ENTRYPOINT_NAME), "memory file written: {files:?}");
let index = std::fs::read_to_string(dir.path().join(ENTRYPOINT_NAME)).unwrap_or_default();
assert!(index.contains("Use pnpm"), "index should reference the memory: {index}");
}
#[tokio::test]
async fn remember_rejects_missing_or_empty() {
let dir = tempdir().unwrap();
let tool = RememberTool::new(dir.path().to_path_buf());
assert!(tool.execute(json!({ "title": "x" })).await.is_error);
assert!(
tool.execute(json!({ "title": "x", "content": " " }))
.await
.is_error
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,286 @@
pub mod null_sink;
pub mod protocol_sink;
pub mod terminal;
use crossterm::execute;
use crossterm::style::{Attribute, Color, Print, ResetColor, SetAttribute, SetForegroundColor};
use std::io::{self, Write};
/// Abstraction over output channels (terminal vs JSON stream protocol)
pub trait OutputSink: Send + Sync {
/// Stream text delta from LLM
fn emit_text_delta(&self, text: &str, msg_id: &str);
/// Stream thinking content from LLM
fn emit_thinking(&self, text: &str, msg_id: &str);
/// Announce a tool call.
fn emit_tool_call(&self, tool_use_id: &str, name: &str, input: &str);
/// Display tool result.
fn emit_tool_result(&self, tool_use_id: &str, name: &str, is_error: bool, content: &str);
/// Signal start of a new message stream
fn emit_stream_start(&self, msg_id: &str);
/// Signal end of a message stream with usage stats
fn emit_stream_end(
&self,
msg_id: &str,
turns: usize,
input_tokens: u64,
output_tokens: u64,
cache_creation_tokens: u64,
cache_read_tokens: u64,
);
/// Display error
fn emit_error(&self, msg: &str);
/// Display informational message
fn emit_info(&self, msg: &str);
/// Display a non-fatal warning: a benign, recoverable diagnostic where the
/// turn/session still completes successfully (autocompact failure, session
/// save/index hiccup, MCP-init failure, `/compact` failure). Unlike
/// `emit_error`, a warning must NOT be treated as a turn-failing condition by
/// downstream consumers — the AutoWork / requirement orchestrator classifies
/// an `Error` stream event as a FAILED turn (re-pend / burn attempt / pause
/// tag). The default routes to `emit_info` (non-fatal); sinks that carry a
/// severity level on the wire (the backend stream bridge) override it.
fn emit_warning(&self, msg: &str) {
self.emit_info(msg);
}
}
pub struct OutputFormatter {
color_enabled: bool,
}
impl OutputFormatter {
pub fn new(no_color: bool) -> Self {
// Also check NO_COLOR env var (standard: https://no-color.org/)
let color_enabled = !no_color
&& std::env::var("NO_COLOR").is_err()
&& is_terminal::is_terminal(io::stderr());
Self { color_enabled }
}
/// Print LLM text delta (streaming, no newline)
pub fn text_delta(&self, text: &str) {
print!("{}", text);
let _ = io::stdout().flush();
}
/// Print tool call announcement
pub fn tool_call(&self, name: &str, input: &str) {
if self.color_enabled {
let mut stderr = io::stderr();
let _ = execute!(
stderr,
SetForegroundColor(Color::Cyan),
SetAttribute(Attribute::Bold),
Print(format!("\n[tool] {}", name)),
ResetColor,
SetForegroundColor(Color::DarkGrey),
Print(format!("({})\n", truncate_display(input, 200))),
ResetColor,
);
} else {
eprintln!("\n[tool] {}({})", name, truncate_display(input, 200));
}
}
/// Print tool result
pub fn tool_result(&self, name: &str, is_error: bool, content: &str) {
if self.color_enabled {
let color = if is_error { Color::Red } else { Color::Green };
let attr = if is_error {
Attribute::Bold
} else {
Attribute::Dim
};
let mut stderr = io::stderr();
let _ = execute!(
stderr,
SetForegroundColor(color),
SetAttribute(attr),
Print(format!("[{}] {}\n", name, truncate_display(content, 500))),
ResetColor,
);
} else {
let prefix = if is_error { "ERROR" } else { "OK" };
eprintln!("[{} {}] {}", name, prefix, truncate_display(content, 500));
}
}
/// Print thinking content
pub fn thinking(&self, text: &str) {
if self.color_enabled {
let mut stderr = io::stderr();
let _ = execute!(
stderr,
SetForegroundColor(Color::DarkGrey),
SetAttribute(Attribute::Italic),
Print(text),
ResetColor,
);
}
// Silent in no-color mode (thinking is optional display)
}
/// Print turn summary stats
pub fn turn_stats(
&self,
turns: usize,
input_tokens: u64,
output_tokens: u64,
cache_creation_tokens: u64,
cache_read_tokens: u64,
) {
let cache_info = if cache_creation_tokens > 0 || cache_read_tokens > 0 {
format!(
" | cache: {} created, {} read",
cache_creation_tokens, cache_read_tokens
)
} else {
String::new()
};
let cached_suffix = if cache_read_tokens > 0 {
format!(" ({} cached)", cache_read_tokens)
} else {
String::new()
};
if self.color_enabled {
let mut stderr = io::stderr();
let _ = execute!(
stderr,
SetForegroundColor(Color::Yellow),
SetAttribute(Attribute::Dim),
Print(format!(
"\n[turns: {} | tokens: {} in{} / {} out{}]\n",
turns, input_tokens, cached_suffix, output_tokens, cache_info
)),
ResetColor,
);
} else {
eprintln!(
"\n[turns: {} | tokens: {} in{} / {} out{}]",
turns, input_tokens, cached_suffix, output_tokens, cache_info
);
}
}
/// Print REPL prompt
pub fn repl_prompt(&self) {
if self.color_enabled {
let mut stdout = io::stdout();
let _ = execute!(
stdout,
SetForegroundColor(Color::Green),
SetAttribute(Attribute::Bold),
Print("\n> "),
ResetColor,
);
let _ = stdout.flush();
} else {
print!("\n> ");
let _ = io::stdout().flush();
}
}
/// Print error
pub fn error(&self, msg: &str) {
if self.color_enabled {
let mut stderr = io::stderr();
let _ = execute!(
stderr,
SetForegroundColor(Color::Red),
Print(format!("[error] {}\n", msg)),
ResetColor,
);
} else {
eprintln!("[error] {}", msg);
}
}
/// Print session info
pub fn session_info(&self, msg: &str) {
if self.color_enabled {
let mut stderr = io::stderr();
let _ = execute!(
stderr,
SetForegroundColor(Color::Blue),
SetAttribute(Attribute::Dim),
Print(format!("{}\n", msg)),
ResetColor,
);
} else {
eprintln!("{}", msg);
}
}
}
fn truncate_display(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
// Find a char boundary to avoid panicking on multi-byte characters
let end = s.char_indices().nth(max).map(|(i, _)| i).unwrap_or(s.len());
format!("{}...", &s[..end])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_formatter_no_color_mode() {
// Verify construction with no_color=true does not panic
let _formatter = OutputFormatter::new(true);
}
#[test]
fn test_text_truncation_short_string_unchanged() {
let result = truncate_display("hello", 10);
assert_eq!(result, "hello");
}
#[test]
fn test_text_truncation_exact_length_unchanged() {
let result = truncate_display("helloworld", 10);
assert_eq!(result, "helloworld");
}
#[test]
fn test_text_truncation_long_string_truncated() {
let result = truncate_display("hello world this is long", 10);
assert_eq!(result, "hello worl...");
}
#[test]
fn test_text_truncation_empty_string() {
let result = truncate_display("", 10);
assert_eq!(result, "");
}
#[test]
fn test_turn_stats_no_panic() {
let formatter = OutputFormatter::new(true);
// Verify turn_stats does not panic with various inputs
formatter.turn_stats(1, 100, 50, 0, 0);
formatter.turn_stats(5, 1000, 500, 200, 300);
formatter.turn_stats(0, 0, 0, 0, 0);
}
#[test]
fn test_text_truncation_cjk_does_not_panic() {
// Each CJK char is 3 bytes; byte-based slicing at max=200 would land
// mid-character and panic without the char_indices fix.
let cjk: String = "你好世界测试".chars().cycle().take(200).collect();
let result = truncate_display(&cjk, 50);
assert!(result.ends_with("..."));
}
#[test]
fn test_text_truncation_mixed_cjk_ascii_does_not_panic() {
let mixed = "abc你好def世界ghi测试".repeat(20);
let result = truncate_display(&mixed, 30);
assert!(result.ends_with("..."));
}
}
@@ -0,0 +1,48 @@
use super::OutputSink;
/// Silent output sink that discards all output.
///
/// Used for sub-agents whose results are collected via `engine.run()` return
/// value and emitted by the parent as a single `tool_result` event. This
/// prevents raw text from leaking into the parent's protocol stream (JSON
/// Lines) — aligning with the Claude Code pattern where sub-agents never
/// write directly to stdout.
pub struct NullSink;
impl OutputSink for NullSink {
fn emit_text_delta(&self, _text: &str, _msg_id: &str) {}
fn emit_thinking(&self, _text: &str, _msg_id: &str) {}
fn emit_tool_call(&self, _tool_use_id: &str, _name: &str, _input: &str) {}
fn emit_tool_result(&self, _tool_use_id: &str, _name: &str, _is_error: bool, _content: &str) {}
fn emit_stream_start(&self, _msg_id: &str) {}
fn emit_stream_end(
&self,
_msg_id: &str,
_turns: usize,
_input_tokens: u64,
_output_tokens: u64,
_cache_creation_tokens: u64,
_cache_read_tokens: u64,
) {
}
fn emit_error(&self, _msg: &str) {}
fn emit_info(&self, _msg: &str) {}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn null_sink_does_not_panic() {
let sink = NullSink;
sink.emit_text_delta("hello", "msg1");
sink.emit_thinking("thought", "msg1");
sink.emit_tool_call("call_read_1", "Read", "{}");
sink.emit_tool_result("call_read_1", "Read", false, "ok");
sink.emit_stream_start("msg1");
sink.emit_stream_end("msg1", 1, 100, 50, 0, 0);
sink.emit_error("err");
sink.emit_info("info");
}
}
@@ -0,0 +1,148 @@
use std::sync::Arc;
use nomi_config::compat::ProviderCompat;
use nomi_protocol::events::{Capabilities, ErrorInfo, ProtocolEvent, Usage};
use nomi_protocol::writer::{ProtocolEmitter, ProtocolWriter};
use super::OutputSink;
/// JSON stream protocol output sink
pub struct ProtocolSink {
writer: Arc<ProtocolWriter>,
}
impl ProtocolSink {
pub fn new(writer: Arc<ProtocolWriter>) -> Self {
Self { writer }
}
/// Emit the ready event at session start
pub fn emit_ready(
&self,
compat: &ProviderCompat,
has_mcp: bool,
session_id: Option<String>,
current_mode: &str,
) {
let _ = self.writer.emit(&ProtocolEvent::Ready {
version: env!("CARGO_PKG_VERSION").to_string(),
session_id,
capabilities: Self::build_capabilities(compat, has_mcp, current_mode),
});
}
/// Emit a config_changed event after set_config or set_mode updates
pub fn emit_config_changed(&self, compat: &ProviderCompat, has_mcp: bool, current_mode: &str) {
let _ = self.writer.emit(&ProtocolEvent::ConfigChanged {
capabilities: Self::build_capabilities(compat, has_mcp, current_mode),
});
}
/// Access the underlying writer for custom events
pub fn writer(&self) -> &Arc<ProtocolWriter> {
&self.writer
}
fn build_capabilities(
compat: &ProviderCompat,
has_mcp: bool,
current_mode: &str,
) -> Capabilities {
Capabilities {
tool_approval: true,
thinking: compat.supports_thinking(),
effort: compat.supports_effort(),
effort_levels: compat.effort_levels().to_vec(),
modes: vec!["default".into(), "auto_edit".into(), "yolo".into()],
current_mode: current_mode.to_string(),
mcp: has_mcp,
}
}
}
impl OutputSink for ProtocolSink {
fn emit_text_delta(&self, text: &str, msg_id: &str) {
let _ = self.writer.emit(&ProtocolEvent::TextDelta {
text: text.to_string(),
msg_id: msg_id.to_string(),
});
}
fn emit_thinking(&self, text: &str, msg_id: &str) {
let _ = self.writer.emit(&ProtocolEvent::Thinking {
text: text.to_string(),
msg_id: msg_id.to_string(),
});
}
fn emit_tool_call(&self, _tool_use_id: &str, name: &str, _input: &str) {
// In protocol mode, tool_call is handled by tool_request/tool_running events.
// This is a fallback for compatibility.
let _ = self.writer.emit(&ProtocolEvent::Info {
msg_id: String::new(),
message: format!("Tool call: {name}"),
});
}
fn emit_tool_result(&self, _tool_use_id: &str, name: &str, is_error: bool, content: &str) {
// In protocol mode, tool results are emitted via explicit ToolResult events
// with call_id. This fallback emits an info event.
let status = if is_error { "error" } else { "success" };
let _ = self.writer.emit(&ProtocolEvent::Info {
msg_id: String::new(),
message: format!("[{name} {status}] {content}"),
});
}
fn emit_stream_start(&self, msg_id: &str) {
let _ = self.writer.emit(&ProtocolEvent::StreamStart {
msg_id: msg_id.to_string(),
});
}
fn emit_stream_end(
&self,
msg_id: &str,
_turns: usize,
input_tokens: u64,
output_tokens: u64,
cache_creation_tokens: u64,
cache_read_tokens: u64,
) {
let _ = self.writer.emit(&ProtocolEvent::StreamEnd {
msg_id: msg_id.to_string(),
usage: Some(Usage {
input_tokens,
output_tokens,
cache_read_tokens: if cache_read_tokens > 0 {
Some(cache_read_tokens)
} else {
None
},
cache_write_tokens: if cache_creation_tokens > 0 {
Some(cache_creation_tokens)
} else {
None
},
}),
});
}
fn emit_error(&self, msg: &str) {
let _ = self.writer.emit(&ProtocolEvent::Error {
msg_id: None,
error: ErrorInfo {
code: "engine_error".to_string(),
message: msg.to_string(),
retryable: false,
},
});
}
fn emit_info(&self, msg: &str) {
let _ = self.writer.emit(&ProtocolEvent::Info {
msg_id: String::new(),
message: msg.to_string(),
});
}
}
@@ -0,0 +1,67 @@
use super::{OutputFormatter, OutputSink};
/// Terminal output sink - wraps the existing OutputFormatter for human-readable output
pub struct TerminalSink {
formatter: OutputFormatter,
}
impl TerminalSink {
pub fn new(no_color: bool) -> Self {
Self {
formatter: OutputFormatter::new(no_color),
}
}
/// Access the underlying formatter for terminal-specific operations (repl_prompt, session_info)
pub fn formatter(&self) -> &OutputFormatter {
&self.formatter
}
}
impl OutputSink for TerminalSink {
fn emit_text_delta(&self, text: &str, _msg_id: &str) {
self.formatter.text_delta(text);
}
fn emit_thinking(&self, text: &str, _msg_id: &str) {
self.formatter.thinking(text);
}
fn emit_tool_call(&self, _tool_use_id: &str, name: &str, input: &str) {
self.formatter.tool_call(name, input);
}
fn emit_tool_result(&self, _tool_use_id: &str, name: &str, is_error: bool, content: &str) {
self.formatter.tool_result(name, is_error, content);
}
fn emit_stream_start(&self, _msg_id: &str) {
// Terminal mode: no explicit stream start marker
}
fn emit_stream_end(
&self,
_msg_id: &str,
turns: usize,
input_tokens: u64,
output_tokens: u64,
cache_creation_tokens: u64,
cache_read_tokens: u64,
) {
self.formatter.turn_stats(
turns,
input_tokens,
output_tokens,
cache_creation_tokens,
cache_read_tokens,
);
}
fn emit_error(&self, msg: &str) {
self.formatter.error(msg);
}
fn emit_info(&self, msg: &str) {
self.formatter.session_info(msg);
}
}
@@ -0,0 +1,102 @@
// Plan file management: path generation, reading, and writing.
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
/// Build the plan file path for a given session.
///
/// Returns `{plan_dir}/{session_id}.md`.
pub fn plan_file_path(plan_dir: &Path, session_id: &str) -> PathBuf {
plan_dir.join(format!("{session_id}.md"))
}
/// Write plan content to disk, creating parent directories if needed.
pub fn write_plan(path: &Path, content: &str) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, content)
}
/// Read plan content from disk.
///
/// Returns `None` if the file does not exist (instead of propagating an error).
/// Other I/O errors are still returned.
pub fn read_plan(path: &Path) -> io::Result<Option<String>> {
match fs::read_to_string(path) {
Ok(content) => Ok(Some(content)),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn path_joins_session_id() {
let dir = Path::new("/tmp/plans");
let path = plan_file_path(dir, "session-abc");
assert_eq!(path, PathBuf::from("/tmp/plans/session-abc.md"));
}
#[test]
fn path_handles_complex_session_id() {
let dir = Path::new("/data");
let path = plan_file_path(dir, "2024-01-01_abc123");
assert_eq!(path, PathBuf::from("/data/2024-01-01_abc123.md"));
}
#[test]
fn write_creates_parent_dirs() {
let tmp = tempfile::TempDir::new().unwrap();
let nested = tmp.path().join("a").join("b").join("plan.md");
write_plan(&nested, "# Plan").unwrap();
assert!(nested.exists());
assert_eq!(fs::read_to_string(&nested).unwrap(), "# Plan");
}
#[test]
fn write_overwrites_existing() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("plan.md");
write_plan(&path, "v1").unwrap();
write_plan(&path, "v2").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "v2");
}
#[test]
fn read_existing_file() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("plan.md");
fs::write(&path, "# My Plan\nStep 1").unwrap();
let result = read_plan(&path).unwrap();
assert_eq!(result, Some("# My Plan\nStep 1".to_string()));
}
#[test]
fn read_nonexistent_returns_none() {
let path = Path::new("/nonexistent/path/plan.md");
let result = read_plan(path).unwrap();
assert_eq!(result, None);
}
#[test]
fn write_then_read_roundtrip() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("plans").join("sess.md");
let content = "# Implementation Plan\n\n## Context\nRefactor auth module";
write_plan(&path, content).unwrap();
let read_back = read_plan(&path).unwrap();
assert_eq!(read_back, Some(content.to_string()));
}
}
@@ -0,0 +1,10 @@
// Plan Mode: state management, tool implementations, prompts, and file I/O.
//
// Plan Mode restricts the agent to read-only tools while composing an
// implementation plan. After the plan is ready the agent exits plan mode
// and regains full tool access.
pub mod file;
pub mod prompt;
pub mod state;
pub mod tools;
@@ -0,0 +1,116 @@
// Plan mode system prompt instructions.
//
// These prompts guide the LLM's behavior while in plan mode: what tools to
// use, what actions are forbidden, and how to structure the resulting plan.
/// Instructions injected into the system prompt when plan mode is active.
///
/// Guides the LLM through a structured planning workflow:
/// 1. Explore the codebase with read-only tools
/// 2. Design the implementation approach
/// 3. Write the plan
/// 4. Call ExitPlanMode when ready for user review
pub fn plan_mode_instructions() -> &'static str {
r#"# Plan Mode
Plan mode is active. You MUST NOT make any edits, run any non-read-only tools, or otherwise make any changes to the system.
## Allowed actions
- Read files, search code, and explore the codebase using read-only tools (Read, Grep, Glob)
- Compose your implementation plan in your response text
- Ask clarifying questions
## Forbidden actions
- Editing, creating, or deleting files
- Running shell commands that modify state
- Making commits or pushing changes
## Planning workflow
### Phase 1: Understand
Explore the codebase to understand the current architecture, relevant files, and existing patterns. Read key files and search for related code.
### Phase 2: Design
Based on your understanding, design the implementation approach:
- Identify which files need to be created or modified
- Reference existing functions and utilities that should be reused
- Consider edge cases and error handling
### Phase 3: Write the plan
Compose a clear, actionable implementation plan in your response including:
- **Context**: brief explanation of why this change is needed
- **Files to modify**: list each file and what changes are needed
- **Existing code to reuse**: reference functions and utilities with file paths
- **Verification**: how to test the changes end-to-end
### Phase 4: Submit for review
When your plan is complete, call ExitPlanMode to submit it for user review. Do not ask "Is this plan okay?" — calling ExitPlanMode is the way to request approval."#
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn instructions_not_empty() {
assert!(!plan_mode_instructions().is_empty());
}
#[test]
fn instructions_mention_read_only_tools() {
let text = plan_mode_instructions();
assert!(text.contains("Read"), "should mention Read tool");
assert!(text.contains("Grep"), "should mention Grep tool");
assert!(text.contains("Glob"), "should mention Glob tool");
}
#[test]
fn instructions_mention_exit_tool() {
assert!(plan_mode_instructions().contains("ExitPlanMode"));
}
#[test]
fn instructions_forbid_writes() {
let text = plan_mode_instructions();
assert!(text.contains("MUST NOT"));
assert!(text.contains("Forbidden"));
}
#[test]
fn instructions_guide_planning_workflow() {
let text = plan_mode_instructions();
assert!(text.contains("Understand"), "should have explore phase");
assert!(text.contains("Design"), "should have design phase");
assert!(
text.contains("Write the plan"),
"should have plan writing phase"
);
assert!(
text.contains("Submit for review"),
"should have submission phase"
);
}
#[test]
fn instructions_compose_in_response_not_write_file() {
let text = plan_mode_instructions();
assert!(
text.contains("Compose your implementation plan in your response"),
"should guide LLM to compose plan in response text"
);
assert!(
!text.contains("Write to the plan file"),
"should not mention writing to plan file (R-3.4-01 fix)"
);
}
#[test]
fn instructions_no_bb_brand() {
let text = plan_mode_instructions();
assert!(!text.contains("Claude"), "should not contain Claude brand");
assert!(
!text.contains("~/.claude"),
"should not contain bb config path"
);
}
}
@@ -0,0 +1,55 @@
/// Runtime state for Plan Mode.
///
/// Tracks whether the agent is currently in plan mode and the tool allow-list
/// that was active before plan mode was entered (for restoration on exit).
#[derive(Debug, Clone, Default)]
pub struct PlanState {
/// Whether plan mode is currently active.
pub is_active: bool,
/// The tool allow-list that was in effect before entering plan mode.
/// Restored when the agent exits plan mode.
pub pre_plan_allow_list: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_inactive() {
let state = PlanState::default();
assert!(!state.is_active);
}
#[test]
fn default_has_empty_allow_list() {
let state = PlanState::default();
assert!(state.pre_plan_allow_list.is_empty());
}
#[test]
fn can_set_active_with_allow_list() {
let state = PlanState {
is_active: true,
pre_plan_allow_list: vec!["Read".into(), "Bash".into()],
};
assert!(state.is_active);
assert_eq!(state.pre_plan_allow_list, vec!["Read", "Bash"]);
}
#[test]
fn clone_produces_independent_copy() {
let original = PlanState {
is_active: true,
pre_plan_allow_list: vec!["Grep".into()],
};
let mut cloned = original.clone();
cloned.is_active = false;
cloned.pre_plan_allow_list.push("Read".into());
// Original unchanged
assert!(original.is_active);
assert_eq!(original.pre_plan_allow_list, vec!["Grep"]);
}
}
@@ -0,0 +1,338 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use async_trait::async_trait;
use serde_json::{Value, json};
use nomi_protocol::events::ToolCategory;
use nomi_tools::Tool;
use nomi_types::skill_types::{ContextModifier, PlanModeTransition};
use nomi_types::tool::{JsonSchema, ToolResult};
// ---------------------------------------------------------------------------
// EnterPlanModeTool
// ---------------------------------------------------------------------------
/// Transitions the agent into Plan Mode.
///
/// While in plan mode the engine restricts the available tool set to
/// read-only (`Info`-category) tools so the LLM can focus on understanding
/// the codebase and composing an implementation plan.
pub struct EnterPlanModeTool {
/// Shared flag indicating whether plan mode is currently active.
/// Read by `execute()` to prevent double-entry.
plan_active: Arc<AtomicBool>,
}
impl EnterPlanModeTool {
pub fn new(plan_active: Arc<AtomicBool>) -> Self {
Self { plan_active }
}
}
#[async_trait]
impl Tool for EnterPlanModeTool {
fn name(&self) -> &str {
"EnterPlanMode"
}
fn description(&self) -> &str {
"Enter plan mode to focus on reading code and creating an implementation plan. \
While in plan mode, only read-only tools are available. \
Use ExitPlanMode when your plan is ready."
}
fn input_schema(&self) -> JsonSchema {
json!({
"type": "object",
"properties": {},
"required": []
})
}
fn is_concurrency_safe(&self, _input: &Value) -> bool {
true
}
fn is_deferred(&self) -> bool {
true
}
async fn execute(&self, _input: Value) -> ToolResult {
if self.plan_active.load(Ordering::Acquire) {
return ToolResult {
content: "Already in plan mode. Use ExitPlanMode to exit first.".to_string(),
is_error: true,
images: Vec::new(),
};
}
ToolResult {
content: "Entered plan mode. You can now only use read-only tools to explore \
the codebase and create your implementation plan. When your plan is \
ready, use ExitPlanMode to exit plan mode and begin implementation."
.to_string(),
is_error: false,
images: Vec::new(),
}
}
fn context_modifier_for(&self, _input: &Value) -> Option<ContextModifier> {
Some(ContextModifier {
plan_mode_transition: Some(PlanModeTransition::Enter),
..Default::default()
})
}
fn category(&self) -> ToolCategory {
ToolCategory::Info
}
fn describe(&self, _input: &Value) -> String {
"Enter plan mode".to_string()
}
}
// ---------------------------------------------------------------------------
// ExitPlanModeTool
// ---------------------------------------------------------------------------
/// Transitions the agent out of Plan Mode.
///
/// On exit the engine restores the full tool set and the allow-list
/// that was in effect before plan mode was entered.
pub struct ExitPlanModeTool {
/// Shared flag indicating whether plan mode is currently active.
/// Read by `execute()` to reject exit when not in plan mode.
plan_active: Arc<AtomicBool>,
}
impl ExitPlanModeTool {
pub fn new(plan_active: Arc<AtomicBool>) -> Self {
Self { plan_active }
}
}
#[async_trait]
impl Tool for ExitPlanModeTool {
fn name(&self) -> &str {
"ExitPlanMode"
}
fn description(&self) -> &str {
"Exit plan mode after completing your implementation plan. \
This restores full tool access so you can begin implementing the plan."
}
fn input_schema(&self) -> JsonSchema {
json!({
"type": "object",
"properties": {},
"required": []
})
}
fn is_concurrency_safe(&self, _input: &Value) -> bool {
true
}
fn is_deferred(&self) -> bool {
true
}
async fn execute(&self, _input: Value) -> ToolResult {
if !self.plan_active.load(Ordering::Acquire) {
return ToolResult {
content: "Not in plan mode. Use EnterPlanMode to enter plan mode first."
.to_string(),
is_error: true,
images: Vec::new(),
};
}
ToolResult {
content: "Exited plan mode. Full tool access has been restored. \
You can now proceed with implementing the plan."
.to_string(),
is_error: false,
images: Vec::new(),
}
}
fn context_modifier_for(&self, _input: &Value) -> Option<ContextModifier> {
Some(ContextModifier {
plan_mode_transition: Some(PlanModeTransition::Exit { plan_content: None }),
..Default::default()
})
}
fn category(&self) -> ToolCategory {
ToolCategory::Info
}
fn describe(&self, _input: &Value) -> String {
"Exit plan mode".to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_shared_flag(active: bool) -> Arc<AtomicBool> {
Arc::new(AtomicBool::new(active))
}
// --- EnterPlanModeTool unit tests ---
#[test]
fn enter_tool_name() {
let tool = EnterPlanModeTool::new(make_shared_flag(false));
assert_eq!(tool.name(), "EnterPlanMode");
}
#[test]
fn enter_tool_category_is_info() {
let tool = EnterPlanModeTool::new(make_shared_flag(false));
assert!(matches!(tool.category(), ToolCategory::Info));
}
#[test]
fn enter_tool_concurrency_safe() {
let tool = EnterPlanModeTool::new(make_shared_flag(false));
assert!(tool.is_concurrency_safe(&json!({})));
}
#[test]
fn enter_tool_schema_has_no_required_fields() {
let tool = EnterPlanModeTool::new(make_shared_flag(false));
let schema = tool.input_schema();
let required = schema["required"].as_array().unwrap();
assert!(required.is_empty());
}
#[test]
fn enter_tool_context_modifier_returns_enter() {
let tool = EnterPlanModeTool::new(make_shared_flag(false));
let modifier = tool.context_modifier_for(&json!({}));
assert!(modifier.is_some());
let cm = modifier.unwrap();
assert_eq!(cm.plan_mode_transition, Some(PlanModeTransition::Enter));
// Other fields are default
assert!(cm.model.is_none());
assert!(cm.effort.is_none());
assert!(cm.allowed_tools.is_empty());
}
#[tokio::test]
async fn enter_succeeds_when_not_active() {
let tool = EnterPlanModeTool::new(make_shared_flag(false));
let result = tool.execute(json!({})).await;
assert!(!result.is_error);
assert!(result.content.contains("Entered plan mode"));
}
#[tokio::test]
async fn enter_rejects_when_already_active() {
let tool = EnterPlanModeTool::new(make_shared_flag(true));
let result = tool.execute(json!({})).await;
assert!(result.is_error);
assert!(result.content.contains("Already in plan mode"));
}
#[test]
fn enter_tool_describe() {
let tool = EnterPlanModeTool::new(make_shared_flag(false));
assert_eq!(tool.describe(&json!({})), "Enter plan mode");
}
// --- ExitPlanModeTool unit tests ---
#[test]
fn exit_tool_name() {
let tool = ExitPlanModeTool::new(make_shared_flag(false));
assert_eq!(tool.name(), "ExitPlanMode");
}
#[test]
fn exit_tool_category_is_info() {
let tool = ExitPlanModeTool::new(make_shared_flag(false));
assert!(matches!(tool.category(), ToolCategory::Info));
}
#[test]
fn exit_tool_concurrency_safe() {
let tool = ExitPlanModeTool::new(make_shared_flag(false));
assert!(tool.is_concurrency_safe(&json!({})));
}
#[test]
fn exit_tool_schema_has_no_required_fields() {
let tool = ExitPlanModeTool::new(make_shared_flag(false));
let schema = tool.input_schema();
let required = schema["required"].as_array().unwrap();
assert!(required.is_empty());
}
#[test]
fn exit_tool_context_modifier_returns_exit() {
let tool = ExitPlanModeTool::new(make_shared_flag(false));
let modifier = tool.context_modifier_for(&json!({}));
assert!(modifier.is_some());
let cm = modifier.unwrap();
assert!(matches!(
cm.plan_mode_transition,
Some(PlanModeTransition::Exit { plan_content: None })
));
// Other fields are default
assert!(cm.model.is_none());
assert!(cm.effort.is_none());
assert!(cm.allowed_tools.is_empty());
}
#[tokio::test]
async fn exit_succeeds_when_active() {
let tool = ExitPlanModeTool::new(make_shared_flag(true));
let result = tool.execute(json!({})).await;
assert!(!result.is_error);
assert!(result.content.contains("Exited plan mode"));
}
#[tokio::test]
async fn exit_rejects_when_not_active() {
let tool = ExitPlanModeTool::new(make_shared_flag(false));
let result = tool.execute(json!({})).await;
assert!(result.is_error);
assert!(result.content.contains("Not in plan mode"));
}
#[test]
fn exit_tool_describe() {
let tool = ExitPlanModeTool::new(make_shared_flag(false));
assert_eq!(tool.describe(&json!({})), "Exit plan mode");
}
// --- Shared flag tests ---
#[tokio::test]
async fn shared_flag_reflects_state_changes() {
let flag = make_shared_flag(false);
let enter_tool = EnterPlanModeTool::new(Arc::clone(&flag));
let exit_tool = ExitPlanModeTool::new(Arc::clone(&flag));
// Initially not active — enter succeeds, exit fails
let r = enter_tool.execute(json!({})).await;
assert!(!r.is_error);
let r = exit_tool.execute(json!({})).await;
assert!(r.is_error);
// Simulate engine setting the flag after processing Enter transition
flag.store(true, Ordering::Release);
// Now active — enter fails, exit succeeds
let r = enter_tool.execute(json!({})).await;
assert!(r.is_error);
let r = exit_tool.execute(json!({})).await;
assert!(!r.is_error);
}
}
@@ -0,0 +1,306 @@
//! Native tools that let an in-process agent report requirement progress back
//! to the backend through a `RequirementSink` trait object. The backend injects
//! a concrete sink; standalone `nomi-cli` passes `None` and these are not registered.
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::{Value, json};
use nomi_protocol::events::ToolCategory;
use nomi_tools::Tool;
use nomi_types::tool::{JsonSchema, ToolResult};
/// Backend seam for requirement self-updates. Implemented by the backend over
/// its `RequirementService`; `nomi-agent` only depends on this trait.
#[async_trait]
pub trait RequirementSink: Send + Sync {
/// Mark a requirement done with a completion note.
async fn complete(&self, requirement_id: &str, note: &str) -> Result<(), String>;
/// Update a requirement's status (`in_progress` | `done` | `failed`) with an optional note.
async fn update_status(
&self,
requirement_id: &str,
status: &str,
note: Option<&str>,
) -> Result<(), String>;
}
/// `requirement_complete` — mark the current requirement done.
pub struct RequirementCompleteTool {
sink: Arc<dyn RequirementSink>,
}
impl RequirementCompleteTool {
pub fn new(sink: Arc<dyn RequirementSink>) -> Self {
Self { sink }
}
}
#[async_trait]
impl Tool for RequirementCompleteTool {
fn name(&self) -> &str {
"requirement_complete"
}
fn description(&self) -> &str {
"Mark the current AutoWork requirement as done. Call this exactly once when you have \
finished the requirement you were given. Provide a concise completion note describing \
what you did. Do not pick the next requirement yourself — the platform will hand it to you."
}
fn input_schema(&self) -> JsonSchema {
json!({
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "The requirement id you were given in the AutoWork prompt"
},
"completion_note": {
"type": "string",
"description": "A concise description of what was accomplished"
}
},
"required": ["id", "completion_note"]
})
}
fn is_concurrency_safe(&self, _input: &Value) -> bool {
false
}
fn is_deferred(&self) -> bool {
// NOT deferred: the AutoWork prompt instructs the agent to call this
// tool directly, so its full parameter schema must be visible up front.
// A deferred stub would make the model call it with `{}` (missing `id`)
// and only then be told to ToolSearch — the bug this fixes.
false
}
async fn execute(&self, input: Value) -> ToolResult {
let id = match input.get("id").and_then(|v| v.as_str()) {
Some(s) if !s.is_empty() => s,
_ => {
return ToolResult {
content: "Missing required 'id' string".to_string(),
is_error: true,
images: Vec::new(),
};
}
};
let note = input
.get("completion_note")
.and_then(|v| v.as_str())
.unwrap_or("");
match self.sink.complete(id, note).await {
Ok(()) => ToolResult {
content: format!("Requirement {id} marked done."),
is_error: false,
images: Vec::new(),
},
Err(e) => ToolResult {
content: format!("Failed to complete requirement {id}: {e}"),
is_error: true,
images: Vec::new(),
},
}
}
fn category(&self) -> ToolCategory {
ToolCategory::Exec
}
}
/// `requirement_update_status` — set status to in_progress | done | failed.
pub struct RequirementUpdateStatusTool {
sink: Arc<dyn RequirementSink>,
}
impl RequirementUpdateStatusTool {
pub fn new(sink: Arc<dyn RequirementSink>) -> Self {
Self { sink }
}
}
#[async_trait]
impl Tool for RequirementUpdateStatusTool {
fn name(&self) -> &str {
"requirement_update_status"
}
fn description(&self) -> &str {
"Update the status of the current AutoWork requirement. Use status='failed' with a reason \
if you cannot complete it, or status='done' when finished. Valid: in_progress, done, failed."
}
fn input_schema(&self) -> JsonSchema {
json!({
"type": "object",
"properties": {
"id": { "type": "string", "description": "The requirement id" },
"status": {
"type": "string",
"enum": ["in_progress", "done", "failed"],
"description": "New status"
},
"note": { "type": "string", "description": "Optional reason / note" }
},
"required": ["id", "status"]
})
}
fn is_concurrency_safe(&self, _input: &Value) -> bool {
false
}
fn is_deferred(&self) -> bool {
// NOT deferred: the AutoWork prompt instructs the agent to call this
// tool directly, so its full parameter schema must be visible up front.
// A deferred stub would make the model call it with `{}` (missing `id`)
// and only then be told to ToolSearch — the bug this fixes.
false
}
async fn execute(&self, input: Value) -> ToolResult {
let id = match input.get("id").and_then(|v| v.as_str()) {
Some(s) if !s.is_empty() => s,
_ => {
return ToolResult {
content: "Missing required 'id' string".to_string(),
is_error: true,
images: Vec::new(),
};
}
};
let status = match input.get("status").and_then(|v| v.as_str()) {
Some(s @ ("in_progress" | "done" | "failed")) => s,
_ => {
return ToolResult {
content: "Invalid 'status' (expected in_progress|done|failed)".to_string(),
is_error: true,
images: Vec::new(),
};
}
};
let note = input.get("note").and_then(|v| v.as_str());
match self.sink.update_status(id, status, note).await {
Ok(()) => ToolResult {
content: format!("Requirement {id} status set to {status}."),
is_error: false,
images: Vec::new(),
},
Err(e) => ToolResult {
content: format!("Failed to update requirement {id}: {e}"),
is_error: true,
images: Vec::new(),
},
}
}
fn category(&self) -> ToolCategory {
ToolCategory::Exec
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
#[derive(Default)]
struct FakeSink {
completed: Mutex<Vec<(String, String)>>,
statuses: Mutex<Vec<(String, String, Option<String>)>>,
fail: bool,
}
#[async_trait]
impl RequirementSink for FakeSink {
async fn complete(&self, id: &str, note: &str) -> Result<(), String> {
if self.fail {
return Err("boom".into());
}
self.completed
.lock()
.unwrap()
.push((id.to_string(), note.to_string()));
Ok(())
}
async fn update_status(
&self,
id: &str,
status: &str,
note: Option<&str>,
) -> Result<(), String> {
if self.fail {
return Err("boom".into());
}
self.statuses.lock().unwrap().push((
id.to_string(),
status.to_string(),
note.map(|s| s.to_string()),
));
Ok(())
}
}
#[tokio::test]
async fn complete_calls_sink() {
let sink = Arc::new(FakeSink::default());
let tool = RequirementCompleteTool::new(sink.clone());
let res = tool
.execute(json!({ "id": "req_1", "completion_note": "done it" }))
.await;
assert!(!res.is_error, "content: {}", res.content);
let completed = sink.completed.lock().unwrap();
assert_eq!(completed.len(), 1);
assert_eq!(completed[0], ("req_1".to_string(), "done it".to_string()));
}
#[tokio::test]
async fn complete_missing_id_is_error() {
let sink = Arc::new(FakeSink::default());
let tool = RequirementCompleteTool::new(sink);
let res = tool.execute(json!({ "completion_note": "x" })).await;
assert!(res.is_error);
}
#[tokio::test]
async fn update_status_validates_enum() {
let sink = Arc::new(FakeSink::default());
let tool = RequirementUpdateStatusTool::new(sink.clone());
let bad = tool
.execute(json!({ "id": "req_1", "status": "weird" }))
.await;
assert!(bad.is_error);
let good = tool
.execute(json!({ "id": "req_1", "status": "failed", "note": "blocked" }))
.await;
assert!(!good.is_error, "content: {}", good.content);
let statuses = sink.statuses.lock().unwrap();
assert_eq!(
statuses[0],
(
"req_1".to_string(),
"failed".to_string(),
Some("blocked".to_string())
)
);
}
#[tokio::test]
async fn sink_error_surfaces_as_tool_error() {
let sink = Arc::new(FakeSink {
fail: true,
..Default::default()
});
let tool = RequirementCompleteTool::new(sink);
let res = tool
.execute(json!({ "id": "req_1", "completion_note": "x" }))
.await;
assert!(res.is_error);
assert!(res.content.contains("boom"));
}
}
@@ -0,0 +1,462 @@
use std::path::PathBuf;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use nomi_types::message::{Message, TokenUsage};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub id: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub provider: String,
pub model: String,
pub cwd: String,
pub total_usage: TokenUsage,
pub messages: Vec<Message>,
/// Stable identity of the conversation INSTANCE that owns this session
/// (e.g. the conversation row's `created_at`). The session directory is
/// keyed by the reusable integer `conversation_id`, so after a delete +
/// id reuse (or a DB rebaseline) a new conversation can land on an old
/// session file. Resume paths compare this token and start fresh on a
/// mismatch instead of inheriting a stranger's history. `None` = legacy
/// session written before this field existed (accepted, then migrated).
#[serde(default)]
pub owner_token: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionIndex {
pub sessions: Vec<SessionMeta>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionMeta {
pub id: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub model: String,
/// First user message, truncated to 80 chars
pub summary: String,
pub message_count: usize,
}
pub struct SessionManager {
directory: PathBuf,
max_sessions: usize,
}
impl SessionManager {
pub fn new(directory: PathBuf, max_sessions: usize) -> Self {
Self {
directory,
max_sessions,
}
}
/// Create a new session, return it
pub fn create(
&self,
provider: &str,
model: &str,
cwd: &str,
session_id: Option<&str>,
) -> anyhow::Result<Session> {
std::fs::create_dir_all(&self.directory)?;
let id = if let Some(custom_id) = session_id {
// Validate that the ID doesn't already exist
let index = self.load_index()?;
if index.sessions.iter().any(|s| s.id == custom_id) {
anyhow::bail!("Session ID '{}' already exists", custom_id);
}
custom_id.to_string()
} else {
generate_short_id()
};
let session = Session {
id,
created_at: Utc::now(),
updated_at: Utc::now(),
provider: provider.to_string(),
model: model.to_string(),
cwd: cwd.to_string(),
total_usage: TokenUsage::default(),
messages: Vec::new(),
owner_token: None,
};
self.save(&session)?;
self.update_index(&session)?;
self.cleanup_old()?;
Ok(session)
}
/// Save current session state (called after each turn)
pub fn save(&self, session: &Session) -> anyhow::Result<()> {
std::fs::create_dir_all(&self.directory)?;
let filename = format!(
"{}_{}.json",
session.created_at.format("%Y-%m-%d"),
session.id
);
let path = self.directory.join(&filename);
let json = serde_json::to_string_pretty(session)?;
std::fs::write(path, json)?;
Ok(())
}
/// Load a session by ID (or "latest")
pub fn load(&self, id_or_latest: &str) -> anyhow::Result<Session> {
let index = self.load_index()?;
let meta = if id_or_latest == "latest" {
index
.sessions
.last()
.ok_or_else(|| anyhow::anyhow!("No sessions found"))?
} else {
index
.sessions
.iter()
.find(|s| s.id == id_or_latest)
.ok_or_else(|| anyhow::anyhow!("Session '{}' not found", id_or_latest))?
};
let pattern = format!("*_{}.json", meta.id);
let session_files: Vec<_> =
glob::glob(self.directory.join(&pattern).to_string_lossy().as_ref())?
.filter_map(|r| r.ok())
.collect();
let path = session_files
.first()
.ok_or_else(|| anyhow::anyhow!("Session file not found for '{}'", meta.id))?;
let content = std::fs::read_to_string(path)?;
let session: Session = serde_json::from_str(&content)?;
Ok(session)
}
/// List all sessions
pub fn list(&self) -> anyhow::Result<Vec<SessionMeta>> {
let index = self.load_index()?;
Ok(index.sessions)
}
fn load_index(&self) -> anyhow::Result<SessionIndex> {
let index_path = self.directory.join("index.json");
match std::fs::read_to_string(&index_path) {
Ok(content) => Ok(serde_json::from_str(&content)?),
Err(_) => Ok(SessionIndex {
sessions: Vec::new(),
}),
}
}
/// Update the session index (public, called from engine after save)
pub fn update_index_for(&self, session: &Session) -> anyhow::Result<()> {
self.update_index(session)
}
fn update_index(&self, session: &Session) -> anyhow::Result<()> {
let mut index = self.load_index()?;
// Extract summary from first user message
let summary = session
.messages
.iter()
.find(|m| m.role == nomi_types::message::Role::User)
.and_then(|m| {
m.content.iter().find_map(|c| {
if let nomi_types::message::ContentBlock::Text { text } = c {
Some(truncate_str(text, 80))
} else {
None
}
})
})
.unwrap_or_default();
let meta = SessionMeta {
id: session.id.clone(),
created_at: session.created_at,
updated_at: session.updated_at,
model: session.model.clone(),
summary,
message_count: session.messages.len(),
};
// Update existing or add new
if let Some(existing) = index.sessions.iter_mut().find(|s| s.id == session.id) {
*existing = meta;
} else {
index.sessions.push(meta);
}
let index_path = self.directory.join("index.json");
let json = serde_json::to_string_pretty(&index)?;
std::fs::write(index_path, json)?;
Ok(())
}
/// Remove oldest sessions beyond max_sessions
fn cleanup_old(&self) -> anyhow::Result<()> {
let mut index = self.load_index()?;
if index.sessions.len() <= self.max_sessions {
return Ok(());
}
// Sort by created_at, remove oldest
index.sessions.sort_by_key(|s| s.created_at);
let to_remove = index.sessions.len() - self.max_sessions;
let removed: Vec<_> = index.sessions.drain(..to_remove).collect();
// Delete session files
for meta in &removed {
let pattern = format!("*_{}.json", meta.id);
if let Ok(paths) = glob::glob(self.directory.join(&pattern).to_string_lossy().as_ref())
{
for path in paths.flatten() {
let _ = std::fs::remove_file(path);
}
}
}
// Save updated index
let index_path = self.directory.join("index.json");
let json = serde_json::to_string_pretty(&index)?;
std::fs::write(index_path, json)?;
Ok(())
}
/// Remove a session by id: delete its `*_{id}.json` file(s) and drop its
/// index entry. Best-effort and idempotent. Called when a conversation is
/// deleted so a future conversation that reuses this integer id cannot
/// resume the stale session (defense-in-depth alongside `owner_token`).
pub fn delete_session(&self, id: &str) -> anyhow::Result<()> {
let pattern = format!("*_{}.json", id);
if let Ok(paths) = glob::glob(self.directory.join(&pattern).to_string_lossy().as_ref()) {
for path in paths.flatten() {
let _ = std::fs::remove_file(path);
}
}
let index_path = self.directory.join("index.json");
if index_path.exists() {
let mut index = self.load_index()?;
let before = index.sessions.len();
index.sessions.retain(|s| s.id != id);
if index.sessions.len() != before {
let json = serde_json::to_string_pretty(&index)?;
std::fs::write(index_path, json)?;
}
}
Ok(())
}
}
/// Decide whether a loaded session may be resumed for the conversation instance
/// identified by `expected_owner` / `conv_created_ms` (see [`Session::owner_token`]).
/// Rejects either (a) a session stamped for a DIFFERENT instance, or (b) a
/// session created BEFORE the conversation instance — an orphan left by a prior
/// holder of this reused id (covers legacy `None`-token files after a DB
/// rebaseline; a legitimate session is created when the conversation's first
/// turn runs, i.e. at/after the conversation row, so it never predates it).
/// A legacy session that postdates the conversation is accepted and migrated.
pub fn session_belongs_to(
session_owner: Option<&str>,
session_created_ms: i64,
expected_owner: Option<&str>,
conv_created_ms: Option<i64>,
) -> bool {
if let (Some(stamped), Some(expected)) = (session_owner, expected_owner)
&& stamped != expected
{
return false;
}
if let Some(conv_ms) = conv_created_ms
&& session_created_ms < conv_ms
{
return false;
}
true
}
fn generate_short_id() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.subsec_nanos();
format!("{:06x}", nanos & 0xFFFFFF)
}
fn truncate_str(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let truncated: String = s.chars().take(max - 3).collect();
format!("{}...", truncated)
}
}
#[cfg(test)]
mod tests {
use super::*;
use nomi_types::message::{ContentBlock, Message, Role};
use tempfile::tempdir;
#[test]
fn test_create_session() {
let dir = tempdir().unwrap();
let manager = SessionManager::new(dir.path().to_path_buf(), 10);
let result = manager.create("openai", "gpt-4", "/tmp", None);
assert!(result.is_ok());
let session = result.unwrap();
assert_eq!(session.provider, "openai");
assert_eq!(session.model, "gpt-4");
assert_eq!(session.cwd, "/tmp");
assert!(session.messages.is_empty());
}
#[test]
fn test_save_and_load_session() {
let dir = tempdir().unwrap();
let manager = SessionManager::new(dir.path().to_path_buf(), 10);
let session = manager
.create("anthropic", "claude-3", "/home", None)
.unwrap();
let loaded = manager.load(&session.id).unwrap();
assert_eq!(loaded.id, session.id);
assert_eq!(loaded.provider, "anthropic");
assert_eq!(loaded.model, "claude-3");
assert_eq!(loaded.cwd, "/home");
}
#[test]
fn test_load_nonexistent_returns_error() {
let dir = tempdir().unwrap();
let manager = SessionManager::new(dir.path().to_path_buf(), 10);
let result = manager.load("nonexistent-id");
assert!(result.is_err());
}
#[test]
fn test_list_sessions_empty() {
let dir = tempdir().unwrap();
let manager = SessionManager::new(dir.path().to_path_buf(), 10);
let sessions = manager.list().unwrap();
assert!(sessions.is_empty());
}
#[test]
fn test_list_sessions_sorted_by_time() {
let dir = tempdir().unwrap();
let manager = SessionManager::new(dir.path().to_path_buf(), 10);
let s1 = manager.create("openai", "gpt-4", "/tmp", None).unwrap();
let s2 = manager
.create("anthropic", "claude-3", "/home", None)
.unwrap();
let list = manager.list().unwrap();
assert_eq!(list.len(), 2);
let ids: Vec<&str> = list.iter().map(|m| m.id.as_str()).collect();
assert!(ids.contains(&s1.id.as_str()));
assert!(ids.contains(&s2.id.as_str()));
}
#[test]
fn test_update_index() {
let dir = tempdir().unwrap();
let manager = SessionManager::new(dir.path().to_path_buf(), 10);
let mut session = manager.create("openai", "gpt-4", "/tmp", None).unwrap();
let msg = Message::new(
Role::User,
vec![ContentBlock::Text {
text: "hello".to_string(),
}],
);
session.messages.push(msg);
manager.update_index_for(&session).unwrap();
let list = manager.list().unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].summary, "hello");
assert_eq!(list[0].message_count, 1);
}
#[test]
fn test_cleanup_old_sessions() {
let dir = tempdir().unwrap();
let manager = SessionManager::new(dir.path().to_path_buf(), 2);
let _s1 = manager.create("openai", "gpt-4", "/tmp", None).unwrap();
let _s2 = manager.create("openai", "gpt-4", "/tmp", None).unwrap();
let _s3 = manager.create("openai", "gpt-4", "/tmp", None).unwrap();
let list = manager.list().unwrap();
assert_eq!(list.len(), 2);
}
#[test]
fn test_session_id_uniqueness() {
let id1 = generate_short_id();
std::thread::sleep(std::time::Duration::from_millis(2));
let id2 = generate_short_id();
assert_ne!(id1, id2);
}
#[test]
fn session_belongs_to_matrix() {
let conv = 1_782_638_611_752i64; // conversation instance created_at (ms)
let after = conv + 5_000; // a legitimate session is created at/after that
// Same stamped instance, session postdates conv → accept.
assert!(session_belongs_to(Some("1782638611752"), after, Some("1782638611752"), Some(conv)));
// Stamped for a different instance → reject.
assert!(!session_belongs_to(Some("1700000000000"), after, Some("1782638611752"), Some(conv)));
// Legacy (no stamp) but session predates the conversation → orphan, reject.
assert!(!session_belongs_to(None, conv - 1, Some("1782638611752"), Some(conv)));
// Legacy (no stamp) and session postdates the conversation → accept (migrate).
assert!(session_belongs_to(None, after, Some("1782638611752"), Some(conv)));
// No expected conversation identity → skip validation (accept).
assert!(session_belongs_to(None, 0, None, None));
}
#[test]
fn delete_session_removes_only_target_file_and_index_entry() {
let dir = tempdir().unwrap();
let manager = SessionManager::new(dir.path().to_path_buf(), 10);
// Two sessions with explicit ids (mirrors conversation_id-keyed sessions).
manager.create("openai", "gpt-4", "/tmp", Some("3")).unwrap();
manager.create("openai", "gpt-4", "/tmp", Some("7")).unwrap();
manager.delete_session("3").unwrap();
// The deleted session is gone; the sibling survives.
assert!(manager.load("3").is_err(), "deleted session must not load");
assert!(manager.load("7").is_ok(), "sibling session must survive");
let ids: Vec<String> = manager.list().unwrap().into_iter().map(|m| m.id).collect();
assert!(!ids.contains(&"3".to_string()), "index must drop the deleted id");
assert!(ids.contains(&"7".to_string()), "index must keep the sibling id");
}
#[test]
fn delete_session_is_idempotent_when_absent() {
let dir = tempdir().unwrap();
let manager = SessionManager::new(dir.path().to_path_buf(), 10);
// No sessions / no index yet — must not error.
assert!(manager.delete_session("3").is_ok());
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,379 @@
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::{Value, json};
use crate::spawner::{AgentSpawner, SubAgentConfig};
use nomi_protocol::events::ToolCategory;
use nomi_types::tool::{JsonSchema, ToolResult};
use nomi_tools::Tool;
const DEFAULT_SUB_AGENT_MAX_TURNS: usize = 200;
const DEFAULT_SUB_AGENT_MAX_TOKENS: u32 = 4096;
const MAX_SUB_AGENTS: usize = 5;
pub struct SpawnTool {
spawner: Arc<AgentSpawner>,
}
impl SpawnTool {
pub fn new(spawner: Arc<AgentSpawner>) -> Self {
Self { spawner }
}
}
#[async_trait]
impl Tool for SpawnTool {
fn name(&self) -> &str {
"Spawn"
}
fn description(&self) -> &str {
"Spawn one or more sub-agents to handle tasks in parallel. \
Each sub-agent has its own conversation context and tool access.\n\n\
- Maximum 5 sub-agents per call.\n\
- Each sub-agent runs up to 200 conversation turns with a 4096 token output limit.\n\
- Use for independent, parallelizable tasks (e.g., searching different modules, \
running separate analyses).\n\
- Do NOT use for tasks that need shared state or sequential coordination.\n\
- Set `synthesize: true` to add a final pass that consolidates the sub-agents' \
outputs and flags conflicts (useful for verification / judge-panel fan-outs)."
}
fn input_schema(&self) -> JsonSchema {
json!({
"type": "object",
"properties": {
"tasks": {
"type": "array",
"description": "List of tasks for sub-agents to execute in parallel",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Short descriptive name for the task"
},
"prompt": {
"type": "string",
"description": "The task description / prompt for the sub-agent"
},
"role": {
"type": "string",
"enum": ["searcher", "reviewer", "verifier", "implementer"],
"description": "Optional role restricting the sub-agent's tools: searcher/reviewer = read-only (Read/Grep/Glob), verifier = read-only + Bash, implementer (default) = all tools."
}
},
"required": ["name", "prompt"]
}
},
"synthesize": {
"type": "boolean",
"description": "When true (and ≥2 tasks), run one extra read-only sub-agent that consolidates all sub-agent outputs into a single answer and flags any conflicts. Use for verification/judge-panel fan-outs where the results must be reconciled; omit for independent tasks you will combine yourself."
},
"coordinate": {
"type": "boolean",
"description": "When true, all sub-agents share a task board (the `shared_tasks` tool): they can claim work to avoid duplicating it and report progress to each other. Use when the tasks overlap or must divide a work-list; omit for fully independent tasks."
},
"isolate": {
"type": "boolean",
"description": "When true, each sub-agent runs in its own isolated git worktree so concurrent edits don't clobber the shared tree. Each result includes its changes as a diff for you to review and apply with ApplyPatch. Use for parallel implementers that edit files; requires a git workspace."
}
},
"required": ["tasks"]
})
}
fn is_concurrency_safe(&self, _input: &Value) -> bool {
false // manages its own concurrency
}
fn is_deferred(&self) -> bool {
true
}
async fn execute(&self, input: Value) -> ToolResult {
let tasks = match parse_tasks(&input) {
Ok(tasks) => tasks,
Err(e) => {
return ToolResult {
content: e,
is_error: true,
images: Vec::new(),
};
}
};
if tasks.is_empty() {
return ToolResult {
content: "No tasks provided".to_string(),
is_error: true,
images: Vec::new(),
};
}
if tasks.len() > MAX_SUB_AGENTS {
return ToolResult {
content: format!(
"Too many sub-agents: {} (max {})",
tasks.len(),
MAX_SUB_AGENTS
),
is_error: true,
images: Vec::new(),
};
}
let results = if input.get("isolate").and_then(|v| v.as_bool()).unwrap_or(false) {
// Isolated fan-out: each sub-agent edits in its own git worktree (§3.4).
self.spawner.spawn_parallel_isolated(tasks).await
} else if input.get("coordinate").and_then(|v| v.as_bool()).unwrap_or(false)
&& tasks.len() >= 2
{
// Coordinated fan-out: siblings share a task board (TIER 2 §3.4).
self.spawner.spawn_parallel_coordinated(tasks).await
} else {
self.spawner.spawn_parallel(tasks).await
};
let output: Vec<String> = results
.iter()
.map(|r| {
let status = if r.is_error { "ERROR" } else { "OK" };
format!(
"## {} [{}]\n{}\n[turns: {} | tokens: {} in / {} out]",
r.name, status, r.text, r.turns, r.usage.input_tokens, r.usage.output_tokens
)
})
.collect();
let all_error = results.iter().all(|r| r.is_error);
// Aggregate header so the parent (and user) see the overall outcome and
// the total token cost across the fan-out at a glance.
let ok_count = results.iter().filter(|r| !r.is_error).count();
let err_count = results.len() - ok_count;
let total_in: u64 = results.iter().map(|r| r.usage.input_tokens).sum();
let total_out: u64 = results.iter().map(|r| r.usage.output_tokens).sum();
let header = format!(
"{} sub-agent(s): {} ok, {} error(s) | total {} in / {} out tokens",
results.len(),
ok_count,
err_count,
total_in,
total_out
);
let fan_out = format!("{}\n\n{}", header, output.join("\n\n---\n\n"));
// Optional synthesis pass (§3.4 verify/synthesize): consolidate the
// sub-agent outputs into one answer and flag conflicts. Opt-in and only
// when there is more than one result to combine; a synthesis failure
// falls back to the raw fan-out so the parent never loses the results.
if wants_synthesis(&input, results.len()) {
let synth_config = SubAgentConfig {
name: "synthesizer".to_string(),
prompt: build_synthesis_prompt(&results),
max_turns: DEFAULT_SUB_AGENT_MAX_TURNS,
max_tokens: DEFAULT_SUB_AGENT_MAX_TOKENS,
system_prompt: None,
// Read-only: the synthesizer reasons over the outputs and may
// verify claims against the workspace, but must not mutate.
allowed_tools: role_tools(Some("reviewer")),
};
let synth = self.spawner.spawn_one(synth_config).await;
if !synth.is_error {
return ToolResult {
content: format!(
"{}\n\n=== Synthesis ===\n{}\n\n<details: raw sub-agent outputs below>\n\n{}",
header, synth.text, output.join("\n\n---\n\n")
),
is_error: false,
images: Vec::new(),
};
}
// Synthesis failed — fall through to the raw fan-out.
}
ToolResult {
content: fan_out,
is_error: all_error,
images: Vec::new(),
}
}
fn category(&self) -> ToolCategory {
ToolCategory::Exec
}
fn describe(&self, input: &Value) -> String {
// The schema field is `tasks` (an array); the old code read a
// non-existent `task` and always fell through to the generic default.
let n = input
.get("tasks")
.and_then(|v| v.as_array())
.map(|a| a.len())
.unwrap_or(0);
if n == 1 {
// Surface the single task's prompt for a useful label.
let prompt = input["tasks"][0]
.get("prompt")
.and_then(|v| v.as_str())
.unwrap_or("sub-agent");
format!("Spawn: {}", nomi_tools::truncate_utf8(prompt, 80))
} else {
format!("Spawn {} parallel sub-agents", n)
}
}
}
/// Map an optional sub-agent role to a restricted tool whitelist. An empty
/// result (default / "implementer") means all built-in tools. Names match the
/// tools registered in `spawner::build_tool_registry`.
fn role_tools(role: Option<&str>) -> Vec<String> {
let names: &[&str] = match role.map(|r| r.to_ascii_lowercase()).as_deref() {
Some("searcher" | "scout" | "reviewer") => &["Read", "Grep", "Glob"],
Some("verifier" | "tester") => &["Read", "Grep", "Glob", "Bash"],
// "implementer", unknown, or absent -> full toolset.
_ => &[],
};
names.iter().map(|s| s.to_string()).collect()
}
/// Whether the caller opted into a post-fan-out synthesis pass. Opt-in (default
/// off so existing fan-outs are unchanged) and only meaningful with ≥2 results
/// to consolidate.
fn wants_synthesis(input: &Value, result_count: usize) -> bool {
input
.get("synthesize")
.and_then(|v| v.as_bool())
.unwrap_or(false)
&& result_count >= 2
}
/// Build the synthesizer sub-agent's prompt from the fan-out results: a digest
/// that delineates each sub-agent's output and status, and instructs the
/// synthesizer to consolidate them into one answer while explicitly flagging any
/// conflicts/disagreements between sub-agents. Pure (no engine) so it is
/// unit-testable.
fn build_synthesis_prompt(results: &[crate::spawner::SubAgentResult]) -> String {
let mut digest = String::from(
"You are a synthesis agent. Several sub-agents independently worked on \
parts of one task; their outputs are below. Consolidate them into a \
single coherent answer. Explicitly flag any CONFLICTS or disagreements \
between sub-agents (e.g. contradictory findings or conclusions) rather \
than silently picking one. Treat outputs marked [ERROR] as unreliable — \
note the gap they leave. Verify load-bearing claims against the \
workspace with your read-only tools when practical.\n\n\
--- Sub-agent outputs ---\n",
);
for r in results {
let status = if r.is_error { "ERROR" } else { "OK" };
digest.push_str(&format!("\n### {} [{}]\n{}\n", r.name, status, r.text));
}
digest.push_str(
"\n--- End of sub-agent outputs ---\n\n\
Produce: (1) the consolidated answer, and (2) a short \"Conflicts/Gaps\" \
section (write \"none\" if there are none).",
);
digest
}
fn parse_tasks(input: &Value) -> Result<Vec<SubAgentConfig>, String> {
let tasks_arr = input["tasks"]
.as_array()
.ok_or("Missing or invalid 'tasks' array")?;
let mut configs = Vec::new();
for task in tasks_arr {
let name = task["name"]
.as_str()
.ok_or("Each task must have a 'name' string")?
.to_string();
let prompt = task["prompt"]
.as_str()
.ok_or("Each task must have a 'prompt' string")?
.to_string();
configs.push(SubAgentConfig {
name,
prompt,
max_turns: DEFAULT_SUB_AGENT_MAX_TURNS,
max_tokens: DEFAULT_SUB_AGENT_MAX_TOKENS,
system_prompt: None,
allowed_tools: role_tools(task["role"].as_str()),
});
}
Ok(configs)
}
#[cfg(test)]
mod tests {
use super::{build_synthesis_prompt, role_tools, wants_synthesis};
use crate::spawner::SubAgentResult;
use nomi_types::message::TokenUsage;
use serde_json::json;
fn result(name: &str, text: &str, is_error: bool) -> SubAgentResult {
SubAgentResult {
name: name.to_string(),
text: text.to_string(),
usage: TokenUsage::default(),
turns: 1,
is_error,
}
}
#[test]
fn role_tools_maps_roles_to_whitelists() {
assert_eq!(role_tools(Some("searcher")), vec!["Read", "Grep", "Glob"]);
// case-insensitive
assert_eq!(role_tools(Some("Reviewer")), vec!["Read", "Grep", "Glob"]);
assert_eq!(
role_tools(Some("verifier")),
vec!["Read", "Grep", "Glob", "Bash"]
);
// implementer / unknown / absent -> full toolset (empty whitelist)
assert!(role_tools(Some("implementer")).is_empty());
assert!(role_tools(None).is_empty());
assert!(role_tools(Some("nonsense")).is_empty());
}
#[test]
fn wants_synthesis_is_opt_in_and_needs_two_results() {
// Absent / false → no synthesis (back-compat: existing fan-outs unchanged).
assert!(!wants_synthesis(&json!({"tasks": []}), 3));
assert!(!wants_synthesis(&json!({"synthesize": false}), 3));
// Enabled but <2 results → nothing to synthesize.
assert!(!wants_synthesis(&json!({"synthesize": true}), 1));
assert!(!wants_synthesis(&json!({"synthesize": true}), 0));
// Enabled with ≥2 results → synthesize.
assert!(wants_synthesis(&json!({"synthesize": true}), 2));
assert!(wants_synthesis(&json!({"synthesize": true}), 5));
}
#[test]
fn build_synthesis_prompt_includes_results_status_and_conflict_instruction() {
let results = vec![
result("scan-auth", "found a bug in login.rs:42", false),
result("scan-db", "no issues in the db layer", false),
result("scan-net", "timed out", true),
];
let prompt = build_synthesis_prompt(&results);
// Each sub-agent's name and text must appear so the synthesizer can reason
// over all of them.
assert!(prompt.contains("scan-auth"));
assert!(prompt.contains("login.rs:42"));
assert!(prompt.contains("scan-db"));
assert!(prompt.contains("scan-net"));
// Failed sub-agents are marked so the synthesizer weights them accordingly.
assert!(prompt.contains("ERROR") || prompt.contains("FAILED"));
// The synthesizer is explicitly told to consolidate AND flag conflicts.
let lower = prompt.to_lowercase();
assert!(lower.contains("conflict") || lower.contains("disagree"));
assert!(lower.contains("consolidat") || lower.contains("synthesiz") || lower.contains("combine"));
}
}
@@ -0,0 +1,627 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use async_trait::async_trait;
use tokio::sync::Semaphore;
use nomi_config::config::Config;
use nomi_providers::LlmProvider;
use nomi_tools::bash::BashTool;
use nomi_tools::edit::EditTool;
use nomi_tools::glob::GlobTool;
use nomi_tools::grep::GrepTool;
use nomi_tools::read::ReadTool;
use nomi_tools::registry::ToolRegistry;
use nomi_tools::write::WriteTool;
use nomi_types::message::TokenUsage;
use crate::engine::AgentEngine;
use crate::output::OutputSink;
use crate::output::null_sink::NullSink;
// Re-export from nomi-types — single source of truth
pub use nomi_types::spawner::{ForkOverrides, Spawner, SubAgentConfig, SubAgentResult};
/// Spawns independent child agents that share the parent's LLM provider.
///
/// Sub-agents use a [`NullSink`] so their streaming output is silently
/// discarded. Results are collected via `engine.run()` and returned to the
/// parent which emits them as a single `tool_result` event — matching the
/// Claude Code pattern where only the parent writes to stdout.
pub struct AgentSpawner {
provider: Arc<dyn LlmProvider>,
base_config: Config,
cwd: PathBuf,
/// Shared across `clone_for_spawn` so the concurrency cap is global to this
/// spawner (all fan-outs draw from the same permit pool).
concurrency: Arc<Semaphore>,
/// Optional shared token ceiling (opt-in; None = uncapped).
token_budget: Option<Arc<TokenBudget>>,
}
impl AgentSpawner {
pub fn new(provider: Arc<dyn LlmProvider>, config: Config, cwd: PathBuf) -> Self {
Self {
provider,
base_config: config,
cwd,
concurrency: Arc::new(Semaphore::new(MAX_CONCURRENT_SUBAGENTS)),
token_budget: None,
}
}
/// Enable a shared cumulative token ceiling for all sub-agents (§3.4).
pub fn with_token_budget(mut self, budget: Option<Arc<TokenBudget>>) -> Self {
self.token_budget = budget;
self
}
/// Spawn a single sub-agent and wait for result.
pub async fn spawn_one(&self, sub_config: SubAgentConfig) -> SubAgentResult {
self.spawn_one_with_board(sub_config, None).await
}
/// Spawn a single sub-agent, optionally giving it the shared task board so it
/// can coordinate with siblings (TIER 2, §3.4).
async fn spawn_one_with_board(
&self,
sub_config: SubAgentConfig,
board: Option<Arc<crate::taskboard::TaskBoard>>,
) -> SubAgentResult {
// Shared token ceiling (opt-in): refuse to start a new sub-agent once
// the budget is spent, rather than launching work that won't be paid for.
if let Some(budget) = &self.token_budget
&& !budget.can_begin()
{
return SubAgentResult {
name: sub_config.name,
text: "Sub-agent skipped: shared token budget exhausted.".to_string(),
usage: TokenUsage::default(),
turns: 0,
is_error: true,
};
}
let mut config = self.base_config.clone();
config.max_turns = Some(sub_config.max_turns);
config.max_tokens = sub_config.max_tokens;
if let Some(sp) = sub_config.system_prompt.clone() {
config.system_prompt = Some(sp);
}
config.session.enabled = false;
config.tools.auto_approve = true;
tracing::info!(target: "nomi_agent", cwd = %self.cwd.display(), "sub-agent spawned with workspace cwd");
let agent_name = sub_config.name.clone();
let board_arg = board.map(|b| (b, agent_name.as_str()));
let tools = build_tool_registry(&sub_config.allowed_tools, &self.cwd, board_arg);
let output: Arc<dyn OutputSink> = Arc::new(NullSink);
let engine = AgentEngine::new_with_provider(
self.provider.clone(),
config,
tools,
output,
self.cwd.clone(),
);
let result = run_subagent(engine, sub_config.name, &sub_config.prompt).await;
// Charge the shared budget for what this sub-agent actually consumed.
if let Some(budget) = &self.token_budget {
budget.record(result.usage.input_tokens + result.usage.output_tokens);
}
result
}
/// Spawn multiple sub-agents in parallel, bounded by the shared concurrency
/// permit pool so a large fan-out cannot start every engine at once.
pub async fn spawn_parallel(&self, sub_configs: Vec<SubAgentConfig>) -> Vec<SubAgentResult> {
let tasks: Vec<_> = sub_configs
.into_iter()
.map(|config| {
let spawner = self.clone_for_spawn();
async move { spawner.spawn_one(config).await }
})
.collect();
run_bounded(self.concurrency.clone(), tasks)
.await
.into_iter()
.map(|joined| {
joined.unwrap_or_else(|e| SubAgentResult {
name: "unknown".to_string(),
text: format!("Task join error: {}", e),
usage: TokenUsage::default(),
turns: 0,
is_error: true,
})
})
.collect()
}
/// Like [`Self::spawn_parallel`] but every sub-agent shares one in-memory
/// task board (TIER 2 coordination, §3.4): siblings can claim work to avoid
/// duplication and report progress to one another via the `shared_tasks` tool.
pub async fn spawn_parallel_coordinated(
&self,
sub_configs: Vec<SubAgentConfig>,
) -> Vec<SubAgentResult> {
let board = Arc::new(crate::taskboard::TaskBoard::new());
let tasks: Vec<_> = sub_configs
.into_iter()
.map(|config| {
let spawner = self.clone_for_spawn();
let board = board.clone();
async move { spawner.spawn_one_with_board(config, Some(board)).await }
})
.collect();
run_bounded(self.concurrency.clone(), tasks)
.await
.into_iter()
.map(|joined| {
joined.unwrap_or_else(|e| SubAgentResult {
name: "unknown".to_string(),
text: format!("Task join error: {}", e),
usage: TokenUsage::default(),
turns: 0,
is_error: true,
})
})
.collect()
}
fn clone_for_spawn(&self) -> Self {
Self {
provider: self.provider.clone(),
base_config: self.base_config.clone(),
cwd: self.cwd.clone(),
concurrency: self.concurrency.clone(),
token_budget: self.token_budget.clone(),
}
}
/// Clone the spawner but rooted at a different cwd (used to run a sub-agent
/// inside an isolated worktree).
fn clone_with_cwd(&self, cwd: PathBuf) -> Self {
let mut c = self.clone_for_spawn();
c.cwd = cwd;
c
}
/// Like [`Self::spawn_parallel`] but each sub-agent runs in its own detached
/// git worktree, so concurrent edits don't clobber the shared tree or each
/// other (§3.4 worktree isolation). Each result has the sub-agent's changes
/// appended as a unified diff for the parent to review/apply; the worktree is
/// removed afterwards. Falls back to plain `spawn_parallel` when the
/// workspace is not a git repo.
pub async fn spawn_parallel_isolated(&self, sub_configs: Vec<SubAgentConfig>) -> Vec<SubAgentResult> {
if !nomi_tools::worktree::is_git_repo(&self.cwd) {
tracing::warn!(target: "nomi_agent", "isolate requested but workspace is not a git repo — running without worktree isolation");
return self.spawn_parallel(sub_configs).await;
}
let tasks: Vec<_> = sub_configs
.into_iter()
.map(|config| {
let base = self.clone_for_spawn();
let repo = self.cwd.clone();
async move {
match nomi_tools::worktree::Worktree::create(&repo) {
Ok(wt) => {
let spawner = base.clone_with_cwd(wt.path().to_path_buf());
let mut result = spawner.spawn_one(config).await;
match wt.capture_diff() {
Ok(diff) if !diff.trim().is_empty() => {
result.text = format!(
"{}\n\n--- Changes (made in an isolated worktree; apply with ApplyPatch if you want them) ---\n{}",
result.text, diff
);
}
_ => {}
}
result // `wt` dropped here → worktree removed
}
Err(e) => SubAgentResult {
name: config.name,
text: format!("Worktree isolation failed: {e}"),
usage: TokenUsage::default(),
turns: 0,
is_error: true,
},
}
}
})
.collect();
run_bounded(self.concurrency.clone(), tasks)
.await
.into_iter()
.map(|joined| {
joined.unwrap_or_else(|e| SubAgentResult {
name: "unknown".to_string(),
text: format!("Task join error: {}", e),
usage: TokenUsage::default(),
turns: 0,
is_error: true,
})
})
.collect()
}
}
#[async_trait]
impl Spawner for AgentSpawner {
async fn spawn_fork(
&self,
sub_config: SubAgentConfig,
overrides: ForkOverrides,
) -> SubAgentResult {
let mut config = self.base_config.clone();
config.max_turns = Some(sub_config.max_turns);
config.max_tokens = sub_config.max_tokens;
if let Some(sp) = sub_config.system_prompt.clone() {
config.system_prompt = Some(sp);
}
config.session.enabled = false;
config.tools.auto_approve = true;
if let Some(model) = overrides.model.clone() {
config.model = model;
}
let tools = build_tool_registry(&overrides.allowed_tools, &self.cwd, None);
let output: Arc<dyn OutputSink> = Arc::new(NullSink);
let mut engine = AgentEngine::new_with_provider(
self.provider.clone(),
config,
tools,
output,
self.cwd.clone(),
);
engine.set_initial_reasoning_effort(overrides.effort.clone());
run_subagent(engine, sub_config.name, &sub_config.prompt).await
}
}
/// Wall-clock cap for a single sub-agent run. Without this, a sub-agent stuck
/// in a tool-call loop (up to its max_turns) or on a hung provider stream blocks
/// the parent indefinitely — a production hang risk. (Phase 2 multi-agent)
const SUBAGENT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
/// Global cap on concurrently-running sub-agents across all fan-outs from one
/// spawner. Without it a single `Spawn` of N tasks (or several concurrent
/// `Spawn` tool calls sharing the spawner) starts N engines/provider streams at
/// once — a resource and provider-rate-limit storm. (Phase 2 multi-agent §3.4)
const MAX_CONCURRENT_SUBAGENTS: usize = 8;
/// Optional cumulative token ceiling shared across every sub-agent of a spawner
/// (§3.4 "共享 token 预算"). Opt-in: absent → uncapped (default, zero change).
/// A *soft* ceiling — concurrent sub-agents each pass `can_begin` before any of
/// them `record`s, so one fan-out may slightly overshoot; its job is to bound
/// cumulative spend across successive `Spawn` calls / nested fan-outs, not to
/// be an exact per-call limit.
pub struct TokenBudget {
remaining: std::sync::atomic::AtomicU64,
}
impl TokenBudget {
pub fn new(limit: u64) -> Self {
Self {
remaining: std::sync::atomic::AtomicU64::new(limit),
}
}
/// Whether there is budget left to start another sub-agent.
pub fn can_begin(&self) -> bool {
self.remaining.load(std::sync::atomic::Ordering::Relaxed) > 0
}
/// Deduct `tokens` from the remaining budget (saturating at 0).
pub fn record(&self, tokens: u64) {
let _ = self.remaining.fetch_update(
std::sync::atomic::Ordering::Relaxed,
std::sync::atomic::Ordering::Relaxed,
|cur| Some(cur.saturating_sub(tokens)),
);
}
pub fn remaining(&self) -> u64 {
self.remaining.load(std::sync::atomic::Ordering::Relaxed)
}
}
/// Run `tasks` concurrently with at most `semaphore`'s permits in flight at
/// once. Each task holds a permit for its whole run; excess tasks queue until a
/// slot frees. Returns each task's output (or its `JoinError` if the task
/// panicked) — successful results in input order, the rare panics appended.
///
/// Uses a `JoinSet`, so if THIS future is dropped (e.g. the parent turn is
/// cancelled while a fan-out is in flight) every still-running sub-agent task is
/// aborted instead of detaching and burning compute/tokens unattended.
async fn run_bounded<F, T>(
semaphore: Arc<Semaphore>,
tasks: Vec<F>,
) -> Vec<Result<T, tokio::task::JoinError>>
where
F: std::future::Future<Output = T> + Send + 'static,
T: Send + 'static,
{
let n = tasks.len();
let mut set = tokio::task::JoinSet::new();
for (idx, task) in tasks.into_iter().enumerate() {
let sem = semaphore.clone();
set.spawn(async move {
// Held for the task's whole lifetime; dropped on completion to free
// the slot for a queued task.
let _permit = sem.acquire_owned().await.expect("subagent semaphore never closed");
(idx, task.await)
});
}
let mut slots: Vec<Option<T>> = (0..n).map(|_| None).collect();
let mut panics: Vec<tokio::task::JoinError> = Vec::new();
while let Some(joined) = set.join_next().await {
match joined {
Ok((idx, val)) => slots[idx] = Some(val),
// A panicked task loses its index; collect its error and append it.
Err(e) => panics.push(e),
}
}
let mut results: Vec<Result<T, tokio::task::JoinError>> = Vec::with_capacity(n);
for slot in slots {
if let Some(v) = slot {
results.push(Ok(v));
}
}
results.extend(panics.into_iter().map(Err));
results
}
/// Map a (timeout-wrapped) engine run outcome to a SubAgentResult. Extracted so
/// the timeout/error/success mapping is unit-testable without a live engine.
fn map_subagent_outcome(
name: String,
outcome: Result<
Result<crate::engine::AgentResult, crate::engine::AgentError>,
tokio::time::error::Elapsed,
>,
timeout_secs: u64,
) -> SubAgentResult {
match outcome {
Ok(Ok(result)) => SubAgentResult {
name,
text: result.text,
usage: result.usage,
turns: result.turns,
is_error: false,
},
Ok(Err(e)) => SubAgentResult {
name,
text: format!("Sub-agent error: {}", e),
usage: TokenUsage::default(),
turns: 0,
is_error: true,
},
Err(_elapsed) => SubAgentResult {
name,
text: format!("Sub-agent timed out after {timeout_secs}s and was aborted"),
usage: TokenUsage::default(),
turns: 0,
is_error: true,
},
}
}
/// Run an engine to completion under a wall-clock timeout, mapping the outcome.
async fn run_subagent(mut engine: AgentEngine, name: String, prompt: &str) -> SubAgentResult {
let outcome = tokio::time::timeout(SUBAGENT_TIMEOUT, engine.run(prompt, "")).await;
map_subagent_outcome(name, outcome, SUBAGENT_TIMEOUT.as_secs())
}
fn build_tool_registry(
allowed: &[String],
cwd: &Path,
board: Option<(Arc<crate::taskboard::TaskBoard>, &str)>,
) -> ToolRegistry {
let all_tools: Vec<(&str, Box<dyn nomi_tools::Tool>)> = vec![
("Read", Box::new(ReadTool::new(None, Some(cwd.to_path_buf())))),
("Write", Box::new(WriteTool::new(None).with_cwd(Some(cwd.to_path_buf())))),
("Edit", Box::new(EditTool::new(None).with_cwd(Some(cwd.to_path_buf())))),
("Bash", Box::new(BashTool::new(cwd.to_path_buf()))),
("Grep", Box::new(GrepTool::new(cwd.to_path_buf()))),
("Glob", Box::new(GlobTool::new(cwd.to_path_buf()))),
];
let mut registry = ToolRegistry::new();
for (name, tool) in all_tools {
if allowed.is_empty() || allowed.iter().any(|a| a.as_str() == name) {
registry.register(tool);
}
}
// Coordinated fan-out: give this sub-agent the shared task board so siblings
// can claim work and report progress to one another. Always registered when
// a board is present (not subject to the role tool-allowlist).
if let Some((board, agent_name)) = board {
registry.register(Box::new(crate::taskboard::TaskBoardTool::new(board, agent_name)));
}
registry
}
#[cfg(test)]
mod phase7_tests {
use super::{ForkOverrides, SubAgentConfig, build_tool_registry, map_subagent_outcome};
use crate::engine::{AgentError, AgentResult};
use nomi_types::message::{StopReason, TokenUsage};
#[tokio::test]
async fn map_subagent_outcome_timeout_is_error() {
// Produce a genuine Elapsed by timing out a never-completing future.
let elapsed: Result<Result<AgentResult, AgentError>, _> =
tokio::time::timeout(std::time::Duration::from_nanos(1), std::future::pending()).await;
assert!(elapsed.is_err(), "precondition: should have elapsed");
let r = map_subagent_outcome("agent-x".to_string(), elapsed, 300);
assert!(r.is_error, "a timed-out sub-agent must be reported as an error");
assert_eq!(r.name, "agent-x");
assert!(r.text.contains("timed out"), "text: {}", r.text);
assert_eq!(r.turns, 0);
}
#[test]
fn map_subagent_outcome_ok_maps_result() {
let ok: Result<Result<AgentResult, AgentError>, tokio::time::error::Elapsed> =
Ok(Ok(AgentResult {
text: "done".to_string(),
stop_reason: StopReason::EndTurn,
usage: TokenUsage::default(),
turns: 3,
}));
let r = map_subagent_outcome("a".to_string(), ok, 300);
assert!(!r.is_error);
assert_eq!(r.text, "done");
assert_eq!(r.turns, 3);
}
#[test]
fn map_subagent_outcome_engine_error_is_error() {
let err: Result<Result<AgentResult, AgentError>, tokio::time::error::Elapsed> =
Ok(Err(AgentError::UserAborted));
let r = map_subagent_outcome("a".to_string(), err, 300);
assert!(r.is_error);
assert!(r.text.contains("Sub-agent error"), "text: {}", r.text);
}
#[test]
fn tc_7_1_fork_overrides_default_values() {
let o = ForkOverrides::default();
assert!(o.model.is_none());
assert!(o.effort.is_none());
assert!(o.allowed_tools.is_empty());
}
#[test]
fn tc_7_40_build_tool_registry_empty_allowed_registers_all() {
let registry = build_tool_registry(&[], &std::env::temp_dir(), None);
for name in &["Read", "Write", "Edit", "Bash", "Grep", "Glob"] {
assert!(
registry.get(name).is_some(),
"tool '{name}' should be registered"
);
}
}
#[test]
fn tc_7_43_build_tool_registry_filters_to_allowed() {
let allowed = vec!["Bash".to_string(), "Read".to_string()];
let registry = build_tool_registry(&allowed, &std::env::temp_dir(), None);
assert!(registry.get("Bash").is_some());
assert!(registry.get("Read").is_some());
assert!(registry.get("Write").is_none());
}
#[test]
fn tc_7_sub_agent_config_original_fields_intact() {
let config = SubAgentConfig {
name: "test-agent".to_string(),
prompt: "do the task".to_string(),
max_turns: 5,
max_tokens: 1024,
system_prompt: Some("you are helpful".to_string()),
allowed_tools: Vec::new(),
};
assert_eq!(config.name, "test-agent");
assert_eq!(config.max_turns, 5);
}
#[tokio::test]
async fn run_bounded_caps_concurrency_and_returns_all() {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
use std::time::Duration;
use tokio::sync::Semaphore;
let cap = 3;
let n = 12;
let peak = Arc::new(AtomicUsize::new(0));
let inflight = Arc::new(AtomicUsize::new(0));
let sem = Arc::new(Semaphore::new(cap));
let tasks: Vec<_> = (0..n)
.map(|i| {
let peak = peak.clone();
let inflight = inflight.clone();
async move {
let now = inflight.fetch_add(1, SeqCst) + 1;
peak.fetch_max(now, SeqCst);
tokio::time::sleep(Duration::from_millis(40)).await;
inflight.fetch_sub(1, SeqCst);
i
}
})
.collect();
let results = super::run_bounded(sem, tasks).await;
assert_eq!(results.len(), n, "every task must produce a result");
let mut values: Vec<usize> = results.into_iter().map(|r| r.expect("no join error")).collect();
values.sort_unstable();
assert_eq!(values, (0..n).collect::<Vec<_>>(), "results preserve all tasks in order");
assert!(
peak.load(SeqCst) <= cap,
"peak concurrency {} must not exceed the cap {cap}",
peak.load(SeqCst)
);
assert!(peak.load(SeqCst) >= 2, "tasks should have run concurrently up to the cap");
}
#[tokio::test]
async fn run_bounded_aborts_in_flight_tasks_when_dropped() {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
use std::time::Duration;
use tokio::sync::Semaphore;
let completed = Arc::new(AtomicUsize::new(0));
let sem = Arc::new(Semaphore::new(8));
let tasks: Vec<_> = (0..8)
.map(|_| {
let completed = completed.clone();
async move {
tokio::time::sleep(Duration::from_millis(500)).await;
completed.fetch_add(1, SeqCst);
}
})
.collect();
// Start the fan-out, then drop its future after 50ms (simulating the
// parent turn being cancelled mid-flight).
{
let fut = super::run_bounded(sem, tasks);
tokio::select! {
_ = fut => {}
_ = tokio::time::sleep(Duration::from_millis(50)) => {}
}
} // `fut` dropped here → JoinSet drops → tasks aborted
// Give any *non-aborted* task time to reach its increment.
tokio::time::sleep(Duration::from_millis(700)).await;
assert_eq!(
completed.load(SeqCst),
0,
"dropping run_bounded must abort in-flight sub-agent tasks (no detached compute)"
);
}
#[test]
fn token_budget_blocks_once_exhausted() {
use super::TokenBudget;
let b = TokenBudget::new(100);
assert!(b.can_begin());
b.record(60);
assert_eq!(b.remaining(), 40);
assert!(b.can_begin(), "40 left → another sub-agent may start");
b.record(60); // saturating past zero
assert_eq!(b.remaining(), 0);
assert!(!b.can_begin(), "exhausted budget blocks further sub-agents");
}
}
@@ -0,0 +1,301 @@
//! TIER 2 multi-agent coordination: a lightweight in-process shared task board
//! (design §3.4). Several sub-agents spawned together can read/write a common
//! list of tasks — claim work to avoid duplication and report progress — instead
//! of running fully blind to one another. Purely in-memory and additive: a
//! sub-agent only sees it when the parent runs a *coordinated* fan-out.
use std::sync::Mutex;
use async_trait::async_trait;
use serde::Serialize;
use serde_json::{Value, json};
use nomi_protocol::events::ToolCategory;
use nomi_types::tool::{JsonSchema, ToolResult};
use nomi_tools::Tool;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskStatus {
Pending,
InProgress,
Done,
}
#[derive(Debug, Clone, Serialize)]
pub struct Task {
pub id: u64,
pub title: String,
pub status: TaskStatus,
pub owner: Option<String>,
pub notes: String,
}
struct Inner {
tasks: Vec<Task>,
seq: u64,
}
/// A shared, lock-guarded task list. Cheap to share via `Arc`; all operations
/// are brief (lock + Vec scan), so a `std::sync::Mutex` is appropriate.
pub struct TaskBoard {
inner: Mutex<Inner>,
}
impl Default for TaskBoard {
fn default() -> Self {
Self::new()
}
}
impl TaskBoard {
pub fn new() -> Self {
Self {
inner: Mutex::new(Inner { tasks: Vec::new(), seq: 0 }),
}
}
/// Add a task and return its id.
pub fn add(&self, title: &str) -> u64 {
let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner());
g.seq += 1;
let id = g.seq;
g.tasks.push(Task {
id,
title: title.to_string(),
status: TaskStatus::Pending,
owner: None,
notes: String::new(),
});
id
}
/// Snapshot of all tasks (cloned).
pub fn list(&self) -> Vec<Task> {
self.inner.lock().unwrap_or_else(|e| e.into_inner()).tasks.clone()
}
/// Claim a pending task for `owner`. Errors if missing or already claimed by
/// someone else — this is the anti-duplication guarantee for siblings.
pub fn claim(&self, id: u64, owner: &str) -> Result<(), String> {
let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let task = g.tasks.iter_mut().find(|t| t.id == id).ok_or_else(|| format!("no task #{id}"))?;
match &task.owner {
Some(existing) if existing != owner => {
Err(format!("task #{id} already claimed by {existing}"))
}
_ => {
task.owner = Some(owner.to_string());
task.status = TaskStatus::InProgress;
Ok(())
}
}
}
/// Update a task's status and (optionally) append a note.
pub fn update(&self, id: u64, status: TaskStatus, note: Option<&str>) -> Result<(), String> {
let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let task = g.tasks.iter_mut().find(|t| t.id == id).ok_or_else(|| format!("no task #{id}"))?;
task.status = status;
if let Some(n) = note {
if !task.notes.is_empty() {
task.notes.push('\n');
}
task.notes.push_str(n);
}
Ok(())
}
}
fn parse_status(s: &str) -> Option<TaskStatus> {
match s {
"pending" => Some(TaskStatus::Pending),
"in_progress" => Some(TaskStatus::InProgress),
"done" => Some(TaskStatus::Done),
_ => None,
}
}
fn render_tasks(tasks: &[Task]) -> String {
if tasks.is_empty() {
return "(no tasks)".to_string();
}
tasks
.iter()
.map(|t| {
let owner = t.owner.as_deref().unwrap_or("-");
let status = serde_json::to_value(t.status).ok().and_then(|v| v.as_str().map(String::from)).unwrap_or_default();
format!("#{} [{}] ({}) {}", t.id, status, owner, t.title)
})
.collect::<Vec<_>>()
.join("\n")
}
/// Tool exposing the shared [`TaskBoard`] to a coordinated sub-agent.
pub struct TaskBoardTool {
board: std::sync::Arc<TaskBoard>,
agent_name: String,
}
impl TaskBoardTool {
pub fn new(board: std::sync::Arc<TaskBoard>, agent_name: impl Into<String>) -> Self {
Self { board, agent_name: agent_name.into() }
}
}
#[async_trait]
impl Tool for TaskBoardTool {
fn name(&self) -> &str {
"shared_tasks"
}
fn description(&self) -> &str {
"Coordinate with sibling sub-agents via a shared task board.\n\n\
actions:\n\
- list: see all tasks (id, status, owner, title).\n\
- add: add a task (title).\n\
- claim: claim a task by id before working it, so siblings don't duplicate it.\n\
- update: set a task's status (pending/in_progress/done) and add a note."
}
fn input_schema(&self) -> JsonSchema {
json!({
"type": "object",
"properties": {
"action": { "type": "string", "enum": ["list", "add", "claim", "update"] },
"title": { "type": "string", "description": "Task title (for add)." },
"id": { "type": "integer", "description": "Task id (for claim/update)." },
"status": { "type": "string", "enum": ["pending", "in_progress", "done"], "description": "New status (for update)." },
"notes": { "type": "string", "description": "A progress note (for update)." }
},
"required": ["action"]
})
}
fn is_concurrency_safe(&self, _input: &Value) -> bool {
false
}
fn category(&self) -> ToolCategory {
ToolCategory::Info
}
fn describe(&self, input: &Value) -> String {
let action = input.get("action").and_then(|v| v.as_str()).unwrap_or("?");
format!("shared_tasks: {action}")
}
async fn execute(&self, input: Value) -> ToolResult {
let action = input.get("action").and_then(|v| v.as_str()).unwrap_or("");
let ok = |content: String| ToolResult { content, is_error: false, images: Vec::new() };
let err = |content: String| ToolResult { content, is_error: true, images: Vec::new() };
match action {
"list" => ok(render_tasks(&self.board.list())),
"add" => match input.get("title").and_then(|v| v.as_str()) {
Some(title) if !title.trim().is_empty() => {
let id = self.board.add(title);
ok(format!("Added task #{id}: {title}"))
}
_ => err("add requires a non-empty `title`".to_string()),
},
"claim" => match input.get("id").and_then(|v| v.as_u64()) {
Some(id) => match self.board.claim(id, &self.agent_name) {
Ok(()) => ok(format!("Claimed task #{id} for {}", self.agent_name)),
Err(e) => err(e),
},
None => err("claim requires an `id`".to_string()),
},
"update" => {
let Some(id) = input.get("id").and_then(|v| v.as_u64()) else {
return err("update requires an `id`".to_string());
};
let Some(status) = input.get("status").and_then(|v| v.as_str()).and_then(parse_status) else {
return err("update requires a `status` of pending/in_progress/done".to_string());
};
let note = input.get("notes").and_then(|v| v.as_str());
match self.board.update(id, status, note) {
Ok(()) => ok(format!("Updated task #{id}")),
Err(e) => err(e),
}
}
other => err(format!("unknown action '{other}'")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
#[test]
fn add_list_assigns_incrementing_ids() {
let b = TaskBoard::new();
let a = b.add("first");
let c = b.add("second");
assert_eq!((a, c), (1, 2));
let tasks = b.list();
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[0].status, TaskStatus::Pending);
assert!(tasks[0].owner.is_none());
}
#[test]
fn claim_is_exclusive_across_siblings() {
let b = TaskBoard::new();
let id = b.add("work");
assert!(b.claim(id, "agent-a").is_ok());
// Same owner re-claim is fine; a different sibling is rejected.
assert!(b.claim(id, "agent-a").is_ok());
assert!(b.claim(id, "agent-b").is_err(), "a claimed task must not be re-claimable by another agent");
assert!(b.claim(999, "agent-a").is_err(), "claiming a missing task errors");
let t = &b.list()[0];
assert_eq!(t.status, TaskStatus::InProgress);
assert_eq!(t.owner.as_deref(), Some("agent-a"));
}
#[test]
fn update_sets_status_and_appends_notes() {
let b = TaskBoard::new();
let id = b.add("work");
b.update(id, TaskStatus::InProgress, Some("started")).unwrap();
b.update(id, TaskStatus::Done, Some("finished")).unwrap();
let t = &b.list()[0];
assert_eq!(t.status, TaskStatus::Done);
assert_eq!(t.notes, "started\nfinished");
assert!(b.update(404, TaskStatus::Done, None).is_err());
}
#[tokio::test]
async fn tool_add_claim_update_flow() {
let board = Arc::new(TaskBoard::new());
let a = TaskBoardTool::new(board.clone(), "agent-a");
let b = TaskBoardTool::new(board.clone(), "agent-b");
let r = a.execute(json!({ "action": "add", "title": "build X" })).await;
assert!(!r.is_error && r.content.contains("#1"), "{}", r.content);
// agent-a claims #1; agent-b is then refused.
assert!(!a.execute(json!({ "action": "claim", "id": 1 })).await.is_error);
let denied = b.execute(json!({ "action": "claim", "id": 1 })).await;
assert!(denied.is_error, "sibling must not steal a claimed task");
let r = a.execute(json!({ "action": "update", "id": 1, "status": "done", "notes": "ok" })).await;
assert!(!r.is_error);
let listed = a.execute(json!({ "action": "list" })).await;
assert!(listed.content.contains("#1 [done] (agent-a) build X"), "got: {}", listed.content);
}
#[tokio::test]
async fn tool_validates_inputs() {
let board = Arc::new(TaskBoard::new());
let t = TaskBoardTool::new(board, "a");
assert!(t.execute(json!({ "action": "add" })).await.is_error, "add needs title");
assert!(t.execute(json!({ "action": "claim" })).await.is_error, "claim needs id");
assert!(t.execute(json!({ "action": "update", "id": 1 })).await.is_error, "update needs status");
assert!(t.execute(json!({ "action": "bogus" })).await.is_error);
}
}
@@ -0,0 +1,220 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
/// A recorded set of HTTP interactions
#[derive(Debug, Serialize, Deserialize)]
pub struct Cassette {
pub name: String,
pub recorded_at: String,
pub interactions: Vec<Interaction>,
}
/// A single request-response pair
#[derive(Debug, Serialize, Deserialize)]
pub struct Interaction {
pub request: RecordedRequest,
pub response: RecordedResponse,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RecordedRequest {
pub method: String,
pub url: String,
pub headers: HashMap<String, String>,
pub body: serde_json::Value,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RecordedResponse {
pub status: u16,
pub headers: HashMap<String, String>,
/// Body stored as string (may be SSE event stream)
pub body: String,
}
/// VCR operating mode
pub enum VcrMode {
/// Normal operation, no VCR
Off,
/// Record interactions to cassette file
Record(PathBuf),
/// Replay from cassette file (no network)
Replay(PathBuf),
}
/// VCR layer that intercepts HTTP interactions for recording/replay
pub struct VcrLayer {
mode: VcrMode,
cassette: Mutex<Cassette>,
replay_index: Mutex<usize>,
}
impl VcrLayer {
/// Create a VCR layer from environment variables
pub fn from_env() -> Option<Self> {
let mode = std::env::var("VCR_MODE").ok()?;
let cassette_path = std::env::var("VCR_CASSETTE").ok()?;
let path = PathBuf::from(&cassette_path);
match mode.as_str() {
"record" => Some(Self::record(path)),
"replay" => Self::replay(path).ok(),
_ => None,
}
}
/// Create a recording VCR layer
pub fn record(path: PathBuf) -> Self {
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unnamed")
.to_string();
Self {
mode: VcrMode::Record(path),
cassette: Mutex::new(Cassette {
name,
recorded_at: chrono::Utc::now().to_rfc3339(),
interactions: Vec::new(),
}),
replay_index: Mutex::new(0),
}
}
/// Create a replay VCR layer from a cassette file
pub fn replay(path: PathBuf) -> anyhow::Result<Self> {
let cassette = load_cassette(&path)?;
Ok(Self {
mode: VcrMode::Replay(path),
cassette: Mutex::new(cassette),
replay_index: Mutex::new(0),
})
}
/// Check if this VCR layer is in replay mode
pub fn is_replay(&self) -> bool {
matches!(self.mode, VcrMode::Replay(_))
}
/// Record an interaction (only in record mode)
#[allow(clippy::too_many_arguments)]
pub fn record_interaction(
&self,
method: &str,
url: &str,
request_headers: &HashMap<String, String>,
request_body: serde_json::Value,
status: u16,
response_headers: &HashMap<String, String>,
response_body: &str,
) {
if let VcrMode::Record(_) = &self.mode {
let interaction = Interaction {
request: RecordedRequest {
method: method.to_string(),
url: url.to_string(),
headers: sanitize_headers(request_headers),
body: request_body,
},
response: RecordedResponse {
status,
headers: response_headers.clone(),
body: response_body.to_string(),
},
};
if let Ok(mut cassette) = self.cassette.lock() {
cassette.interactions.push(interaction);
}
}
}
/// Get the next replay response (only in replay mode)
pub fn next_replay(&self) -> Option<&RecordedResponse> {
// We need to work around Mutex not allowing returning references.
// Instead, use get_replay_response which returns owned data.
None
}
/// Get the next replay response as owned data
pub fn get_replay_response(&self) -> Option<(u16, HashMap<String, String>, String)> {
if let VcrMode::Replay(_) = &self.mode {
let mut index = self.replay_index.lock().ok()?;
let cassette = self.cassette.lock().ok()?;
if *index < cassette.interactions.len() {
let interaction = &cassette.interactions[*index];
*index += 1;
Some((
interaction.response.status,
interaction.response.headers.clone(),
interaction.response.body.clone(),
))
} else {
None
}
} else {
None
}
}
/// Save the cassette to disk (only in record mode)
pub fn save(&self) -> anyhow::Result<()> {
if let VcrMode::Record(path) = &self.mode {
let cassette = self
.cassette
.lock()
.map_err(|e| anyhow::anyhow!("Lock error: {}", e))?;
if cassette.interactions.is_empty() {
return Ok(()); // nothing to save
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(&*cassette)?;
std::fs::write(path, json)?;
tracing::info!(target: "nomi_agent", interactions = cassette.interactions.len(), path = %path.display(), "vcr cassette saved");
}
Ok(())
}
}
impl Drop for VcrLayer {
fn drop(&mut self) {
if let Err(e) = self.save() {
tracing::warn!(target: "nomi_agent", error = %e, "failed to save vcr cassette");
}
}
}
/// Load a cassette from disk
fn load_cassette(path: &Path) -> anyhow::Result<Cassette> {
let content = std::fs::read_to_string(path)?;
let cassette: Cassette = serde_json::from_str(&content)?;
tracing::info!(target: "nomi_agent", name = %cassette.name, interactions = cassette.interactions.len(), "vcr cassette loaded");
Ok(cassette)
}
/// Remove sensitive headers from recorded requests
fn sanitize_headers(headers: &HashMap<String, String>) -> HashMap<String, String> {
headers
.iter()
.map(|(k, v)| {
let sanitized_value = if k.to_lowercase().contains("key")
|| k.to_lowercase().contains("auth")
|| k.to_lowercase().contains("token")
{
"[REDACTED]".to_string()
} else {
v.clone()
};
(k.clone(), sanitized_value)
})
.collect()
}
@@ -0,0 +1,269 @@
// Acceptance tests for context compression (all three compaction levels).
//
// TC-A2-01 and TC-A2-03 are purely local (no LLM call).
// TC-A2-02 makes a real LLM call and is skipped when OPENAI_API_KEY is absent.
use nomi_agent::compact::auto::{BOUNDARY_PREFIX, autocompact, should_autocompact};
use nomi_agent::compact::emergency::is_at_emergency_limit;
use nomi_agent::compact::micro::{CLEARED_TOOL_RESULT, microcompact};
use nomi_agent::compact::state::CompactState;
use nomi_config::compact::CompactConfig;
use nomi_types::message::{ContentBlock, Message, Role};
use serde_json::json;
use crate::helpers;
// ── Helpers ────────────────────────────────────────────────────────────────
fn tool_use_block(id: &str, name: &str) -> ContentBlock {
ContentBlock::ToolUse {
id: id.to_string(),
name: name.to_string(),
input: json!({}),
extra: None,
}
}
fn tool_result_block(id: &str, content: &str) -> ContentBlock {
ContentBlock::ToolResult {
tool_use_id: id.to_string(),
content: content.to_string(),
is_error: false,
images: Vec::new(),
}
}
// ── TC-A2-01: Microcompact clears old tool results (LOCAL) ─────────────────
/// Construct a message history with more than `micro_keep_recent * 2`
/// compactable tool results (each with a matching ToolUse block), run
/// microcompact, and verify that old results are cleared while the most
/// recent `micro_keep_recent` are preserved.
#[test]
fn microcompact_clears_old_tool_results() {
let keep_recent: usize = 3;
// We need MORE than keep_recent * 2 = 6 compactable results, so use 8.
let total_results: usize = 8;
let config = CompactConfig {
micro_keep_recent: keep_recent,
compactable_tools: vec!["Read".to_string()],
..CompactConfig::default()
};
// Build messages: alternating ToolUse (assistant) and ToolResult (user)
let mut messages: Vec<Message> = Vec::with_capacity(total_results * 2);
for i in 0..total_results {
let id = format!("tool_{i}");
messages.push(Message::new(
Role::Assistant,
vec![tool_use_block(&id, "Read")],
));
messages.push(Message::new(
Role::User,
vec![tool_result_block(&id, &format!("content of file {i}"))],
));
}
let result = microcompact(&mut messages, &config);
// Verify cleared count is positive
assert!(
result.cleared_count > 0,
"microcompact should clear at least one tool result, got cleared_count=0"
);
// Exactly total_results - keep_recent should be cleared
let expected_cleared = total_results - keep_recent;
assert_eq!(
result.cleared_count, expected_cleared,
"expected {expected_cleared} cleared, got {}",
result.cleared_count
);
// Verify old results (first `expected_cleared`) are replaced with placeholder
for i in 0..expected_cleared {
let user_msg_idx = i * 2 + 1; // user messages are at odd indices
match &messages[user_msg_idx].content[0] {
ContentBlock::ToolResult { content, .. } => {
assert_eq!(
content, CLEARED_TOOL_RESULT,
"tool result at index {i} should be cleared"
);
}
other => panic!("expected ToolResult at index {user_msg_idx}, got {other:?}"),
}
}
// Verify most recent `keep_recent` results are preserved
for i in expected_cleared..total_results {
let user_msg_idx = i * 2 + 1;
match &messages[user_msg_idx].content[0] {
ContentBlock::ToolResult { content, .. } => {
let expected = format!("content of file {i}");
assert_eq!(
content, &expected,
"tool result at index {i} should be preserved with original content"
);
}
other => panic!("expected ToolResult at index {user_msg_idx}, got {other:?}"),
}
}
}
// ── TC-A2-02: Autocompact triggers LLM summary (REAL API CALL) ────────────
/// Set a very low autocompact threshold, verify should_autocompact triggers,
/// then call autocompact with a real LLM provider and verify the result
/// contains the boundary prefix marker.
#[tokio::test]
async fn autocompact_triggers_llm_summary() {
let api_key = match helpers::openai_api_key() {
Some(k) => k,
None => {
eprintln!("[acceptance] OPENAI_API_KEY not set — skipping");
return;
}
};
// Use gpt-4.1-mini which supports up to 32768 output tokens.
// The autocompact function requests COMPACT_MAX_OUTPUT_TOKENS (20000),
// which exceeds gpt-4o-mini's 16384 limit.
let config = {
let base = helpers::openai_config(&api_key);
nomi_config::config::Config {
model: "gpt-4.1-mini".to_string(),
..base
}
};
let compact_config = CompactConfig {
context_window: 1000,
output_reserve: 100,
autocompact_buffer: 100,
// threshold = 1000 - 100 - 100 = 800
..CompactConfig::default()
};
// Verify should_autocompact detects the threshold is exceeded
assert!(
should_autocompact(900, &compact_config),
"900 tokens should exceed the threshold of 800"
);
assert!(
!should_autocompact(700, &compact_config),
"700 tokens should be below the threshold of 800"
);
// Build a simple conversation
let messages = vec![
Message::new(
Role::User,
vec![ContentBlock::Text {
text: "Hello".to_string(),
}],
),
Message::new(
Role::Assistant,
vec![ContentBlock::Text {
text: "Hi there!".to_string(),
}],
),
Message::new(
Role::User,
vec![ContentBlock::Text {
text: "What is 2+2?".to_string(),
}],
),
Message::new(
Role::Assistant,
vec![ContentBlock::Text {
text: "4".to_string(),
}],
),
];
// Create a real provider and run autocompact
let provider = nomi_providers::create_provider(&config);
let state = CompactState {
last_input_tokens: 900, // above the threshold of 800
..CompactState::default()
};
// autocompact takes &mut state for recording success/failure
let mut state = state;
let result = autocompact(
provider.as_ref(),
&messages,
&config.model,
&compact_config,
&mut state,
)
.await;
let compact_result = result.expect("autocompact should succeed with a real LLM");
// Verify the result messages contain the boundary prefix
let has_boundary = compact_result.messages.iter().any(|msg| {
msg.content.iter().any(|block| {
if let ContentBlock::Text { text } = block {
text.starts_with(BOUNDARY_PREFIX)
} else {
false
}
})
});
assert!(
has_boundary,
"autocompact result should contain a message with the boundary prefix"
);
// Verify metadata
assert_eq!(compact_result.messages_summarized, messages.len());
assert_eq!(compact_result.pre_compact_tokens, 900);
}
// ── TC-A2-03: Emergency truncation detection (LOCAL) ───────────────────────
/// Verify that is_at_emergency_limit correctly detects when the token count
/// is within the emergency buffer of the context window, and that it works
/// even when compact is disabled.
#[test]
fn emergency_truncation_detection() {
let config = CompactConfig {
context_window: 1000,
emergency_buffer: 100,
// limit = 1000 - 100 = 900
..CompactConfig::default()
};
// 950 >= 900 → true (at emergency limit)
assert!(
is_at_emergency_limit(950, &config),
"950 tokens should be at the emergency limit (threshold = 900)"
);
// 800 < 900 → false (below emergency limit)
assert!(
!is_at_emergency_limit(800, &config),
"800 tokens should be below the emergency limit (threshold = 900)"
);
// Verify emergency check works even when config.enabled = false
let disabled_config = CompactConfig {
context_window: 1000,
emergency_buffer: 100,
enabled: false,
..CompactConfig::default()
};
assert!(
is_at_emergency_limit(950, &disabled_config),
"emergency limit should apply even when compact is disabled"
);
assert!(
!is_at_emergency_limit(800, &disabled_config),
"below-limit should still return false when compact is disabled"
);
}
@@ -0,0 +1,142 @@
// TC-AX-01: Multi-feature collaboration test (LOCAL, no LLM).
//
// Exercises memory + compression + file cache + tool description all at once.
use nomi_agent::compact::micro::{CLEARED_TOOL_RESULT, microcompact};
use nomi_agent::context::{SystemPromptCache, build_system_prompt};
use nomi_config::compact::CompactConfig;
use nomi_config::file_cache::FileCacheConfig;
use nomi_tools::Tool;
use nomi_tools::file_cache::FileStateCache;
use nomi_tools::read::ReadTool;
use nomi_types::message::{ContentBlock, Message, Role};
use serde_json::json;
use std::sync::{Arc, RwLock};
#[tokio::test]
async fn tc_ax_01_multi_feature_collaboration() {
// ── Step 1: Setup memory directory with MEMORY.md and an entry ──
let tmp = tempfile::TempDir::new().unwrap();
let mem_dir = tmp.path().join("memory");
std::fs::create_dir_all(&mem_dir).unwrap();
std::fs::write(
mem_dir.join("MEMORY.md"),
"- [Preferences](prefs.md) \u{2014} user prefers dark theme\n",
)
.unwrap();
std::fs::write(
mem_dir.join("prefs.md"),
"The user prefers dark theme and compact layout.\n",
)
.unwrap();
// ── Step 2: Build system prompt with memory ──
let system_prompt = build_system_prompt(
&mut SystemPromptCache::new(),
None,
"/tmp",
"test-model",
&[],
None,
Some(&mem_dir),
false, // plan_mode_active = false
false,
false, // browser_enabled
);
// Assert: system prompt contains memory content
assert!(
system_prompt.contains("auto memory"),
"system prompt should contain memory system display name"
);
assert!(
system_prompt.contains("prefs.md"),
"system prompt should reference the memory entry file"
);
// Assert: system prompt contains tool guidance
assert!(
system_prompt.contains("# Using your tools"),
"system prompt should contain tool usage guidance heading"
);
// ── Step 3: ReadTool dedup via FileStateCache ──
let cache_config = FileCacheConfig {
max_entries: 100,
max_size_bytes: 25 * 1024 * 1024,
enabled: true,
};
let cache = Arc::new(RwLock::new(FileStateCache::new(&cache_config)));
let read_tool = ReadTool::new(Some(Arc::clone(&cache)), None);
let test_file = tmp.path().join("test_read.txt");
std::fs::write(&test_file, "line one\nline two\nline three\n").unwrap();
let input = json!({ "file_path": test_file.to_str().unwrap() });
// First read: full content
let r1 = read_tool.execute(input.clone()).await;
assert!(!r1.is_error, "first read should succeed");
assert!(
r1.content.contains("line one"),
"first read should return file content"
);
// Second read: dedup stub (file unchanged)
let r2 = read_tool.execute(input).await;
assert!(!r2.is_error, "second read should succeed");
assert!(
r2.content.contains("unchanged since last read"),
"second read should return dedup stub, got: {}",
r2.content
);
// ── Step 4: Microcompact clears old tool results ──
let mut messages = Vec::new();
for i in 0..8 {
let id = format!("t{i}");
messages.push(Message::new(
Role::Assistant,
vec![ContentBlock::ToolUse {
id: id.clone(),
name: "Read".to_string(),
input: json!({}),
extra: None,
}],
));
messages.push(Message::new(
Role::User,
vec![ContentBlock::ToolResult {
tool_use_id: id,
content: format!("file-content-{i}"),
is_error: false,
images: Vec::new(),
}],
));
}
let compact_config = CompactConfig {
micro_keep_recent: 3,
..CompactConfig::default()
};
let result = microcompact(&mut messages, &compact_config);
// Assert: microcompact cleared some old tool results
assert!(
result.cleared_count > 0,
"microcompact should clear old tool results, got cleared_count={}",
result.cleared_count
);
// Verify cleared results contain the placeholder
let cleared_results: Vec<_> = messages
.iter()
.flat_map(|m| &m.content)
.filter(|b| matches!(b, ContentBlock::ToolResult { content, .. } if content == CLEARED_TOOL_RESULT))
.collect();
assert_eq!(
cleared_results.len(),
result.cleared_count,
"number of cleared placeholders should match cleared_count"
);
}
@@ -0,0 +1,112 @@
// Acceptance tests for file cache dedup and cross-tool integration.
//
// These are LOCAL tests — no LLM call required.
use std::sync::{Arc, RwLock};
use serde_json::json;
use nomi_config::file_cache::FileCacheConfig;
use nomi_tools::Tool;
use nomi_tools::edit::EditTool;
use nomi_tools::file_cache::FileStateCache;
use nomi_tools::read::ReadTool;
use nomi_tools::write::WriteTool;
fn make_cache() -> Arc<RwLock<FileStateCache>> {
let config = FileCacheConfig::default();
Arc::new(RwLock::new(FileStateCache::new(&config)))
}
/// TC-A5-01: Read dedup (LOCAL, no LLM).
///
/// Verifies that a second read of an unchanged file returns a short dedup stub
/// instead of re-sending the full content.
#[tokio::test]
async fn read_dedup_returns_stub_on_second_read() {
let cache = make_cache();
let read_tool = ReadTool::new(Some(cache.clone()), None);
// Create a temporary file with known content.
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("dedup_test.txt");
std::fs::write(&file_path, "line one\nline two\nline three\n").unwrap();
let path_str = file_path.to_str().unwrap();
let input = json!({ "file_path": path_str });
// First read: should return full line-numbered content.
let r1 = read_tool.execute(input.clone()).await;
assert!(!r1.is_error, "first read should succeed: {}", r1.content);
assert!(
r1.content.contains("1\tline one"),
"first read should contain line-numbered content, got: {}",
r1.content
);
assert!(
r1.content.contains("2\tline two"),
"first read should contain line 2"
);
assert!(
r1.content.contains("3\tline three"),
"first read should contain line 3"
);
// Second read WITHOUT modifying the file: should return dedup stub.
let r2 = read_tool.execute(input).await;
assert!(!r2.is_error, "second read should succeed: {}", r2.content);
assert!(
r2.content.contains("unchanged since last read"),
"second read should return dedup stub, got: {}",
r2.content
);
}
/// TC-A5-02: Write -> Edit chain (LOCAL, no LLM).
///
/// Verifies that WriteTool populates the cache so EditTool can immediately
/// edit the file without a separate Read call (no "must Read first" error).
#[tokio::test]
async fn write_then_edit_chain_succeeds() {
let cache = make_cache();
let write_tool = WriteTool::new(Some(cache.clone()));
let edit_tool = EditTool::new(Some(cache.clone()));
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("write_edit_chain.txt");
let path_str = file_path.to_str().unwrap();
// Write a file via WriteTool.
let write_result = write_tool
.execute(json!({
"file_path": path_str,
"content": "hello world\n"
}))
.await;
assert!(
!write_result.is_error,
"write should succeed: {}",
write_result.content
);
// Immediately edit via EditTool — should NOT get "must Read first" error.
let edit_result = edit_tool
.execute(json!({
"file_path": path_str,
"old_string": "hello",
"new_string": "goodbye"
}))
.await;
assert!(
!edit_result.is_error,
"edit after write should succeed without 'must Read first' error: {}",
edit_result.content
);
// Verify file content on disk.
let content = std::fs::read_to_string(&file_path).unwrap();
assert_eq!(
content, "goodbye world\n",
"file content should reflect the edit"
);
}
@@ -0,0 +1,134 @@
// Shared helpers for acceptance tests: provider detection and config builders.
use nomi_config::compat::ProviderCompat;
use nomi_config::config::{BedrockConfig, Config, ProviderType, SessionConfig, ToolsConfig};
use nomi_config::hooks::HooksConfig;
use nomi_mcp::config::McpConfig;
// ---------------------------------------------------------------------------
// Provider detection
// ---------------------------------------------------------------------------
/// Returns the OpenAI API key if set and non-empty.
pub fn openai_api_key() -> Option<String> {
std::env::var("OPENAI_API_KEY")
.ok()
.filter(|k| !k.is_empty())
}
/// Returns true when AWS Bedrock is configured for use.
pub fn bedrock_configured() -> bool {
let has_profile = std::env::var("AWS_PROFILE")
.ok()
.filter(|v| !v.is_empty())
.is_some();
let bedrock_flag = std::env::var("CLAUDE_CODE_USE_BEDROCK")
.ok()
.filter(|v| v == "1")
.is_some();
has_profile && bedrock_flag
}
// ---------------------------------------------------------------------------
// Skip macros
// ---------------------------------------------------------------------------
/// Skips the current test if OPENAI_API_KEY is not set.
/// Usage: `skip_if_no_openai!();` at the start of a test function.
macro_rules! skip_if_no_openai {
() => {
#[allow(unused_variables)]
let openai_api_key = match $crate::helpers::openai_api_key() {
Some(k) => k,
None => {
eprintln!("[acceptance] OPENAI_API_KEY not set — skipping");
return;
}
};
};
}
/// Skips the current test if Bedrock is not configured.
/// Usage: `skip_if_no_bedrock!();` at the start of a test function.
macro_rules! skip_if_no_bedrock {
() => {
if !$crate::helpers::bedrock_configured() {
eprintln!("[acceptance] Bedrock not configured — skipping");
return;
}
};
}
// ---------------------------------------------------------------------------
// Config builders
// ---------------------------------------------------------------------------
/// Build a Config for the OpenAI provider (gpt-4o-mini, cheap for tests).
pub fn openai_config(api_key: &str) -> Config {
Config {
provider: ProviderType::OpenAI,
provider_label: "openai".to_string(),
api_key: api_key.to_string(),
base_url: "https://api.openai.com".to_string(),
model: "gpt-4o-mini".to_string(),
max_tokens: 256,
max_turns: Some(3),
system_prompt: Some("You are a helpful assistant. Be concise.".to_string()),
thinking: None,
prompt_caching: false,
compat: ProviderCompat::openai_defaults(),
tools: ToolsConfig {
auto_approve: true,
allow_list: vec![],
..ToolsConfig::default()
},
session: SessionConfig {
enabled: false,
directory: "/tmp/nomi-acceptance".to_string(),
max_sessions: 1,
},
compact: nomi_config::compact::CompactConfig::default(),
plan: nomi_config::plan::PlanConfig::default(),
file_cache: nomi_config::file_cache::FileCacheConfig::default(),
hooks: HooksConfig::default(),
bedrock: None,
vertex: None,
mcp: McpConfig::default(),
logging: nomi_config::logging::LoggingConfig::default(),
}
}
/// Build a Config for the AWS Bedrock provider (Claude Haiku).
pub fn bedrock_config() -> Config {
Config {
provider: ProviderType::Bedrock,
provider_label: "bedrock".to_string(),
api_key: String::new(), // Bedrock uses AWS credentials, not API key
base_url: String::new(),
model: "us.anthropic.claude-haiku-4-20250514-v1:0".to_string(),
max_tokens: 256,
max_turns: Some(3),
system_prompt: Some("You are a helpful assistant. Be concise.".to_string()),
thinking: None,
prompt_caching: false,
compat: ProviderCompat::anthropic_defaults(),
tools: ToolsConfig {
auto_approve: true,
allow_list: vec![],
..ToolsConfig::default()
},
session: SessionConfig {
enabled: false,
directory: "/tmp/nomi-acceptance".to_string(),
max_sessions: 1,
},
compact: nomi_config::compact::CompactConfig::default(),
plan: nomi_config::plan::PlanConfig::default(),
file_cache: nomi_config::file_cache::FileCacheConfig::default(),
hooks: HooksConfig::default(),
bedrock: Some(BedrockConfig::default()),
vertex: None,
mcp: McpConfig::default(),
logging: nomi_config::logging::LoggingConfig::default(),
}
}
@@ -0,0 +1,197 @@
// Acceptance tests for the memory system end-to-end.
//
// These tests verify that the memory system's file I/O, index management,
// and prompt building work together correctly. No LLM API calls are needed.
use nomi_agent::context::{SystemPromptCache, build_system_prompt};
use nomi_memory::index::{append_index_entry, remove_index_entry};
use nomi_memory::paths::ENTRYPOINT_NAME;
use nomi_memory::store::{delete_memory, write_memory};
use nomi_memory::types::{MemoryEntry, MemoryType};
/// TC-A1-01: Memory injection into system prompt.
///
/// Verifies that when a memory directory exists with an index file and a
/// memory entry, `build_system_prompt()` produces output containing both
/// the compact behavioral instructions and the MEMORY.md index content.
#[test]
fn memory_injection_into_system_prompt() {
let tmp = tempfile::TempDir::new().unwrap();
let mem_dir = tmp.path().join("memory");
std::fs::create_dir_all(&mem_dir).unwrap();
// Write MEMORY.md index with one entry
let index_path = mem_dir.join(ENTRYPOINT_NAME);
std::fs::write(
&index_path,
"# Memory Index\n\n- [User role](user_role.md) \u{2014} senior Rust engineer\n",
)
.unwrap();
// Write a corresponding memory entry file
std::fs::write(
mem_dir.join("user_role.md"),
"---\nname: user_role\ndescription: senior Rust engineer\ntype: user\n---\n\nThe user is a senior Rust engineer.\n",
)
.unwrap();
let prompt = build_system_prompt(
&mut SystemPromptCache::new(),
None,
"/tmp",
"test-model",
&[],
None,
Some(&mem_dir),
false,
false,
false, // browser_enabled
);
// Behavioral instructions must be present
assert!(
prompt.contains("auto memory"),
"system prompt should contain the memory display name"
);
assert!(
prompt.contains("Memory types:"),
"system prompt should contain the compact memory type summary"
);
// MEMORY.md content must be injected
assert!(
prompt.contains("user_role.md"),
"system prompt should contain the MEMORY.md index filename reference"
);
assert!(
prompt.contains("senior Rust engineer"),
"system prompt should contain the MEMORY.md index summary"
);
}
/// TC-A1-02: Memory full lifecycle (create, index, verify, delete, verify gone).
///
/// Exercises the complete lifecycle of a memory entry through the public API:
/// 1. write_memory() -> create the file
/// 2. append_index_entry() -> add to MEMORY.md
/// 3. build_system_prompt() -> verify the content appears
/// 4. delete_memory() -> remove the file
/// 5. remove_index_entry() -> clean the index
/// 6. build_system_prompt() -> verify the content is gone
#[test]
fn memory_full_lifecycle() {
let tmp = tempfile::TempDir::new().unwrap();
let mem_dir = tmp.path().join("memory");
std::fs::create_dir_all(&mem_dir).unwrap();
let index_path = mem_dir.join(ENTRYPOINT_NAME);
// -- Phase 1: Create memory entry via the store API -----------------------
let entry = MemoryEntry::build(
"project status",
"current sprint goals",
MemoryType::Project,
"We are migrating the auth service to the new provider.",
);
let entry_path = write_memory(&mem_dir, &entry).unwrap();
assert!(entry_path.exists(), "memory file should be created on disk");
let entry_filename = entry_path.file_name().unwrap().to_str().unwrap().to_owned();
// -- Phase 2: Add the entry to the MEMORY.md index ------------------------
append_index_entry(
&index_path,
"Project status",
&entry_filename,
"current sprint goals",
)
.unwrap();
let index_content = std::fs::read_to_string(&index_path).unwrap();
assert!(
index_content.contains(&entry_filename),
"MEMORY.md should reference the new entry file"
);
// -- Phase 3: Verify system prompt includes the memory content ------------
let prompt_with_memory = build_system_prompt(
&mut SystemPromptCache::new(),
None,
"/tmp",
"test-model",
&[],
None,
Some(&mem_dir),
false,
false,
false, // browser_enabled
);
assert!(
prompt_with_memory.contains("auto memory"),
"prompt should contain behavioral instructions"
);
assert!(
prompt_with_memory.contains("Memory types:"),
"prompt should contain compact memory type summary"
);
assert!(
prompt_with_memory.contains(&entry_filename),
"prompt should contain the memory entry filename from the index"
);
assert!(
prompt_with_memory.contains("current sprint goals"),
"prompt should contain the index summary"
);
// -- Phase 4: Delete the memory file --------------------------------------
delete_memory(&entry_path).unwrap();
assert!(
!entry_path.exists(),
"memory file should be removed from disk"
);
// -- Phase 5: Remove the entry from the MEMORY.md index -------------------
remove_index_entry(&index_path, &entry_filename).unwrap();
let index_after = std::fs::read_to_string(&index_path).unwrap();
assert!(
!index_after.contains(&entry_filename),
"MEMORY.md should no longer reference the deleted entry"
);
// -- Phase 6: Verify the content is gone from the system prompt -----------
let prompt_after_delete = build_system_prompt(
&mut SystemPromptCache::new(),
None,
"/tmp",
"test-model",
&[],
None,
Some(&mem_dir),
false,
false,
false, // browser_enabled
);
assert!(
!prompt_after_delete.contains(&entry_filename),
"prompt should no longer contain the deleted entry filename"
);
assert!(
!prompt_after_delete.contains("current sprint goals"),
"prompt should no longer contain the deleted entry summary"
);
// With everything removed, the index is empty — the prompt should show the empty state
assert!(
prompt_after_delete.contains("currently empty"),
"prompt should show empty memory state after all entries are removed"
);
}
@@ -0,0 +1,21 @@
#![allow(dead_code, unused_macros, unused_imports)]
/// Acceptance tests for evolution features (Phase 6).
///
/// These tests validate end-to-end behavior of each evolution feature
/// against real LLM providers. They are skipped when provider credentials
/// are absent, making them safe to run in any environment.
///
/// Required env vars (at least one):
/// OPENAI_API_KEY — runs OpenAI provider tests
/// AWS_PROFILE + CLAUDE_CODE_USE_BEDROCK=1 — runs Bedrock provider tests
///
/// Run manually:
/// OPENAI_API_KEY=sk-... cargo nextest run -p nomi-agent --profile e2e --test acceptance
#[macro_use]
mod helpers;
mod compact_test;
mod cross_feature_test;
mod file_cache_test;
mod memory_test;
mod plan_mode_test;
mod tool_desc_test;
@@ -0,0 +1,226 @@
// Acceptance tests for Plan Mode tool filtering and prompt injection (Task 6.4).
//
// These tests are LOCAL (no LLM required) and verify that:
// - Tool registry filtering produces the correct tool sets for normal vs plan mode
// - System prompt correctly includes/excludes plan mode instructions
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use async_trait::async_trait;
use nomi_agent::context::{SystemPromptCache, build_system_prompt};
use nomi_agent::plan::tools::{EnterPlanModeTool, ExitPlanModeTool};
use nomi_protocol::events::ToolCategory;
use nomi_tools::Tool;
use nomi_tools::registry::ToolRegistry;
use nomi_types::tool::ToolResult;
use serde_json::{Value, json};
// ---------------------------------------------------------------------------
// Helpers: mock tool with configurable category
// ---------------------------------------------------------------------------
struct CategoryMockTool {
tool_name: String,
cat: ToolCategory,
}
impl CategoryMockTool {
fn new(name: &str, cat: ToolCategory) -> Self {
Self {
tool_name: name.to_string(),
cat,
}
}
}
#[async_trait]
impl Tool for CategoryMockTool {
fn name(&self) -> &str {
&self.tool_name
}
fn description(&self) -> &str {
"mock"
}
fn input_schema(&self) -> Value {
json!({"type": "object"})
}
fn category(&self) -> ToolCategory {
self.cat
}
fn is_concurrency_safe(&self, _input: &Value) -> bool {
true
}
async fn execute(&self, _input: Value) -> ToolResult {
ToolResult {
content: String::new(),
is_error: false,
images: Vec::new(),
}
}
}
// ---------------------------------------------------------------------------
// TC-A3-01: Plan Mode tool filtering (LOCAL, no LLM)
// ---------------------------------------------------------------------------
#[test]
fn tc_a3_01_plan_mode_tool_filtering() {
let flag = Arc::new(AtomicBool::new(false));
let mut registry = ToolRegistry::new();
// Info category tools
registry.register(Box::new(CategoryMockTool::new("Read", ToolCategory::Info)));
registry.register(Box::new(CategoryMockTool::new("Grep", ToolCategory::Info)));
registry.register(Box::new(EnterPlanModeTool::new(Arc::clone(&flag))));
registry.register(Box::new(ExitPlanModeTool::new(Arc::clone(&flag))));
// Edit category tool
registry.register(Box::new(CategoryMockTool::new("Write", ToolCategory::Edit)));
// Exec category tool
registry.register(Box::new(CategoryMockTool::new("Bash", ToolCategory::Exec)));
// --- Normal mode: all tools except ExitPlanMode ---
let normal_defs = registry.to_tool_defs_filtered(|t| t.name() != "ExitPlanMode");
let normal_names: Vec<&str> = normal_defs.iter().map(|d| d.name.as_str()).collect();
assert!(
!normal_names.contains(&"ExitPlanMode"),
"ExitPlanMode should be excluded in normal mode"
);
assert!(
normal_names.contains(&"Read"),
"Read should be present in normal mode"
);
assert!(
normal_names.contains(&"Grep"),
"Grep should be present in normal mode"
);
assert!(
normal_names.contains(&"Write"),
"Write should be present in normal mode"
);
assert!(
normal_names.contains(&"Bash"),
"Bash should be present in normal mode"
);
assert!(
normal_names.contains(&"EnterPlanMode"),
"EnterPlanMode should be present in normal mode"
);
// --- Plan mode: only Info tools, excluding EnterPlanMode ---
let plan_defs = registry.to_tool_defs_filtered(|t| {
t.category() == ToolCategory::Info && t.name() != "EnterPlanMode"
});
let plan_names: Vec<&str> = plan_defs.iter().map(|d| d.name.as_str()).collect();
// Info tools should be present
assert!(
plan_names.contains(&"Read"),
"Read (Info) should be available in plan mode"
);
assert!(
plan_names.contains(&"Grep"),
"Grep (Info) should be available in plan mode"
);
assert!(
plan_names.contains(&"ExitPlanMode"),
"ExitPlanMode (Info) should be available in plan mode"
);
// EnterPlanMode should be excluded
assert!(
!plan_names.contains(&"EnterPlanMode"),
"EnterPlanMode should be excluded in plan mode"
);
// Edit and Exec tools should be excluded
assert!(
!plan_names.contains(&"Write"),
"Write (Edit) should be excluded in plan mode"
);
assert!(
!plan_names.contains(&"Bash"),
"Bash (Exec) should be excluded in plan mode"
);
}
// ---------------------------------------------------------------------------
// TC-A3-02: Plan Mode system prompt injection (LOCAL, no LLM)
// ---------------------------------------------------------------------------
#[test]
fn tc_a3_02_plan_mode_system_prompt_injection() {
// --- Plan mode active: prompt should contain plan mode instructions ---
let active_prompt = build_system_prompt(
&mut SystemPromptCache::new(),
None,
"/tmp",
"test-model",
&[],
None,
None,
true,
false,
false,
);
assert!(
active_prompt.contains("# Plan Mode"),
"active prompt should contain plan mode heading"
);
assert!(
active_prompt.contains("Understand"),
"active prompt should mention Phase 1: Understand"
);
assert!(
active_prompt.contains("Design"),
"active prompt should mention Phase 2: Design"
);
assert!(
active_prompt.contains("Write the plan"),
"active prompt should mention Phase 3: Write the plan"
);
assert!(
active_prompt.contains("Submit for review"),
"active prompt should mention Phase 4: Submit for review"
);
assert!(
active_prompt.contains("Forbidden"),
"active prompt should mention forbidden actions"
);
assert!(
active_prompt.contains("ExitPlanMode"),
"active prompt should reference ExitPlanMode tool"
);
// --- Plan mode inactive: prompt should NOT contain plan mode instructions ---
let inactive_prompt = build_system_prompt(
&mut SystemPromptCache::new(),
None,
"/tmp",
"test-model",
&[],
None,
None,
false,
false,
false,
);
assert!(
!inactive_prompt.contains("# Plan Mode"),
"inactive prompt should NOT contain plan mode heading"
);
assert!(
!inactive_prompt.contains("Forbidden actions"),
"inactive prompt should NOT contain forbidden actions section"
);
}
@@ -0,0 +1,73 @@
// Acceptance test for tool usage guidance in the system prompt (TC-A4-01).
//
// This is a LOCAL test — no LLM call required.
use nomi_agent::context::{SystemPromptCache, build_system_prompt};
/// TC-A4-01: System prompt contains tool guidance.
///
/// Calls `build_system_prompt` with minimal arguments and verifies that the
/// returned prompt includes the tool-usage guidance section with all expected
/// content: heading, Bash prohibition mappings, parallel call guidance,
/// Edit-over-Write preference, and Read-before-Edit rule.
#[test]
fn system_prompt_contains_tool_guidance() {
let prompt = build_system_prompt(
&mut SystemPromptCache::new(),
None,
"/tmp",
"test-model",
&[],
None,
None,
false,
false,
false,
);
// 1. Heading
assert!(
prompt.contains("# Using your tools"),
"system prompt must contain the '# Using your tools' heading"
);
// 2. Bash prohibition mappings — dedicated tool replacements
assert!(
prompt.contains("Glob"),
"should mention Glob as replacement for find/ls"
);
assert!(
prompt.contains("Grep"),
"should mention Grep as replacement for grep/rg"
);
assert!(
prompt.contains("Read"),
"should mention Read as replacement for cat/head/tail"
);
assert!(
prompt.contains("Edit"),
"should mention Edit as replacement for sed/awk"
);
assert!(
prompt.contains("Write"),
"should mention Write as replacement for echo redirection"
);
// 3. Parallel call guidance
assert!(
prompt.contains("parallel"),
"should contain parallel call guidance"
);
// 4. Edit-over-Write preference
assert!(
prompt.contains("Prefer Edit over Write"),
"should contain Edit-over-Write preference"
);
// 5. Read-before-Edit rule
assert!(
prompt.contains("Read a file before editing"),
"should contain Read-before-Edit rule"
);
}
@@ -0,0 +1,512 @@
//! Black-box integration tests for the autocompact subsystem.
//!
//! These tests correspond to TC-2.4-* in the test plan.
//! They exercise the public autocompact API with a mock LLM provider,
//! validating trigger logic, summary formatting, boundary markers,
//! circuit breaker, and PTL retry behaviour.
use std::collections::VecDeque;
use std::sync::Mutex;
use async_trait::async_trait;
use tokio::sync::mpsc;
use nomi_agent::compact::auto::{
CompactError, autocompact, extract_compact_metadata, is_compact_boundary, should_autocompact,
};
use nomi_agent::compact::prompt::{
build_compact_prompt, build_summary_content, format_compact_summary,
};
use nomi_agent::compact::state::CompactState;
use nomi_config::compact::CompactConfig;
use nomi_providers::{LlmProvider, ProviderError};
use nomi_types::compact::CompactTrigger;
use nomi_types::llm::{LlmEvent, LlmRequest};
use nomi_types::message::{ContentBlock, Message, Role, StopReason, TokenUsage};
// ── Mock provider ───────────────────────────────────────────────────────────
/// A mock LLM provider that returns pre-configured responses in order.
struct MockProvider {
responses: Mutex<VecDeque<Result<Vec<LlmEvent>, ProviderError>>>,
}
impl MockProvider {
fn new(responses: Vec<Result<Vec<LlmEvent>, ProviderError>>) -> Self {
Self {
responses: Mutex::new(VecDeque::from(responses)),
}
}
/// Create a provider that returns a successful summary response.
fn with_summary(summary: &str) -> Self {
Self::new(vec![Ok(vec![
LlmEvent::TextDelta(summary.to_string()),
LlmEvent::Done {
stop_reason: StopReason::EndTurn,
usage: TokenUsage {
input_tokens: 50_000,
output_tokens: 2_000,
..Default::default()
},
},
])])
}
/// Create a provider that returns an error.
fn with_error(error: ProviderError) -> Self {
Self::new(vec![Err(error)])
}
}
#[async_trait]
impl LlmProvider for MockProvider {
async fn stream(
&self,
_request: &LlmRequest,
) -> Result<mpsc::Receiver<LlmEvent>, ProviderError> {
let response = self
.responses
.lock()
.unwrap()
.pop_front()
.expect("MockProvider: no more responses queued");
match response {
Ok(events) => {
let (tx, rx) = mpsc::channel(events.len() + 1);
for event in events {
tx.send(event).await.ok();
}
Ok(rx)
}
Err(e) => Err(e),
}
}
}
// ── Helpers ─────────────────────────────────────────────────────────────────
fn text_msg(role: Role, content: &str) -> Message {
Message::new(
role,
vec![ContentBlock::Text {
text: content.to_string(),
}],
)
}
fn sample_conversation(n: usize) -> Vec<Message> {
(0..n)
.map(|i| {
let role = if i % 2 == 0 {
Role::User
} else {
Role::Assistant
};
text_msg(role, &format!("message-{i}"))
})
.collect()
}
fn default_config() -> CompactConfig {
CompactConfig::default()
}
// ── TC-2.4-01: Watermark above threshold triggers ───────────────────────────
#[test]
fn tc_2_4_01_above_threshold_triggers() {
// effective_window = 200k - 20k = 180k, threshold = 180k - 13k = 167k
assert!(should_autocompact(170_000, &default_config()));
}
// ── TC-2.4-02: Below threshold does not trigger ─────────────────────────────
#[test]
fn tc_2_4_02_below_threshold_does_not_trigger() {
assert!(!should_autocompact(160_000, &default_config()));
}
// ── TC-2.4-03: Exact threshold triggers ─────────────────────────────────────
#[test]
fn tc_2_4_03_at_exact_threshold_triggers() {
assert!(should_autocompact(167_000, &default_config()));
}
// ── TC-2.4-04: Circuit breaker initial state ────────────────────────────────
#[test]
fn tc_2_4_04_initial_state_not_broken() {
let state = CompactState::new();
assert_eq!(state.consecutive_failures, 0);
assert!(!state.is_circuit_broken(&default_config()));
}
// ── TC-2.4-05: Circuit breaker trips ────────────────────────────────────────
#[test]
fn tc_2_4_05_circuit_breaker_trips() {
let config = default_config();
let mut state = CompactState::new();
state.record_failure();
state.record_failure();
state.record_failure();
assert!(state.is_circuit_broken(&config));
}
// ── TC-2.4-06: Circuit breaker resets ───────────────────────────────────────
#[test]
fn tc_2_4_06_circuit_breaker_resets_on_success() {
let config = default_config();
let mut state = CompactState::new();
state.record_failure();
state.record_failure();
state.record_success();
assert_eq!(state.consecutive_failures, 0);
assert!(!state.is_circuit_broken(&config));
}
// ── TC-2.4-07: Circuit breaker blocks autocompact ───────────────────────────
#[tokio::test]
async fn tc_2_4_07_circuit_breaker_blocks_autocompact() {
let provider = MockProvider::with_summary("<summary>should not be called</summary>");
let messages = sample_conversation(10);
let config = default_config();
let mut state = CompactState::new();
state.record_failure();
state.record_failure();
state.record_failure();
let result = autocompact(&provider, &messages, "test-model", &config, &mut state).await;
assert!(matches!(result, Err(CompactError::CircuitBroken { .. })));
}
// ── TC-2.4-08: Prompt contains all 9 sections ──────────────────────────────
#[test]
fn tc_2_4_08_prompt_contains_all_sections() {
let prompt = build_compact_prompt();
for i in 1..=9 {
assert!(prompt.contains(&format!("{i}.")), "Missing section {i}");
}
assert!(prompt.contains("CRITICAL: Respond with TEXT ONLY"));
}
// ── TC-2.4-09: Summary formatting (normal) ──────────────────────────────────
#[test]
fn tc_2_4_09_format_strips_analysis_extracts_summary() {
let raw = "<analysis>thinking</analysis>\n<summary>result</summary>";
assert_eq!(format_compact_summary(raw), "Summary:\nresult");
}
// ── TC-2.4-10: Summary formatting (no analysis) ────────────────────────────
#[test]
fn tc_2_4_10_format_without_analysis() {
let raw = "<summary>result</summary>";
assert_eq!(format_compact_summary(raw), "Summary:\nresult");
}
// ── TC-2.4-11: Summary formatting (no tags) ────────────────────────────────
#[test]
fn tc_2_4_11_format_graceful_degradation() {
let raw = "plain text without tags";
assert_eq!(format_compact_summary(raw), "plain text without tags");
}
// ── TC-2.4-12: Post-compact message structure ───────────────────────────────
#[tokio::test]
async fn tc_2_4_12_post_compact_message_structure() {
let summary = "<analysis>thinking</analysis>\n<summary>Detailed summary here</summary>";
let provider = MockProvider::with_summary(summary);
let messages = sample_conversation(20);
let config = default_config();
let mut state = CompactState::new();
state.last_input_tokens = 170_000;
let result = autocompact(&provider, &messages, "test-model", &config, &mut state)
.await
.expect("autocompact should succeed");
// Should have 2 messages: boundary + summary
assert_eq!(result.messages.len(), 2);
assert_eq!(result.messages_summarized, 20);
// First message is the boundary marker
assert!(is_compact_boundary(&result.messages[0]));
assert_eq!(result.messages[0].role, Role::User);
// Second message is the summary
assert_eq!(result.messages[1].role, Role::User);
match &result.messages[1].content[0] {
ContentBlock::Text { text } => {
assert!(text.contains("Detailed summary here"));
assert!(text.contains("This session is being continued"));
}
_ => panic!("expected Text block"),
}
}
// ── TC-2.4-13: Boundary marker metadata ─────────────────────────────────────
#[tokio::test]
async fn tc_2_4_13_boundary_metadata() {
let provider = MockProvider::with_summary("<summary>summary</summary>");
let messages = sample_conversation(15);
let config = default_config();
let mut state = CompactState::new();
state.last_input_tokens = 170_000;
let result = autocompact(&provider, &messages, "test-model", &config, &mut state)
.await
.expect("autocompact should succeed");
let metadata = extract_compact_metadata(&result.messages[0]).expect("should have metadata");
assert_eq!(metadata.trigger, CompactTrigger::Auto);
assert_eq!(metadata.pre_compact_tokens, 170_000);
assert_eq!(metadata.messages_summarized, 15);
}
// ── TC-2.4-14: Disabled config skips (tested via should_autocompact) ────────
#[test]
fn tc_2_4_14_disabled_config_skips() {
let config = CompactConfig {
enabled: false,
..default_config()
};
assert!(!should_autocompact(999_999, &config));
}
// ── TC-2.4-15: Prompt forbids tool calls ────────────────────────────────────
#[test]
fn tc_2_4_15_prompt_forbids_tool_calls() {
let prompt = build_compact_prompt();
assert!(prompt.contains("Do NOT call any tools"));
}
// ── TC-2.4-16: Success resets failure counter ───────────────────────────────
#[tokio::test]
async fn tc_2_4_16_success_resets_failure_counter() {
let provider = MockProvider::with_summary("<summary>summary</summary>");
let messages = sample_conversation(10);
let config = default_config();
let mut state = CompactState::new();
state.consecutive_failures = 2;
state.last_input_tokens = 170_000;
autocompact(&provider, &messages, "test-model", &config, &mut state)
.await
.expect("autocompact should succeed");
assert_eq!(state.consecutive_failures, 0);
}
// ── TC-2.4-17: Failure increments failure counter ───────────────────────────
#[tokio::test]
async fn tc_2_4_17_failure_increments_counter() {
let provider = MockProvider::with_error(ProviderError::Api {
status: 500,
message: "Internal error".to_string(),
});
let messages = sample_conversation(10);
let config = default_config();
let mut state = CompactState::new();
let result = autocompact(&provider, &messages, "test-model", &config, &mut state).await;
assert!(result.is_err());
assert_eq!(state.consecutive_failures, 1);
}
// ── TC-2.4-18: PTL retry succeeds on second attempt ────────────────────────
#[tokio::test]
async fn tc_2_4_18_ptl_retry_succeeds() {
let provider = MockProvider::new(vec![
// First attempt: prompt too long
Err(ProviderError::PromptTooLong(
"prompt exceeds limit".to_string(),
)),
// Second attempt (after truncation): success
Ok(vec![
LlmEvent::TextDelta("<summary>retried summary</summary>".to_string()),
LlmEvent::Done {
stop_reason: StopReason::EndTurn,
usage: TokenUsage::default(),
},
]),
]);
let messages = sample_conversation(20);
let config = default_config();
let mut state = CompactState::new();
state.last_input_tokens = 170_000;
let result = autocompact(&provider, &messages, "test-model", &config, &mut state)
.await
.expect("autocompact should succeed after retry");
assert_eq!(result.messages.len(), 2);
assert_eq!(state.consecutive_failures, 0);
// Verify summary content
match &result.messages[1].content[0] {
ContentBlock::Text { text } => {
assert!(text.contains("retried summary"));
}
_ => panic!("expected Text block"),
}
}
// ── TC-2.4-19: PTL retry exhausted ─────────────────────────────────────────
#[tokio::test]
async fn tc_2_4_19_ptl_retry_exhausted() {
let provider = MockProvider::new(vec![
Err(ProviderError::PromptTooLong("too long 1".to_string())),
Err(ProviderError::PromptTooLong("too long 2".to_string())),
Err(ProviderError::PromptTooLong("too long 3".to_string())),
]);
let messages = sample_conversation(20);
let config = default_config();
let mut state = CompactState::new();
let result = autocompact(&provider, &messages, "test-model", &config, &mut state).await;
assert!(matches!(result, Err(CompactError::PromptTooLong { .. })));
assert_eq!(state.consecutive_failures, 1);
}
// ── TC-2.4-20: PTL retry truncates messages ─────────────────────────────────
#[tokio::test]
async fn tc_2_4_20_ptl_retry_truncates_messages() {
// Track the request message count on each attempt
let request_counts: std::sync::Arc<Mutex<Vec<usize>>> =
std::sync::Arc::new(Mutex::new(Vec::new()));
let counts_clone = request_counts.clone();
// Custom mock that records message counts
struct CountingProvider {
counts: std::sync::Arc<Mutex<Vec<usize>>>,
attempt: Mutex<u32>,
}
#[async_trait]
impl LlmProvider for CountingProvider {
async fn stream(
&self,
request: &LlmRequest,
) -> Result<mpsc::Receiver<LlmEvent>, ProviderError> {
// Scope the lock so the MutexGuard is dropped before the await
let current_attempt = {
let mut attempt = self.attempt.lock().unwrap();
self.counts.lock().unwrap().push(request.messages.len());
let val = *attempt;
*attempt += 1;
val
};
if current_attempt == 0 {
return Err(ProviderError::PromptTooLong("too long".to_string()));
}
// Second attempt: succeed
let (tx, rx) = mpsc::channel(2);
tx.send(LlmEvent::TextDelta(
"<summary>truncated summary</summary>".to_string(),
))
.await
.ok();
tx.send(LlmEvent::Done {
stop_reason: StopReason::EndTurn,
usage: TokenUsage::default(),
})
.await
.ok();
Ok(rx)
}
}
let provider = CountingProvider {
counts: counts_clone,
attempt: Mutex::new(0),
};
let messages = sample_conversation(20);
let config = default_config();
let mut state = CompactState::new();
state.last_input_tokens = 170_000;
autocompact(&provider, &messages, "test-model", &config, &mut state)
.await
.expect("should succeed after retry");
let counts = request_counts.lock().unwrap();
assert_eq!(counts.len(), 2, "should have 2 attempts");
// First attempt: 20 conversation + 1 prompt = 21
assert_eq!(counts[0], 21);
// Second attempt: truncated (~20% dropped from 20 = 4 dropped) + 1 prompt
// 20 - 4 = 16, + 1 prompt = 17
assert_eq!(counts[1], 17);
}
// ── Additional edge cases ───────────────────────────────────────────────────
#[tokio::test]
async fn empty_response_fails() {
// Provider returns Done without any TextDelta
let provider = MockProvider::new(vec![Ok(vec![LlmEvent::Done {
stop_reason: StopReason::EndTurn,
usage: TokenUsage::default(),
}])]);
let messages = sample_conversation(10);
let config = default_config();
let mut state = CompactState::new();
let result = autocompact(&provider, &messages, "test-model", &config, &mut state).await;
assert!(matches!(result, Err(CompactError::EmptyResponse)));
assert_eq!(state.consecutive_failures, 1);
}
#[tokio::test]
async fn stream_error_fails() {
let provider = MockProvider::new(vec![Ok(vec![
LlmEvent::TextDelta("partial".to_string()),
LlmEvent::Error("connection reset".to_string()),
])]);
let messages = sample_conversation(10);
let config = default_config();
let mut state = CompactState::new();
let result = autocompact(&provider, &messages, "test-model", &config, &mut state).await;
assert!(matches!(result, Err(CompactError::StreamError(_))));
assert_eq!(state.consecutive_failures, 1);
}
#[test]
fn summary_content_auto_has_continuation() {
let content = build_summary_content("Summary:\ntest", true);
assert!(content.contains("Continue the conversation"));
assert!(content.contains("as if the break never happened"));
}
#[test]
fn summary_content_manual_no_continuation() {
let content = build_summary_content("Summary:\ntest", false);
assert!(!content.contains("Continue the conversation"));
}
@@ -0,0 +1,176 @@
use std::sync::Arc;
use nomi_agent::bootstrap::AgentBootstrap;
use nomi_agent::output::null_sink::NullSink;
use nomi_config::compat::ProviderCompat;
use nomi_config::config::{Config, ProviderType};
fn minimal_config() -> Config {
Config {
provider_label: "openai".into(),
provider: ProviderType::OpenAI,
api_key: "sk-test".into(),
base_url: "http://localhost:0".into(),
model: "gpt-test-model".into(),
max_tokens: 1024,
max_turns: Some(5),
system_prompt: None,
thinking: None,
prompt_caching: false,
compat: ProviderCompat::openai_defaults(),
tools: Default::default(),
session: Default::default(),
compact: Default::default(),
plan: Default::default(),
file_cache: Default::default(),
hooks: Default::default(),
bedrock: None,
vertex: None,
mcp: Default::default(),
logging: Default::default(),
}
}
fn null_output() -> Arc<dyn nomi_agent::output::OutputSink> {
Arc::new(NullSink)
}
#[tokio::test]
async fn bootstrap_builds_engine_with_model_in_prompt() {
let config = minimal_config();
let result = AgentBootstrap::new(config, "/tmp/test-workspace", null_output())
.build()
.await
.expect("bootstrap should succeed");
assert!(!result.engine.tool_names().is_empty());
assert!(!result.has_mcp);
assert!(result.mcp_managers.is_empty());
}
#[tokio::test]
async fn bootstrap_registers_all_expected_tools() {
let config = minimal_config();
let result = AgentBootstrap::new(config, "/tmp/test-workspace", null_output())
.build()
.await
.unwrap();
let names = result.engine.tool_names();
for expected in &["Read", "Write", "Edit", "Bash", "Grep", "Glob"] {
assert!(
names.iter().any(|n| n == expected),
"missing built-in tool: {expected}"
);
}
assert!(
names.iter().any(|n| n == "Skill"),
"SkillTool should be registered"
);
assert!(
names.iter().any(|n| n == "Spawn"),
"SpawnTool should be registered"
);
assert!(
names.iter().any(|n| n == "ToolSearch"),
"ToolSearchTool should be registered"
);
}
#[tokio::test]
async fn bootstrap_plan_tools_when_enabled() {
let mut config = minimal_config();
config.plan.enabled = true;
let result = AgentBootstrap::new(config, "/tmp/test-workspace", null_output())
.build()
.await
.unwrap();
let names = result.engine.tool_names();
assert!(
names.iter().any(|n| n == "EnterPlanMode"),
"EnterPlanMode should be registered when plan.enabled"
);
assert!(
names.iter().any(|n| n == "ExitPlanMode"),
"ExitPlanMode should be registered when plan.enabled"
);
}
#[tokio::test]
async fn bootstrap_no_plan_tools_when_disabled() {
let mut config = minimal_config();
config.plan.enabled = false;
let result = AgentBootstrap::new(config, "/tmp/test-workspace", null_output())
.build()
.await
.unwrap();
let names = result.engine.tool_names();
assert!(
!names.iter().any(|n| n == "EnterPlanMode"),
"EnterPlanMode should NOT be registered when plan.disabled"
);
}
#[tokio::test]
async fn bootstrap_no_mcp_when_no_servers() {
let config = minimal_config();
let result = AgentBootstrap::new(config, "/tmp/test-workspace", null_output())
.build()
.await
.unwrap();
assert!(!result.has_mcp);
assert!(result.mcp_managers.is_empty());
}
#[tokio::test]
async fn bootstrap_with_custom_system_prompt() {
let mut config = minimal_config();
config.system_prompt = Some("You are a pirate assistant.".into());
let _result = AgentBootstrap::new(config, "/tmp/test-workspace", null_output())
.build()
.await
.unwrap();
}
#[tokio::test]
async fn bootstrap_with_agents_md_in_workspace() {
let tmp = tempfile::TempDir::new().unwrap();
let workspace = tmp.path();
std::fs::write(workspace.join("AGENTS.md"), "PROJECT_RULES_MARKER").unwrap();
let config = minimal_config();
let _result = AgentBootstrap::new(config, workspace.to_string_lossy().as_ref(), null_output())
.build()
.await
.unwrap();
}
#[tokio::test]
async fn bootstrap_config_accessor_returns_config() {
let config = minimal_config();
let bootstrap = AgentBootstrap::new(config, "/tmp/ws", null_output());
assert_eq!(bootstrap.config().model, "gpt-test-model");
assert_eq!(bootstrap.config().max_tokens, 1024);
}
#[tokio::test]
async fn bootstrap_with_external_provider() {
let config = minimal_config();
let provider = nomi_providers::create_provider(&config);
let result = AgentBootstrap::new(config, "/tmp/test-workspace", null_output())
.provider(provider)
.build()
.await
.unwrap();
assert!(!result.engine.tool_names().is_empty());
}
@@ -0,0 +1,279 @@
// Shared test utilities for integration tests.
#![allow(dead_code)]
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use serde_json::{Value, json};
use tokio::sync::mpsc;
use nomi_agent::confirm::ToolConfirmer;
use nomi_config::config::{Config, ProviderType, SessionConfig, ToolsConfig};
use nomi_config::hooks::HooksConfig;
use nomi_mcp::config::McpConfig;
use nomi_protocol::events::ToolCategory;
use nomi_providers::{LlmProvider, ProviderError};
use nomi_tools::Tool;
use nomi_types::llm::{LlmEvent, LlmRequest};
use nomi_types::message::{StopReason, TokenUsage};
use nomi_types::tool::ToolResult;
// ---------------------------------------------------------------------------
// MockLlmProvider — deterministic LLM for engine / spawn tests
// ---------------------------------------------------------------------------
/// A mock LLM provider that emits a pre-configured sequence of events.
/// Each call to `stream` pops the first sequence from `responses`.
/// When `responses` is empty it falls back to a single EndTurn with empty text.
pub struct MockLlmProvider {
responses: Mutex<Vec<Vec<LlmEvent>>>,
}
impl MockLlmProvider {
/// Create a provider that returns a single text response then ends.
pub fn with_text_response(text: &str) -> Self {
let events = vec![
LlmEvent::TextDelta(text.to_string()),
LlmEvent::Done {
stop_reason: StopReason::EndTurn,
usage: TokenUsage {
input_tokens: 100,
output_tokens: 50,
cache_creation_tokens: 0,
cache_read_tokens: 0,
},
},
];
Self {
responses: Mutex::new(vec![events]),
}
}
/// Create a provider that returns a single tool_use then ends with ToolUse stop reason.
pub fn with_tool_use(id: &str, name: &str, input: Value) -> Self {
let events = vec![
LlmEvent::ToolUse {
id: id.to_string(),
name: name.to_string(),
input,
extra: None,
},
LlmEvent::Done {
stop_reason: StopReason::ToolUse,
usage: TokenUsage {
input_tokens: 80,
output_tokens: 30,
cache_creation_tokens: 0,
cache_read_tokens: 0,
},
},
];
Self {
responses: Mutex::new(vec![events]),
}
}
/// Create a provider with multiple turns of pre-configured event sequences.
/// Each call to `stream` consumes the next sequence.
pub fn with_turns(turns: Vec<Vec<LlmEvent>>) -> Self {
Self {
responses: Mutex::new(turns),
}
}
/// Create a provider that returns custom events.
pub fn with_events(events: Vec<LlmEvent>) -> Self {
Self {
responses: Mutex::new(vec![events]),
}
}
}
#[async_trait]
impl LlmProvider for MockLlmProvider {
async fn stream(
&self,
_request: &LlmRequest,
) -> Result<mpsc::Receiver<LlmEvent>, ProviderError> {
let events = {
let mut responses = self.responses.lock().unwrap();
if responses.is_empty() {
// Fallback: end turn with empty text
vec![LlmEvent::Done {
stop_reason: StopReason::EndTurn,
usage: TokenUsage::default(),
}]
} else {
responses.remove(0)
}
};
let (tx, rx) = mpsc::channel(64);
tokio::spawn(async move {
for event in events {
let _ = tx.send(event).await;
}
});
Ok(rx)
}
}
// ---------------------------------------------------------------------------
// MockTool — deterministic tool for orchestration tests
// ---------------------------------------------------------------------------
/// A simple mock tool that returns a pre-configured result.
pub struct MockTool {
pub tool_name: String,
pub tool_description: String,
pub concurrent_safe: bool,
pub result: Mutex<ToolResult>,
}
impl MockTool {
pub fn new(name: &str, result: &str, is_error: bool) -> Self {
Self {
tool_name: name.to_string(),
tool_description: format!("Mock tool: {}", name),
concurrent_safe: true,
result: Mutex::new(ToolResult {
content: result.to_string(),
is_error,
images: Vec::new(),
}),
}
}
pub fn sequential(name: &str, result: &str) -> Self {
Self {
tool_name: name.to_string(),
tool_description: format!("Mock sequential tool: {}", name),
concurrent_safe: false,
result: Mutex::new(ToolResult {
content: result.to_string(),
is_error: false,
images: Vec::new(),
}),
}
}
}
#[async_trait]
impl Tool for MockTool {
fn name(&self) -> &str {
&self.tool_name
}
fn description(&self) -> &str {
&self.tool_description
}
fn input_schema(&self) -> Value {
json!({"type": "object"})
}
fn category(&self) -> ToolCategory {
ToolCategory::Info
}
fn is_concurrency_safe(&self, _input: &Value) -> bool {
self.concurrent_safe
}
async fn execute(&self, _input: Value) -> ToolResult {
self.result.lock().unwrap().clone()
}
}
// ---------------------------------------------------------------------------
// ExecMockTool — mock tool with Exec category (requires approval)
// ---------------------------------------------------------------------------
/// A mock tool that returns a pre-configured result, with Exec category.
pub struct ExecMockTool {
pub tool_name: String,
pub result: Mutex<ToolResult>,
}
impl ExecMockTool {
pub fn new(name: &str, result: &str) -> Self {
Self {
tool_name: name.to_string(),
result: Mutex::new(ToolResult {
content: result.to_string(),
is_error: false,
images: Vec::new(),
}),
}
}
}
#[async_trait]
impl Tool for ExecMockTool {
fn name(&self) -> &str {
&self.tool_name
}
fn description(&self) -> &str {
"Mock exec tool"
}
fn input_schema(&self) -> Value {
json!({"type": "object"})
}
fn category(&self) -> ToolCategory {
ToolCategory::Exec
}
fn is_concurrency_safe(&self, _input: &Value) -> bool {
false
}
async fn execute(&self, _input: Value) -> ToolResult {
self.result.lock().unwrap().clone()
}
}
// ---------------------------------------------------------------------------
// Helper: build a minimal Config for testing
// ---------------------------------------------------------------------------
pub fn test_config() -> Config {
Config {
provider_label: "anthropic".to_string(),
provider: ProviderType::Anthropic,
api_key: "test-key".to_string(),
base_url: "http://localhost:0".to_string(),
model: "test-model".to_string(),
max_tokens: 4096,
max_turns: Some(10),
system_prompt: Some("You are a test assistant.".to_string()),
thinking: None,
prompt_caching: false,
compat: nomi_config::compat::ProviderCompat::anthropic_defaults(),
tools: ToolsConfig {
auto_approve: true,
allow_list: vec![],
..ToolsConfig::default()
},
session: SessionConfig {
enabled: false,
directory: "/tmp/nomi-test-sessions".to_string(),
max_sessions: 5,
},
compact: nomi_config::compact::CompactConfig::default(),
plan: nomi_config::plan::PlanConfig::default(),
file_cache: nomi_config::file_cache::FileCacheConfig::default(),
hooks: HooksConfig::default(),
bedrock: None,
vertex: None,
mcp: McpConfig::default(),
logging: nomi_config::logging::LoggingConfig::default(),
}
}
/// Create a ToolConfirmer that auto-approves everything.
pub fn auto_approve_confirmer() -> Arc<Mutex<ToolConfirmer>> {
Arc::new(Mutex::new(ToolConfirmer::new(true, vec![])))
}
@@ -0,0 +1,93 @@
//! Black-box integration tests for compact types (TC-2.2-04 through TC-2.2-06).
//!
//! These test Message.timestamp serialization and CompactMetadata roundtrip
//! from a consumer's perspective.
use nomi_types::compact::{CompactMetadata, CompactTrigger};
use nomi_types::message::{ContentBlock, Message, Role};
/// TC-2.2-04: Message timestamp serialization — ISO 8601 format.
#[test]
fn tc_2_2_04_message_timestamp_serialization() {
let msg = Message::now(
Role::User,
vec![ContentBlock::Text {
text: "hello".into(),
}],
);
let json = serde_json::to_string(&msg).unwrap();
assert!(
json.contains("\"timestamp\""),
"JSON should contain timestamp"
);
// Verify ISO 8601 format (contains 'T' separator and '+' or 'Z' timezone)
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
let ts_str = value["timestamp"].as_str().unwrap();
assert!(
ts_str.contains('T'),
"timestamp should be ISO 8601 with T separator"
);
}
/// TC-2.2-05: Message timestamp backward compatibility — old JSON without
/// timestamp deserializes with timestamp = None.
#[test]
fn tc_2_2_05_message_timestamp_backward_compat() {
let old_json = r#"{
"role": "user",
"content": [{"type": "text", "text": "hello"}]
}"#;
let msg: Message = serde_json::from_str(old_json).unwrap();
assert!(
msg.timestamp.is_none(),
"old JSON without timestamp should deserialize to None"
);
assert_eq!(msg.role, Role::User);
assert_eq!(msg.content.len(), 1);
}
/// TC-2.2-06: CompactMetadata serialization/deserialization roundtrip.
#[test]
fn tc_2_2_06_compact_metadata_roundtrip() {
let meta = CompactMetadata {
trigger: CompactTrigger::Auto,
pre_compact_tokens: 150_000,
messages_summarized: 42,
};
let json = serde_json::to_value(&meta).unwrap();
let back: CompactMetadata = serde_json::from_value(json).unwrap();
assert_eq!(back.trigger, CompactTrigger::Auto);
assert_eq!(back.pre_compact_tokens, 150_000);
assert_eq!(back.messages_summarized, 42);
}
/// Additional: CompactState circuit breaker integration with CompactConfig.
#[test]
fn compact_state_circuit_breaker_integration() {
use nomi_agent::compact::state::CompactState;
use nomi_config::compact::CompactConfig;
let config = CompactConfig {
max_failures: 3,
..Default::default()
};
let mut state = CompactState::new();
// Not broken initially
assert!(!state.is_circuit_broken(&config));
// Record failures up to the limit
for _ in 0..3 {
state.record_failure();
}
assert!(state.is_circuit_broken(&config));
// One success resets
state.record_success();
assert!(!state.is_circuit_broken(&config));
}
@@ -0,0 +1,88 @@
//! Integration tests verifying that tools use the injected workspace cwd
//! rather than the process working directory.
use std::fs;
use nomi_tools::Tool;
use nomi_tools::bash::BashTool;
use nomi_tools::glob::GlobTool;
use nomi_tools::grep::GrepTool;
use serde_json::json;
use tempfile::tempdir;
// Windows `cd` outputs 8.3 short names (RUNNER~1) that don't match canonicalized paths;
// bash_tool_with_file_operations_uses_correct_cwd covers the same behavior reliably.
#[cfg(not(windows))]
#[tokio::test]
async fn bash_tool_executes_in_injected_cwd_not_process_cwd() {
let workspace = tempdir().unwrap();
let tool = BashTool::new(workspace.path().to_path_buf());
let result = tool.execute(json!({"command": "pwd"})).await;
assert!(!result.is_error, "unexpected error: {}", result.content);
let expected = workspace
.path()
.canonicalize()
.unwrap_or_else(|_| workspace.path().to_path_buf());
assert!(
result.content.contains(expected.to_string_lossy().as_ref()),
"BashTool should run in injected cwd '{}', got: {}",
expected.display(),
result.content
);
}
#[tokio::test]
async fn glob_tool_finds_files_relative_to_injected_cwd() {
let workspace = tempdir().unwrap();
fs::write(workspace.path().join("cwd_marker.txt"), "hello").unwrap();
let tool = GlobTool::new(workspace.path().to_path_buf());
let result = tool.execute(json!({"pattern": "cwd_marker.txt"})).await;
assert!(!result.is_error, "unexpected error: {}", result.content);
assert!(
result.content.contains("cwd_marker.txt"),
"GlobTool should find file relative to injected cwd, got: {}",
result.content
);
}
#[tokio::test]
async fn grep_tool_searches_relative_to_injected_cwd() {
let workspace = tempdir().unwrap();
fs::write(
workspace.path().join("searchable.txt"),
"unique_cwd_injection_marker_99",
)
.unwrap();
let tool = GrepTool::new(workspace.path().to_path_buf());
let result = tool
.execute(json!({"pattern": "unique_cwd_injection_marker_99", "path": "."}))
.await;
assert!(!result.is_error, "unexpected error: {}", result.content);
assert!(
result.content.contains("unique_cwd_injection_marker_99"),
"GrepTool should search in injected cwd, got: {}",
result.content
);
}
#[tokio::test]
async fn bash_tool_with_file_operations_uses_correct_cwd() {
let workspace = tempdir().unwrap();
fs::write(workspace.path().join("canary.txt"), "found_it").unwrap();
let tool = BashTool::new(workspace.path().to_path_buf());
let result = tool.execute(json!({"command": "cat canary.txt"})).await;
assert!(!result.is_error, "unexpected error: {}", result.content);
assert!(
result.content.contains("found_it"),
"BashTool should be able to read files in injected cwd, got: {}",
result.content
);
}
@@ -0,0 +1,127 @@
use std::sync::Arc;
use nomi_agent::engine::AgentEngine;
use nomi_agent::output::OutputSink;
use nomi_agent::output::terminal::TerminalSink;
use nomi_config::compat::ProviderCompat;
use nomi_config::config::{Config, ProviderType, SessionConfig, ToolsConfig};
use nomi_config::hooks::HooksConfig;
use nomi_mcp::config::McpConfig;
use nomi_providers::create_provider;
use nomi_tools::read::ReadTool;
use nomi_tools::registry::ToolRegistry;
/// Skip the test if ANTHROPIC_API_KEY is not set.
fn anthropic_api_key() -> Option<String> {
std::env::var("ANTHROPIC_API_KEY")
.ok()
.filter(|k| !k.is_empty())
}
fn anthropic_config(api_key: &str) -> Config {
Config {
provider: ProviderType::Anthropic,
provider_label: "anthropic".to_string(),
api_key: api_key.to_string(),
base_url: "https://api.anthropic.com".to_string(),
model: "claude-haiku-4-20250514".to_string(), // cheapest for e2e
max_tokens: 256,
max_turns: Some(3),
system_prompt: Some("You are a helpful assistant. Be concise.".to_string()),
thinking: None,
prompt_caching: false,
compat: ProviderCompat::anthropic_defaults(),
tools: ToolsConfig {
auto_approve: true,
allow_list: vec![],
..ToolsConfig::default()
},
session: SessionConfig {
enabled: false,
directory: "/tmp".to_string(),
max_sessions: 1,
},
compact: nomi_config::compact::CompactConfig::default(),
plan: nomi_config::plan::PlanConfig::default(),
file_cache: nomi_config::file_cache::FileCacheConfig::default(),
hooks: HooksConfig::default(),
bedrock: None,
vertex: None,
mcp: McpConfig::default(),
logging: nomi_config::logging::LoggingConfig::default(),
}
}
/// Smoke test: single-turn text completion returns non-empty text.
#[tokio::test]
async fn test_anthropic_single_turn_completion() {
let Some(api_key) = anthropic_api_key() else {
eprintln!("[e2e] ANTHROPIC_API_KEY not set — skipping");
return;
};
let config = anthropic_config(&api_key);
let provider = create_provider(&config);
let output: Arc<dyn OutputSink> = Arc::new(TerminalSink::new(true));
let registry = ToolRegistry::new();
let mut engine =
AgentEngine::new_with_provider(provider, config, registry, output, std::env::temp_dir());
let result = engine
.run("Say 'hello world' and nothing else.", "")
.await
.expect("engine.run should not fail for a valid request");
assert!(!result.text.is_empty(), "response text should not be empty");
assert!(result.turns >= 1, "should complete in at least 1 turn");
assert!(result.usage.output_tokens > 0, "should have output tokens");
eprintln!(
"[e2e] anthropic single-turn: {} tokens in / {} out",
result.usage.input_tokens, result.usage.output_tokens
);
}
/// Tool-use smoke test: agent calls Read tool when asked to read a file.
#[tokio::test]
async fn test_anthropic_tool_use() {
let Some(api_key) = anthropic_api_key() else {
eprintln!("[e2e] ANTHROPIC_API_KEY not set — skipping");
return;
};
// Write a temp file to read
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
std::fs::write(tmp.path(), "e2e-test-content-42").expect("write tempfile");
let path = tmp.path().to_string_lossy().to_string();
let config = anthropic_config(&api_key);
let provider = create_provider(&config);
let output: Arc<dyn OutputSink> = Arc::new(TerminalSink::new(true));
let mut registry = ToolRegistry::new();
registry.register(Box::new(ReadTool::new(None, None)));
let mut engine =
AgentEngine::new_with_provider(provider, config, registry, output, std::env::temp_dir());
let prompt = format!(
"Read the file at path '{}' and tell me what it contains. Be brief.",
path
);
let result = engine
.run(&prompt, "")
.await
.expect("engine.run should not fail");
assert!(!result.text.is_empty(), "response text should not be empty");
// The model should have called Read and seen our content
assert!(
result.text.contains("e2e-test-content-42") || result.turns > 1,
"model should either echo the content or have used multiple turns (tool call): {}",
result.text
);
eprintln!(
"[e2e] anthropic tool-use: {} turns, {} tokens out",
result.turns, result.usage.output_tokens
);
}
@@ -0,0 +1,399 @@
use std::sync::{Arc, Mutex};
use nomi_agent::confirm::ToolConfirmer;
use nomi_agent::engine::AgentEngine;
use nomi_agent::orchestration::execute_tool_calls;
use nomi_agent::output::OutputSink;
use nomi_agent::output::null_sink::NullSink;
use nomi_compact::CompactionLevel;
use nomi_config::compat::ProviderCompat;
use nomi_config::config::{Config, ProviderType, SessionConfig, ToolsConfig};
use nomi_config::hooks::HooksConfig;
use nomi_mcp::config::McpConfig;
use nomi_providers::create_provider;
use nomi_tools::registry::ToolRegistry;
use nomi_types::message::ContentBlock;
use serde_json::json;
const TEST_OUTPUT: &str = "\x1b[32mSTATUS: OK\x1b[0m\n\n\n\n50%\r100%\nCompiling dep-0 v1.0.0\nCompiling dep-1 v1.0.0\nCompiling dep-2 v1.0.0\nCompiling dep-3 v1.0.0\nCompiling dep-4 v1.0.0\n{\n \"id\": 1,\n \"name\": \"Alice Wonderland\",\n \"email\": \"alice@example.com\",\n \"age\": 30,\n \"address\": \"123 Main Street, Anytown, USA 12345\",\n \"phone\": \"+1-555-0123\"\n}";
const TOON_INPUT: &str =
r#"[{"id":1,"name":"Alice","role":"admin"},{"id":2,"name":"Bob","role":"user"}]"#;
fn openai_api_key() -> Option<String> {
std::env::var("OPENAI_API_KEY")
.ok()
.filter(|k| !k.is_empty())
}
fn openai_config(api_key: &str) -> Config {
Config {
provider: ProviderType::OpenAI,
provider_label: "openai".to_string(),
api_key: api_key.to_string(),
base_url: "https://api.openai.com".to_string(),
model: "gpt-4o-mini".to_string(),
max_tokens: 256,
max_turns: Some(3),
system_prompt: Some(
"You are a helpful assistant. Be concise. Answer exactly what is asked.".to_string(),
),
thinking: None,
prompt_caching: false,
compat: ProviderCompat::openai_defaults(),
tools: ToolsConfig {
auto_approve: true,
allow_list: vec![],
..ToolsConfig::default()
},
session: SessionConfig {
enabled: false,
directory: "/tmp".to_string(),
max_sessions: 1,
},
compact: nomi_config::compact::CompactConfig::default(),
plan: nomi_config::plan::PlanConfig::default(),
file_cache: nomi_config::file_cache::FileCacheConfig::default(),
hooks: HooksConfig::default(),
bedrock: None,
vertex: None,
mcp: McpConfig::default(),
logging: nomi_config::logging::LoggingConfig::default(),
}
}
struct FixedOutputTool {
name: String,
output: String,
}
impl FixedOutputTool {
fn new(name: &str, output: &str) -> Self {
Self {
name: name.to_string(),
output: output.to_string(),
}
}
}
#[async_trait::async_trait]
impl nomi_tools::Tool for FixedOutputTool {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
"Returns fixed output for testing"
}
fn input_schema(&self) -> serde_json::Value {
json!({"type": "object", "properties": {}, "required": []})
}
fn category(&self) -> nomi_protocol::events::ToolCategory {
nomi_protocol::events::ToolCategory::Info
}
fn is_concurrency_safe(&self, _input: &serde_json::Value) -> bool {
true
}
async fn execute(&self, _input: serde_json::Value) -> nomi_types::tool::ToolResult {
nomi_types::tool::ToolResult {
content: self.output.clone(),
is_error: false,
images: Vec::new(),
}
}
}
fn extract_tool_result_content(blocks: &[ContentBlock]) -> Option<String> {
for block in blocks {
if let ContentBlock::ToolResult { content, .. } = block {
return Some(content.clone());
}
}
None
}
// ---------------------------------------------------------------------------
// C Layer: Case 9 (Off vs Safe content comparison)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn case_9_off_vs_safe_content() {
let Some(api_key) = openai_api_key() else {
eprintln!("[e2e:compaction] OPENAI_API_KEY not set — skipping");
return;
};
eprintln!("[e2e:compaction] === Case 9: Off vs Safe content comparison ===");
let confirmer = Arc::new(Mutex::new(ToolConfirmer::new(true, vec![])));
let mut registry = ToolRegistry::new();
registry.register(Box::new(FixedOutputTool::new("check_tool", TEST_OUTPUT)));
let tool_calls = vec![ContentBlock::ToolUse {
id: "t1".to_string(),
name: "check_tool".to_string(),
input: json!({}),
extra: None,
}];
// Off
let outcome_off = execute_tool_calls(
&registry,
&tool_calls,
&confirmer,
None,
CompactionLevel::Off,
false,
)
.await
.expect("should succeed");
let content_off = extract_tool_result_content(&outcome_off).unwrap();
// Safe
let outcome_safe = execute_tool_calls(
&registry,
&tool_calls,
&confirmer,
None,
CompactionLevel::Safe,
false,
)
.await
.expect("should succeed");
let content_safe = extract_tool_result_content(&outcome_safe).unwrap();
eprintln!("[e2e:compaction] Off content ({} chars)", content_off.len());
eprintln!(
"[e2e:compaction] Safe content ({} chars)",
content_safe.len()
);
assert!(
content_off.contains("\x1b"),
"Off should preserve ANSI escapes"
);
assert!(
!content_safe.contains("\x1b"),
"Safe should strip ANSI escapes"
);
// LLM question (secondary evidence)
let mut config = openai_config(&api_key);
config.compact.compaction = CompactionLevel::Safe;
let provider = create_provider(&config);
let mut registry2 = ToolRegistry::new();
registry2.register(Box::new(FixedOutputTool::new("check_tool", TEST_OUTPUT)));
let output: Arc<dyn OutputSink> = Arc::new(NullSink);
let mut engine =
AgentEngine::new_with_provider(provider, config, registry2, output, std::env::temp_dir());
let prompt = "Call check_tool, then answer: does the tool output contain ANSI color escape codes (sequences starting with \\x1b)? Answer only 'yes' or 'no'.";
let result = engine
.run(prompt, "")
.await
.expect("engine.run should succeed");
eprintln!("[e2e:compaction] LLM question: does Safe output contain ANSI?");
eprintln!("[e2e:compaction] LLM answer: {}", result.text);
eprintln!(
"[e2e:compaction] Token usage: {} input / {} output",
result.usage.input_tokens, result.usage.output_tokens
);
let answer = result.text.to_lowercase();
if answer.contains("no") {
eprintln!("[e2e:compaction] ✓ LLM confirms no ANSI in Safe output");
} else {
eprintln!(
"[e2e:compaction] ⚠ LLM answer unexpected (non-deterministic, logged for review)"
);
}
eprintln!("[e2e:compaction] ✓ PASS (primary: content assertions passed)");
}
// ---------------------------------------------------------------------------
// C Layer: Case 10 (Off vs Full token savings)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn case_10_off_vs_full_token_savings() {
let Some(api_key) = openai_api_key() else {
eprintln!("[e2e:compaction] OPENAI_API_KEY not set — skipping");
return;
};
eprintln!("[e2e:compaction] === Case 10: Off vs Full token savings ===");
let mut large_output = String::new();
for i in 0..20 {
large_output.push_str(&format!(
"Compiling dependency-{i} v0.1.0 (registry+https://github.com/rust-lang/crates.io-index)\n"
));
}
large_output.push_str("{\n \"users\": [\n");
for i in 0..10 {
large_output.push_str(&format!(
" {{\n \"id\": {i},\n \"name\": \"User {i}\",\n \"email\": \"user{i}@example.com\"\n }}{}\n",
if i < 9 { "," } else { "" }
));
}
large_output.push_str(" ]\n}");
// Off
let mut config_off = openai_config(&api_key);
config_off.compact.compaction = CompactionLevel::Off;
let provider_off = create_provider(&config_off);
let mut registry_off = ToolRegistry::new();
registry_off.register(Box::new(FixedOutputTool::new("big_tool", &large_output)));
let output_off: Arc<dyn OutputSink> = Arc::new(NullSink);
let mut engine_off = AgentEngine::new_with_provider(
provider_off,
config_off,
registry_off,
output_off,
std::env::temp_dir(),
);
let prompt = "Call big_tool, then say 'done'.";
let result_off = engine_off
.run(prompt, "")
.await
.expect("engine.run should succeed");
// Full
let mut config_full = openai_config(&api_key);
config_full.compact.compaction = CompactionLevel::Full;
let provider_full = create_provider(&config_full);
let mut registry_full = ToolRegistry::new();
registry_full.register(Box::new(FixedOutputTool::new("big_tool", &large_output)));
let output_full: Arc<dyn OutputSink> = Arc::new(NullSink);
let mut engine_full = AgentEngine::new_with_provider(
provider_full,
config_full,
registry_full,
output_full,
std::env::temp_dir(),
);
let result_full = engine_full
.run(prompt, "")
.await
.expect("engine.run should succeed");
eprintln!(
"[e2e:compaction] Off input_tokens: {}",
result_off.usage.input_tokens
);
eprintln!(
"[e2e:compaction] Full input_tokens: {}",
result_full.usage.input_tokens
);
eprintln!(
"[e2e:compaction] Savings: {} tokens ({:.1}%)",
result_off
.usage
.input_tokens
.saturating_sub(result_full.usage.input_tokens),
if result_off.usage.input_tokens > 0 {
(1.0 - result_full.usage.input_tokens as f64 / result_off.usage.input_tokens as f64)
* 100.0
} else {
0.0
}
);
assert!(
result_full.usage.input_tokens < result_off.usage.input_tokens,
"Full compaction should use fewer input tokens: full={} vs off={}",
result_full.usage.input_tokens,
result_off.usage.input_tokens
);
eprintln!("[e2e:compaction] ✓ PASS");
}
// ---------------------------------------------------------------------------
// C Layer: Case 11 (TOON comprehension + system prompt)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn case_11_toon_comprehension_and_system_prompt() {
let Some(api_key) = openai_api_key() else {
eprintln!("[e2e:compaction] OPENAI_API_KEY not set — skipping");
return;
};
eprintln!("[e2e:compaction] === Case 11: TOON comprehension + system prompt ===");
// Direct content check (deterministic)
let confirmer = Arc::new(Mutex::new(ToolConfirmer::new(true, vec![])));
let mut registry_check = ToolRegistry::new();
registry_check.register(Box::new(FixedOutputTool::new("data_tool", TOON_INPUT)));
let tool_calls = vec![ContentBlock::ToolUse {
id: "t1".to_string(),
name: "data_tool".to_string(),
input: json!({}),
extra: None,
}];
let outcome = execute_tool_calls(
&registry_check,
&tool_calls,
&confirmer,
None,
CompactionLevel::Full,
true,
)
.await
.expect("should succeed");
let content = extract_tool_result_content(&outcome).unwrap();
eprintln!("[e2e:compaction] TOON-encoded content: {content}");
assert!(
content.contains("[2]{id,name,role}:"),
"should contain TOON header: {content}"
);
// LLM comprehension test
let mut config = openai_config(&api_key);
config.compact.compaction = CompactionLevel::Full;
config.compact.toon = true;
let provider = create_provider(&config);
let mut registry = ToolRegistry::new();
registry.register(Box::new(FixedOutputTool::new("data_tool", TOON_INPUT)));
let output: Arc<dyn OutputSink> = Arc::new(NullSink);
let mut engine =
AgentEngine::new_with_provider(provider, config, registry, output, std::env::temp_dir());
let prompt = "Call data_tool, then answer: what is the name of the second record? Answer with just the name, nothing else.";
let result = engine
.run(prompt, "")
.await
.expect("engine.run should succeed");
eprintln!("[e2e:compaction] LLM question: name of second record?");
eprintln!("[e2e:compaction] LLM answer: {}", result.text);
eprintln!(
"[e2e:compaction] Token usage: {} input / {} output",
result.usage.input_tokens, result.usage.output_tokens
);
let answer = result.text.to_lowercase();
if answer.contains("bob") {
eprintln!("[e2e:compaction] ✓ LLM correctly understood TOON format");
} else {
eprintln!(
"[e2e:compaction] ⚠ LLM answer: '{}' (expected 'Bob', logged for review)",
result.text
);
}
eprintln!("[e2e:compaction] ✓ PASS (primary: TOON content assertion passed)");
}
@@ -0,0 +1,15 @@
/// End-to-end tests that hit real LLM provider APIs.
///
/// These tests are skipped when the required environment variable is absent,
/// making them safe to compile and run in any environment while still providing
/// full coverage in CI when secrets are available.
///
/// Required env vars (at least one):
/// ANTHROPIC_API_KEY — runs Anthropic provider tests
/// OPENAI_API_KEY — runs OpenAI provider tests
///
/// Run manually:
/// ANTHROPIC_API_KEY=sk-ant-... cargo test -p nomi-agent --test e2e -- --nocapture
mod anthropic;
mod compaction;
mod openai;
@@ -0,0 +1,123 @@
use std::sync::Arc;
use nomi_agent::engine::AgentEngine;
use nomi_agent::output::OutputSink;
use nomi_agent::output::terminal::TerminalSink;
use nomi_config::compat::ProviderCompat;
use nomi_config::config::{Config, ProviderType, SessionConfig, ToolsConfig};
use nomi_config::hooks::HooksConfig;
use nomi_mcp::config::McpConfig;
use nomi_providers::create_provider;
use nomi_tools::read::ReadTool;
use nomi_tools::registry::ToolRegistry;
fn openai_api_key() -> Option<String> {
std::env::var("OPENAI_API_KEY")
.ok()
.filter(|k| !k.is_empty())
}
fn openai_config(api_key: &str) -> Config {
Config {
provider: ProviderType::OpenAI,
provider_label: "openai".to_string(),
api_key: api_key.to_string(),
base_url: "https://api.openai.com".to_string(),
model: "gpt-4o-mini".to_string(), // cheapest for e2e
max_tokens: 256,
max_turns: Some(3),
system_prompt: Some("You are a helpful assistant. Be concise.".to_string()),
thinking: None,
prompt_caching: false,
compat: ProviderCompat::openai_defaults(),
tools: ToolsConfig {
auto_approve: true,
allow_list: vec![],
..ToolsConfig::default()
},
session: SessionConfig {
enabled: false,
directory: "/tmp".to_string(),
max_sessions: 1,
},
compact: nomi_config::compact::CompactConfig::default(),
plan: nomi_config::plan::PlanConfig::default(),
file_cache: nomi_config::file_cache::FileCacheConfig::default(),
hooks: HooksConfig::default(),
bedrock: None,
vertex: None,
mcp: McpConfig::default(),
logging: nomi_config::logging::LoggingConfig::default(),
}
}
/// Smoke test: single-turn text completion.
#[tokio::test]
async fn test_openai_single_turn_completion() {
let Some(api_key) = openai_api_key() else {
eprintln!("[e2e] OPENAI_API_KEY not set — skipping");
return;
};
let config = openai_config(&api_key);
let provider = create_provider(&config);
let output: Arc<dyn OutputSink> = Arc::new(TerminalSink::new(true));
let registry = ToolRegistry::new();
let mut engine =
AgentEngine::new_with_provider(provider, config, registry, output, std::env::temp_dir());
let result = engine
.run("Say 'hello world' and nothing else.", "")
.await
.expect("engine.run should not fail");
assert!(!result.text.is_empty(), "response text should not be empty");
assert!(result.usage.output_tokens > 0);
eprintln!(
"[e2e] openai single-turn: {} tokens in / {} out",
result.usage.input_tokens, result.usage.output_tokens
);
}
/// Tool-use smoke test: agent calls Read tool when asked to read a file.
#[tokio::test]
async fn test_openai_tool_use() {
let Some(api_key) = openai_api_key() else {
eprintln!("[e2e] OPENAI_API_KEY not set — skipping");
return;
};
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
std::fs::write(tmp.path(), "e2e-openai-content-99").expect("write tempfile");
let path = tmp.path().to_string_lossy().to_string();
let config = openai_config(&api_key);
let provider = create_provider(&config);
let output: Arc<dyn OutputSink> = Arc::new(TerminalSink::new(true));
let mut registry = ToolRegistry::new();
registry.register(Box::new(ReadTool::new(None, None)));
let mut engine =
AgentEngine::new_with_provider(provider, config, registry, output, std::env::temp_dir());
let prompt = format!(
"Read the file at '{}' and tell me what it contains. Be brief.",
path
);
let result = engine
.run(&prompt, "")
.await
.expect("engine.run should not fail");
assert!(!result.text.is_empty());
assert!(
result.text.contains("e2e-openai-content-99") || result.turns > 1,
"model should echo the content or use multiple turns: {}",
result.text
);
eprintln!(
"[e2e] openai tool-use: {} turns, {} tokens out",
result.turns, result.usage.output_tokens
);
}
@@ -0,0 +1,123 @@
//! Black-box integration tests for emergency truncation (TC-2.5-01 .. TC-2.5-04).
//!
//! These tests treat `is_at_emergency_limit` as a public API and verify
//! functional requirements from test-plan.md without relying on internal details.
use nomi_agent::compact::emergency::{EMERGENCY_USER_MESSAGE, is_at_emergency_limit};
use nomi_config::compact::CompactConfig;
// ── TC-2.5-01: Below emergency threshold ───────────────────────────────────
#[test]
fn tc_2_5_01_below_emergency_threshold() {
// context_window=200_000, emergency_buffer=3_000
// emergency_limit = 200k - 3k = 197k
// 190k < 197k → false
let config = CompactConfig::default();
assert!(
!is_at_emergency_limit(190_000, &config),
"190k tokens should be below the 197k emergency limit"
);
}
// ── TC-2.5-02: Above emergency threshold ───────────────────────────────────
#[test]
fn tc_2_5_02_above_emergency_threshold() {
// 198k >= 197k → true
let config = CompactConfig::default();
assert!(
is_at_emergency_limit(198_000, &config),
"198k tokens should exceed the 197k emergency limit"
);
}
// ── TC-2.5-03: Exactly at emergency threshold ──────────────────────────────
#[test]
fn tc_2_5_03_at_exact_emergency_threshold() {
// 197k >= 197k → true
let config = CompactConfig::default();
assert!(
is_at_emergency_limit(197_000, &config),
"197k tokens should trigger at exactly the emergency limit"
);
}
// ── TC-2.5-04: Small context window ────────────────────────────────────────
#[test]
fn tc_2_5_04_small_context_window() {
// context_window=8_000, emergency_buffer=3_000
// emergency_limit = 8k - 3k = 5k
// 6k >= 5k → true
let config = CompactConfig {
context_window: 8_000,
emergency_buffer: 3_000,
..CompactConfig::default()
};
assert!(
is_at_emergency_limit(6_000, &config),
"6k tokens should exceed 5k emergency limit on an 8k context window"
);
}
// ── Additional integration-level checks ────────────────────────────────────
#[test]
fn emergency_check_ignores_enabled_flag() {
// Emergency is the safety net — it fires even when compact is disabled
let config = CompactConfig {
enabled: false,
..CompactConfig::default()
};
assert!(
is_at_emergency_limit(198_000, &config),
"emergency check must fire regardless of the enabled flag"
);
}
#[test]
fn user_message_is_actionable() {
// The message should tell the user what to do
assert!(
EMERGENCY_USER_MESSAGE.contains("/compact"),
"emergency message should mention /compact"
);
assert!(
EMERGENCY_USER_MESSAGE.contains("new conversation"),
"emergency message should mention starting a new conversation"
);
}
#[test]
fn autocompact_fires_before_emergency() {
// Verify that the autocompact threshold is lower than the emergency limit
// so autocompact gets a chance to run before the safety net kicks in.
use nomi_agent::compact::auto::should_autocompact;
let config = CompactConfig::default();
// Pick a token count that triggers autocompact but not emergency
let token_count: u64 = 170_000;
let autocompact_triggers = should_autocompact(token_count, &config);
let emergency_triggers = is_at_emergency_limit(token_count, &config);
assert!(
autocompact_triggers && !emergency_triggers,
"at 170k tokens, autocompact should trigger (threshold 167k) \
but emergency should not (limit 197k)"
);
}
#[test]
fn both_trigger_near_limit() {
// When very close to the limit, both autocompact and emergency should fire
use nomi_agent::compact::auto::should_autocompact;
let config = CompactConfig::default();
let token_count: u64 = 198_000;
assert!(should_autocompact(token_count, &config));
assert!(is_at_emergency_limit(token_count, &config));
}
@@ -0,0 +1,878 @@
//! Black-box integration tests for engine compaction integration (TC-2.6-*).
//!
//! These tests exercise the full `AgentEngine::run()` loop and verify
//! that the compaction pipeline (microcompact → autocompact → emergency)
//! is correctly wired into the agentic loop.
mod common;
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use tokio::sync::mpsc;
use nomi_agent::engine::{AgentEngine, AgentError};
use nomi_agent::output::OutputSink;
use nomi_agent::output::terminal::TerminalSink;
use nomi_agent::session::SessionManager;
use nomi_config::compact::CompactConfig;
use nomi_providers::{LlmProvider, ProviderError};
use nomi_tools::registry::ToolRegistry;
use nomi_types::llm::{LlmEvent, LlmRequest};
use nomi_types::message::{StopReason, TokenUsage};
use tempfile::tempdir;
use common::test_config;
// ── Helpers ────────────────────────────────────────────────────────────────
fn silent_output() -> Arc<dyn OutputSink> {
Arc::new(TerminalSink::new(true))
}
/// A mock provider that returns configurable per-turn events.
/// Tracks the number of stream() calls for order verification.
struct CompactMockProvider {
turns: Mutex<VecDeque<Vec<LlmEvent>>>,
call_count: Mutex<usize>,
}
impl CompactMockProvider {
fn new(turns: Vec<Vec<LlmEvent>>) -> Self {
Self {
turns: Mutex::new(VecDeque::from(turns)),
call_count: Mutex::new(0),
}
}
fn call_count(&self) -> usize {
*self.call_count.lock().unwrap()
}
}
#[async_trait]
impl LlmProvider for CompactMockProvider {
async fn stream(
&self,
_request: &LlmRequest,
) -> Result<mpsc::Receiver<LlmEvent>, ProviderError> {
*self.call_count.lock().unwrap() += 1;
let events = self.turns.lock().unwrap().pop_front().unwrap_or_else(|| {
vec![LlmEvent::Done {
stop_reason: StopReason::EndTurn,
usage: TokenUsage::default(),
}]
});
let (tx, rx) = mpsc::channel(64);
tokio::spawn(async move {
for event in events {
let _ = tx.send(event).await;
}
});
Ok(rx)
}
}
/// Build events for a simple text response with configurable input_tokens.
fn text_turn(text: &str, input_tokens: u64) -> Vec<LlmEvent> {
vec![
LlmEvent::TextDelta(text.to_string()),
LlmEvent::Done {
stop_reason: StopReason::EndTurn,
usage: TokenUsage {
input_tokens,
output_tokens: 100,
..Default::default()
},
},
]
}
/// Build events for a summary LLM call (used by autocompact internally).
fn summary_turn(summary_text: &str) -> Vec<LlmEvent> {
vec![
LlmEvent::TextDelta(summary_text.to_string()),
LlmEvent::Done {
stop_reason: StopReason::EndTurn,
usage: TokenUsage {
input_tokens: 5_000,
output_tokens: 2_000,
..Default::default()
},
},
]
}
// ── TC-2.6-01: First turn does not trigger compaction ──────────────────────
#[tokio::test]
async fn tc_2_6_01_first_turn_no_compaction() {
// On the first turn last_input_tokens is 0, so neither autocompact
// nor emergency should fire.
let provider = Arc::new(CompactMockProvider::new(vec![text_turn("Hello", 50_000)]));
let config = test_config();
let registry = ToolRegistry::new();
let output = silent_output();
let mut engine = AgentEngine::new_with_provider(
provider.clone(),
config,
registry,
output,
std::env::temp_dir(),
);
let result = engine.run("Hi", "msg-1").await.expect("should succeed");
assert_eq!(result.text, "Hello");
assert_eq!(result.turns, 1);
// Only one call to stream() — no compaction call
assert_eq!(provider.call_count(), 1);
}
// ── TC-2.6-03: Emergency truncation returns error ──────────────────────────
#[tokio::test]
async fn tc_2_6_03_emergency_returns_error() {
// Emergency is the last safety net — it fires when autocompact is
// disabled or circuit-broken. We disable compact so only emergency
// is active, then push input_tokens above the emergency limit.
//
// Turn 1: tool use, returns input_tokens above emergency threshold
// Turn 2: emergency fires before the API call → ContextTooLong
let turn1 = vec![
LlmEvent::ToolUse {
id: "t1".to_string(),
name: "mock_tool".to_string(),
input: serde_json::json!({}),
extra: None,
},
LlmEvent::Done {
stop_reason: StopReason::ToolUse,
usage: TokenUsage {
input_tokens: 198_000, // above emergency limit (197k)
output_tokens: 100,
..Default::default()
},
},
];
// Turn 2 events are queued but should never be consumed
let turn2 = text_turn("Should not reach", 50_000);
let provider = Arc::new(CompactMockProvider::new(vec![turn1, turn2]));
let mut config = test_config();
config.compact.enabled = false; // disable auto/micro so emergency is the only gate
config.compact.context_window = 200_000;
config.compact.emergency_buffer = 3_000;
let mut registry = ToolRegistry::new();
registry.register(Box::new(common::MockTool::new(
"mock_tool",
"result",
false,
)));
let output = silent_output();
let mut engine = AgentEngine::new_with_provider(
provider.clone(),
config,
registry,
output,
std::env::temp_dir(),
);
let err = engine.run("Do something", "msg-1").await.unwrap_err();
match err {
AgentError::ContextTooLong {
input_tokens,
limit,
} => {
assert_eq!(input_tokens, 198_000);
assert_eq!(limit, 197_000);
}
other => panic!("expected ContextTooLong, got: {:?}", other),
}
// Only one call to stream() — second call blocked by emergency
assert_eq!(provider.call_count(), 1);
}
// ── TC-2.6-04: Autocompact then continue ───────────────────────────────────
#[tokio::test]
async fn tc_2_6_04_autocompact_then_continue() {
// Turn 1: tool use, returns input_tokens=170k (above autocompact threshold 167k)
// Before turn 2: autocompact fires → LLM summary call → messages replaced
// Turn 2 (after compact): text response with low input_tokens
let turn1 = vec![
LlmEvent::ToolUse {
id: "t1".to_string(),
name: "mock_tool".to_string(),
input: serde_json::json!({}),
extra: None,
},
LlmEvent::Done {
stop_reason: StopReason::ToolUse,
usage: TokenUsage {
input_tokens: 170_000,
output_tokens: 100,
..Default::default()
},
},
];
let compact_summary = summary_turn("<summary>Conversation summary</summary>");
let turn2_after_compact = text_turn("Continuing after compact", 10_000);
let provider = Arc::new(CompactMockProvider::new(vec![
turn1,
compact_summary,
turn2_after_compact,
]));
let mut config = test_config();
config.compact = CompactConfig::default();
let mut registry = ToolRegistry::new();
registry.register(Box::new(common::MockTool::new(
"mock_tool",
"result",
false,
)));
let output = silent_output();
let mut engine = AgentEngine::new_with_provider(
provider.clone(),
config,
registry,
output,
std::env::temp_dir(),
);
let result = engine
.run("Start work", "msg-1")
.await
.expect("should succeed after compact");
assert_eq!(result.text, "Continuing after compact");
assert_eq!(result.turns, 2);
// 3 calls: turn1 + compact summary + turn2
assert_eq!(provider.call_count(), 3);
}
// ── TC-2.6-05: Session save includes compacted messages ────────────────────
#[tokio::test]
async fn tc_2_6_05_session_save_after_compact() {
let dir = tempdir().expect("tempdir");
let turn1 = vec![
LlmEvent::ToolUse {
id: "t1".to_string(),
name: "mock_tool".to_string(),
input: serde_json::json!({}),
extra: None,
},
LlmEvent::Done {
stop_reason: StopReason::ToolUse,
usage: TokenUsage {
input_tokens: 170_000,
output_tokens: 100,
..Default::default()
},
},
];
let compact_summary = summary_turn("<summary>Session summary</summary>");
let turn2 = text_turn("After compact", 10_000);
let provider = Arc::new(CompactMockProvider::new(vec![
turn1,
compact_summary,
turn2,
]));
let mut config = test_config();
config.compact = CompactConfig::default();
config.session.enabled = true;
config.session.directory = dir.path().to_string_lossy().into_owned();
let mut registry = ToolRegistry::new();
registry.register(Box::new(common::MockTool::new(
"mock_tool",
"result",
false,
)));
let output = silent_output();
let mut engine =
AgentEngine::new_with_provider(provider, config, registry, output, std::env::temp_dir());
engine
.init_session("test", "/tmp", None)
.expect("init session");
engine.run("Start", "msg-1").await.expect("should succeed");
// Load the saved session
let mgr = SessionManager::new(dir.path().to_path_buf(), 10);
let session = mgr.load("latest").expect("load session");
// After compaction + turn2, messages should include the compact boundary,
// summary, and the post-compact assistant/user messages.
// The exact count depends on implementation, but should be small (not
// the full pre-compact count).
assert!(
session.messages.len() < 10,
"session should have compacted messages, got {}",
session.messages.len()
);
// Verify at least one message contains compact boundary marker
let has_boundary = session.messages.iter().any(|m| {
m.content.iter().any(|b| {
matches!(b, nomi_types::message::ContentBlock::Text { text } if text.contains("[Conversation compacted]"))
})
});
assert!(
has_boundary,
"session should contain compact boundary marker"
);
}
// ── TC-2.6-06: Disabled skips all except emergency ─────────────────────────
#[tokio::test]
async fn tc_2_6_06_disabled_skips_micro_auto() {
// With compact disabled, a text response that reports high usage
// should not trigger autocompact (only emergency if at limit).
let provider = Arc::new(CompactMockProvider::new(vec![
// Returns high but not emergency-level tokens
text_turn("Normal response", 170_000),
]));
let mut config = test_config();
config.compact.enabled = false;
let registry = ToolRegistry::new();
let output = silent_output();
let mut engine = AgentEngine::new_with_provider(
provider.clone(),
config,
registry,
output,
std::env::temp_dir(),
);
let result = engine.run("Hi", "msg-1").await.expect("should succeed");
assert_eq!(result.text, "Normal response");
// Only 1 call — no compact summary call
assert_eq!(provider.call_count(), 1);
}
#[tokio::test]
async fn tc_2_6_06b_disabled_still_fires_emergency() {
let turn1 = vec![
LlmEvent::ToolUse {
id: "t1".to_string(),
name: "mock_tool".to_string(),
input: serde_json::json!({}),
extra: None,
},
LlmEvent::Done {
stop_reason: StopReason::ToolUse,
usage: TokenUsage {
input_tokens: 198_000,
output_tokens: 100,
..Default::default()
},
},
];
let provider = Arc::new(CompactMockProvider::new(vec![
turn1,
text_turn("unreachable", 0),
]));
let mut config = test_config();
config.compact.enabled = false;
config.compact.context_window = 200_000;
config.compact.emergency_buffer = 3_000;
let mut registry = ToolRegistry::new();
registry.register(Box::new(common::MockTool::new(
"mock_tool",
"result",
false,
)));
let output = silent_output();
let mut engine =
AgentEngine::new_with_provider(provider, config, registry, output, std::env::temp_dir());
let err = engine.run("Go", "msg-1").await.unwrap_err();
assert!(
matches!(err, AgentError::ContextTooLong { .. }),
"emergency should fire even when disabled"
);
}
// ── TC-2.6-07: input_tokens correctly tracked ──────────────────────────────
#[tokio::test]
async fn tc_2_6_07_input_tokens_tracked() {
// Two turns: first returns 50k tokens, second returns 60k tokens.
// We verify that the engine updates compact state after each turn.
let turn1 = vec![
LlmEvent::ToolUse {
id: "t1".to_string(),
name: "mock_tool".to_string(),
input: serde_json::json!({}),
extra: None,
},
LlmEvent::Done {
stop_reason: StopReason::ToolUse,
usage: TokenUsage {
input_tokens: 50_000,
output_tokens: 100,
..Default::default()
},
},
];
let turn2 = text_turn("Done", 60_000);
let provider = Arc::new(CompactMockProvider::new(vec![turn1, turn2]));
let config = test_config();
let mut registry = ToolRegistry::new();
registry.register(Box::new(common::MockTool::new(
"mock_tool",
"result",
false,
)));
let output = silent_output();
let mut engine =
AgentEngine::new_with_provider(provider, config, registry, output, std::env::temp_dir());
let result = engine.run("Work", "msg-1").await.expect("should succeed");
assert_eq!(result.turns, 2);
// Total usage should accumulate: 50k + 60k = 110k input tokens
assert_eq!(result.usage.input_tokens, 110_000);
}
// ── TC-2.6-02: Execution order — micro before auto ────────────────────────
#[tokio::test]
async fn tc_2_6_02_micro_before_auto_execution_order() {
// Build a scenario where both microcompact and autocompact trigger
// in the same compaction cycle. A custom provider captures the
// messages sent to the autocompact LLM call so we can verify that
// microcompact already cleared old tool results before autocompact
// was invoked.
let captured: Arc<Mutex<Option<Vec<nomi_types::message::Message>>>> =
Arc::new(Mutex::new(None));
let capture_ref = captured.clone();
struct OrderProvider {
regular_count: Mutex<usize>,
captured: Arc<Mutex<Option<Vec<nomi_types::message::Message>>>>,
}
#[async_trait]
impl LlmProvider for OrderProvider {
async fn stream(
&self,
request: &LlmRequest,
) -> Result<mpsc::Receiver<LlmEvent>, ProviderError> {
let is_compact = request.tools.is_empty();
if is_compact {
// Capture messages that autocompact sends to the LLM
*self.captured.lock().unwrap() = Some(request.messages.clone());
let events = vec![
LlmEvent::TextDelta("<summary>Order test summary</summary>".to_string()),
LlmEvent::Done {
stop_reason: StopReason::EndTurn,
usage: TokenUsage {
input_tokens: 5_000,
output_tokens: 2_000,
..Default::default()
},
},
];
let (tx, rx) = mpsc::channel(64);
tokio::spawn(async move {
for e in events {
let _ = tx.send(e).await;
}
});
return Ok(rx);
}
let count = {
let mut c = self.regular_count.lock().unwrap();
let v = *c;
*c += 1;
v
};
// Turns 0-6: tool use. Turn 6 reports high input_tokens
// so that micro and auto both trigger in the SAME cycle
// (turn 7's run_compaction).
// Turn 7 (after compact): text to end the run.
//
// micro_keep_recent = 3 → count threshold = 6.
// After 7 tool-use turns: 7 > 6 → micro fires.
// After turn 6: last_input_tokens = 170k > 167k → auto fires.
let events = if count < 7 {
let input_tokens = if count == 6 { 170_000 } else { 10_000 };
vec![
LlmEvent::ToolUse {
id: format!("t{count}"),
name: "mock_tool".to_string(),
input: serde_json::json!({}),
extra: None,
},
LlmEvent::Done {
stop_reason: StopReason::ToolUse,
usage: TokenUsage {
input_tokens,
output_tokens: 100,
..Default::default()
},
},
]
} else {
vec![
LlmEvent::TextDelta("Done after compact".to_string()),
LlmEvent::Done {
stop_reason: StopReason::EndTurn,
usage: TokenUsage {
input_tokens: 5_000,
output_tokens: 100,
..Default::default()
},
},
]
};
let (tx, rx) = mpsc::channel(64);
tokio::spawn(async move {
for e in events {
let _ = tx.send(e).await;
}
});
Ok(rx)
}
}
let provider = Arc::new(OrderProvider {
regular_count: Mutex::new(0),
captured: capture_ref,
});
let mut config = test_config();
config.compact = CompactConfig {
micro_keep_recent: 3,
compactable_tools: vec!["mock_tool".into()],
context_window: 200_000,
emergency_buffer: 3_000,
..Default::default()
};
config.max_turns = Some(20);
let mut registry = ToolRegistry::new();
registry.register(Box::new(common::MockTool::new(
"mock_tool",
"tool output data",
false,
)));
let output = silent_output();
let mut engine =
AgentEngine::new_with_provider(provider, config, registry, output, std::env::temp_dir());
let result = engine.run("Start", "msg-1").await.expect("should succeed");
assert_eq!(result.text, "Done after compact");
// Verify: the messages that autocompact received should contain
// tool results cleared by microcompact (proving micro ran first
// within the SAME compaction cycle).
let msgs = captured.lock().unwrap();
let msgs = msgs.as_ref().expect("autocompact should have been called");
let cleared_count = msgs
.iter()
.flat_map(|m| m.content.iter())
.filter(|b| {
matches!(
b,
nomi_types::message::ContentBlock::ToolResult { content, .. }
if content == nomi_agent::compact::micro::CLEARED_TOOL_RESULT
)
})
.count();
// 7 tool results total, keep_recent=3 → 4 cleared by micro
// before auto received the messages.
assert_eq!(
cleared_count, 4,
"microcompact should have cleared 4 tool results before autocompact ran"
);
}
// ── TC-2.6-E2E-02: Microcompact + autocompact cooperative scenario ────────
#[tokio::test]
async fn tc_2_6_e2e_02_micro_and_auto_cooperative() {
// Verify that microcompact and autocompact cooperate in the same
// compaction cycle. Microcompact frees some tokens from old tool
// results, and autocompact still fires because the input token
// watermark (which is not reduced by micro) remains above threshold.
let compact_call_count: Arc<Mutex<usize>> = Arc::new(Mutex::new(0));
let counter_ref = compact_call_count.clone();
struct CoopProvider {
regular_count: Mutex<usize>,
compact_calls: Arc<Mutex<usize>>,
}
#[async_trait]
impl LlmProvider for CoopProvider {
async fn stream(
&self,
request: &LlmRequest,
) -> Result<mpsc::Receiver<LlmEvent>, ProviderError> {
let is_compact = request.tools.is_empty();
if is_compact {
*self.compact_calls.lock().unwrap() += 1;
let events = vec![
LlmEvent::TextDelta("<summary>Cooperative summary</summary>".to_string()),
LlmEvent::Done {
stop_reason: StopReason::EndTurn,
usage: TokenUsage {
input_tokens: 5_000,
output_tokens: 2_000,
..Default::default()
},
},
];
let (tx, rx) = mpsc::channel(64);
tokio::spawn(async move {
for e in events {
let _ = tx.send(e).await;
}
});
return Ok(rx);
}
let count = {
let mut c = self.regular_count.lock().unwrap();
let v = *c;
*c += 1;
v
};
// 7 tool-use turns (count 0-6). Turn 6 returns high tokens.
// micro_keep_recent = 3 → count threshold = 6.
// After 7 tool results: 7 > 6 → micro fires.
// After turn 6: last_input_tokens = 170k > 167k → auto fires.
let events = if count < 7 {
let input_tokens = if count == 6 { 170_000 } else { 10_000 };
vec![
LlmEvent::ToolUse {
id: format!("t{count}"),
name: "mock_tool".to_string(),
input: serde_json::json!({}),
extra: None,
},
LlmEvent::Done {
stop_reason: StopReason::ToolUse,
usage: TokenUsage {
input_tokens,
output_tokens: 100,
..Default::default()
},
},
]
} else {
vec![
LlmEvent::TextDelta("After cooperative compact".to_string()),
LlmEvent::Done {
stop_reason: StopReason::EndTurn,
usage: TokenUsage {
input_tokens: 5_000,
output_tokens: 100,
..Default::default()
},
},
]
};
let (tx, rx) = mpsc::channel(64);
tokio::spawn(async move {
for e in events {
let _ = tx.send(e).await;
}
});
Ok(rx)
}
}
let provider = Arc::new(CoopProvider {
regular_count: Mutex::new(0),
compact_calls: counter_ref,
});
let mut config = test_config();
config.compact = CompactConfig {
micro_keep_recent: 3,
compactable_tools: vec!["mock_tool".into()],
context_window: 200_000,
emergency_buffer: 3_000,
..Default::default()
};
config.max_turns = Some(20);
let mut registry = ToolRegistry::new();
registry.register(Box::new(common::MockTool::new(
"mock_tool",
"tool output data",
false,
)));
let output = silent_output();
let mut engine =
AgentEngine::new_with_provider(provider, config, registry, output, std::env::temp_dir());
let result = engine.run("Work", "msg-1").await.expect("should succeed");
assert_eq!(result.text, "After cooperative compact");
// Autocompact was called exactly once (micro freed tokens but
// did not reduce last_input_tokens, so auto still fired).
let calls = *compact_call_count.lock().unwrap();
assert_eq!(
calls, 1,
"autocompact should fire exactly once despite microcompact running first"
);
// Total turns: 7 tool-use + 1 post-compact text = 8 engine turns,
// plus 1 internal compact LLM call = 9 provider calls.
assert_eq!(result.turns, 8);
}
// ── TC-2.6-E2E-03: Circuit breaker after repeated failures ─────────────────
#[tokio::test]
async fn tc_2_6_e2e_03_circuit_breaker_stops_retries() {
// Simulate: 3 turns where autocompact would trigger but fails each time.
// After 3 failures the circuit breaker trips and autocompact stops.
//
// We use a provider that always fails the compact summary call with
// a generic API error, but succeeds for regular conversation turns.
struct CircuitBreakerProvider {
call_index: Mutex<usize>,
}
#[async_trait]
impl LlmProvider for CircuitBreakerProvider {
async fn stream(
&self,
request: &LlmRequest,
) -> Result<mpsc::Receiver<LlmEvent>, ProviderError> {
let idx = {
let mut i = self.call_index.lock().unwrap();
let v = *i;
*i += 1;
v
};
// Compact summary calls have no tools defined and include the
// compact prompt in messages. We detect them by checking tools.is_empty().
let is_compact_call = request.tools.is_empty();
if is_compact_call {
return Err(ProviderError::Api {
status: 500,
message: "Internal error".to_string(),
});
}
// Regular conversation turns: tool use on odd calls, text on even
let events = if idx % 2 == 0 {
// Tool use turn → keeps the loop going
vec![
LlmEvent::ToolUse {
id: format!("t{idx}"),
name: "mock_tool".to_string(),
input: serde_json::json!({}),
extra: None,
},
LlmEvent::Done {
stop_reason: StopReason::ToolUse,
usage: TokenUsage {
input_tokens: 170_000, // above autocompact threshold
output_tokens: 100,
..Default::default()
},
},
]
} else {
// Text turn → ends the loop
vec![
LlmEvent::TextDelta("Final".to_string()),
LlmEvent::Done {
stop_reason: StopReason::EndTurn,
usage: TokenUsage {
input_tokens: 170_000,
output_tokens: 100,
..Default::default()
},
},
]
};
let (tx, rx) = mpsc::channel(64);
tokio::spawn(async move {
for event in events {
let _ = tx.send(event).await;
}
});
Ok(rx)
}
}
let provider = Arc::new(CircuitBreakerProvider {
call_index: Mutex::new(0),
});
let mut config = test_config();
config.compact = CompactConfig {
max_failures: 3,
// Set emergency very high so it doesn't interfere
context_window: 500_000,
emergency_buffer: 3_000,
..Default::default()
};
config.max_turns = Some(10);
let mut registry = ToolRegistry::new();
registry.register(Box::new(common::MockTool::new(
"mock_tool",
"result",
false,
)));
let output = silent_output();
let mut engine =
AgentEngine::new_with_provider(provider, config, registry, output, std::env::temp_dir());
let result = engine.run("Work", "msg-1").await.expect("should succeed");
assert_eq!(result.text, "Final");
}
@@ -0,0 +1,589 @@
mod common;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use nomi_agent::engine::{AgentEngine, AgentError};
use nomi_agent::output::OutputSink;
use nomi_agent::output::terminal::TerminalSink;
use nomi_agent::session::SessionManager;
use nomi_providers::{LlmProvider, ProviderError};
use nomi_tools::registry::ToolRegistry;
use nomi_types::llm::{LlmEvent, LlmRequest};
use nomi_types::message::{ContentBlock, Message, Role, StopReason, TokenUsage};
use serde_json::json;
use tempfile::tempdir;
use tokio::sync::mpsc;
use common::{MockLlmProvider, MockTool, test_config};
// ---------------------------------------------------------------------------
// Helper: build a no-color OutputFormatter for silent test output
// ---------------------------------------------------------------------------
fn silent_output() -> Arc<dyn OutputSink> {
Arc::new(TerminalSink::new(true))
}
#[derive(Default)]
struct RecordingOutputSink {
tool_calls: Mutex<Vec<(String, String)>>,
tool_results: Mutex<Vec<(String, String, bool)>>,
}
impl OutputSink for RecordingOutputSink {
fn emit_text_delta(&self, _text: &str, _msg_id: &str) {}
fn emit_thinking(&self, _text: &str, _msg_id: &str) {}
fn emit_tool_call(&self, tool_use_id: &str, name: &str, _input: &str) {
self.tool_calls
.lock()
.unwrap()
.push((tool_use_id.to_owned(), name.to_owned()));
}
fn emit_tool_result(&self, tool_use_id: &str, name: &str, is_error: bool, _content: &str) {
self.tool_results
.lock()
.unwrap()
.push((tool_use_id.to_owned(), name.to_owned(), is_error));
}
fn emit_stream_start(&self, _msg_id: &str) {}
fn emit_stream_end(
&self,
_msg_id: &str,
_turns: usize,
_input_tokens: u64,
_output_tokens: u64,
_cache_creation_tokens: u64,
_cache_read_tokens: u64,
) {
}
fn emit_error(&self, _msg: &str) {}
fn emit_info(&self, _msg: &str) {}
}
struct RecordingRequestProvider {
requests: Arc<Mutex<Vec<Vec<Message>>>>,
responses: Mutex<Vec<Vec<LlmEvent>>>,
}
impl RecordingRequestProvider {
fn new(responses: Vec<Vec<LlmEvent>>) -> Self {
Self {
requests: Arc::new(Mutex::new(Vec::new())),
responses: Mutex::new(responses),
}
}
fn requests(&self) -> Arc<Mutex<Vec<Vec<Message>>>> {
Arc::clone(&self.requests)
}
}
#[async_trait]
impl LlmProvider for RecordingRequestProvider {
async fn stream(
&self,
request: &LlmRequest,
) -> Result<mpsc::Receiver<LlmEvent>, ProviderError> {
self.requests.lock().unwrap().push(request.messages.clone());
let events = self.responses.lock().unwrap().remove(0);
let (tx, rx) = mpsc::channel(64);
tokio::spawn(async move {
for event in events {
let _ = tx.send(event).await;
}
});
Ok(rx)
}
}
// ---------------------------------------------------------------------------
// test_engine_text_response_ends_turn
//
// Verifies that when the LLM returns a pure text response the engine:
// - captures the full text
// - reports StopReason::EndTurn
// - completes in a single turn
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_engine_text_response_ends_turn() {
let provider = Arc::new(MockLlmProvider::with_text_response("Hello, world!"));
let config = test_config();
let registry = ToolRegistry::new();
let output = silent_output();
let mut engine =
AgentEngine::new_with_provider(provider, config, registry, output, std::env::temp_dir());
let result = engine.run("Hi", "").await.expect("engine should succeed");
assert_eq!(result.text, "Hello, world!");
assert_eq!(result.stop_reason, StopReason::EndTurn);
assert_eq!(result.turns, 1);
}
// ---------------------------------------------------------------------------
// test_engine_tool_use_executes_and_continues
//
// Verifies the agentic loop when the LLM first requests a tool then, after
// receiving the tool result, produces a final text answer.
// - Turn 1: LLM emits ToolUse for "mock_tool"
// - Turn 2: LLM emits TextDelta("Done") + EndTurn
// - result.turns == 2 and result.text == "Done"
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_engine_tool_use_executes_and_continues() {
let turn1 = vec![
LlmEvent::ToolUse {
id: "tool-1".to_string(),
name: "mock_tool".to_string(),
input: json!({}),
extra: None,
},
LlmEvent::Done {
stop_reason: StopReason::ToolUse,
usage: TokenUsage {
input_tokens: 80,
output_tokens: 30,
cache_creation_tokens: 0,
cache_read_tokens: 0,
},
},
];
let turn2 = vec![
LlmEvent::TextDelta("Done".to_string()),
LlmEvent::Done {
stop_reason: StopReason::EndTurn,
usage: TokenUsage {
input_tokens: 100,
output_tokens: 50,
cache_creation_tokens: 0,
cache_read_tokens: 0,
},
},
];
let provider = Arc::new(MockLlmProvider::with_turns(vec![turn1, turn2]));
let config = test_config();
let mut registry = ToolRegistry::new();
registry.register(Box::new(MockTool::new("mock_tool", "tool output", false)));
let output = silent_output();
let mut engine =
AgentEngine::new_with_provider(provider, config, registry, output, std::env::temp_dir());
let result = engine
.run("Use the tool", "")
.await
.expect("engine should succeed");
assert_eq!(result.turns, 2);
assert_eq!(result.text, "Done");
}
#[tokio::test]
async fn test_engine_round_trips_thinking_signature_into_tool_followup_request() {
let provider = Arc::new(RecordingRequestProvider::new(vec![
vec![
LlmEvent::ThinkingDelta("need a tool".to_string()),
LlmEvent::ThinkingSignature("sig-123".to_string()),
LlmEvent::ToolUse {
id: "call_1".to_string(),
name: "mock_tool".to_string(),
input: json!({}),
extra: None,
},
LlmEvent::Done {
stop_reason: StopReason::ToolUse,
usage: TokenUsage::default(),
},
],
vec![
LlmEvent::TextDelta("done".to_string()),
LlmEvent::Done {
stop_reason: StopReason::EndTurn,
usage: TokenUsage::default(),
},
],
]));
let requests = provider.requests();
let mut registry = ToolRegistry::new();
registry.register(Box::new(MockTool::new("mock_tool", "tool result", false)));
let mut engine = AgentEngine::new_with_provider(
provider,
test_config(),
registry,
silent_output(),
std::env::temp_dir(),
);
let result = engine
.run("use tool", "")
.await
.expect("engine should succeed");
assert_eq!(result.text, "done");
let requests = requests.lock().unwrap();
assert_eq!(requests.len(), 2);
let followup_messages = &requests[1];
let assistant_message = followup_messages
.iter()
.find(|message| message.role == Role::Assistant)
.expect("assistant message should be present");
match &assistant_message.content[0] {
ContentBlock::Thinking {
thinking,
signature,
} => {
assert_eq!(thinking, "need a tool");
assert_eq!(signature.as_deref(), Some("sig-123"));
}
other => panic!("expected thinking block, got {other:?}"),
}
}
#[tokio::test]
async fn duplicate_tool_names_emit_distinct_tool_use_ids() {
let turn1 = vec![
LlmEvent::ToolUse {
id: "call_a".to_string(),
name: "Glob".to_string(),
input: json!({"pattern": "*.rs"}),
extra: None,
},
LlmEvent::ToolUse {
id: "call_b".to_string(),
name: "Glob".to_string(),
input: json!({"pattern": "*.toml"}),
extra: None,
},
LlmEvent::Done {
stop_reason: StopReason::ToolUse,
usage: TokenUsage {
input_tokens: 80,
output_tokens: 30,
cache_creation_tokens: 0,
cache_read_tokens: 0,
},
},
];
let turn2 = vec![
LlmEvent::TextDelta("Done".to_string()),
LlmEvent::Done {
stop_reason: StopReason::EndTurn,
usage: TokenUsage {
input_tokens: 100,
output_tokens: 50,
cache_creation_tokens: 0,
cache_read_tokens: 0,
},
},
];
let provider = Arc::new(MockLlmProvider::with_turns(vec![turn1, turn2]));
let config = test_config();
let mut registry = ToolRegistry::new();
registry.register(Box::new(MockTool::new("Glob", "tool output", false)));
let output = Arc::new(RecordingOutputSink::default());
let mut engine = AgentEngine::new_with_provider(
provider,
config,
registry,
output.clone(),
std::env::temp_dir(),
);
let result = engine
.run("Use Glob twice", "")
.await
.expect("engine should succeed");
assert_eq!(result.text, "Done");
assert_eq!(
*output.tool_calls.lock().unwrap(),
vec![
("call_a".to_string(), "Glob".to_string()),
("call_b".to_string(), "Glob".to_string()),
]
);
assert_eq!(
*output.tool_results.lock().unwrap(),
vec![
("call_a".to_string(), "Glob".to_string(), false),
("call_b".to_string(), "Glob".to_string(), false),
]
);
}
// ---------------------------------------------------------------------------
// test_engine_max_tokens_handling
//
// Verifies that a MaxTokens stop reason is surfaced correctly when the LLM
// hits its token limit mid-response.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_engine_max_tokens_handling() {
let events = vec![
LlmEvent::TextDelta("partial".to_string()),
LlmEvent::Done {
stop_reason: StopReason::MaxTokens,
usage: TokenUsage {
input_tokens: 200,
output_tokens: 100,
cache_creation_tokens: 0,
cache_read_tokens: 0,
},
},
];
let provider = Arc::new(MockLlmProvider::with_events(events));
let config = test_config();
let registry = ToolRegistry::new();
let output = silent_output();
let mut engine =
AgentEngine::new_with_provider(provider, config, registry, output, std::env::temp_dir());
let result = engine
.run("Give me a long answer", "")
.await
.expect("engine should succeed");
assert_eq!(result.stop_reason, StopReason::MaxTokens);
assert_eq!(result.text, "partial");
}
// ---------------------------------------------------------------------------
// test_engine_message_accumulation
//
// Verifies that consecutive calls to `run` accumulate messages across turns.
// Session persistence is used to observe the messages externally since
// engine.messages is private.
//
// After two independent `run` calls the persisted session must contain
// exactly 4 messages: [user, assistant, user, assistant].
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_engine_message_accumulation() {
let dir = tempdir().expect("tempdir should be created");
// Provider needs two responses (one per run() call)
let provider = Arc::new(MockLlmProvider::with_turns(vec![
vec![
LlmEvent::TextDelta("Response 1".to_string()),
LlmEvent::Done {
stop_reason: StopReason::EndTurn,
usage: TokenUsage {
input_tokens: 10,
output_tokens: 5,
cache_creation_tokens: 0,
cache_read_tokens: 0,
},
},
],
vec![
LlmEvent::TextDelta("Response 2".to_string()),
LlmEvent::Done {
stop_reason: StopReason::EndTurn,
usage: TokenUsage {
input_tokens: 10,
output_tokens: 5,
cache_creation_tokens: 0,
cache_read_tokens: 0,
},
},
],
]));
let mut config = test_config();
config.session.enabled = true;
config.session.directory = dir.path().to_string_lossy().into_owned();
let registry = ToolRegistry::new();
let output = silent_output();
let mut engine = AgentEngine::new_with_provider(
provider,
config.clone(),
registry,
output,
std::env::temp_dir(),
);
// Initialize session so save_session() has a session to persist
engine
.init_session("test-provider", "/tmp", None)
.expect("init_session should succeed");
engine
.run("First message", "")
.await
.expect("first run should succeed");
engine
.run("Second message", "")
.await
.expect("second run should succeed");
// Load the persisted session and count accumulated messages
let session_manager = SessionManager::new(dir.path().to_path_buf(), 10);
let session = session_manager
.load("latest")
.expect("session should be loadable");
// Expected layout: user, assistant, user, assistant
assert_eq!(
session.messages.len(),
4,
"expected 4 messages (user+assistant for each run), got {}",
session.messages.len()
);
}
// ---------------------------------------------------------------------------
// test_engine_token_usage_tracking
//
// Verifies that token usage is accumulated correctly across multiple turns.
// - Turn 1: ToolUse with usage(80 in, 30 out)
// - Turn 2: EndTurn with usage(100 in, 50 out)
// - Expected total: input=180, output=80
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_engine_token_usage_tracking() {
let turn1 = vec![
LlmEvent::ToolUse {
id: "tool-1".to_string(),
name: "mock_tool".to_string(),
input: json!({}),
extra: None,
},
LlmEvent::Done {
stop_reason: StopReason::ToolUse,
usage: TokenUsage {
input_tokens: 80,
output_tokens: 30,
cache_creation_tokens: 0,
cache_read_tokens: 0,
},
},
];
let turn2 = vec![
LlmEvent::TextDelta("Final answer".to_string()),
LlmEvent::Done {
stop_reason: StopReason::EndTurn,
usage: TokenUsage {
input_tokens: 100,
output_tokens: 50,
cache_creation_tokens: 0,
cache_read_tokens: 0,
},
},
];
let provider = Arc::new(MockLlmProvider::with_turns(vec![turn1, turn2]));
let config = test_config();
let mut registry = ToolRegistry::new();
registry.register(Box::new(MockTool::new("mock_tool", "result", false)));
let output = silent_output();
let mut engine =
AgentEngine::new_with_provider(provider, config, registry, output, std::env::temp_dir());
let result = engine
.run("Do work", "")
.await
.expect("engine should succeed");
assert_eq!(
result.usage.input_tokens, 180,
"input tokens should accumulate across turns"
);
assert_eq!(
result.usage.output_tokens, 80,
"output tokens should accumulate across turns"
);
}
// ---------------------------------------------------------------------------
// test_engine_max_turns_returns_ok
//
// Verifies that the engine returns Ok with StopReason::MaxTurns when the
// LLM keeps requesting tools beyond the configured max_turns limit.
//
// With max_turns=1 the engine executes one turn. If that turn has tool
// calls it processes them, then loops back and hits the limit.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_engine_max_turns_returns_ok() {
let tool_use_turn = || {
vec![
LlmEvent::ToolUse {
id: "tool-1".to_string(),
name: "mock_tool".to_string(),
input: json!({}),
extra: None,
},
LlmEvent::Done {
stop_reason: StopReason::ToolUse,
usage: TokenUsage {
input_tokens: 50,
output_tokens: 20,
cache_creation_tokens: 0,
cache_read_tokens: 0,
},
},
]
};
let provider = Arc::new(MockLlmProvider::with_turns(vec![
tool_use_turn(),
tool_use_turn(),
]));
let mut config = test_config();
config.max_turns = Some(1);
let mut registry = ToolRegistry::new();
registry.register(Box::new(MockTool::new("mock_tool", "result", false)));
let output = silent_output();
let mut engine =
AgentEngine::new_with_provider(provider, config, registry, output, std::env::temp_dir());
let result = engine
.run("Keep calling tools", "")
.await
.expect("should return Ok, not Err");
assert_eq!(result.stop_reason, StopReason::MaxTurns);
assert_eq!(result.turns, 1);
}
// ---------------------------------------------------------------------------
// test_engine_api_error_handling
//
// Verifies that an LlmEvent::Error propagates as AgentError::ApiError with
// the original error message intact.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_engine_api_error_handling() {
let events = vec![LlmEvent::Error("test error".to_string())];
let provider = Arc::new(MockLlmProvider::with_events(events));
let config = test_config();
let registry = ToolRegistry::new();
let output = silent_output();
let mut engine =
AgentEngine::new_with_provider(provider, config, registry, output, std::env::temp_dir());
let err = engine
.run("Hello", "")
.await
.map(|_| panic!("expected error, got Ok"))
.unwrap_err();
match err {
AgentError::ApiError(msg) => assert_eq!(msg, "test error"),
other => panic!("expected ApiError(\"test error\"), got: {:?}", other),
}
}
@@ -0,0 +1,321 @@
mod common;
use std::sync::Arc;
use serde_json::json;
use nomi_agent::engine::AgentEngine;
use nomi_agent::output::OutputSink;
use nomi_agent::output::terminal::TerminalSink;
use nomi_protocol::writer::ProtocolWriter;
use nomi_protocol::{ToolApprovalManager, ToolApprovalResult};
use nomi_tools::registry::ToolRegistry;
use nomi_types::llm::LlmEvent;
use nomi_types::message::{StopReason, TokenUsage};
use common::{ExecMockTool, MockLlmProvider, test_config};
fn silent_output() -> Arc<dyn OutputSink> {
Arc::new(TerminalSink::new(true))
}
fn token_usage(input: u64, output: u64) -> TokenUsage {
TokenUsage {
input_tokens: input,
output_tokens: output,
cache_creation_tokens: 0,
cache_read_tokens: 0,
}
}
// ---------------------------------------------------------------------------
// test: tool approval approve flow
//
// LLM requests exec_tool → engine pauses at approval_manager.request_approval
// → background task resolves with Approved → tool executes → LLM continues
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_tool_approval_approve_flow() {
let turn1 = vec![
LlmEvent::ToolUse {
id: "call-1".to_string(),
name: "exec_tool".to_string(),
input: json!({}),
extra: None,
},
LlmEvent::Done {
stop_reason: StopReason::ToolUse,
usage: token_usage(80, 30),
},
];
let turn2 = vec![
LlmEvent::TextDelta("Done".to_string()),
LlmEvent::Done {
stop_reason: StopReason::EndTurn,
usage: token_usage(100, 50),
},
];
let provider = Arc::new(MockLlmProvider::with_turns(vec![turn1, turn2]));
let mut config = test_config();
config.tools.auto_approve = false;
let mut registry = ToolRegistry::new();
registry.register(Box::new(ExecMockTool::new("exec_tool", "tool output")));
let output = silent_output();
let approval_manager = Arc::new(ToolApprovalManager::new());
let writer = Arc::new(ProtocolWriter::new());
let mut engine =
AgentEngine::new_with_provider(provider, config, registry, output, std::env::temp_dir());
engine.set_approval_manager(approval_manager.clone());
engine.set_protocol_writer(writer);
// Spawn a task that approves the tool call after a short delay
let am = approval_manager.clone();
tokio::spawn(async move {
// Wait until the approval request appears
loop {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
let has_pending = {
// Check if there's a pending request by trying to resolve a known id
// We know the call_id is "call-1" from the mock
true
};
if has_pending {
am.resolve("call-1", ToolApprovalResult::Approved);
break;
}
}
});
let result = engine
.run("Use the tool", "msg-1")
.await
.expect("should succeed");
assert_eq!(result.text, "Done");
assert_eq!(result.turns, 2);
}
// ---------------------------------------------------------------------------
// test: tool approval deny flow
//
// LLM requests exec_tool → engine pauses → background resolves with Denied
// → tool_cancelled → denial fed back to LLM → LLM responds with text
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_tool_approval_deny_flow() {
let turn1 = vec![
LlmEvent::ToolUse {
id: "call-2".to_string(),
name: "exec_tool".to_string(),
input: json!({}),
extra: None,
},
LlmEvent::Done {
stop_reason: StopReason::ToolUse,
usage: token_usage(80, 30),
},
];
let turn2 = vec![
LlmEvent::TextDelta("Cannot run tool".to_string()),
LlmEvent::Done {
stop_reason: StopReason::EndTurn,
usage: token_usage(100, 50),
},
];
let provider = Arc::new(MockLlmProvider::with_turns(vec![turn1, turn2]));
let mut config = test_config();
config.tools.auto_approve = false;
let mut registry = ToolRegistry::new();
registry.register(Box::new(ExecMockTool::new("exec_tool", "tool output")));
let output = silent_output();
let approval_manager = Arc::new(ToolApprovalManager::new());
let writer = Arc::new(ProtocolWriter::new());
let mut engine =
AgentEngine::new_with_provider(provider, config, registry, output, std::env::temp_dir());
engine.set_approval_manager(approval_manager.clone());
engine.set_protocol_writer(writer);
let am = approval_manager.clone();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
am.resolve(
"call-2",
ToolApprovalResult::Denied {
reason: "policy violation".into(),
},
);
});
let result = engine
.run("Use the tool", "msg-2")
.await
.expect("should succeed");
assert_eq!(result.text, "Cannot run tool");
assert_eq!(result.turns, 2);
}
// ---------------------------------------------------------------------------
// test: auto_approve bypasses approval wait
//
// With auto_approve=true, exec category tools should execute immediately
// without waiting for approval.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_auto_approve_bypasses_approval() {
let turn1 = vec![
LlmEvent::ToolUse {
id: "call-3".to_string(),
name: "exec_tool".to_string(),
input: json!({}),
extra: None,
},
LlmEvent::Done {
stop_reason: StopReason::ToolUse,
usage: token_usage(80, 30),
},
];
let turn2 = vec![
LlmEvent::TextDelta("Auto done".to_string()),
LlmEvent::Done {
stop_reason: StopReason::EndTurn,
usage: token_usage(100, 50),
},
];
let provider = Arc::new(MockLlmProvider::with_turns(vec![turn1, turn2]));
let mut config = test_config();
config.tools.auto_approve = true;
let mut registry = ToolRegistry::new();
registry.register(Box::new(ExecMockTool::new("exec_tool", "tool output")));
let output = silent_output();
let approval_manager = Arc::new(ToolApprovalManager::new());
let writer = Arc::new(ProtocolWriter::new());
let mut engine =
AgentEngine::new_with_provider(provider, config, registry, output, std::env::temp_dir());
engine.set_approval_manager(approval_manager.clone());
engine.set_protocol_writer(writer);
// No background task to approve — should not hang
let result = engine
.run("Use the tool", "msg-3")
.await
.expect("should succeed");
assert_eq!(result.text, "Auto done");
assert_eq!(result.turns, 2);
}
// ---------------------------------------------------------------------------
// test: session auto-approve (scope=always) bypasses future approvals
//
// After add_auto_approve("exec"), exec tools skip the approval wait.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_session_auto_approve_category() {
let turn1 = vec![
LlmEvent::ToolUse {
id: "call-4".to_string(),
name: "exec_tool".to_string(),
input: json!({}),
extra: None,
},
LlmEvent::Done {
stop_reason: StopReason::ToolUse,
usage: token_usage(80, 30),
},
];
let turn2 = vec![
LlmEvent::TextDelta("Session auto".to_string()),
LlmEvent::Done {
stop_reason: StopReason::EndTurn,
usage: token_usage(100, 50),
},
];
let provider = Arc::new(MockLlmProvider::with_turns(vec![turn1, turn2]));
let mut config = test_config();
config.tools.auto_approve = false;
let mut registry = ToolRegistry::new();
registry.register(Box::new(ExecMockTool::new("exec_tool", "tool output")));
let output = silent_output();
let approval_manager = Arc::new(ToolApprovalManager::new());
// Pre-approve the "exec" category
approval_manager.add_auto_approve("exec");
let writer = Arc::new(ProtocolWriter::new());
let mut engine =
AgentEngine::new_with_provider(provider, config, registry, output, std::env::temp_dir());
engine.set_approval_manager(approval_manager.clone());
engine.set_protocol_writer(writer);
// No background task to approve — should not hang
let result = engine
.run("Use the tool", "msg-4")
.await
.expect("should succeed");
assert_eq!(result.text, "Session auto");
assert_eq!(result.turns, 2);
}
// ---------------------------------------------------------------------------
// test: client disconnect (channel drop) causes UserAborted
//
// If the approval channel sender is dropped before resolve, the engine
// should return an abort error.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn test_client_disconnect_aborts() {
let turn1 = vec![
LlmEvent::ToolUse {
id: "call-5".to_string(),
name: "exec_tool".to_string(),
input: json!({}),
extra: None,
},
LlmEvent::Done {
stop_reason: StopReason::ToolUse,
usage: token_usage(80, 30),
},
];
let provider = Arc::new(MockLlmProvider::with_turns(vec![turn1]));
let mut config = test_config();
config.tools.auto_approve = false;
let mut registry = ToolRegistry::new();
registry.register(Box::new(ExecMockTool::new("exec_tool", "tool output")));
let output = silent_output();
let approval_manager = Arc::new(ToolApprovalManager::new());
let writer = Arc::new(ProtocolWriter::new());
let mut engine =
AgentEngine::new_with_provider(provider, config, registry, output, std::env::temp_dir());
engine.set_approval_manager(approval_manager.clone());
engine.set_protocol_writer(writer);
// Simulate client disconnect: drop the pending sender without resolving
let am = approval_manager.clone();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
am.drop_pending("call-5");
});
let err = engine.run("Use the tool", "msg-5").await.unwrap_err();
assert!(
format!("{:?}", err).contains("UserAborted"),
"expected UserAborted, got: {:?}",
err
);
}
@@ -0,0 +1,315 @@
// Integration tests for memory system context assembly (TC-7).
//
// These are black-box tests that verify the memory system is correctly
// integrated into the system prompt assembly pipeline.
use std::fs;
use nomi_agent::context::{SystemPromptCache, build_system_prompt};
// ---------------------------------------------------------------------------
// TC-7.1: With memory_dir, system prompt includes memory content
// ---------------------------------------------------------------------------
#[test]
fn tc_7_1_memory_dir_with_content_injects_prompt() {
let tmp = tempfile::TempDir::new().unwrap();
let mem_dir = tmp.path().join("memory");
fs::create_dir_all(&mem_dir).unwrap();
fs::write(
mem_dir.join("MEMORY.md"),
"- [Role](user_role.md) \u{2014} senior engineer\n\
- [Policy](feedback_tests.md) \u{2014} always use real DB\n",
)
.unwrap();
let result = build_system_prompt(
&mut SystemPromptCache::new(),
None,
"/tmp",
"test-model",
&[],
None,
Some(&mem_dir),
false,
false,
false,
);
// Should contain minimal memory system sections
assert!(
result.contains("auto memory"),
"should contain memory system display name"
);
assert!(
result.contains("Memory types:"),
"should contain compact type summary"
);
assert!(
result.contains("MEMORY.md is the index"),
"should contain compact save guidance"
);
// Should contain MEMORY.md content
assert!(
result.contains("user_role.md"),
"should contain MEMORY.md entries"
);
assert!(
result.contains("senior engineer"),
"should contain entry descriptions"
);
}
// ---------------------------------------------------------------------------
// TC-7.2: Without memory_dir, no memory injection
// ---------------------------------------------------------------------------
#[test]
fn tc_7_2_no_memory_dir_no_injection() {
let result = build_system_prompt(
&mut SystemPromptCache::new(),
None,
"/tmp",
"test-model",
&[],
None,
None,
false,
false,
false,
);
assert!(
!result.contains("auto memory"),
"no memory content when memory_dir is None"
);
assert!(
!result.contains("Types of memory"),
"no type definitions when memory_dir is None"
);
}
// ---------------------------------------------------------------------------
// TC-7.3: Memory appears after AGENTS.md, before skills
// ---------------------------------------------------------------------------
#[test]
fn tc_7_3_section_ordering() {
let tmp = tempfile::TempDir::new().unwrap();
let cwd = tmp.path();
// Create AGENTS.md
fs::write(cwd.join("AGENTS.md"), "PROJECT_RULES_CONTENT").unwrap();
// Create memory dir
let mem_dir = tmp.path().join("memory");
fs::create_dir_all(&mem_dir).unwrap();
fs::write(mem_dir.join("MEMORY.md"), "- [A](a.md) \u{2014} test\n").unwrap();
// Create a minimal skill metadata
use nomi_skills::types::{ExecutionContext, LoadedFrom, SkillMetadata, SkillSource};
let skill = SkillMetadata {
name: "test-skill".to_string(),
display_name: None,
description: "A test skill".to_string(),
has_user_specified_description: false,
allowed_tools: vec![],
argument_hint: None,
argument_names: vec![],
when_to_use: None,
version: None,
model: None,
disable_model_invocation: false,
user_invocable: true,
execution_context: ExecutionContext::Inline,
agent: None,
effort: None,
shell: None,
paths: vec![],
hooks_raw: None,
source: SkillSource::User,
loaded_from: LoadedFrom::Skills,
content: String::new(),
content_length: 0,
skill_root: None,
};
let result = build_system_prompt(
&mut SystemPromptCache::new(),
None,
&cwd.to_string_lossy(),
"test-model",
&[skill],
None,
Some(&mem_dir),
false,
false,
false,
);
let agents_pos = result
.find("PROJECT_RULES_CONTENT")
.expect("AGENTS.md content should be present");
let memory_pos = result
.find("auto memory")
.expect("memory section should be present");
let skills_pos = result
.find("test-skill")
.expect("skills section should be present");
assert!(
agents_pos < memory_pos,
"AGENTS.md content should appear before memory section"
);
assert!(
memory_pos < skills_pos,
"memory section should appear before skills listing"
);
}
// ---------------------------------------------------------------------------
// TC-7.4: Non-existent memory_dir degrades gracefully
// ---------------------------------------------------------------------------
#[test]
fn tc_7_4_nonexistent_dir_graceful_degradation() {
let result = build_system_prompt(
&mut SystemPromptCache::new(),
None,
"/tmp",
"test-model",
&[],
None,
Some(std::path::Path::new("/nonexistent/memory/dir")),
false,
false,
false,
);
// Should not panic
assert!(
result.contains("currently empty"),
"nonexistent memory dir should show empty state"
);
assert!(
result.contains("auto memory"),
"memory section should still be present (with empty state)"
);
}
// ---------------------------------------------------------------------------
// TC-7.5: MEMORY.md content correctly injected
// ---------------------------------------------------------------------------
#[test]
fn tc_7_5_memory_md_content_injected() {
let tmp = tempfile::TempDir::new().unwrap();
let mem_dir = tmp.path().join("memory");
fs::create_dir_all(&mem_dir).unwrap();
fs::write(
mem_dir.join("MEMORY.md"),
"- [User Role](user_role.md) \u{2014} senior engineer\n\
- [Test Policy](feedback_tests.md) \u{2014} always use real DB\n\
- [Sprint](project_sprint.md) \u{2014} sprint 42 ends Friday\n",
)
.unwrap();
let result = build_system_prompt(
&mut SystemPromptCache::new(),
None,
"/tmp",
"test-model",
&[],
None,
Some(&mem_dir),
false,
false,
false,
);
assert!(
result.contains("user_role.md"),
"should contain first entry"
);
assert!(
result.contains("feedback_tests.md"),
"should contain second entry"
);
assert!(
result.contains("project_sprint.md"),
"should contain third entry"
);
assert!(
result.contains("sprint 42 ends Friday"),
"should contain entry descriptions"
);
}
// ---------------------------------------------------------------------------
// TC-7.6: No MEMORY.md shows empty state
// ---------------------------------------------------------------------------
#[test]
fn tc_7_6_no_memory_md_shows_empty() {
let tmp = tempfile::TempDir::new().unwrap();
let mem_dir = tmp.path().join("memory");
fs::create_dir_all(&mem_dir).unwrap();
// No MEMORY.md created
let result = build_system_prompt(
&mut SystemPromptCache::new(),
None,
"/tmp",
"test-model",
&[],
None,
Some(&mem_dir),
false,
false,
false,
);
assert!(
result.contains("currently empty"),
"should show empty state when MEMORY.md doesn't exist"
);
}
// ---------------------------------------------------------------------------
// TC-7.7: No bb brand identifiers in integrated prompt
// ---------------------------------------------------------------------------
#[test]
fn tc_7_7_no_bb_brand_in_integrated_prompt() {
let tmp = tempfile::TempDir::new().unwrap();
let mem_dir = tmp.path().join("memory");
fs::create_dir_all(&mem_dir).unwrap();
fs::write(
mem_dir.join("MEMORY.md"),
"- [Test](test.md) \u{2014} entry\n",
)
.unwrap();
let result = build_system_prompt(
&mut SystemPromptCache::new(),
None,
"/tmp",
"test-model",
&[],
None,
Some(&mem_dir),
false,
false,
false,
);
assert!(
!result.contains("~/.claude"),
"should not contain bb brand path ~/.claude"
);
assert!(
!result.contains("CLAUDE.md"),
"should not reference CLAUDE.md"
);
}
@@ -0,0 +1,338 @@
//! Black-box integration tests for the microcompact subsystem.
//!
//! These tests correspond to TC-2.3-01 through TC-2.3-11 in the test plan.
//! They treat `should_microcompact` and `microcompact` as opaque functions
//! and validate observable behaviour only (inputs → outputs).
use chrono::{Duration, Utc};
use nomi_agent::compact::micro::{
CLEARED_TOOL_RESULT, MicrocompactResult, microcompact, should_microcompact,
};
use nomi_config::compact::CompactConfig;
use nomi_types::message::{ContentBlock, Message, Role};
use serde_json::json;
// ── Helpers ─────────────────────────────────────────────────────────────────
fn tool_use(id: &str, name: &str) -> ContentBlock {
ContentBlock::ToolUse {
id: id.into(),
name: name.into(),
input: json!({}),
extra: None,
}
}
fn tool_result(id: &str, content: &str) -> ContentBlock {
ContentBlock::ToolResult {
tool_use_id: id.into(),
content: content.into(),
is_error: false,
images: Vec::new(),
}
}
fn text(s: &str) -> ContentBlock {
ContentBlock::Text { text: s.into() }
}
fn assistant(blocks: Vec<ContentBlock>) -> Message {
Message::new(Role::Assistant, blocks)
}
fn user(blocks: Vec<ContentBlock>) -> Message {
Message::new(Role::User, blocks)
}
fn assistant_at(blocks: Vec<ContentBlock>, ts: chrono::DateTime<Utc>) -> Message {
Message {
role: Role::Assistant,
content: blocks,
timestamp: Some(ts),
}
}
fn get_tool_result_content(msg: &Message, block_idx: usize) -> &str {
match &msg.content[block_idx] {
ContentBlock::ToolResult { content, .. } => content.as_str(),
other => panic!("expected ToolResult, got {other:?}"),
}
}
// ── TC-2.3-01: Basic clearing ───────────────────────────────────────────────
#[test]
fn tc_2_3_01_basic_clearing() {
// 10 messages containing 8 tool results (Read x3, Bash x3, Grep x2).
// keep_recent = 3 → oldest 5 cleared.
let tool_specs = [
("r1", "Read"),
("b1", "Bash"),
("g1", "Grep"),
("r2", "Read"),
("b2", "Bash"),
("g2", "Grep"),
("r3", "Read"),
("b3", "Bash"),
];
let mut msgs: Vec<Message> = Vec::new();
for (id, name) in &tool_specs {
msgs.push(assistant(vec![tool_use(id, name)]));
msgs.push(user(vec![tool_result(id, &format!("output-{id}"))]));
}
let config = CompactConfig {
micro_keep_recent: 3,
..Default::default()
};
let result = microcompact(&mut msgs, &config);
assert_eq!(result.cleared_count, 5);
// First 5 user messages (indices 1, 3, 5, 7, 9) are cleared.
for i in 0..5 {
let user_msg_idx = i * 2 + 1;
assert_eq!(
get_tool_result_content(&msgs[user_msg_idx], 0),
CLEARED_TOOL_RESULT,
"tool result at msg index {user_msg_idx} should be cleared"
);
}
// Last 3 user messages (indices 11, 13, 15) retain original content.
for (idx, &(id, _name)) in tool_specs.iter().enumerate().skip(5) {
let user_msg_idx = idx * 2 + 1;
assert_eq!(
get_tool_result_content(&msgs[user_msg_idx], 0),
format!("output-{id}"),
"tool result at msg index {user_msg_idx} should be preserved"
);
}
}
// ── TC-2.3-02: Tool results insufficient — no clearing ──────────────────────
#[test]
fn tc_2_3_02_insufficient_results_no_clearing() {
let mut msgs = vec![
assistant(vec![tool_use("t1", "Read")]),
user(vec![tool_result("t1", "data-1")]),
assistant(vec![tool_use("t2", "Bash")]),
user(vec![tool_result("t2", "data-2")]),
];
let config = CompactConfig {
micro_keep_recent: 5,
..Default::default()
};
let result = microcompact(&mut msgs, &config);
assert_eq!(result.cleared_count, 0);
assert_eq!(
result,
MicrocompactResult {
cleared_count: 0,
estimated_tokens_freed: 0,
}
);
}
// ── TC-2.3-03: Only compactable tools are cleared ───────────────────────────
#[test]
fn tc_2_3_03_only_compactable_tools_cleared() {
let mut msgs = vec![
assistant(vec![tool_use("t1", "Read")]),
user(vec![tool_result("t1", "read-output")]),
assistant(vec![tool_use("t2", "Bash")]),
user(vec![tool_result("t2", "bash-output")]),
assistant(vec![tool_use("t3", "Skill")]),
user(vec![tool_result("t3", "skill-output")]),
assistant(vec![tool_use("t4", "Read")]),
user(vec![tool_result("t4", "read-output-2")]),
];
// compactable_tools does NOT include "Skill".
let config = CompactConfig {
micro_keep_recent: 1,
compactable_tools: vec!["Read".into(), "Bash".into()],
..Default::default()
};
let result = microcompact(&mut msgs, &config);
// 3 compactable (t1-Read, t2-Bash, t4-Read), keep 1 → clear 2.
assert_eq!(result.cleared_count, 2);
// Skill result (t3) must be untouched.
assert_eq!(get_tool_result_content(&msgs[5], 0), "skill-output");
// Most recent compactable (t4) must be preserved.
assert_eq!(get_tool_result_content(&msgs[7], 0), "read-output-2");
}
// ── TC-2.3-04: Time trigger — exceeds threshold ────────────────────────────
#[test]
fn tc_2_3_04_time_trigger_exceeds_threshold() {
let old_ts = Utc::now() - Duration::seconds(3660); // 61 minutes ago
let msgs = vec![assistant_at(vec![text("thinking")], old_ts)];
let config = CompactConfig {
micro_gap_seconds: 3600,
..Default::default()
};
assert!(should_microcompact(&msgs, &config));
}
// ── TC-2.3-05: Time trigger — within threshold ─────────────────────────────
#[test]
fn tc_2_3_05_time_trigger_within_threshold() {
let recent_ts = Utc::now() - Duration::seconds(1800); // 30 minutes ago
let msgs = vec![assistant_at(vec![text("thinking")], recent_ts)];
let config = CompactConfig {
micro_gap_seconds: 3600,
..Default::default()
};
assert!(!should_microcompact(&msgs, &config));
}
// ── TC-2.3-06: Count trigger ────────────────────────────────────────────────
#[test]
fn tc_2_3_06_count_trigger() {
// 12 compactable results, keep_recent=5 → threshold = 10.
// 12 > 10 → should trigger.
let mut msgs = Vec::new();
for i in 0..12 {
let id = format!("t{i}");
msgs.push(assistant(vec![tool_use(&id, "Read")]));
msgs.push(user(vec![tool_result(&id, "data")]));
}
let config = CompactConfig {
micro_keep_recent: 5,
..Default::default()
};
assert!(should_microcompact(&msgs, &config));
}
// ── TC-2.3-07: No timestamp — time check skipped ───────────────────────────
#[test]
fn tc_2_3_07_no_timestamp_skips_time_check() {
// All messages have no timestamp.
// Only 2 compactable results with keep_recent=5 → count trigger also false.
let msgs = vec![
assistant(vec![tool_use("t1", "Read")]),
user(vec![tool_result("t1", "data-1")]),
assistant(vec![tool_use("t2", "Read")]),
user(vec![tool_result("t2", "data-2")]),
];
let config = CompactConfig {
micro_keep_recent: 5,
micro_gap_seconds: 3600,
..Default::default()
};
// No timestamp → time trigger skipped.
// 2 results ≤ 5*2=10 → count trigger false.
assert!(!should_microcompact(&msgs, &config));
}
// ── TC-2.3-08: Token estimation after clearing ──────────────────────────────
#[test]
fn tc_2_3_08_token_estimation() {
// 3 tool results with known content lengths, clear all but 1.
let content_a = "x".repeat(200); // 50 tokens
let content_b = "y".repeat(400); // 100 tokens
let content_c = "z".repeat(80); // 20 tokens — kept
let mut msgs = vec![
assistant(vec![tool_use("a", "Read")]),
user(vec![tool_result("a", &content_a)]),
assistant(vec![tool_use("b", "Bash")]),
user(vec![tool_result("b", &content_b)]),
assistant(vec![tool_use("c", "Grep")]),
user(vec![tool_result("c", &content_c)]),
];
let config = CompactConfig {
micro_keep_recent: 1,
..Default::default()
};
let result = microcompact(&mut msgs, &config);
assert_eq!(result.cleared_count, 2);
assert!(result.estimated_tokens_freed > 0);
// 200/4 + 400/4 = 50 + 100 = 150
assert_eq!(result.estimated_tokens_freed, 150);
}
// ── TC-2.3-09: Already cleared content not re-cleared ───────────────────────
#[test]
fn tc_2_3_09_already_cleared_not_recounted() {
let mut msgs = vec![
assistant(vec![tool_use("t1", "Read")]),
user(vec![tool_result("t1", CLEARED_TOOL_RESULT)]),
assistant(vec![tool_use("t2", "Read")]),
user(vec![tool_result("t2", "live-data")]),
];
let config = CompactConfig {
micro_keep_recent: 1,
..Default::default()
};
let result = microcompact(&mut msgs, &config);
// t1 already cleared, only t2 is compactable and is the most recent → keep.
assert_eq!(result.cleared_count, 0);
assert_eq!(result.estimated_tokens_freed, 0);
}
// ── TC-2.3-10: Empty message list ───────────────────────────────────────────
#[test]
fn tc_2_3_10_empty_messages() {
let mut msgs: Vec<Message> = vec![];
let result = microcompact(&mut msgs, &CompactConfig::default());
assert_eq!(
result,
MicrocompactResult {
cleared_count: 0,
estimated_tokens_freed: 0,
}
);
}
// ── TC-2.3-11: Message order preserved ──────────────────────────────────────
#[test]
fn tc_2_3_11_message_order_preserved() {
let mut msgs = vec![
assistant(vec![tool_use("t1", "Read")]),
user(vec![tool_result("t1", "data-1")]),
assistant(vec![text("thinking about it")]),
user(vec![text("please continue")]),
assistant(vec![tool_use("t2", "Bash")]),
user(vec![tool_result("t2", "bash-out")]),
assistant(vec![tool_use("t3", "Grep")]),
user(vec![tool_result("t3", "grep-out")]),
];
let original_len = msgs.len();
let original_roles: Vec<Role> = msgs.iter().map(|m| m.role).collect();
let config = CompactConfig {
micro_keep_recent: 1,
..Default::default()
};
microcompact(&mut msgs, &config);
// Message count unchanged.
assert_eq!(msgs.len(), original_len);
// Role sequence unchanged.
let after_roles: Vec<Role> = msgs.iter().map(|m| m.role).collect();
assert_eq!(after_roles, original_roles);
// Non-tool-result content blocks unchanged.
match &msgs[2].content[0] {
ContentBlock::Text { text } => assert_eq!(text, "thinking about it"),
_ => panic!("expected Text"),
}
match &msgs[3].content[0] {
ContentBlock::Text { text } => assert_eq!(text, "please continue"),
_ => panic!("expected Text"),
}
}
@@ -0,0 +1,363 @@
mod common;
use common::{MockTool, auto_approve_confirmer};
use nomi_agent::orchestration::execute_tool_calls;
use nomi_compact::CompactionLevel;
use nomi_config::hooks::{HookDef, HookEngine, HooksConfig};
use nomi_tools::registry::ToolRegistry;
use nomi_types::message::ContentBlock;
use serde_json::json;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn make_tool_use(id: &str, name: &str) -> ContentBlock {
ContentBlock::ToolUse {
id: id.to_string(),
name: name.to_string(),
input: json!({}),
extra: None,
}
}
fn make_pre_hook(name: &str, tool_match: &str, command: &str) -> HookDef {
HookDef {
name: name.to_string(),
tool_match: vec![tool_match.to_string()],
file_match: vec![],
command: command.to_string(),
timeout_ms: 5_000,
}
}
fn make_post_hook(name: &str, tool_match: &str, command: &str) -> HookDef {
HookDef {
name: name.to_string(),
tool_match: vec![tool_match.to_string()],
file_match: vec![],
command: command.to_string(),
timeout_ms: 5_000,
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
/// Single tool call executes and returns the expected result
#[tokio::test]
async fn test_execute_single_tool_call() {
let mut registry = ToolRegistry::new();
registry.register(Box::new(MockTool::new("echo", "hello", false)));
let tool_calls = vec![make_tool_use("call-1", "echo")];
let confirmer = auto_approve_confirmer();
let results = execute_tool_calls(
&registry,
&tool_calls,
&confirmer,
None,
CompactionLevel::Off,
false,
)
.await
.expect("execution should succeed");
assert_eq!(results.len(), 1);
match &results[0] {
ContentBlock::ToolResult {
tool_use_id,
content,
is_error,
..
} => {
assert_eq!(tool_use_id, "call-1");
assert_eq!(content, "hello");
assert!(!is_error);
}
other => panic!("expected ToolResult, got {:?}", other),
}
}
/// Two concurrent-safe tools execute in parallel and both return results
#[tokio::test]
async fn test_execute_concurrent_safe_tools() {
let mut registry = ToolRegistry::new();
registry.register(Box::new(MockTool::new("tool_a", "result_a", false)));
registry.register(Box::new(MockTool::new("tool_b", "result_b", false)));
let tool_calls = vec![
make_tool_use("id-a", "tool_a"),
make_tool_use("id-b", "tool_b"),
];
let confirmer = auto_approve_confirmer();
let results = execute_tool_calls(
&registry,
&tool_calls,
&confirmer,
None,
CompactionLevel::Off,
false,
)
.await
.expect("execution should succeed");
assert_eq!(results.len(), 2);
// Collect content strings keyed by tool_use_id for order-independent assertion
let content_map: std::collections::HashMap<_, _> = results
.iter()
.filter_map(|r| match r {
ContentBlock::ToolResult {
tool_use_id,
content,
..
} => Some((tool_use_id.as_str(), content.as_str())),
_ => None,
})
.collect();
assert_eq!(content_map.get("id-a"), Some(&"result_a"));
assert_eq!(content_map.get("id-b"), Some(&"result_b"));
}
/// Two sequential (non-concurrent) tools execute one after the other and both succeed
#[tokio::test]
async fn test_execute_non_concurrent_tools_sequential() {
let mut registry = ToolRegistry::new();
registry.register(Box::new(MockTool::sequential("seq_a", "seq_result_a")));
registry.register(Box::new(MockTool::sequential("seq_b", "seq_result_b")));
let tool_calls = vec![
make_tool_use("id-a", "seq_a"),
make_tool_use("id-b", "seq_b"),
];
let confirmer = auto_approve_confirmer();
let results = execute_tool_calls(
&registry,
&tool_calls,
&confirmer,
None,
CompactionLevel::Off,
false,
)
.await
.expect("execution should succeed");
assert_eq!(results.len(), 2);
let content_map: std::collections::HashMap<_, _> = results
.iter()
.filter_map(|r| match r {
ContentBlock::ToolResult {
tool_use_id,
content,
..
} => Some((tool_use_id.as_str(), content.as_str())),
_ => None,
})
.collect();
assert_eq!(content_map.get("id-a"), Some(&"seq_result_a"));
assert_eq!(content_map.get("id-b"), Some(&"seq_result_b"));
}
/// Calling a tool that is not registered returns an error ToolResult with "Unknown tool"
#[tokio::test]
async fn test_unknown_tool_returns_error() {
let registry = ToolRegistry::new(); // empty registry
let tool_calls = vec![make_tool_use("id-x", "nonexistent_tool")];
let confirmer = auto_approve_confirmer();
let results = execute_tool_calls(
&registry,
&tool_calls,
&confirmer,
None,
CompactionLevel::Off,
false,
)
.await
.expect("execute_tool_calls itself should not fail");
assert_eq!(results.len(), 1);
match &results[0] {
ContentBlock::ToolResult {
content, is_error, ..
} => {
assert!(*is_error, "unknown tool should produce is_error = true");
assert!(
content.contains("Unknown tool"),
"error message should mention 'Unknown tool', got: {}",
content
);
}
other => panic!("expected ToolResult, got {:?}", other),
}
}
/// A tool that signals an error surfaces is_error = true in the result
#[tokio::test]
async fn test_tool_error_returns_error_result() {
let mut registry = ToolRegistry::new();
registry.register(Box::new(MockTool::new("fail_tool", "error message", true)));
let tool_calls = vec![make_tool_use("id-fail", "fail_tool")];
let confirmer = auto_approve_confirmer();
let results = execute_tool_calls(
&registry,
&tool_calls,
&confirmer,
None,
CompactionLevel::Off,
false,
)
.await
.expect("execution should succeed");
assert_eq!(results.len(), 1);
match &results[0] {
ContentBlock::ToolResult {
content, is_error, ..
} => {
assert!(*is_error, "tool error should propagate as is_error = true");
assert_eq!(content, "error message");
}
other => panic!("expected ToolResult, got {:?}", other),
}
}
/// A pre-tool-use hook that exits with a non-zero status blocks tool execution
#[tokio::test]
async fn test_pre_hook_blocks_tool() {
let hook_config = HooksConfig {
pre_tool_use: vec![make_pre_hook("blocker", "echo", "exit 1")],
post_tool_use: vec![],
stop: vec![],
};
let mut hook_engine = HookEngine::new(hook_config, std::env::temp_dir());
let mut registry = ToolRegistry::new();
registry.register(Box::new(MockTool::new("echo", "should not appear", false)));
let tool_calls = vec![make_tool_use("id-blocked", "echo")];
let confirmer = auto_approve_confirmer();
let results = execute_tool_calls(
&registry,
&tool_calls,
&confirmer,
Some(&mut hook_engine),
CompactionLevel::Off,
false,
)
.await
.expect("execute_tool_calls itself should not fail");
assert_eq!(results.len(), 1);
match &results[0] {
ContentBlock::ToolResult {
content, is_error, ..
} => {
assert!(
*is_error,
"blocked execution should produce is_error = true"
);
assert!(
content.contains("Blocked by hook"),
"result should mention 'Blocked by hook', got: {}",
content
);
}
other => panic!("expected ToolResult, got {:?}", other),
}
}
/// A post-tool-use hook runs after the tool but does not alter the tool's result
#[tokio::test]
async fn test_post_hook_runs_after_tool() {
let hook_config = HooksConfig {
pre_tool_use: vec![],
post_tool_use: vec![make_post_hook("post-logger", "echo", "echo done")],
stop: vec![],
};
let mut hook_engine = HookEngine::new(hook_config, std::env::temp_dir());
let mut registry = ToolRegistry::new();
registry.register(Box::new(MockTool::new("echo", "result", false)));
let tool_calls = vec![make_tool_use("id-post", "echo")];
let confirmer = auto_approve_confirmer();
let results = execute_tool_calls(
&registry,
&tool_calls,
&confirmer,
Some(&mut hook_engine),
CompactionLevel::Off,
false,
)
.await
.expect("execution should succeed");
assert_eq!(results.len(), 1);
match &results[0] {
ContentBlock::ToolResult {
content, is_error, ..
} => {
// Post-hooks must not mutate the tool result
assert!(!is_error);
assert_eq!(content, "result");
}
other => panic!("expected ToolResult, got {:?}", other),
}
}
/// Results that exceed max_result_size are truncated with a "[truncated N chars]" marker
#[tokio::test]
async fn test_tool_result_truncation() {
// Default max_result_size is 50_000; build a result that exceeds it
let long_result: String = "x".repeat(60_000);
let mut registry = ToolRegistry::new();
registry.register(Box::new(MockTool::new("big_tool", &long_result, false)));
let tool_calls = vec![make_tool_use("id-big", "big_tool")];
let confirmer = auto_approve_confirmer();
let results = execute_tool_calls(
&registry,
&tool_calls,
&confirmer,
None,
CompactionLevel::Off,
false,
)
.await
.expect("execution should succeed");
assert_eq!(results.len(), 1);
match &results[0] {
ContentBlock::ToolResult {
content, is_error, ..
} => {
assert!(!is_error);
assert!(
content.len() < long_result.len(),
"truncated result should be shorter than the original"
);
assert!(
content.contains("truncated"),
"truncated result should contain the word 'truncated', got length {}",
content.len()
);
}
other => panic!("expected ToolResult, got {:?}", other),
}
}
@@ -0,0 +1,494 @@
mod common;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use tokio::sync::mpsc;
use common::{MockLlmProvider, MockTool, auto_approve_confirmer, test_config};
use nomi_agent::context::{SystemPromptCache, build_system_prompt};
use nomi_agent::engine::AgentEngine;
use nomi_agent::orchestration::execute_tool_calls;
use nomi_agent::output::OutputSink;
use nomi_agent::output::null_sink::NullSink;
use nomi_compact::CompactionLevel;
use nomi_providers::{LlmProvider, ProviderError};
use nomi_tools::registry::ToolRegistry;
use nomi_types::llm::{LlmEvent, LlmRequest};
use nomi_types::message::{ContentBlock, StopReason, TokenUsage};
use serde_json::json;
const TEST_OUTPUT: &str = "\x1b[32mSTATUS: OK\x1b[0m\n\n\n\n50%\r100%\nCompiling dep-0 v1.0.0\nCompiling dep-1 v1.0.0\nCompiling dep-2 v1.0.0\nCompiling dep-3 v1.0.0\nCompiling dep-4 v1.0.0\n{\n \"id\": 1,\n \"name\": \"Alice Wonderland\",\n \"email\": \"alice@example.com\",\n \"age\": 30,\n \"address\": \"123 Main Street, Anytown, USA 12345\",\n \"phone\": \"+1-555-0123\"\n}";
const TOON_INPUT: &str =
r#"[{"id":1,"name":"Alice","role":"admin"},{"id":2,"name":"Bob","role":"user"}]"#;
fn make_tool_use(id: &str, name: &str) -> ContentBlock {
ContentBlock::ToolUse {
id: id.to_string(),
name: name.to_string(),
input: json!({}),
extra: None,
}
}
fn extract_tool_result_content(blocks: &[ContentBlock]) -> &str {
for block in blocks {
if let ContentBlock::ToolResult { content, .. } = block {
return content;
}
}
panic!("no ToolResult found in blocks");
}
// ---------------------------------------------------------------------------
// A Layer: Case 1-3 (Off / Safe / Full)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn case_1_off_passthrough() {
let mut registry = ToolRegistry::new();
registry.register(Box::new(MockTool::new("test_tool", TEST_OUTPUT, false)));
let tool_calls = vec![make_tool_use("c1", "test_tool")];
let confirmer = auto_approve_confirmer();
let outcome = execute_tool_calls(
&registry,
&tool_calls,
&confirmer,
None,
CompactionLevel::Off,
false,
)
.await
.expect("should succeed");
let content = extract_tool_result_content(&outcome);
eprintln!("[compaction:A] === Case 1: Off passthrough ===");
eprintln!(
"[compaction:A] raw ({} chars): {:?}",
TEST_OUTPUT.len(),
&TEST_OUTPUT[..60]
);
eprintln!(
"[compaction:A] result ({} chars): {:?}",
content.len(),
&content[..60]
);
assert_eq!(
content, TEST_OUTPUT,
"Off level should pass content through unchanged"
);
eprintln!("[compaction:A] ✓ content unchanged");
}
#[tokio::test]
async fn case_2_safe_sanitizes() {
let mut registry = ToolRegistry::new();
registry.register(Box::new(MockTool::new("test_tool", TEST_OUTPUT, false)));
let tool_calls = vec![make_tool_use("c2", "test_tool")];
let confirmer = auto_approve_confirmer();
let outcome = execute_tool_calls(
&registry,
&tool_calls,
&confirmer,
None,
CompactionLevel::Safe,
false,
)
.await
.expect("should succeed");
let content = extract_tool_result_content(&outcome);
eprintln!("[compaction:A] === Case 2: Safe sanitizes ===");
eprintln!("[compaction:A] raw ({} chars)", TEST_OUTPUT.len());
eprintln!(
"[compaction:A] result ({} chars): {:?}",
content.len(),
content
);
assert!(!content.contains("\x1b"), "Safe should strip ANSI escapes");
assert!(!content.contains("\n\n\n"), "Safe should merge blank lines");
assert!(!content.contains("\r"), "Safe should collapse CR lines");
assert!(
content.contains("Compiling dep-0"),
"Safe should keep all repeated lines"
);
assert!(
content.contains("Compiling dep-4"),
"Safe should keep all repeated lines"
);
assert!(
content.contains(" \"id\""),
"Safe should preserve original JSON indentation"
);
eprintln!(
"[compaction:A] ✓ ANSI stripped, blanks merged, CR collapsed, repeats & JSON untouched"
);
}
#[tokio::test]
async fn case_3_full_folds_and_compacts() {
let mut registry = ToolRegistry::new();
registry.register(Box::new(MockTool::new("test_tool", TEST_OUTPUT, false)));
let tool_calls = vec![make_tool_use("c3", "test_tool")];
let confirmer = auto_approve_confirmer();
let outcome = execute_tool_calls(
&registry,
&tool_calls,
&confirmer,
None,
CompactionLevel::Full,
false,
)
.await
.expect("should succeed");
let content = extract_tool_result_content(&outcome);
eprintln!("[compaction:A] === Case 3: Full folds and compacts ===");
eprintln!("[compaction:A] raw ({} chars)", TEST_OUTPUT.len());
eprintln!(
"[compaction:A] result ({} chars): {:?}",
content.len(),
content
);
assert!(!content.contains("\x1b"), "Full should strip ANSI");
assert!(
content.contains("similar lines") || content.contains("identical lines"),
"Full should fold repeated lines: {content}"
);
assert!(
content.len() < TEST_OUTPUT.len(),
"Full should produce shorter output: {} vs {}",
content.len(),
TEST_OUTPUT.len()
);
eprintln!("[compaction:A] ✓ ANSI stripped, lines folded, output shorter");
}
// ---------------------------------------------------------------------------
// A Layer: Case 4-5 (TOON on / off)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn case_4_toon_encodes_array() {
let mut registry = ToolRegistry::new();
registry.register(Box::new(MockTool::new("test_tool", TOON_INPUT, false)));
let tool_calls = vec![make_tool_use("c4", "test_tool")];
let confirmer = auto_approve_confirmer();
let outcome = execute_tool_calls(
&registry,
&tool_calls,
&confirmer,
None,
CompactionLevel::Full,
true,
)
.await
.expect("should succeed");
let content = extract_tool_result_content(&outcome);
eprintln!("[compaction:A] === Case 4: TOON encodes array ===");
eprintln!("[compaction:A] raw: {TOON_INPUT}");
eprintln!("[compaction:A] result: {content}");
assert!(
content.contains("[2]{id,name,role}:"),
"TOON should produce header: {content}"
);
assert!(content.contains("Alice"), "TOON should contain data");
assert!(content.contains("Bob"), "TOON should contain data");
eprintln!("[compaction:A] ✓ TOON header present with data rows");
}
#[tokio::test]
async fn case_5_toon_disabled_no_encoding() {
let mut registry = ToolRegistry::new();
registry.register(Box::new(MockTool::new("test_tool", TOON_INPUT, false)));
let tool_calls = vec![make_tool_use("c5", "test_tool")];
let confirmer = auto_approve_confirmer();
let outcome = execute_tool_calls(
&registry,
&tool_calls,
&confirmer,
None,
CompactionLevel::Full,
false,
)
.await
.expect("should succeed");
let content = extract_tool_result_content(&outcome);
eprintln!("[compaction:A] === Case 5: TOON disabled ===");
eprintln!("[compaction:A] raw: {TOON_INPUT}");
eprintln!("[compaction:A] result: {content}");
assert!(
!content.contains("[2]{id,name,role}:"),
"TOON off should not produce TOON header: {content}"
);
eprintln!("[compaction:A] ✓ no TOON encoding when disabled");
}
// ---------------------------------------------------------------------------
// CapturingProvider — wraps MockLlmProvider, records each LlmRequest
// ---------------------------------------------------------------------------
struct CapturingProvider {
inner: MockLlmProvider,
captured: Arc<Mutex<Vec<LlmRequest>>>,
}
#[async_trait]
impl LlmProvider for CapturingProvider {
async fn stream(
&self,
request: &LlmRequest,
) -> Result<mpsc::Receiver<LlmEvent>, ProviderError> {
self.captured.lock().unwrap().push(request.clone());
self.inner.stream(request).await
}
}
// ---------------------------------------------------------------------------
// B Layer: Case 6 (compressed content reaches LLM)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn case_6_compressed_content_reaches_llm() {
let captured: Arc<Mutex<Vec<LlmRequest>>> = Arc::new(Mutex::new(Vec::new()));
let provider = CapturingProvider {
inner: MockLlmProvider::with_turns(vec![
vec![
LlmEvent::ToolUse {
id: "t1".to_string(),
name: "test_tool".to_string(),
input: json!({}),
extra: None,
},
LlmEvent::Done {
stop_reason: StopReason::ToolUse,
usage: TokenUsage::default(),
},
],
vec![
LlmEvent::TextDelta("done".to_string()),
LlmEvent::Done {
stop_reason: StopReason::EndTurn,
usage: TokenUsage::default(),
},
],
]),
captured: captured.clone(),
};
let mut config = test_config();
config.compact.compaction = CompactionLevel::Full;
config.compact.toon = false;
let mut registry = ToolRegistry::new();
registry.register(Box::new(MockTool::new("test_tool", TEST_OUTPUT, false)));
let output: Arc<dyn OutputSink> = Arc::new(NullSink);
let mut engine = AgentEngine::new_with_provider(
Arc::new(provider),
config,
registry,
output,
std::env::temp_dir(),
);
engine
.run("call test_tool", "")
.await
.expect("engine.run should succeed");
let requests = captured.lock().unwrap();
eprintln!("[compaction:B] === Case 6: Compressed content reaches LLM ===");
eprintln!("[compaction:B] captured {} LlmRequests", requests.len());
assert!(
requests.len() >= 2,
"should have at least 2 requests (initial + after tool)"
);
let second_req = &requests[1];
let mut found_tool_result = false;
for msg in &second_req.messages {
for block in &msg.content {
if let ContentBlock::ToolResult { content, .. } = block {
eprintln!(
"[compaction:B] tool_result content ({} chars): {:?}",
content.len(),
content
);
assert!(!content.contains("\x1b"), "LLM should not see ANSI escapes");
assert!(
content.contains("similar lines") || content.contains("identical lines"),
"LLM should see folded lines: {content}"
);
found_tool_result = true;
}
}
}
assert!(
found_tool_result,
"second request should contain a ToolResult"
);
eprintln!("[compaction:B] ✓ LLM received compressed content");
}
// ---------------------------------------------------------------------------
// B Layer: Case 7 (runtime compaction switch)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn case_7_runtime_compaction_switch() {
let mut registry = ToolRegistry::new();
registry.register(Box::new(MockTool::new("test_tool", TEST_OUTPUT, false)));
let tool_calls = vec![make_tool_use("c7", "test_tool")];
let confirmer = auto_approve_confirmer();
let outcome_off = execute_tool_calls(
&registry,
&tool_calls,
&confirmer,
None,
CompactionLevel::Off,
false,
)
.await
.expect("should succeed");
let content_off = extract_tool_result_content(&outcome_off).to_string();
let outcome_full = execute_tool_calls(
&registry,
&tool_calls,
&confirmer,
None,
CompactionLevel::Full,
false,
)
.await
.expect("should succeed");
let content_full = extract_tool_result_content(&outcome_full).to_string();
eprintln!("[compaction:B] === Case 7: Runtime compaction switch ===");
eprintln!("[compaction:B] Off content ({} chars)", content_off.len());
eprintln!("[compaction:B] Full content ({} chars)", content_full.len());
assert_ne!(
content_off, content_full,
"Off and Full should produce different content"
);
assert!(content_off.contains("\x1b"), "Off should preserve ANSI");
assert!(!content_full.contains("\x1b"), "Full should strip ANSI");
assert!(
content_full.contains("similar lines") || content_full.contains("identical lines"),
"Full should fold lines"
);
// Verify apply_config_update works on the engine
let mut config = test_config();
config.compact.compaction = CompactionLevel::Off;
let registry_engine = ToolRegistry::new();
let output: Arc<dyn OutputSink> = Arc::new(NullSink);
let mut engine = AgentEngine::new_with_provider(
Arc::new(MockLlmProvider::with_text_response("ok")),
config,
registry_engine,
output,
std::env::temp_dir(),
);
assert_eq!(engine.compaction_level(), CompactionLevel::Off);
let changes = engine.apply_config_update(None, None, None, None, Some("full".to_string()));
assert!(!changes.is_empty(), "should report changes");
assert_eq!(engine.compaction_level(), CompactionLevel::Full);
eprintln!("[compaction:B] apply_config_update changes: {:?}", changes);
eprintln!("[compaction:B] ✓ runtime switch from Off to Full verified");
}
// ---------------------------------------------------------------------------
// B Layer: Case 8 (TOON system prompt injection)
// ---------------------------------------------------------------------------
#[test]
fn case_8_toon_system_prompt_injection() {
eprintln!("[compaction:B] === Case 8: TOON system prompt injection ===");
// TOON enabled
let mut cache_on = SystemPromptCache::new();
let prompt_on = build_system_prompt(
&mut cache_on,
Some("You are a test assistant."),
"/tmp",
"test-model",
&[],
None,
None,
false,
true, // toon_enabled
false, // browser_enabled
);
eprintln!(
"[compaction:B] TOON=true system prompt length: {} chars",
prompt_on.len()
);
assert!(
prompt_on.contains("TOON"),
"TOON enabled: system prompt should mention TOON"
);
assert!(
prompt_on.contains("Token-Oriented Object Notation"),
"should contain full TOON description"
);
// TOON disabled
let mut cache_off = SystemPromptCache::new();
let prompt_off = build_system_prompt(
&mut cache_off,
Some("You are a test assistant."),
"/tmp",
"test-model",
&[],
None,
None,
false,
false, // toon_enabled
false, // browser_enabled
);
eprintln!(
"[compaction:B] TOON=false system prompt length: {} chars",
prompt_off.len()
);
assert!(
!prompt_off.contains("TOON"),
"TOON disabled: system prompt should NOT mention TOON"
);
eprintln!("[compaction:B] ✓ TOON system prompt injection verified");
}
@@ -0,0 +1,387 @@
//! End-to-end integration tests for Plan Mode (task 3.6).
//!
//! Tests are numbered to match the test-plan.md identifiers (TC-3.6-E2E-*).
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use async_trait::async_trait;
use nomi_agent::plan::prompt::plan_mode_instructions;
use nomi_agent::plan::tools::{EnterPlanModeTool, ExitPlanModeTool};
use nomi_protocol::events::ToolCategory;
use nomi_tools::Tool;
use nomi_tools::registry::ToolRegistry;
use nomi_types::skill_types::PlanModeTransition;
use serde_json::json;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
struct MockTool {
tool_name: String,
tool_category: ToolCategory,
}
#[async_trait]
impl Tool for MockTool {
fn name(&self) -> &str {
&self.tool_name
}
fn description(&self) -> &str {
"mock tool"
}
fn input_schema(&self) -> serde_json::Value {
json!({"type": "object", "properties": {}, "required": []})
}
fn is_concurrency_safe(&self, _input: &serde_json::Value) -> bool {
true
}
async fn execute(&self, _input: serde_json::Value) -> nomi_types::tool::ToolResult {
nomi_types::tool::ToolResult {
content: format!("{} executed", self.tool_name),
is_error: false,
images: Vec::new(),
}
}
fn category(&self) -> ToolCategory {
self.tool_category
}
}
fn mock_tool(name: &str, category: ToolCategory) -> Box<MockTool> {
Box::new(MockTool {
tool_name: name.to_string(),
tool_category: category,
})
}
/// Simulate the plan mode filter the engine applies in its run() loop.
fn plan_mode_filter(registry: &ToolRegistry) -> Vec<String> {
registry
.to_tool_defs_filtered(|t| {
t.category() == ToolCategory::Info && t.name() != "EnterPlanMode"
})
.iter()
.map(|d| d.name.clone())
.collect()
}
/// Simulate the normal mode filter the engine applies in its run() loop.
fn normal_mode_filter(registry: &ToolRegistry) -> Vec<String> {
registry
.to_tool_defs_filtered(|t| t.name() != "ExitPlanMode")
.iter()
.map(|d| d.name.clone())
.collect()
}
// ---------------------------------------------------------------------------
// TC-3.6-E2E-01: Full plan mode lifecycle
//
// Verifies the complete flow: normal → enter plan mode → use read-only tools
// → exit plan mode → write tools restored.
// ---------------------------------------------------------------------------
#[tokio::test]
async fn tc_3_6_e2e_01_full_plan_mode_lifecycle() {
let flag = Arc::new(AtomicBool::new(false));
let mut registry = ToolRegistry::new();
// Register standard tools
registry.register(mock_tool("Read", ToolCategory::Info));
registry.register(mock_tool("Grep", ToolCategory::Info));
registry.register(mock_tool("Glob", ToolCategory::Info));
registry.register(mock_tool("Skill", ToolCategory::Info));
registry.register(mock_tool("Write", ToolCategory::Edit));
registry.register(mock_tool("Edit", ToolCategory::Edit));
registry.register(mock_tool("Bash", ToolCategory::Exec));
registry.register(Box::new(EnterPlanModeTool::new(Arc::clone(&flag))));
registry.register(Box::new(ExitPlanModeTool::new(Arc::clone(&flag))));
// Step 1: Verify normal mode — all tools except ExitPlanMode
let normal_tools = normal_mode_filter(&registry);
assert!(normal_tools.contains(&"Read".to_string()));
assert!(normal_tools.contains(&"Write".to_string()));
assert!(normal_tools.contains(&"Bash".to_string()));
assert!(normal_tools.contains(&"EnterPlanMode".to_string()));
assert!(!normal_tools.contains(&"ExitPlanMode".to_string()));
// Step 2: LLM calls EnterPlanMode
let enter_tool = EnterPlanModeTool::new(Arc::clone(&flag));
let result = enter_tool.execute(json!({})).await;
assert!(!result.is_error, "EnterPlanMode should succeed");
// Verify context modifier signals Enter transition
let cm = enter_tool.context_modifier_for(&json!({})).unwrap();
assert_eq!(cm.plan_mode_transition, Some(PlanModeTransition::Enter));
// Simulate engine processing the transition
flag.store(true, Ordering::Release);
// Step 3: Verify plan mode — only read-only tools + ExitPlanMode
let plan_tools = plan_mode_filter(&registry);
assert!(plan_tools.contains(&"Read".to_string()));
assert!(plan_tools.contains(&"Grep".to_string()));
assert!(plan_tools.contains(&"Glob".to_string()));
assert!(plan_tools.contains(&"Skill".to_string()));
assert!(plan_tools.contains(&"ExitPlanMode".to_string()));
assert!(!plan_tools.contains(&"Write".to_string()));
assert!(!plan_tools.contains(&"Edit".to_string()));
assert!(!plan_tools.contains(&"Bash".to_string()));
assert!(!plan_tools.contains(&"EnterPlanMode".to_string()));
// Step 4: Verify read-only tools execute successfully in plan mode
let read_tool = mock_tool("Read", ToolCategory::Info);
let read_result = read_tool.execute(json!({})).await;
assert!(!read_result.is_error, "Read should work in plan mode");
// Step 5: Verify double-enter is rejected
let double_enter = enter_tool.execute(json!({})).await;
assert!(double_enter.is_error, "double-enter should be rejected");
// Step 6: LLM calls ExitPlanMode
let exit_tool = ExitPlanModeTool::new(Arc::clone(&flag));
let exit_result = exit_tool.execute(json!({})).await;
assert!(!exit_result.is_error, "ExitPlanMode should succeed");
// Verify context modifier signals Exit transition
let exit_cm = exit_tool.context_modifier_for(&json!({})).unwrap();
assert!(matches!(
exit_cm.plan_mode_transition,
Some(PlanModeTransition::Exit { .. })
));
// Simulate engine processing the transition
flag.store(false, Ordering::Release);
// Step 7: Verify normal mode restored — write tools available again
let restored_tools = normal_mode_filter(&registry);
assert!(restored_tools.contains(&"Write".to_string()));
assert!(restored_tools.contains(&"Edit".to_string()));
assert!(restored_tools.contains(&"Bash".to_string()));
assert!(restored_tools.contains(&"EnterPlanMode".to_string()));
assert!(!restored_tools.contains(&"ExitPlanMode".to_string()));
// Step 8: Verify double-exit is rejected
let double_exit = exit_tool.execute(json!({})).await;
assert!(double_exit.is_error, "double-exit should be rejected");
}
// ---------------------------------------------------------------------------
// TC-3.6-E2E-02: Plan mode + compaction don't conflict
//
// Verifies that microcompact does not interfere with plan mode state.
// Uses tool result clearing to simulate compaction activity.
// ---------------------------------------------------------------------------
#[test]
fn tc_3_6_e2e_02_plan_mode_and_compaction_independent() {
use nomi_agent::compact::micro::microcompact;
use nomi_agent::plan::state::PlanState;
use nomi_config::compact::CompactConfig;
use nomi_types::message::{ContentBlock, Message, Role};
// Build messages with compactable tool results
let mut messages = Vec::new();
for i in 0..8 {
let id = format!("t{i}");
messages.push(Message::new(
Role::Assistant,
vec![ContentBlock::ToolUse {
id: id.clone(),
name: "Read".to_string(),
input: json!({}),
extra: None,
}],
));
messages.push(Message::new(
Role::User,
vec![ContentBlock::ToolResult {
tool_use_id: id,
content: format!("data-{i}"),
is_error: false,
images: Vec::new(),
}],
));
}
// Create plan state simulating active plan mode
let plan_state = PlanState {
is_active: true,
pre_plan_allow_list: vec!["Read".to_string(), "Grep".to_string()],
};
// Actually run microcompact
let config = CompactConfig {
micro_keep_recent: 3,
..CompactConfig::default()
};
let result = microcompact(&mut messages, &config);
// Microcompact should have cleared some results
assert!(
result.cleared_count > 0,
"microcompact should clear old results"
);
// Plan state should be completely unaffected (microcompact only touches messages)
assert!(plan_state.is_active, "plan mode should remain active");
assert_eq!(
plan_state.pre_plan_allow_list,
vec!["Read".to_string(), "Grep".to_string()],
"allow list should be unchanged"
);
}
// ---------------------------------------------------------------------------
// TC-3.6-E2E-03: SkillTool available in plan mode
//
// Verifies that Skill (category: Info) is available in plan mode.
// ---------------------------------------------------------------------------
#[test]
fn tc_3_6_e2e_03_skill_tool_available_in_plan_mode() {
let flag = Arc::new(AtomicBool::new(false));
let mut registry = ToolRegistry::new();
registry.register(mock_tool("Read", ToolCategory::Info));
registry.register(mock_tool("Skill", ToolCategory::Info));
registry.register(mock_tool("Write", ToolCategory::Edit));
registry.register(mock_tool("Bash", ToolCategory::Exec));
registry.register(Box::new(EnterPlanModeTool::new(Arc::clone(&flag))));
registry.register(Box::new(ExitPlanModeTool::new(Arc::clone(&flag))));
// Plan mode filter
let plan_tools = plan_mode_filter(&registry);
assert!(
plan_tools.contains(&"Skill".to_string()),
"Skill tool (Info category) should be available in plan mode"
);
assert!(
plan_tools.contains(&"Read".to_string()),
"Read should be available"
);
assert!(
!plan_tools.contains(&"Write".to_string()),
"Write should not be available"
);
}
// ---------------------------------------------------------------------------
// TC-3.6-E2E-04: Plan mode state is runtime-only, not persisted
//
// Verifies that PlanState defaults to inactive — session resume starts fresh.
// ---------------------------------------------------------------------------
#[test]
fn tc_3_6_e2e_04_plan_state_not_persisted_across_sessions() {
use nomi_agent::plan::state::PlanState;
// Simulate a "previous session" where plan mode was active
let active_state = PlanState {
is_active: true,
pre_plan_allow_list: vec!["Read".into(), "Bash".into()],
};
assert!(active_state.is_active);
// On session resume, engine creates PlanState::default() (see engine.rs resume_with_provider)
let resumed_state = PlanState::default();
assert!(
!resumed_state.is_active,
"plan state should be inactive after session resume"
);
assert!(
resumed_state.pre_plan_allow_list.is_empty(),
"allow list should be empty after resume"
);
}
// ---------------------------------------------------------------------------
// Additional: System prompt reflects plan mode transitions
// ---------------------------------------------------------------------------
#[test]
fn system_prompt_tracks_plan_mode_transitions() {
let base_prompt = "You are an AI assistant.";
let instructions = plan_mode_instructions();
// Normal mode: no plan instructions
assert!(
!base_prompt.contains("Plan Mode"),
"base prompt should not mention plan mode"
);
// Enter plan mode: instructions appended
let active_prompt = format!("{}\n\n{}", base_prompt, instructions);
assert!(active_prompt.contains("# Plan Mode"));
assert!(active_prompt.contains("MUST NOT"));
assert!(active_prompt.contains("ExitPlanMode"));
assert!(active_prompt.contains(base_prompt));
// Exit plan mode: back to base prompt only
let exited_prompt = base_prompt.to_string();
assert!(!exited_prompt.contains("# Plan Mode"));
}
// ---------------------------------------------------------------------------
// Additional: Multiple enter-exit cycles maintain consistency
// ---------------------------------------------------------------------------
#[tokio::test]
async fn multiple_plan_mode_cycles_consistent() {
let flag = Arc::new(AtomicBool::new(false));
let enter = EnterPlanModeTool::new(Arc::clone(&flag));
let exit = ExitPlanModeTool::new(Arc::clone(&flag));
for cycle in 0..3 {
// Enter should succeed
let r = enter.execute(json!({})).await;
assert!(!r.is_error, "enter should succeed on cycle {cycle}");
flag.store(true, Ordering::Release);
// Exit should succeed
let r = exit.execute(json!({})).await;
assert!(!r.is_error, "exit should succeed on cycle {cycle}");
flag.store(false, Ordering::Release);
}
}
// ---------------------------------------------------------------------------
// Additional: Plan mode context_modifier fields are orthogonal
// ---------------------------------------------------------------------------
#[test]
fn plan_mode_modifiers_do_not_interfere_with_other_fields() {
let enter = EnterPlanModeTool::new(Arc::new(AtomicBool::new(false)));
let exit = ExitPlanModeTool::new(Arc::new(AtomicBool::new(true)));
// Enter modifier should only set plan_mode_transition
let enter_cm = enter.context_modifier_for(&json!({})).unwrap();
assert!(enter_cm.model.is_none());
assert!(enter_cm.effort.is_none());
assert!(enter_cm.allowed_tools.is_empty());
assert_eq!(
enter_cm.plan_mode_transition,
Some(PlanModeTransition::Enter)
);
// Exit modifier should only set plan_mode_transition
let exit_cm = exit.context_modifier_for(&json!({})).unwrap();
assert!(exit_cm.model.is_none());
assert!(exit_cm.effort.is_none());
assert!(exit_cm.allowed_tools.is_empty());
assert!(matches!(
exit_cm.plan_mode_transition,
Some(PlanModeTransition::Exit { .. })
));
}
@@ -0,0 +1,315 @@
//! Integration tests for Plan Mode engine integration (task 3.5).
//!
//! Tests are numbered to match the test-plan.md identifiers (TC-3.5-*).
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use async_trait::async_trait;
use nomi_agent::plan::tools::{EnterPlanModeTool, ExitPlanModeTool};
use nomi_protocol::events::ToolCategory;
use nomi_tools::Tool;
use nomi_tools::registry::ToolRegistry;
use serde_json::json;
// ---------------------------------------------------------------------------
// Helpers: mock tools with configurable categories
// ---------------------------------------------------------------------------
struct MockTool {
tool_name: String,
tool_category: ToolCategory,
}
#[async_trait]
impl Tool for MockTool {
fn name(&self) -> &str {
&self.tool_name
}
fn description(&self) -> &str {
"mock tool"
}
fn input_schema(&self) -> serde_json::Value {
json!({"type": "object", "properties": {}, "required": []})
}
fn is_concurrency_safe(&self, _input: &serde_json::Value) -> bool {
true
}
async fn execute(&self, _input: serde_json::Value) -> nomi_types::tool::ToolResult {
nomi_types::tool::ToolResult {
content: "ok".to_string(),
is_error: false,
images: Vec::new(),
}
}
fn category(&self) -> ToolCategory {
self.tool_category
}
}
fn mock_tool(name: &str, category: ToolCategory) -> Box<MockTool> {
Box::new(MockTool {
tool_name: name.to_string(),
tool_category: category,
})
}
/// Build a registry with typical tools + plan mode tools
fn build_test_registry() -> ToolRegistry {
let flag = Arc::new(AtomicBool::new(false));
let mut registry = ToolRegistry::new();
// Info tools
registry.register(mock_tool("Read", ToolCategory::Info));
registry.register(mock_tool("Grep", ToolCategory::Info));
registry.register(mock_tool("Glob", ToolCategory::Info));
registry.register(mock_tool("Skill", ToolCategory::Info));
// Edit tools
registry.register(mock_tool("Write", ToolCategory::Edit));
registry.register(mock_tool("Edit", ToolCategory::Edit));
// Exec tools
registry.register(mock_tool("Bash", ToolCategory::Exec));
// Plan mode tools (both are Info category)
registry.register(Box::new(EnterPlanModeTool::new(Arc::clone(&flag))));
registry.register(Box::new(ExitPlanModeTool::new(Arc::clone(&flag))));
registry
}
// ---------------------------------------------------------------------------
// TC-3.5-01: Plan mode filters to only Info tools + ExitPlanMode
// ---------------------------------------------------------------------------
#[test]
fn tc_3_5_01_plan_mode_only_info_tools_plus_exit() {
let registry = build_test_registry();
// Plan mode filter: Info category except EnterPlanMode
let defs = registry.to_tool_defs_filtered(|t| {
t.category() == ToolCategory::Info && t.name() != "EnterPlanMode"
});
let names: Vec<&str> = defs.iter().map(|d| d.name.as_str()).collect();
// Should include Info tools
assert!(
names.contains(&"Read"),
"Read should be available in plan mode"
);
assert!(
names.contains(&"Grep"),
"Grep should be available in plan mode"
);
assert!(
names.contains(&"Glob"),
"Glob should be available in plan mode"
);
assert!(
names.contains(&"Skill"),
"Skill should be available in plan mode"
);
assert!(
names.contains(&"ExitPlanMode"),
"ExitPlanMode should be available in plan mode"
);
// Should NOT include write/exec/EnterPlanMode
assert!(
!names.contains(&"Write"),
"Write should NOT be in plan mode"
);
assert!(!names.contains(&"Edit"), "Edit should NOT be in plan mode");
assert!(!names.contains(&"Bash"), "Bash should NOT be in plan mode");
assert!(
!names.contains(&"EnterPlanMode"),
"EnterPlanMode should NOT be in plan mode"
);
}
// ---------------------------------------------------------------------------
// TC-3.5-02: Normal mode includes all tools except ExitPlanMode
// ---------------------------------------------------------------------------
#[test]
fn tc_3_5_02_normal_mode_all_tools_except_exit_plan_mode() {
let registry = build_test_registry();
// Normal mode filter: everything except ExitPlanMode
let defs = registry.to_tool_defs_filtered(|t| t.name() != "ExitPlanMode");
let names: Vec<&str> = defs.iter().map(|d| d.name.as_str()).collect();
// Should include all standard tools and EnterPlanMode
assert!(names.contains(&"Read"));
assert!(names.contains(&"Grep"));
assert!(names.contains(&"Glob"));
assert!(names.contains(&"Skill"));
assert!(names.contains(&"Write"));
assert!(names.contains(&"Edit"));
assert!(names.contains(&"Bash"));
assert!(
names.contains(&"EnterPlanMode"),
"EnterPlanMode should be in normal mode"
);
// Should NOT include ExitPlanMode
assert!(
!names.contains(&"ExitPlanMode"),
"ExitPlanMode should NOT be in normal mode"
);
}
// ---------------------------------------------------------------------------
// TC-3.5-07: to_tool_defs_filtered correctly filters mixed categories
// ---------------------------------------------------------------------------
#[test]
fn tc_3_5_07_to_tool_defs_filtered_mixed_categories() {
let mut registry = ToolRegistry::new();
let flag = Arc::new(AtomicBool::new(false));
registry.register(mock_tool("Read", ToolCategory::Info));
registry.register(mock_tool("Write", ToolCategory::Edit));
registry.register(mock_tool("Bash", ToolCategory::Exec));
registry.register(Box::new(ExitPlanModeTool::new(flag)));
let defs = registry.to_tool_defs_filtered(|t| {
t.category() == ToolCategory::Info || t.name() == "ExitPlanMode"
});
let names: Vec<&str> = defs.iter().map(|d| d.name.as_str()).collect();
assert!(names.contains(&"Read"), "Info tool should be included");
assert!(
names.contains(&"ExitPlanMode"),
"ExitPlanMode should be included"
);
assert!(!names.contains(&"Write"), "Edit tool should be excluded");
assert!(!names.contains(&"Bash"), "Exec tool should be excluded");
}
// ---------------------------------------------------------------------------
// TC-3.5-08: System prompt dynamically includes plan mode instructions
// ---------------------------------------------------------------------------
#[test]
fn tc_3_5_08_system_prompt_includes_plan_mode_when_active() {
use nomi_agent::plan::prompt::plan_mode_instructions;
let base_prompt = "You are an AI assistant.";
let instructions = plan_mode_instructions();
// Simulate plan mode active: system prompt should contain plan instructions
let active_prompt = format!("{}\n\n{}", base_prompt, instructions);
assert!(
active_prompt.contains("Plan Mode"),
"active prompt should contain plan mode instructions"
);
assert!(
active_prompt.contains("MUST NOT"),
"should contain plan mode restrictions"
);
assert!(
active_prompt.contains(base_prompt),
"should still contain base prompt"
);
}
#[test]
fn tc_3_5_08_system_prompt_excludes_plan_mode_when_inactive() {
let base_prompt = "You are an AI assistant.";
// Normal mode: system prompt is just the base
assert!(
!base_prompt.contains("Plan Mode"),
"inactive prompt should NOT contain plan mode instructions"
);
}
// ---------------------------------------------------------------------------
// TC-3.5-06: PlanConfig disabled means no plan tools registered
// ---------------------------------------------------------------------------
#[test]
fn tc_3_5_06_disabled_config_no_plan_tools() {
// Simulate PlanConfig.enabled = false — do not register plan tools
let mut registry = ToolRegistry::new();
registry.register(mock_tool("Read", ToolCategory::Info));
registry.register(mock_tool("Write", ToolCategory::Edit));
// Intentionally NOT registering EnterPlanMode/ExitPlanMode
let defs = registry.to_tool_defs();
let names: Vec<&str> = defs.iter().map(|d| d.name.as_str()).collect();
assert!(
!names.contains(&"EnterPlanMode"),
"EnterPlanMode should not be registered when disabled"
);
assert!(
!names.contains(&"ExitPlanMode"),
"ExitPlanMode should not be registered when disabled"
);
}
// ---------------------------------------------------------------------------
// Additional: plan mode filter with MCP tools
// ---------------------------------------------------------------------------
#[test]
fn plan_mode_filter_excludes_mcp_tools() {
let mut registry = ToolRegistry::new();
registry.register(mock_tool("Read", ToolCategory::Info));
registry.register(mock_tool("mcp_server_tool", ToolCategory::Mcp));
let defs = registry.to_tool_defs_filtered(|t| {
t.category() == ToolCategory::Info && t.name() != "EnterPlanMode"
});
let names: Vec<&str> = defs.iter().map(|d| d.name.as_str()).collect();
assert!(names.contains(&"Read"));
assert!(
!names.contains(&"mcp_server_tool"),
"MCP tools should be excluded in plan mode"
);
}
// ---------------------------------------------------------------------------
// Additional: full enter-exit cycle verifies tool set transitions
// ---------------------------------------------------------------------------
#[test]
fn tool_set_transitions_through_plan_mode_cycle() {
let registry = build_test_registry();
// Normal mode: all except ExitPlanMode
let normal_defs = registry.to_tool_defs_filtered(|t| t.name() != "ExitPlanMode");
let normal_names: Vec<&str> = normal_defs.iter().map(|d| d.name.as_str()).collect();
assert!(normal_names.contains(&"Write"));
assert!(normal_names.contains(&"EnterPlanMode"));
assert!(!normal_names.contains(&"ExitPlanMode"));
// Enter plan mode: only Info (minus EnterPlanMode)
let plan_defs = registry.to_tool_defs_filtered(|t| {
t.category() == ToolCategory::Info && t.name() != "EnterPlanMode"
});
let plan_names: Vec<&str> = plan_defs.iter().map(|d| d.name.as_str()).collect();
assert!(!plan_names.contains(&"Write"));
assert!(!plan_names.contains(&"EnterPlanMode"));
assert!(plan_names.contains(&"ExitPlanMode"));
assert!(plan_names.contains(&"Read"));
// Exit plan mode: back to normal
let back_to_normal = registry.to_tool_defs_filtered(|t| t.name() != "ExitPlanMode");
let back_names: Vec<&str> = back_to_normal.iter().map(|d| d.name.as_str()).collect();
assert_eq!(
normal_names, back_names,
"tool set should be identical after exit"
);
}
@@ -0,0 +1,262 @@
//! Integration tests for Plan Mode prompts and file management (task 3.4).
//!
//! Tests are numbered to match the test-plan.md identifiers (TC-3.4-*).
use std::fs;
use std::path::Path;
use nomi_agent::context::{SystemPromptCache, build_system_prompt};
use nomi_agent::plan::file::{plan_file_path, read_plan, write_plan};
use nomi_agent::plan::prompt::plan_mode_instructions;
// ---------------------------------------------------------------------------
// TC-3.4-01 plan_mode_instructions content
// ---------------------------------------------------------------------------
#[test]
fn tc_3_4_01_instructions_not_empty() {
let text = plan_mode_instructions();
assert!(!text.is_empty(), "instructions should not be empty");
}
#[test]
fn tc_3_4_01_instructions_guide_code_reading() {
let text = plan_mode_instructions();
assert!(
text.contains("Read") && text.contains("Grep") && text.contains("Glob"),
"instructions should reference read-only tools"
);
}
#[test]
fn tc_3_4_01_instructions_guide_plan_creation() {
let text = plan_mode_instructions();
// Should mention planning/design phases
assert!(
text.contains("plan") || text.contains("Plan"),
"instructions should guide plan creation"
);
}
#[test]
fn tc_3_4_01_instructions_mention_exit_tool() {
let text = plan_mode_instructions();
assert!(
text.contains("ExitPlanMode"),
"instructions should mention ExitPlanMode tool"
);
}
#[test]
fn tc_3_4_01_instructions_forbid_writes() {
let text = plan_mode_instructions();
assert!(
text.contains("MUST NOT") || text.contains("Forbidden"),
"instructions should forbid write operations"
);
}
// ---------------------------------------------------------------------------
// TC-3.4-03 System prompt with plan mode active
// ---------------------------------------------------------------------------
#[test]
fn tc_3_4_03_system_prompt_includes_plan_instructions_when_active() {
let result = build_system_prompt(
&mut SystemPromptCache::new(),
None,
"/tmp",
"test-model",
&[],
None,
None,
true,
false,
false,
);
// Should contain plan mode instructions
assert!(
result.contains("Plan Mode"),
"active plan mode should inject plan mode instructions"
);
assert!(
result.contains("ExitPlanMode"),
"plan mode instructions should mention ExitPlanMode"
);
assert!(
result.contains("MUST NOT"),
"plan mode instructions should contain restrictions"
);
}
// ---------------------------------------------------------------------------
// TC-3.4-04 System prompt without plan mode
// ---------------------------------------------------------------------------
#[test]
fn tc_3_4_04_system_prompt_excludes_plan_instructions_when_inactive() {
let result = build_system_prompt(
&mut SystemPromptCache::new(),
None,
"/tmp",
"test-model",
&[],
None,
None,
false,
false,
false,
);
// Should NOT contain plan mode instructions
assert!(
!result.contains("# Plan Mode"),
"inactive plan mode should not inject plan mode heading"
);
}
// ---------------------------------------------------------------------------
// TC-3.4-05 Plan file write
// ---------------------------------------------------------------------------
#[test]
fn tc_3_4_05_write_plan_creates_file_and_parents() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("deep").join("nested").join("plan.md");
write_plan(&path, "# My Plan\n\n## Steps\n1. Do thing").unwrap();
assert!(path.exists(), "plan file should be created");
let content = fs::read_to_string(&path).unwrap();
assert_eq!(content, "# My Plan\n\n## Steps\n1. Do thing");
}
// ---------------------------------------------------------------------------
// TC-3.4-06 Plan file read
// ---------------------------------------------------------------------------
#[test]
fn tc_3_4_06_read_plan_returns_content() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("plan.md");
fs::write(&path, "# My Plan\nStep 1").unwrap();
let result = read_plan(&path).unwrap();
assert_eq!(result, Some("# My Plan\nStep 1".to_string()));
}
// ---------------------------------------------------------------------------
// TC-3.4-07 Plan file read when not exists
// ---------------------------------------------------------------------------
#[test]
fn tc_3_4_07_read_plan_nonexistent_returns_none() {
let result = read_plan(Path::new("/nonexistent/path/plan.md")).unwrap();
assert_eq!(result, None, "reading nonexistent plan should return None");
}
// ---------------------------------------------------------------------------
// TC-3.4-08 Plan file path generation
// ---------------------------------------------------------------------------
#[test]
fn tc_3_4_08_plan_file_path_format() {
let path = plan_file_path(Path::new("/tmp/plans"), "session-abc");
assert_eq!(
path,
std::path::PathBuf::from("/tmp/plans/session-abc.md"),
"plan file path should be {{dir}}/{{session_id}}.md"
);
}
// ---------------------------------------------------------------------------
// TC-3.4-09 No bb brand identifiers in plan mode instructions
// ---------------------------------------------------------------------------
#[test]
fn tc_3_4_09_no_bb_brand_in_instructions() {
let text = plan_mode_instructions();
assert!(
!text.contains("Claude"),
"instructions should not contain Claude brand"
);
assert!(
!text.contains("claude"),
"instructions should not contain lowercase claude"
);
assert!(
!text.contains("~/.claude"),
"instructions should not contain bb config path"
);
}
// ---------------------------------------------------------------------------
// Additional: write-then-read roundtrip
// ---------------------------------------------------------------------------
#[test]
fn write_then_read_roundtrip() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("plans");
let path = plan_file_path(&dir, "test-session");
let content = "# Implementation Plan\n\n## Context\nRefactor auth module\n\n## Files\n- src/auth.rs\n- src/middleware.rs";
write_plan(&path, content).unwrap();
let result = read_plan(&path).unwrap();
assert_eq!(result, Some(content.to_string()));
}
// ---------------------------------------------------------------------------
// Additional: plan mode instructions appear in correct position in system prompt
// ---------------------------------------------------------------------------
#[test]
fn plan_instructions_appear_after_memory_before_skills() {
let tmp = tempfile::TempDir::new().unwrap();
let mem_dir = tmp.path().join("memory");
std::fs::create_dir_all(&mem_dir).unwrap();
std::fs::write(mem_dir.join("MEMORY.md"), "- [A](a.md) \u{2014} test\n").unwrap();
let result = build_system_prompt(
&mut SystemPromptCache::new(),
None,
"/tmp",
"test-model",
&[],
None,
Some(&mem_dir),
true,
false,
false,
);
let memory_pos = result
.find("auto memory")
.expect("memory section should be present");
let plan_pos = result
.find("# Plan Mode")
.expect("plan mode instructions should be present");
assert!(
memory_pos < plan_pos,
"memory should appear before plan mode instructions"
);
}
// ---------------------------------------------------------------------------
// Additional: write_plan overwrites existing
// ---------------------------------------------------------------------------
#[test]
fn write_plan_overwrites_existing_content() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("plan.md");
write_plan(&path, "version 1").unwrap();
write_plan(&path, "version 2").unwrap();
let result = read_plan(&path).unwrap();
assert_eq!(result, Some("version 2".to_string()));
}
@@ -0,0 +1,275 @@
//! Integration tests for Plan Mode tools (task 3.3).
//!
//! Tests are numbered to match the test-plan.md identifiers (TC-3.3-*).
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use serde_json::json;
use nomi_agent::plan::state::PlanState;
use nomi_agent::plan::tools::{EnterPlanModeTool, ExitPlanModeTool};
use nomi_protocol::events::ToolCategory;
use nomi_tools::Tool;
use nomi_types::skill_types::PlanModeTransition;
// ---------------------------------------------------------------------------
// TC-3.3-01 PlanState initial state
// ---------------------------------------------------------------------------
#[test]
fn tc_3_3_01_plan_state_default_is_inactive() {
let state = PlanState::default();
assert!(!state.is_active);
}
#[test]
fn tc_3_3_01_plan_state_default_allow_list_empty() {
let state = PlanState::default();
assert!(state.pre_plan_allow_list.is_empty());
}
// ---------------------------------------------------------------------------
// TC-3.3-02 EnterPlanMode normal execution
// ---------------------------------------------------------------------------
#[tokio::test]
async fn tc_3_3_02_enter_plan_mode_succeeds_when_not_active() {
let flag = Arc::new(AtomicBool::new(false));
let tool = EnterPlanModeTool::new(flag);
let result = tool.execute(json!({})).await;
assert!(!result.is_error, "should succeed when not in plan mode");
assert!(
result.content.contains("plan mode"),
"confirmation message should mention plan mode"
);
}
// ---------------------------------------------------------------------------
// TC-3.3-03 EnterPlanMode duplicate entry rejected
// ---------------------------------------------------------------------------
#[tokio::test]
async fn tc_3_3_03_enter_plan_mode_rejects_when_already_active() {
let flag = Arc::new(AtomicBool::new(true));
let tool = EnterPlanModeTool::new(flag);
let result = tool.execute(json!({})).await;
assert!(result.is_error, "should fail when already in plan mode");
assert!(
result.content.contains("Already in plan mode"),
"error message should indicate already in plan mode"
);
}
// ---------------------------------------------------------------------------
// TC-3.3-04 ExitPlanMode normal execution
// ---------------------------------------------------------------------------
#[tokio::test]
async fn tc_3_3_04_exit_plan_mode_succeeds_when_active() {
let flag = Arc::new(AtomicBool::new(true));
let tool = ExitPlanModeTool::new(flag);
let result = tool.execute(json!({})).await;
assert!(!result.is_error, "should succeed when in plan mode");
assert!(
result.content.contains("Exited plan mode"),
"confirmation message should mention exiting"
);
}
// ---------------------------------------------------------------------------
// TC-3.3-05 ExitPlanMode when not in plan mode
// ---------------------------------------------------------------------------
#[tokio::test]
async fn tc_3_3_05_exit_plan_mode_rejects_when_not_active() {
let flag = Arc::new(AtomicBool::new(false));
let tool = ExitPlanModeTool::new(flag);
let result = tool.execute(json!({})).await;
assert!(result.is_error, "should fail when not in plan mode");
assert!(
result.content.contains("Not in plan mode"),
"error message should indicate not in plan mode"
);
}
// ---------------------------------------------------------------------------
// TC-3.3-06 EnterPlanMode context_modifier returns Enter
// ---------------------------------------------------------------------------
#[test]
fn tc_3_3_06_enter_context_modifier_returns_enter_transition() {
let flag = Arc::new(AtomicBool::new(false));
let tool = EnterPlanModeTool::new(flag);
let modifier = tool.context_modifier_for(&json!({}));
assert!(modifier.is_some(), "should return a context modifier");
let cm = modifier.unwrap();
assert_eq!(
cm.plan_mode_transition,
Some(PlanModeTransition::Enter),
"transition should be Enter"
);
}
// ---------------------------------------------------------------------------
// TC-3.3-07 ExitPlanMode context_modifier returns Exit
// ---------------------------------------------------------------------------
#[test]
fn tc_3_3_07_exit_context_modifier_returns_exit_transition() {
let flag = Arc::new(AtomicBool::new(true));
let tool = ExitPlanModeTool::new(flag);
let modifier = tool.context_modifier_for(&json!({}));
assert!(modifier.is_some(), "should return a context modifier");
let cm = modifier.unwrap();
assert!(
matches!(
cm.plan_mode_transition,
Some(PlanModeTransition::Exit { .. })
),
"transition should be Exit variant"
);
}
// ---------------------------------------------------------------------------
// TC-3.3-08 EnterPlanMode tool metadata
// ---------------------------------------------------------------------------
#[test]
fn tc_3_3_08_enter_tool_name() {
let flag = Arc::new(AtomicBool::new(false));
let tool = EnterPlanModeTool::new(flag);
assert_eq!(tool.name(), "EnterPlanMode");
}
#[test]
fn tc_3_3_08_enter_tool_category_is_info() {
let flag = Arc::new(AtomicBool::new(false));
let tool = EnterPlanModeTool::new(flag);
assert!(matches!(tool.category(), ToolCategory::Info));
}
#[test]
fn tc_3_3_08_enter_tool_is_concurrency_safe() {
let flag = Arc::new(AtomicBool::new(false));
let tool = EnterPlanModeTool::new(flag);
assert!(tool.is_concurrency_safe(&json!({})));
}
#[test]
fn tc_3_3_08_enter_tool_schema_no_required_params() {
let flag = Arc::new(AtomicBool::new(false));
let tool = EnterPlanModeTool::new(flag);
let schema = tool.input_schema();
let required = schema["required"]
.as_array()
.expect("required should be an array");
assert!(required.is_empty(), "no required parameters expected");
}
// ---------------------------------------------------------------------------
// TC-3.3-09 ExitPlanMode tool metadata
// ---------------------------------------------------------------------------
#[test]
fn tc_3_3_09_exit_tool_name() {
let flag = Arc::new(AtomicBool::new(false));
let tool = ExitPlanModeTool::new(flag);
assert_eq!(tool.name(), "ExitPlanMode");
}
#[test]
fn tc_3_3_09_exit_tool_category_is_info() {
let flag = Arc::new(AtomicBool::new(false));
let tool = ExitPlanModeTool::new(flag);
assert!(matches!(tool.category(), ToolCategory::Info));
}
#[test]
fn tc_3_3_09_exit_tool_is_concurrency_safe() {
let flag = Arc::new(AtomicBool::new(false));
let tool = ExitPlanModeTool::new(flag);
assert!(tool.is_concurrency_safe(&json!({})));
}
#[test]
fn tc_3_3_09_exit_tool_schema_no_required_params() {
let flag = Arc::new(AtomicBool::new(false));
let tool = ExitPlanModeTool::new(flag);
let schema = tool.input_schema();
let required = schema["required"]
.as_array()
.expect("required should be an array");
assert!(required.is_empty(), "no required parameters expected");
}
// ---------------------------------------------------------------------------
// Additional: full enter-exit cycle with shared flag
// ---------------------------------------------------------------------------
#[tokio::test]
async fn enter_exit_cycle_with_shared_flag() {
let flag = Arc::new(AtomicBool::new(false));
let enter = EnterPlanModeTool::new(Arc::clone(&flag));
let exit = ExitPlanModeTool::new(Arc::clone(&flag));
// Phase 1: enter should succeed
let r = enter.execute(json!({})).await;
assert!(!r.is_error);
// Simulate engine applying the transition
flag.store(true, Ordering::Release);
// Phase 2: double-enter should fail
let r = enter.execute(json!({})).await;
assert!(r.is_error);
// Phase 3: exit should succeed
let r = exit.execute(json!({})).await;
assert!(!r.is_error);
// Simulate engine applying the transition
flag.store(false, Ordering::Release);
// Phase 4: double-exit should fail
let r = exit.execute(json!({})).await;
assert!(r.is_error);
// Phase 5: re-enter should succeed
let r = enter.execute(json!({})).await;
assert!(!r.is_error);
}
// ---------------------------------------------------------------------------
// Additional: context_modifier fields are default except plan_mode_transition
// ---------------------------------------------------------------------------
#[test]
fn enter_context_modifier_other_fields_are_default() {
let tool = EnterPlanModeTool::new(Arc::new(AtomicBool::new(false)));
let cm = tool.context_modifier_for(&json!({})).unwrap();
assert!(cm.model.is_none());
assert!(cm.effort.is_none());
assert!(cm.allowed_tools.is_empty());
}
#[test]
fn exit_context_modifier_other_fields_are_default() {
let tool = ExitPlanModeTool::new(Arc::new(AtomicBool::new(false)));
let cm = tool.context_modifier_for(&json!({})).unwrap();
assert!(cm.model.is_none());
assert!(cm.effort.is_none());
assert!(cm.allowed_tools.is_empty());
}
@@ -0,0 +1,345 @@
//! End-to-end skill tests using real files on disk.
//!
//! Each test creates skill files in a temporary directory that mirrors the
//! `.nomi/skills/` and `.nomi/commands/` layout, then exercises the full
//! pipeline: discovery -> loading -> system prompt injection -> SkillTool execution.
//!
//! Tests use `load_all_skills` with `add_dirs` or a temp cwd to avoid depending
//! on any pre-existing files in the repo or user home directory.
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use nomi_agent::context::{SystemPromptCache, build_system_prompt};
use nomi_agent::skill_tool::SkillTool;
use nomi_agent::skills::loader::load_all_skills;
use nomi_agent::skills::permissions::SkillPermissionChecker;
use nomi_agent::skills::types::SkillMetadata;
use nomi_tools::Tool;
use serde_json::json;
use tempfile::TempDir;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn find_skill<'a>(skills: &'a [SkillMetadata], name: &str) -> Option<&'a SkillMetadata> {
skills.iter().find(|s| s.name == name)
}
/// Create a project-like temp directory with `.git` marker and `.nomi/skills/` + `.nomi/commands/`.
/// Returns (TempDir guard, root path).
fn make_project() -> (TempDir, PathBuf) {
let tmp = TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
// Git root marker so walk_up stops here
fs::create_dir(root.join(".git")).unwrap();
// Skills directory
let skills_dir = root.join(".nomi").join("skills");
fs::create_dir_all(&skills_dir).unwrap();
// Commands directory (legacy)
let commands_dir = root.join(".nomi").join("commands");
fs::create_dir_all(&commands_dir).unwrap();
// --- greet skill ---
let greet_dir = skills_dir.join("greet");
fs::create_dir_all(&greet_dir).unwrap();
fs::write(
greet_dir.join("SKILL.md"),
"---\nname: greet\ndescription: Greet a user by name\n---\n\nHello, $ARGUMENTS! Welcome to the project.\n",
).unwrap();
// --- db:migrate (nested namespace) ---
let migrate_dir = skills_dir.join("db").join("migrate");
fs::create_dir_all(&migrate_dir).unwrap();
fs::write(
migrate_dir.join("SKILL.md"),
"---\nname: db:migrate\ndescription: Run database migrations\n---\n\nRunning migrations for: $ARGUMENTS\nSkill directory: ${NOMI_SKILL_DIR}\n",
).unwrap();
// --- rust-review (conditional paths) ---
let review_dir = skills_dir.join("rust-review");
fs::create_dir_all(&review_dir).unwrap();
fs::write(
review_dir.join("SKILL.md"),
"---\nname: rust-review\ndescription: Rust-specific code review checklist\npaths:\n - \"**/*.rs\"\n - \"Cargo.toml\"\n---\n\nWhen reviewing Rust code, check:\n- No unwrap() in library code\n",
).unwrap();
// --- shell-demo (shell expansion) ---
let shell_dir = skills_dir.join("shell-demo");
fs::create_dir_all(&shell_dir).unwrap();
fs::write(
shell_dir.join("SKILL.md"),
"---\nname: shell-demo\ndescription: Demonstrate shell command expansion\n---\n\nCurrent date: !`date +%Y-%m-%d`\n",
).unwrap();
// --- legacy command (flat .md in commands/) ---
fs::write(
commands_dir.join("legacy-cmd.md"),
"---\nname: legacy-cmd\ndescription: A legacy command for backward compatibility testing\n---\n\nThis is a legacy command loaded from .nomi/commands/\nArguments: $ARGUMENTS\n",
).unwrap();
(tmp, root)
}
fn make_tool(skills: Vec<SkillMetadata>, cwd: &str) -> SkillTool {
SkillTool::new(
Arc::new(skills),
cwd.to_string(),
SkillPermissionChecker::new(vec![], vec![], false),
)
}
// ---------------------------------------------------------------------------
// E1: Project-level skill discovery
// ---------------------------------------------------------------------------
#[tokio::test]
async fn e1_project_skill_discovered() {
let (_guard, root) = make_project();
let skills = load_all_skills(&root, &[], false, None).await;
let greet = find_skill(&skills, "greet");
assert!(greet.is_some(), "E1 FAIL: 'greet' skill not discovered");
assert_eq!(greet.unwrap().description, "Greet a user by name");
println!("E1 PASS: project-level skill 'greet' discovered with correct description");
}
// ---------------------------------------------------------------------------
// E2: Legacy commands discovery
// ---------------------------------------------------------------------------
#[tokio::test]
async fn e2_legacy_commands_discovered() {
let (_guard, root) = make_project();
let skills = load_all_skills(&root, &[], false, None).await;
let legacy = find_skill(&skills, "legacy-cmd");
assert!(legacy.is_some(), "E2 FAIL: 'legacy-cmd' not discovered");
println!("E2 PASS: legacy command 'legacy-cmd' discovered from .nomi/commands/");
}
// ---------------------------------------------------------------------------
// E3: Nested namespace (db:migrate)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn e3_nested_namespace() {
let (_guard, root) = make_project();
let skills = load_all_skills(&root, &[], false, None).await;
let migrate = find_skill(&skills, "db:migrate");
assert!(migrate.is_some(), "E3 FAIL: 'db:migrate' not discovered");
println!("E3 PASS: nested skill 'db:migrate' discovered with colon namespace");
}
// ---------------------------------------------------------------------------
// E4: Variable substitution ($ARGUMENTS)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn e4_variable_substitution() {
let (_guard, root) = make_project();
let cwd = root.to_string_lossy().to_string();
let skills = load_all_skills(&root, &[], false, None).await;
let tool = make_tool(skills, &cwd);
let result = tool
.execute(json!({"skill": "greet", "args": "Alice"}))
.await;
assert!(!result.is_error, "E4 FAIL: error: {}", result.content);
assert!(
result.content.contains("Hello, Alice!"),
"E4 FAIL: $ARGUMENTS not substituted. Got: {}",
result.content
);
println!("E4 PASS: $ARGUMENTS substituted correctly");
}
// ---------------------------------------------------------------------------
// E5: Shell command expansion
// ---------------------------------------------------------------------------
#[tokio::test]
#[cfg(not(windows))] // Shell expansion uses Unix commands; skip on Windows
async fn e5_shell_expansion() {
let (_guard, root) = make_project();
let cwd = root.to_string_lossy().to_string();
let skills = load_all_skills(&root, &[], false, None).await;
let tool = make_tool(skills, &cwd);
let result = tool.execute(json!({"skill": "shell-demo"})).await;
assert!(!result.is_error, "E5 FAIL: error: {}", result.content);
let today = chrono::Local::now().format("%Y-%m-%d").to_string();
assert!(
result.content.contains(&today),
"E5 FAIL: shell expansion did not produce today's date. Got: {}",
result.content
);
println!("E5 PASS: shell expansion produced today's date ({})", today);
}
// ---------------------------------------------------------------------------
// E6: Conditional activation (paths filter)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn e6_conditional_activation() {
let (_guard, root) = make_project();
let skills = load_all_skills(&root, &[], false, None).await;
let rust_review = find_skill(&skills, "rust-review").expect("E6 FAIL: 'rust-review' not found");
assert!(
!rust_review.paths.is_empty(),
"E6 FAIL: paths should not be empty"
);
assert!(
rust_review.paths.iter().any(|p| p.contains("*.rs")),
"E6 FAIL: paths should contain '*.rs'. Got: {:?}",
rust_review.paths
);
println!(
"E6 PASS: 'rust-review' has conditional paths: {:?}",
rust_review.paths
);
}
// ---------------------------------------------------------------------------
// E7: System prompt injection
// ---------------------------------------------------------------------------
#[tokio::test]
async fn e7_system_prompt_injection() {
let (_guard, root) = make_project();
let cwd = root.to_string_lossy().to_string();
let skills = load_all_skills(&root, &[], false, None).await;
let prompt = build_system_prompt(
&mut SystemPromptCache::new(),
None,
&cwd,
"test-model",
&skills,
None,
None,
false,
false,
false,
);
assert!(
prompt.contains("greet"),
"E7 FAIL: 'greet' not in system prompt"
);
assert!(
prompt.contains("db:migrate"),
"E7 FAIL: 'db:migrate' not in system prompt"
);
assert!(
prompt.contains("system-reminder"),
"E7 FAIL: missing <system-reminder> wrapper"
);
println!("E7 PASS: skills injected into system prompt");
}
// ---------------------------------------------------------------------------
// E8: Full SkillTool execution (db:migrate with $ARGUMENTS + ${NOMI_SKILL_DIR})
// ---------------------------------------------------------------------------
#[tokio::test]
async fn e8_full_execution() {
let (_guard, root) = make_project();
let cwd = root.to_string_lossy().to_string();
let skills = load_all_skills(&root, &[], false, None).await;
let tool = make_tool(skills, &cwd);
let result = tool
.execute(json!({"skill": "db:migrate", "args": "production"}))
.await;
assert!(!result.is_error, "E8 FAIL: error: {}", result.content);
assert!(
result
.content
.contains("Running migrations for: production"),
"E8 FAIL: $ARGUMENTS not substituted. Got: {}",
result.content
);
assert!(
!result.content.contains("${NOMI_SKILL_DIR}"),
"E8 FAIL: ${{NOMI_SKILL_DIR}} not expanded. Got: {}",
result.content
);
println!("E8 PASS: full execution with $ARGUMENTS and ${{NOMI_SKILL_DIR}} substitution");
}
// ---------------------------------------------------------------------------
// E9: Deduplication
// ---------------------------------------------------------------------------
#[tokio::test]
async fn e9_deduplication() {
let (_guard, root) = make_project();
let skills = load_all_skills(&root, &[], false, None).await;
let mut name_counts = std::collections::HashMap::new();
for skill in &skills {
*name_counts.entry(skill.name.as_str()).or_insert(0usize) += 1;
}
for (name, count) in &name_counts {
assert_eq!(*count, 1, "E9 FAIL: '{}' appears {} times", name, count);
}
println!("E9 PASS: all {} skills have unique names", skills.len());
}
// ---------------------------------------------------------------------------
// E10: Skill not found error
// ---------------------------------------------------------------------------
#[tokio::test]
async fn e10_skill_not_found() {
let (_guard, root) = make_project();
let cwd = root.to_string_lossy().to_string();
let skills = load_all_skills(&root, &[], false, None).await;
let tool = make_tool(skills, &cwd);
let result = tool.execute(json!({"skill": "nonexistent-skill"})).await;
assert!(result.is_error, "E10 FAIL: should return error");
assert!(
result.content.contains("not found"),
"E10 FAIL: got: {}",
result.content
);
println!("E10 PASS: nonexistent skill returns clear error message");
}
// ---------------------------------------------------------------------------
// E11: Legacy command execution
// ---------------------------------------------------------------------------
#[tokio::test]
async fn e11_legacy_command_execution() {
let (_guard, root) = make_project();
let cwd = root.to_string_lossy().to_string();
let skills = load_all_skills(&root, &[], false, None).await;
let tool = make_tool(skills, &cwd);
let result = tool
.execute(json!({"skill": "legacy-cmd", "args": "test-arg"}))
.await;
assert!(!result.is_error, "E11 FAIL: error: {}", result.content);
assert!(
result.content.contains("legacy command"),
"E11 FAIL: got: {}",
result.content
);
assert!(
result.content.contains("test-arg"),
"E11 FAIL: $ARGUMENTS not substituted. Got: {}",
result.content
);
println!("E11 PASS: legacy command executed with variable substitution");
}
@@ -0,0 +1,67 @@
//! Integration tests for Spawn tool description (TC-4.2-07).
//!
//! Verifies the enhanced Spawn tool description contains capacity limits
//! and usage guidance as specified in the test plan.
mod common;
use std::sync::Arc;
use common::{MockLlmProvider, test_config};
use nomi_agent::spawn_tool::SpawnTool;
use nomi_agent::spawner::AgentSpawner;
use nomi_tools::Tool;
fn make_spawn_tool() -> SpawnTool {
let provider = Arc::new(MockLlmProvider::with_text_response("ok"));
let spawner = Arc::new(AgentSpawner::new(
provider,
test_config(),
std::env::temp_dir(),
));
SpawnTool::new(spawner)
}
// --- TC-4.2-07: Spawn tool description contains capacity limits ---
#[test]
fn spawn_description_mentions_max_agents() {
let tool = make_spawn_tool();
let desc = tool.description();
assert!(
desc.contains('5'),
"Spawn description should mention the 5 sub-agent limit"
);
}
#[test]
fn spawn_description_mentions_parallel() {
let tool = make_spawn_tool();
let desc = tool.description();
assert!(
desc.contains("parallel"),
"Spawn description should mention parallel execution"
);
}
// --- R-4.1-01: Spawn description should document max_turns and max_tokens ---
#[test]
fn spawn_description_mentions_max_turns() {
let tool = make_spawn_tool();
let desc = tool.description();
assert!(
desc.contains("200"),
"Spawn description should mention the 200 turn limit per sub-agent"
);
}
#[test]
fn spawn_description_mentions_max_tokens() {
let tool = make_spawn_tool();
let desc = tool.description();
assert!(
desc.contains("4096"),
"Spawn description should mention the 4096 token limit per sub-agent"
);
}
@@ -0,0 +1,163 @@
mod common;
use std::sync::Arc;
use common::{MockLlmProvider, test_config};
use nomi_agent::spawner::{AgentSpawner, SubAgentConfig};
use nomi_types::llm::LlmEvent;
use nomi_types::message::{StopReason, TokenUsage};
// ---------------------------------------------------------------------------
// Helper: build a minimal SubAgentConfig for testing
// ---------------------------------------------------------------------------
fn make_sub_config(name: &str) -> SubAgentConfig {
SubAgentConfig {
name: name.to_string(),
prompt: format!("Task for {}", name),
max_turns: 5,
max_tokens: 1024,
system_prompt: None,
allowed_tools: Vec::new(),
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
/// Single sub-agent executes and returns the expected text result.
#[tokio::test]
async fn test_spawn_single_agent() {
let provider = Arc::new(MockLlmProvider::with_text_response("Sub-agent done"));
let spawner = AgentSpawner::new(provider, test_config(), std::env::temp_dir());
let result = spawner.spawn_one(make_sub_config("agent-1")).await;
assert_eq!(result.text, "Sub-agent done");
assert!(!result.is_error, "expected no error, got: {}", result.text);
assert_eq!(result.turns, 1);
assert_eq!(result.name, "agent-1");
}
/// Parallel sub-agents all complete successfully and return distinct results.
#[tokio::test]
async fn test_spawn_parallel_agents() {
// Provide one turn sequence per sub-agent; each stream() call pops one entry.
let make_turn = |text: &str| {
vec![
LlmEvent::TextDelta(text.to_string()),
LlmEvent::Done {
stop_reason: StopReason::EndTurn,
usage: TokenUsage {
input_tokens: 10,
output_tokens: 5,
cache_creation_tokens: 0,
cache_read_tokens: 0,
},
},
]
};
let provider = Arc::new(MockLlmProvider::with_turns(vec![
make_turn("result-A"),
make_turn("result-B"),
make_turn("result-C"),
]));
let spawner = AgentSpawner::new(provider, test_config(), std::env::temp_dir());
let sub_configs = vec![
make_sub_config("agent-A"),
make_sub_config("agent-B"),
make_sub_config("agent-C"),
];
let results = spawner.spawn_parallel(sub_configs).await;
assert_eq!(results.len(), 3, "expected 3 results from 3 sub-agents");
for result in &results {
assert!(
!result.is_error,
"sub-agent '{}' returned an error: {}",
result.name, result.text
);
}
// Each result should contain one of the expected texts (order may vary due
// to concurrent scheduling, so we just verify the full set is covered).
let texts: std::collections::HashSet<&str> = results.iter().map(|r| r.text.as_str()).collect();
assert!(texts.contains("result-A"), "missing result-A");
assert!(texts.contains("result-B"), "missing result-B");
assert!(texts.contains("result-C"), "missing result-C");
}
/// The same provider Arc is reused across sequentially spawned sub-agents.
#[tokio::test]
async fn test_spawn_shares_provider() {
// Two turns: one for each sequential sub-agent call.
let provider = Arc::new(MockLlmProvider::with_turns(vec![
vec![
LlmEvent::TextDelta("first".to_string()),
LlmEvent::Done {
stop_reason: StopReason::EndTurn,
usage: TokenUsage {
input_tokens: 10,
output_tokens: 5,
cache_creation_tokens: 0,
cache_read_tokens: 0,
},
},
],
vec![
LlmEvent::TextDelta("second".to_string()),
LlmEvent::Done {
stop_reason: StopReason::EndTurn,
usage: TokenUsage {
input_tokens: 10,
output_tokens: 5,
cache_creation_tokens: 0,
cache_read_tokens: 0,
},
},
],
]));
// Both sub-agents share the same underlying provider via Arc.
let provider_dyn: Arc<dyn nomi_providers::LlmProvider> = provider;
let spawner = AgentSpawner::new(
Arc::clone(&provider_dyn),
test_config(),
std::env::temp_dir(),
);
let result1 = spawner.spawn_one(make_sub_config("seq-1")).await;
let result2 = spawner.spawn_one(make_sub_config("seq-2")).await;
assert!(!result1.is_error, "seq-1 errored: {}", result1.text);
assert!(!result2.is_error, "seq-2 errored: {}", result2.text);
assert_eq!(result1.text, "first");
assert_eq!(result2.text, "second");
}
/// An LLM error event causes the sub-agent result to be marked as an error.
#[tokio::test]
async fn test_spawn_agent_error_captured() {
// Emit an Error event — the engine converts this to AgentError::ApiError,
// which spawner catches and stores in SubAgentResult::is_error.
let provider = Arc::new(MockLlmProvider::with_events(vec![LlmEvent::Error(
"provider failed".to_string(),
)]));
let spawner = AgentSpawner::new(provider, test_config(), std::env::temp_dir());
let result = spawner.spawn_one(make_sub_config("error-agent")).await;
assert!(result.is_error, "expected is_error=true");
assert!(
result.text.to_lowercase().contains("error"),
"expected error message to contain 'error', got: {}",
result.text
);
}
@@ -0,0 +1,375 @@
//! Integration tests for system prompt tool usage guidance (TC-4.3-01 through TC-4.3-08).
//!
//! Black-box tests verifying the "# Using your tools" section is correctly
//! assembled into the system prompt with proper content and ordering.
use std::fs;
use nomi_agent::context::{SystemPromptCache, build_system_prompt};
use nomi_skills::types::{ExecutionContext, LoadedFrom, SkillMetadata, SkillSource};
fn make_skill(name: &str, description: &str) -> SkillMetadata {
SkillMetadata {
name: name.to_string(),
display_name: None,
description: description.to_string(),
has_user_specified_description: false,
allowed_tools: vec![],
argument_hint: None,
argument_names: vec![],
when_to_use: None,
version: None,
model: None,
disable_model_invocation: false,
user_invocable: true,
execution_context: ExecutionContext::Inline,
agent: None,
effort: None,
shell: None,
paths: vec![],
hooks_raw: None,
source: SkillSource::User,
loaded_from: LoadedFrom::Skills,
content: String::new(),
content_length: 0,
skill_root: None,
}
}
// ---------------------------------------------------------------------------
// TC-4.3-01: Tool guidance section exists
// ---------------------------------------------------------------------------
#[test]
fn tc_4_3_01_tool_guidance_section_exists() {
let result = build_system_prompt(
&mut SystemPromptCache::new(),
None,
"/tmp",
"test-model",
&[],
None,
None,
false,
false,
false, // browser_enabled
);
assert!(
result.contains("# Using your tools"),
"system prompt should contain the tool guidance section heading"
);
}
// ---------------------------------------------------------------------------
// TC-4.3-02: Bash prohibition list with dedicated tool alternatives
// ---------------------------------------------------------------------------
#[test]
fn tc_4_3_02_bash_prohibition_list() {
let result = build_system_prompt(
&mut SystemPromptCache::new(),
None,
"/tmp",
"test-model",
&[],
None,
None,
false,
false,
false, // browser_enabled
);
// Glob replaces find/ls
assert!(
result.contains("Glob") && result.contains("find"),
"should map Glob as replacement for find"
);
// Grep replaces grep/rg
assert!(
result.contains("Grep") && result.contains("grep"),
"should map Grep as replacement for grep"
);
// Read replaces cat/head/tail
assert!(
result.contains("Read") && result.contains("cat"),
"should map Read as replacement for cat"
);
// Edit replaces sed/awk
assert!(
result.contains("Edit") && result.contains("sed"),
"should map Edit as replacement for sed"
);
// Write replaces echo/heredoc
assert!(
result.contains("Write") && result.contains("echo"),
"should map Write as replacement for echo"
);
}
// ---------------------------------------------------------------------------
// TC-4.3-03: Parallel call guidance
// ---------------------------------------------------------------------------
#[test]
fn tc_4_3_03_parallel_call_guidance() {
let result = build_system_prompt(
&mut SystemPromptCache::new(),
None,
"/tmp",
"test-model",
&[],
None,
None,
false,
false,
false, // browser_enabled
);
assert!(
result.contains("parallel"),
"should contain parallel call guidance"
);
assert!(
result.contains("sequentially"),
"should explain when to run sequentially (dependencies)"
);
}
// ---------------------------------------------------------------------------
// TC-4.3-04: Edit-over-Write and Read-before-Edit rules
// ---------------------------------------------------------------------------
#[test]
fn tc_4_3_04_edit_write_read_rules() {
let result = build_system_prompt(
&mut SystemPromptCache::new(),
None,
"/tmp",
"test-model",
&[],
None,
None,
false,
false,
false, // browser_enabled
);
assert!(
result.contains("Prefer Edit over Write"),
"should contain Edit-over-Write preference"
);
assert!(
result.contains("Read a file before editing"),
"should contain Read-before-Edit rule"
);
}
// ---------------------------------------------------------------------------
// TC-4.3-05: Section order — guidance after intro, before custom prompt
// ---------------------------------------------------------------------------
#[test]
fn tc_4_3_05_order_after_intro_before_custom() {
let result = build_system_prompt(
&mut SystemPromptCache::new(),
Some("CUSTOM_PROMPT_MARKER"),
"/tmp",
"test-model",
&[],
None,
None,
false,
false,
false, // browser_enabled
);
let intro_pos = result
.find("Working directory")
.expect("intro should contain 'Working directory'");
let guidance_pos = result
.find("# Using your tools")
.expect("tool guidance section should exist");
let custom_pos = result
.find("CUSTOM_PROMPT_MARKER")
.expect("custom prompt should exist");
assert!(
guidance_pos > intro_pos,
"tool guidance should appear after the base intro"
);
assert!(
guidance_pos < custom_pos,
"tool guidance should appear before custom prompt"
);
}
// ---------------------------------------------------------------------------
// TC-4.3-06: Section order — guidance before skills and memory
// ---------------------------------------------------------------------------
#[test]
fn tc_4_3_06_order_before_skills() {
let skills = vec![make_skill("order-test-skill", "Order test")];
let result = build_system_prompt(
&mut SystemPromptCache::new(),
None,
"/tmp",
"test-model",
&skills,
None,
None,
false,
false,
false, // browser_enabled
);
let guidance_pos = result
.find("# Using your tools")
.expect("tool guidance should exist");
let skills_pos = result
.find("order-test-skill")
.expect("skill should be listed");
assert!(
guidance_pos < skills_pos,
"tool guidance should appear before skills listing"
);
}
#[test]
fn tc_4_3_06_order_before_memory() {
let tmp = tempfile::TempDir::new().unwrap();
let mem_dir = tmp.path().join("memory");
fs::create_dir_all(&mem_dir).unwrap();
fs::write(
mem_dir.join("MEMORY.md"),
"- [Note](note.md) \u{2014} some note\n",
)
.unwrap();
let result = build_system_prompt(
&mut SystemPromptCache::new(),
None,
"/tmp",
"test-model",
&[],
None,
Some(&mem_dir),
false,
false,
false, // browser_enabled
);
let guidance_pos = result
.find("# Using your tools")
.expect("tool guidance should exist");
let memory_pos = result
.find("auto memory")
.expect("memory section should exist");
assert!(
guidance_pos < memory_pos,
"tool guidance should appear before memory section"
);
}
// ---------------------------------------------------------------------------
// TC-4.3-07: All sections coexist with correct ordering
// ---------------------------------------------------------------------------
#[test]
fn tc_4_3_07_all_sections_coexist() {
let tmp = tempfile::TempDir::new().unwrap();
let cwd = tmp.path();
// Create AGENTS.md
fs::write(cwd.join("AGENTS.md"), "PROJECT_RULES_COEXIST").unwrap();
// Create memory
let mem_dir = tmp.path().join("memory");
fs::create_dir_all(&mem_dir).unwrap();
fs::write(
mem_dir.join("MEMORY.md"),
"- [Item](item.md) \u{2014} coexist test\n",
)
.unwrap();
let skills = vec![make_skill("coexist-skill", "Coexist test")];
let result = build_system_prompt(
&mut SystemPromptCache::new(),
Some("CUSTOM_COEXIST"),
&cwd.to_string_lossy(),
"test-model",
&skills,
None,
Some(&mem_dir),
true, // plan mode active
false,
false, // browser_enabled
);
// All sections should exist
assert!(result.contains("Working directory"), "intro should exist");
assert!(
result.contains("# Using your tools"),
"tool guidance should exist"
);
assert!(
result.contains("CUSTOM_COEXIST"),
"custom prompt should exist"
);
assert!(
result.contains("PROJECT_RULES_COEXIST"),
"AGENTS.md should exist"
);
assert!(
result.contains("auto memory"),
"memory section should exist"
);
assert!(
result.contains("coexist-skill"),
"skills listing should exist"
);
// Verify ordering: intro < guidance < custom < agents.md < memory < skills
let intro_pos = result.find("Working directory").unwrap();
let guidance_pos = result.find("# Using your tools").unwrap();
let custom_pos = result.find("CUSTOM_COEXIST").unwrap();
let agents_pos = result.find("PROJECT_RULES_COEXIST").unwrap();
let memory_pos = result.find("auto memory").unwrap();
let skills_pos = result.find("coexist-skill").unwrap();
assert!(guidance_pos > intro_pos, "guidance after intro");
assert!(custom_pos > guidance_pos, "custom after guidance");
assert!(agents_pos > custom_pos, "agents.md after custom");
assert!(memory_pos > agents_pos, "memory after agents.md");
assert!(skills_pos > memory_pos, "skills after memory");
}
// ---------------------------------------------------------------------------
// TC-4.3-08: Tool guidance present in plan mode
// ---------------------------------------------------------------------------
#[test]
fn tc_4_3_08_guidance_in_plan_mode() {
let result = build_system_prompt(
&mut SystemPromptCache::new(),
None,
"/tmp",
"test-model",
&[],
None,
None,
true,
false,
false, // browser_enabled
);
assert!(
result.contains("# Using your tools"),
"tool guidance should be present even in plan mode"
);
// Plan mode instructions should also be present
assert!(
result.contains("plan") || result.contains("Plan"),
"plan mode instructions should coexist with tool guidance"
);
}