feat(echo): stateless index-free echolocation + program-wide token diet

Sound every file read, grep, file search, and project map with its
surroundings — sibling podmates, line-numbered symbols, import roots,
resolved file links, and (in the terminal buzz) reverse-link callers —
sounded fresh from source with no index, no background work, and no new
dependencies. Deterministic (sorted, capped, budgeted) so repeated calls
are byte-identical and cache-friendly.

Echolocation (new: tools/echolocation.rs, ~2.5k lines incl. tests):
- read footer: [Pod] mates + [Sounding] symbols/imports/links/heard-by,
  1200B hard budget; miss echo on NotFound (denylist-gated, never fires
  on refused probes); terminal buzz adds callers after 2 distinct
  files read in a pod
- grep + file_search chart matched dirs (caller's own visibility gates:
  denylist, excludes, extensions, gitignore); project_map sounds key
  files; 4 sounding languages (Rust/Python/JS-TS/Go)

Token diet (no compression, behavior-preserving):
- prefix 14,241 -> 7,118 tokens/turn (-50.0%): skill path cut
  (load_skill resolves names), MCP prose 1526->548B (scar-tissue
  phrases kept), agent+workflow deferral, goal tools conditional-eager
  keyed off the turn's resolved goal (preview/wire-safe)
- activation cache: best-match-last push + regex name-rank + 16->24KiB
  bound (fixes deferral stranding agent behind its own discovery set)
- grep empty-context omit, workflow event projection (lossless:
  duplicates dropped, workspaces hoisted, branch kept) + zero-memo omit
- wire-prefix dump moved to PreparedOutboundRequest (exact-final-body)

Evidence: targeted echo 46, workflow 131, file 145 green; full
tui+workflow suites green except 4 failures reproducing identically on
base c75b23397 (pre-existing, unrelated) + known timing flakes proven
solo-pass; clippy zero on owned files. Research: codewhale-research/
ECHOLOCATION-THESIS.md (exp-10/11/12 + hardening audit).
This commit is contained in:
AdityaVG13 committed 2026-09-20 23:36:15 -04:00
1 parent c75b233978
commit ac33dd49db
26 files changed
+3490 -277

No files matched your search

