2224 lines
72 KiB
Rust
2224 lines
72 KiB
Rust
//! Feature contribution registry for Pod-hosted builtin/plugin modules.
|
|
//!
|
|
//! This module defines the Pod-side feature boundary used to collect
|
|
//! descriptor metadata, tool contributions, safe hook contributions, background
|
|
//! task declarations, service declarations, and protocol-backed provider
|
|
//! startup discovery before installing them into the existing Worker/HookRegistry
|
|
//! host surfaces.
|
|
//!
|
|
//! The implementation is intentionally host-mediated: tools are installed through
|
|
//! the normal Worker tool path, hooks are installed through
|
|
//! [`crate::hook::HookRegistryBuilder`], and provider output is represented as
|
|
//! ordinary feature reports/diagnostics instead of a separate authority layer.
|
|
|
|
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<String>) -> Result<Self, FeatureInstallError> {
|
|
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<str>) -> 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<FeatureId> for String {
|
|
fn from(value: FeatureId) -> Self {
|
|
value.0
|
|
}
|
|
}
|
|
|
|
/// Stable source-qualified identifier for a protocol-backed provider instance.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
|
pub struct ProviderId(String);
|
|
|
|
impl ProviderId {
|
|
pub fn new(value: impl Into<String>) -> Result<Self, FeatureInstallError> {
|
|
let value = value.into();
|
|
if value.trim().is_empty() {
|
|
return Err(FeatureInstallError::InvalidDescriptor(
|
|
"provider id must not be empty".into(),
|
|
));
|
|
}
|
|
Ok(Self(value))
|
|
}
|
|
|
|
pub fn builtin(slug: impl AsRef<str>) -> Self {
|
|
Self(format!("builtin:{}", slug.as_ref()))
|
|
}
|
|
|
|
pub fn as_str(&self) -> &str {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for ProviderId {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
f.write_str(&self.0)
|
|
}
|
|
}
|
|
|
|
/// Startup/lifecycle state for a protocol-backed provider.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ProtocolProviderLifecycleState {
|
|
Starting,
|
|
Ready,
|
|
Degraded,
|
|
Failed,
|
|
Stopped,
|
|
}
|
|
|
|
impl ProtocolProviderLifecycleState {
|
|
pub fn can_contribute(&self) -> bool {
|
|
matches!(self, Self::Ready | Self::Degraded)
|
|
}
|
|
}
|
|
|
|
/// Approved protocol-backed provider declaration in a feature descriptor.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct ProtocolProviderDeclaration {
|
|
pub id: ProviderId,
|
|
pub protocol: String,
|
|
pub display_name: String,
|
|
pub version: String,
|
|
pub description: String,
|
|
}
|
|
|
|
impl ProtocolProviderDeclaration {
|
|
pub fn new(
|
|
id: ProviderId,
|
|
protocol: impl Into<String>,
|
|
display_name: impl Into<String>,
|
|
version: impl Into<String>,
|
|
) -> Self {
|
|
Self {
|
|
id,
|
|
protocol: protocol.into(),
|
|
display_name: display_name.into(),
|
|
version: version.into(),
|
|
description: String::new(),
|
|
}
|
|
}
|
|
|
|
pub fn with_description(mut self, description: impl Into<String>) -> Self {
|
|
self.description = description.into();
|
|
self
|
|
}
|
|
}
|
|
|
|
/// Lifecycle diagnostic captured for a protocol-backed provider.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct ProtocolProviderLifecycleDiagnostic {
|
|
pub provider_id: ProviderId,
|
|
pub state: ProtocolProviderLifecycleState,
|
|
pub severity: FeatureDiagnosticSeverity,
|
|
pub message: String,
|
|
}
|
|
|
|
impl ProtocolProviderLifecycleDiagnostic {
|
|
pub fn new(
|
|
provider_id: ProviderId,
|
|
state: ProtocolProviderLifecycleState,
|
|
severity: FeatureDiagnosticSeverity,
|
|
message: impl Into<String>,
|
|
) -> Self {
|
|
Self {
|
|
provider_id,
|
|
state,
|
|
severity,
|
|
message: message.into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Startup-discovered contribution set returned by a protocol-backed provider.
|
|
///
|
|
/// Tool definitions are materialized exactly once when registered, then inserted
|
|
/// into the normal Worker tool path as stable metadata plus executable tool
|
|
/// handles for the remainder of the run. Execution still flows through the
|
|
/// Worker, permission, history, and bounded-result machinery.
|
|
pub struct ProtocolProviderContribution {
|
|
declaration: ProtocolProviderDeclaration,
|
|
state: ProtocolProviderLifecycleState,
|
|
tools: Vec<ToolContribution>,
|
|
services: Vec<ServiceDeclaration>,
|
|
background_tasks: Vec<BackgroundTaskDeclaration>,
|
|
diagnostics: Vec<FeatureDiagnostic>,
|
|
}
|
|
|
|
impl ProtocolProviderContribution {
|
|
pub fn new(
|
|
declaration: ProtocolProviderDeclaration,
|
|
state: ProtocolProviderLifecycleState,
|
|
) -> Self {
|
|
Self {
|
|
declaration,
|
|
state,
|
|
tools: Vec::new(),
|
|
services: Vec::new(),
|
|
background_tasks: Vec::new(),
|
|
diagnostics: Vec::new(),
|
|
}
|
|
}
|
|
|
|
pub fn ready(declaration: ProtocolProviderDeclaration) -> Self {
|
|
Self::new(declaration, ProtocolProviderLifecycleState::Ready)
|
|
}
|
|
|
|
pub fn failed(declaration: ProtocolProviderDeclaration, message: impl Into<String>) -> Self {
|
|
Self::new(declaration.clone(), ProtocolProviderLifecycleState::Failed).with_diagnostic(
|
|
FeatureDiagnostic::error(format!(
|
|
"provider {} failed during startup: {}",
|
|
declaration.id,
|
|
message.into()
|
|
)),
|
|
)
|
|
}
|
|
|
|
pub fn provider(&self) -> &ProtocolProviderDeclaration {
|
|
&self.declaration
|
|
}
|
|
|
|
pub fn state(&self) -> &ProtocolProviderLifecycleState {
|
|
&self.state
|
|
}
|
|
|
|
pub fn with_tool(mut self, tool: ToolContribution) -> Self {
|
|
self.tools.push(tool);
|
|
self
|
|
}
|
|
|
|
pub fn with_service(mut self, service: ServiceDeclaration) -> Self {
|
|
self.services.push(service);
|
|
self
|
|
}
|
|
|
|
pub fn with_background_task(mut self, task: BackgroundTaskDeclaration) -> Self {
|
|
self.background_tasks.push(task);
|
|
self
|
|
}
|
|
|
|
pub fn with_diagnostic(mut self, diagnostic: FeatureDiagnostic) -> Self {
|
|
self.diagnostics.push(diagnostic);
|
|
self
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
ProtocolProvider,
|
|
}
|
|
|
|
/// 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,
|
|
}
|
|
|
|
/// 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<String>, description: impl Into<String>) -> Self {
|
|
Self {
|
|
name: name.into(),
|
|
description: description.into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Executable tool contribution wrapper.
|
|
pub struct ToolContribution {
|
|
name: String,
|
|
definition: ToolDefinition,
|
|
}
|
|
|
|
impl ToolContribution {
|
|
pub fn new(name: impl Into<String>, definition: ToolDefinition) -> Self {
|
|
Self {
|
|
name: name.into(),
|
|
definition,
|
|
}
|
|
}
|
|
|
|
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<String>, 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<String>, description: impl Into<String>) -> 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<String>) -> Result<Self, FeatureInstallError> {
|
|
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<str>) -> 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<String>, description: impl Into<String>) -> Self {
|
|
Self {
|
|
id,
|
|
version: version.into(),
|
|
description: description.into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Feature service requirement used for contribution 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<String>) -> Self {
|
|
Self {
|
|
id,
|
|
version: ServiceVersionReq::any(),
|
|
required: true,
|
|
reason: reason.into(),
|
|
}
|
|
}
|
|
|
|
pub fn optional(id: ServiceId, reason: impl Into<String>) -> Self {
|
|
Self {
|
|
id,
|
|
version: ServiceVersionReq::any(),
|
|
required: false,
|
|
reason: reason.into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Contribution service registry skeleton used during feature installation.
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct FeatureServiceRegistry {
|
|
providers: HashMap<ServiceId, FeatureServiceProvider>,
|
|
}
|
|
|
|
impl FeatureServiceRegistry {
|
|
pub fn providers(&self) -> &HashMap<ServiceId, FeatureServiceProvider> {
|
|
&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 tools: Vec<ToolDeclaration>,
|
|
pub hooks: Vec<HookDeclaration>,
|
|
pub background_tasks: Vec<BackgroundTaskDeclaration>,
|
|
pub provides_services: Vec<ServiceDeclaration>,
|
|
pub requires_services: Vec<ServiceRequirement>,
|
|
pub protocol_providers: Vec<ProtocolProviderDeclaration>,
|
|
}
|
|
|
|
impl FeatureDescriptor {
|
|
pub fn builtin(id: impl AsRef<str>, display_name: impl Into<String>) -> Self {
|
|
Self {
|
|
id: FeatureId::builtin(id),
|
|
runtime: FeatureRuntimeKind::Builtin,
|
|
display_name: display_name.into(),
|
|
version: env!("CARGO_PKG_VERSION").into(),
|
|
description: String::new(),
|
|
tools: Vec::new(),
|
|
hooks: Vec::new(),
|
|
background_tasks: Vec::new(),
|
|
provides_services: Vec::new(),
|
|
requires_services: Vec::new(),
|
|
protocol_providers: Vec::new(),
|
|
}
|
|
}
|
|
|
|
pub fn with_description(mut self, description: impl Into<String>) -> Self {
|
|
self.description = description.into();
|
|
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
|
|
}
|
|
|
|
pub fn with_protocol_provider(mut self, provider: ProtocolProviderDeclaration) -> Self {
|
|
self.protocol_providers.push(provider);
|
|
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<String>) -> Self {
|
|
Self {
|
|
severity: FeatureDiagnosticSeverity::Info,
|
|
message: message.into(),
|
|
}
|
|
}
|
|
|
|
pub fn warning(message: impl Into<String>) -> Self {
|
|
Self {
|
|
severity: FeatureDiagnosticSeverity::Warning,
|
|
message: message.into(),
|
|
}
|
|
}
|
|
|
|
pub fn error(message: impl Into<String>) -> 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,
|
|
ProtocolProvider,
|
|
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 installed_tools: Vec<String>,
|
|
pub installed_hooks: Vec<HookDeclaration>,
|
|
pub declared_background_tasks: Vec<BackgroundTaskDeclaration>,
|
|
pub provided_services: Vec<ServiceDeclaration>,
|
|
pub resolved_service_requirements: Vec<ServiceRequirement>,
|
|
pub protocol_providers: Vec<ProtocolProviderLifecycleDiagnostic>,
|
|
pub skipped: Vec<SkippedContribution>,
|
|
pub diagnostics: Vec<FeatureDiagnostic>,
|
|
}
|
|
|
|
impl FeatureInstallReport {
|
|
fn new(descriptor: &FeatureDescriptor) -> Self {
|
|
Self {
|
|
feature_id: descriptor.id.clone(),
|
|
runtime: descriptor.runtime.clone(),
|
|
installed: false,
|
|
installed_tools: Vec::new(),
|
|
installed_hooks: Vec::new(),
|
|
declared_background_tasks: Vec::new(),
|
|
provided_services: Vec::new(),
|
|
resolved_service_requirements: Vec::new(),
|
|
protocol_providers: Vec::new(),
|
|
skipped: Vec::new(),
|
|
diagnostics: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn mark_skipped(
|
|
&mut self,
|
|
kind: FeatureContributionKind,
|
|
name: impl Into<String>,
|
|
reason: impl Into<String>,
|
|
) {
|
|
self.skipped.push(SkippedContribution {
|
|
kind,
|
|
name: name.into(),
|
|
reason: reason.into(),
|
|
});
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct FeatureContributionDeclarations {
|
|
tools: HashSet<String>,
|
|
hooks: HashSet<(String, FeatureHookPoint)>,
|
|
background_tasks: HashSet<String>,
|
|
provided_services: HashSet<(ServiceId, String)>,
|
|
protocol_providers: HashSet<ProviderId>,
|
|
}
|
|
|
|
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(),
|
|
protocol_providers: descriptor
|
|
.protocol_providers
|
|
.iter()
|
|
.map(|provider| provider.id.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 contains_protocol_provider(&self, declaration: &ProtocolProviderDeclaration) -> bool {
|
|
self.protocol_providers.contains(&declaration.id)
|
|
}
|
|
}
|
|
|
|
fn reject_undeclared_contribution(
|
|
feature_id: &FeatureId,
|
|
report: &mut FeatureInstallReport,
|
|
kind: FeatureContributionKind,
|
|
name: impl Into<String>,
|
|
) -> 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
|
|
}
|
|
|
|
/// Model-visible durable notification sink skeleton. The first slice exposes
|
|
/// the boundary without implementing a new event channel.
|
|
pub struct FeatureNotificationSink<'a> {
|
|
report: &'a mut FeatureInstallReport,
|
|
}
|
|
|
|
impl FeatureNotificationSink<'_> {
|
|
pub fn notify_model(&mut self, message: impl Into<String>) -> Result<(), FeatureInstallError> {
|
|
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<String>) {
|
|
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<String>) {
|
|
self.push(FeatureDiagnostic::info(message));
|
|
}
|
|
|
|
pub fn warning(&mut self, message: impl Into<String>) {
|
|
self.push(FeatureDiagnostic::warning(message));
|
|
}
|
|
|
|
pub fn error(&mut self, message: impl Into<String>) {
|
|
self.push(FeatureDiagnostic::error(message));
|
|
}
|
|
}
|
|
|
|
fn register_tool_contribution(
|
|
feature_id: &FeatureId,
|
|
report: &mut FeatureInstallReport,
|
|
pending_tools: &mut Vec<ToolDefinition>,
|
|
installed_tool_names: &mut HashMap<String, FeatureId>,
|
|
contribution: ToolContribution,
|
|
require_declared: impl FnOnce(&str) -> bool,
|
|
) -> 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(),
|
|
};
|
|
report.mark_skipped(
|
|
FeatureContributionKind::Tool,
|
|
model_visible_name,
|
|
error.to_string(),
|
|
);
|
|
return Err(error);
|
|
}
|
|
|
|
if !require_declared(&model_visible_name) {
|
|
return Err(reject_undeclared_contribution(
|
|
feature_id,
|
|
report,
|
|
FeatureContributionKind::Tool,
|
|
model_visible_name,
|
|
));
|
|
}
|
|
|
|
if let Some(first) = installed_tool_names.get(&model_visible_name) {
|
|
let error = FeatureInstallError::DuplicateToolName {
|
|
tool: model_visible_name.clone(),
|
|
first_feature: first.to_string(),
|
|
duplicate_feature: feature_id.to_string(),
|
|
};
|
|
report.mark_skipped(
|
|
FeatureContributionKind::Tool,
|
|
model_visible_name,
|
|
error.to_string(),
|
|
);
|
|
return Err(error);
|
|
}
|
|
|
|
installed_tool_names.insert(model_visible_name.clone(), feature_id.clone());
|
|
report.installed_tools.push(model_visible_name);
|
|
pending_tools.push(Arc::new(move || (tool_meta.clone(), Arc::clone(&tool))));
|
|
Ok(())
|
|
}
|
|
|
|
/// Tool contribution registrar exposed inside [`FeatureInstallContext`].
|
|
pub struct ToolContributionRegistrar<'a> {
|
|
feature_id: &'a FeatureId,
|
|
declarations: &'a FeatureContributionDeclarations,
|
|
pending_tools: &'a mut Vec<ToolDefinition>,
|
|
installed_tool_names: &'a mut HashMap<String, FeatureId>,
|
|
report: &'a mut FeatureInstallReport,
|
|
}
|
|
|
|
impl ToolContributionRegistrar<'_> {
|
|
pub fn register(&mut self, contribution: ToolContribution) -> Result<(), FeatureInstallError> {
|
|
register_tool_contribution(
|
|
self.feature_id,
|
|
self.report,
|
|
self.pending_tools,
|
|
self.installed_tool_names,
|
|
contribution,
|
|
|model_visible_name| self.declarations.contains_tool(model_visible_name),
|
|
)
|
|
}
|
|
}
|
|
|
|
/// 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<String>,
|
|
hook: impl Hook<PreLlmRequest> + '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<String>,
|
|
hook: impl Hook<PreToolCall> + '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<String>,
|
|
hook: impl Hook<PostToolCall> + '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<String>,
|
|
hook: impl Hook<OnTurnEnd> + '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(())
|
|
}
|
|
}
|
|
|
|
/// Registrar for startup-discovered protocol-backed provider contributions.
|
|
pub struct ProtocolProviderRegistrar<'a> {
|
|
feature_id: &'a FeatureId,
|
|
declarations: &'a FeatureContributionDeclarations,
|
|
pending_tools: &'a mut Vec<ToolDefinition>,
|
|
installed_tool_names: &'a mut HashMap<String, FeatureId>,
|
|
service_registry: &'a mut FeatureServiceRegistry,
|
|
report: &'a mut FeatureInstallReport,
|
|
}
|
|
|
|
impl ProtocolProviderRegistrar<'_> {
|
|
pub fn register(
|
|
&mut self,
|
|
contribution: ProtocolProviderContribution,
|
|
) -> Result<(), FeatureInstallError> {
|
|
let ProtocolProviderContribution {
|
|
declaration,
|
|
state,
|
|
tools,
|
|
services,
|
|
background_tasks,
|
|
diagnostics,
|
|
} = contribution;
|
|
|
|
if !self.declarations.contains_protocol_provider(&declaration) {
|
|
return Err(reject_undeclared_contribution(
|
|
self.feature_id,
|
|
self.report,
|
|
FeatureContributionKind::ProtocolProvider,
|
|
declaration.id.to_string(),
|
|
));
|
|
}
|
|
|
|
if self
|
|
.report
|
|
.protocol_providers
|
|
.iter()
|
|
.any(|provider| provider.provider_id == declaration.id)
|
|
{
|
|
let reason = format!(
|
|
"duplicate protocol provider contribution: {}",
|
|
declaration.id
|
|
);
|
|
let error = FeatureInstallError::InvalidDescriptor(reason.clone());
|
|
self.report.mark_skipped(
|
|
FeatureContributionKind::ProtocolProvider,
|
|
declaration.id.to_string(),
|
|
reason,
|
|
);
|
|
return Err(error);
|
|
}
|
|
|
|
for diagnostic in diagnostics {
|
|
self.report.diagnostics.push(FeatureDiagnostic {
|
|
severity: diagnostic.severity.clone(),
|
|
message: format!("provider {}: {}", declaration.id, diagnostic.message),
|
|
});
|
|
}
|
|
|
|
self.report
|
|
.protocol_providers
|
|
.push(ProtocolProviderLifecycleDiagnostic::new(
|
|
declaration.id.clone(),
|
|
state.clone(),
|
|
if state.can_contribute() {
|
|
FeatureDiagnosticSeverity::Info
|
|
} else {
|
|
FeatureDiagnosticSeverity::Error
|
|
},
|
|
format!(
|
|
"protocol provider {} ({}) is {:?}",
|
|
declaration.display_name, declaration.protocol, state
|
|
),
|
|
));
|
|
|
|
if !state.can_contribute() {
|
|
let reason = format!("protocol provider is not available: {:?}", state);
|
|
for tool in tools {
|
|
self.report
|
|
.mark_skipped(FeatureContributionKind::Tool, tool.name, reason.clone());
|
|
}
|
|
for service in services {
|
|
self.report.mark_skipped(
|
|
FeatureContributionKind::Service,
|
|
service.id.to_string(),
|
|
reason.clone(),
|
|
);
|
|
}
|
|
for task in background_tasks {
|
|
self.report.mark_skipped(
|
|
FeatureContributionKind::BackgroundTask,
|
|
task.name,
|
|
reason.clone(),
|
|
);
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
for tool in tools {
|
|
register_tool_contribution(
|
|
self.feature_id,
|
|
self.report,
|
|
self.pending_tools,
|
|
self.installed_tool_names,
|
|
tool,
|
|
|_| true,
|
|
)?;
|
|
}
|
|
|
|
for service in services {
|
|
if !self
|
|
.report
|
|
.provided_services
|
|
.iter()
|
|
.any(|provided| provided.id == service.id && provided.version == service.version)
|
|
{
|
|
self.service_registry
|
|
.register_provider(self.feature_id.clone(), service.clone())?;
|
|
self.report.provided_services.push(service);
|
|
}
|
|
}
|
|
|
|
for task in background_tasks {
|
|
if !self
|
|
.report
|
|
.declared_background_tasks
|
|
.iter()
|
|
.any(|declared| declared.name == task.name)
|
|
{
|
|
self.report.declared_background_tasks.push(task);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Install-time context provided to a feature module.
|
|
pub struct FeatureInstallContext<'a> {
|
|
feature_id: &'a FeatureId,
|
|
declarations: &'a FeatureContributionDeclarations,
|
|
pending_tools: &'a mut Vec<ToolDefinition>,
|
|
installed_tool_names: &'a mut HashMap<String, FeatureId>,
|
|
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 tools(&mut self) -> ToolContributionRegistrar<'_> {
|
|
ToolContributionRegistrar {
|
|
feature_id: self.feature_id,
|
|
declarations: self.declarations,
|
|
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 protocol_providers(&mut self) -> ProtocolProviderRegistrar<'_> {
|
|
ProtocolProviderRegistrar {
|
|
feature_id: self.feature_id,
|
|
declarations: self.declarations,
|
|
pending_tools: self.pending_tools,
|
|
installed_tool_names: self.installed_tool_names,
|
|
service_registry: self.service_registry,
|
|
report: self.report,
|
|
}
|
|
}
|
|
|
|
pub fn notifications(&mut self) -> FeatureNotificationSink<'_> {
|
|
FeatureNotificationSink {
|
|
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<FeatureInstallReport>,
|
|
pub services: FeatureServiceRegistry,
|
|
}
|
|
|
|
impl FeatureRegistryInstallReport {
|
|
pub fn installed_tool_names(&self) -> Vec<String> {
|
|
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<Arc<dyn FeatureModule>>,
|
|
}
|
|
|
|
impl FeatureRegistryBuilder {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
pub fn add_module<M>(&mut self, module: M) -> &mut Self
|
|
where
|
|
M: FeatureModule + 'static,
|
|
{
|
|
self.modules.push(Arc::new(module));
|
|
self
|
|
}
|
|
|
|
pub fn with_module<M>(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<FeatureDescriptor> {
|
|
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<C: LlmClient>(
|
|
self,
|
|
worker: &mut Worker<C, Mutable>,
|
|
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<ToolDefinition>,
|
|
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 declarations = FeatureContributionDeclarations::from_descriptor(&descriptor);
|
|
let mut report = FeatureInstallReport::new(&descriptor);
|
|
|
|
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;
|
|
}
|
|
|
|
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,
|
|
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 install failed: {0}")]
|
|
Install(String),
|
|
}
|
|
|
|
pub mod builtin;
|
|
|
|
#[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<ResponseStream, ClientError> {
|
|
Ok(Box::pin(stream::empty()))
|
|
}
|
|
|
|
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
|
Box::new(self.clone())
|
|
}
|
|
}
|
|
|
|
struct DummyTool;
|
|
|
|
#[async_trait]
|
|
impl Tool for DummyTool {
|
|
async fn execute(
|
|
&self,
|
|
_input_json: &str,
|
|
_ctx: llm_worker::tool::ToolExecutionContext,
|
|
) -> Result<ToolOutput, ToolError> {
|
|
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<dyn Tool>,
|
|
)
|
|
})
|
|
}
|
|
|
|
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_contributions_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");
|
|
}
|
|
|
|
#[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");
|
|
}
|
|
|
|
struct ProviderFeature {
|
|
descriptor: FeatureDescriptor,
|
|
provider: ProtocolProviderDeclaration,
|
|
calls: Arc<AtomicUsize>,
|
|
state: ProtocolProviderLifecycleState,
|
|
}
|
|
|
|
impl FeatureModule for ProviderFeature {
|
|
fn descriptor(&self) -> FeatureDescriptor {
|
|
self.descriptor.clone()
|
|
}
|
|
|
|
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 {
|
|
"DynamicTool"
|
|
} else {
|
|
"ChangedDynamicTool"
|
|
};
|
|
(
|
|
ToolMeta::new(name)
|
|
.description("startup-discovered")
|
|
.input_schema(json!({ "type": "object" })),
|
|
Arc::new(DummyTool) as Arc<dyn Tool>,
|
|
)
|
|
});
|
|
let contribution =
|
|
ProtocolProviderContribution::new(self.provider.clone(), self.state.clone())
|
|
.with_tool(ToolContribution::new("DynamicTool", definition))
|
|
.with_service(ServiceDeclaration::new(
|
|
ServiceId::builtin("dynamic-service"),
|
|
"1.0.0",
|
|
"startup-discovered service",
|
|
))
|
|
.with_background_task(BackgroundTaskDeclaration::descriptor_only(
|
|
"provider-poller",
|
|
"provider lifecycle poller",
|
|
))
|
|
.with_diagnostic(FeatureDiagnostic::info("startup discovery completed"));
|
|
context.protocol_providers().register(contribution)
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn protocol_provider_registers_startup_discovered_contributions_through_worker_path() {
|
|
let provider = ProtocolProviderDeclaration::new(
|
|
ProviderId::builtin("dynamic-provider"),
|
|
"test-protocol",
|
|
"Dynamic provider",
|
|
"1",
|
|
);
|
|
let descriptor = FeatureDescriptor::builtin("provider-feature", "Provider feature")
|
|
.with_protocol_provider(provider.clone());
|
|
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(ProviderFeature {
|
|
descriptor,
|
|
provider,
|
|
calls: Arc::clone(&calls),
|
|
state: ProtocolProviderLifecycleState::Ready,
|
|
})
|
|
.install_into_worker(&mut worker, &mut hook_builder);
|
|
|
|
worker.tool_server_handle().flush_pending();
|
|
let tool_names: Vec<_> = worker
|
|
.tool_server_handle()
|
|
.tool_definitions_sorted()
|
|
.into_iter()
|
|
.map(|tool| tool.name)
|
|
.collect();
|
|
let feature_report = &report.reports[0];
|
|
|
|
assert!(feature_report.installed);
|
|
assert_eq!(feature_report.installed_tools, vec!["DynamicTool"]);
|
|
assert_eq!(tool_names, vec!["DynamicTool"]);
|
|
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
|
assert_eq!(feature_report.provided_services.len(), 1);
|
|
assert_eq!(
|
|
feature_report.provided_services[0].id,
|
|
ServiceId::builtin("dynamic-service")
|
|
);
|
|
assert_eq!(
|
|
feature_report.declared_background_tasks[0].name,
|
|
"provider-poller"
|
|
);
|
|
assert_eq!(feature_report.protocol_providers.len(), 1);
|
|
assert_eq!(
|
|
feature_report.protocol_providers[0].state,
|
|
ProtocolProviderLifecycleState::Ready
|
|
);
|
|
assert!(
|
|
feature_report
|
|
.diagnostics
|
|
.iter()
|
|
.any(|diagnostic| diagnostic.message.contains("startup discovery completed"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unavailable_protocol_provider_records_lifecycle_and_skips_discovered_contributions() {
|
|
let provider = ProtocolProviderDeclaration::new(
|
|
ProviderId::builtin("failed-provider"),
|
|
"test-protocol",
|
|
"Failed provider",
|
|
"1",
|
|
);
|
|
let descriptor = FeatureDescriptor::builtin("failed-provider-feature", "Failed provider")
|
|
.with_protocol_provider(provider.clone());
|
|
let calls = Arc::new(AtomicUsize::new(0));
|
|
let mut hook_builder = HookRegistryBuilder::default();
|
|
let mut pending_tools = Vec::new();
|
|
|
|
let report = FeatureRegistryBuilder::new()
|
|
.with_module(ProviderFeature {
|
|
descriptor,
|
|
provider,
|
|
calls: Arc::clone(&calls),
|
|
state: ProtocolProviderLifecycleState::Failed,
|
|
})
|
|
.install_into_pending(&mut pending_tools, &mut hook_builder);
|
|
|
|
let feature_report = &report.reports[0];
|
|
assert!(feature_report.installed);
|
|
assert!(pending_tools.is_empty());
|
|
assert_eq!(calls.load(Ordering::SeqCst), 0);
|
|
assert_eq!(
|
|
feature_report.protocol_providers[0].state,
|
|
ProtocolProviderLifecycleState::Failed
|
|
);
|
|
assert!(feature_report.provided_services.is_empty());
|
|
assert!(feature_report.declared_background_tasks.is_empty());
|
|
assert!(
|
|
feature_report
|
|
.skipped
|
|
.iter()
|
|
.any(|skipped| skipped.kind == FeatureContributionKind::Tool)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn undeclared_protocol_provider_is_rejected_before_registering_tools() {
|
|
let provider = ProtocolProviderDeclaration::new(
|
|
ProviderId::builtin("undeclared-provider"),
|
|
"test-protocol",
|
|
"Undeclared provider",
|
|
"1",
|
|
);
|
|
let descriptor = FeatureDescriptor::builtin("undeclared-provider-feature", "Undeclared");
|
|
let calls = Arc::new(AtomicUsize::new(0));
|
|
let mut hook_builder = HookRegistryBuilder::default();
|
|
let mut pending_tools = Vec::new();
|
|
|
|
let report = FeatureRegistryBuilder::new()
|
|
.with_module(ProviderFeature {
|
|
descriptor,
|
|
provider,
|
|
calls: Arc::clone(&calls),
|
|
state: ProtocolProviderLifecycleState::Ready,
|
|
})
|
|
.install_into_pending(&mut pending_tools, &mut hook_builder);
|
|
|
|
assert!(!report.reports[0].installed);
|
|
assert!(pending_tools.is_empty());
|
|
assert_eq!(calls.load(Ordering::SeqCst), 0);
|
|
assert_eq!(
|
|
report.reports[0].skipped[0].kind,
|
|
FeatureContributionKind::ProtocolProvider
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn stateful_tool_definition_is_materialized_once_for_report_and_worker() {
|
|
struct StatefulToolFeature {
|
|
calls: Arc<AtomicUsize>,
|
|
}
|
|
|
|
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<dyn Tool>,
|
|
)
|
|
});
|
|
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<PreToolCall> 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_descriptor_contribution() {
|
|
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_descriptor_contribution() {
|
|
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_internal_task_feature_descriptor_has_exact_tools_hooks() {
|
|
let descriptor = builtin::task_tools_feature().descriptor();
|
|
let tool_names: Vec<_> = descriptor
|
|
.tools
|
|
.iter()
|
|
.map(|tool| tool.name.as_str())
|
|
.collect();
|
|
|
|
let hook_points: Vec<_> = descriptor
|
|
.hooks
|
|
.iter()
|
|
.map(|hook| hook.point.clone())
|
|
.collect();
|
|
|
|
assert_eq!(descriptor.id.as_str(), "builtin:task-tools");
|
|
assert_eq!(descriptor.runtime, FeatureRuntimeKind::Builtin);
|
|
assert_eq!(
|
|
hook_points,
|
|
vec![FeatureHookPoint::PreRequest, FeatureHookPoint::PreToolCall]
|
|
);
|
|
assert!(descriptor.background_tasks.is_empty());
|
|
assert!(descriptor.provides_services.is_empty());
|
|
assert!(descriptor.requires_services.is_empty());
|
|
assert_eq!(
|
|
tool_names,
|
|
vec!["TaskCreate", "TaskUpdate", "TaskGet", "TaskList"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn builtin_internal_task_feature_installs_declared_tools() {
|
|
let mut hook_builder = HookRegistryBuilder::default();
|
|
let mut pending_tools = Vec::new();
|
|
let mut builder = FeatureRegistryBuilder::new();
|
|
builder.add_module(builtin::task_tools_feature());
|
|
let mut declared_names: Vec<_> = builder.descriptors()[0]
|
|
.tools
|
|
.iter()
|
|
.map(|tool| tool.name.clone())
|
|
.collect();
|
|
let report = builder.install_into_pending(&mut pending_tools, &mut hook_builder);
|
|
let pending_names: Vec<_> = pending_tools
|
|
.iter()
|
|
.map(|definition| definition().0.name)
|
|
.collect();
|
|
let installed_names = report.installed_tool_names();
|
|
let mut sorted_installed_names = installed_names.clone();
|
|
declared_names.sort();
|
|
sorted_installed_names.sort();
|
|
|
|
assert_eq!(report.reports.len(), 1);
|
|
assert!(report.reports[0].installed);
|
|
assert!(report.reports[0].skipped.is_empty());
|
|
assert!(report.reports[0].diagnostics.is_empty());
|
|
assert_eq!(report.reports[0].installed_hooks.len(), 2);
|
|
let hook_registry = hook_builder.build();
|
|
assert_eq!(hook_registry.pre_llm_request.len(), 1);
|
|
assert_eq!(hook_registry.pre_tool_call.len(), 1);
|
|
assert_eq!(declared_names, sorted_installed_names);
|
|
assert_eq!(
|
|
installed_names,
|
|
vec!["TaskCreate", "TaskList", "TaskGet", "TaskUpdate"]
|
|
);
|
|
assert_eq!(
|
|
pending_names,
|
|
vec!["TaskCreate", "TaskList", "TaskGet", "TaskUpdate"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn builtin_task_feature_installs_through_worker_tool_path() {
|
|
let mut worker = Worker::new(DummyClient);
|
|
let mut hook_builder = HookRegistryBuilder::default();
|
|
let report = FeatureRegistryBuilder::new()
|
|
.with_module(builtin::task_tools_feature())
|
|
.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);
|
|
}
|
|
}
|