diff --git a/crates/pod/src/controller.rs b/crates/pod/src/controller.rs index 5cd1907b..79046c6e 100644 --- a/crates/pod/src/controller.rs +++ b/crates/pod/src/controller.rs @@ -9,6 +9,7 @@ use session_store::Store; use tokio::sync::{broadcast, mpsc, oneshot}; use crate::discovery::{PodDiscovery, list_pods_tool, restore_pod_tool, send_to_peer_pod_tool}; +use crate::feature::{FeatureRegistryBuilder, builtin::task_feature}; use crate::ipc::alerter::Alerter; use crate::ipc::notify_buffer::NotifyBuffer; use crate::ipc::server::SocketServer; @@ -502,8 +503,6 @@ where let pod_store = pod.store().clone(); let self_parent_socket = pod.callback_socket().cloned(); - let worker = pod.worker_mut(); - // The Pod's SharedScope (already augmented with the bash-output // Read rule by the caller) is the single source of truth — every // ScopedFs (builtin tools, fs_view, compact worker) reads from it, @@ -515,14 +514,19 @@ where // a clone for the FS view we attach below, since the tools consume // `fs` itself. let fs_for_view = fs.clone(); - worker.register_tools(tools::builtin_tools( + pod.worker_mut().register_tools(tools::core_builtin_tools( fs, tracker.clone(), - task_store, bash_output_dir, web_config, )); + let mut feature_registry = FeatureRegistryBuilder::new(); + feature_registry.add_module(task_feature(task_store)); + 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 diff --git a/crates/pod/src/feature.rs b/crates/pod/src/feature.rs new file mode 100644 index 00000000..377ac7d9 --- /dev/null +++ b/crates/pod/src/feature.rs @@ -0,0 +1,1827 @@ +//! Feature contribution registry for Pod-hosted builtin/plugin modules. +//! +//! This module defines the Pod-side feature boundary used to collect +//! descriptor metadata, authority requests, tool contributions, safe hook +//! contributions, background task declarations, and service declarations before +//! installing them into the existing Worker/HookRegistry host surfaces. +//! +//! The first implementation slice is intentionally host-mediated and +//! descriptor-first: tools are installed through the normal Worker tool path, +//! hooks are installed through [`crate::hook::HookRegistryBuilder`], while +//! service and background-task contributions are represented in descriptors and +//! install reports without starting an independent runtime lifecycle. + +use std::collections::{HashMap, HashSet}; +use std::fmt; +use std::sync::Arc; + +use llm_worker::Worker; +use llm_worker::llm_client::client::LlmClient; +use llm_worker::state::Mutable; +use llm_worker::tool::ToolDefinition; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::hook::{Hook, HookRegistryBuilder, OnTurnEnd, PostToolCall, PreLlmRequest, PreToolCall}; + +/// Stable source-qualified identifier for a feature module. +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub struct FeatureId(String); + +impl FeatureId { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.trim().is_empty() { + return Err(FeatureInstallError::InvalidDescriptor( + "feature id must not be empty".into(), + )); + } + Ok(Self(value)) + } + + pub fn builtin(slug: impl AsRef) -> Self { + Self(format!("builtin:{}", slug.as_ref())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for FeatureId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl From for String { + fn from(value: FeatureId) -> Self { + value.0 + } +} + +/// Runtime/source class for a feature module. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FeatureRuntimeKind { + Builtin, + LuaProfile, + ExternalPlugin, +} + +/// Host authority requested by a feature for host-mediated operations that can +/// cross sandbox or model-context boundaries. +/// +/// Contribution declarations such as tools, hooks, background tasks, and +/// services are descriptor/package-approved host-visible contributions, not +/// sandbox authorities. Grants are additive and do not replace manifest/tool +/// permission checks. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HostAuthority { + Filesystem, + Network, + SecretRef { id: String }, + ModelNotification, + PodManagement, + StateStore { name: String }, + ServiceAccess { service: ServiceId }, +} + +/// A safe hook contribution point exposed to feature modules. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FeatureHookPoint { + PreRequest, + PreToolCall, + ToolResult, + TurnEnd, +} + +/// Authority request declared by a feature descriptor. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthorityRequest { + pub authority: HostAuthority, + pub required: bool, + pub reason: String, +} + +impl AuthorityRequest { + pub fn required(authority: HostAuthority, reason: impl Into) -> Self { + Self { + authority, + required: true, + reason: reason.into(), + } + } + + pub fn optional(authority: HostAuthority, reason: impl Into) -> Self { + Self { + authority, + required: false, + reason: reason.into(), + } + } +} + +/// Authority grants resolved by the host for one feature installation. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthorityGrantSet { + granted: HashSet, + denied: Vec, +} + +impl AuthorityGrantSet { + pub fn grant_all(requests: &[AuthorityRequest]) -> Self { + Self { + granted: requests + .iter() + .map(|request| request.authority.clone()) + .collect(), + denied: Vec::new(), + } + } + + pub fn empty() -> Self { + Self::default() + } + + pub fn contains(&self, authority: &HostAuthority) -> bool { + self.granted.contains(authority) + } + + pub fn denied(&self) -> &[AuthorityDenial] { + &self.denied + } + + pub fn grant(&mut self, authority: HostAuthority) { + self.granted.insert(authority); + } + + pub fn deny(&mut self, authority: HostAuthority, reason: impl Into) { + self.granted.remove(&authority); + self.denied.push(AuthorityDenial { + authority, + reason: reason.into(), + }); + } +} + +/// Host-side denial of a requested feature authority. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthorityDenial { + pub authority: HostAuthority, + pub reason: String, +} + +/// Serializable declaration of a tool contribution. The executable factory is +/// carried by [`ToolContribution`] during installation. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ToolDeclaration { + pub name: String, + pub description: String, +} + +impl ToolDeclaration { + pub fn new(name: impl Into, description: impl Into) -> Self { + Self { + name: name.into(), + description: description.into(), + } + } +} + +/// Executable tool contribution wrapper. +pub struct ToolContribution { + name: String, + definition: ToolDefinition, + required_authorities: Vec, +} + +impl ToolContribution { + pub fn new(name: impl Into, definition: ToolDefinition) -> Self { + Self { + name: name.into(), + definition, + required_authorities: Vec::new(), + } + } + + pub fn with_required_authorities(mut self, required_authorities: Vec) -> Self { + self.required_authorities = required_authorities; + self + } + + pub fn name(&self) -> &str { + &self.name + } +} + +/// Serializable declaration of a hook contribution. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct HookDeclaration { + pub name: String, + pub point: FeatureHookPoint, +} + +impl HookDeclaration { + pub fn new(name: impl Into, point: FeatureHookPoint) -> Self { + Self { + name: name.into(), + point, + } + } +} + +/// Background task lifecycle phase represented by this registry slice. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BackgroundTaskLifecycle { + DescriptorOnly, + HostManaged, +} + +/// Declaration for a feature-provided background task. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BackgroundTaskDeclaration { + pub name: String, + pub description: String, + pub lifecycle: BackgroundTaskLifecycle, +} + +impl BackgroundTaskDeclaration { + pub fn descriptor_only(name: impl Into, description: impl Into) -> Self { + Self { + name: name.into(), + description: description.into(), + lifecycle: BackgroundTaskLifecycle::DescriptorOnly, + } + } +} + +/// Source-qualified service identifier. +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub struct ServiceId(String); + +impl ServiceId { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.trim().is_empty() { + return Err(FeatureInstallError::InvalidDescriptor( + "service id must not be empty".into(), + )); + } + Ok(Self(value)) + } + + pub fn builtin(slug: impl AsRef) -> Self { + Self(format!("builtin:{}", slug.as_ref())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for ServiceId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +/// Minimal version requirement placeholder for service resolution. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ServiceVersionReq { + pub requirement: String, +} + +impl ServiceVersionReq { + pub fn any() -> Self { + Self { + requirement: "*".into(), + } + } +} + +/// Feature-provided service declaration. This first slice records provider +/// metadata and supports requirement matching; it does not expose concrete +/// provider objects across feature boundaries. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ServiceDeclaration { + pub id: ServiceId, + pub version: String, + pub description: String, +} + +impl ServiceDeclaration { + pub fn new(id: ServiceId, version: impl Into, description: impl Into) -> Self { + Self { + id, + version: version.into(), + description: description.into(), + } + } +} + +/// Feature service requirement used for host-mediated dependency resolution. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ServiceRequirement { + pub id: ServiceId, + pub version: ServiceVersionReq, + pub required: bool, + pub reason: String, +} + +impl ServiceRequirement { + pub fn required(id: ServiceId, reason: impl Into) -> Self { + Self { + id, + version: ServiceVersionReq::any(), + required: true, + reason: reason.into(), + } + } + + pub fn optional(id: ServiceId, reason: impl Into) -> Self { + Self { + id, + version: ServiceVersionReq::any(), + required: false, + reason: reason.into(), + } + } +} + +/// Host-mediated service registry skeleton used during feature installation. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct FeatureServiceRegistry { + providers: HashMap, +} + +impl FeatureServiceRegistry { + pub fn providers(&self) -> &HashMap { + &self.providers + } + + pub fn provides(&self, id: &ServiceId) -> bool { + self.providers.contains_key(id) + } + + fn register_provider( + &mut self, + feature_id: FeatureId, + declaration: ServiceDeclaration, + ) -> Result<(), FeatureInstallError> { + if let Some(existing) = self.providers.get(&declaration.id) { + return Err(FeatureInstallError::DuplicateService { + service: declaration.id.to_string(), + first_feature: existing.feature_id.to_string(), + duplicate_feature: feature_id.to_string(), + }); + } + self.providers.insert( + declaration.id.clone(), + FeatureServiceProvider { + feature_id, + declaration, + }, + ); + Ok(()) + } +} + +/// Provider metadata for one service declaration. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FeatureServiceProvider { + pub feature_id: FeatureId, + pub declaration: ServiceDeclaration, +} + +/// Feature descriptor advertised before installation. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FeatureDescriptor { + pub id: FeatureId, + pub runtime: FeatureRuntimeKind, + pub display_name: String, + pub version: String, + pub description: String, + pub requested_authorities: Vec, + pub tools: Vec, + pub hooks: Vec, + pub background_tasks: Vec, + pub provides_services: Vec, + pub requires_services: Vec, +} + +impl FeatureDescriptor { + pub fn builtin(id: impl AsRef, display_name: impl Into) -> Self { + Self { + id: FeatureId::builtin(id), + runtime: FeatureRuntimeKind::Builtin, + display_name: display_name.into(), + version: env!("CARGO_PKG_VERSION").into(), + description: String::new(), + requested_authorities: Vec::new(), + tools: Vec::new(), + hooks: Vec::new(), + background_tasks: Vec::new(), + provides_services: Vec::new(), + requires_services: Vec::new(), + } + } + + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = description.into(); + self + } + + pub fn with_authority(mut self, request: AuthorityRequest) -> Self { + self.requested_authorities.push(request); + self + } + + pub fn with_tool(mut self, tool: ToolDeclaration) -> Self { + self.tools.push(tool); + self + } + + pub fn with_hook(mut self, hook: HookDeclaration) -> Self { + self.hooks.push(hook); + self + } + + pub fn with_background_task(mut self, task: BackgroundTaskDeclaration) -> Self { + self.background_tasks.push(task); + self + } + + pub fn with_provided_service(mut self, service: ServiceDeclaration) -> Self { + self.provides_services.push(service); + self + } + + pub fn with_service_requirement(mut self, requirement: ServiceRequirement) -> Self { + self.requires_services.push(requirement); + self + } +} + +/// Feature module contribution boundary. +pub trait FeatureModule: Send + Sync { + fn descriptor(&self) -> FeatureDescriptor; + fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError>; +} + +/// Severity for feature installation diagnostics. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FeatureDiagnosticSeverity { + Info, + Warning, + Error, +} + +/// Installation diagnostic emitted by the feature host or feature module. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FeatureDiagnostic { + pub severity: FeatureDiagnosticSeverity, + pub message: String, +} + +impl FeatureDiagnostic { + pub fn info(message: impl Into) -> Self { + Self { + severity: FeatureDiagnosticSeverity::Info, + message: message.into(), + } + } + + pub fn warning(message: impl Into) -> Self { + Self { + severity: FeatureDiagnosticSeverity::Warning, + message: message.into(), + } + } + + pub fn error(message: impl Into) -> Self { + Self { + severity: FeatureDiagnosticSeverity::Error, + message: message.into(), + } + } +} + +/// Kind of contribution represented in install reports. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FeatureContributionKind { + Tool, + Hook, + BackgroundTask, + Service, + Notification, + Alert, + Diagnostic, +} + +/// A contribution intentionally skipped by the host. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SkippedContribution { + pub kind: FeatureContributionKind, + pub name: String, + pub reason: String, +} + +/// Per-feature installation report. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FeatureInstallReport { + pub feature_id: FeatureId, + pub runtime: FeatureRuntimeKind, + pub installed: bool, + pub granted_authorities: AuthorityGrantSet, + pub installed_tools: Vec, + pub installed_hooks: Vec, + pub declared_background_tasks: Vec, + pub provided_services: Vec, + pub resolved_service_requirements: Vec, + pub skipped: Vec, + pub diagnostics: Vec, +} + +impl FeatureInstallReport { + fn new(descriptor: &FeatureDescriptor, granted_authorities: AuthorityGrantSet) -> Self { + Self { + feature_id: descriptor.id.clone(), + runtime: descriptor.runtime.clone(), + installed: false, + granted_authorities, + installed_tools: Vec::new(), + installed_hooks: Vec::new(), + declared_background_tasks: Vec::new(), + provided_services: Vec::new(), + resolved_service_requirements: Vec::new(), + skipped: Vec::new(), + diagnostics: Vec::new(), + } + } + + fn mark_skipped( + &mut self, + kind: FeatureContributionKind, + name: impl Into, + reason: impl Into, + ) { + self.skipped.push(SkippedContribution { + kind, + name: name.into(), + reason: reason.into(), + }); + } +} + +#[derive(Clone, Debug)] +struct FeatureContributionDeclarations { + tools: HashSet, + hooks: HashSet<(String, FeatureHookPoint)>, + background_tasks: HashSet, + provided_services: HashSet<(ServiceId, String)>, +} + +impl FeatureContributionDeclarations { + fn from_descriptor(descriptor: &FeatureDescriptor) -> Self { + Self { + tools: descriptor + .tools + .iter() + .map(|tool| tool.name.clone()) + .collect(), + hooks: descriptor + .hooks + .iter() + .map(|hook| (hook.name.clone(), hook.point.clone())) + .collect(), + background_tasks: descriptor + .background_tasks + .iter() + .map(|task| task.name.clone()) + .collect(), + provided_services: descriptor + .provides_services + .iter() + .map(|service| (service.id.clone(), service.version.clone())) + .collect(), + } + } + + fn contains_tool(&self, name: &str) -> bool { + self.tools.contains(name) + } + + fn contains_hook(&self, declaration: &HookDeclaration) -> bool { + self.hooks + .contains(&(declaration.name.clone(), declaration.point.clone())) + } + + fn contains_background_task(&self, declaration: &BackgroundTaskDeclaration) -> bool { + self.background_tasks.contains(&declaration.name) + } + + fn contains_provided_service(&self, declaration: &ServiceDeclaration) -> bool { + self.provided_services + .contains(&(declaration.id.clone(), declaration.version.clone())) + } +} + +fn reject_undeclared_contribution( + feature_id: &FeatureId, + report: &mut FeatureInstallReport, + kind: FeatureContributionKind, + name: impl Into, +) -> FeatureInstallError { + let name = name.into(); + let error = FeatureInstallError::UndeclaredContribution { + kind: kind.clone(), + name: name.clone(), + feature: feature_id.to_string(), + }; + report.mark_skipped(kind, name, error.to_string()); + error +} + +fn require_authority( + grants: &AuthorityGrantSet, + report: &mut FeatureInstallReport, + kind: FeatureContributionKind, + name: impl Into, + authority: &HostAuthority, +) -> Result<(), FeatureInstallError> { + if grants.contains(authority) { + return Ok(()); + } + + let reason = format!("required authority was not granted: {authority:?}"); + report.mark_skipped(kind, name, reason.clone()); + Err(FeatureInstallError::AuthorityDenied(reason)) +} + +/// Model-visible durable notification sink skeleton. The first slice exposes +/// the boundary without implementing a new event channel. +pub struct FeatureNotificationSink<'a> { + grants: &'a AuthorityGrantSet, + report: &'a mut FeatureInstallReport, +} + +impl FeatureNotificationSink<'_> { + pub fn notify_model(&mut self, message: impl Into) -> Result<(), FeatureInstallError> { + require_authority( + self.grants, + self.report, + FeatureContributionKind::Notification, + "notify_model", + &HostAuthority::ModelNotification, + )?; + let message = message.into(); + self.report.diagnostics.push(FeatureDiagnostic::warning(format!( + "model notification requested during feature installation but no durable Notify host is attached: {message}" + ))); + self.report.mark_skipped( + FeatureContributionKind::Notification, + "notify_model", + "durable Notify/SystemItem host is not connected during feature installation", + ); + Ok(()) + } +} + +/// Transient human-facing alert sink skeleton. +pub struct FeatureAlertSink<'a> { + report: &'a mut FeatureInstallReport, +} + +impl FeatureAlertSink<'_> { + pub fn alert(&mut self, message: impl Into) { + let message = message.into(); + self.report + .diagnostics + .push(FeatureDiagnostic::info(format!("feature alert: {message}"))); + self.report.mark_skipped( + FeatureContributionKind::Alert, + "alert", + "transient alert host is not connected during feature installation", + ); + } +} + +/// Diagnostic sink available to feature installers. +pub struct FeatureDiagnosticSink<'a> { + report: &'a mut FeatureInstallReport, +} + +impl FeatureDiagnosticSink<'_> { + pub fn push(&mut self, diagnostic: FeatureDiagnostic) { + self.report.diagnostics.push(diagnostic); + } + + pub fn info(&mut self, message: impl Into) { + self.push(FeatureDiagnostic::info(message)); + } + + pub fn warning(&mut self, message: impl Into) { + self.push(FeatureDiagnostic::warning(message)); + } + + pub fn error(&mut self, message: impl Into) { + self.push(FeatureDiagnostic::error(message)); + } +} + +/// Tool contribution registrar exposed inside [`FeatureInstallContext`]. +pub struct ToolContributionRegistrar<'a> { + feature_id: &'a FeatureId, + declarations: &'a FeatureContributionDeclarations, + grants: &'a AuthorityGrantSet, + pending_tools: &'a mut Vec, + installed_tool_names: &'a mut HashMap, + report: &'a mut FeatureInstallReport, +} + +impl ToolContributionRegistrar<'_> { + pub fn register(&mut self, contribution: ToolContribution) -> Result<(), FeatureInstallError> { + let (tool_meta, tool) = (contribution.definition)(); + let model_visible_name = tool_meta.name.clone(); + if contribution.name != model_visible_name { + let error = FeatureInstallError::ToolNameMismatch { + declared: contribution.name, + model_visible: model_visible_name.clone(), + }; + self.report.mark_skipped( + FeatureContributionKind::Tool, + model_visible_name, + error.to_string(), + ); + return Err(error); + } + + if !self.declarations.contains_tool(&model_visible_name) { + return Err(reject_undeclared_contribution( + self.feature_id, + self.report, + FeatureContributionKind::Tool, + model_visible_name, + )); + } + + for authority in &contribution.required_authorities { + require_authority( + self.grants, + self.report, + FeatureContributionKind::Tool, + model_visible_name.clone(), + authority, + )?; + } + + if let Some(first) = self.installed_tool_names.get(&model_visible_name) { + let error = FeatureInstallError::DuplicateToolName { + tool: model_visible_name.clone(), + first_feature: first.to_string(), + duplicate_feature: self.feature_id.to_string(), + }; + self.report.mark_skipped( + FeatureContributionKind::Tool, + model_visible_name, + error.to_string(), + ); + return Err(error); + } + + self.installed_tool_names + .insert(model_visible_name.clone(), self.feature_id.clone()); + self.report.installed_tools.push(model_visible_name); + self.pending_tools + .push(Arc::new(move || (tool_meta.clone(), Arc::clone(&tool)))); + Ok(()) + } +} + +/// Safe hook contribution registrar backed by [`HookRegistryBuilder`]. +pub struct HookContributionRegistrar<'a> { + feature_id: &'a FeatureId, + declarations: &'a FeatureContributionDeclarations, + hook_builder: &'a mut HookRegistryBuilder, + report: &'a mut FeatureInstallReport, +} + +impl HookContributionRegistrar<'_> { + fn require_declared( + &mut self, + declaration: &HookDeclaration, + ) -> Result<(), FeatureInstallError> { + if self.declarations.contains_hook(declaration) { + return Ok(()); + } + Err(reject_undeclared_contribution( + self.feature_id, + self.report, + FeatureContributionKind::Hook, + format!("{}:{:?}", declaration.name, declaration.point), + )) + } + + pub fn add_pre_request( + &mut self, + name: impl Into, + hook: impl Hook + 'static, + ) -> Result<(), FeatureInstallError> { + let declaration = HookDeclaration::new(name, FeatureHookPoint::PreRequest); + self.require_declared(&declaration)?; + self.hook_builder.add_pre_llm_request(hook); + self.report.installed_hooks.push(declaration); + Ok(()) + } + + pub fn add_pre_tool_call( + &mut self, + name: impl Into, + hook: impl Hook + 'static, + ) -> Result<(), FeatureInstallError> { + let declaration = HookDeclaration::new(name, FeatureHookPoint::PreToolCall); + self.require_declared(&declaration)?; + self.hook_builder.add_pre_tool_call(hook); + self.report.installed_hooks.push(declaration); + Ok(()) + } + + pub fn add_tool_result( + &mut self, + name: impl Into, + hook: impl Hook + 'static, + ) -> Result<(), FeatureInstallError> { + let declaration = HookDeclaration::new(name, FeatureHookPoint::ToolResult); + self.require_declared(&declaration)?; + self.hook_builder.add_post_tool_call(hook); + self.report.installed_hooks.push(declaration); + Ok(()) + } + + pub fn add_turn_end( + &mut self, + name: impl Into, + hook: impl Hook + 'static, + ) -> Result<(), FeatureInstallError> { + let declaration = HookDeclaration::new(name, FeatureHookPoint::TurnEnd); + self.require_declared(&declaration)?; + self.hook_builder.add_on_turn_end(hook); + self.report.installed_hooks.push(declaration); + Ok(()) + } +} + +/// Background task registrar for descriptor/report-only contributions. +pub struct BackgroundTaskRegistrar<'a> { + feature_id: &'a FeatureId, + declarations: &'a FeatureContributionDeclarations, + report: &'a mut FeatureInstallReport, +} + +impl BackgroundTaskRegistrar<'_> { + pub fn declare( + &mut self, + declaration: BackgroundTaskDeclaration, + ) -> Result<(), FeatureInstallError> { + if !self.declarations.contains_background_task(&declaration) { + return Err(reject_undeclared_contribution( + self.feature_id, + self.report, + FeatureContributionKind::BackgroundTask, + declaration.name, + )); + } + if !self + .report + .declared_background_tasks + .iter() + .any(|task| task.name == declaration.name) + { + self.report.declared_background_tasks.push(declaration); + } + Ok(()) + } +} + +/// Service registrar for descriptor/report-only provider metadata. +pub struct FeatureServiceRegistrar<'a> { + feature_id: &'a FeatureId, + declarations: &'a FeatureContributionDeclarations, + service_registry: &'a mut FeatureServiceRegistry, + report: &'a mut FeatureInstallReport, +} + +impl FeatureServiceRegistrar<'_> { + pub fn provide(&mut self, declaration: ServiceDeclaration) -> Result<(), FeatureInstallError> { + if !self.declarations.contains_provided_service(&declaration) { + return Err(reject_undeclared_contribution( + self.feature_id, + self.report, + FeatureContributionKind::Service, + declaration.id.to_string(), + )); + } + if self + .report + .provided_services + .iter() + .any(|service| service.id == declaration.id && service.version == declaration.version) + { + return Ok(()); + } + self.service_registry + .register_provider(self.feature_id.clone(), declaration.clone())?; + self.report.provided_services.push(declaration); + Ok(()) + } +} + +/// Install-time context provided to a feature module. +pub struct FeatureInstallContext<'a> { + feature_id: &'a FeatureId, + declarations: &'a FeatureContributionDeclarations, + grants: &'a AuthorityGrantSet, + pending_tools: &'a mut Vec, + installed_tool_names: &'a mut HashMap, + hook_builder: &'a mut HookRegistryBuilder, + service_registry: &'a mut FeatureServiceRegistry, + report: &'a mut FeatureInstallReport, +} + +impl FeatureInstallContext<'_> { + pub fn feature_id(&self) -> &FeatureId { + self.feature_id + } + + pub fn grants(&self) -> &AuthorityGrantSet { + self.grants + } + + pub fn tools(&mut self) -> ToolContributionRegistrar<'_> { + ToolContributionRegistrar { + feature_id: self.feature_id, + declarations: self.declarations, + grants: self.grants, + pending_tools: self.pending_tools, + installed_tool_names: self.installed_tool_names, + report: self.report, + } + } + + pub fn hooks(&mut self) -> HookContributionRegistrar<'_> { + HookContributionRegistrar { + feature_id: self.feature_id, + declarations: self.declarations, + hook_builder: self.hook_builder, + report: self.report, + } + } + + pub fn background_tasks(&mut self) -> BackgroundTaskRegistrar<'_> { + BackgroundTaskRegistrar { + feature_id: self.feature_id, + declarations: self.declarations, + report: self.report, + } + } + + pub fn services(&mut self) -> FeatureServiceRegistrar<'_> { + FeatureServiceRegistrar { + feature_id: self.feature_id, + declarations: self.declarations, + service_registry: self.service_registry, + report: self.report, + } + } + + pub fn notifications(&mut self) -> FeatureNotificationSink<'_> { + FeatureNotificationSink { + grants: self.grants, + report: self.report, + } + } + + pub fn alerts(&mut self) -> FeatureAlertSink<'_> { + FeatureAlertSink { + report: self.report, + } + } + + pub fn diagnostics(&mut self) -> FeatureDiagnosticSink<'_> { + FeatureDiagnosticSink { + report: self.report, + } + } +} + +/// Aggregate install output for a registry installation. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct FeatureRegistryInstallReport { + pub reports: Vec, + pub services: FeatureServiceRegistry, +} + +impl FeatureRegistryInstallReport { + pub fn installed_tool_names(&self) -> Vec { + self.reports + .iter() + .flat_map(|report| report.installed_tools.iter().cloned()) + .collect() + } +} + +/// Builder/installer for enabled feature modules. +#[derive(Default)] +pub struct FeatureRegistryBuilder { + modules: Vec>, +} + +impl FeatureRegistryBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn add_module(&mut self, module: M) -> &mut Self + where + M: FeatureModule + 'static, + { + self.modules.push(Arc::new(module)); + self + } + + pub fn with_module(mut self, module: M) -> Self + where + M: FeatureModule + 'static, + { + self.add_module(module); + self + } + + pub fn is_empty(&self) -> bool { + self.modules.is_empty() + } + + pub fn descriptors(&self) -> Vec { + self.modules + .iter() + .map(|module| module.descriptor()) + .collect() + } + + /// Install modules into the existing Worker tool path and hook builder. + pub(crate) fn install_into_worker( + self, + worker: &mut Worker, + hook_builder: &mut HookRegistryBuilder, + ) -> FeatureRegistryInstallReport { + let mut pending_tools = Vec::new(); + let report = self.install_into_pending(&mut pending_tools, hook_builder); + worker.register_tools(pending_tools); + report + } + + pub(crate) fn install_into_pending( + self, + pending_tools: &mut Vec, + hook_builder: &mut HookRegistryBuilder, + ) -> FeatureRegistryInstallReport { + let descriptors: Vec<_> = self + .modules + .iter() + .map(|module| module.descriptor()) + .collect(); + let mut service_registry = FeatureServiceRegistry::default(); + let mut reports = Vec::with_capacity(self.modules.len()); + let mut installed_tool_names = HashMap::new(); + let mut seen_features = HashSet::new(); + + for (module, descriptor) in self.modules.into_iter().zip(descriptors.into_iter()) { + let grants = AuthorityGrantSet::grant_all(&descriptor.requested_authorities); + let declarations = FeatureContributionDeclarations::from_descriptor(&descriptor); + let mut report = FeatureInstallReport::new(&descriptor, grants.clone()); + + if !seen_features.insert(descriptor.id.clone()) { + report.diagnostics.push(FeatureDiagnostic::error(format!( + "duplicate feature id: {}", + descriptor.id + ))); + report.mark_skipped( + FeatureContributionKind::Diagnostic, + descriptor.id.to_string(), + "duplicate feature id", + ); + reports.push(report); + continue; + } + + for authority in grants.denied() { + report.diagnostics.push(FeatureDiagnostic::warning(format!( + "authority denied: {:?}: {}", + authority.authority, authority.reason + ))); + } + + let mut required_service_failed = false; + for requirement in descriptor.requires_services.iter().cloned() { + if service_registry.provides(&requirement.id) { + report.resolved_service_requirements.push(requirement); + } else if requirement.required { + let reason = format!( + "required service requirement is not available: {}", + requirement.id + ); + report + .diagnostics + .push(FeatureDiagnostic::error(reason.clone())); + report.mark_skipped( + FeatureContributionKind::Service, + requirement.id.to_string(), + reason, + ); + required_service_failed = true; + } else { + report.diagnostics.push(FeatureDiagnostic::warning(format!( + "optional service requirement is not available: {}", + requirement.id + ))); + report.mark_skipped( + FeatureContributionKind::Service, + requirement.id.to_string(), + "optional service requirement is not available", + ); + } + } + if required_service_failed { + reports.push(report); + continue; + } + + for background_task in descriptor.background_tasks.iter().cloned() { + report.declared_background_tasks.push(background_task); + } + + for service in descriptor.provides_services.iter().cloned() { + match service_registry.register_provider(descriptor.id.clone(), service.clone()) { + Ok(()) => report.provided_services.push(service), + Err(error) => { + report + .diagnostics + .push(FeatureDiagnostic::error(error.to_string())); + report.mark_skipped( + FeatureContributionKind::Service, + service.id.to_string(), + error.to_string(), + ); + } + } + } + + let install_result = { + let mut context = FeatureInstallContext { + feature_id: &descriptor.id, + declarations: &declarations, + grants: &grants, + pending_tools, + installed_tool_names: &mut installed_tool_names, + hook_builder, + service_registry: &mut service_registry, + report: &mut report, + }; + module.install(&mut context) + }; + + match install_result { + Ok(()) => report.installed = true, + Err(error) => { + report + .diagnostics + .push(FeatureDiagnostic::error(error.to_string())); + } + } + reports.push(report); + } + + FeatureRegistryInstallReport { + reports, + services: service_registry, + } + } +} + +/// Feature installation errors. +#[derive(Debug, Error)] +pub enum FeatureInstallError { + #[error("invalid feature descriptor: {0}")] + InvalidDescriptor(String), + #[error( + "duplicate tool contribution `{tool}` from feature `{duplicate_feature}`; first registered by `{first_feature}`" + )] + DuplicateToolName { + tool: String, + first_feature: String, + duplicate_feature: String, + }, + #[error( + "tool contribution declared name `{declared}` does not match model-visible tool name `{model_visible}`" + )] + ToolNameMismatch { + declared: String, + model_visible: String, + }, + #[error( + "undeclared {kind:?} contribution `{name}` from feature `{feature}` is not present in the approved feature descriptor" + )] + UndeclaredContribution { + kind: FeatureContributionKind, + name: String, + feature: String, + }, + #[error( + "duplicate service declaration `{service}` from feature `{duplicate_feature}`; first provided by `{first_feature}`" + )] + DuplicateService { + service: String, + first_feature: String, + duplicate_feature: String, + }, + #[error("feature authority denied: {0}")] + AuthorityDenied(String), + #[error("feature install failed: {0}")] + Install(String), +} + +/// Builtin task tools feature used to prove existing builtin tool registration +/// through the feature registry without changing tool names, schemas, or +/// permission behavior. +pub mod builtin { + use super::*; + + pub fn task_feature(task_store: tools::TaskStore) -> impl FeatureModule { + TaskFeature { task_store } + } + + struct TaskFeature { + task_store: tools::TaskStore, + } + + impl FeatureModule for TaskFeature { + fn descriptor(&self) -> FeatureDescriptor { + FeatureDescriptor::builtin("task-tools", "Task tools") + .with_description("Session-lifetime task tracking builtin tools") + .with_tool(ToolDeclaration::new( + "TaskCreate", + "Create a session-lifetime user-visible task", + )) + .with_tool(ToolDeclaration::new( + "TaskUpdate", + "Update a session-lifetime user-visible task", + )) + .with_tool(ToolDeclaration::new( + "TaskGet", + "Get one session-lifetime user-visible task", + )) + .with_tool(ToolDeclaration::new( + "TaskList", + "List session-lifetime user-visible tasks", + )) + } + + fn install( + &self, + context: &mut FeatureInstallContext<'_>, + ) -> Result<(), FeatureInstallError> { + let names = ["TaskCreate", "TaskList", "TaskGet", "TaskUpdate"]; + for (name, definition) in names + .into_iter() + .zip(tools::task_tools(self.task_store.clone())) + { + context + .tools() + .register(ToolContribution::new(name, definition))?; + } + Ok(()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use futures::stream; + use llm_worker::llm_client::{ClientError, Request, ResponseStream}; + use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; + use serde_json::json; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[derive(Clone)] + struct DummyClient; + + #[async_trait] + impl LlmClient for DummyClient { + async fn stream(&self, _request: Request) -> Result { + Ok(Box::pin(stream::empty())) + } + + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } + } + + struct DummyTool; + + #[async_trait] + impl Tool for DummyTool { + async fn execute(&self, _input_json: &str) -> Result { + Ok(ToolOutput::from("ok".to_string())) + } + } + + fn dummy_tool(name: &'static str) -> ToolDefinition { + Arc::new(move || { + ( + ToolMeta::new(name) + .description("dummy") + .input_schema(json!({})), + Arc::new(DummyTool) as Arc, + ) + }) + } + + struct ToolFeature { + descriptor: FeatureDescriptor, + contribution_name: &'static str, + model_visible_name: &'static str, + } + + impl FeatureModule for ToolFeature { + fn descriptor(&self) -> FeatureDescriptor { + self.descriptor.clone() + } + + fn install( + &self, + context: &mut FeatureInstallContext<'_>, + ) -> Result<(), FeatureInstallError> { + context.tools().register(ToolContribution::new( + self.contribution_name, + dummy_tool(self.model_visible_name), + )) + } + } + + #[test] + fn descriptor_authorities_and_install_report_are_recorded() { + let descriptor = FeatureDescriptor::builtin("dummy", "Dummy") + .with_tool(ToolDeclaration::new("Dummy", "dummy tool")) + .with_background_task(BackgroundTaskDeclaration::descriptor_only( + "daily", + "descriptor-only background task", + )); + let mut hook_builder = HookRegistryBuilder::default(); + let mut pending_tools = Vec::new(); + let report = FeatureRegistryBuilder::new() + .with_module(ToolFeature { + descriptor, + contribution_name: "Dummy", + model_visible_name: "Dummy", + }) + .install_into_pending(&mut pending_tools, &mut hook_builder); + + assert_eq!(pending_tools.len(), 1); + assert_eq!(report.reports.len(), 1); + let feature_report = &report.reports[0]; + assert!(feature_report.installed); + assert_eq!(feature_report.installed_tools, vec!["Dummy"]); + assert_eq!(feature_report.declared_background_tasks[0].name, "daily"); + assert!(feature_report.granted_authorities.denied().is_empty()); + } + + #[test] + fn duplicate_tool_names_are_rejected() { + let descriptor_a = FeatureDescriptor::builtin("a", "A") + .with_tool(ToolDeclaration::new("Duplicate", "first tool")); + let descriptor_b = FeatureDescriptor::builtin("b", "B") + .with_tool(ToolDeclaration::new("Duplicate", "second tool")); + let mut hook_builder = HookRegistryBuilder::default(); + let mut pending_tools = Vec::new(); + let report = FeatureRegistryBuilder::new() + .with_module(ToolFeature { + descriptor: descriptor_a, + contribution_name: "Duplicate", + model_visible_name: "Duplicate", + }) + .with_module(ToolFeature { + descriptor: descriptor_b, + contribution_name: "Duplicate", + model_visible_name: "Duplicate", + }) + .install_into_pending(&mut pending_tools, &mut hook_builder); + + assert_eq!(pending_tools.len(), 1); + assert!(report.reports[0].installed); + assert!(!report.reports[1].installed); + assert!( + report.reports[1] + .diagnostics + .iter() + .any(|diagnostic| diagnostic.message.contains("duplicate tool contribution")) + ); + assert_eq!( + report.reports[1].skipped[0].kind, + FeatureContributionKind::Tool + ); + } + + #[test] + fn mismatched_tool_contribution_name_is_rejected_before_queueing() { + let descriptor = FeatureDescriptor::builtin("mismatch", "Mismatch") + .with_tool(ToolDeclaration::new("Actual", "actual model-visible tool")); + let mut hook_builder = HookRegistryBuilder::default(); + let mut pending_tools = Vec::new(); + let report = FeatureRegistryBuilder::new() + .with_module(ToolFeature { + descriptor, + contribution_name: "Declared", + model_visible_name: "Actual", + }) + .install_into_pending(&mut pending_tools, &mut hook_builder); + + assert!(pending_tools.is_empty()); + assert!(!report.reports[0].installed); + assert!(report.reports[0].diagnostics.iter().any(|diagnostic| { + diagnostic + .message + .contains("does not match model-visible tool name") + })); + assert_eq!(report.reports[0].skipped[0].name, "Actual"); + } + + #[test] + fn stateful_tool_definition_is_materialized_once_for_report_and_worker() { + struct StatefulToolFeature { + calls: Arc, + } + + impl FeatureModule for StatefulToolFeature { + fn descriptor(&self) -> FeatureDescriptor { + FeatureDescriptor::builtin("stateful-tool", "Stateful tool") + .with_tool(ToolDeclaration::new("First", "stateful tool")) + } + + fn install( + &self, + context: &mut FeatureInstallContext<'_>, + ) -> Result<(), FeatureInstallError> { + let calls = Arc::clone(&self.calls); + let definition: ToolDefinition = Arc::new(move || { + let call_index = calls.fetch_add(1, Ordering::SeqCst); + let name = if call_index == 0 { "First" } else { "Second" }; + ( + ToolMeta::new(name) + .description("stateful") + .input_schema(json!({})), + Arc::new(DummyTool) as Arc, + ) + }); + context + .tools() + .register(ToolContribution::new("First", definition)) + } + } + + let calls = Arc::new(AtomicUsize::new(0)); + let mut worker = Worker::new(DummyClient); + let mut hook_builder = HookRegistryBuilder::default(); + let report = FeatureRegistryBuilder::new() + .with_module(StatefulToolFeature { + calls: Arc::clone(&calls), + }) + .install_into_worker(&mut worker, &mut hook_builder); + + worker.tool_server_handle().flush_pending(); + let names: Vec<_> = worker + .tool_server_handle() + .tool_definitions_sorted() + .into_iter() + .map(|tool| tool.name) + .collect(); + + assert_eq!(report.installed_tool_names(), vec!["First"]); + assert_eq!(names, vec!["First"]); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + struct ServiceFeature { + descriptor: FeatureDescriptor, + } + + impl FeatureModule for ServiceFeature { + fn descriptor(&self) -> FeatureDescriptor { + self.descriptor.clone() + } + + fn install( + &self, + _context: &mut FeatureInstallContext<'_>, + ) -> Result<(), FeatureInstallError> { + Ok(()) + } + } + + struct DummyPreToolHook; + + #[async_trait] + impl Hook for DummyPreToolHook { + async fn call( + &self, + _input: &crate::hook::ToolCallSummary, + ) -> crate::hook::HookPreToolAction { + crate::hook::HookPreToolAction::Continue + } + } + + struct HookFeature { + descriptor: FeatureDescriptor, + hook_name: &'static str, + } + + impl FeatureModule for HookFeature { + fn descriptor(&self) -> FeatureDescriptor { + self.descriptor.clone() + } + + fn install( + &self, + context: &mut FeatureInstallContext<'_>, + ) -> Result<(), FeatureInstallError> { + context + .hooks() + .add_pre_tool_call(self.hook_name, DummyPreToolHook) + } + } + + struct BackgroundFeature { + descriptor: FeatureDescriptor, + task_name: &'static str, + } + + impl FeatureModule for BackgroundFeature { + fn descriptor(&self) -> FeatureDescriptor { + self.descriptor.clone() + } + + fn install( + &self, + context: &mut FeatureInstallContext<'_>, + ) -> Result<(), FeatureInstallError> { + context + .background_tasks() + .declare(BackgroundTaskDeclaration::descriptor_only( + self.task_name, + "runtime background task", + )) + } + } + + struct ServiceProviderFeature { + descriptor: FeatureDescriptor, + service: ServiceId, + } + + impl FeatureModule for ServiceProviderFeature { + fn descriptor(&self) -> FeatureDescriptor { + self.descriptor.clone() + } + + fn install( + &self, + context: &mut FeatureInstallContext<'_>, + ) -> Result<(), FeatureInstallError> { + context.services().provide(ServiceDeclaration::new( + self.service.clone(), + "1", + "runtime service provider", + )) + } + } + + #[test] + fn undeclared_tool_contribution_is_rejected() { + let descriptor = FeatureDescriptor::builtin("undeclared-tool", "Undeclared tool"); + let mut hook_builder = HookRegistryBuilder::default(); + let mut pending_tools = Vec::new(); + let report = FeatureRegistryBuilder::new() + .with_module(ToolFeature { + descriptor, + contribution_name: "HiddenTool", + model_visible_name: "HiddenTool", + }) + .install_into_pending(&mut pending_tools, &mut hook_builder); + + assert!(pending_tools.is_empty()); + assert!(!report.reports[0].installed); + assert_eq!( + report.reports[0].skipped[0].kind, + FeatureContributionKind::Tool + ); + assert_eq!(report.reports[0].skipped[0].name, "HiddenTool"); + assert!(report.reports[0].diagnostics.iter().any(|diagnostic| { + diagnostic + .message + .contains("is not present in the approved feature descriptor") + })); + } + + #[test] + fn undeclared_hook_contribution_is_rejected() { + let descriptor = FeatureDescriptor::builtin("undeclared-hook", "Undeclared hook"); + let mut hook_builder = HookRegistryBuilder::default(); + let mut pending_tools = Vec::new(); + let report = FeatureRegistryBuilder::new() + .with_module(HookFeature { + descriptor, + hook_name: "hidden-hook", + }) + .install_into_pending(&mut pending_tools, &mut hook_builder); + + assert!(!report.reports[0].installed); + assert!(report.reports[0].installed_hooks.is_empty()); + assert_eq!( + report.reports[0].skipped[0].kind, + FeatureContributionKind::Hook + ); + assert!(report.reports[0].skipped[0].name.contains("hidden-hook")); + } + + #[test] + fn undeclared_background_task_contribution_is_rejected() { + let descriptor = + FeatureDescriptor::builtin("undeclared-background", "Undeclared background"); + let mut hook_builder = HookRegistryBuilder::default(); + let mut pending_tools = Vec::new(); + let report = FeatureRegistryBuilder::new() + .with_module(BackgroundFeature { + descriptor, + task_name: "hidden-task", + }) + .install_into_pending(&mut pending_tools, &mut hook_builder); + + assert!(!report.reports[0].installed); + assert!(report.reports[0].declared_background_tasks.is_empty()); + assert_eq!( + report.reports[0].skipped[0].kind, + FeatureContributionKind::BackgroundTask + ); + assert_eq!(report.reports[0].skipped[0].name, "hidden-task"); + } + + #[test] + fn undeclared_service_provider_contribution_is_rejected() { + let service = ServiceId::builtin("hidden-service"); + let descriptor = FeatureDescriptor::builtin("undeclared-service", "Undeclared service"); + let mut hook_builder = HookRegistryBuilder::default(); + let mut pending_tools = Vec::new(); + let report = FeatureRegistryBuilder::new() + .with_module(ServiceProviderFeature { + descriptor, + service: service.clone(), + }) + .install_into_pending(&mut pending_tools, &mut hook_builder); + + assert!(!report.reports[0].installed); + assert!(!report.services.provides(&service)); + assert!(report.reports[0].provided_services.is_empty()); + assert_eq!( + report.reports[0].skipped[0].kind, + FeatureContributionKind::Service + ); + assert_eq!(report.reports[0].skipped[0].name, service.to_string()); + } + + #[test] + fn service_requirements_resolve_against_prior_providers() { + let service = ServiceId::builtin("demo-service"); + let provider = FeatureDescriptor::builtin("provider", "Provider").with_provided_service( + ServiceDeclaration::new(service.clone(), "1", "demo service"), + ); + let consumer = FeatureDescriptor::builtin("consumer", "Consumer") + .with_service_requirement(ServiceRequirement::required(service.clone(), "needs demo")); + let missing_service = ServiceId::builtin("missing-service"); + let missing = FeatureDescriptor::builtin("missing", "Missing").with_service_requirement( + ServiceRequirement::required(missing_service, "needs missing"), + ); + let optional_service = ServiceId::builtin("optional-service"); + let optional = FeatureDescriptor::builtin("optional", "Optional").with_service_requirement( + ServiceRequirement::optional(optional_service, "nice to have"), + ); + let mut hook_builder = HookRegistryBuilder::default(); + let mut pending_tools = Vec::new(); + let report = FeatureRegistryBuilder::new() + .with_module(ServiceFeature { + descriptor: provider, + }) + .with_module(ServiceFeature { + descriptor: consumer, + }) + .with_module(ServiceFeature { + descriptor: missing, + }) + .with_module(ServiceFeature { + descriptor: optional, + }) + .install_into_pending(&mut pending_tools, &mut hook_builder); + + assert!(report.services.provides(&service)); + assert!(report.reports[1].installed); + assert_eq!( + report.reports[1].resolved_service_requirements[0].id, + service + ); + assert!(!report.reports[2].installed); + assert!( + report.reports[2] + .diagnostics + .iter() + .any(|diagnostic| diagnostic.message.contains("required service requirement")) + ); + assert!(report.reports[3].installed); + assert_eq!( + report.reports[3].skipped[0].kind, + FeatureContributionKind::Service + ); + } + + #[test] + fn background_task_declaration_is_not_sandbox_authority_gated() { + let descriptor = FeatureDescriptor::builtin("background", "Background") + .with_background_task(BackgroundTaskDeclaration::descriptor_only( + "declared-task", + "descriptor contribution", + )); + let mut hook_builder = HookRegistryBuilder::default(); + let mut pending_tools = Vec::new(); + let report = FeatureRegistryBuilder::new() + .with_module(ServiceFeature { descriptor }) + .install_into_pending(&mut pending_tools, &mut hook_builder); + + assert!(report.reports[0].installed); + assert_eq!( + report.reports[0].declared_background_tasks[0].name, + "declared-task" + ); + assert!(report.reports[0].skipped.is_empty()); + } + + #[test] + fn service_provider_declaration_is_not_sandbox_authority_gated() { + let service = ServiceId::builtin("declared-service"); + let descriptor = FeatureDescriptor::builtin("service", "Service").with_provided_service( + ServiceDeclaration::new(service.clone(), "1", "descriptor contribution"), + ); + let mut hook_builder = HookRegistryBuilder::default(); + let mut pending_tools = Vec::new(); + let report = FeatureRegistryBuilder::new() + .with_module(ServiceFeature { descriptor }) + .install_into_pending(&mut pending_tools, &mut hook_builder); + + assert!(report.reports[0].installed); + assert!(report.services.provides(&service)); + assert_eq!(report.reports[0].provided_services[0].id, service); + assert!(report.reports[0].skipped.is_empty()); + } + + #[test] + fn builtin_task_feature_installs_through_worker_tool_path() { + let task_store = tools::TaskStore::new(); + let mut worker = Worker::new(DummyClient); + let mut hook_builder = HookRegistryBuilder::default(); + let report = FeatureRegistryBuilder::new() + .with_module(builtin::task_feature(task_store)) + .install_into_worker(&mut worker, &mut hook_builder); + + worker.tool_server_handle().flush_pending(); + let names: Vec<_> = worker + .tool_server_handle() + .tool_definitions_sorted() + .into_iter() + .map(|tool| tool.name) + .collect(); + + assert_eq!( + names, + vec!["TaskCreate", "TaskGet", "TaskList", "TaskUpdate"] + ); + assert_eq!( + report.installed_tool_names(), + vec!["TaskCreate", "TaskList", "TaskGet", "TaskUpdate"] + ); + assert!(report.reports[0].installed); + } +} diff --git a/crates/pod/src/lib.rs b/crates/pod/src/lib.rs index aec13a77..c607e835 100644 --- a/crates/pod/src/lib.rs +++ b/crates/pod/src/lib.rs @@ -2,6 +2,7 @@ pub mod compact; pub mod controller; pub mod discovery; pub mod entrypoint; +pub mod feature; pub mod fs_view; pub mod hook; pub mod ipc; diff --git a/crates/pod/src/pod.rs b/crates/pod/src/pod.rs index 3f5b2f85..8600b941 100644 --- a/crates/pod/src/pod.rs +++ b/crates/pod/src/pod.rs @@ -28,6 +28,7 @@ use manifest::{ use crate::compact::state::CompactState; use crate::compact::usage_tracker::UsageTracker; +use crate::feature::{FeatureRegistryBuilder, FeatureRegistryInstallReport}; use crate::hook::{ Hook, HookPreRequestAction, HookRegistryBuilder, OnAbort, OnPromptSubmit, OnTurnEnd, PostToolCall, PreLlmRequest, PreRequestInfo, PreToolCall, @@ -784,6 +785,15 @@ impl Pod { self.worker.as_mut().expect("worker taken during run") } + /// Install enabled feature modules into the Pod host surfaces. + pub fn install_features( + &mut self, + registry: FeatureRegistryBuilder, + ) -> FeatureRegistryInstallReport { + let worker = self.worker.as_mut().expect("worker taken during run"); + registry.install_into_worker(worker, &mut self.hook_builder) + } + /// Reference to the store. pub fn store(&self) -> &St { &self.store diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 535953aa..7f76e4d2 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -43,8 +43,9 @@ pub use tracker::Tracker; pub use web::{web_fetch_tool, web_search_tool}; pub use write::write_tool; -/// Register all builtin tools, wiring them to a shared `ScopedFs` -/// (Pod-process lifetime) and `Tracker` (Pod-process lifetime). +/// Register core builtin tools that do not require Pod-local task state, +/// wiring them to a shared `ScopedFs` (Pod-process lifetime) and `Tracker` +/// (Pod-process lifetime). /// /// All returned factories share the same tracker instance so that /// `Read` / `Write` / `Edit` see a consistent history across tool @@ -54,14 +55,13 @@ pub use write::write_tool; /// caller is responsible for adding that path to the readable scope /// (see [`manifest::Scope::with_extra_read`]) so the agent can `Read` /// the saved files. -pub fn builtin_tools( +pub fn core_builtin_tools( fs: ScopedFs, tracker: Tracker, - task_store: TaskStore, bash_output_dir: std::path::PathBuf, web_config: Option, ) -> Vec { - let mut defs = vec![ + vec![ read_tool(fs.clone(), tracker.clone()), write_tool(fs.clone(), tracker.clone()), edit_tool(fs.clone(), tracker), @@ -70,7 +70,19 @@ pub fn builtin_tools( bash_tool(fs, bash_output_dir), web_search_tool(web::WebTools::new(web_config.clone())), web_fetch_tool(web::WebTools::new(web_config)), - ]; + ] +} + +/// Register all builtin tools, including task tools, for callers that are not +/// using the Pod feature registry path. +pub fn builtin_tools( + fs: ScopedFs, + tracker: Tracker, + task_store: TaskStore, + bash_output_dir: std::path::PathBuf, + web_config: Option, +) -> Vec { + let mut defs = core_builtin_tools(fs, tracker, bash_output_dir, web_config); defs.extend(task_tools(task_store)); defs }