merge: plugin tool surface registration
This commit is contained in:
commit
204d0d022f
|
|
@ -127,6 +127,31 @@ impl From<String> for ToolOutput {
|
||||||
// ToolMeta - Immutable Meta Information
|
// ToolMeta - Immutable Meta Information
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Origin metadata for a registered tool.
|
||||||
|
///
|
||||||
|
/// This metadata is intentionally not part of the provider-facing tool schema.
|
||||||
|
/// It lets host layers audit where a model-visible tool definition came from
|
||||||
|
/// while keeping execution and permission semantics in the normal Worker path.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ToolOrigin {
|
||||||
|
/// Origin kind, for example `plugin` or `builtin`.
|
||||||
|
pub kind: String,
|
||||||
|
/// Package-local plugin id.
|
||||||
|
pub plugin_id: String,
|
||||||
|
/// Source-qualified plugin/package reference when `kind == "plugin"`.
|
||||||
|
pub plugin_ref: String,
|
||||||
|
/// Plugin source such as `user`, `project`, or `builtin`.
|
||||||
|
pub source: String,
|
||||||
|
/// Resolved package digest.
|
||||||
|
pub digest: String,
|
||||||
|
/// Resolved package version.
|
||||||
|
pub package_version: String,
|
||||||
|
/// Plugin API/schema version declared by the package.
|
||||||
|
pub package_api_version: u32,
|
||||||
|
/// Surface that contributed this tool. Plugin tools use `tool`.
|
||||||
|
pub surface: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// Tool meta information (fixed at registration, immutable)
|
/// Tool meta information (fixed at registration, immutable)
|
||||||
///
|
///
|
||||||
/// Generated from `ToolDefinition` factory and does not change after registration with Worker.
|
/// Generated from `ToolDefinition` factory and does not change after registration with Worker.
|
||||||
|
|
@ -139,6 +164,8 @@ pub struct ToolMeta {
|
||||||
pub description: String,
|
pub description: String,
|
||||||
/// JSON Schema for arguments
|
/// JSON Schema for arguments
|
||||||
pub input_schema: Value,
|
pub input_schema: Value,
|
||||||
|
/// Optional host-side origin metadata. This is not exposed to the LLM.
|
||||||
|
pub origin: Option<ToolOrigin>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToolMeta {
|
impl ToolMeta {
|
||||||
|
|
@ -148,6 +175,7 @@ impl ToolMeta {
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
description: String::new(),
|
description: String::new(),
|
||||||
input_schema: Value::Object(Default::default()),
|
input_schema: Value::Object(Default::default()),
|
||||||
|
origin: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -162,6 +190,12 @@ impl ToolMeta {
|
||||||
self.input_schema = schema;
|
self.input_schema = schema;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set host-side origin metadata.
|
||||||
|
pub fn origin(mut self, origin: ToolOrigin) -> Self {
|
||||||
|
self.origin = Some(origin);
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,8 @@ pub struct FeatureConfigPartial {
|
||||||
pub ticket: Option<TicketFeatureConfigPartial>,
|
pub ticket: Option<TicketFeatureConfigPartial>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ticket_orchestration: Option<FeatureFlagConfigPartial>,
|
pub ticket_orchestration: Option<FeatureFlagConfigPartial>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub plugins: Option<FeatureFlagConfigPartial>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FeatureConfigPartial {
|
impl FeatureConfigPartial {
|
||||||
|
|
@ -99,6 +101,7 @@ impl FeatureConfigPartial {
|
||||||
other.ticket_orchestration,
|
other.ticket_orchestration,
|
||||||
FeatureFlagConfigPartial::merge,
|
FeatureFlagConfigPartial::merge,
|
||||||
),
|
),
|
||||||
|
plugins: merge_option(self.plugins, other.plugins, FeatureFlagConfigPartial::merge),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -152,6 +155,10 @@ impl From<FeatureConfigPartial> for FeatureConfig {
|
||||||
.ticket_orchestration
|
.ticket_orchestration
|
||||||
.map(FeatureFlagConfig::from)
|
.map(FeatureFlagConfig::from)
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
|
plugins: value
|
||||||
|
.plugins
|
||||||
|
.map(FeatureFlagConfig::from)
|
||||||
|
.unwrap_or_default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -199,6 +206,7 @@ impl From<FeatureConfig> for FeatureConfigPartial {
|
||||||
pods: Some(value.pods.into()),
|
pods: Some(value.pods.into()),
|
||||||
ticket: Some(value.ticket.into()),
|
ticket: Some(value.ticket.into()),
|
||||||
ticket_orchestration: Some(value.ticket_orchestration.into()),
|
ticket_orchestration: Some(value.ticket_orchestration.into()),
|
||||||
|
plugins: Some(value.plugins.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,8 @@ pub struct FeatureConfig {
|
||||||
pub ticket: TicketFeatureConfig,
|
pub ticket: TicketFeatureConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub ticket_orchestration: FeatureFlagConfig,
|
pub ticket_orchestration: FeatureFlagConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub plugins: FeatureFlagConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for FeatureConfig {
|
impl Default for FeatureConfig {
|
||||||
|
|
@ -118,6 +120,7 @@ impl Default for FeatureConfig {
|
||||||
pods: FeatureFlagConfig::disabled(),
|
pods: FeatureFlagConfig::disabled(),
|
||||||
ticket: TicketFeatureConfig::default(),
|
ticket: TicketFeatureConfig::default(),
|
||||||
ticket_orchestration: FeatureFlagConfig::disabled(),
|
ticket_orchestration: FeatureFlagConfig::disabled(),
|
||||||
|
plugins: FeatureFlagConfig::disabled(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -188,14 +188,19 @@ pub struct PluginPackageManifest {
|
||||||
pub runtime: Option<PluginRuntimeManifest>,
|
pub runtime: Option<PluginRuntimeManifest>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub hooks: Vec<PluginHookManifest>,
|
pub hooks: Vec<PluginHookManifest>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tools: Vec<PluginToolManifest>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PluginPackageManifest {
|
impl PluginPackageManifest {
|
||||||
fn declared_surfaces(&self) -> BTreeSet<PluginSurface> {
|
pub fn declared_surfaces(&self) -> BTreeSet<PluginSurface> {
|
||||||
let mut surfaces: BTreeSet<_> = self.surfaces.iter().copied().collect();
|
let mut surfaces: BTreeSet<_> = self.surfaces.iter().copied().collect();
|
||||||
if !self.hooks.is_empty() {
|
if !self.hooks.is_empty() {
|
||||||
surfaces.insert(PluginSurface::Hook);
|
surfaces.insert(PluginSurface::Hook);
|
||||||
}
|
}
|
||||||
|
if !self.tools.is_empty() {
|
||||||
|
surfaces.insert(PluginSurface::Tool);
|
||||||
|
}
|
||||||
if self.runtime.is_some() {
|
if self.runtime.is_some() {
|
||||||
surfaces.insert(PluginSurface::Wasm);
|
surfaces.insert(PluginSurface::Wasm);
|
||||||
}
|
}
|
||||||
|
|
@ -218,6 +223,14 @@ pub struct PluginHookManifest {
|
||||||
pub file: String,
|
pub file: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct PluginToolManifest {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub input_schema: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct PluginDiscoveryLimits {
|
pub struct PluginDiscoveryLimits {
|
||||||
pub max_packages_per_store: usize,
|
pub max_packages_per_store: usize,
|
||||||
|
|
@ -1653,6 +1666,29 @@ file = "hooks/summary.md"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn package_manifest_tool_surface_shape_is_accepted() {
|
||||||
|
let manifest: PluginPackageManifest = toml::from_str(
|
||||||
|
r#"
|
||||||
|
schema_version = 1
|
||||||
|
id = "example.tool"
|
||||||
|
name = "Example Tool"
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[[tools]]
|
||||||
|
name = "ExampleTool"
|
||||||
|
description = "Runs a package-defined tool."
|
||||||
|
input_schema = { type = "object", properties = { query = { type = "string" } }, required = ["query"], additionalProperties = false }
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(manifest.tools.len(), 1);
|
||||||
|
assert!(manifest.declared_surfaces().contains(&PluginSurface::Tool));
|
||||||
|
assert_eq!(manifest.tools[0].name, "ExampleTool");
|
||||||
|
assert_eq!(manifest.tools[0].input_schema["type"], "object");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn malformed_manifest_multibyte_diagnostic_is_bounded_and_redacted() {
|
fn malformed_manifest_multibyte_diagnostic_is_bounded_and_redacted() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -634,66 +634,74 @@ where
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let _feature_install_report = pod.install_features(feature_registry);
|
for module in crate::feature::plugin::plugin_tool_features_if_enabled(
|
||||||
|
feature_config.plugins.enabled,
|
||||||
let worker = pod.worker_mut();
|
&pod.manifest().plugins,
|
||||||
|
) {
|
||||||
// Memory tools require both explicit feature exposure and memory storage
|
feature_registry = feature_registry.with_module(module);
|
||||||
// configuration. This keeps resident-memory config separate from the
|
|
||||||
// model-visible Memory*/Knowledge* tool surface.
|
|
||||||
if feature_config.memory.enabled {
|
|
||||||
let mem = memory_config.as_ref().ok_or_else(|| {
|
|
||||||
std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidInput,
|
|
||||||
"[feature.memory].enabled = true requires a [memory] configuration section",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let layout = memory::WorkspaceLayout::resolve(mem, &workspace_root);
|
|
||||||
let query_cfg = memory::tool::QueryConfig::from(mem);
|
|
||||||
worker.register_tool(memory::tool::read_tool_with_usage(
|
|
||||||
layout.clone(),
|
|
||||||
session_id_for_usage,
|
|
||||||
));
|
|
||||||
worker.register_tool(memory::tool::write_tool(layout.clone()));
|
|
||||||
worker.register_tool(memory::tool::edit_tool(layout.clone()));
|
|
||||||
worker.register_tool(memory::tool::delete_tool(layout.clone()));
|
|
||||||
worker.register_tool(memory::tool::memory_query_tool(layout.clone(), query_cfg));
|
|
||||||
worker.register_tool(memory::tool::knowledge_query_tool(layout, query_cfg));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pod-orchestration tools (SpawnPod + the four comm tools) share
|
{
|
||||||
// the Pod-scoped `SpawnedPodRegistry` (also consumed by the main
|
let worker = pod.worker_mut();
|
||||||
// loop's `PodEvent` handler). Expose them only behind the explicit
|
|
||||||
// profile feature and require delegation authority up front so enabling
|
// Memory tools require both explicit feature exposure and memory storage
|
||||||
// the surface cannot imply broad child scope by accident.
|
// configuration. This keeps resident-memory config separate from the
|
||||||
if feature_config.pods.enabled {
|
// model-visible Memory*/Knowledge* tool surface.
|
||||||
if spawner_manifest.delegation_scope.allow.is_empty() {
|
if feature_config.memory.enabled {
|
||||||
return Err(std::io::Error::new(
|
let mem = memory_config.as_ref().ok_or_else(|| {
|
||||||
std::io::ErrorKind::InvalidInput,
|
std::io::Error::new(
|
||||||
"[feature.pods].enabled = true requires non-empty [[delegation_scope.allow]]",
|
std::io::ErrorKind::InvalidInput,
|
||||||
|
"[feature.memory].enabled = true requires a [memory] configuration section",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let layout = memory::WorkspaceLayout::resolve(mem, &workspace_root);
|
||||||
|
let query_cfg = memory::tool::QueryConfig::from(mem);
|
||||||
|
worker.register_tool(memory::tool::read_tool_with_usage(
|
||||||
|
layout.clone(),
|
||||||
|
session_id_for_usage,
|
||||||
));
|
));
|
||||||
|
worker.register_tool(memory::tool::write_tool(layout.clone()));
|
||||||
|
worker.register_tool(memory::tool::edit_tool(layout.clone()));
|
||||||
|
worker.register_tool(memory::tool::delete_tool(layout.clone()));
|
||||||
|
worker.register_tool(memory::tool::memory_query_tool(layout.clone(), query_cfg));
|
||||||
|
worker.register_tool(memory::tool::knowledge_query_tool(layout, query_cfg));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pod-orchestration tools (SpawnPod + the four comm tools) share
|
||||||
|
// the Pod-scoped `SpawnedPodRegistry` (also consumed by the main
|
||||||
|
// loop's `PodEvent` handler). Expose them only behind the explicit
|
||||||
|
// profile feature and require delegation authority up front so enabling
|
||||||
|
// the surface cannot imply broad child scope by accident.
|
||||||
|
if feature_config.pods.enabled {
|
||||||
|
if spawner_manifest.delegation_scope.allow.is_empty() {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidInput,
|
||||||
|
"[feature.pods].enabled = true requires non-empty [[delegation_scope.allow]]",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
worker.register_tool(spawn_pod_tool(
|
||||||
|
spawner_name.clone(),
|
||||||
|
spawner_socket,
|
||||||
|
runtime_base.clone(),
|
||||||
|
workspace_root.clone(),
|
||||||
|
cwd.clone(),
|
||||||
|
spawned_registry.clone(),
|
||||||
|
self_parent_socket,
|
||||||
|
spawner_manifest,
|
||||||
|
scope_handle,
|
||||||
|
prompts,
|
||||||
|
));
|
||||||
|
worker.register_tool(send_to_pod_tool(spawned_registry.clone()));
|
||||||
|
worker.register_tool(read_pod_output_tool(spawned_registry.clone()));
|
||||||
|
worker.register_tool(stop_pod_tool(spawned_registry.clone()));
|
||||||
|
let discovery =
|
||||||
|
PodDiscovery::new(pod_store, spawner_name, runtime_base, cwd, spawned_registry);
|
||||||
|
worker.register_tool(list_pods_tool(discovery.clone()));
|
||||||
|
worker.register_tool(restore_pod_tool(discovery.clone()));
|
||||||
|
worker.register_tool(send_to_peer_pod_tool(discovery));
|
||||||
}
|
}
|
||||||
worker.register_tool(spawn_pod_tool(
|
|
||||||
spawner_name.clone(),
|
|
||||||
spawner_socket,
|
|
||||||
runtime_base.clone(),
|
|
||||||
workspace_root.clone(),
|
|
||||||
cwd.clone(),
|
|
||||||
spawned_registry.clone(),
|
|
||||||
self_parent_socket,
|
|
||||||
spawner_manifest,
|
|
||||||
scope_handle,
|
|
||||||
prompts,
|
|
||||||
));
|
|
||||||
worker.register_tool(send_to_pod_tool(spawned_registry.clone()));
|
|
||||||
worker.register_tool(read_pod_output_tool(spawned_registry.clone()));
|
|
||||||
worker.register_tool(stop_pod_tool(spawned_registry.clone()));
|
|
||||||
let discovery =
|
|
||||||
PodDiscovery::new(pod_store, spawner_name, runtime_base, cwd, spawned_registry);
|
|
||||||
worker.register_tool(list_pods_tool(discovery.clone()));
|
|
||||||
worker.register_tool(restore_pod_tool(discovery.clone()));
|
|
||||||
worker.register_tool(send_to_peer_pod_tool(discovery));
|
|
||||||
}
|
}
|
||||||
|
let _feature_install_report = pod.install_features(feature_registry);
|
||||||
pod.attach_tracker(tracker);
|
pod.attach_tracker(tracker);
|
||||||
Ok(fs_for_view)
|
Ok(fs_for_view)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1290,15 +1290,36 @@ impl FeatureRegistryBuilder {
|
||||||
hook_builder: &mut HookRegistryBuilder,
|
hook_builder: &mut HookRegistryBuilder,
|
||||||
) -> FeatureRegistryInstallReport {
|
) -> FeatureRegistryInstallReport {
|
||||||
let mut pending_tools = Vec::new();
|
let mut pending_tools = Vec::new();
|
||||||
let report = self.install_into_pending(&mut pending_tools, hook_builder);
|
worker.tool_server_handle().flush_pending();
|
||||||
|
let registered_tool_names = worker
|
||||||
|
.tool_server_handle()
|
||||||
|
.tool_definitions_sorted()
|
||||||
|
.into_iter()
|
||||||
|
.map(|definition| (definition.name, FeatureId::builtin("preexisting-tool")))
|
||||||
|
.collect();
|
||||||
|
let report = self.install_into_pending_with_registered(
|
||||||
|
&mut pending_tools,
|
||||||
|
hook_builder,
|
||||||
|
registered_tool_names,
|
||||||
|
);
|
||||||
worker.register_tools(pending_tools);
|
worker.register_tools(pending_tools);
|
||||||
report
|
report
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub(crate) fn install_into_pending(
|
pub(crate) fn install_into_pending(
|
||||||
self,
|
self,
|
||||||
pending_tools: &mut Vec<ToolDefinition>,
|
pending_tools: &mut Vec<ToolDefinition>,
|
||||||
hook_builder: &mut HookRegistryBuilder,
|
hook_builder: &mut HookRegistryBuilder,
|
||||||
|
) -> FeatureRegistryInstallReport {
|
||||||
|
self.install_into_pending_with_registered(pending_tools, hook_builder, HashMap::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_into_pending_with_registered(
|
||||||
|
self,
|
||||||
|
pending_tools: &mut Vec<ToolDefinition>,
|
||||||
|
hook_builder: &mut HookRegistryBuilder,
|
||||||
|
mut installed_tool_names: HashMap<String, FeatureId>,
|
||||||
) -> FeatureRegistryInstallReport {
|
) -> FeatureRegistryInstallReport {
|
||||||
let descriptors: Vec<_> = self
|
let descriptors: Vec<_> = self
|
||||||
.modules
|
.modules
|
||||||
|
|
@ -1307,7 +1328,6 @@ impl FeatureRegistryBuilder {
|
||||||
.collect();
|
.collect();
|
||||||
let mut service_registry = FeatureServiceRegistry::default();
|
let mut service_registry = FeatureServiceRegistry::default();
|
||||||
let mut reports = Vec::with_capacity(self.modules.len());
|
let mut reports = Vec::with_capacity(self.modules.len());
|
||||||
let mut installed_tool_names = HashMap::new();
|
|
||||||
let mut seen_features = HashSet::new();
|
let mut seen_features = HashSet::new();
|
||||||
|
|
||||||
for (module, descriptor) in self.modules.into_iter().zip(descriptors.into_iter()) {
|
for (module, descriptor) in self.modules.into_iter().zip(descriptors.into_iter()) {
|
||||||
|
|
@ -1455,6 +1475,7 @@ pub enum FeatureInstallError {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod builtin;
|
pub mod builtin;
|
||||||
|
pub mod plugin;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|
|
||||||
671
crates/pod/src/feature/plugin.rs
Normal file
671
crates/pod/src/feature/plugin.rs
Normal file
|
|
@ -0,0 +1,671 @@
|
||||||
|
//! Plugin package contributions for model-visible Tool schemas.
|
||||||
|
//!
|
||||||
|
//! This module registers *enabled* plugin package tool surface definitions as
|
||||||
|
//! unavailable Tool stubs. It deliberately does not execute plugin code or grant
|
||||||
|
//! plugin permissions; the runtime/WASM executor belongs to a later boundary.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use llm_worker::tool::{
|
||||||
|
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOrigin, ToolOutput,
|
||||||
|
};
|
||||||
|
use manifest::plugin::{PluginConfig, PluginSurface, ResolvedPluginRecord};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
FeatureDescriptor, FeatureId, FeatureInstallContext, FeatureInstallError, FeatureModule,
|
||||||
|
FeatureRuntimeKind, ToolContribution, ToolDeclaration,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Build Feature modules for enabled plugin packages when the profile exposes
|
||||||
|
/// the plugin Tool surface feature.
|
||||||
|
pub fn plugin_tool_features_if_enabled(
|
||||||
|
feature_enabled: bool,
|
||||||
|
config: &PluginConfig,
|
||||||
|
) -> Vec<PluginToolFeature> {
|
||||||
|
if !feature_enabled {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
plugin_tool_features(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build Feature modules for enabled plugin packages that declare Tool surfaces.
|
||||||
|
pub fn plugin_tool_features(config: &PluginConfig) -> Vec<PluginToolFeature> {
|
||||||
|
config
|
||||||
|
.resolved
|
||||||
|
.iter()
|
||||||
|
.filter(|record| record.enabled_surfaces.contains(&PluginSurface::Tool))
|
||||||
|
.filter(|record| !record.manifest.tools.is_empty())
|
||||||
|
.cloned()
|
||||||
|
.map(PluginToolFeature::new)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct PluginToolFeature {
|
||||||
|
record: ResolvedPluginRecord,
|
||||||
|
feature_id: FeatureId,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PluginToolFeature {
|
||||||
|
pub fn new(record: ResolvedPluginRecord) -> Self {
|
||||||
|
let feature_id = FeatureId::new(format!("plugin:{}:tool", record.identity))
|
||||||
|
.expect("source-qualified plugin identity yields non-empty feature id");
|
||||||
|
Self { record, feature_id }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn origin(&self) -> ToolOrigin {
|
||||||
|
ToolOrigin {
|
||||||
|
kind: "plugin".into(),
|
||||||
|
plugin_id: self.record.manifest.id.clone(),
|
||||||
|
plugin_ref: self.record.identity.to_string(),
|
||||||
|
source: self.record.identity.source.to_string(),
|
||||||
|
digest: self.record.digest.clone(),
|
||||||
|
package_version: self.record.version.clone(),
|
||||||
|
package_api_version: self.record.manifest.schema_version,
|
||||||
|
surface: "tool".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FeatureModule for PluginToolFeature {
|
||||||
|
fn descriptor(&self) -> FeatureDescriptor {
|
||||||
|
let mut descriptor =
|
||||||
|
FeatureDescriptor {
|
||||||
|
id: self.feature_id.clone(),
|
||||||
|
runtime: FeatureRuntimeKind::ExternalPlugin,
|
||||||
|
display_name: self.record.manifest.name.clone(),
|
||||||
|
version: self.record.manifest.version.clone(),
|
||||||
|
description: self.record.manifest.description.clone().unwrap_or_else(|| {
|
||||||
|
format!("Plugin tool surface from {}", self.record.identity)
|
||||||
|
}),
|
||||||
|
tools: Vec::new(),
|
||||||
|
hooks: Vec::new(),
|
||||||
|
background_tasks: Vec::new(),
|
||||||
|
provides_services: Vec::new(),
|
||||||
|
requires_services: Vec::new(),
|
||||||
|
protocol_providers: Vec::new(),
|
||||||
|
};
|
||||||
|
for tool in &self.record.manifest.tools {
|
||||||
|
descriptor = descriptor.with_tool(ToolDeclaration::new(
|
||||||
|
tool.name.clone(),
|
||||||
|
tool.description.clone(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
descriptor
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||||
|
validate_declared_tool_names(&self.record)?;
|
||||||
|
let origin = self.origin();
|
||||||
|
for tool in &self.record.manifest.tools {
|
||||||
|
validate_tool_name(&tool.name).map_err(|reason| {
|
||||||
|
FeatureInstallError::Install(format!(
|
||||||
|
"plugin `{}` tool `{}` has invalid name: {reason}",
|
||||||
|
self.record.identity, tool.name
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
validate_input_schema(&tool.input_schema).map_err(|reason| {
|
||||||
|
FeatureInstallError::Install(format!(
|
||||||
|
"plugin `{}` tool `{}` has invalid input_schema: {reason}",
|
||||||
|
self.record.identity, tool.name
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
context.tools().register(ToolContribution::new(
|
||||||
|
tool.name.clone(),
|
||||||
|
plugin_runtime_missing_definition(
|
||||||
|
tool.name.clone(),
|
||||||
|
tool.description.clone(),
|
||||||
|
tool.input_schema.clone(),
|
||||||
|
origin.clone(),
|
||||||
|
),
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plugin_runtime_missing_definition(
|
||||||
|
name: String,
|
||||||
|
description: String,
|
||||||
|
input_schema: Value,
|
||||||
|
origin: ToolOrigin,
|
||||||
|
) -> ToolDefinition {
|
||||||
|
Arc::new(move || {
|
||||||
|
(
|
||||||
|
ToolMeta::new(name.clone())
|
||||||
|
.description(description.clone())
|
||||||
|
.input_schema(input_schema.clone())
|
||||||
|
.origin(origin.clone()),
|
||||||
|
Arc::new(PluginRuntimeMissingTool {
|
||||||
|
name: name.clone(),
|
||||||
|
origin: origin.clone(),
|
||||||
|
}) as Arc<dyn Tool>,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PluginRuntimeMissingTool {
|
||||||
|
name: String,
|
||||||
|
origin: ToolOrigin,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for PluginRuntimeMissingTool {
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
_input_json: &str,
|
||||||
|
_ctx: ToolExecutionContext,
|
||||||
|
) -> Result<ToolOutput, ToolError> {
|
||||||
|
Err(ToolError::ExecutionFailed(format!(
|
||||||
|
"plugin tool runtime missing/unavailable for `{}` from `{}` (digest {}, package {} api {})",
|
||||||
|
self.name,
|
||||||
|
self.origin.plugin_ref,
|
||||||
|
self.origin.digest,
|
||||||
|
self.origin.package_version,
|
||||||
|
self.origin.package_api_version
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_declared_tool_names(record: &ResolvedPluginRecord) -> Result<(), FeatureInstallError> {
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
for tool in &record.manifest.tools {
|
||||||
|
if !seen.insert(tool.name.as_str()) {
|
||||||
|
return Err(FeatureInstallError::DuplicateToolName {
|
||||||
|
tool: tool.name.clone(),
|
||||||
|
first_feature: format!("{} (same plugin package)", record.identity),
|
||||||
|
duplicate_feature: record.identity.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_tool_name(name: &str) -> Result<(), &'static str> {
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err("name must not be empty");
|
||||||
|
}
|
||||||
|
if name.len() > 128 {
|
||||||
|
return Err("name is longer than 128 bytes");
|
||||||
|
}
|
||||||
|
if name.chars().any(|c| c.is_control() || c.is_whitespace()) {
|
||||||
|
return Err("name must not contain whitespace or control characters");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
enum SupportedSchemaType {
|
||||||
|
Object,
|
||||||
|
Array,
|
||||||
|
String,
|
||||||
|
Number,
|
||||||
|
Integer,
|
||||||
|
Boolean,
|
||||||
|
Null,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SupportedSchemaType {
|
||||||
|
fn parse(value: &str) -> Option<Self> {
|
||||||
|
match value {
|
||||||
|
"object" => Some(Self::Object),
|
||||||
|
"array" => Some(Self::Array),
|
||||||
|
"string" => Some(Self::String),
|
||||||
|
"number" => Some(Self::Number),
|
||||||
|
"integer" => Some(Self::Integer),
|
||||||
|
"boolean" => Some(Self::Boolean),
|
||||||
|
"null" => Some(Self::Null),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_input_schema(schema: &Value) -> Result<(), String> {
|
||||||
|
let ty = validate_schema_node(schema, "$", true)?;
|
||||||
|
if ty != SupportedSchemaType::Object {
|
||||||
|
return Err("root schema type must be `object`".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_schema_node(
|
||||||
|
schema: &Value,
|
||||||
|
path: &str,
|
||||||
|
root: bool,
|
||||||
|
) -> Result<SupportedSchemaType, String> {
|
||||||
|
let Value::Object(map) = schema else {
|
||||||
|
return Err(format!("{path}: schema node must be a JSON object"));
|
||||||
|
};
|
||||||
|
|
||||||
|
for key in map.keys() {
|
||||||
|
if !is_supported_schema_keyword(key) {
|
||||||
|
return Err(format!("{path}: unsupported schema keyword `{key}`"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let ty = match map.get("type") {
|
||||||
|
Some(Value::String(value)) => SupportedSchemaType::parse(value)
|
||||||
|
.ok_or_else(|| format!("{path}: unsupported schema type `{value}`"))?,
|
||||||
|
Some(_) => return Err(format!("{path}: type must be a string")),
|
||||||
|
None if root => return Err("root schema must declare type = `object`".into()),
|
||||||
|
None => return Err(format!("{path}: schema node must declare type")),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(title) = map.get("title") {
|
||||||
|
if !title.is_string() {
|
||||||
|
return Err(format!("{path}: title must be a string"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(description) = map.get("description") {
|
||||||
|
if !description.is_string() {
|
||||||
|
return Err(format!("{path}: description must be a string"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let properties = map.get("properties");
|
||||||
|
if let Some(properties) = properties {
|
||||||
|
if ty != SupportedSchemaType::Object {
|
||||||
|
return Err(format!(
|
||||||
|
"{path}: properties is only supported for object schemas"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let Some(properties) = properties.as_object() else {
|
||||||
|
return Err(format!("{path}: properties must be a JSON object"));
|
||||||
|
};
|
||||||
|
for (name, child_schema) in properties {
|
||||||
|
validate_schema_node(child_schema, &format!("{path}.properties.{name}"), false)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(required) = map.get("required") {
|
||||||
|
if ty != SupportedSchemaType::Object {
|
||||||
|
return Err(format!(
|
||||||
|
"{path}: required is only supported for object schemas"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let Some(required) = required.as_array() else {
|
||||||
|
return Err(format!("{path}: required must be an array"));
|
||||||
|
};
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
for entry in required {
|
||||||
|
let Some(name) = entry.as_str() else {
|
||||||
|
return Err(format!("{path}: required entries must be strings"));
|
||||||
|
};
|
||||||
|
if !seen.insert(name) {
|
||||||
|
return Err(format!("{path}: required entries must be unique"));
|
||||||
|
}
|
||||||
|
if let Some(properties) = properties.and_then(Value::as_object) {
|
||||||
|
if !properties.contains_key(name) {
|
||||||
|
return Err(format!(
|
||||||
|
"{path}: required entry `{name}` is not declared in properties"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(additional) = map.get("additionalProperties") {
|
||||||
|
if ty != SupportedSchemaType::Object {
|
||||||
|
return Err(format!(
|
||||||
|
"{path}: additionalProperties is only supported for object schemas"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
match additional {
|
||||||
|
Value::Bool(_) => {}
|
||||||
|
Value::Object(_) => {
|
||||||
|
validate_schema_node(additional, &format!("{path}.additionalProperties"), false)?;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(format!(
|
||||||
|
"{path}: additionalProperties must be boolean or schema object"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(items) = map.get("items") {
|
||||||
|
if ty != SupportedSchemaType::Array {
|
||||||
|
return Err(format!("{path}: items is only supported for array schemas"));
|
||||||
|
}
|
||||||
|
validate_schema_node(items, &format!("{path}.items"), false)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(enum_values) = map.get("enum") {
|
||||||
|
let Some(enum_values) = enum_values.as_array() else {
|
||||||
|
return Err(format!("{path}: enum must be an array"));
|
||||||
|
};
|
||||||
|
if enum_values.is_empty() {
|
||||||
|
return Err(format!("{path}: enum must not be empty"));
|
||||||
|
}
|
||||||
|
for (index, value) in enum_values.iter().enumerate() {
|
||||||
|
if enum_values
|
||||||
|
.iter()
|
||||||
|
.skip(index + 1)
|
||||||
|
.any(|other| other == value)
|
||||||
|
{
|
||||||
|
return Err(format!("{path}: enum entries must be unique"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ty)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_supported_schema_keyword(key: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
key,
|
||||||
|
"type"
|
||||||
|
| "title"
|
||||||
|
| "description"
|
||||||
|
| "properties"
|
||||||
|
| "required"
|
||||||
|
| "additionalProperties"
|
||||||
|
| "items"
|
||||||
|
| "enum"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use manifest::plugin::{PluginPackageManifest, SourceQualifiedPluginId};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn tool(name: &str) -> manifest::plugin::PluginToolManifest {
|
||||||
|
manifest::plugin::PluginToolManifest {
|
||||||
|
name: name.into(),
|
||||||
|
description: format!("{name} tool"),
|
||||||
|
input_schema: json!({"type":"object","properties":{},"additionalProperties":false}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record(tools: Vec<manifest::plugin::PluginToolManifest>) -> ResolvedPluginRecord {
|
||||||
|
record_with_identity("project:example", tools)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_with_identity(
|
||||||
|
identity: &str,
|
||||||
|
tools: Vec<manifest::plugin::PluginToolManifest>,
|
||||||
|
) -> ResolvedPluginRecord {
|
||||||
|
let parsed_identity = SourceQualifiedPluginId::parse(identity).unwrap();
|
||||||
|
ResolvedPluginRecord {
|
||||||
|
identity: parsed_identity.clone(),
|
||||||
|
source: parsed_identity.source,
|
||||||
|
package_path: std::path::PathBuf::from("/tmp/example.zip"),
|
||||||
|
package_label: "example.zip".into(),
|
||||||
|
digest: "sha256:abc".into(),
|
||||||
|
version: "0.1.0".into(),
|
||||||
|
manifest: PluginPackageManifest {
|
||||||
|
schema_version: 1,
|
||||||
|
id: "example".into(),
|
||||||
|
name: "Example".into(),
|
||||||
|
version: "0.1.0".into(),
|
||||||
|
description: None,
|
||||||
|
surfaces: vec![PluginSurface::Tool],
|
||||||
|
runtime: None,
|
||||||
|
hooks: Vec::new(),
|
||||||
|
tools,
|
||||||
|
},
|
||||||
|
enabled_surfaces: vec![PluginSurface::Tool],
|
||||||
|
grants: manifest::plugin::PluginGrantConfig::default(),
|
||||||
|
config: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn skipped_count(report: &super::super::FeatureRegistryInstallReport) -> usize {
|
||||||
|
report
|
||||||
|
.reports
|
||||||
|
.iter()
|
||||||
|
.map(|feature_report| feature_report.skipped.len())
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_diagnostic(report: &super::super::FeatureRegistryInstallReport, needle: &str) -> bool {
|
||||||
|
report.reports.iter().any(|feature_report| {
|
||||||
|
feature_report
|
||||||
|
.diagnostics
|
||||||
|
.iter()
|
||||||
|
.any(|diagnostic| diagnostic.message.contains(needle))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_invalid_root_schema() {
|
||||||
|
let schema = json!({"type":"string"});
|
||||||
|
assert!(
|
||||||
|
validate_input_schema(&schema)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("type must be `object`")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_unsupported_schema_keyword() {
|
||||||
|
let schema = json!({"type":"object","oneOf":[]});
|
||||||
|
assert!(
|
||||||
|
validate_input_schema(&schema)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("unsupported schema keyword")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_invalid_nested_property_schema_node() {
|
||||||
|
let schema = json!({
|
||||||
|
"type":"object",
|
||||||
|
"properties":{"query":"not-a-schema"},
|
||||||
|
"required":["query"],
|
||||||
|
"additionalProperties":false
|
||||||
|
});
|
||||||
|
let error = validate_input_schema(&schema).unwrap_err();
|
||||||
|
assert!(error.contains("$.properties.query"));
|
||||||
|
assert!(error.contains("schema node must be a JSON object"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_invalid_recursive_schema_members() {
|
||||||
|
let duplicate_required = json!({
|
||||||
|
"type":"object",
|
||||||
|
"properties":{"query":{"type":"string"}},
|
||||||
|
"required":["query", "query"]
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
validate_input_schema(&duplicate_required)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("required entries must be unique")
|
||||||
|
);
|
||||||
|
|
||||||
|
let invalid_items = json!({
|
||||||
|
"type":"object",
|
||||||
|
"properties":{"values":{"type":"array", "items":"not-a-schema"}}
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
validate_input_schema(&invalid_items)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("$.properties.values.items")
|
||||||
|
);
|
||||||
|
|
||||||
|
let invalid_additional = json!({
|
||||||
|
"type":"object",
|
||||||
|
"additionalProperties":{"type":"unsupported"}
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
validate_input_schema(&invalid_additional)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("unsupported schema type")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_object_tool_schema() {
|
||||||
|
validate_input_schema(&json!({
|
||||||
|
"type":"object",
|
||||||
|
"properties":{
|
||||||
|
"query":{"type":"string", "description":"Search text"},
|
||||||
|
"limit":{"type":"integer", "enum":[1, 5, 10]},
|
||||||
|
"tags":{"type":"array", "items":{"type":"string"}}
|
||||||
|
},
|
||||||
|
"required":["query"],
|
||||||
|
"additionalProperties":{"type":"string"}
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn origin_retains_plugin_metadata() {
|
||||||
|
let feature = PluginToolFeature::new(record(Vec::new()));
|
||||||
|
let origin = feature.origin();
|
||||||
|
assert_eq!(origin.kind, "plugin");
|
||||||
|
assert_eq!(origin.plugin_id, "example");
|
||||||
|
assert_eq!(origin.plugin_ref, "project:example");
|
||||||
|
assert_eq!(origin.source, "project");
|
||||||
|
assert_eq!(origin.digest, "sha256:abc");
|
||||||
|
assert_eq!(origin.package_version, "0.1.0");
|
||||||
|
assert_eq!(origin.package_api_version, 1);
|
||||||
|
assert_eq!(origin.surface, "tool");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enabled_plugin_tool_registers_model_visible_schema_and_origin() {
|
||||||
|
let mut pending = Vec::new();
|
||||||
|
let mut hooks = crate::hook::HookRegistryBuilder::new();
|
||||||
|
let report = super::super::FeatureRegistryBuilder::default()
|
||||||
|
.with_module(PluginToolFeature::new(record(vec![tool("PluginSearch")])))
|
||||||
|
.install_into_pending(&mut pending, &mut hooks);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
report
|
||||||
|
.reports
|
||||||
|
.iter()
|
||||||
|
.all(|feature_report| feature_report.diagnostics.is_empty()),
|
||||||
|
"{:#?}",
|
||||||
|
report.reports
|
||||||
|
);
|
||||||
|
assert_eq!(report.installed_tool_names(), vec!["PluginSearch"]);
|
||||||
|
assert_eq!(pending.len(), 1);
|
||||||
|
let (meta, _) = pending[0]();
|
||||||
|
assert_eq!(meta.name, "PluginSearch");
|
||||||
|
assert_eq!(meta.input_schema["type"], "object");
|
||||||
|
let origin = meta.origin.expect("plugin origin metadata");
|
||||||
|
assert_eq!(origin.plugin_ref, "project:example");
|
||||||
|
assert_eq!(origin.digest, "sha256:abc");
|
||||||
|
assert_eq!(origin.source, "project");
|
||||||
|
assert_eq!(origin.surface, "tool");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn package_without_enabled_tool_surface_registers_no_schema() {
|
||||||
|
let mut config = PluginConfig::default();
|
||||||
|
let mut disabled = record(vec![tool("PluginSearch")]);
|
||||||
|
disabled.enabled_surfaces.clear();
|
||||||
|
config.resolved.push(disabled);
|
||||||
|
|
||||||
|
assert!(plugin_tool_features(&config).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disabled_profile_feature_registers_no_schema() {
|
||||||
|
let mut config = PluginConfig::default();
|
||||||
|
config.resolved.push(record(vec![tool("PluginSearch")]));
|
||||||
|
|
||||||
|
assert!(plugin_tool_features_if_enabled(false, &config).is_empty());
|
||||||
|
assert_eq!(plugin_tool_features_if_enabled(true, &config).len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_plugin_tool_names_are_rejected_with_diagnostic() {
|
||||||
|
let mut pending = Vec::new();
|
||||||
|
let mut hooks = crate::hook::HookRegistryBuilder::new();
|
||||||
|
let report = super::super::FeatureRegistryBuilder::default()
|
||||||
|
.with_module(PluginToolFeature::new(record(vec![tool("PluginSearch")])))
|
||||||
|
.with_module(PluginToolFeature::new(record_with_identity(
|
||||||
|
"project:other",
|
||||||
|
vec![tool("PluginSearch")],
|
||||||
|
)))
|
||||||
|
.install_into_pending(&mut pending, &mut hooks);
|
||||||
|
|
||||||
|
assert_eq!(pending.len(), 1);
|
||||||
|
assert_eq!(skipped_count(&report), 1);
|
||||||
|
assert!(has_diagnostic(&report, "duplicate tool contribution"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builtin_tool_name_collision_is_rejected_with_diagnostic() {
|
||||||
|
let mut pending = Vec::new();
|
||||||
|
let mut hooks = crate::hook::HookRegistryBuilder::new();
|
||||||
|
let mut registered = std::collections::HashMap::new();
|
||||||
|
registered.insert("Read".to_string(), FeatureId::builtin("preexisting-tool"));
|
||||||
|
|
||||||
|
let report = super::super::FeatureRegistryBuilder::default()
|
||||||
|
.with_module(PluginToolFeature::new(record(vec![tool("Read")])))
|
||||||
|
.install_into_pending_with_registered(&mut pending, &mut hooks, registered);
|
||||||
|
|
||||||
|
assert!(pending.is_empty());
|
||||||
|
assert_eq!(skipped_count(&report), 1);
|
||||||
|
assert!(has_diagnostic(&report, "duplicate tool contribution"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_input_schema_is_rejected_with_diagnostic() {
|
||||||
|
let mut invalid = tool("BadSchema");
|
||||||
|
invalid.input_schema = json!({"type":"object","$ref":"#/defs/input"});
|
||||||
|
let mut pending = Vec::new();
|
||||||
|
let mut hooks = crate::hook::HookRegistryBuilder::new();
|
||||||
|
|
||||||
|
let report = super::super::FeatureRegistryBuilder::default()
|
||||||
|
.with_module(PluginToolFeature::new(record(vec![invalid])))
|
||||||
|
.install_into_pending(&mut pending, &mut hooks);
|
||||||
|
|
||||||
|
assert!(pending.is_empty());
|
||||||
|
assert!(has_diagnostic(&report, "invalid input_schema"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_invalid_input_schema_does_not_register_plugin_tool() {
|
||||||
|
let mut invalid = tool("BadNestedSchema");
|
||||||
|
invalid.input_schema = json!({
|
||||||
|
"type":"object",
|
||||||
|
"properties":{"query":"not-a-schema"},
|
||||||
|
"required":["query"],
|
||||||
|
"additionalProperties":false
|
||||||
|
});
|
||||||
|
let mut pending = Vec::new();
|
||||||
|
let mut hooks = crate::hook::HookRegistryBuilder::new();
|
||||||
|
|
||||||
|
let report = super::super::FeatureRegistryBuilder::default()
|
||||||
|
.with_module(PluginToolFeature::new(record(vec![invalid])))
|
||||||
|
.install_into_pending(&mut pending, &mut hooks);
|
||||||
|
|
||||||
|
assert!(pending.is_empty());
|
||||||
|
assert!(has_diagnostic(&report, "invalid input_schema"));
|
||||||
|
assert!(has_diagnostic(&report, "$.properties.query"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn registered_tool_executes_as_runtime_missing_error() {
|
||||||
|
let mut pending = Vec::new();
|
||||||
|
let mut hooks = crate::hook::HookRegistryBuilder::new();
|
||||||
|
let report = super::super::FeatureRegistryBuilder::default()
|
||||||
|
.with_module(PluginToolFeature::new(record(vec![tool("PluginSearch")])))
|
||||||
|
.install_into_pending(&mut pending, &mut hooks);
|
||||||
|
assert!(
|
||||||
|
report
|
||||||
|
.reports
|
||||||
|
.iter()
|
||||||
|
.all(|feature_report| feature_report.diagnostics.is_empty()),
|
||||||
|
"{:#?}",
|
||||||
|
report.reports
|
||||||
|
);
|
||||||
|
|
||||||
|
let (_, tool) = pending[0]();
|
||||||
|
let error = tool
|
||||||
|
.execute("{}", ToolExecutionContext::default())
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(error.to_string().contains("runtime missing/unavailable"));
|
||||||
|
assert!(error.to_string().contains("project:example"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -824,7 +824,6 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
registry: FeatureRegistryBuilder,
|
registry: FeatureRegistryBuilder,
|
||||||
) -> FeatureRegistryInstallReport {
|
) -> FeatureRegistryInstallReport {
|
||||||
let worker = self.worker.as_mut().expect("worker taken during run");
|
let worker = self.worker.as_mut().expect("worker taken during run");
|
||||||
let report = registry.install_into_worker(worker, &mut self.hook_builder);
|
|
||||||
let active_workflow_committer = self.log_writer.clone().map(|writer| {
|
let active_workflow_committer = self.log_writer.clone().map(|writer| {
|
||||||
Arc::new(move |entry| writer.commit_log_entry(entry))
|
Arc::new(move |entry| writer.commit_log_entry(entry))
|
||||||
as active_workflow::LogEntryCommitter
|
as active_workflow::LogEntryCommitter
|
||||||
|
|
@ -833,6 +832,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
self.active_workflows.clone(),
|
self.active_workflows.clone(),
|
||||||
active_workflow_committer,
|
active_workflow_committer,
|
||||||
));
|
));
|
||||||
|
let report = registry.install_into_worker(worker, &mut self.hook_builder);
|
||||||
report
|
report
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5353,6 +5353,7 @@ permission = "read"
|
||||||
surfaces: vec![manifest::plugin::PluginSurface::Hook],
|
surfaces: vec![manifest::plugin::PluginSurface::Hook],
|
||||||
runtime: None,
|
runtime: None,
|
||||||
hooks: vec![],
|
hooks: vec![],
|
||||||
|
tools: vec![],
|
||||||
},
|
},
|
||||||
enabled_surfaces: vec![manifest::plugin::PluginSurface::Hook],
|
enabled_surfaces: vec![manifest::plugin::PluginSurface::Hook],
|
||||||
grants: manifest::plugin::PluginGrantConfig::default(),
|
grants: manifest::plugin::PluginGrantConfig::default(),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user