diff --git a/.yoi/profiles/_base.lua b/.yoi/profiles/_base.lua index 1777d041..ca5d3acd 100644 --- a/.yoi/profiles/_base.lua +++ b/.yoi/profiles/_base.lua @@ -29,6 +29,15 @@ return function(opts) worker_context_max_tokens = 100000, }, + feature = opts.feature or { + task = { enabled = true }, + memory = { enabled = true }, + web = { enabled = true }, + pod_management = { enabled = false }, + ticket = { enabled = false, access = "lifecycle" }, + ticket_orchestration = { enabled = false }, + }, + memory = { extract_threshold = 50000, consolidation_threshold_files = 5, diff --git a/.yoi/profiles/coder.lua b/.yoi/profiles/coder.lua index 67be8f8f..c2a798be 100644 --- a/.yoi/profiles/coder.lua +++ b/.yoi/profiles/coder.lua @@ -5,6 +5,14 @@ return base { slug = "coder", description = "Coder role profile: GPT-5.5 with bundled default behavior", model_ref = "codex-oauth/gpt-5.5", + feature = { + task = { enabled = true }, + memory = { enabled = true }, + web = { enabled = true }, + pod_management = { enabled = false }, + ticket = { enabled = false, access = "lifecycle" }, + ticket_orchestration = { enabled = false }, + }, language = "Japanese", scope = scope.workspace_write(), } diff --git a/.yoi/profiles/companion.lua b/.yoi/profiles/companion.lua index 5fddc075..77da67d3 100644 --- a/.yoi/profiles/companion.lua +++ b/.yoi/profiles/companion.lua @@ -4,5 +4,13 @@ return base { slug = "companion", description = "Companion role profile: GPT-5.5 with bundled default behavior", model_ref = "codex-oauth/gpt-5.5", + feature = { + task = { enabled = true }, + memory = { enabled = true }, + web = { enabled = true }, + pod_management = { enabled = false }, + ticket = { enabled = false, access = "lifecycle" }, + ticket_orchestration = { enabled = false }, + }, language = "Japanese", } diff --git a/.yoi/profiles/intake.lua b/.yoi/profiles/intake.lua index 5561c5ad..3be88e39 100644 --- a/.yoi/profiles/intake.lua +++ b/.yoi/profiles/intake.lua @@ -4,5 +4,13 @@ return base { slug = "intake", description = "Intake role profile: GPT-5.5 with bundled default behavior", model_ref = "codex-oauth/gpt-5.5", + feature = { + task = { enabled = true }, + memory = { enabled = true }, + web = { enabled = true }, + pod_management = { enabled = false }, + ticket = { enabled = true, access = "lifecycle" }, + ticket_orchestration = { enabled = false }, + }, language = "Japanese", } diff --git a/.yoi/profiles/orchestrator.lua b/.yoi/profiles/orchestrator.lua index 87c6ad15..4fb03314 100644 --- a/.yoi/profiles/orchestrator.lua +++ b/.yoi/profiles/orchestrator.lua @@ -5,6 +5,14 @@ return base { slug = "orchestrator", description = "Orchestrator role profile: GPT-5.5 with bundled default behavior", delegation_scope = scope.workspace_write(), + feature = { + task = { enabled = true }, + memory = { enabled = true }, + web = { enabled = true }, + pod_management = { enabled = true }, + ticket = { enabled = true, access = "lifecycle" }, + ticket_orchestration = { enabled = true }, + }, model_ref = "codex-oauth/gpt-5.5", language = "Japanese", } diff --git a/.yoi/profiles/reviewer.lua b/.yoi/profiles/reviewer.lua index 87732a99..8b22a3f3 100644 --- a/.yoi/profiles/reviewer.lua +++ b/.yoi/profiles/reviewer.lua @@ -4,5 +4,13 @@ return base { slug = "reviewer", description = "Reviewer role profile: GPT-5.5 with bundled default behavior", model_ref = "codex-oauth/gpt-5.5", + feature = { + task = { enabled = true }, + memory = { enabled = true }, + web = { enabled = true }, + pod_management = { enabled = false }, + ticket = { enabled = false, access = "lifecycle" }, + ticket_orchestration = { enabled = false }, + }, language = "Japanese", } diff --git a/crates/manifest/src/config.rs b/crates/manifest/src/config.rs index c6232c6a..fa6e1172 100644 --- a/crates/manifest/src/config.rs +++ b/crates/manifest/src/config.rs @@ -16,9 +16,10 @@ use serde::{Deserialize, Serialize}; use crate::defaults; use crate::model::{AuthRef, ModelManifest, ReasoningControl}; use crate::{ - CompactionConfig, FileUploadLimits, MemoryConfig, PodManifest, PodMeta, ScopeConfig, - SessionConfig, SkillsConfig, ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, - WebConfig, WorkerManifest, + CompactionConfig, FeatureConfig, FeatureFlagConfig, FileUploadLimits, MemoryConfig, + PodManifest, PodMeta, ScopeConfig, SessionConfig, SkillsConfig, TicketFeatureAccessConfig, + TicketFeatureConfig, ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, WebConfig, + WorkerManifest, }; /// Partial-form Pod manifest. Every field is optional; one or more @@ -47,6 +48,10 @@ pub struct PodManifestConfig { /// is disabled; `Some` requires `default_action` during final resolve. #[serde(default)] pub permissions: Option, + /// Explicit built-in feature/tool-surface enablement. Absent flags resolve + /// disabled after cascade merge. + #[serde(default)] + pub feature: FeatureConfigPartial, #[serde(default)] pub compaction: Option, /// First-class web tool opt-in. See [`WebConfig`]. @@ -60,6 +65,146 @@ pub struct PodManifestConfig { pub skills: Option, } +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct FeatureConfigPartial { + #[serde(default)] + pub task: Option, + #[serde(default)] + pub memory: Option, + #[serde(default)] + pub web: Option, + #[serde(default)] + pub pod_management: Option, + #[serde(default)] + pub ticket: Option, + #[serde(default)] + pub ticket_orchestration: Option, +} + +impl FeatureConfigPartial { + fn merge(self, other: Self) -> Self { + Self { + task: merge_option(self.task, other.task, FeatureFlagConfigPartial::merge), + memory: merge_option(self.memory, other.memory, FeatureFlagConfigPartial::merge), + web: merge_option(self.web, other.web, FeatureFlagConfigPartial::merge), + pod_management: merge_option( + self.pod_management, + other.pod_management, + FeatureFlagConfigPartial::merge, + ), + ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge), + ticket_orchestration: merge_option( + self.ticket_orchestration, + other.ticket_orchestration, + FeatureFlagConfigPartial::merge, + ), + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct FeatureFlagConfigPartial { + #[serde(default)] + pub enabled: Option, +} + +impl FeatureFlagConfigPartial { + fn merge(self, other: Self) -> Self { + Self { + enabled: other.enabled.or(self.enabled), + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TicketFeatureConfigPartial { + #[serde(default)] + pub enabled: Option, + #[serde(default)] + pub access: Option, +} + +impl TicketFeatureConfigPartial { + fn merge(self, other: Self) -> Self { + Self { + enabled: other.enabled.or(self.enabled), + access: other.access.or(self.access), + } + } +} + +impl From for FeatureConfig { + fn from(value: FeatureConfigPartial) -> Self { + Self { + task: value.task.map(FeatureFlagConfig::from).unwrap_or_default(), + memory: value + .memory + .map(FeatureFlagConfig::from) + .unwrap_or_default(), + web: value.web.map(FeatureFlagConfig::from).unwrap_or_default(), + pod_management: value + .pod_management + .map(FeatureFlagConfig::from) + .unwrap_or_default(), + ticket: value + .ticket + .map(TicketFeatureConfig::from) + .unwrap_or_default(), + ticket_orchestration: value + .ticket_orchestration + .map(FeatureFlagConfig::from) + .unwrap_or_default(), + } + } +} + +impl From for FeatureFlagConfig { + fn from(value: FeatureFlagConfigPartial) -> Self { + Self { + enabled: value.enabled.unwrap_or_default(), + } + } +} + +impl From for FeatureFlagConfigPartial { + fn from(value: FeatureFlagConfig) -> Self { + Self { + enabled: Some(value.enabled), + } + } +} + +impl From for TicketFeatureConfig { + fn from(value: TicketFeatureConfigPartial) -> Self { + Self { + enabled: value.enabled.unwrap_or_default(), + access: value.access.unwrap_or_default(), + } + } +} + +impl From for TicketFeatureConfigPartial { + fn from(value: TicketFeatureConfig) -> Self { + Self { + enabled: Some(value.enabled), + access: Some(value.access), + } + } +} + +impl From for FeatureConfigPartial { + fn from(value: FeatureConfig) -> Self { + Self { + task: Some(value.task.into()), + memory: Some(value.memory.into()), + web: Some(value.web.into()), + pod_management: Some(value.pod_management.into()), + ticket: Some(value.ticket.into()), + ticket_orchestration: Some(value.ticket_orchestration.into()), + } + } +} + #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct PodMetaConfig { #[serde(default)] @@ -305,6 +450,7 @@ impl PodManifestConfig { upper.permissions, PermissionConfigPartial::merge, ), + feature: self.feature.merge(upper.feature), compaction: merge_option( self.compaction, upper.compaction, @@ -690,6 +836,7 @@ impl TryFrom for PodManifest { delegation_scope: cfg.delegation_scope, session, permissions, + feature: FeatureConfig::from(cfg.feature), compaction, web: cfg.web, memory: cfg.memory, @@ -735,6 +882,7 @@ mod tests { }, delegation_scope: ScopeConfig::default(), permissions: None, + feature: FeatureConfigPartial::default(), session: None, compaction: None, web: None, @@ -1280,6 +1428,125 @@ worker_max_turns = 7 ); } + #[test] + fn feature_flags_default_disabled_in_resolved_manifest() { + let manifest: PodManifest = minimal_valid().try_into().unwrap(); + assert!(!manifest.feature.task.enabled); + assert!(!manifest.feature.memory.enabled); + assert!(!manifest.feature.web.enabled); + assert!(!manifest.feature.pod_management.enabled); + assert!(!manifest.feature.ticket.enabled); + assert!(!manifest.feature.ticket_orchestration.enabled); + } + + #[test] + fn from_toml_parses_explicit_feature_flags() { + let cfg = PodManifestConfig::from_toml( + r#" +[feature.task] +enabled = true + +[feature.ticket] +enabled = true +access = "read_only" + +[feature.ticket_orchestration] +enabled = true +"#, + ) + .unwrap(); + let manifest: PodManifest = PodManifestConfig::builtin_defaults() + .merge(cfg) + .merge(PodManifestConfig { + pod: PodMetaConfig { + name: Some("feature-test".into()), + prompt_pack: None, + }, + model: ModelManifest { + scheme: Some(SchemeKind::Anthropic), + model_id: Some("m".into()), + ..Default::default() + }, + scope: ScopeConfig { + allow: vec![ScopeRule { + target: abs("/pod"), + permission: Permission::Read, + recursive: true, + }], + deny: Vec::new(), + }, + ..Default::default() + }) + .try_into() + .unwrap(); + assert!(manifest.feature.task.enabled); + assert!(manifest.feature.ticket.enabled); + assert_eq!( + manifest.feature.ticket.access, + TicketFeatureAccessConfig::ReadOnly + ); + assert!(manifest.feature.ticket_orchestration.enabled); + assert!(!manifest.feature.memory.enabled); + } + + #[test] + fn feature_flags_merge_as_partial_profile_layers() { + let base = PodManifestConfig::from_toml( + r#" +[feature.memory] +enabled = true + +[feature.ticket] +enabled = true +access = "read_only" +"#, + ) + .unwrap(); + let upper = PodManifestConfig::from_toml( + r#" +[feature.ticket] +access = "lifecycle" + +[feature.web] +enabled = true +"#, + ) + .unwrap(); + let manifest: PodManifest = PodManifestConfig::builtin_defaults() + .merge(base) + .merge(upper) + .merge(PodManifestConfig { + pod: PodMetaConfig { + name: Some("feature-merge-test".into()), + prompt_pack: None, + }, + model: ModelManifest { + scheme: Some(SchemeKind::Anthropic), + model_id: Some("m".into()), + ..Default::default() + }, + scope: ScopeConfig { + allow: vec![ScopeRule { + target: abs("/pod"), + permission: Permission::Read, + recursive: true, + }], + deny: Vec::new(), + }, + ..Default::default() + }) + .try_into() + .unwrap(); + assert!(manifest.feature.memory.enabled); + assert!(manifest.feature.ticket.enabled); + assert_eq!( + manifest.feature.ticket.access, + TicketFeatureAccessConfig::Lifecycle + ); + assert!(manifest.feature.web.enabled); + assert!(!manifest.feature.pod_management.enabled); + } + #[test] fn from_toml_partial_layer_succeeds() { // A project-layer manifest with only scope set must parse fine. diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 6c6ea654..43bf6380 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -53,18 +53,20 @@ pub struct PodManifest { /// permission layer is disabled and tool calls run as before. #[serde(default)] pub permissions: Option, + /// Explicit built-in feature/tool-surface enablement. Omitted feature flags + /// resolve disabled so Profile authors choose the exposed built-in surfaces. + #[serde(default)] + pub feature: FeatureConfig, #[serde(default)] pub compaction: Option, - /// Memory subsystem opt-in. Presence of `[memory]` in TOML enables - /// the memory tools (MemoryRead / MemoryWrite / MemoryEdit) and - /// causes Pod to deny generic write access to `/memory/` - /// and `/knowledge/`. Absent ⇒ legacy behaviour, no - /// memory tools registered. + /// Memory subsystem configuration. Presence of `[memory]` configures memory + /// storage, extraction, consolidation, and resident injection, but memory + /// tools are surfaced only when `[feature.memory].enabled = true`. #[serde(default)] pub memory: Option, - /// First-class web tools configuration. Absent or `enabled = false` keeps - /// WebSearch/WebFetch registered but disabled, so no network access occurs - /// unless a manifest explicitly opts in. + /// First-class web tools configuration. Network access remains fail-closed + /// under this config; WebSearch/WebFetch schemas are surfaced only when + /// `[feature.web].enabled = true`. #[serde(default)] pub web: Option, /// External Agent Skills (`SKILL.md`) directories to ingest as @@ -82,6 +84,94 @@ pub struct PodManifest { pub profile: Option, } +/// Explicit built-in feature/tool-surface enablement. These flags are +/// profile/config data only: they do not carry runtime Pod names, sockets, +/// sessions, secrets, or resolved host state. Tool registration still applies +/// the normal scope, host-authority, backend, memory, and network checks. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FeatureConfig { + #[serde(default)] + pub task: FeatureFlagConfig, + #[serde(default)] + pub memory: FeatureFlagConfig, + #[serde(default)] + pub web: FeatureFlagConfig, + #[serde(default)] + pub pod_management: FeatureFlagConfig, + #[serde(default)] + pub ticket: TicketFeatureConfig, + #[serde(default)] + pub ticket_orchestration: FeatureFlagConfig, +} + +impl Default for FeatureConfig { + fn default() -> Self { + Self { + task: FeatureFlagConfig::disabled(), + memory: FeatureFlagConfig::disabled(), + web: FeatureFlagConfig::disabled(), + pod_management: FeatureFlagConfig::disabled(), + ticket: TicketFeatureConfig::default(), + ticket_orchestration: FeatureFlagConfig::disabled(), + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub struct FeatureFlagConfig { + #[serde(default)] + pub enabled: bool, +} + +impl FeatureFlagConfig { + pub const fn disabled() -> Self { + Self { enabled: false } + } + + pub const fn enabled() -> Self { + Self { enabled: true } + } +} + +impl Default for FeatureFlagConfig { + fn default() -> Self { + Self::disabled() + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub struct TicketFeatureConfig { + #[serde(default)] + pub enabled: bool, + /// Which non-orchestration Ticket surface to expose when `enabled = true`. + /// Orchestration-plan/relation tools are controlled independently by + /// `[feature.ticket_orchestration].enabled`. + #[serde(default)] + pub access: TicketFeatureAccessConfig, +} + +impl Default for TicketFeatureConfig { + fn default() -> Self { + Self { + enabled: false, + access: TicketFeatureAccessConfig::Lifecycle, + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum TicketFeatureAccessConfig { + ReadOnly, + Lifecycle, +} + +impl Default for TicketFeatureAccessConfig { + fn default() -> Self { + Self::Lifecycle + } +} + /// External Agent Skills (`SKILL.md`) ingest configuration. Skills are /// loaded *only* from the directories listed here — there is no /// implicit `$config_dir/skills/` or builtin probe. Profile and Manifest diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index 32b9600f..857df5d3 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -13,7 +13,9 @@ use std::rc::Rc; use mlua::{Lua, LuaOptions, LuaSerdeExt, RegistryKey, StdLib, Table, Value as LuaValue}; use serde::{Deserialize, Serialize}; -use crate::config::{CompactionConfigPartial, PermissionConfigPartial, SessionConfigPartial}; +use crate::config::{ + CompactionConfigPartial, FeatureConfigPartial, PermissionConfigPartial, SessionConfigPartial, +}; use crate::model::{AuthRef, ModelManifest}; use crate::{ MemoryConfig, Permission, PodManifest, PodManifestConfig, PodMetaConfig, ResolveError, @@ -571,6 +573,7 @@ fn resolve_lua_profile_value( ), session: profile.session, permissions: profile.permissions, + feature: profile.feature, compaction, web: profile.web, memory: profile.memory, @@ -630,6 +633,8 @@ struct ProfileConfig { #[serde(default)] permissions: Option, #[serde(default)] + feature: FeatureConfigPartial, + #[serde(default)] compaction: Option, #[serde(default)] web: Option, @@ -1457,6 +1462,57 @@ return profile { Some("coder") ); } + #[test] + fn resolves_lua_profile_feature_flags_without_runtime_state() { + let tmp = TempDir::new().unwrap(); + let profile = write_profile( + tmp.path(), + "feature.lua", + r#" +local profile = require("yoi.profile") +local scope = require("yoi.scope") +return profile { + slug = "feature", + model = { scheme = "anthropic", model_id = "claude-sonnet-4-20250514" }, + scope = scope.workspace_read(), + delegation_scope = scope.workspace_write(), + feature = { + task = { enabled = true }, + memory = { enabled = false }, + web = { enabled = true }, + pod_management = { enabled = true }, + ticket = { enabled = true, access = "read_only" }, + ticket_orchestration = { enabled = false }, + }, +} +"#, + ); + let workspace = tmp.path().join("workspace"); + std::fs::create_dir(&workspace).unwrap(); + let resolved = ProfileResolver::new() + .with_workspace_base(&workspace) + .resolve( + &ProfileSelector::path(&profile), + ProfileResolveOptions::with_pod_name("runtime-pod"), + ) + .unwrap(); + assert_eq!(resolved.manifest.pod.name, "runtime-pod"); + assert!(resolved.manifest.feature.task.enabled); + assert!(!resolved.manifest.feature.memory.enabled); + assert!(resolved.manifest.feature.web.enabled); + assert!(resolved.manifest.feature.pod_management.enabled); + assert!(resolved.manifest.feature.ticket.enabled); + assert_eq!( + resolved.manifest.feature.ticket.access, + crate::TicketFeatureAccessConfig::ReadOnly + ); + assert!(!resolved.manifest.feature.ticket_orchestration.enabled); + assert_eq!( + resolved.manifest.delegation_scope.allow[0].target, + workspace + ); + } + #[test] fn host_modules_and_local_require_work() { let tmp = TempDir::new().unwrap(); diff --git a/crates/pod/src/controller.rs b/crates/pod/src/controller.rs index 21f2a49d..92bdf14d 100644 --- a/crates/pod/src/controller.rs +++ b/crates/pod/src/controller.rs @@ -4,6 +4,7 @@ use std::sync::atomic::Ordering; use llm_worker::WorkerError; use llm_worker::llm_client::client::LlmClient; +use manifest::TicketFeatureAccessConfig; use pod_store::PodMetadataStore; use session_store::Store; use tokio::sync::{broadcast, mpsc, oneshot}; @@ -223,7 +224,7 @@ impl PodController { runtime_dir.socket_path(), runtime_base.to_path_buf(), spawned_registry.clone(), - ); + )?; // Intake role Pods self-terminate only after a successful // TicketIntakeReady turn has fully settled back to Idle. The request @@ -499,7 +500,7 @@ fn register_pod_tools( spawner_socket: PathBuf, runtime_base: PathBuf, spawned_registry: Arc, -) -> tools::ScopedFs +) -> std::io::Result where C: LlmClient + Clone + 'static, St: Store + PodMetadataStore + Clone + 'static, @@ -513,6 +514,7 @@ where let session_id_for_usage = pod.segment_id().to_string(); let memory_config = pod.manifest().memory.clone(); let web_config = pod.manifest().web.clone(); + let feature_config = pod.manifest().feature.clone(); let spawner_name = pod.manifest().pod.name.clone(); let spawner_manifest = pod.manifest().clone(); let prompts = pod.prompts().clone(); @@ -534,24 +536,47 @@ where fs, tracker.clone(), bash_output_dir, - web_config, )); + if feature_config.web.enabled { + pod.worker_mut() + .register_tools(tools::web_builtin_tools(web_config)); + } let mut feature_registry = FeatureRegistryBuilder::new(); - feature_registry.add_module(task_feature); - feature_registry.add_module(crate::feature::builtin::ticket_tools_feature( - &workspace_root, - )); + if feature_config.task.enabled { + feature_registry.add_module(task_feature); + } + if feature_config.ticket.enabled || feature_config.ticket_orchestration.enabled { + let ticket_access = match feature_config.ticket.access { + TicketFeatureAccessConfig::ReadOnly => { + crate::feature::builtin::ticket::TicketFeatureAccess::ReadOnly + } + TicketFeatureAccessConfig::Lifecycle => { + crate::feature::builtin::ticket::TicketFeatureAccess::Lifecycle + } + }; + feature_registry.add_module( + crate::feature::builtin::ticket::ticket_tools_feature_with_options( + &workspace_root, + feature_config.ticket.enabled.then_some(ticket_access), + feature_config.ticket_orchestration.enabled, + ), + ); + } let _feature_install_report = pod.install_features(feature_registry); let worker = pod.worker_mut(); - // Memory subsystem opt-in. When `[memory]` is present in the - // manifest, register the memory-specific Read/Write/Edit tools that - // target `/memory/` and `/knowledge/` with - // their built-in linter. Companion deny rules on the generic CRUD - // scope were already applied during `Pod::from_manifest`. - if let Some(mem) = memory_config.as_ref() { + // Memory tools require both explicit feature exposure and memory storage + // configuration. This keeps resident-memory config separate from the + // model-visible Memory*/Knowledge* tool surface. + if feature_config.memory.enabled { + let mem = memory_config.as_ref().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "[feature.memory].enabled = true requires a [memory] configuration section", + ) + })?; let layout = memory::WorkspaceLayout::resolve(mem, &workspace_root); let query_cfg = memory::tool::QueryConfig::from(mem); worker.register_tool(memory::tool::read_tool_with_usage( @@ -567,28 +592,39 @@ where // Pod-orchestration tools (SpawnPod + the four comm tools) share // the Pod-scoped `SpawnedPodRegistry` (also consumed by the main - // loop's `PodEvent` handler). - worker.register_tool(spawn_pod_tool( - spawner_name.clone(), - spawner_socket, - runtime_base.clone(), - workspace_root.clone(), - pwd.clone(), - spawned_registry.clone(), - self_parent_socket, - spawner_manifest, - scope_handle, - prompts, - )); - worker.register_tool(send_to_pod_tool(spawned_registry.clone())); - worker.register_tool(read_pod_output_tool(spawned_registry.clone())); - worker.register_tool(stop_pod_tool(spawned_registry.clone())); - let discovery = PodDiscovery::new(pod_store, spawner_name, runtime_base, pwd, spawned_registry); - worker.register_tool(list_pods_tool(discovery.clone())); - worker.register_tool(restore_pod_tool(discovery.clone())); - worker.register_tool(send_to_peer_pod_tool(discovery)); + // loop's `PodEvent` handler). Expose them only behind the explicit + // profile feature and require delegation authority up front so enabling + // the surface cannot imply broad child scope by accident. + if feature_config.pod_management.enabled { + if spawner_manifest.delegation_scope.allow.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "[feature.pod_management].enabled = true requires non-empty [[delegation_scope.allow]]", + )); + } + worker.register_tool(spawn_pod_tool( + spawner_name.clone(), + spawner_socket, + runtime_base.clone(), + workspace_root.clone(), + pwd.clone(), + spawned_registry.clone(), + self_parent_socket, + spawner_manifest, + scope_handle, + prompts, + )); + worker.register_tool(send_to_pod_tool(spawned_registry.clone())); + worker.register_tool(read_pod_output_tool(spawned_registry.clone())); + worker.register_tool(stop_pod_tool(spawned_registry.clone())); + let discovery = + PodDiscovery::new(pod_store, spawner_name, runtime_base, pwd, spawned_registry); + worker.register_tool(list_pods_tool(discovery.clone())); + worker.register_tool(restore_pod_tool(discovery.clone())); + worker.register_tool(send_to_peer_pod_tool(discovery)); + } pod.attach_tracker(tracker); - fs_for_view + Ok(fs_for_view) } /// Idle/Paused event loop. Each iteration either fires a staged diff --git a/crates/pod/src/feature/builtin.rs b/crates/pod/src/feature/builtin.rs index ae0054b1..0a2922c9 100644 --- a/crates/pod/src/feature/builtin.rs +++ b/crates/pod/src/feature/builtin.rs @@ -10,4 +10,5 @@ pub mod ticket; pub use task::{TaskFeature, task_tools_feature}; pub use ticket::{ TicketFeature, TicketFeatureAccess, ticket_tools_feature, ticket_tools_feature_with_access, + ticket_tools_feature_with_options, }; diff --git a/crates/pod/src/feature/builtin/ticket.rs b/crates/pod/src/feature/builtin/ticket.rs index ac385b34..471f0e97 100644 --- a/crates/pod/src/feature/builtin/ticket.rs +++ b/crates/pod/src/feature/builtin/ticket.rs @@ -9,7 +9,11 @@ use std::path::{Path, PathBuf}; use ticket::{ LocalTicketBackend, config::{DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TicketConfig}, - tool::{TICKET_READ_ONLY_TOOL_NAMES, TICKET_TOOL_NAMES, ticket_tools}, + tool::{ + TICKET_BASE_READ_ONLY_TOOL_NAMES, TICKET_BASE_TOOL_NAMES, + TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES, TICKET_ORCHESTRATION_TOOL_NAMES, + TICKET_READ_ONLY_TOOL_NAMES, TICKET_TOOL_NAMES, ticket_tools, + }, }; use crate::feature::{ @@ -32,10 +36,17 @@ pub enum TicketFeatureAccess { } impl TicketFeatureAccess { - pub fn tool_names(self) -> &'static [&'static str] { + pub fn base_tool_names(self) -> &'static [&'static str] { match self { - Self::ReadOnly => &TICKET_READ_ONLY_TOOL_NAMES, - Self::Lifecycle => &TICKET_TOOL_NAMES, + Self::ReadOnly => &TICKET_BASE_READ_ONLY_TOOL_NAMES, + Self::Lifecycle => &TICKET_BASE_TOOL_NAMES, + } + } + + pub fn orchestration_tool_names(self) -> &'static [&'static str] { + match self { + Self::ReadOnly => &TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES, + Self::Lifecycle => &TICKET_ORCHESTRATION_TOOL_NAMES, } } } @@ -46,6 +57,8 @@ pub struct TicketFeature { record_language: Option, config_error: Option, access: TicketFeatureAccess, + include_base_tools: bool, + include_orchestration_tools: bool, } impl TicketFeature { @@ -54,11 +67,21 @@ impl TicketFeature { } pub fn new_with_access(backend_root: impl Into, access: TicketFeatureAccess) -> Self { + Self::new_with_options(backend_root, Some(access), true) + } + + pub fn new_with_options( + backend_root: impl Into, + access: Option, + include_orchestration_tools: bool, + ) -> Self { Self { backend_root: backend_root.into(), record_language: None, config_error: None, - access, + access: access.unwrap_or(TicketFeatureAccess::Lifecycle), + include_base_tools: access.is_some(), + include_orchestration_tools, } } @@ -69,22 +92,36 @@ impl TicketFeature { pub fn for_workspace_with_access( workspace: impl AsRef, access: TicketFeatureAccess, + ) -> Self { + Self::for_workspace_with_options(workspace, Some(access), true) + } + + pub fn for_workspace_with_options( + workspace: impl AsRef, + access: Option, + include_orchestration_tools: bool, ) -> Self { let workspace = workspace.as_ref(); match TicketConfig::load_workspace(workspace) { Ok(config) => { let backend_root = config.backend_root().to_path_buf(); let record_language = config.ticket_record_language().map(str::to_string); - let mut feature = Self::new_with_access(backend_root, access); + let mut feature = + Self::new_with_options(backend_root, access, include_orchestration_tools); feature.record_language = record_language; feature } - Err(error) => Self { - backend_root: workspace.join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH), - record_language: None, - config_error: Some(error.to_string()), - access, - }, + Err(error) => { + let access_value = access.unwrap_or(TicketFeatureAccess::Lifecycle); + Self { + backend_root: workspace.join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH), + record_language: None, + config_error: Some(error.to_string()), + access: access_value, + include_base_tools: access.is_some(), + include_orchestration_tools, + } + } } } @@ -96,6 +133,23 @@ impl TicketFeature { self.access } + fn enabled_tool_names(&self) -> Vec<&'static str> { + if self.include_base_tools && self.include_orchestration_tools { + return match self.access { + TicketFeatureAccess::ReadOnly => TICKET_READ_ONLY_TOOL_NAMES.to_vec(), + TicketFeatureAccess::Lifecycle => TICKET_TOOL_NAMES.to_vec(), + }; + } + let mut names = Vec::new(); + if self.include_base_tools { + names.extend_from_slice(self.access.base_tool_names()); + } + if self.include_orchestration_tools { + names.extend_from_slice(self.access.orchestration_tool_names()); + } + names + } + fn authority(&self) -> HostAuthority { HostAuthority::TicketBackend { root: self.backend_root.display().to_string(), @@ -122,7 +176,8 @@ impl FeatureModule for TicketFeature { self.authority(), AUTHORITY_REASON, )); - for name in self.access.tool_names() { + let enabled_tool_names = self.enabled_tool_names(); + for name in &enabled_tool_names { descriptor = descriptor.with_tool(ToolDeclaration::new(*name, tool_description(name))); } descriptor @@ -152,12 +207,15 @@ impl FeatureModule for TicketFeature { let authority = self.authority(); let backend = LocalTicketBackend::new(usable_root) .with_record_language(self.record_language.as_deref()); - let allowed_tool_names = self.access.tool_names(); + let allowed_tool_names = self.enabled_tool_names(); let mut tools = context.tools(); for definition in ticket_tools(backend) { let (meta, _) = definition(); let name = meta.name.clone(); - if !allowed_tool_names.contains(&name.as_str()) { + if !allowed_tool_names + .iter() + .any(|allowed| *allowed == name.as_str()) + { continue; } tools.register( @@ -211,12 +269,24 @@ pub fn ticket_tools_feature_with_access( TicketFeature::for_workspace_with_access(workspace, access) } +pub fn ticket_tools_feature_with_options( + workspace: impl AsRef, + access: Option, + include_orchestration_tools: bool, +) -> TicketFeature { + TicketFeature::for_workspace_with_options(workspace, access, include_orchestration_tools) +} + #[cfg(test)] mod tests { use super::*; use crate::feature::{FeatureRegistryBuilder, FeatureRuntimeKind}; use crate::hook::HookRegistryBuilder; use tempfile::TempDir; + use ticket::tool::{ + TICKET_BASE_TOOL_NAMES, TICKET_ORCHESTRATION_TOOL_NAMES, TICKET_READ_ONLY_TOOL_NAMES, + TICKET_TOOL_NAMES, + }; fn make_ticket_root(root: &Path) { std::fs::create_dir_all(root).unwrap(); @@ -269,6 +339,40 @@ mod tests { assert_eq!(descriptor.requested_host_authorities.len(), 1); } + #[test] + fn descriptor_can_expose_base_ticket_without_orchestration_tools() { + let temp = TempDir::new().unwrap(); + let feature = ticket_tools_feature_with_options( + temp.path(), + Some(TicketFeatureAccess::Lifecycle), + false, + ); + let descriptor = feature.descriptor(); + assert_eq!( + descriptor + .tools + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(), + TICKET_BASE_TOOL_NAMES + ); + } + + #[test] + fn descriptor_can_expose_orchestration_only_tools() { + let temp = TempDir::new().unwrap(); + let feature = ticket_tools_feature_with_options(temp.path(), None, true); + let descriptor = feature.descriptor(); + assert_eq!( + descriptor + .tools + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(), + TICKET_ORCHESTRATION_TOOL_NAMES + ); + } + #[test] fn read_only_installation_does_not_expose_mutating_tools() { let temp = TempDir::new().unwrap(); diff --git a/crates/pod/src/spawn/tool.rs b/crates/pod/src/spawn/tool.rs index 5fc4d13e..713c9757 100644 --- a/crates/pod/src/spawn/tool.rs +++ b/crates/pod/src/spawn/tool.rs @@ -775,6 +775,7 @@ fn manifest_to_reusable_config(manifest: &PodManifest) -> PodManifestConfig { default_action: Some(p.default_action), rules: p.rules.clone(), }), + feature: manifest.feature.clone().into(), compaction: manifest .compaction .as_ref() diff --git a/crates/pod/tests/controller_test.rs b/crates/pod/tests/controller_test.rs index 5d9c88a5..080eb785 100644 --- a/crates/pod/tests/controller_test.rs +++ b/crates/pod/tests/controller_test.rs @@ -215,6 +215,127 @@ async fn wait_for_status(handle: &PodHandle, status: PodStatus) { // --------------------------------------------------------------------------- +fn request_tool_names(request: &Request) -> Vec { + let mut names = request + .tools + .iter() + .map(|tool| tool.name.clone()) + .collect::>(); + names.sort(); + names +} + +async fn wait_for_captured_request(client: &MockClient) -> Request { + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + let requests = client.captured_requests(); + if let Some(request) = requests.into_iter().next() { + return request; + } + assert!( + tokio::time::Instant::now() < deadline, + "timed out waiting for captured LLM request" + ); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } +} + +#[tokio::test] +async fn feature_flags_default_to_core_tool_surface_only() { + let client = MockClient::new(simple_text_events()); + let client_for_assert = client.clone(); + let pod = make_pod(client).await; + let handle = spawn_controller(pod).await; + + handle.send(Method::run_text("Hello")).await.unwrap(); + wait_for_status(&handle, PodStatus::Idle).await; + + let request = wait_for_captured_request(&client_for_assert).await; + let names = request_tool_names(&request); + assert_eq!(names, vec!["Bash", "Edit", "Glob", "Grep", "Read", "Write"]); + assert!(!names.iter().any(|name| name == "TaskCreate")); + assert!(!names.iter().any(|name| name == "WebSearch")); + assert!(!names.iter().any(|name| name == "SpawnPod")); +} + +#[tokio::test] +async fn enabled_task_and_web_features_register_their_tools() { + let manifest = r#" +[pod] +name = "feature-test-pod" +pwd = "./" + +[model] +scheme = "anthropic" +model_id = "test-model" + +[worker] +max_tokens = 100 + +[feature.task] +enabled = true + +[feature.web] +enabled = true + +[web] +enabled = false + +[[scope.allow]] +target = "./" +permission = "write" +"#; + let client = MockClient::new(simple_text_events()); + let client_for_assert = client.clone(); + let pod = make_pod_with_pwd_and_manifest(client, manifest).await.0; + let handle = spawn_controller(pod).await; + + handle.send(Method::run_text("Hello")).await.unwrap(); + wait_for_status(&handle, PodStatus::Idle).await; + + let request = wait_for_captured_request(&client_for_assert).await; + let names = request_tool_names(&request); + assert!(names.iter().any(|name| name == "TaskCreate")); + assert!(names.iter().any(|name| name == "TaskUpdate")); + assert!(names.iter().any(|name| name == "WebSearch")); + assert!(names.iter().any(|name| name == "WebFetch")); + assert!(!names.iter().any(|name| name == "SpawnPod")); + assert!(!names.iter().any(|name| name == "MemoryRead")); +} + +#[tokio::test] +async fn pod_management_feature_requires_delegation_scope() { + let manifest = r#" +[pod] +name = "pod-management-feature-test" +pwd = "./" + +[model] +scheme = "anthropic" +model_id = "test-model" + +[worker] +max_tokens = 100 + +[feature.pod_management] +enabled = true + +[[scope.allow]] +target = "./" +permission = "write" +"#; + let client = MockClient::new(simple_text_events()); + let pod = make_pod_with_pwd_and_manifest(client, manifest).await.0; + let tmp = tempfile::tempdir().unwrap(); + let result = PodController::spawn(pod, tmp.path()).await; + assert!(result.is_err()); + let message = result.err().unwrap().to_string(); + assert!( + message.contains("[feature.pod_management].enabled = true requires non-empty"), + "unexpected error: {message}" + ); +} + #[tokio::test] async fn run_end_returns_to_idle_without_busy_status() { let client = MockClient::new(simple_text_events()); diff --git a/crates/ticket/src/tool.rs b/crates/ticket/src/tool.rs index e05c638b..6f86b38e 100644 --- a/crates/ticket/src/tool.rs +++ b/crates/ticket/src/tool.rs @@ -32,6 +32,31 @@ const MAX_BODY_MAX_BYTES: usize = 64 * 1024; const DEFAULT_DIAGNOSTIC_LIMIT: usize = 100; const MAX_DIAGNOSTIC_LIMIT: usize = 500; +pub const TICKET_BASE_TOOL_NAMES: [&str; 9] = [ + "TicketCreate", + "TicketList", + "TicketShow", + "TicketComment", + "TicketReview", + "TicketIntakeReady", + "TicketWorkflowState", + "TicketClose", + "TicketDoctor", +]; + +pub const TICKET_BASE_READ_ONLY_TOOL_NAMES: [&str; 3] = + ["TicketList", "TicketShow", "TicketDoctor"]; + +pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 4] = [ + "TicketRelationRecord", + "TicketRelationQuery", + "TicketOrchestrationPlanRecord", + "TicketOrchestrationPlanQuery", +]; + +pub const TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES: [&str; 2] = + ["TicketRelationQuery", "TicketOrchestrationPlanQuery"]; + pub const TICKET_TOOL_NAMES: [&str; 13] = [ "TicketCreate", "TicketList", diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index fe2af16a..4a55feb8 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -57,7 +57,6 @@ pub fn core_builtin_tools( fs: ScopedFs, tracker: Tracker, bash_output_dir: std::path::PathBuf, - web_config: Option, ) -> Vec { vec![ read_tool(fs.clone(), tracker.clone()), @@ -66,6 +65,13 @@ pub fn core_builtin_tools( glob_tool(fs.clone()), grep_tool(fs.clone()), bash_tool(fs, bash_output_dir), + ] +} + +pub fn web_builtin_tools( + web_config: Option, +) -> Vec { + vec![ web_search_tool(web::WebTools::new(web_config.clone())), web_fetch_tool(web::WebTools::new(web_config)), ] diff --git a/crates/tools/tests/edge_cases.rs b/crates/tools/tests/edge_cases.rs index 7926ac10..a3014ca2 100644 --- a/crates/tools/tests/edge_cases.rs +++ b/crates/tools/tests/edge_cases.rs @@ -43,12 +43,7 @@ fn setup() -> (TempDir, TempDir, Registry) { let scope = Scope::from_config(&config).unwrap(); let fs = ScopedFs::new(scope, dir.path().to_path_buf()); let tracker = Tracker::new(); - let reg = Registry::new(core_builtin_tools( - fs, - tracker, - spill.path().to_path_buf(), - None, - )); + let reg = Registry::new(core_builtin_tools(fs, tracker, spill.path().to_path_buf())); (dir, spill, reg) } diff --git a/crates/tools/tests/integration.rs b/crates/tools/tests/integration.rs index ce605fe0..3fd74620 100644 --- a/crates/tools/tests/integration.rs +++ b/crates/tools/tests/integration.rs @@ -56,12 +56,7 @@ fn setup() -> (TempDir, TempDir, Registry) { let scope = scope_with_spill(dir.path(), spill.path()); let fs = ScopedFs::new(scope, dir.path().to_path_buf()); let tracker = Tracker::new(); - let reg = Registry::new(core_builtin_tools( - fs, - tracker, - spill.path().to_path_buf(), - None, - )); + let reg = Registry::new(core_builtin_tools(fs, tracker, spill.path().to_path_buf())); (dir, spill, reg) } @@ -82,19 +77,7 @@ fn core_builtin_tools_registers_full_set() { let (_dir, _spill, reg) = setup(); let mut names = reg.names(); names.sort(); - assert_eq!( - names, - vec![ - "Bash", - "Edit", - "Glob", - "Grep", - "Read", - "WebFetch", - "WebSearch", - "Write" - ] - ); + assert_eq!(names, vec!["Bash", "Edit", "Glob", "Grep", "Read", "Write"]); } #[test] @@ -287,20 +270,11 @@ async fn edit_requires_read_across_tools() { #[tokio::test] async fn deterministic_tool_order_is_registration_order() { let (_dir, _spill, reg) = setup(); - // Registration order from core_builtin_tools(): Read, Write, Edit, Glob, Grep, Bash, WebSearch, WebFetch + // Registration order from core_builtin_tools(): Read, Write, Edit, Glob, Grep, Bash let names: Vec<&str> = reg.entries.iter().map(|(m, _)| m.name.as_str()).collect(); assert_eq!( names, - vec![ - "Read", - "Write", - "Edit", - "Glob", - "Grep", - "Bash", - "WebSearch", - "WebFetch", - ] + vec!["Read", "Write", "Edit", "Glob", "Grep", "Bash",] ); } @@ -308,16 +282,7 @@ async fn deterministic_tool_order_is_registration_order() { #[test] fn tool_names_match_reference_spec() { let (_dir, _spill, reg) = setup(); - for expected in [ - "Read", - "Write", - "Edit", - "Glob", - "Grep", - "Bash", - "WebSearch", - "WebFetch", - ] { + for expected in ["Read", "Write", "Edit", "Glob", "Grep", "Bash"] { assert!( reg.entries.iter().any(|(m, _)| m.name == expected), "missing tool {expected}" @@ -337,7 +302,6 @@ async fn tracker_recent_files_tracks_read_write_edit() { fs, tracker.clone(), spill.path().to_path_buf(), - None, )); let a = dir.path().join("a.txt"); diff --git a/resources/profiles/default.lua b/resources/profiles/default.lua index 988896f5..4490b79f 100644 --- a/resources/profiles/default.lua +++ b/resources/profiles/default.lua @@ -26,6 +26,15 @@ return profile { worker_context_max_tokens = 100000, }, + feature = { + task = { enabled = true }, + memory = { enabled = true }, + web = { enabled = true }, + pod_management = { enabled = false }, + ticket = { enabled = false, access = "lifecycle" }, + ticket_orchestration = { enabled = false }, + }, + memory = { extract_threshold = 50000, consolidation_threshold_files = 5,