refactor(engine): own the per-turn tool surface in one policy object

Refs #3940.

Before this change the model-visible catalog, executable registry,
initial request tools field, command-preview surface, question-tool
posture, and allow/deny gates were each reconstructed from mutable
engine/session configuration at different points in the turn, so they
could drift: gates applied at catalog build time were re-widened when
plan_turn_tools re-injected synthetic tools, the preview narrowed
independently of dispatch, and the execution stop read live session
state mid-turn.

Now build_turn_tool_registry_and_catalog returns one ToolSurfacePolicy
value owning: the concrete registry; the full catalog after allow/deny
narrowing (deny before allow); the exact initial request subset (None
sends no tools field); the question-tool posture derived once from the
permission posture; and the strict-mode flag. Synthetic tools are
injected before narrowing so an explicit gate can never re-advertise
them. Preview consumes the same policy dispatch consumes, so preview
and live request hashes agree by construction. The execution gate
consults the same policy, deny first then allow; mid-turn configuration
edits now apply from the next turn's policy rather than silently
rewriting the in-flight turn's contract.

Static Agent/Plan/Operate prompt prose no longer asserts the presence
of tools the catalog may filter out; wording is conditional on the
current catalog. Deleted: render_core_tool_taxonomy_body,
core_taxonomy_tools_for_mode, the taxonomy consts, plan_turn_tools,
TurnToolPlan, filter_tool_catalog_for_gates, and
filter_tool_catalog_for_permission_posture (zero surviving callers);
command_allows_tool/command_denies_tool demoted to #[cfg(test)] shims.

Regression coverage:
- denied synthetic tool blocked by the same policy at execution
- synthetic tools never reintroduced after gate filtering
- empty allowlist empties the catalog and sends no tools field
- Auto-Review hides the question tool while other postures keep it;
  legacy yolo-auto maps to effective Full Access
- request snapshot matches the exact mock request payload
- catalog filter applies (and is inert without) allow/deny gates

