feat: gate built-in tools by profile features
This commit is contained in:
parent
41133e0cd5
commit
f0f6cc92d8
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
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 `<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,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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
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<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();
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user