feat: gate built-in tools by profile features

This commit is contained in:
Keisuke Hirata 2026-06-09 21:05:38 +09:00
parent 41133e0cd5
commit f0f6cc92d8
No known key found for this signature in database
19 changed files with 833 additions and 109 deletions

View File

@ -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,

View File

@ -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(),
}

View File

@ -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",
}

View File

@ -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",
}

View File

@ -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",
}

View File

@ -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",
}

View File

@ -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<PermissionConfigPartial>,
/// Explicit built-in feature/tool-surface enablement. Absent flags resolve
/// disabled after cascade merge.
#[serde(default)]
pub feature: FeatureConfigPartial,
#[serde(default)]
pub compaction: Option<CompactionConfigPartial>,
/// First-class web tool opt-in. See [`WebConfig`].
@ -60,6 +65,146 @@ pub struct PodManifestConfig {
pub skills: Option<SkillsConfig>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FeatureConfigPartial {
#[serde(default)]
pub task: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub memory: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub web: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub pod_management: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub ticket: Option<TicketFeatureConfigPartial>,
#[serde(default)]
pub ticket_orchestration: Option<FeatureFlagConfigPartial>,
}
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<bool>,
}
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<bool>,
#[serde(default)]
pub access: Option<TicketFeatureAccessConfig>,
}
impl TicketFeatureConfigPartial {
fn merge(self, other: Self) -> Self {
Self {
enabled: other.enabled.or(self.enabled),
access: other.access.or(self.access),
}
}
}
impl From<FeatureConfigPartial> 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<FeatureFlagConfigPartial> for FeatureFlagConfig {
fn from(value: FeatureFlagConfigPartial) -> Self {
Self {
enabled: value.enabled.unwrap_or_default(),
}
}
}
impl From<FeatureFlagConfig> for FeatureFlagConfigPartial {
fn from(value: FeatureFlagConfig) -> Self {
Self {
enabled: Some(value.enabled),
}
}
}
impl From<TicketFeatureConfigPartial> for TicketFeatureConfig {
fn from(value: TicketFeatureConfigPartial) -> Self {
Self {
enabled: value.enabled.unwrap_or_default(),
access: value.access.unwrap_or_default(),
}
}
}
impl From<TicketFeatureConfig> for TicketFeatureConfigPartial {
fn from(value: TicketFeatureConfig) -> Self {
Self {
enabled: Some(value.enabled),
access: Some(value.access),
}
}
}
impl From<FeatureConfig> 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<PodManifestConfig> 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.

View File

@ -53,18 +53,20 @@ pub struct PodManifest {
/// permission layer is disabled and tool calls run as before.
#[serde(default)]
pub permissions: Option<ToolPermissionConfig>,
/// 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<CompactionConfig>,
/// 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 `<workspace>/memory/`
/// and `<workspace>/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<MemoryConfig>,
/// 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<WebConfig>,
/// External Agent Skills (`SKILL.md`) directories to ingest as
@ -82,6 +84,94 @@ pub struct PodManifest {
pub profile: Option<profile::ProfileManifestSnapshot>,
}
/// 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

View File

@ -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<PermissionConfigPartial>,
#[serde(default)]
feature: FeatureConfigPartial,
#[serde(default)]
compaction: Option<serde_json::Value>,
#[serde(default)]
web: Option<WebConfig>,
@ -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();

View File

@ -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<C, St>(
spawner_socket: PathBuf,
runtime_base: PathBuf,
spawned_registry: Arc<SpawnedPodRegistry>,
) -> tools::ScopedFs
) -> std::io::Result<tools::ScopedFs>
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();
if feature_config.task.enabled {
feature_registry.add_module(task_feature);
feature_registry.add_module(crate::feature::builtin::ticket_tools_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 `<workspace>/memory/` and `<workspace>/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,7 +592,16 @@ where
// Pod-orchestration tools (SpawnPod + the four comm tools) share
// the Pod-scoped `SpawnedPodRegistry` (also consumed by the main
// loop's `PodEvent` handler).
// 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,
@ -583,12 +617,14 @@ where
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);
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

View File

@ -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,
};

View File

@ -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<String>,
config_error: Option<String>,
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<PathBuf>, access: TicketFeatureAccess) -> Self {
Self::new_with_options(backend_root, Some(access), true)
}
pub fn new_with_options(
backend_root: impl Into<PathBuf>,
access: Option<TicketFeatureAccess>,
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<Path>,
access: TicketFeatureAccess,
) -> Self {
Self::for_workspace_with_options(workspace, Some(access), true)
}
pub fn for_workspace_with_options(
workspace: impl AsRef<Path>,
access: Option<TicketFeatureAccess>,
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 {
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: 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<Path>,
access: Option<TicketFeatureAccess>,
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::<Vec<_>>(),
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::<Vec<_>>(),
TICKET_ORCHESTRATION_TOOL_NAMES
);
}
#[test]
fn read_only_installation_does_not_expose_mutating_tools() {
let temp = TempDir::new().unwrap();

View File

@ -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()

View File

@ -215,6 +215,127 @@ async fn wait_for_status(handle: &PodHandle, status: PodStatus) {
// ---------------------------------------------------------------------------
fn request_tool_names(request: &Request) -> Vec<String> {
let mut names = request
.tools
.iter()
.map(|tool| tool.name.clone())
.collect::<Vec<_>>();
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());

View File

@ -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",

View File

@ -57,7 +57,6 @@ pub fn core_builtin_tools(
fs: ScopedFs,
tracker: Tracker,
bash_output_dir: std::path::PathBuf,
web_config: Option<manifest::WebConfig>,
) -> Vec<llm_worker::tool::ToolDefinition> {
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<manifest::WebConfig>,
) -> Vec<llm_worker::tool::ToolDefinition> {
vec![
web_search_tool(web::WebTools::new(web_config.clone())),
web_fetch_tool(web::WebTools::new(web_config)),
]

View File

@ -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)
}

View File

@ -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");

View File

@ -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,