Verified: focused tests 7/7 + 2/2 + 16/16; prompts suite 139/139;
warnings-denied TUI clippy PASS; dead-code PASS 482; source-structure
PASS; fmt/diff clean; independent exact-diff review READY (8/8).
Runtime-contract identities re-baselined separately at integration:
the prompt changes are intentional (Act -3B, Operate -818B, Plan +55B)
and tool surfaces are unchanged.
This commit is contained in:
Hmbown
2026-07-30 22:01:59 -07:00
parent 997339d2a6
commit b155681fbd
7 changed files with 494 additions and 521 deletions
+63 -103
View File
@@ -3455,29 +3455,24 @@ impl Engine {
None
};
if let Some(subagent_runtime) = runtime {
Some(
builder
.with_subagent_tools(self.subagent_manager.clone(), subagent_runtime)
.build(tool_context),
)
builder
.with_subagent_tools(self.subagent_manager.clone(), subagent_runtime)
.build(tool_context)
} else {
tracing::warn!(
"Sub-agents enabled but no API client available, falling back to basic tool set"
);
Some(builder.build(tool_context))
builder.build(tool_context)
}
} else {
Some(builder.build(tool_context))
builder.build(tool_context)
};
// Load plugin tools from the user's tools directory and apply any
// config.toml overrides. Explicit overrides win over auto-discovered
// scripts with the same tool name.
let mut plugin_tool_names: std::collections::HashSet<String> =
std::collections::HashSet::new();
if let Some(ref mut tool_registry) = tool_registry {
plugin_tool_names = configure_plugin_tools(tool_registry, self.config.tools.as_ref());
}
let plugin_tool_names =
configure_plugin_tools(&mut tool_registry, self.config.tools.as_ref());
let mcp_state = if self.config.features.enabled(Feature::Mcp) {
if mcp_access.may_connect() {
@@ -3500,48 +3495,45 @@ impl Engine {
// caller can attribute MCP contributions without a second connect.
let mcp_tools = mcp_state.tools().to_vec();
let mcp_tool_names: Vec<String> = mcp_tools.iter().map(|tool| tool.name.clone()).collect();
let tools = tool_registry.as_ref().map(|registry| {
// The surface budget belongs to the route the request would go
// to, which is not necessarily the installed one: with auto model
// routing the host has already planned a different route for the
// next turn.
let capability = route.capability_profile();
let mut always_load = self.config.tools_always_load.clone();
if self.config.features.enabled(Feature::Mcp) {
always_load.insert("start_mcp_server".to_string());
// 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 mut always_load = self.config.tools_always_load.clone();
if self.config.features.enabled(Feature::Mcp) {
always_load.insert("start_mcp_server".to_string());
}
let bypass = input_policy.auto_approve
|| input_policy.approval_mode == crate::tui::approval::ApprovalMode::Bypass;
let catalog_mode = if bypass {
AppMode::Yolo
} else {
input_policy.mode
};
let mut catalog = build_model_tool_catalog_with_surface(
tool_registry.to_api_tools_with_cache(true),
mcp_tools,
catalog_mode,
&always_load,
capability.tool_surface_budget,
);
for tool in &mut catalog {
if plugin_tool_names.contains(&tool.name) {
tool.defer_loading = Some(false);
}
let bypass = input_policy.auto_approve
|| input_policy.approval_mode == crate::tui::approval::ApprovalMode::Bypass;
let mut catalog = build_model_tool_catalog_with_surface(
registry.to_api_tools_with_cache(true),
mcp_tools,
if bypass {
AppMode::Yolo
} else {
input_policy.mode
},
&always_load,
capability.tool_surface_budget,
);
for tool in &mut catalog {
if plugin_tool_names.contains(&tool.name) {
tool.defer_loading = Some(false);
}
}
filter_tool_catalog_for_gates(
&mut catalog,
allowed_tools.as_deref(),
self.config.disallowed_tools.as_deref(),
);
filter_tool_catalog_for_permission_posture(
&mut catalog,
input_policy.approval_mode_for_session(),
);
catalog
});
}
let surface = ToolSurfacePolicy::new(
tool_registry,
Some(catalog),
input_policy.mode,
&always_load,
&input_policy.dynamic_active_tools,
self.config.strict_tool_mode,
allowed_tools,
self.config.disallowed_tools.clone(),
input_policy.approval_mode_for_session(),
);
TurnToolBuild {
registry: tool_registry,
catalog: tools,
surface,
mcp_tool_names,
mcp: mcp_state,
subagent_runtime_model,
@@ -3920,8 +3912,7 @@ impl Engine {
// Build tool registry and tool list for the current mode
let turn_id_for_mailbox = turn.id.clone();
let TurnToolBuild {
registry: tool_registry,
catalog: tools,
surface,
mailbox: mut mailbox_for_runtime,
plugin_tool_names,
..
@@ -3949,7 +3940,7 @@ impl Engine {
&turn_id_for_mailbox,
)
.await;
let tool_catalog_for_event = tools.clone();
let tool_catalog_for_event = Some(surface.catalog.clone());
// Resolve, once per turn, the out-of-request facts the read-only
// request projection is allowed to report: flattened registry facts,
@@ -3958,10 +3949,7 @@ impl Engine {
// live; the snapshot itself is built later, at the request seam, from
// the tools actually prepared for that step.
let mut tool_surface = crate::tool_inspection::ToolSurfaceContext {
registry: tool_registry
.as_ref()
.map(|registry| registry.registry_facts(&plugin_tool_names))
.unwrap_or_default(),
registry: surface.registry.registry_facts(&plugin_tool_names),
mcp_servers: match self.mcp_pool.as_ref() {
Some(pool) => pool.lock().await.resolved_tool_servers(),
None => std::collections::BTreeMap::new(),
@@ -3986,10 +3974,7 @@ impl Engine {
use futures_util::FutureExt as _;
let turn_result = std::panic::AssertUnwindSafe(self.handle_deepseek_turn(
&mut turn,
tool_registry.as_ref(),
tools,
input_policy.mode,
input_policy.dynamic_active_tools,
surface,
Some(tool_surface),
))
.catch_unwind()
@@ -5578,28 +5563,26 @@ pub(crate) struct TurnMailboxBarrier {
pub(crate) drain_handle: tokio::task::JoinHandle<()>,
}
pub(crate) struct TurnToolBuild {
/// Runtime registry that will execute the tools.
pub(crate) registry: Option<crate::tools::ToolRegistry>,
/// Full model-facing catalog, including deferred entries.
pub(crate) catalog: Option<Vec<Tool>>,
struct TurnToolBuild {
/// One authority for executable, searchable, and initially active tools.
surface: ToolSurfacePolicy,
/// Names of the MCP-contributed tools in this build.
pub(crate) mcp_tool_names: Vec<String>,
mcp_tool_names: Vec<String>,
/// What is known about the MCP contribution to this catalog.
pub(crate) mcp: McpToolState,
mcp: McpToolState,
/// Route model installed into the child runtime, when sub-agent tools were
/// available. This is an internal receipt, not a manifest field.
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) subagent_runtime_model: Option<String>,
subagent_runtime_model: Option<String>,
/// Turn-scoped sub-agent mailbox and its flush barrier, when sub-agent
/// wiring was live. The engine must seal, flush, and await this before it
/// emits `TurnComplete`: that is what makes detached-child usage accounting
/// exactly-once rather than "whatever arrived in time".
pub(crate) mailbox: Option<TurnMailboxBarrier>,
mailbox: Option<TurnMailboxBarrier>,
/// Tools this build loaded from the plugin surface rather than the built-in
/// registry builder. Carried out so the read-only request projection can
/// tell `plugin` provenance from `builtin` instead of collapsing both.
pub(crate) plugin_tool_names: std::collections::HashSet<String>,
plugin_tool_names: std::collections::HashSet<String>,
}
/// The route a tool catalog is being shaped for.
@@ -5774,35 +5757,11 @@ pub(crate) use token_estimate_cache::TokenEstimateCache;
pub(super) const MAX_PARALLEL_SHELL_EXEC: usize = 4;
#[cfg(test)]
pub(crate) fn default_active_native_tool_names() -> &'static [&'static str] {
tool_catalog::DEFAULT_ACTIVE_NATIVE_TOOLS
}
/// Drop catalog entries the execution gates would reject (#3027): the model
/// should never be advertised a tool it cannot call. Deny wins over allow.
fn filter_tool_catalog_for_gates(
catalog: &mut Vec<Tool>,
allowed_tools: Option<&[String]>,
disallowed_tools: Option<&[String]>,
) {
catalog.retain(|tool| {
!turn_loop::command_denies_tool(disallowed_tools, &tool.name)
&& turn_loop::command_allows_tool(allowed_tools, &tool.name)
});
}
/// Auto-Review is the one fully autonomous posture. Hiding the question tool
/// keeps both the eager and deferred/tool-search surfaces aligned with the
/// runtime question guard in `turn_loop`.
fn filter_tool_catalog_for_permission_posture(
catalog: &mut Vec<Tool>,
approval_mode: crate::tui::approval::ApprovalMode,
) {
if !super::authority::permission_posture_allows_questions(approval_mode) {
catalog.retain(|tool| tool.name != REQUEST_USER_INPUT_NAME);
}
}
use self::approval::{ApprovalDecision, ApprovalResult, UserInputDecision};
use self::dispatch::{
ParallelToolResult, ParallelToolResultEntry, ToolApprovalStamp, ToolExecGuard, ToolExecOutcome,
@@ -5828,16 +5787,17 @@ use self::streaming::{
};
use self::tool_catalog::{
CODE_EXECUTION_TOOL_NAME, JS_EXECUTION_TOOL_NAME, MULTI_TOOL_PARALLEL_NAME,
REQUEST_USER_INPUT_NAME, active_tools_for_request, build_model_tool_catalog_with_surface,
default_synthetic_catalog_tool_names, execute_code_execution_tool, execute_tool_search,
is_tool_search_tool, maybe_hydrate_requested_deferred_tool, missing_tool_error_message,
plan_turn_tools, tool_catalog_consistency_issues,
REQUEST_USER_INPUT_NAME, ToolSurfacePolicy, active_tools_for_request,
build_model_tool_catalog_with_surface, default_synthetic_catalog_tool_names,
execute_code_execution_tool, execute_tool_search, is_tool_search_tool,
maybe_hydrate_requested_deferred_tool, missing_tool_error_message,
};
#[cfg(test)]
use self::tool_catalog::{
TOOL_SEARCH_NAME, active_tools_for_step, build_model_tool_catalog, ensure_advanced_tooling,
initial_active_tools, maybe_activate_requested_deferred_tool,
preflight_requested_deferred_tool, should_default_defer_tool,
preflight_requested_deferred_tool, should_default_defer_tool, tool_allowed,
tool_catalog_consistency_issues, tool_denied,
};
use self::tool_execution::emit_tool_audit;
use self::tool_preparation::{prepare_tool_call, reprepare_tool_call_after_hook};
+12 -19
View File
@@ -12,8 +12,8 @@
//! - **Never `session.last_tool_catalog`.** That value is one turn stale and
//! stores the pre-activation catalog, so it cannot describe what the *next*
//! request would send. The catalog is rebuilt through
//! [`Engine::build_turn_tool_registry_and_catalog`] and narrowed through
//! [`plan_turn_tools`] — the same two calls a real turn makes.
//! [`Engine::build_turn_tool_registry_and_catalog`], which returns the same
//! typed policy a real turn consumes.
//! - **Never invent a route.** For fixed routes, the host resolves the next
//! turn through the same shared planner production dispatch uses. Auto would
//! require a model-classifier call, so the human preview stops before the
@@ -315,20 +315,13 @@ impl Engine {
)
.await;
// Exactly the narrowing the turn loop applies before building its
// request — including deferred-tool activation and strict mode.
let plan = plan_turn_tools(
build.catalog,
input_policy.mode,
&self.config.tools_always_load,
&input_policy.dynamic_active_tools,
self.config.strict_tool_mode,
);
let active_tools = plan.active.clone().unwrap_or_default();
// The build owns the exact same initial subset dispatch consumes.
let surface = &build.surface;
let active_tools = surface.active.clone().unwrap_or_default();
let active_catalog_sha256 = active_tool_catalog_sha256(&active_tools);
let tool_choice = plan.active.as_ref().map(|_| {
if self.config.strict_tool_mode {
let tool_choice = surface.active.as_ref().map(|_| {
if surface.strict_tool_mode {
json!("required")
} else {
json!({ "type": "auto" })
@@ -340,8 +333,8 @@ impl Engine {
// turn", which is the failure mode this command exists to avoid.
let tools = match build.mcp.server_count() {
Some(mcp_server_count) => Availability::Exact(ToolSurfaceFacts {
catalog_tool_count: plan.catalog.len(),
deferred_tool_count: plan
catalog_tool_count: surface.catalog.len(),
deferred_tool_count: surface
.catalog
.iter()
.filter(|tool| tool.defer_loading.unwrap_or(false))
@@ -353,7 +346,7 @@ impl Engine {
route_context.capability_profile().tool_surface_budget
),
standard_and_full_surfaces_collapsed: standard_and_full_collapse(
&plan.catalog,
&surface.catalog,
&self.config.tools_always_load,
),
mcp_server_count,
@@ -508,7 +501,7 @@ impl Engine {
messages: outbound_messages,
max_tokens: effective_max_output_tokens_for_route(provider, &model, limits),
system: system_prompt,
tools: plan.active.clone(),
tools: surface.active.clone(),
tool_choice: tool_choice.clone(),
metadata: None,
thinking: None,
@@ -1202,8 +1195,8 @@ mod tests {
.await;
assert!(
build
.surface
.catalog
.expect("catalog")
.iter()
.any(|tool| tool.name == "agent"),
"the planned route client must make sub-agent tools available"
+194 -69
View File
@@ -3029,27 +3029,47 @@ fn catalog_tool(name: &str) -> Tool {
}
}
fn policy_for_catalog(
catalog: Vec<Tool>,
allowed_tools: Option<Vec<String>>,
disallowed_tools: Option<Vec<String>>,
approval_mode: crate::tui::approval::ApprovalMode,
) -> ToolSurfacePolicy {
ToolSurfacePolicy::new(
crate::tools::ToolRegistry::new(crate::tools::ToolContext::new(PathBuf::from("."))),
Some(catalog),
AppMode::Agent,
&HashSet::new(),
&[],
false,
allowed_tools,
disallowed_tools,
approval_mode,
)
}
#[test]
fn tool_catalog_filter_applies_allow_and_deny_gates() {
// #3027 AC1: the advertised catalog must not contain tools the execution
// gates would deny; deny wins over allow.
let mut catalog = vec![
let catalog = vec![
catalog_tool("read_file"),
catalog_tool("exec_shell"),
catalog_tool("grep_files"),
];
filter_tool_catalog_for_gates(
&mut catalog,
Some(&["read_file".to_string(), "exec_shell".to_string()][..]),
Some(&["exec_shell".to_string()][..]),
let surface = policy_for_catalog(
catalog,
Some(vec!["read_file".to_string(), "exec_shell".to_string()]),
Some(vec!["exec_shell".to_string()]),
crate::tui::approval::ApprovalMode::Suggest,
);
let names: Vec<&str> = catalog.iter().map(|t| t.name.as_str()).collect();
let names: Vec<&str> = surface.catalog.iter().map(|t| t.name.as_str()).collect();
assert_eq!(names, ["read_file"]);
}
#[test]
fn tool_catalog_shell_only_benchmark_surface_hides_native_tools() {
let mut catalog = vec![
let catalog = vec![
catalog_tool("exec_shell"),
catalog_tool("exec_shell_wait"),
catalog_tool("exec_shell_interact"),
@@ -3065,9 +3085,14 @@ fn tool_catalog_shell_only_benchmark_surface_hides_native_tools() {
"exec_shell_interact".to_string(),
];
filter_tool_catalog_for_gates(&mut catalog, Some(&shell_only), None);
let surface = policy_for_catalog(
catalog,
Some(shell_only.to_vec()),
None,
crate::tui::approval::ApprovalMode::Suggest,
);
let names: Vec<&str> = catalog.iter().map(|t| t.name.as_str()).collect();
let names: Vec<&str> = surface.catalog.iter().map(|t| t.name.as_str()).collect();
assert_eq!(
names,
["exec_shell", "exec_shell_wait", "exec_shell_interact"]
@@ -3076,9 +3101,115 @@ fn tool_catalog_shell_only_benchmark_surface_hides_native_tools() {
#[test]
fn tool_catalog_filter_is_inert_without_gates() {
let mut catalog = vec![catalog_tool("read_file"), catalog_tool("exec_shell")];
filter_tool_catalog_for_gates(&mut catalog, None, None);
assert_eq!(catalog.len(), 2);
let surface = policy_for_catalog(
vec![catalog_tool("read_file"), catalog_tool("exec_shell")],
None,
None,
crate::tui::approval::ApprovalMode::Suggest,
);
assert!(surface.catalog.iter().any(|tool| tool.name == "read_file"));
assert!(surface.catalog.iter().any(|tool| tool.name == "exec_shell"));
}
#[test]
fn tool_surface_policy_never_reintroduces_denied_synthetic_tools() {
let denied = vec![
TOOL_SEARCH_NAME.to_string(),
CODE_EXECUTION_TOOL_NAME.to_string(),
JS_EXECUTION_TOOL_NAME.to_string(),
];
let surface = policy_for_catalog(
vec![
catalog_tool("read_file"),
catalog_tool(CODE_EXECUTION_TOOL_NAME),
catalog_tool(JS_EXECUTION_TOOL_NAME),
],
Some(vec![
TOOL_SEARCH_NAME.to_string(),
CODE_EXECUTION_TOOL_NAME.to_string(),
JS_EXECUTION_TOOL_NAME.to_string(),
]),
Some(denied),
crate::tui::approval::ApprovalMode::Suggest,
);
for denied_name in [
TOOL_SEARCH_NAME,
CODE_EXECUTION_TOOL_NAME,
JS_EXECUTION_TOOL_NAME,
] {
assert!(surface.denies_tool(denied_name));
assert!(surface.passes_allow_list(denied_name));
assert!(
!surface.allows_tool(denied_name),
"deny must win over allow for {denied_name}"
);
assert!(
surface.catalog.iter().all(|tool| tool.name != denied_name),
"{denied_name} must not reappear after policy narrowing"
);
assert!(!surface.active_names.contains(denied_name));
}
}
#[tokio::test]
async fn denied_synthetic_tool_is_blocked_by_the_same_turn_policy_at_execution() {
use crate::llm_client::mock::{MockLlmClient, canned};
let workspace = tempdir().expect("tempdir");
let mock = std::sync::Arc::new(MockLlmClient::new(vec![
canned::tool_call_turn(
"call-denied-search",
TOOL_SEARCH_NAME,
r#"{"query":"File"}"#,
),
canned::simple_text_turn("Denied tool handled."),
]));
let client: crate::core::model_client::SharedModelClient = mock;
let (mut engine, handle) = Engine::new_with_model_client(
deterministic_engine_config(workspace.path()),
&Config::default(),
client,
);
let policy = policy_for_catalog(
vec![catalog_tool("read_file")],
Some(vec![TOOL_SEARCH_NAME.to_string()]),
Some(vec![TOOL_SEARCH_NAME.to_string()]),
crate::tui::approval::ApprovalMode::Suggest,
);
assert!(!policy.allows_tool(TOOL_SEARCH_NAME));
let mut turn = crate::core::turn::TurnContext::new(4);
let (status, error) = engine.handle_deepseek_turn(&mut turn, policy, None).await;
assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
let mut events = handle.rx_event.write().await;
let denied = std::iter::from_fn(|| events.try_recv().ok()).find_map(|event| match event {
Event::ToolCallComplete { name, result, .. } if name == TOOL_SEARCH_NAME => Some(result),
_ => None,
});
let error = denied
.expect("denied synthetic tool completion")
.expect_err("denied synthetic tool must not execute");
assert!(
error.to_string().contains("disallowed-tools list"),
"{error:?}"
);
}
#[test]
fn empty_allowed_tools_surface_is_empty_and_sends_no_tools_field() {
let surface = policy_for_catalog(
vec![catalog_tool("read_file")],
Some(Vec::new()),
None,
crate::tui::approval::ApprovalMode::Suggest,
);
assert!(surface.catalog.is_empty());
assert!(surface.active_names.is_empty());
assert!(surface.active.is_none());
assert!(!surface.allows_tool("read_file"));
}
/// The turn-start capture carries mode/workspace/working-set state only. Work
@@ -3376,6 +3507,25 @@ impl crate::core::model_client::ModelClient for BlockingModelClient {
}
}
fn test_tool_surface(
engine: &Engine,
registry: crate::tools::ToolRegistry,
tools: Option<Vec<crate::models::Tool>>,
mode: AppMode,
) -> ToolSurfacePolicy {
ToolSurfacePolicy::new(
registry,
tools,
mode,
&engine.config.tools_always_load,
&[],
engine.config.strict_tool_mode,
engine.config.allowed_tools.clone(),
engine.config.disallowed_tools.clone(),
engine.session.approval_mode,
)
}
#[tokio::test]
async fn tool_request_snapshot_matches_the_exact_mock_request_payload() {
use crate::llm_client::mock::{MockLlmClient, canned};
@@ -3392,18 +3542,10 @@ async fn tool_request_snapshot_matches_the_exact_mock_request_payload() {
let mut registry = crate::tools::ToolRegistry::new(context);
registry.register(std::sync::Arc::new(crate::tools::file::ReadFileTool));
let tools = Some(registry.to_api_tools_with_cache(true));
let surface = test_tool_surface(&engine, registry, tools, AppMode::Agent);
let mut turn = crate::core::turn::TurnContext::new(4);
let (status, error) = engine
.handle_deepseek_turn(
&mut turn,
Some(&registry),
tools,
AppMode::Agent,
Vec::new(),
None,
)
.await;
let (status, error) = engine.handle_deepseek_turn(&mut turn, surface, None).await;
assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
let request = mock.last_request().expect("mock request");
@@ -3444,10 +3586,11 @@ async fn snapshot_for_catalog(
&Config::default(),
client,
);
let registry =
crate::tools::ToolRegistry::new(crate::tools::ToolContext::new(workspace.to_path_buf()));
let surface = test_tool_surface(&engine, registry, catalog, AppMode::Agent);
let mut turn = crate::core::turn::TurnContext::new(2);
let (status, error) = engine
.handle_deepseek_turn(&mut turn, None, catalog, AppMode::Agent, Vec::new(), None)
.await;
let (status, error) = engine.handle_deepseek_turn(&mut turn, surface, None).await;
assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
let mut events = handle.rx_event.write().await;
std::iter::from_fn(|| events.try_recv().ok())
@@ -3507,18 +3650,10 @@ async fn request_snapshots_advance_to_the_latest_tool_step() {
let mut registry = crate::tools::ToolRegistry::new(context);
registry.register(std::sync::Arc::new(crate::tools::file::ReadFileTool));
let tools = Some(registry.to_api_tools_with_cache(true));
let surface = test_tool_surface(&engine, registry, tools, AppMode::Agent);
let mut turn = crate::core::turn::TurnContext::new(4);
let (status, error) = engine
.handle_deepseek_turn(
&mut turn,
Some(&registry),
tools,
AppMode::Agent,
Vec::new(),
None,
)
.await;
let (status, error) = engine.handle_deepseek_turn(&mut turn, surface, None).await;
assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
let mut events = handle.rx_event.write().await;
let snapshots = std::iter::from_fn(|| events.try_recv().ok())
@@ -3576,17 +3711,11 @@ async fn request_snapshot_reports_registry_provenance_for_the_transmitted_catalo
synthetic_names: synthetic_names.clone(),
provider: engine.tool_surface_provider_receipt(),
};
let policy = test_tool_surface(&engine, registry, tools, AppMode::Agent);
let mut turn = crate::core::turn::TurnContext::new(4);
let (status, error) = engine
.handle_deepseek_turn(
&mut turn,
Some(&registry),
tools,
AppMode::Agent,
Vec::new(),
Some(surface),
)
.handle_deepseek_turn(&mut turn, policy, Some(surface))
.await;
assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
@@ -3899,18 +4028,10 @@ async fn coalesced_raw_read_error_touches_working_set_once() {
let mut registry = crate::tools::ToolRegistry::new(context);
registry.register(std::sync::Arc::new(crate::tools::file::ReadFileTool));
let tools = Some(registry.to_api_tools_with_cache(true));
let surface = test_tool_surface(&engine, registry, tools, AppMode::Agent);
let mut turn = crate::core::turn::TurnContext::new(8);
let (status, error) = engine
.handle_deepseek_turn(
&mut turn,
Some(&registry),
tools,
AppMode::Agent,
Vec::new(),
None,
)
.await;
let (status, error) = engine.handle_deepseek_turn(&mut turn, surface, None).await;
assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
engine
@@ -5928,18 +6049,11 @@ async fn measure_production_mode_tool_catalogs() -> serde_json::Value {
"",
)
.await;
let plan = plan_turn_tools(
build.catalog,
mode,
&engine.config.tools_always_load,
&policy.dynamic_active_tools,
engine.config.strict_tool_mode,
);
let active = plan.active.unwrap_or_default();
let active = build.surface.active.clone().unwrap_or_default();
mode_metrics.insert(
mode_name.to_string(),
serde_json::json!({
"full": tool_catalog_surface_metrics(&plan.catalog),
"full": tool_catalog_surface_metrics(&build.surface.catalog),
"active": tool_catalog_surface_metrics(&active),
}),
);
@@ -6670,16 +6784,22 @@ fn auto_review_hides_question_tool_while_other_postures_keep_it() {
(ApprovalMode::Bypass, true),
(ApprovalMode::Never, true),
] {
let mut catalog = vec![api_tool("read_file"), api_tool(REQUEST_USER_INPUT_NAME)];
filter_tool_catalog_for_permission_posture(&mut catalog, posture);
let surface = policy_for_catalog(
vec![api_tool("read_file"), api_tool(REQUEST_USER_INPUT_NAME)],
None,
None,
posture,
);
assert_eq!(
catalog
surface
.catalog
.iter()
.any(|tool| tool.name == REQUEST_USER_INPUT_NAME),
expected,
"{posture:?}"
);
assert!(catalog.iter().any(|tool| tool.name == "read_file"));
assert_eq!(surface.allows_questions(), expected, "{posture:?}");
assert!(surface.catalog.iter().any(|tool| tool.name == "read_file"));
}
}
@@ -6699,10 +6819,15 @@ fn legacy_yolo_auto_shape_keeps_question_tool_as_effective_full_access() {
crate::tui::approval::ApprovalMode::Bypass
);
let mut catalog = vec![api_tool("read_file"), api_tool(REQUEST_USER_INPUT_NAME)];
filter_tool_catalog_for_permission_posture(&mut catalog, authority.approval_mode_for_session());
let surface = policy_for_catalog(
vec![api_tool("read_file"), api_tool(REQUEST_USER_INPUT_NAME)],
None,
None,
authority.approval_mode_for_session(),
);
assert!(
catalog
surface
.catalog
.iter()
.any(|tool| tool.name == REQUEST_USER_INPUT_NAME),
"effective Full Access must keep the question tool"
+110 -43
View File
@@ -11,6 +11,7 @@ use std::time::Duration;
use serde_json::{Value, json};
#[cfg(test)]
use crate::mcp::McpPool;
use crate::model_profile::ToolSurfaceBudget;
use crate::models::Tool;
@@ -103,9 +104,8 @@ fn cached_fallbacks() -> &'static [CachedFallback] {
}
/// Membership index over [`DEFAULT_ACTIVE_NATIVE_TOOLS`], built once for the
/// process lifetime. The array stays the source of truth for *ordered*
/// iteration (see [`tool_catalog_consistency_issues`] and
/// `engine::default_active_native_tool_names`); this set only accelerates the
/// process lifetime. The array stays the source of truth for ordered
/// inspection; this set only accelerates the
/// hot membership check in [`should_default_defer_tool`], which runs once per
/// catalog tool on every catalog rebuild (i.e. per turn) — an O(n·m) linear
/// scan over the array collapses to O(1) hashed lookups.
@@ -379,30 +379,121 @@ pub(super) fn active_tools_for_step(catalog: &[Tool], active: &HashSet<String>)
active_tool_list_from_catalog(catalog, active)
}
/// The exact tool state the next model request would carry.
/// One turn's executable and model-visible tool contract.
///
/// This is the single answer to "what tools would the next turn send?".
/// [`super::turn_loop`] seeds its mutable per-step state from it, and
/// `/preview-request` reports [`Self::active`] verbatim. Nothing else may
/// re-derive tool selection — in particular, the session's *last* catalog is
/// both stale and pre-activation, so it is never a substitute for this.
#[derive(Debug, Clone)]
pub(super) struct TurnToolPlan {
/// Full catalog after mode/always-load repair, including deferred tools.
/// The catalog is also the prompt's only availability taxonomy: stable prompt
/// prose deliberately does not enumerate tool names. Keeping the concrete
/// registry, searchable catalog, initial request subset, and command gates in
/// one value prevents preview, dispatch, and execution from reconstructing
/// different surfaces from mutable engine configuration.
pub(super) struct ToolSurfacePolicy {
/// Runtime registry that executes native and plugin tools.
pub(super) registry: crate::tools::ToolRegistry,
/// Full model-facing catalog, including deferred entries.
pub(super) catalog: Vec<Tool>,
/// Names active at the start of the turn.
pub(super) active_names: HashSet<String>,
/// The catalog subset that would actually be serialized into the request.
/// `None` when the turn would send no `tools` field at all.
/// Exact initial `tools` field. `None` means no field is sent.
pub(super) active: Option<Vec<Tool>>,
pub(super) mode: AppMode,
pub(super) strict_tool_mode: bool,
allowed_tools: Option<Vec<String>>,
disallowed_tools: Option<Vec<String>>,
questions_allowed: bool,
}
impl ToolSurfacePolicy {
#[allow(clippy::too_many_arguments)]
pub(super) fn new(
registry: crate::tools::ToolRegistry,
tools: Option<Vec<Tool>>,
mode: AppMode,
always_load: &HashSet<String>,
dynamic_active_tools: &[&'static str],
strict_tool_mode: bool,
allowed_tools: Option<Vec<String>>,
disallowed_tools: Option<Vec<String>>,
approval_mode: crate::tui::approval::ApprovalMode,
) -> Self {
let mut catalog = tools.unwrap_or_default();
if !catalog.is_empty() {
ensure_advanced_tooling(&mut catalog, mode, always_load);
}
// Synthetic tools are injected before narrowing. Doing this after the
// retain would re-advertise tool_search/code execution despite an
// explicit command gate.
catalog.retain(|tool| {
!tool_denied(disallowed_tools.as_deref(), &tool.name)
&& tool_allowed(allowed_tools.as_deref(), &tool.name)
});
let questions_allowed =
super::super::authority::permission_posture_allows_questions(approval_mode);
if !questions_allowed {
catalog.retain(|tool| tool.name != REQUEST_USER_INPUT_NAME);
}
let mut active_names = initial_active_tools(&catalog);
active_names.extend(dynamic_active_tools.iter().map(|name| (*name).to_string()));
active_names.retain(|name| catalog.iter().any(|tool| tool.name == *name));
let active = active_tools_for_request(&catalog, &active_names, strict_tool_mode);
Self {
registry,
catalog,
active_names,
active,
mode,
strict_tool_mode,
allowed_tools,
disallowed_tools,
questions_allowed,
}
}
#[cfg(test)]
pub(super) fn allows_tool(&self, name: &str) -> bool {
!self.denies_tool(name) && self.passes_allow_list(name)
}
pub(super) fn passes_allow_list(&self, name: &str) -> bool {
tool_allowed(self.allowed_tools.as_deref(), name)
}
pub(super) fn denies_tool(&self, name: &str) -> bool {
tool_denied(self.disallowed_tools.as_deref(), name)
}
pub(super) fn allows_questions(&self) -> bool {
self.questions_allowed
}
}
pub(super) fn tool_allowed(allowed_tools: Option<&[String]>, tool_name: &str) -> bool {
let Some(allowed_tools) = allowed_tools else {
return true;
};
tool_matches_any_rule(allowed_tools, tool_name)
}
pub(super) fn tool_denied(disallowed_tools: Option<&[String]>, tool_name: &str) -> bool {
disallowed_tools.is_some_and(|rules| tool_matches_any_rule(rules, tool_name))
}
fn tool_matches_any_rule(rules: &[String], tool_name: &str) -> bool {
let tool_name = tool_name.to_ascii_lowercase();
rules.iter().any(|rule| {
let rule = rule.to_ascii_lowercase();
rule.strip_suffix('*')
.map_or_else(|| tool_name == rule, |prefix| tool_name.starts_with(prefix))
})
}
/// The `tools` field of one outbound request, from a catalog and the set of
/// currently-active tool names.
///
/// Shared by [`plan_turn_tools`] (turn seed and `/preview-request`) and by the
/// per-step rebuild inside the turn loop, so activating a deferred tool
/// mid-turn goes through exactly one code path.
/// Shared by [`ToolSurfacePolicy`] and the per-step rebuild inside the turn
/// loop, so activating a deferred tool mid-turn goes through one code path.
pub(super) fn active_tools_for_request(
catalog: &[Tool],
active: &HashSet<String>,
@@ -418,32 +509,6 @@ pub(super) fn active_tools_for_request(
Some(tools)
}
/// Compute [`TurnToolPlan`] from a freshly built catalog.
///
/// `tools` is the catalog produced by `build_model_tool_catalog_with_surface`
/// plus the gate and permission-posture filters — i.e. exactly the value the
/// engine hands to `handle_deepseek_turn`.
pub(super) fn plan_turn_tools(
tools: Option<Vec<Tool>>,
mode: AppMode,
always_load: &HashSet<String>,
dynamic_active_tools: &[&'static str],
strict_tool_mode: bool,
) -> TurnToolPlan {
let mut catalog = tools.unwrap_or_default();
if !catalog.is_empty() {
ensure_advanced_tooling(&mut catalog, mode, always_load);
}
let mut active_names = initial_active_tools(&catalog);
active_names.extend(dynamic_active_tools.iter().map(|name| (*name).to_string()));
let active = active_tools_for_request(&catalog, &active_names, strict_tool_mode);
TurnToolPlan {
catalog,
active_names,
active,
}
}
fn tool_search_haystack(tool: &Tool) -> String {
format!(
"{}\n{}\n{}",
@@ -676,12 +741,14 @@ pub(super) fn default_synthetic_catalog_tool_names() -> Vec<String> {
names
}
#[cfg(test)]
fn is_synthetic_catalog_tool(name: &str) -> bool {
is_tool_search_tool(name)
|| matches!(name, CODE_EXECUTION_TOOL_NAME | JS_EXECUTION_TOOL_NAME)
|| McpPool::is_mcp_tool(name)
}
#[cfg(test)]
pub(super) fn tool_catalog_consistency_issues(
catalog: &[Tool],
registry: &crate::tools::ToolRegistry,
+35 -90
View File
@@ -363,14 +363,11 @@ impl Engine {
pub(super) async fn handle_deepseek_turn(
&mut self,
turn: &mut TurnContext,
tool_registry: Option<&crate::tools::ToolRegistry>,
tools: Option<Vec<Tool>>,
mode: AppMode,
dynamic_active_tools: Vec<&'static str>,
tool_policy: ToolSurfacePolicy,
// Out-of-request facts resolved once for this turn. `None` means the
// caller captured none, and the projection reports every
// registry-derived field as unknown rather than guessing.
tool_surface: Option<crate::tool_inspection::ToolSurfaceContext>,
inspection_surface: Option<crate::tool_inspection::ToolSurfaceContext>,
) -> (TurnOutcomeStatus, Option<String>) {
// Only interactive TUI hosts own terminal chrome. Headless exec,
// app-server, and stream-json stdout must remain byte-clean.
@@ -391,28 +388,12 @@ impl Engine {
let mut read_repeat_guard = ReadRepeatGuard::default();
let mut turn_error: Option<String> = None;
let mut context_recovery_attempts = 0u8;
// Seed the turn's tool state from the shared planner so
// `/preview-request` and dispatch cannot disagree about which tools
// the next request would carry.
let tool_plan = plan_turn_tools(
tools,
mode,
&self.config.tools_always_load,
&dynamic_active_tools,
self.config.strict_tool_mode,
);
let tool_catalog = tool_plan.catalog;
if let Some(registry) = tool_registry {
let issues = tool_catalog_consistency_issues(&tool_catalog, registry);
if !issues.is_empty() {
tracing::warn!(
target: "engine.tool_catalog",
?issues,
"model/search tool catalog is inconsistent with the runtime registry"
);
}
}
let mut active_tool_names = tool_plan.active_names;
let mut tool_policy = tool_policy;
let mode = tool_policy.mode;
let strict_tool_mode = tool_policy.strict_tool_mode;
let tool_catalog = std::mem::take(&mut tool_policy.catalog);
let mut active_tool_names = std::mem::take(&mut tool_policy.active_names);
let tool_registry = Some(&tool_policy.registry);
let mut goal_continuations_this_turn = 0u32;
// Outer stream-retry counter: when the chunked-transfer connection
// dies mid-stream and either nothing useful was streamed (#103
@@ -633,11 +614,8 @@ impl Engine {
// helper that seeded this turn and that `/preview-request`
// reports, so a deferred tool activated mid-turn is reflected
// identically in both places.
let active_tools = active_tools_for_request(
&tool_catalog,
&active_tool_names,
self.config.strict_tool_mode,
);
let active_tools =
active_tools_for_request(&tool_catalog, &active_tool_names, strict_tool_mode);
// Resolve `auto` reasoning_effort to a concrete tier (#663).
let effective_reasoning_effort = resolve_auto_effort(
@@ -757,7 +735,7 @@ impl Engine {
system: self.session.system_prompt.clone(),
tools: active_tools.clone(),
tool_choice: if active_tools.is_some() {
if self.config.strict_tool_mode {
if strict_tool_mode {
Some(json!("required"))
} else {
Some(json!({ "type": "auto" }))
@@ -777,7 +755,7 @@ impl Engine {
&turn.id,
turn.step,
request.tools.as_deref(),
tool_surface.as_ref(),
inspection_surface.as_ref(),
);
// Stream the response. Keep the request around (cloned into the
@@ -1830,17 +1808,13 @@ impl Engine {
// #3027: deny wins over allow — check the deny-list first so a
// tool present in both lists is still blocked.
if blocked_error.is_none()
&& command_denies_tool(self.config.disallowed_tools.as_deref(), &tool_name)
{
if blocked_error.is_none() && tool_policy.denies_tool(&tool_name) {
blocked_error = Some(ToolError::permission_denied(format!(
"Tool '{tool_name}' is in the disallowed-tools list"
)));
}
if blocked_error.is_none()
&& !command_allows_tool(self.config.allowed_tools.as_deref(), &tool_name)
{
if blocked_error.is_none() && !tool_policy.passes_allow_list(&tool_name) {
blocked_error = Some(ToolError::permission_denied(format!(
"Tool '{tool_name}' is not in the allowed-tools list for the current command"
)));
@@ -2666,30 +2640,27 @@ impl Engine {
if tool_name == REQUEST_USER_INPUT_NAME {
let started_at = Instant::now();
let result =
if crate::core::authority::permission_posture_allows_questions(
self.session.approval_mode,
) {
match UserInputRequest::from_value(&tool_input) {
Ok(request) => self
.await_user_input(&tool_id, request)
.await
.and_then(|response| {
ToolResult::json(&response).map_err(|e| {
ToolError::execution_failed(e.to_string())
})
}),
Err(err) => Err(err),
}
} else {
Ok(ToolResult::success(
let result = if tool_policy.allows_questions() {
match UserInputRequest::from_value(&tool_input) {
Ok(request) => self
.await_user_input(&tool_id, request)
.await
.and_then(|response| {
ToolResult::json(&response).map_err(|e| {
ToolError::execution_failed(e.to_string())
})
}),
Err(err) => Err(err),
}
} else {
Ok(ToolResult::success(
"Auto-Review does not pause for user questions. Decide from the available context and continue autonomously.",
)
.with_metadata(json!({
"auto_resolved": true,
"permission_posture": "auto-review",
})))
};
};
let _ = self
.tx_event
@@ -3675,23 +3646,9 @@ mod stream_timeout_tests {
}
}
pub(super) fn command_allows_tool(allowed_tools: Option<&[String]>, tool_name: &str) -> bool {
let Some(allowed_tools) = allowed_tools else {
return true;
};
// Symmetric with `command_denies_tool`: support a trailing `*` wildcard
// and lowercase both sides, so `allowed_tools = ["mcp_*"]` or `["ReadFile"]`
// work instead of silently matching nothing (which strips the whole
// catalog).
let tool_name = tool_name.to_ascii_lowercase();
allowed_tools.iter().any(|rule| {
let rule = rule.to_ascii_lowercase();
if let Some(prefix) = rule.strip_suffix('*') {
tool_name.starts_with(prefix)
} else {
tool_name == rule
}
})
#[cfg(test)]
fn command_allows_tool(allowed_tools: Option<&[String]>, tool_name: &str) -> bool {
tool_allowed(allowed_tools, tool_name)
}
/// Folded outcome of all `tool_call_before` hook results for one tool call
@@ -3876,21 +3833,9 @@ fn fold_tool_call_before_results(results: &[crate::hooks::HookResult]) -> ToolCa
fold
}
/// Check whether `tool_name` is explicitly denied (#3027).
/// Deny always wins over allow.
pub(super) fn command_denies_tool(disallowed_tools: Option<&[String]>, tool_name: &str) -> bool {
let Some(disallowed_tools) = disallowed_tools else {
return false;
};
let tool_name = tool_name.to_ascii_lowercase();
disallowed_tools.iter().any(|rule| {
let rule = rule.to_ascii_lowercase();
if let Some(prefix) = rule.strip_suffix('*') {
tool_name.starts_with(prefix)
} else {
tool_name == rule
}
})
#[cfg(test)]
fn command_denies_tool(disallowed_tools: Option<&[String]>, tool_name: &str) -> bool {
tool_denied(disallowed_tools, tool_name)
}
fn resolve_tool_definition<'a>(
+31 -139
View File
@@ -4,14 +4,14 @@
//! Prompts are assembled from composable layers loaded at compile time from
//! the single [`text`] module:
//! constitution + personality overlay → `message[0]` (byte-stable).
//! mode delta + tool taxonomy + approval policy → request-time runtime metadata.
//! mode delta + approval policy → request-time runtime metadata.
//! Tool availability comes only from the per-turn model catalog.
//!
//! Keeping every layer's text in one module makes prompt tuning a
//! single-file operation.
use crate::models::{SystemBlock, SystemPrompt};
use crate::project_context::{ProjectContext, load_project_context_with_parents};
use crate::tui::app::AppMode;
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex};
@@ -428,8 +428,8 @@ static PROMPT_OVERRIDE_NOTICES: LazyLock<Mutex<Vec<String>>> =
/// Context passed to an embedder-provided static prompt composer.
///
/// This hook only replaces the byte-stable base/personality prompt segment.
/// Mode deltas, approval policy, tool taxonomy, Core Execution, and the
/// Compaction Relay stay owned by Codewhale's system prompt assembly.
/// Mode deltas, approval policy, Core Execution, and the Compaction Relay stay
/// owned by Codewhale's system prompt assembly.
#[non_exhaustive]
#[derive(Debug)]
pub struct StaticPromptCtx<'a> {
@@ -561,8 +561,8 @@ pub fn set_static_prompt_composer_override(
// custom embedder build.
//
// Scope is deliberately narrow: only the byte-stable base prompt segment is
// user-overridable. Mode deltas, approval policy, tool taxonomy, Core
// Execution, and the Compaction Relay stay owned by the runtime assembly (see
// user-overridable. Mode deltas, approval policy, Core Execution, and the
// Compaction Relay stay owned by the runtime assembly (see
// `StaticPromptCtx`), so an override cannot strip safety-relevant guidance.
// A missing or empty file is a no-op — the bundled constant is used — so this
// is fully backward compatible.
@@ -1036,15 +1036,6 @@ impl Personality {
// ── Composition ───────────────────────────────────────────────────────
/// Compose the full system prompt in deterministic order:
/// 1. tool taxonomy — compact hints generated from the eager core tools
/// 2. constitution.md — core identity, toolbox, execution contract
/// 3. personality — voice and tone overlay
/// 4. mode delta — mode-specific permissions and workflow
/// 5. approval policy — tool-approval behavior
///
/// Each layer is separated by a blank line for readability in the
/// rendered prompt (the model sees them as contiguous sections).
/// Substitute the model id for embedder-supplied prompt overrides that still
/// template it. The bundled constitution is deliberately model-agnostic and
/// carries no model-fact placeholders.
@@ -1056,60 +1047,6 @@ fn apply_model_template(
prompt.replace("{model_id}", model_id)
}
const TOOL_TAXONOMY_DISCOVERY: &[&str] = &["File"];
const TOOL_TAXONOMY_GIT: &[&str] = &["Git"];
const TOOL_TAXONOMY_VERIFICATION: &[&str] = &["Run"];
/// Return the core tool taxonomy body **without** a markdown heading.
/// Suitable for embedding under a mode-specific sub-heading in the
/// Runtime Policy Reference without producing a broken heading hierarchy.
pub(crate) fn render_core_tool_taxonomy_body(mode: AppMode) -> String {
let core_tools = core_taxonomy_tools_for_mode(mode);
let mut sentences = Vec::new();
if let Some(discovery) = render_core_tool_group(TOOL_TAXONOMY_DISCOVERY, &core_tools) {
sentences.push(format!("Use {discovery} for discovery."));
}
if let Some(git) = render_core_tool_group(TOOL_TAXONOMY_GIT, &core_tools) {
sentences.push(format!("Use {git} for git inspection."));
}
if let Some(verification) = render_core_tool_group(TOOL_TAXONOMY_VERIFICATION, &core_tools) {
sentences.push(format!("Use {verification} for verification."));
}
if core_tools.contains(&"Run") {
sentences.push(
"For long build/test/lint suites, call `Run` with `action: \"verifiers\"` and `background: true`, then continue independent inspection."
.to_string(),
);
}
debug_assert!(
!sentences.is_empty(),
"core tool taxonomy has no active tool groups"
);
sentences.join(" ")
}
fn core_taxonomy_tools_for_mode(mode: AppMode) -> Vec<&'static str> {
let core_tools = crate::core::engine::default_active_native_tool_names();
core_tools
.iter()
.copied()
.filter(|tool| mode != AppMode::Plan || *tool != "Run")
.collect()
}
fn render_core_tool_group(group: &[&str], core_tools: &[&str]) -> Option<String> {
let rendered = group
.iter()
.copied()
.filter(|tool| core_tools.contains(tool))
.map(|tool| format!("`{tool}`"))
.collect::<Vec<_>>()
.join("/");
(!rendered.is_empty()).then_some(rendered)
}
/// Authority recap block — appended at the end of the system prompt,
/// just before the user's first message. Uses recency bias constructively
/// without restating ranks: precedence is stated only in `BASE_PROMPT`
@@ -1749,13 +1686,10 @@ mod tests {
for phrase in [
"Execute the user's task autonomously",
"Keep `work_update` current",
"when it is present",
"If it is absent",
"verify load-bearing child",
"never manufacture completion sentinels",
// Live progress upkeep (2026-07-23 user report: models wrote the
// list once and never updated it while working).
"exactly one item in_progress before you
start it",
"never batch completions",
] {
assert!(
AGENT_MODE.contains(phrase),
@@ -2002,53 +1936,14 @@ start it",
}
#[test]
fn composed_prompt_no_longer_inlines_tool_taxonomy() {
fn composed_prompt_does_not_claim_tool_availability() {
let prompt =
compose_prompt_with_approval_model_and_shell(Personality::Calm, "deepseek-v4-pro");
// The core tool taxonomy (grep_files / git_status / run_tests hints)
// is no longer prepended as a standalone "## Core Tool Taxonomy" block.
// It now lives inside the "## Runtime Policy Reference" section of the
// system prompt, scoped under each mode sub-heading.
assert!(!prompt.contains("## Core Tool Taxonomy"));
assert!(!prompt.contains("## Toolbox"));
assert!(prompt.contains("You are Codewhale"));
}
#[test]
fn plan_prompt_taxonomy_omits_run_tests() {
let taxonomy = render_core_tool_taxonomy_body(AppMode::Plan);
// Plan taxonomy should omit execution tools (verified at the source).
assert!(
taxonomy.contains("for discovery") && taxonomy.contains("for git inspection"),
"Plan taxonomy should keep read-only discovery and git guidance"
);
assert!(
!taxonomy.contains("run_tests")
&& !taxonomy.contains("run_verifiers")
&& !taxonomy.contains("exec_shell"),
"Plan taxonomy must not mention run_tests, run_verifiers, or exec_shell"
);
// The taxonomy block is rendered correctly but no longer inlined
// into the base system prompt — it lives inside the
// "## Runtime Policy Reference" section of the system prompt,
// scoped under each mode sub-heading.
}
#[test]
fn core_tool_taxonomy_only_references_default_active_tools() {
let core_tools = crate::core::engine::default_active_native_tool_names();
for tool in TOOL_TAXONOMY_DISCOVERY
.iter()
.chain(TOOL_TAXONOMY_GIT)
.chain(TOOL_TAXONOMY_VERIFICATION)
{
assert!(
core_tools.contains(tool),
"tool taxonomy references {tool}, but it is not in the eager native-tool list"
);
}
}
#[test]
fn authority_recap_appears_in_full_prompt() {
let tmp = tempdir().expect("tempdir");
@@ -2174,8 +2069,10 @@ start it",
#[test]
fn plan_mode_prompt_uses_one_progress_surface() {
assert!(
PLAN_MODE.contains("canonical list in `work_update`"),
"Plan mode must keep progress in the canonical list"
PLAN_MODE.contains("When `work_update` is present")
&& PLAN_MODE.contains("canonical list there")
&& PLAN_MODE.contains("otherwise keep progress in your response"),
"Plan mode must condition progress guidance on the live catalog"
);
assert!(!PLAN_MODE.contains("call `update_plan`"));
assert!(
@@ -2473,13 +2370,6 @@ start it",
!contains_cjk(BASE_PROMPT),
"base prompt must not contain static CJK priming tokens"
);
for mode in [AppMode::Agent, AppMode::Plan, AppMode::Yolo] {
let taxonomy = render_core_tool_taxonomy_body(mode);
assert!(
!contains_cjk(&taxonomy),
"tool taxonomy must not contain static CJK priming tokens: {taxonomy:?}"
);
}
// Do not assert on arbitrary CJK in the full system prompt: project
// context may legitimately contain localized file names, README text,
// or user-authored instructions. The locale bookend markers above are
@@ -3069,12 +2959,9 @@ start it",
let prompt = AGENT_MODE.replace("\r\n", "\n").replace('\r', "\n");
for must in [
"autonomously",
"`File`",
"`Git`",
"`Run`",
"`Bash`",
"tools in the current catalog",
"work_update",
"Delegate independent work",
"current catalog includes delegation",
"Do not announce the mode",
] {
assert!(
@@ -3082,6 +2969,9 @@ start it",
"compressed agent mode missing invariant {must:?}"
);
}
for unavailable_claim in ["`File`", "`Git`", "`Run`", "`Bash`"] {
assert!(!prompt.contains(unavailable_claim));
}
// Procedural PowerShell manuals must not live in the mode delta.
for forbidden in ["Invoke-Expression", "pwsh.exe -NoLogo", "ProcessStartInfo"] {
assert!(
@@ -3273,8 +3163,9 @@ start it",
let prompt = compose_prompt(Personality::Calm);
assert!(!prompt.contains("Tool Selection Guide"));
for tool in ["`File`", "`Git`", "`Run`", "`Bash`"] {
assert!(AGENT_MODE.contains(tool));
assert!(!AGENT_MODE.contains(tool));
}
assert!(AGENT_MODE.contains("tools in the current catalog"));
for legacy in ["read_file", "git_status", "run_tests", "exec_shell"] {
assert!(!AGENT_MODE.contains(legacy));
}
@@ -3491,7 +3382,7 @@ start it",
#[test]
fn prompt_bounds_explore_without_tiny_cap_for_implementers() {
assert!(AGENT_MODE.contains("Delegate independent work"));
assert!(AGENT_MODE.contains("current catalog includes delegation"));
assert!(!AGENT_MODE.contains("3-5 tool calls"));
assert!(!AGENT_MODE.contains("No fan-out without a fan-in owner"));
}
@@ -3512,17 +3403,18 @@ start it",
fn operate_mode_prompt_keeps_multitask_simple_and_async() {
for phrase in [
"ordinary messages",
"small or tightly coupled tasks directly",
"Dispatching background workers is the default",
"queued user message as a new task",
"approval, sandbox, and repository policies",
"lifecycle claims stay exact",
"coordination capabilities present in the current catalog",
"When worker dispatch is available",
"When background execution is available",
"queued user messages as new tasks",
"Preserve approval",
"settled work from verified work",
"internal control-plane mechanics",
"Goal first",
"When goal control is available",
"Dispatch is not completion",
"verification evidence",
"best-of-n",
"parent stays free",
"verification capabilities",
"When an ordered Workflow capability is present",
"keep the parent responsive",
] {
assert!(
OPERATE_MODE.contains(phrase),
+49 -58
View File
@@ -249,78 +249,69 @@ Your voice is warm, energetic, and playful. You're still precise — you just ha
pub const AGENT_MODE: &str = r#"##### Mode: Agent
Execute the user's task autonomously. Read-only actions run directly; mutations
follow the active approval policy. Use `File`, `Git`, `Run`, and `Bash` for their
documented actions. Keep `work_update` current only for genuinely multi-step
work. It is the one user-facing progress list; do not create a parallel
strategy checklist. Keep it live: exactly one item in_progress before you
start it, completed the moment it finishes never batch completions.
follow the active approval policy. Use only the tools in the current catalog,
following their documented actions. Keep `work_update` current only for
genuinely multi-step work when it is present. If it is absent, keep progress in
your response instead of inventing a call. Never create a parallel strategy
checklist.
Delegate independent work when it improves throughput. Treat runtime and
sub-agent completion events as internal evidence, verify load-bearing child
claims, and never manufacture completion sentinels. Do not wait by polling when
the runtime can notify or join work directly.
When the current catalog includes delegation, use it for independent work when
that improves throughput. Treat any runtime and sub-agent completion events as internal evidence,
verify load-bearing child claims, and never manufacture completion sentinels.
Do not poll when an available runtime tool can notify or join work directly.
Do not announce the mode or its approval mechanics.
"#;
/// Plan mode delta.
pub const PLAN_MODE: &str = r#"##### Mode: Plan
Investigate with read-only tools, keep the canonical list in `work_update`,
then present the grounded implementation contract in your response. There is
no second Strategy/Plan progress surface. All writes, patches, shell commands,
and code execution are blocked. Read-only
sub-agents are allowed. After presenting the plan, ask the user to reply with
revisions or switch to Act (`/mode act`) to implement, then wait. Do not
announce the mode.
Investigate with read-only tools. When `work_update` is present, keep the
canonical list there; otherwise keep progress in your response. There is no
second Strategy/Plan progress surface. All writes, patches, shell commands, and
code execution are blocked. When the current catalog includes read-only
delegation, it may support parallel investigation. After presenting the plan,
ask the user to reply with revisions or switch to Act (`/mode act`) to
implement, then wait. Do not announce the mode.
"#;
/// Full-access mode delta.
pub const YOLO_MODE: &str = r#"##### Mode: YOLO
All actions are auto-approved within the user's scope. Verify destructive
targets and preserve unrelated work. Use `work_update` only for genuinely
multi-step work. Do not announce the mode.
targets and preserve unrelated work. When `work_update` is present, use it only
for genuinely multi-step work. Do not announce the mode.
"#;
/// Operate mode delta.
///
/// Hard doctrine (not soft preferences): the parent session is the conductor.
/// Dispatching background workers is the default way real work happens;
/// verification is part of completion, not optional polish.
/// Hard doctrine (not soft preferences): the parent session is the conductor,
/// and verification is part of completion rather than optional polish.
pub const OPERATE_MODE: &str = r#"##### Mode: Operate
You are the operator of this session, not a single-file implementer. The parent
turn stays free for ordinary messages, steers, and synthesis. Dispatching background workers is the default way Operate does real work the user does
not need a special command to multitask.
turn stays free for ordinary messages, steers, and synthesis. Use only the
coordination capabilities present in the current catalog; an absent capability
is unavailable, not permission to invent a call.
Operate doctrine (must):
1. Goal first when work spans more than one turn or more than one independent
stream: `create_goal` (or honor the active `/goal`) before long implement
loops in the parent.
2. Dispatch workers early for independent, parallel, long-running, or
isolation-needing work. Handle small or tightly coupled tasks directly in
the parent; do not monopolize the parent turn for large multi-file patches
when a background implementer (with worktree when writes can collide) would
keep the session responsive.
3. Start workers in the background and return. Do not busy-wait unless the
user needs one combined answer right now. Prefer `agent` starts that return
an agent_id immediately; coordinate with status/wait only when fan-in is
required.
4. Treat each queued user message as a new task unless it clearly steers
existing work. When safe (independent ask, not a cancel/steer of an
in-flight child), promote it into its own background worker so the parent stays free dispatch is the default multitask path, not an opt-in verb.
5. Dispatch is not completion. After any write-capable child settles, require
verification evidence (verifier child, `run_verifiers`, or structured
self-check with real commands and PASS/FAIL). Receipts must distinguish
settled work from verified work; lifecycle claims stay exact.
6. Prefer Workflow when order, phases, gates, shared budgets, or deterministic
fan-in matter (starter recipes: staged-fix, parallel-scout / read-audit,
best-of-n). Prefer direct `agent` workers for independent fire-and-forget
streams. Do not soft-auto every chat message into a Workflow.
7. Best-of-N for high-stakes or ambiguous approaches: N worktree implementers
(or plan agents), then a reviewer/verifier; apply the winner only after
PASS evidence. Use the `best-of-n` skill when that pattern fits.
8. Parent synthesizes receipts and answers the user; children do not address
the end user. Preserve the active approval, sandbox, and repository policies Operate changes scheduling emphasis, not authority.
9. Do not announce Operate mode or expose internal control-plane mechanics
1. When goal control is available and work spans turns or independent streams,
establish or honor the goal before a long implementation loop.
2. When worker dispatch is available, use it early for independent, parallel,
long-running, or isolation-needing work. Handle small, tightly coupled work
directly and keep the parent responsive.
3. When background execution is available, return control instead of
busy-waiting unless the user needs one combined answer immediately.
4. Treat queued user messages as new tasks unless they clearly steer existing
work. Dispatch an independent message only when a present capability and the
active authority permit it.
5. Dispatch is not completion. Verify load-bearing child work with available
verification capabilities or a direct evidence-based check, and distinguish
settled work from verified work.
6. When an ordered Workflow capability is present, prefer it for phases,
gates, shared budgets, or deterministic fan-in. When direct worker dispatch
is present, prefer it for independent fire-and-forget streams.
7. Parent synthesizes receipts and answers the user. Preserve approval,
sandbox, and repository policies; Operate changes scheduling emphasis, not
authority.
8. Do not announce Operate mode or expose internal control-plane mechanics
unless asked.
"#;
@@ -332,7 +323,7 @@ All tool calls are pre-approved. You will not see approval prompts — your acti
This means you carry more responsibility:
- Pause before destructive operations (deletes, force-pushes, `rm -rf`).
- Use `work_update` for multi-step work so progress stays visible even though no one is watching.
- When `work_update` is present, use it for multi-step work so progress stays visible.
- If you're uncertain about a course of action, state your reasoning before proceeding.
- The user can interrupt you at any time.
@@ -344,7 +335,7 @@ pub const SUGGEST_APPROVAL: &str = r#"##### Approval Policy: Suggest
Read-only operations run silently. Write operations (file edits, patches, shell execution, sub-agent spawns, CSV batches) require user approval before executing.
When you need approval:
1. For multi-step changes, lay out your approach with `work_update`.
1. For multi-step changes, use `work_update` when it is present; otherwise state the approach briefly.
2. The user will see your proposed action and can approve or deny it.
Decomposition is your best tool for earning approvals. A clear plan with verifiable steps gets approved faster than an opaque request.
@@ -356,10 +347,10 @@ pub const NEVER_APPROVAL: &str = r#"##### Approval Policy: Never
All write operations are blocked. You can read, search, and investigate, but you cannot modify the workspace.
This is a read-only mode. Use it to:
- Build thorough plans with the one canonical `work_update` list.
- Investigate codebases, trace logic, and gather context.
- Spawn read-only sub-agents for parallel exploration.
This is a read-only mode. Build thorough plans, investigate codebases, trace
logic, and gather context. When `work_update` is present, use it as the one
canonical list. When read-only delegation is present, it may support parallel
exploration.
If the user asks you to edit files, run shell commands, apply patches, or otherwise change the workspace while this policy is active, do not draft a large implementation first. Stop early, say that the current approval policy blocks writes, and give the exact escape hatch: run `/config approval_mode suggest` for prompted writes, or select Full Access only in a trusted workspace.