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:
+63
-103
@@ -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,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"
|
||||
|
||||
@@ -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(®istry),
|
||||
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(®istry),
|
||||
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(®istry),
|
||||
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(®istry),
|
||||
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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user