+3 -3
View File
@@ -7031,14 +7031,14 @@ mod tests {
let expected_names = crate::core::engine::default_active_native_tool_names()
.iter()
.copied()
// Children can inspect the shared goal, but only its owning
// session can create it or change its completion state.
// Children discover goal inspection via search; only the
// owning session can create a goal or change its
// completion state, so those never ride the child surface.
.filter(|name| !matches!(*name, "create_goal" | "update_goal"))
.chain([crate::core::engine::tool_catalog::TOOL_SEARCH_NAME])
.map(str::to_string)
.collect();
assert_eq!(source_names, expected_names);
assert!(source_names.contains("get_goal"));
assert!(!source_names.contains("create_goal"));
assert!(!source_names.contains("update_goal"));
+65 -22
View File
@@ -1793,7 +1793,6 @@ impl<'a> PromptBuilder<'a> {
),
false,
);
dump_system_prompt_if_requested(&messages);
if provider == ApiProvider::Arcee {
apply_arcee_waf_safe_message_encoding(&mut messages);
}
@@ -1873,38 +1872,82 @@ const SYSTEM_PROMPT_DUMP_BEGIN: &str = "<<<CODEWHALE_SYSTEM_PROMPT_BEGIN>>>";
const SYSTEM_PROMPT_DUMP_END: &str = "<<<CODEWHALE_SYSTEM_PROMPT_END>>>";
const ARCEE_WAF_TEXT_SPLIT_TRIGGERS: &[(&str, &str, &str)] = &[("python -c", "python ", "-c")];
fn dump_system_prompt_if_requested(messages: &[Value]) {
/// Dump the wire prefix (system region + tools array) when the
/// system-prompt dump flag is set, so prefix-token audits can measure the
/// catalog beside the prompt. Called once from
/// [`PreparedOutboundRequest::new`](super::prepared::PreparedOutboundRequest::new),
/// the single funnel for all three wire dialects, so the bytes are the
/// exact-final-body bytes. Debug-only, zero production impact.
pub(crate) fn dump_wire_prefix_if_requested(body: &Value) {
let Ok(flag) = std::env::var(SYSTEM_PROMPT_DUMP_ENV) else {
return;
};
if !matches!(flag.trim(), "1" | "true" | "TRUE" | "yes" | "YES") {
return;
}
let Some(prompt) = messages.iter().find_map(system_message_text) else {
return;
};
let mut stderr = std::io::stderr().lock();
let _ = writeln!(stderr, "{SYSTEM_PROMPT_DUMP_BEGIN}");
let _ = writeln!(stderr, "{prompt}");
let _ = writeln!(stderr, "{SYSTEM_PROMPT_DUMP_END}");
if let Some(prompt) = wire_system_text(body) {
let _ = writeln!(stderr, "{SYSTEM_PROMPT_DUMP_BEGIN}");
let _ = writeln!(stderr, "{prompt}");
let _ = writeln!(stderr, "{SYSTEM_PROMPT_DUMP_END}");
}
if let Some(tools) = body.get("tools") {
let _ = writeln!(stderr, "<<<CODEWHALE_WIRE_TOOLS_BEGIN>>>");
let _ = writeln!(stderr, "{tools}");
let _ = writeln!(stderr, "<<<CODEWHALE_WIRE_TOOLS_END>>>");
}
}
fn system_message_text(message: &Value) -> Option<String> {
if message.get("role").and_then(Value::as_str) != Some("system") {
return None;
/// Best-effort system-region text across the three wire dialects:
/// Anthropic `system` (string or blocks), Responses `instructions`, and
/// chat `messages[]` with a system/developer role.
fn wire_system_text(body: &Value) -> Option<String> {
if let Some(system) = body.get("system") {
return Some(match system {
Value::String(text) => text.clone(),
Value::Array(parts) => {
let text = parts
.iter()
.filter_map(|part| part.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("\n");
if text.is_empty() {
system.to_string()
} else {
text
}
}
_ => system.to_string(),
});
}
match message.get("content")? {
Value::String(text) => Some(text.clone()),
Value::Array(parts) => {
let text = parts
.iter()
.filter_map(|part| part.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("");
(!text.is_empty()).then_some(text)
}
_ => None,
if let Some(instructions) = body.get("instructions").and_then(Value::as_str) {
return Some(instructions.to_string());
}
body.get("messages")?
.as_array()?
.iter()
.find_map(|message| {
if !matches!(
message.get("role").and_then(Value::as_str),
Some("system" | "developer")
) {
return None;
}
match message.get("content")? {
Value::String(text) => Some(text.clone()),
Value::Array(parts) => {
// Same "\n" block join as the Anthropic arm above, so
// the audit text is dialect-consistent.
let text = parts
.iter()
.filter_map(|part| part.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("\n");
(!text.is_empty()).then_some(text)
}
_ => None,
}
})
}
fn apply_arcee_waf_safe_message_encoding(messages: &mut [Value]) {
+1
View File
@@ -305,6 +305,7 @@ impl PreparedOutboundRequest {
replay_input_tokens: Option<u32>,
entrypoint: CallerStreamMode,
) -> Self {
super::chat::dump_wire_prefix_if_requested(&body);
let reasoning = ReasoningReceipt::from_body(dialect, &body, requested_effort);
Self {
dialect,
+17 -2
View File
@@ -136,7 +136,7 @@ fn agent_list_event(manager: &SubAgentManager, active_session_id: &str) -> Event
}
const MCP_REGISTRY_FIRST_INSTRUCTION_SOURCE: &str = "runtime:mcp-registry-first";
const MCP_REGISTRY_FIRST_INSTRUCTION: &str = "## MCP Registry\n\nThe Registry installs and connects a local MCP server when this session lacks a capability. It is a fallback for a capability you do not have, not a step before ordinary work.\n\nPrefer what is already available, in order: tools already in this catalog, the project's own scripts, tests, and dev tooling, and platform capabilities. Creating a file, reading a fixture, running a repo command, and checking your own output are ordinary work — do them directly.\n\nReach for the Registry once you have identified a specific capability that no available tool covers and that you would otherwise install or reimplement, such as a document or media converter, access to an external database or service, or a protocol client. Then call `registry_sync` with a `query` naming that capability; it scores the local Registry snapshot host-side and returns at most eight matches, so the full index never enters the conversation. When a returned server plausibly covers that capability, call `start_registry_mcp_server` with its exact name rather than installing or running its package command through the shell. If nothing matches, refine the query once, then continue with local tools.\n\nBoth Registry tools are deferred: load one with `tool_search` before its first call, and use the returned schema. If a call instead reports that it only loaded the schema, retry once with that schema. Do not go searching for them for work you can already do.";
const MCP_REGISTRY_FIRST_INSTRUCTION: &str = "## MCP Registry\n\nThe Registry installs a local MCP server when this session lacks a capability — a fallback for a capability you do not have, not a step before ordinary work. Prefer what is already available and do ordinary work directly; only reach for `registry_sync` with a `query` naming a specific missing capability. Both Registry tools are deferred: load one with `tool_search` before its first call. Start a match with `start_registry_mcp_server` and its exact name rather than installing or running its package command through the shell.";
const ISOLATED_CHAT_ENGINE_PROMPT: &str = "You are Codewhale Chat. Answer the user's request directly and conversationally. This isolated chat-only session has no local workspace, project, memory, skill, account, credential, path, runtime context, or tools.";
pub(crate) fn sanitize_isolated_chat_attachments(mut text: String) -> String {
@@ -4474,6 +4474,7 @@ impl Engine {
mcp_access: McpAccess,
route: TurnRouteContext,
turn_id: &str,
turn_goal_objective: Option<&str>,
) -> TurnToolBuild {
// Account-owned Chat is a text-only inference boundary. Do not build
// native/plugin/dynamic registries, connect or snapshot MCP, capture a
@@ -4729,7 +4730,20 @@ impl Engine {
// The surface budget belongs to the route the request would go to,
// which is not necessarily the installed one under auto routing.
let capability = route.capability_profile();
let always_load = self.config.tools_always_load.clone();
let mut always_load = self.config.tools_always_load.clone();
// Token diet: get/update_goal cannot succeed without an active goal,
// so they stay deferred until one exists. Keyed off the TURN's
// resolved goal (the same `NextTurnPromptContext.goal_objective`
// the turn fragment renders), never ambient engine config:
// production installs the turn goal before this build while
// preview builds without installing, so ambient state would
// diverge preview from the wire body on detached-goal turns.
// Same-field gating also means no new cache break — the tools
// flip exactly when the fragment flips.
if turn_goal_objective.is_some() {
always_load.insert("get_goal".to_string());
always_load.insert("update_goal".to_string());
}
self.turn_tool_surface_budget = Some(capability.tool_surface_budget);
let catalog = build_model_tool_catalog_with_surface(
tool_registry.to_api_tools_with_cache(true),
@@ -5332,6 +5346,7 @@ impl Engine {
reasoning_effort_auto: self.session.reasoning_effort_auto,
},
&turn_id_for_mailbox,
prompt_context.goal_objective.as_deref(),
)
.await;
let tool_catalog_for_event = Some(surface.catalog.clone());
+1
View File
@@ -312,6 +312,7 @@ impl Engine {
McpAccess::PassiveSnapshot,
route_context.clone(),
"",
prompt_context.goal_objective.as_deref(),
)
.await;
@@ -633,6 +633,7 @@ async fn planned_route_builds_subagent_catalog_without_installed_client() {
reasoning_effort_auto: planned.auto_controls_reasoning,
},
"",
None,
)
.await;
assert!(
@@ -2186,6 +2187,7 @@ async fn preview_tool_snapshot_has_no_mcp_or_event_side_effects() {
reasoning_effort_auto: false,
},
"",
None,
)
.await;
+43 -24
View File
@@ -10552,28 +10552,33 @@ fn approval_stamp_scenario() {
#[test]
fn core_primitives_and_todo_write_default_to_eager() {
let always_load = HashSet::new();
for core in ["read", "write", "edit", "bash", "agent", "todo_write"] {
for core in ["read", "write", "edit", "bash", "todo_write"] {
assert!(!should_default_defer_tool(core, &always_load));
}
for searchable in ["File", "Bash", "Git", "Run", "tasks", "git_blame"] {
// Token diet: agent+workflow (21KB of schema) and get/update_goal
// (meaningless without an active goal) stay searchable by default.
for searchable in [
"File",
"Bash",
"Git",
"Run",
"tasks",
"git_blame",
"agent",
"workflow",
"get_goal",
"update_goal",
] {
assert!(should_default_defer_tool(searchable, &always_load));
}
}
#[test]
fn default_active_contract_keeps_discovery_and_core_tools_eager() {
const EXPECTED_NATIVE: [&str; 10] = [
"read",
"write",
"edit",
"bash",
"agent",
"workflow",
"todo_write",
"create_goal",
"get_goal",
"update_goal",
];
// Token diet: agent+workflow and get/update_goal are searchable by
// default (see `core_primitives_and_todo_write_default_to_eager`).
const EXPECTED_NATIVE: [&str; 6] =
["read", "write", "edit", "bash", "todo_write", "create_goal"];
assert_eq!(
default_active_native_tool_names(),
EXPECTED_NATIVE.as_slice()
@@ -10612,7 +10617,7 @@ fn default_active_contract_keeps_discovery_and_core_tools_eager() {
#[test]
fn non_yolo_mode_retains_default_defer_policy() {
let always_load = HashSet::new();
for core in ["read", "write", "edit", "bash", "agent", "todo_write"] {
for core in ["read", "write", "edit", "bash", "todo_write"] {
assert!(!should_default_defer_tool(core, &always_load));
}
for searchable in [
@@ -10697,9 +10702,11 @@ fn model_tool_catalog_applies_native_and_mcp_deferral() {
.and_then(|tool| tool.defer_loading)
};
for core in ["read", "write", "edit", "bash", "agent"] {
for core in ["read", "write", "edit", "bash"] {
assert_eq!(defer_loading(core), Some(false));
}
// Token diet: agent stays searchable even on wide surfaces.
assert_eq!(defer_loading("agent"), Some(true));
assert_eq!(defer_loading("Git"), Some(true));
assert_eq!(defer_loading("Run"), Some(true));
assert_eq!(defer_loading("remember"), Some(true));
@@ -10761,9 +10768,11 @@ fn capability_compact_surface_defers_nonessential_core_tools() {
.and_then(|tool| tool.defer_loading)
};
for core in ["read", "write", "edit", "bash", "agent"] {
for core in ["read", "write", "edit", "bash"] {
assert_eq!(defer_loading(core), Some(false));
}
// Token diet: agent stays searchable even on wide surfaces.
assert_eq!(defer_loading("agent"), Some(true));
assert_eq!(defer_loading("Git"), Some(true));
assert_eq!(defer_loading("update_plan"), Some(true));
assert_eq!(defer_loading(TOOL_SEARCH_NAME), Some(false));
@@ -10791,7 +10800,7 @@ fn capability_full_surface_preserves_small_default_head() {
crate::model_profile::ToolSurfaceBudget::Full,
);
for name in ["read", "write", "edit", "bash", "agent"] {
for name in ["read", "write", "edit", "bash"] {
assert_eq!(
catalog
.iter()
@@ -10801,6 +10810,14 @@ fn capability_full_surface_preserves_small_default_head() {
"{name} should stay eager on full tool surfaces"
);
}
// Token diet: agent stays searchable even on full surfaces.
assert_eq!(
catalog
.iter()
.find(|tool| tool.name == "agent")
.and_then(|tool| tool.defer_loading),
Some(true),
);
assert_eq!(
catalog
.iter()
@@ -11065,6 +11082,7 @@ async fn measure_production_mode_tool_catalogs() -> serde_json::Value {
McpAccess::PassiveSnapshot,
route,
"",
None,
)
.await;
let active = build.surface.active.clone().unwrap_or_default();
@@ -11101,17 +11119,16 @@ fn metric_tool_names<'a>(
#[allow(clippy::await_holding_lock)]
async fn runtime_contract_tool_metric_uses_canonical_mode_surfaces() {
let payload = measure_production_mode_tool_catalogs().await;
// Token diet: the request head carries 7 eager tools; agent+workflow
// and get/update_goal stay searchable (the "full" surface below still
// lists them as catalog members).
let expected_active = HashSet::from([
"agent",
"bash",
"create_goal",
"get_goal",
"update_goal",
"edit",
"read",
"todo_write",
"tool_search",
"workflow",
"write",
]);
@@ -11137,7 +11154,7 @@ async fn runtime_contract_tool_metric_uses_canonical_mode_surfaces() {
assert_eq!(
metric_tool_names(&payload, mode, "active"),
expected_active,
"{mode} must keep the same request head including goal controls"
"{mode} must keep the same request head"
);
}
@@ -14138,7 +14155,8 @@ fn plan_mode_toggle_preserves_catalog_byte_stability() {
.map(|t| t.name.as_str())
.collect();
let expected_head = ["agent", "bash", "edit", "read", "tool_search", "write"];
// Token diet: agent is searchable, not head.
let expected_head = ["bash", "edit", "read", "tool_search", "write"];
assert_eq!(plan_names, expected_head);
assert_eq!(agent_names, expected_head);
@@ -22303,6 +22321,7 @@ readline.createInterface({ input: process.stdin }).on('line', async line => {
McpAccess::Connect,
route,
"",
None,
);
tokio::pin!(build);
std::future::poll_fn(|cx| {
+26 -11
View File
@@ -50,10 +50,16 @@ pub(crate) fn is_tool_search_tool(name: &str) -> bool {
#[rustfmt::skip]
pub(crate) const DEFAULT_ACTIVE_NATIVE_TOOLS: &[&str] = &[
// Core work controls are eager; specialized tools stay searchable.
"read", "write", "edit", "bash", "agent", "workflow", "todo_write",
// Continuation instructions require these controls. Hiding them behind
// discovery leaves a model unable to stop the work it was asked to run.
"create_goal", "get_goal", "update_goal",
// Token diet: agent+workflow are deferred despite being core-adjacent —
// their schemas cost 21KB/turn and BM25 discovery (`tool_search`) finds
// them reliably when delegation is actually wanted (cued-delegation
// probe: discovered and executed; uncued sessions behave identically).
"read", "write", "edit", "bash", "todo_write",
// create_goal stays eager: it is the entry point. get/update_goal cannot
// succeed without an active goal, so they ride `always_load` only while
// one exists (see the catalog build in `engine.rs`) instead of taxing
// every goalless turn.
"create_goal",
];
const CORE_ACTION_TOOL_FALLBACKS: &[CoreActionToolFallback] = &[
@@ -912,7 +918,12 @@ fn discover_tools_with_regex(
let regex = compile_user_regex(query)
.map_err(|err| ToolError::invalid_input(format!("Invalid regex query: {err}")))?;
let mut matches = Vec::new();
// Name matches outrank haystack matches: a regex that hits the tool's
// own name is the strongest possible signal, and catalog (alphabetical)
// order otherwise lets an incidental mention evict the literal target
// from the bounded activation cache.
let mut name_matches = Vec::new();
let mut hay_matches = Vec::new();
let mut scratch = ToolSearchScratch::default();
for tool in catalog {
// tool_search loads definitions omitted from the current request. An
@@ -922,15 +933,19 @@ fn discover_tools_with_regex(
if !tool.defer_loading.unwrap_or(false) || is_tool_search_tool(&tool.name) {
continue;
}
// Lowercased, like the haystack path below: `Agent` must hit `agent`.
if regex.is_match(&tool.name.to_lowercase()) {
name_matches.push(tool.name.clone());
continue;
}
scratch.load(tool);
if regex.is_match(&scratch.hay) {
matches.push(tool.name.clone());
}
if matches.len() >= max_results {
break;
hay_matches.push(tool.name.clone());
}
}
Ok(matches)
name_matches.extend(hay_matches);
name_matches.truncate(max_results);
Ok(name_matches)
}
fn discover_tools_with_bm25_like(catalog: &[Tool], query: &str, max_results: usize) -> Vec<String> {
@@ -1520,7 +1535,7 @@ fn execute_tool_search_inner(
json!({
"type": "unavailable_tool_reference",
"tool_name": name,
"reason": "The tool schema exceeds the bounded conversation toolbox (8 cached tools, 16KiB of added serialized schemas). Narrow the search or use a smaller matching tool."
"reason": "The tool schema exceeds the bounded conversation toolbox (8 cached tools, 24KiB of added serialized schemas). Narrow the search or use a smaller matching tool."
})
}));
@@ -70,32 +70,20 @@ fn published_synthetic_names_agree_with_the_synthetic_predicate() {
#[test]
fn first_turn_surface_is_stable_across_plan_work_and_operate() {
// Token diet: agent+workflow (21KB of schema) and get/update_goal
// (meaningless without an active goal) stay deferred; discovery via
// tool_search restores them on demand.
assert_eq!(
DEFAULT_ACTIVE_NATIVE_TOOLS,
&[
"read",
"write",
"edit",
"bash",
"agent",
"workflow",
"todo_write",
"create_goal",
"get_goal",
"update_goal"
]
&["read", "write", "edit", "bash", "todo_write", "create_goal"]
);
let expected = [
"agent",
"bash",
"create_goal",
"get_goal",
"update_goal",
"edit",
"read",
"todo_write",
"tool_search",
"workflow",
"write",
]
.into_iter()
@@ -238,7 +226,9 @@ fn successful_cached_execution_updates_lru_without_granting_uncached_names() {
remove_evicted_cache_activations(&catalog, &mut active, delta.evicted);
active.extend(delta.admitted);
assert!(cache.names().any(|name| name == "deferred-0"));
assert!(!cache.names().any(|name| name == "deferred-1"));
// Priority order: deferred-7 was the lowest-priority entry of the
// first batch and was never re-touched, so it is the victim.
assert!(!cache.names().any(|name| name == "deferred-7"));
assert!(!touch_cached_tool_after_execution(
&catalog,
@@ -320,7 +310,7 @@ fn unknown_and_wildcard_allowlists_keep_mcp_startup() {
}
#[test]
fn compact_surface_keeps_agent_and_workflow_eager() {
fn compact_surface_keeps_agent_and_workflow_deferred() {
let catalog = build_model_tool_catalog_with_surface(
[
"read",
@@ -346,12 +336,30 @@ fn compact_surface_keeps_agent_and_workflow_eager() {
.iter()
.find(|definition| definition.name == name)
.and_then(|definition| definition.defer_loading),
Some(false),
Some(true),
"{name}"
);
}
}
#[test]
fn deferred_core_tools_restore_through_always_load() {
// The diet's escape hatch: discovery/activation and the goal-aware
// catalog build in `engine.rs` re-eager exactly these names.
let mut catalog = ["agent", "workflow", "get_goal", "update_goal", "read"]
.into_iter()
.map(tool)
.collect::<Vec<_>>();
let always_load = ["agent", "workflow", "get_goal", "update_goal"]
.into_iter()
.map(str::to_string)
.collect::<HashSet<_>>();
apply_native_tool_deferral(&mut catalog, &always_load);
for definition in &catalog {
assert_eq!(definition.defer_loading, Some(false), "{}", definition.name);
}
}
/// The per-tool Registry paragraph is gone; the catalog builder must leave the
/// shell tool's description exactly as the registry produced it, so the KV
/// prefix stays byte-stable and there is one Registry authority (the prompt).
+50 -8
View File
@@ -12,11 +12,15 @@ use std::collections::{HashSet, VecDeque};
use std::path::PathBuf;
/// Maximum number of deferred schemas a conversation may keep in its active
/// toolbox. The permanent `read`/`write`/`edit`/`bash`/`agent`/`tool_search`
/// router surface is not counted here.
/// toolbox. The permanent eager head is not counted here.
pub(crate) const TOOL_ACTIVATION_CACHE_MAX_NAMES: usize = 8;
/// Maximum serialized bytes added to requests by cached deferred schemas.
pub(crate) const TOOL_ACTIVATION_CACHE_MAX_SCHEMA_BYTES: usize = 16 * 1024;
/// Sized to co-activate the delegation pair (`agent` 11.7KB + `workflow`
/// 9.7KB) with room for small companions: at 16KB the two thrashed each
/// other out of the cache every discovery round. The prefix diet removed
/// 28KB from the head; this returns 8KB of activation headroom, still a
/// deep net cut.
pub(crate) const TOOL_ACTIVATION_CACHE_MAX_SCHEMA_BYTES: usize = 24 * 1024;
/// Bounded, process-local conversation cache for tools activated by
/// `tool_search`.
@@ -96,6 +100,14 @@ impl ToolActivationCache {
/// Touch requested deferred tools in search-result order. An oversized
/// schema is rejected; otherwise least-recently-used entries are evicted
/// until both bounds hold.
///
/// Push order is the REVERSE of the request order: eviction pops from
/// the front, so pushing best-match-last keeps the highest-priority
/// tool (which the caller ranked first) alive the longest. Pushing in
/// request order stranded large best matches — `agent` (11.7KB) was
/// discovered, then immediately evicted by its own small-fry company
/// whenever the set overflowed the byte bound. Cross-turn LRU is unaffected:
/// every batch entry is still newer than every older entry.
pub(crate) fn activate(
&mut self,
catalog: &[codewhale_models::Tool],
@@ -106,7 +118,9 @@ impl ToolActivationCache {
..ToolActivationDelta::default()
};
let mut seen = HashSet::new();
for name in requested {
let mut ordered: Vec<&String> = requested.iter().collect();
ordered.reverse();
for name in ordered {
if !seen.insert(name.clone()) {
continue;
}
@@ -469,15 +483,41 @@ mod tests {
let delta = cache.activate(&catalog, &requested);
assert_eq!(cache.names().count(), TOOL_ACTIVATION_CACHE_MAX_NAMES);
// Request order is priority (search ranked best-first), so the
// TAIL of the request is evicted first — the retained set is the
// eight highest-priority tools.
assert_eq!(
cache.names().collect::<Vec<_>>(),
vec![
"tool_2", "tool_3", "tool_4", "tool_5", "tool_6", "tool_7", "tool_8", "tool_9"
"tool_7", "tool_6", "tool_5", "tool_4", "tool_3", "tool_2", "tool_1", "tool_0"
]
);
assert_eq!(delta.admitted.len(), TOOL_ACTIVATION_CACHE_MAX_NAMES);
assert!(delta.evicted.contains(&"tool_0".to_string()));
assert!(delta.evicted.contains(&"tool_1".to_string()));
assert!(delta.evicted.contains(&"tool_8".to_string()));
assert!(delta.evicted.contains(&"tool_9".to_string()));
}
#[test]
fn best_match_survives_byte_overflow_eviction() {
// Priority-inversion regression: `agent` (11.7KB, best match) was
// discovered, then immediately evicted by its own small-fry company
// whenever the set overflowed the byte bound.
let mut catalog = vec![deferred_tool("big_best", 20_000)];
for index in 0..8 {
catalog.push(deferred_tool(&format!("small_{index}"), 1_000));
}
let requested = catalog
.iter()
.map(|tool| tool.name.clone())
.collect::<Vec<_>>();
let mut cache = ToolActivationCache::default();
let delta = cache.activate(&catalog, &requested);
assert!(
delta.admitted.contains(&"big_best".to_string()),
"best match must survive its own discovery set: {delta:?}"
);
assert!(delta.admitted.len() < requested.len());
}
#[test]
@@ -496,7 +536,9 @@ mod tests {
let names = cache.names().collect::<Vec<_>>();
assert!(names.contains(&"tool_0"));
assert!(!names.contains(&"tool_1"));
// tool_7 was the lowest-priority entry of the first batch and was
// never re-touched, so it is the eviction victim — not tool_1.
assert!(!names.contains(&"tool_7"));
assert_eq!(names.last().copied(), Some("tool_8"));
}
+12 -7
View File
@@ -5868,20 +5868,17 @@ command = "echo project"
// here would never fire. This table is checked in both directions, so
// a rename fails on the missing entry and a classifier change fails on
// the mismatched category.
// Token diet: agent+workflow and get/update_goal are deferred by
// default (discoverable via tool_search), so they left the eager
// table; their classifications are pinned with the other
// deferred tools below instead.
const EXPECTED: &[(&str, &str)] = &[
("read", "safe"),
("write", "file_write"),
("edit", "file_write"),
("bash", "shell"),
// The router itself touches nothing a hook needs to gate.
("agent", "other"),
("workflow", "other"),
("todo_write", "safe"),
// Goal controls retain their existing hook classification when
// promoted from deferred discovery to the eager catalog.
("create_goal", "other"),
("get_goal", "other"),
("update_goal", "other"),
];
for name in crate::core::engine::tool_catalog::DEFAULT_ACTIVE_NATIVE_TOOLS {
let expected = EXPECTED.iter().find(|(n, _)| n == name).map(|(_, c)| *c);
@@ -5902,6 +5899,14 @@ command = "echo project"
);
}
// Deferred by default, still classified: the router touches
// nothing a hook needs to gate, and the goal controls keep
// their classification when promoted to eager.
assert_eq!(tool_category_for("agent", None), "other");
assert_eq!(tool_category_for("workflow", None), "other");
assert_eq!(tool_category_for("get_goal", None), "other");
assert_eq!(tool_category_for("update_goal", None), "other");
// The shell surface.
assert_eq!(tool_category_for("Bash", None), "shell");
// Retained: shell.rs stamps this for the shell_env event.
+14 -70
View File
@@ -1320,7 +1320,7 @@ pub fn render_available_skills_context_for_workspace_with_mode_and_plugins(
}
/// Progressive-disclosure contract: the model sees a bounded page of skill
/// names, descriptions, and paths, then uses `load_skill` for the complete
/// names and descriptions, then uses `load_skill` for the complete
/// catalogue or a specific `SKILL.md` body.
///
/// Test-only single-directory variant. Production callers scan the complete
@@ -1450,65 +1450,10 @@ fn replace_prompt_path_root(text: &str, root: &str, replacement: &str) -> String
out
}
/// Render a skill path without leaking private absolute paths into the
/// system-prompt prefix (#4632): workspace skills become workspace-relative,
/// home-dir skills become `~/…`, and anything else is reduced to its trailing
/// components so the prefix stays free of user-identifying absolute paths.
/// Skill paths in the prompt are consumed by the model as text, not by the
/// platform's shell, so normalize Windows separators to forward slashes:
/// the catalog renders identically on every platform (#5473).
fn prompt_display(path: &Path) -> String {
path.display()
.to_string()
.replace(std::path::MAIN_SEPARATOR, "/")
}
fn privacy_safe_skill_path(path: &Path, workspace: &Path) -> String {
if let Ok(rel) = path.strip_prefix(workspace) {
return prompt_display(rel);
}
if let Some(home) = crate::config::effective_home_dir()
&& let Ok(rel) = path.strip_prefix(&home)
{
return format!("~/{}", prompt_display(rel));
}
match (path.parent().and_then(Path::file_name), path.file_name()) {
(Some(dir), Some(file)) => {
format!("…/{}/{}", dir.to_string_lossy(), file.to_string_lossy())
}
_ => path
.file_name()
.map(|file| file.to_string_lossy().into_owned())
.unwrap_or_else(|| "SKILL.md".to_string()),
}
}
fn path_is_within_root(path: &Path, root: &Path) -> bool {
if path.starts_with(root) {
return true;
}
let Some(canonical_path) = fs::canonicalize(path).ok() else {
return false;
};
let Some(canonical_root) = fs::canonicalize(root).ok() else {
return false;
};
canonical_path.starts_with(canonical_root)
}
fn prompt_skill_path(
path: &Path,
workspace: &Path,
configured_skills_root: Option<&Path>,
) -> Option<String> {
if let Some(root) = configured_skills_root
&& path_is_within_root(path, root)
{
return None;
}
Some(privacy_safe_skill_path(path, workspace))
}
// Token diet: the ambient index carries names + descriptions only, never
// on-disk paths — `load_skill` resolves names through the registry. (The
// former privacy-safe path renderers lived here; deleting them also deletes
// the #4632/#5473 surface they existed to defend.)
#[cfg(test)]
fn render_skills_block(registry: &SkillRegistry, locale: &str, workspace: &Path) -> Option<String> {
render_skills_block_with_configured_root(
@@ -1656,17 +1601,16 @@ Skills are optional instruction packs. This index exposes routing metadata; bodi
// instructions or consuming prompt budget.
.filter(|skill| skill.invocation != SkillInvocation::ExplicitOnly)
.map(|skill| {
// Native skills expose the real on-disk path captured at discovery.
// Plugin skills expose only their reviewed snapshot identity so the
// model cannot bypass the content-bound trust receipt via a mutable
// source path. Paths render privacy-safe (workspace-relative or
// ~/…) so the prompt prefix never embeds absolute user paths
// (#4632). A caller-provided skills root omits its physical path
// because that root may change per session; load_skill still
// resolves the stable skill name through the internal registry.
let display_path = prompt_skill_path(&skill.path, workspace, configured_skills_root);
// Token diet: native skills expose no on-disk path. `load_skill`
// resolves the stable skill name through the internal registry,
// so the path only invited the slower read-the-file bypass the
// tool description already steers away from — at ~50 bytes a row,
// every turn, in the cache-pinned prefix. Plugin skills keep
// their reviewed snapshot identity: the trust receipt must stay
// visible so the model cannot mistake a snapshot for a mutable
// source path.
let source = match &skill.source {
SkillSource::Native => display_path.map(|path| format!("file: {path}")),
SkillSource::Native => None,
SkillSource::Plugin {
plugin_id,
plugin_name,
+22 -28
View File
@@ -111,7 +111,7 @@ fn prompt_warning_sanitizer_handles_non_utf8_configured_roots() {
}
#[test]
fn render_available_skills_context_lists_paths_and_usage() {
fn render_available_skills_context_lists_names_and_usage() {
let tmpdir = TempDir::new().unwrap();
create_skill_dir(
&tmpdir,
@@ -122,17 +122,16 @@ fn render_available_skills_context_lists_paths_and_usage() {
let rendered = crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
.expect("skill context");
// #4632: paths render relative to the skills base dir (privacy-safe),
// so the assertion checks the workspace-relative form.
let expected_path = super::prompt_display(&std::path::Path::new("test-skill").join("SKILL.md"));
// Token diet: names + descriptions only. `load_skill` resolves the
// name through the registry, so no on-disk path is rendered at all
// (#4632's surface is gone with the paths).
assert!(rendered.contains("## Skills"));
assert!(rendered.contains("- test-skill: A test skill"));
assert!(rendered.contains("load the exact skill before use"));
assert!(rendered.contains("do not expand tool, approval, or trust authority"));
assert!(
rendered.contains(&expected_path),
"expected path {expected_path:?} not in rendered output"
!rendered.contains("test-skill/SKILL.md"),
"no on-disk paths in the index"
);
assert!(!rendered.contains(tmpdir.path().to_str().unwrap_or("/nonexistent")));
assert!(rendered.contains("### Usage"));
@@ -181,12 +180,12 @@ fn workspace_prompt_omits_disabled_skills_without_configured_directory() {
}
#[test]
fn render_available_skills_context_uses_real_dir_name_not_frontmatter_name() {
// Regression: when a community-installed or manually-placed skill
fn render_available_skills_context_resolves_by_name_without_invented_paths() {
// Regression (pathless form): when a community-installed skill
// lives in a directory whose name differs from its frontmatter
// `name`, the rendered prompt must point to the real on-disk file
// path, not <skills_dir>/<frontmatter-name>/SKILL.md (which does
// not exist).
// `name`, the row carries the registry name and no path at all —
// `load_skill` resolves the name to the real on-disk file, so no
// caller can reconstruct a stale <frontmatter>/SKILL.md path.
let tmpdir = TempDir::new().unwrap();
create_skill_dir(
&tmpdir,
@@ -197,18 +196,17 @@ fn render_available_skills_context_uses_real_dir_name_not_frontmatter_name() {
let rendered = crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
.expect("skill context");
// #4632: rendered relative to the skills base dir; the regression
// intent (real dir name, not frontmatter name) is unchanged.
let real_path = super::prompt_display(&std::path::Path::new("weird-dir-name").join("SKILL.md"));
let stale_path = super::prompt_display(&std::path::Path::new("friendly-name").join("SKILL.md"));
assert!(
rendered.contains(&real_path),
"expected real on-disk path {real_path:?} in rendered output, got:\n{rendered}"
rendered.contains("- friendly-name: drift case"),
"registry-name row present, got:\n{rendered}"
);
assert!(
!rendered.contains(&stale_path),
"rendered output must not invent a path under the frontmatter name:\n{rendered}"
!rendered.contains("weird-dir-name/SKILL.md"),
"no dir-derived path:\n{rendered}"
);
assert!(
!rendered.contains("friendly-name/SKILL.md"),
"no frontmatter-derived path:\n{rendered}"
);
}
@@ -1885,9 +1883,7 @@ fn configured_skill_prompt_uses_a_stable_root_in_entries_and_warnings() {
.expect("configured skill context");
assert!(rendered.contains("- visual-design: Design assets\n"));
assert!(rendered.contains(
"- workspace-skill: Workspace skill (file: .claude/skills/workspace-skill/SKILL.md)"
));
assert!(rendered.contains("- workspace-skill: Workspace skill\n"));
assert!(
rendered
.contains("in <configured-skills>/visual-design/SKILL.md is not a safe command name")
@@ -1897,7 +1893,7 @@ fn configured_skill_prompt_uses_a_stable_root_in_entries_and_warnings() {
}
#[test]
fn default_workspace_skill_prompt_preserves_its_discoverable_path() {
fn default_workspace_skill_prompt_omits_paths_in_favour_of_load_skill() {
let _env_lock = crate::test_support::lock_test_env();
super::clear_skill_discovery_cache();
let tmpdir = TempDir::new().unwrap();
@@ -1927,9 +1923,7 @@ fn default_workspace_skill_prompt_preserves_its_discoverable_path() {
)
.expect("workspace skill context");
assert!(rendered.contains(
"- workspace-skill: Workspace skill (file: .agents/skills/workspace-skill/SKILL.md)"
));
assert!(rendered.contains("- workspace-skill: Workspace skill\n"));
}
#[test]
File diff suppressed because it is too large. Load diff
+28 -1
View File
@@ -774,7 +774,16 @@ impl ReadFileTool {
enforce_read_denylist(&file_path, "read")?;
check_file_operation_cancelled(context)?;
let bytes = tokio::fs::read(&file_path).await.map_err(|error| {
ToolError::execution_failed(format!("Failed to read {}: {error}", file_path.display()))
let mut message = format!("Failed to read {}: {error}", file_path.display());
// Miss echo: only for genuine NotFound — never for denylist or
// permission failures, where listing the parent would answer a
// refused probe.
if error.kind() == std::io::ErrorKind::NotFound
&& let Some(echo) = crate::tools::echolocation::sound_miss_echo(&file_path)
{
message.push_str(&echo);
}
ToolError::execution_failed(message)
})?;
// #6283: every read response carries the file's byte size, line
// count, and truncation flag so the caller can page deliberately
@@ -863,6 +872,24 @@ impl ReadFileTool {
}
}
// Echolocation: stateless kin footer sounded fresh from source
// (no index, no new deps). Deterministic and hard-budgeted, so a
// repeated read is byte-identical. Two distinct files read in
// this pod put the next read there into the terminal buzz
// (callers included).
let buzz = file_path.parent().is_some_and(|parent| {
context.pod_visit_count(parent) >= crate::tools::echolocation::BUZZ_VISIT_THRESHOLD
});
output.push_str(
&crate::tools::echolocation::sound_echolocation(
&context.workspace,
&file_path,
&text,
buzz,
)
.render_footer(),
);
// This internal observation keeps hidden legacy edit replay working,
// but no hash or read-before-edit ceremony reaches the lowercase
// schema or result.
+145 -26
View File
@@ -78,7 +78,10 @@ async fn contract_read_returns_a_file_of_more_than_two_thousand_short_lines_whol
let result = ReadFileTool::execute_contract_read(json!({"path": "many.txt"}), &context)
.await
.expect("read result");
assert_eq!(result.content, content);
assert!(
result.content.starts_with(&content),
"whole file first, pod footer after"
);
assert!(
!result.content.contains("[Showing lines"),
"no truncation footer"
@@ -86,7 +89,7 @@ async fn contract_read_returns_a_file_of_more_than_two_thousand_short_lines_whol
}
#[tokio::test]
async fn contract_read_returns_an_ordinary_source_file_whole_without_a_footer() {
async fn contract_read_appends_a_pod_footer_to_a_whole_source_file() {
let temporary = tempfile::tempdir().expect("tempdir");
let content = "fn main() {\n println!(\"hi\");\n}\n";
std::fs::write(temporary.path().join("main.rs"), content).expect("fixture");
@@ -94,7 +97,66 @@ async fn contract_read_returns_an_ordinary_source_file_whole_without_a_footer()
let result = ReadFileTool::execute_contract_read(json!({"path": "main.rs"}), &context)
.await
.expect("read result");
assert_eq!(result.content, content);
assert!(
result.content.starts_with(content),
"whole file first, pod footer after"
);
assert!(
result.content.contains("[Pod:"),
"pod footer present: {}",
result.content
);
assert!(
result.content.contains("[Sounding: 1 symbol: fn main:1]"),
"sounded symbol present: {}",
result.content
);
}
#[tokio::test]
async fn contract_read_miss_echoes_parent_mates_on_not_found() {
let temporary = tempfile::tempdir().expect("tempdir");
std::fs::write(temporary.path().join("alpha.rs"), "fn a() {}\n").expect("fixture");
std::fs::write(temporary.path().join("beta.rs"), "fn b() {}\n").expect("fixture");
let context = ToolContext::new(temporary.path());
let error = ReadFileTool::execute_contract_read(json!({"path": "alpah.rs"}), &context)
.await
.expect_err("typo'd path must fail");
match error {
ToolError::ExecutionFailed { message } => {
assert!(message.contains("Failed to read"), "{message}");
assert!(message.contains("[Miss echo: "), "{message}");
assert!(message.contains("alpha.rs"), "{message}");
assert!(message.contains("beta.rs"), "{message}");
}
other => panic!("expected ordinary read failure, got {other:?}"),
}
}
#[tokio::test]
async fn contract_read_refused_probe_gets_no_miss_echo() {
// Security gate: a denylisted path must fail WITHOUT the miss echo —
// listing the parent would answer the refused probe. The denylist
// check runs before the IO read, so the refusal wins whether or not
// the file exists. Mirrors the home-dir fixture pattern in
// file/tests/tools.rs (the guard snapshots HOME process-wide).
let _env_lock = crate::test_support::lock_test_env();
let Some(home) = dirs::home_dir() else {
return;
};
let temporary = tempfile::tempdir().expect("tempdir");
let context = ToolContext::new(temporary.path());
let probe = home.join(".ssh").join("id_ed25519_no_such_key");
let error =
ReadFileTool::execute_contract_read(json!({"path": probe.to_string_lossy()}), &context)
.await
.expect_err("denylisted path must fail");
let message = error.to_string();
assert!(message.contains("deny-list"), "{message}");
assert!(
!message.contains("[Miss echo: "),
"refused probe must not echo: {message}"
);
}
#[test]
@@ -173,7 +235,10 @@ async fn contract_read_max_bytes_raises_the_budget_for_one_call() {
)
.await
.expect("raised budget read");
assert_eq!(raised.content, content);
assert!(
raised.content.starts_with(&content),
"whole file first, pod footer after"
);
// Above the hard maximum clamps down; the file still fits, so it is whole.
let clamped = ReadFileTool::execute_contract_read(
@@ -182,7 +247,10 @@ async fn contract_read_max_bytes_raises_the_budget_for_one_call() {
)
.await
.expect("clamped budget read");
assert_eq!(clamped.content, content);
assert!(
clamped.content.starts_with(&content),
"whole file first, pod footer after"
);
}
#[tokio::test]
@@ -201,17 +269,14 @@ async fn contract_read_paginates_an_oversized_file_with_an_honest_budget_footer(
let first = ReadFileTool::execute_contract_read(json!({"path": "big.txt"}), &context)
.await
.expect("first page");
let footer = first
.content
.rsplit_once("\n\n")
.expect("footer present")
.1
.to_string();
assert_eq!(
footer,
"[Showing lines 1-100 of 2000 (1.9MB total, 100000-byte output budget). Use offset=101 to continue, or max_bytes up to 500000 to read more per call.]"
assert!(
first.content.contains(
"[Showing lines 1-100 of 2000 (1.9MB total, 100000-byte output budget). Use offset=101 to continue, or max_bytes up to 500000 to read more per call.]"
),
"paging footer exact: {}",
first.content
);
let shown = first.content.rsplit_once("\n\n").expect("body").0;
let shown = first.content.split("\n\n[").next().expect("body");
assert_eq!(shown.lines().count(), 100);
// The named continuation offset is exact: page two starts on line 101.
@@ -307,8 +372,8 @@ async fn contract_read_pages_stay_bounded_and_cover_the_whole_file() {
);
let body = result
.content
.rsplit_once("\n\n[")
.map(|(body, _)| body)
.split("\n\n[")
.next()
.unwrap_or(&result.content);
seen.extend(body.lines().map(str::to_string));
let truncated = metadata["truncated"].as_bool().expect("truncated flag");
@@ -323,7 +388,7 @@ async fn contract_read_pages_stay_bounded_and_cover_the_whole_file() {
}
/// #6283: ordinary whole reads carry the same paging metadata (with
/// truncated=false) and keep their footer-free shape.
/// truncated=false); the pod footer follows the whole file.
#[tokio::test]
async fn contract_read_metadata_for_ordinary_whole_read() {
let temporary = tempfile::tempdir().expect("tempdir");
@@ -334,7 +399,15 @@ async fn contract_read_metadata_for_ordinary_whole_read() {
let result = ReadFileTool::execute_contract_read(json!({"path": "small.txt"}), &context)
.await
.expect("read result");
assert_eq!(result.content, content);
assert!(
result.content.starts_with(content),
"whole file first, pod footer after"
);
assert!(
result.content.contains("[Pod:"),
"pod footer present: {}",
result.content
);
let metadata = result.metadata.clone().expect("paging metadata");
assert_eq!(metadata["size"], content.len() as u64);
assert_eq!(metadata["truncated"], false);
@@ -399,9 +472,12 @@ async fn contract_read_reports_huge_first_line_with_exact_bash_fallback() {
let result = ReadFileTool::execute_contract_read(json!({"path": "huge.txt"}), &context)
.await
.expect("read result");
assert_eq!(
result.content,
"[Line 1 is 97.7KB, exceeds the 100000-byte output budget for this call. Use bash: sed -n '1p' huge.txt | head -c 100000]"
assert!(
result.content.starts_with(
"[Line 1 is 97.7KB, exceeds the 100000-byte output budget for this call. Use bash: sed -n '1p' huge.txt | head -c 100000]"
),
"exact bash fallback first, pod footer after: {}",
result.content
);
}
@@ -417,9 +493,12 @@ async fn contract_read_offset_oob_and_limit_continuation_match_contract() {
)
.await
.expect("limited read");
assert_eq!(
limited.content,
"two\n\n[1 more lines in file (13B total). Use offset=3 to continue.]"
assert!(
limited
.content
.starts_with("two\n\n[1 more lines in file (13B total). Use offset=3 to continue.]"),
"page plus paging footer first, pod footer after: {}",
limited.content
);
let error =
@@ -451,7 +530,11 @@ async fn contract_read_uses_magic_not_extension_for_images() {
let text = ReadFileTool::execute_contract_read(json!({"path": "plain.png"}), &context)
.await
.expect("fake extension remains text");
assert_eq!(text.content, "ordinary text");
assert!(
text.content.starts_with("ordinary text"),
"text first, pod footer after: {}",
text.content
);
let image = ReadFileTool::execute_contract_read(json!({"path": "renamed.data"}), &context)
.await
.expect("real image uses typed transport");
@@ -611,3 +694,39 @@ async fn contract_edit_rejects_read_only_target_before_atomic_replace() {
assert!(error.to_string().contains("readable and writable"));
assert_eq!(std::fs::read_to_string(path).expect("unchanged"), "alpha\n");
}
#[tokio::test]
async fn contract_read_enters_terminal_buzz_on_third_pod_visit() {
let temporary = tempfile::tempdir().expect("tempdir");
let root = temporary.path();
std::fs::create_dir_all(root.join("src/net")).expect("mkdir");
std::fs::write(root.join("src/main.rs"), "fn main() {}\n").expect("fixture");
std::fs::write(root.join("src/net/mod.rs"), "pub mod server;\n").expect("fixture");
std::fs::write(root.join("src/net/server.rs"), "fn serve() {}\n").expect("fixture");
std::fs::write(root.join("src/net/client.rs"), "fn connect() {}\n").expect("fixture");
let context = ToolContext::new(root);
// First two pod reads: full footer, no callers yet.
for path in ["src/net/mod.rs", "src/net/client.rs"] {
let result = ReadFileTool::execute_contract_read(json!({"path": path}), &context)
.await
.expect("read result");
assert!(!result.content.contains("heard by"), "{path}");
}
// Third distinct pod read: the buzz adds who links here.
let result =
ReadFileTool::execute_contract_read(json!({"path": "src/net/server.rs"}), &context)
.await
.expect("read result");
assert!(
result.content.contains("heard by: src/net/mod.rs"),
"{}",
result.content
);
// Another pod stays sparse: the buzz is per-pod, not global.
std::fs::write(root.join("src/other.rs"), "fn other() {}\n").expect("fixture");
let result = ReadFileTool::execute_contract_read(json!({"path": "src/other.rs"}), &context)
.await
.expect("read result");
assert!(!result.content.contains("heard by"), "{}", result.content);
}
+66 -6
View File
@@ -110,6 +110,9 @@ impl ToolSpec for FileSearchTool {
let extensions = parse_extensions(&input);
let exclude_patterns = parse_exclude_patterns(&input);
let pod_root = base_path.clone();
let pod_excludes = exclude_patterns.clone();
let pod_extensions = extensions.clone();
let matches = search_files_async(
query.to_string(),
base_path,
@@ -121,7 +124,34 @@ impl ToolSpec for FileSearchTool {
context.follow_symlinks,
)
.await?;
ToolResult::json(&matches).map_err(|e| ToolError::execution_failed(e.to_string()))
// Echolocation sounding: chart the matched dirs (sorted, capped)
// so each candidate arrives with its neighborhood. Dirs resolve
// against the search root, which is the workspace by default.
let paths: Vec<String> = matches.iter().map(|item| item.path.clone()).collect();
let vis = super::echolocation::PodVisibility {
include: &[],
exclude: &pod_excludes,
extensions: &pod_extensions,
glob_root: &pod_root,
gitignore: true,
};
let (pods, pods_omitted) = super::echolocation::sound_match_pods(&pod_root, &paths, &vis);
let pods_json: Vec<Value> = pods
.iter()
.map(|pod| {
json!({
"dir": pod.dir,
"mates": pod.mates,
"mate_total": pod.mate_total,
})
})
.collect();
ToolResult::json(&json!({
"matches": matches,
"pods": pods_json,
"pods_omitted": pods_omitted,
}))
.map_err(|e| ToolError::execution_failed(e.to_string()))
}
}
@@ -346,7 +376,7 @@ fn should_exclude(rel_path: &str, exclude_patterns: &[String]) -> bool {
.any(|pattern| matches_glob(rel_path, pattern))
}
fn extension_matches(path: &Path, extensions: &[String]) -> bool {
pub(crate) fn extension_matches(path: &Path, extensions: &[String]) -> bool {
let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
return false;
};
@@ -463,6 +493,36 @@ mod tests {
assert!(result.content.contains("main.rs"));
}
#[tokio::test]
async fn test_file_search_charts_match_pods() {
let tmp = tempdir().expect("tempdir");
let root = tmp.path();
std::fs::create_dir_all(root.join("src/net")).expect("mkdir");
std::fs::write(root.join("src/net/server.rs"), "fn serve() {}\n").expect("write");
std::fs::write(root.join("src/net/client.rs"), "fn connect() {}\n").expect("write");
let ctx = ToolContext::new(root.to_path_buf());
let result = FileSearchTool
.execute(json!({"query": "server", "limit": 5}), &ctx)
.await
.expect("execute");
assert!(result.success);
let body: Value = serde_json::from_str(&result.content).expect("json result");
assert!(!body["matches"].as_array().expect("matches").is_empty());
let pods = body["pods"].as_array().expect("pods array");
assert_eq!(pods.len(), 1);
assert_eq!(pods[0]["dir"], "src/net");
let mates: Vec<&str> = pods[0]["mates"]
.as_array()
.expect("mates")
.iter()
.filter_map(Value::as_str)
.collect();
assert!(mates.contains(&"server.rs (match)"), "{mates:?}");
assert!(mates.contains(&"client.rs"), "{mates:?}");
}
#[tokio::test]
async fn test_file_search_respects_gitignore() {
let tmp = tempdir().expect("tempdir");
@@ -518,9 +578,9 @@ mod tests {
.expect("execute");
assert!(result.success);
let matches: Value = serde_json::from_str(&result.content).expect("search json");
let body: Value = serde_json::from_str(&result.content).expect("search json");
assert!(
matches
body["matches"]
.as_array()
.expect("matches")
.iter()
@@ -545,9 +605,9 @@ mod tests {
.expect("execute");
assert!(result.success);
let matches: Value = serde_json::from_str(&result.content).expect("search json");
let body: Value = serde_json::from_str(&result.content).expect("search json");
assert!(
matches
body["matches"]
.as_array()
.expect("matches")
.iter()
+1
View File
@@ -24,6 +24,7 @@ pub mod file;
pub mod file_search;
pub mod finance;
pub mod echolocation;
pub mod fetch_url;
pub mod file_tool;
pub mod fim;
+34
View File
@@ -17,6 +17,8 @@ struct ProjectMap {
tree: String,
summary: String,
key_files: Vec<String>,
key_file_soundings: std::collections::BTreeMap<String, String>,
key_files_omitted: usize,
}
#[async_trait]
@@ -84,9 +86,41 @@ fn generate_project_map(
}
}
// Echolocation sounding: one line of symbols per key file (sorted,
// capped), so the map says what lives where, not just what exists.
let (key_file_soundings, key_files_omitted) =
super::echolocation::sound_key_files(root, &key_files);
Ok(ProjectMap {
tree,
summary,
key_files,
key_file_soundings,
key_files_omitted,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn project_map_sounds_key_file_symbols() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("main.rs"), "fn main() {}\nfn serve() {}\n")
.expect("fixture");
std::fs::write(dir.path().join("Cargo.toml"), "[package]\n").expect("fixture");
let context = ToolContext::new(dir.path().to_path_buf());
let result = ProjectMapTool
.execute(json!({}), &context)
.await
.expect("execute");
assert!(result.success);
let body: Value = serde_json::from_str(&result.content).expect("json result");
assert_eq!(
body["key_file_soundings"]["main.rs"],
"2 symbols: fn main:1, fn serve:2"
);
assert!(body["key_file_soundings"].get("Cargo.toml").is_none());
}
}
+32 -1
View File
@@ -32,13 +32,17 @@ const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024; // 10MB
/// tree. Mirrors the file_search tool so both blocking searches behave the same.
const GREP_FILES_TIMEOUT: Duration = Duration::from_secs(30);
/// Result of a grep match
/// Result of a grep match. Empty context arrays are omitted on the
/// wire (models request context in ~0% of calls); `default` keeps old
/// payloads parsing.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GrepMatch {
pub file: String,
pub line_number: usize,
pub line: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub context_before: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub context_after: Vec<String>,
}
@@ -250,6 +254,31 @@ impl ToolSpec for GrepFilesTool {
.map(|item| grep_match_to_json(item, context_lines))
.collect();
// Echolocation sounding: chart the matched dirs (sorted, capped)
// so the model sees each match's podmates without a follow-up
// list. Computed here, inside the blocking worker, with the
// workspace already at hand.
let matched_files: Vec<String> = results.iter().map(|item| item.file.clone()).collect();
let vis = crate::tools::echolocation::PodVisibility {
include: &include_patterns,
exclude: &exclude_patterns,
extensions: &[],
glob_root: &search_path,
gitignore: false,
};
let (pods, pods_omitted) =
crate::tools::echolocation::sound_match_pods(&workspace, &matched_files, &vis);
let pods_json: Vec<Value> = pods
.iter()
.map(|pod| {
json!({
"dir": pod.dir,
"mates": pod.mates,
"mate_total": pod.mate_total,
})
})
.collect();
// Build result. When context_lines == 1, return the single context
// line as a string instead of a one-item array. That keeps the common
// "show just the adjacent line" case easy for model callers to read.
@@ -258,6 +287,8 @@ impl ToolSpec for GrepFilesTool {
"total_matches": total_matches,
"files_searched": files_searched,
"truncated": total_matches > max_results,
"pods": pods_json,
"pods_omitted": pods_omitted,
}))
})
.await?;
+32 -2
View File
@@ -99,6 +99,35 @@ fn test_matches_glob_unicode_filename() {
assert!(!matches_glob(filename, "nonexistent*"));
}
#[tokio::test]
async fn grep_pods_chart_matched_dirs_with_marks() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
fs::create_dir(tmp.path().join("net")).expect("subdir");
fs::write(tmp.path().join("net/server.rs"), "fn serve() {}\n").expect("write");
fs::write(tmp.path().join("net/client.rs"), "fn connect() {}\n").expect("write");
let result = GrepFilesTool
.execute(json!({"pattern": "serve"}), &ctx)
.await
.expect("execute");
assert!(result.success);
let body: Value = serde_json::from_str(&result.content).expect("json result");
assert_eq!(body["total_matches"], 1);
let pods = body["pods"].as_array().expect("pods array");
assert_eq!(pods.len(), 1);
assert_eq!(pods[0]["dir"], "net");
let mates: Vec<&str> = pods[0]["mates"]
.as_array()
.expect("mates")
.iter()
.filter_map(Value::as_str)
.collect();
assert!(mates.contains(&"client.rs"), "{mates:?}");
assert!(mates.contains(&"server.rs (match)"), "{mates:?}");
assert_eq!(body["pods_omitted"], 0);
}
#[tokio::test]
async fn test_grep_files_basic() {
let tmp = tempdir().expect("tempdir");
@@ -400,12 +429,13 @@ async fn test_grep_files_ring_buffer_context_matches_full_read() {
let parsed: Value = serde_json::from_str(&result.content).unwrap();
let matches = parsed["matches"].as_array().unwrap();
assert_eq!(matches.len(), 3);
assert_eq!(matches[0]["context_before"], json!([]));
// Token diet: empty context arrays are omitted on the wire.
assert!(matches[0].get("context_before").is_none());
assert_eq!(matches[0]["context_after"], json!(["b1", "b2"]));
assert_eq!(matches[1]["context_before"], json!(["b2", "b3"]));
assert_eq!(matches[1]["context_after"], json!(["a1", "a2"]));
assert_eq!(matches[2]["context_before"], json!(["a2", "a3"]));
assert_eq!(matches[2]["context_after"], json!([]));
assert!(matches[2].get("context_after").is_none());
assert_eq!(matches[2]["line_number"].as_u64().unwrap(), 9);
}
+17
View File
@@ -982,6 +982,23 @@ impl ToolContext {
tracker.reads.insert(path.to_path_buf(), snapshot);
}
/// Distinct files read under `dir` so far this session. Echolocation's
/// hunt-phase sensor: 2+ tracked reads means the model is working this
/// pod and the next read there enters the terminal buzz. Lock
/// contention degrades to zero (sparse sounding), never an error.
pub(crate) fn pod_visit_count(&self, dir: &Path) -> usize {
self.file_read_tracker
.lock()
.map(|tracker| {
tracker
.reads
.keys()
.filter(|path| path.parent() == Some(dir))
.count()
})
.unwrap_or(0)
}
/// Require a successful, still-fresh `read_file` snapshot before a narrow
/// in-place edit. This catches model edits made against guessed or stale
/// content while leaving transactional patch preflight separate.
+1 -1
View File
@@ -17845,7 +17845,7 @@ impl SubAgentToolRegistry {
fn deferred_catalog_for_model(&self, agent_type: &FleetRole) -> Vec<Tool> {
// Every allowed tool remains searchable. Native deferral then leaves the
// fixed lowercase primitives plus agent/tool_search active; Web, MCP,
// fixed lowercase primitives plus tool_search active; agent, Web, MCP,
// plugins, and other tools stay discoverable rather than eager.
let mut catalog = self.tools_for_model(agent_type);
catalog.retain(|tool| !is_tool_search_tool(&tool.name));
+106 -38
View File
@@ -7732,6 +7732,76 @@ fn enabled_agent_surface_options() -> AgentToolSurfaceOptions {
///
/// Request-boundary tests use this fixture so Moonshot compatibility coverage
/// cannot drift into a hand-maintained approximation of the child surface.
#[test]
fn deferred_agent_and_workflow_are_discoverable_by_name() {
// Diet regression: deferral must never strand the tools. The real
// registry (not fixtures) carries them deferred, and both match
// algorithms surface them for the obvious queries.
use crate::core::engine::tool_catalog::{
TOOL_SEARCH_NAME, apply_native_tool_deferral, execute_tool_search_with_cache,
};
use crate::core::session::ToolActivationCache;
use crate::tools::registry::ToolRegistryBuilder;
use crate::tools::{ToolContext, todo};
use std::collections::HashSet;
let tmp = tempdir().expect("tempdir");
let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 4);
let registry = ToolRegistryBuilder::new()
.with_agent_runtime_surface(
None,
"test-model".to_string(),
enabled_agent_surface_options(),
todo::new_shared_todo_list(),
crate::tools::plan::new_shared_plan_state(),
)
.with_subagent_tools(manager, stub_runtime())
.build(ToolContext::new(tmp.path().to_path_buf()));
let mut catalog = registry.to_api_tools();
let always_load = HashSet::new();
apply_native_tool_deferral(&mut catalog, &always_load);
for name in ["agent", "workflow"] {
assert_eq!(
catalog
.iter()
.find(|tool| tool.name == name)
.and_then(|tool| tool.defer_loading),
Some(true),
"{name} is registered and deferred"
);
}
for (query, match_kind, wanted) in [
("agent", "regex", "agent"),
("workflow", "regex", "workflow"),
("spawn subagent worker", "bm25", "agent"),
("spawn subagent worker", "bm25", "workflow"),
] {
let mut active = HashSet::new();
let mut cache = ToolActivationCache::default();
let found = execute_tool_search_with_cache(
TOOL_SEARCH_NAME,
&json!({"query": query, "match": match_kind}),
&catalog,
&mut active,
&mut cache,
)
.expect("tool_search runs")
.metadata
.expect("metadata")["tool_references"]
.as_array()
.expect("refs")
.iter()
.filter_map(|v| v.as_str())
.map(str::to_string)
.collect::<Vec<_>>();
assert!(
found.contains(&wanted.to_string()),
"{match_kind} {query:?} must surface {wanted}; got {found:?}"
);
}
}
pub(crate) fn kimi_general_child_request_tools_fixture() -> Vec<Tool> {
let tmp = tempdir().expect("tempdir");
let mut runtime =
@@ -7855,27 +7925,27 @@ async fn execute_surface_tool(
}
#[test]
fn small_surface_starts_with_core_tools_and_read_only_goal_control() {
fn small_surface_starts_with_core_tools_and_discoverable_goal_control() {
let registry = small_surface_registry(FleetRole::Builder);
let catalog = registry.deferred_catalog_for_model(&FleetRole::Builder);
let mut surface = SubAgentToolSurface::new(catalog, &[]);
// Token diet: agent+workflow+get_goal ride discovery, not the head.
assert_eq!(
model_tool_names(model_request_tools(&mut surface)),
[
"agent",
"bash",
"edit",
"get_goal",
"read",
"todo_write",
"tool_search",
"workflow",
"write",
]
.into_iter()
.map(str::to_string)
.collect()
["bash", "edit", "read", "todo_write", "tool_search", "write",]
.into_iter()
.map(str::to_string)
.collect()
);
// The goal control still exists — deferred, not deleted.
assert_eq!(
surface
.catalog
.iter()
.find(|tool| tool.name == "get_goal")
.and_then(|tool| tool.defer_loading),
Some(true)
);
let strict = surface.request_tools(surface.catalog.clone(), true);
assert_eq!(
@@ -8065,19 +8135,20 @@ fn small_surface_caches_are_independent_bounded_and_revalidated() {
let mut first = SubAgentToolSurface::new(catalog.clone(), &warm);
let mut second = SubAgentToolSurface::new(catalog, &[]);
let first_names = model_tool_names(model_request_tools(&mut first));
assert!(!first_names.contains("deferred_0"));
assert!(first_names.contains("deferred_8"));
assert!(!model_tool_names(model_request_tools(&mut second)).contains("deferred_8"));
// Priority order: the warm tail (lowest priority) is evicted first.
assert!(!first_names.contains("deferred_8"));
assert!(first_names.contains("deferred_0"));
assert!(!model_tool_names(model_request_tools(&mut second)).contains("deferred_0"));
first.catalog.retain(|tool| tool.name != "deferred_8");
assert!(!model_tool_names(model_request_tools(&mut first)).contains("deferred_8"));
first.catalog.retain(|tool| tool.name != "deferred_0");
assert!(!model_tool_names(model_request_tools(&mut first)).contains("deferred_0"));
first
.catalog
.push(synthetic_deferred_tool("oversized", 17 * 1024));
.push(synthetic_deferred_tool("oversized", 25 * 1024));
assert!(first.hydrate("oversized").is_err());
let mut byte_catalog = (0..3)
.map(|index| synthetic_deferred_tool(&format!("bytes_{index}"), 6 * 1024))
.map(|index| synthetic_deferred_tool(&format!("bytes_{index}"), 9 * 1024))
.collect::<Vec<_>>();
ensure_advanced_tooling(
&mut byte_catalog,
@@ -8091,8 +8162,8 @@ fn small_surface_caches_are_independent_bounded_and_revalidated() {
.collect::<Vec<_>>();
let mut byte_surface = SubAgentToolSurface::new(byte_catalog, &byte_warm);
let byte_names = model_tool_names(model_request_tools(&mut byte_surface));
assert!(!byte_names.contains("bytes_0"));
assert!(byte_names.contains("bytes_1") && byte_names.contains("bytes_2"));
assert!(!byte_names.contains("bytes_2"));
assert!(byte_names.contains("bytes_0") && byte_names.contains("bytes_1"));
}
#[tokio::test]
@@ -8139,21 +8210,14 @@ fn small_surface_depth_cap_removes_only_agent() {
registry.deferred_catalog_for_model(&FleetRole::Builder),
&[],
);
// Token diet: the depth cap still removes only agent; get_goal and
// workflow are absent from the head by deferral, not by depth.
assert_eq!(
model_tool_names(model_request_tools(&mut surface)),
[
"bash",
"edit",
"get_goal",
"read",
"todo_write",
"tool_search",
"workflow",
"write"
]
.into_iter()
.map(str::to_string)
.collect()
["bash", "edit", "read", "todo_write", "tool_search", "write",]
.into_iter()
.map(str::to_string)
.collect()
);
}
@@ -22952,7 +23016,11 @@ fn tool_search_cannot_return_a_retired_agents_tool() {
let tmp = tempdir().expect("tempdir");
let registry = subagent_registry_for_catalog(tmp.path());
let mut catalog = registry.to_api_tools();
apply_native_tool_deferral(&mut catalog, &HashSet::new());
// Token diet: agent is searchable by default; this test's subject is
// retired-tool exclusion, so its precondition re-eagers agent through
// the same always-load path discovery uses.
let always_load = HashSet::from(["agent".to_string()]);
apply_native_tool_deferral(&mut catalog, &always_load);
assert!(
catalog
.iter()
+144 -4
View File
@@ -2381,9 +2381,10 @@ impl RunPayloadBounds {
}
}
/// Build the model-facing view of a run record (#2974). The JSON shape is
/// identical to the full record (panel hydration and history cards keep
/// working unchanged), but the unbounded parts are clipped:
/// Build the model-facing view of a run record (#2974). Panel hydration
/// and history cards keep working unchanged (they read keys this view
/// preserves), but the unbounded parts are clipped and each event is
/// projected to its model-essential keys:
///
/// - `events`: newest `WORKFLOW_RESULT_EVENTS_TAIL` entries.
/// - `progress`: newest `WORKFLOW_RESULT_PROGRESS_TAIL` lines.
@@ -2393,9 +2394,16 @@ impl RunPayloadBounds {
/// when the serialized value exceeds `WORKFLOW_RESULT_VALUE_MAX_CHARS`.
/// - `execution.leaf_results[*].output`: per-leaf preview capped at
/// `WORKFLOW_RESULT_LEAF_OUTPUT_MAX_CHARS`.
/// - event objects: exact top-level duplicates (`owner_session_id`,
/// `workflow_goal`, `workflow_id`, `source_path`, `token_budget`) are
/// removed; distinct `workspace` values hoist to top-level
/// `event_workspaces` once instead of repeating per event; routing,
/// isolation, and payload keys stay.
///
/// Full detail remains available in `.codewhale/workflow-runs.jsonl`; every
/// clip adds an explicit note/pointer so the model can fetch more on demand.
/// lossy clip adds an explicit note/pointer so the model can fetch more on
/// demand. The key projection is lossless by construction (duplicates live
/// on at top level; workspaces hoist), so it carries no pointer.
fn bounded_run_record_value(
record: &WorkflowRunRecord,
journal_path: &Path,
@@ -2416,14 +2424,53 @@ fn bounded_run_record_value(
);
obj.insert("progress_count".to_string(), json!(record.progress_count));
let mut workspaces = std::collections::BTreeSet::new();
if let Some(events) = obj.get_mut("events").and_then(Value::as_array_mut) {
if events.len() > WORKFLOW_RESULT_EVENTS_TAIL {
let omitted = events.len() - WORKFLOW_RESULT_EVENTS_TAIL;
events.drain(..omitted);
bounds.events_omitted = omitted;
}
// Token diet: project each event to model-essential keys. Dropped
// keys are exact top-level duplicates (`RunStarted` clones the
// record's goal/id/source/budget verbatim; `owner_session_id`
// repeats on every event). `workspace` is hoisted instead of
// dropped: the distinct set rides top-level once, since a
// worktree path names an isolation dir the model may need.
// `git_branch` stays per event — short run context that can vary
// per child. Routing keys (profile, resolved_*, route_source),
// worktree (isolation signal), and message/title/status/error/
// usage stay: asserted contract (`run_record_events_project_to_
// model_essential_keys`), and the model reads them. Full rows
// stay in the journal either way.
for event in events.iter_mut() {
let Some(fields) = event.as_object_mut() else {
continue;
};
if let Some(workspace) = fields.remove("workspace")
&& let Some(path) = workspace.as_str()
&& !path.is_empty()
{
workspaces.insert(path.to_string());
}
for key in [
"owner_session_id",
"workflow_goal",
"workflow_id",
"source_path",
"token_budget",
] {
fields.remove(key);
}
}
bounds.events_returned = events.len();
}
if !workspaces.is_empty() {
obj.insert(
"event_workspaces".to_string(),
Value::Array(workspaces.into_iter().map(Value::String).collect()),
);
}
if bounds.events_omitted > 0 {
obj.insert(
"events_note".to_string(),
@@ -11554,6 +11601,99 @@ FINAL RECEIPT
);
}
#[test]
fn run_record_events_project_to_model_essential_keys() {
fn task_started(task_id: &str, workspace: &str) -> WorkflowUiEvent {
WorkflowUiEvent::at(
2,
"session-test",
WorkflowUiEventKind::TaskStarted(Box::new(WorkflowTaskStartedEvent {
task_id: task_id.to_string(),
label: Some("dig".to_string()),
role: None,
profile: Some("deep".to_string()),
model: None,
strength: None,
thinking: None,
requested_reasoning: None,
effective_reasoning: None,
resolved_role: None,
resolved_profile: None,
resolved_provider: "zai".to_string(),
resolved_model: "glm-5".to_string(),
route_source: "fleet".to_string(),
child_route: None,
worktree: true,
workspace: Some(PathBuf::from(workspace)),
git_branch: Some("feature/x".to_string()),
parent_task_id: None,
depth: 0,
workflow_run_id: None,
workflow_phase_id: None,
workflow_task_label: None,
workflow_child_index: None,
fleet_receipt: None,
})),
)
}
let tmp = tempfile::tempdir().expect("tempdir");
let mut record = WorkflowRunRecord::new(
"workflow_projection_run".to_string(),
Some("session-test".to_string()),
Some(PathBuf::from("/ws/flow.json")),
Some(1_000),
None,
);
record.workflow_id = Some("flow-1".to_string());
record.workflow_goal = Some("ship it".to_string());
record.push_event(WorkflowUiEvent::at(
1,
"session-test",
WorkflowUiEventKind::RunStarted {
workflow_id: Some("flow-1".to_string()),
workflow_goal: Some("ship it".to_string()),
source_path: Some(PathBuf::from("/ws/flow.json")),
token_budget: Some(1_000),
},
));
// Two dispatches, one shared workspace: the hoist dedupes.
record.push_event(task_started("t1", "/tmp/wt-1"));
record.push_event(task_started("t2", "/tmp/wt-1"));
let journal = tmp.path().join("workflow-runs.jsonl");
let (payload, bounds) = bounded_run_record_value(&record, &journal);
assert!(!bounds.truncated());
let events = payload["events"].as_array().expect("events");
// Duplicates are gone from every event …
for event in events {
for key in [
"owner_session_id",
"workflow_goal",
"workflow_id",
"source_path",
"token_budget",
"workspace",
] {
assert!(event.get(key).is_none(), "dropped {key}: {event}");
}
}
// … but live on at top level (duplicates) or hoisted (workspaces).
assert_eq!(payload["workflow_goal"], "ship it");
assert_eq!(payload["token_budget"], 1_000);
assert_eq!(payload["event_workspaces"], json!(["/tmp/wt-1"]));
// Routing, isolation, and payload keys stay on the dispatch event.
let dispatch = &events[1];
assert_eq!(dispatch["task_id"], "t1");
assert_eq!(dispatch["route_source"], "fleet");
assert_eq!(dispatch["resolved_provider"], "zai");
assert_eq!(dispatch["resolved_model"], "glm-5");
assert_eq!(dispatch["worktree"], true);
assert_eq!(dispatch["git_branch"], "feature/x");
}
#[test]
fn workflow_status_payload_keeps_small_records_intact() {
let tmp = tempfile::tempdir().expect("tempdir");
+13 -4
View File
@@ -639,7 +639,7 @@ pub struct BranchResult {
pub status: WorkflowRunStatus,
#[serde(default)]
pub usage: WorkflowUsage,
#[serde(default)]
#[serde(default, skip_serializing_if = "WorkflowMemoUsage::is_zero")]
pub memo_usage: WorkflowMemoUsage,
#[serde(default)]
pub artifacts: Vec<String>,
@@ -660,7 +660,7 @@ pub struct LeafResult {
pub status: WorkflowRunStatus,
#[serde(default)]
pub usage: WorkflowUsage,
#[serde(default)]
#[serde(default, skip_serializing_if = "WorkflowMemoUsage::is_zero")]
pub memo_usage: WorkflowMemoUsage,
#[serde(default)]
pub output: Option<String>,
@@ -718,6 +718,15 @@ pub struct WorkflowMemoUsage {
}
impl WorkflowMemoUsage {
/// True when every counter is zero: 136 bytes of nothing on the wire.
pub fn is_zero(&self) -> bool {
self.armh_hits == 0
&& self.armh_misses == 0
&& self.armh_saved_estimated_tokens == 0
&& self.provider_prompt_cache_hits == 0
&& self.provider_prompt_cache_misses == 0
}
pub(crate) fn add_assign(&mut self, other: Self) {
self.armh_hits = self.armh_hits.saturating_add(other.armh_hits);
self.armh_misses = self.armh_misses.saturating_add(other.armh_misses);
@@ -775,7 +784,7 @@ pub struct WorkflowExecution {
pub status: WorkflowRunStatus,
#[serde(default)]
pub usage: WorkflowUsage,
#[serde(default)]
#[serde(default, skip_serializing_if = "WorkflowMemoUsage::is_zero")]
pub memo_usage: WorkflowMemoUsage,
#[serde(default)]
pub leaf_results: Vec<LeafResult>,
@@ -828,7 +837,7 @@ pub struct MockLeafOutcome {
pub status: WorkflowRunStatus,
#[serde(default)]
pub usage: WorkflowUsage,
#[serde(default)]
#[serde(default, skip_serializing_if = "WorkflowMemoUsage::is_zero")]
pub memo_usage: WorkflowMemoUsage,
#[serde(default)]
pub output: Option<String>,