feat: stage exact Plugin Tools through shared Worker composition
Extract the unchanged canonical plan model for typed Runtime snapshots without a Server dependency cycle. Bind Tool consumer identities to persisted Session/call IDs, use the owner-issued context and current publication authorization, and register through FeatureInstallPlan with cooperative shutdown. Validation so far: root cargo check --locked, portable model 6 tests/doctest, Worker actual Component adapter 7 focused tests, Server plan 9 tests via shared isolation. Full product launch owner/pin lifecycle and Remote/Embedded vertical integration remain a subsequent coherent change.
This commit is contained in:
@@ -15,6 +15,7 @@ agen = { workspace = true, features = ["codex"] }
|
||||
session-store = { workspace = true }
|
||||
secrets = { workspace = true }
|
||||
manifest = { workspace = true }
|
||||
yoi-plugin-runtime = { path = "../plugin-runtime" }
|
||||
job = { workspace = true }
|
||||
mcp = { workspace = true }
|
||||
protocol = { workspace = true, features = ["json-schema"] }
|
||||
|
||||
@@ -1544,6 +1544,18 @@ where
|
||||
} else {
|
||||
(FeatureRegistryBuilder::new(), None)
|
||||
};
|
||||
match worker.plugin_tools() {
|
||||
Some(tools) => {
|
||||
feature_registry.add_module(tools);
|
||||
}
|
||||
None if !worker.manifest().plugins.is_empty() => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
"Plugin selection requires a persisted plan and current Host preparation",
|
||||
));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
feature_registry
|
||||
.add_module(crate::feature::builtin::chat_invocation::AttachmentInvocationFeature);
|
||||
if let Some(binding) = backend_job.as_ref() {
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod job;
|
||||
mod metadata_lifecycle;
|
||||
pub mod model_client;
|
||||
mod paste_artifact_tool;
|
||||
pub mod plugin;
|
||||
pub mod profile_manifest;
|
||||
pub mod prompt;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
//! Host-selected Plugin Tools use the ordinary Feature install transaction and
|
||||
//! Agen execution/history path. This module neither selects a Plugin nor grants
|
||||
//! access: callers bind exact persisted pins and current owner adapters before
|
||||
//! controller publication. Verified bytes and compiled Components are not leases.
|
||||
|
||||
use crate::feature::{
|
||||
FeatureDescriptor, FeatureHookPoint, FeatureId, FeatureInstallContext, FeatureInstallError,
|
||||
FeatureModule, FeatureRuntimeKind, HookDeclaration, ToolContribution, ToolDeclaration,
|
||||
};
|
||||
use crate::hook::{
|
||||
Hook, HookError, HookErrorCategory, HookExecutionPolicy, WorkerStopping, WorkerStoppingContext,
|
||||
};
|
||||
use agen::tool::{
|
||||
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOrigin, ToolOutput,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use tokio::sync::Semaphore;
|
||||
use yoi_plugin_runtime::{
|
||||
CompiledTool, Executor, Limits,
|
||||
delivery::{ArtifactCache, ArtifactExpectation, ArtifactSource},
|
||||
host::{CancellationToken, Grants, Host, InvocationContext, Operation},
|
||||
owner::{OwnerHost, PluginHostOwner},
|
||||
};
|
||||
|
||||
/// Upper bounds projected by the trusted launch owner from one persisted pin.
|
||||
/// Never construct this from model input, current Profile, cache inventory, or
|
||||
/// unvalidated delivery metadata. The expectation must match the persisted pin.
|
||||
pub struct PluginToolPin {
|
||||
pub workspace_id: String,
|
||||
pub installation_id: String,
|
||||
pub plan_id: String,
|
||||
pub subject: String,
|
||||
pub request: server_api::PluginArtifactDeliveryRequest,
|
||||
pub expectation: ArtifactExpectation,
|
||||
pub tools: Vec<String>,
|
||||
pub config: Value,
|
||||
pub grants: Grants,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PluginPreparationError {
|
||||
#[error("invalid Plugin launch snapshot")]
|
||||
InvalidSnapshot,
|
||||
#[error("Plugin artifact delivery refused or unavailable")]
|
||||
Delivery,
|
||||
#[error("Plugin Component or execution policy is unsupported")]
|
||||
Component,
|
||||
}
|
||||
|
||||
struct ActiveCall {
|
||||
cancellation: CancellationToken,
|
||||
host: Mutex<Option<Arc<OwnerHost>>>,
|
||||
}
|
||||
impl ActiveCall {
|
||||
async fn cancel(&self) -> Result<(), ToolError> {
|
||||
self.cancellation.cancel();
|
||||
let host = self.host.lock().expect("Plugin call lock").clone();
|
||||
if let Some(host) = host {
|
||||
host.cancel()
|
||||
.await
|
||||
.map_err(|_| failure("Plugin cancellation outcome requires reconciliation"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Calls {
|
||||
stopped: bool,
|
||||
active: BTreeMap<String, Arc<ActiveCall>>,
|
||||
}
|
||||
struct CallLease {
|
||||
key: String,
|
||||
state: Arc<Mutex<Calls>>,
|
||||
call: Arc<ActiveCall>,
|
||||
}
|
||||
impl Drop for CallLease {
|
||||
fn drop(&mut self) {
|
||||
self.call.cancellation.cancel();
|
||||
self.state
|
||||
.lock()
|
||||
.expect("Plugin calls lock")
|
||||
.active
|
||||
.remove(&self.key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fully materialized Tool contributions. Preparation publishes nothing; the
|
||||
/// existing Feature planner stages all names, schemas and hooks atomically.
|
||||
#[derive(Clone)]
|
||||
pub struct PluginToolsFeature {
|
||||
tools: Vec<(ToolMeta, Arc<PluginTool>)>,
|
||||
calls: Arc<Mutex<Calls>>,
|
||||
}
|
||||
impl PluginToolsFeature {
|
||||
pub async fn prepare(
|
||||
pins: Vec<PluginToolPin>,
|
||||
source: &dyn ArtifactSource,
|
||||
cache: &ArtifactCache,
|
||||
owner: Arc<dyn PluginHostOwner>,
|
||||
session_id: &str,
|
||||
limits: Limits,
|
||||
) -> Result<Self, PluginPreparationError> {
|
||||
use PluginPreparationError::*;
|
||||
if session_id.is_empty() || pins.len() > 128 {
|
||||
return Err(InvalidSnapshot);
|
||||
}
|
||||
let executor = Arc::new(Executor::new(limits).map_err(|_| Component)?);
|
||||
let calls = Arc::new(Mutex::new(Calls::default()));
|
||||
let capacity = Arc::new(Semaphore::new(16));
|
||||
let mut names = BTreeSet::new();
|
||||
let mut installations = BTreeSet::new();
|
||||
let mut tools = Vec::new();
|
||||
for pin in pins {
|
||||
let descriptor = pin.expectation.descriptor();
|
||||
if pin.workspace_id.is_empty()
|
||||
|| pin.installation_id.is_empty()
|
||||
|| pin.plan_id.is_empty()
|
||||
|| pin.plan_id != pin.request.plan_id
|
||||
|| pin.installation_id != pin.request.installation_id
|
||||
|| descriptor.artifact_digest != pin.request.artifact_digest
|
||||
|| !pin.config.is_object()
|
||||
|| !installations.insert((pin.workspace_id.clone(), pin.installation_id.clone()))
|
||||
|| pin.tools.iter().collect::<BTreeSet<_>>().len() != pin.tools.len()
|
||||
{
|
||||
return Err(InvalidSnapshot);
|
||||
}
|
||||
let config_json = serde_json::to_string(&pin.config).map_err(|_| InvalidSnapshot)?;
|
||||
if config_json.len() > executor.limits().config_bytes {
|
||||
return Err(InvalidSnapshot);
|
||||
}
|
||||
let artifact = cache
|
||||
.resolve(source, &pin.workspace_id, &pin.request, &pin.expectation)
|
||||
.await
|
||||
.map_err(|_| Delivery)?;
|
||||
let compiled = Arc::new(
|
||||
executor
|
||||
.compile(
|
||||
artifact.bytes(),
|
||||
&descriptor.package_digest,
|
||||
&descriptor.artifact_digest,
|
||||
u64::from(descriptor.artifact_size),
|
||||
)
|
||||
.map_err(|_| Component)?,
|
||||
);
|
||||
for name in &pin.tools {
|
||||
let declaration = compiled
|
||||
.tools()
|
||||
.iter()
|
||||
.find(|tool| &tool.name == name)
|
||||
.ok_or(InvalidSnapshot)?;
|
||||
let public_name = format!("{}__{}", pin.installation_id, name);
|
||||
if public_name.len() > 128 || !names.insert(public_name.clone()) {
|
||||
return Err(InvalidSnapshot);
|
||||
}
|
||||
let meta = ToolMeta::new(public_name)
|
||||
.description(declaration.description.clone())
|
||||
.input_schema(declaration.input_schema.clone())
|
||||
.origin(ToolOrigin {
|
||||
kind: "plugin".into(),
|
||||
plugin_id: descriptor.package_id.clone(),
|
||||
plugin_ref: pin.installation_id.clone(),
|
||||
source: "workspace".into(),
|
||||
digest: descriptor.package_digest.clone(),
|
||||
package_version: descriptor.version.clone(),
|
||||
package_api_version: descriptor.schema_version,
|
||||
surface: "tool".into(),
|
||||
});
|
||||
let tool = Arc::new(PluginTool {
|
||||
executor: executor.clone(),
|
||||
compiled: compiled.clone(),
|
||||
owner: owner.clone(),
|
||||
workspace: pin.workspace_id.clone(),
|
||||
installation: pin.installation_id.clone(),
|
||||
plan: pin.plan_id.clone(),
|
||||
subject: pin.subject.clone(),
|
||||
grants: pin.grants.clone(),
|
||||
tool_name: name.clone(),
|
||||
config_json: config_json.clone(),
|
||||
session_id: session_id.into(),
|
||||
calls: calls.clone(),
|
||||
capacity: capacity.clone(),
|
||||
});
|
||||
tools.push((meta, tool));
|
||||
}
|
||||
}
|
||||
Ok(Self { tools, calls })
|
||||
}
|
||||
}
|
||||
impl FeatureModule for PluginToolsFeature {
|
||||
fn descriptor(&self) -> FeatureDescriptor {
|
||||
let mut descriptor =
|
||||
FeatureDescriptor::builtin("plugin-tools", "Workspace Plugin Tools").with_hook(
|
||||
HookDeclaration::new("stop", FeatureHookPoint::WorkerStopping),
|
||||
);
|
||||
descriptor.id = FeatureId::new("plugin:tools").expect("fixed Feature identity");
|
||||
descriptor.runtime = FeatureRuntimeKind::ExternalPlugin;
|
||||
for (meta, _) in &self.tools {
|
||||
descriptor
|
||||
.tools
|
||||
.push(ToolDeclaration::new(&meta.name, &meta.description));
|
||||
}
|
||||
descriptor
|
||||
}
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
for (meta, tool) in &self.tools {
|
||||
let meta = meta.clone();
|
||||
let tool = tool.clone();
|
||||
let name = meta.name.clone();
|
||||
let definition: ToolDefinition = Arc::new(move || (meta.clone(), tool.clone()));
|
||||
context
|
||||
.tools()
|
||||
.register(ToolContribution::new(name, definition))?;
|
||||
}
|
||||
context.hooks().add_worker_stopping(
|
||||
"stop",
|
||||
HookExecutionPolicy::fail_closed(),
|
||||
self.clone(),
|
||||
)
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl Hook<WorkerStopping> for PluginToolsFeature {
|
||||
async fn call(&self, _: &WorkerStoppingContext) -> Result<(), HookError> {
|
||||
let active = {
|
||||
let mut calls = self.calls.lock().expect("Plugin calls lock");
|
||||
calls.stopped = true;
|
||||
calls.active.values().cloned().collect::<Vec<_>>()
|
||||
};
|
||||
let mut failed = false;
|
||||
for call in active {
|
||||
if call.cancel().await.is_err() {
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
if failed {
|
||||
return Err(HookError::new(
|
||||
HookErrorCategory::Dependency,
|
||||
"Plugin cancellation requires reconciliation",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
struct PluginTool {
|
||||
executor: Arc<Executor>,
|
||||
compiled: Arc<CompiledTool>,
|
||||
owner: Arc<dyn PluginHostOwner>,
|
||||
workspace: String,
|
||||
installation: String,
|
||||
plan: String,
|
||||
subject: String,
|
||||
grants: Grants,
|
||||
tool_name: String,
|
||||
config_json: String,
|
||||
session_id: String,
|
||||
calls: Arc<Mutex<Calls>>,
|
||||
capacity: Arc<Semaphore>,
|
||||
}
|
||||
fn failure(message: &str) -> ToolError {
|
||||
ToolError::Internal(message.into())
|
||||
}
|
||||
impl PluginTool {
|
||||
fn start(&self, context: &ToolExecutionContext) -> Result<CallLease, ToolError> {
|
||||
let key = context.execution_id();
|
||||
let mut calls = self.calls.lock().expect("Plugin calls lock");
|
||||
if calls.stopped || calls.active.contains_key(&key) {
|
||||
return Err(failure("Plugin Tool is stopping or already running"));
|
||||
}
|
||||
let call = Arc::new(ActiveCall {
|
||||
cancellation: CancellationToken::new(),
|
||||
host: Mutex::new(None),
|
||||
});
|
||||
calls.active.insert(key.clone(), call.clone());
|
||||
Ok(CallLease {
|
||||
key,
|
||||
state: self.calls.clone(),
|
||||
call,
|
||||
})
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl Tool for PluginTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
context: ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
if context.call_id.is_empty() || context.call_id == "direct" {
|
||||
return Err(failure("Plugin Tool requires committed Tool-call identity"));
|
||||
}
|
||||
let _capacity = self
|
||||
.capacity
|
||||
.clone()
|
||||
.try_acquire_owned()
|
||||
.map_err(|_| failure("Plugin invocation capacity exceeded"))?;
|
||||
let lease = self.start(&context)?;
|
||||
// The persisted Session and provider call ID survive Runtime restoration;
|
||||
// the engine-local batch ID identifies only cancellation of a live attempt.
|
||||
let id_bytes = serde_json::to_vec(&(&self.session_id, &context.call_id))
|
||||
.expect("identity serialization");
|
||||
let hex: String = Sha256::digest(&id_bytes)
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect();
|
||||
let call_id = format!("tool-{hex}");
|
||||
let invocation = InvocationContext {
|
||||
workspace: self.workspace.clone(),
|
||||
installation: self.installation.clone(),
|
||||
plan: self.plan.clone(),
|
||||
subject: self.subject.clone(),
|
||||
invocation_id: String::new(),
|
||||
artifact: self.compiled.identity().clone(),
|
||||
tool_name: self.tool_name.clone(),
|
||||
grants: self.grants.clone(),
|
||||
deadline: tokio::time::Instant::now() + self.executor.limits().wall_time,
|
||||
cancellation: lease.call.cancellation.clone(),
|
||||
};
|
||||
let host = Arc::new(
|
||||
OwnerHost::begin(
|
||||
self.owner.clone(),
|
||||
invocation,
|
||||
server_api::PluginConsumer::ToolCall {
|
||||
tool_call_id: call_id,
|
||||
tool_name: self.tool_name.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|_| failure("Plugin live admission refused or unavailable"))?,
|
||||
);
|
||||
*lease.call.host.lock().expect("Plugin call lock") = Some(host.clone());
|
||||
if lease.call.cancellation.is_cancelled() {
|
||||
host.cancel()
|
||||
.await
|
||||
.map_err(|_| failure("Plugin cancellation requires reconciliation"))?;
|
||||
return Err(failure("Plugin invocation cancelled"));
|
||||
}
|
||||
let output = self
|
||||
.executor
|
||||
.invoke(
|
||||
&self.compiled,
|
||||
host.context().clone(),
|
||||
host.clone(),
|
||||
&self.tool_name,
|
||||
input_json,
|
||||
&self.config_json,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| failure("Plugin invocation failed; no automatic retry"))?
|
||||
.output;
|
||||
// Fence again at the adapter publication boundary, not just on guest return.
|
||||
host.authorize(host.context(), Operation::Publish)
|
||||
.await
|
||||
.map_err(|_| failure("Plugin result publication refused"))?;
|
||||
let content = match output.structured_json {
|
||||
Some(structured) => Some(serde_json::json!({
|
||||
"content": output.content,
|
||||
"structured": serde_json::from_str::<Value>(&structured).map_err(|_| failure("Plugin output is invalid"))?,
|
||||
}).to_string()),
|
||||
None => output.content,
|
||||
};
|
||||
Ok(ToolOutput {
|
||||
summary: output.summary,
|
||||
content,
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
}
|
||||
async fn cancel_execution(&self, context: &ToolExecutionContext) -> Result<(), ToolError> {
|
||||
let call = self
|
||||
.calls
|
||||
.lock()
|
||||
.expect("Plugin calls lock")
|
||||
.active
|
||||
.get(&context.execution_id())
|
||||
.cloned();
|
||||
if let Some(call) = call {
|
||||
call.cancel().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,676 @@
|
||||
//! Component coverage through canonical T-791 packing, real ArtifactCache/Executor,
|
||||
//! Feature installation and the Engine Tool registry. Only ArtifactSource and
|
||||
//! PluginHostOwner are scripted: this is not a full Server/HTTPS vertical test,
|
||||
//! nor proof of owner-side durable storage. Cancellation checks the exact owner
|
||||
//! Cancel request and acknowledgement, not an inferred effect rollback.
|
||||
//! Run with `cargo test -p worker --lib plugin::tests::`; no guest build is needed.
|
||||
|
||||
use super::*;
|
||||
use crate::feature::FeatureRegistryBuilder;
|
||||
use crate::hook::{HookInvocationContext, HookRegistry, HookRegistryBuilder};
|
||||
use agen::Engine;
|
||||
use agen::llm_client::client::LlmClient;
|
||||
use agen::llm_client::{ClientError, Request, ResponseStream};
|
||||
use agen::state::Mutable;
|
||||
use manifest::plugin::{self, PluginPackageLimits, PluginSourceKind};
|
||||
use serde_json::json;
|
||||
use server_api::{
|
||||
PluginArtifact, PluginArtifactDeliveryMetadata, PluginArtifactDeliveryRequest, PluginConsumer,
|
||||
PluginEffectObservation, PluginHostError, PluginHostErrorCode, PluginHostOperation,
|
||||
PluginHostRequest, PluginHostResponse, PluginSchemaContribution,
|
||||
};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use yoi_plugin_runtime::delivery::{ArtifactStream, CacheLimits, DeliveryError};
|
||||
|
||||
const MANIFEST: &str =
|
||||
include_str!("../../../../resources/plugin/templates/rust-component-tool/plugin.toml");
|
||||
const COMPONENT: &[u8] =
|
||||
include_bytes!("../../../plugin-pdk/tests/fixtures/tool-v2.component.wasm");
|
||||
const TOOL_A: &str = "installation_a__example_echo";
|
||||
const TOOL_C: &str = "installation_c__example_echo";
|
||||
const INPUT: &str = r#"{"text":"hello from a Component"}"#;
|
||||
|
||||
struct Source {
|
||||
artifact: PluginArtifact,
|
||||
bytes: Vec<u8>,
|
||||
fetches: AtomicUsize,
|
||||
authorizations: AtomicUsize,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ArtifactSource for Source {
|
||||
fn server_authority_identity(&self) -> &str {
|
||||
"component-fixture-owner"
|
||||
}
|
||||
|
||||
async fn authorize(
|
||||
&self,
|
||||
request: &PluginArtifactDeliveryRequest,
|
||||
) -> Result<PluginArtifactDeliveryMetadata, DeliveryError> {
|
||||
self.authorizations.fetch_add(1, Ordering::SeqCst);
|
||||
assert_eq!(request.artifact_digest, self.artifact.artifact_digest);
|
||||
Ok(PluginArtifactDeliveryMetadata {
|
||||
workspace_id: "workspace-a".into(),
|
||||
request: request.clone(),
|
||||
artifact: self.artifact.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch(
|
||||
&self,
|
||||
_: &PluginArtifactDeliveryRequest,
|
||||
) -> Result<ArtifactStream, DeliveryError> {
|
||||
self.fetches.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(Box::pin(std::io::Cursor::new(self.bytes.clone())))
|
||||
}
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
_directory: tempfile::TempDir,
|
||||
source: Source,
|
||||
cache: ArtifactCache,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
std::fs::write(directory.path().join("plugin.toml"), MANIFEST).unwrap();
|
||||
std::fs::write(directory.path().join("plugin.component.wasm"), COMPONENT).unwrap();
|
||||
let limits = PluginPackageLimits::default();
|
||||
let package =
|
||||
plugin::read_plugin_directory(directory.path(), PluginSourceKind::Explicit, &limits)
|
||||
.unwrap();
|
||||
let packed = plugin::pack_plugin_package(&package, &limits).unwrap();
|
||||
let m = package.package.manifest;
|
||||
let schema_hex: String = Sha256::digest(m.config_schema.as_bytes())
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect();
|
||||
let schema_digest = format!("sha256:{schema_hex}");
|
||||
let artifact = PluginArtifact {
|
||||
package_id: m.id.clone(),
|
||||
name: m.name,
|
||||
version: m.version.clone(),
|
||||
description: m.description,
|
||||
package_digest: packed.package_digest,
|
||||
artifact_digest: packed.artifact_digest,
|
||||
artifact_size: packed.artifact_size.try_into().unwrap(),
|
||||
schema_version: m.schema_version,
|
||||
world: m.runtime.world,
|
||||
required_imports: m.runtime.required_imports,
|
||||
tools: serde_json::from_value(serde_json::to_value(m.tools).unwrap()).unwrap(),
|
||||
connections: vec![],
|
||||
requested_grants: vec![],
|
||||
config_schema: PluginSchemaContribution {
|
||||
identity: format!("{}/config", m.id),
|
||||
version: m.version.clone(),
|
||||
namespace: format!("{}/config/{}/{}", m.id, m.version, schema_digest),
|
||||
digest: schema_digest,
|
||||
dcdl: m.config_schema,
|
||||
},
|
||||
revoked: false,
|
||||
revocation_reason: None,
|
||||
};
|
||||
let cache =
|
||||
ArtifactCache::new(directory.path().join("cache"), CacheLimits::default()).unwrap();
|
||||
Self {
|
||||
_directory: directory,
|
||||
source: Source {
|
||||
artifact,
|
||||
bytes: packed.bytes,
|
||||
fetches: AtomicUsize::new(0),
|
||||
authorizations: AtomicUsize::new(0),
|
||||
},
|
||||
cache,
|
||||
}
|
||||
}
|
||||
|
||||
async fn prepare(
|
||||
&self,
|
||||
installations: &[&str],
|
||||
owner: Arc<Owner>,
|
||||
session: &str,
|
||||
) -> PluginToolsFeature {
|
||||
let pins = installations
|
||||
.iter()
|
||||
.map(|installation| PluginToolPin {
|
||||
workspace_id: "workspace-a".into(),
|
||||
installation_id: (*installation).into(),
|
||||
plan_id: "persisted-plan".into(),
|
||||
subject: "worker-a".into(),
|
||||
request: PluginArtifactDeliveryRequest {
|
||||
plan_id: "persisted-plan".into(),
|
||||
plan_fingerprint: "persisted-fingerprint".into(),
|
||||
installation_id: (*installation).into(),
|
||||
artifact_digest: self.source.artifact.artifact_digest.clone(),
|
||||
},
|
||||
expectation: ArtifactExpectation::new(self.source.artifact.clone()),
|
||||
tools: vec!["example_echo".into()],
|
||||
config: json!({}),
|
||||
grants: Grants::default(),
|
||||
})
|
||||
.collect();
|
||||
PluginToolsFeature::prepare(
|
||||
pins,
|
||||
&self.source,
|
||||
&self.cache,
|
||||
owner,
|
||||
session,
|
||||
Limits::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Behavior {
|
||||
Allow,
|
||||
DenyBegin,
|
||||
DenyAdmit,
|
||||
DenyLatePublish,
|
||||
HoldAdmit,
|
||||
}
|
||||
|
||||
struct Owner {
|
||||
behavior: Behavior,
|
||||
requests: Mutex<Vec<PluginHostRequest>>,
|
||||
entered: Semaphore,
|
||||
}
|
||||
|
||||
impl Owner {
|
||||
fn new(behavior: Behavior) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
behavior,
|
||||
requests: Mutex::new(Vec::new()),
|
||||
entered: Semaphore::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
fn requests(&self) -> Vec<PluginHostRequest> {
|
||||
self.requests.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn consumers(&self) -> Vec<PluginConsumer> {
|
||||
self.requests()
|
||||
.into_iter()
|
||||
.filter_map(|request| match request {
|
||||
PluginHostRequest::Begin { consumer, .. } => Some(consumer),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn publications(&self) -> usize {
|
||||
self.requests()
|
||||
.iter()
|
||||
.filter(|request| {
|
||||
matches!(
|
||||
request,
|
||||
PluginHostRequest::Authorize {
|
||||
operation: PluginHostOperation::Publish,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
fn denied() -> PluginHostError {
|
||||
PluginHostError {
|
||||
code: PluginHostErrorCode::Denied,
|
||||
effect: PluginEffectObservation::NotDispatched,
|
||||
diagnostic_id: "component-fixture-denial".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginHostOwner for Owner {
|
||||
async fn call(
|
||||
&self,
|
||||
request: PluginHostRequest,
|
||||
) -> Result<PluginHostResponse, PluginHostError> {
|
||||
self.requests.lock().unwrap().push(request.clone());
|
||||
match request {
|
||||
PluginHostRequest::Begin {
|
||||
plan_id,
|
||||
installation_id,
|
||||
consumer:
|
||||
PluginConsumer::ToolCall {
|
||||
tool_call_id,
|
||||
tool_name,
|
||||
},
|
||||
} => {
|
||||
assert_eq!(plan_id, "persisted-plan");
|
||||
assert!(matches!(
|
||||
installation_id.as_str(),
|
||||
"installation_a" | "installation_c"
|
||||
));
|
||||
assert_eq!(tool_name, "example_echo");
|
||||
let invocation_id = format!("owner-{installation_id}-{tool_call_id}");
|
||||
// This fixture scripts owner rejection of an already cancelled
|
||||
// consumer; the production owner persistence belongs to its suite.
|
||||
let cancelled = self.requests().iter().any(|request| {
|
||||
matches!(request, PluginHostRequest::Cancel { invocation_id: id } if id == &invocation_id)
|
||||
});
|
||||
if self.behavior == Behavior::DenyBegin || cancelled {
|
||||
return Err(denied());
|
||||
}
|
||||
Ok(PluginHostResponse::Invocation {
|
||||
invocation_id,
|
||||
expires_at_ms: (SystemTime::now().duration_since(UNIX_EPOCH).unwrap()
|
||||
+ Duration::from_secs(60))
|
||||
.as_millis()
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
})
|
||||
}
|
||||
PluginHostRequest::Authorize { operation, .. } => {
|
||||
if operation == PluginHostOperation::Admit {
|
||||
if self.behavior == Behavior::DenyAdmit {
|
||||
return Err(denied());
|
||||
}
|
||||
if self.behavior == Behavior::HoldAdmit {
|
||||
self.entered.add_permits(1);
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
}
|
||||
// Executor publication succeeds; only the adapter's final
|
||||
// publication fence is denied after the guest has returned.
|
||||
if operation == PluginHostOperation::Publish
|
||||
&& self.behavior == Behavior::DenyLatePublish
|
||||
&& self.publications() == 2
|
||||
{
|
||||
return Err(denied());
|
||||
}
|
||||
Ok(PluginHostResponse::Authorized)
|
||||
}
|
||||
PluginHostRequest::Cancel { .. } => Ok(PluginHostResponse::Authorized),
|
||||
other => panic!("pure Component must not request Host effects: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct NoLlm;
|
||||
|
||||
#[async_trait]
|
||||
impl LlmClient for NoLlm {
|
||||
async fn stream(&self, _: Request) -> Result<ResponseStream, ClientError> {
|
||||
panic!("tools are driven through the Engine registry, without an LLM")
|
||||
}
|
||||
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn install(feature: PluginToolsFeature) -> (Engine<NoLlm, Mutable, ()>, HookRegistry) {
|
||||
let mut engine = Engine::new_annotated(NoLlm);
|
||||
let mut hooks = HookRegistryBuilder::new();
|
||||
let mut registry = FeatureRegistryBuilder::new();
|
||||
registry.add_module(feature);
|
||||
let report = registry.install_into_engine(&mut engine, &mut hooks);
|
||||
assert!(!report.has_errors(), "{}", report.error_message());
|
||||
engine.tool_server_handle().flush_pending();
|
||||
(engine, hooks.build())
|
||||
}
|
||||
|
||||
fn context(call: &str, batch: &str) -> ToolExecutionContext {
|
||||
ToolExecutionContext::new(call, batch, 0)
|
||||
}
|
||||
|
||||
fn stopping() -> WorkerStoppingContext {
|
||||
WorkerStoppingContext {
|
||||
invocation: HookInvocationContext {
|
||||
workspace_id: Some("workspace-a".into()),
|
||||
worker_id: "worker-a".into(),
|
||||
session_id: "session-a".into(),
|
||||
..Default::default()
|
||||
},
|
||||
reason: "component test stop".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn selected_installations_publish_only_namespaced_tools_and_manifest_schema() {
|
||||
let fixture = Fixture::new();
|
||||
let owner = Owner::new(Behavior::Allow);
|
||||
// The same package is available to A/B/C, but only persisted A/C pins are selected.
|
||||
let feature = fixture
|
||||
.prepare(
|
||||
&["installation_a", "installation_c"],
|
||||
owner.clone(),
|
||||
"session-a",
|
||||
)
|
||||
.await;
|
||||
let (engine, _) = install(feature);
|
||||
let tools = engine.tool_server_handle();
|
||||
let definitions = tools.tool_definitions_sorted();
|
||||
assert_eq!(
|
||||
definitions
|
||||
.iter()
|
||||
.map(|tool| tool.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
[TOOL_A, TOOL_C]
|
||||
);
|
||||
for (installation, name) in [("installation_a", TOOL_A), ("installation_c", TOOL_C)] {
|
||||
let (meta, _) = tools.get_tool(name).unwrap();
|
||||
assert_eq!(
|
||||
meta.input_schema,
|
||||
fixture.source.artifact.tools[0].input_schema
|
||||
);
|
||||
assert_eq!(meta.input_schema["required"], json!(["text"]));
|
||||
assert_eq!(meta.input_schema["additionalProperties"], false);
|
||||
let origin = meta.origin.unwrap();
|
||||
assert_eq!(origin.kind, "plugin");
|
||||
assert_eq!(origin.plugin_ref, installation);
|
||||
assert_eq!(origin.plugin_id, fixture.source.artifact.package_id);
|
||||
assert_eq!(origin.digest, fixture.source.artifact.package_digest);
|
||||
assert_eq!(origin.surface, "tool");
|
||||
}
|
||||
for absent in ["example_echo", "installation_b__example_echo"] {
|
||||
assert!(tools.get_tool(absent).is_none());
|
||||
assert!(
|
||||
tools
|
||||
.call_tool(absent, INPUT, context("missing", "batch"))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
owner.requests().is_empty(),
|
||||
"registration must not admit a call"
|
||||
);
|
||||
assert_eq!(fixture.source.fetches.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pure_component_returns_normal_tool_output_and_structured_json() {
|
||||
let fixture = Fixture::new();
|
||||
let owner = Owner::new(Behavior::Allow);
|
||||
let feature = fixture
|
||||
.prepare(&["installation_a"], owner.clone(), "session-a")
|
||||
.await;
|
||||
let (engine, _) = install(feature);
|
||||
let tools = engine.tool_server_handle();
|
||||
let output = tools
|
||||
.call_tool(TOOL_A, INPUT, context("call-echo", "batch-a"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(output.summary, "example_echo echoed text");
|
||||
assert!(output.attachments.is_empty());
|
||||
let output: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
output["structured"],
|
||||
json!({"tool":"example_echo", "text":"hello from a Component"})
|
||||
);
|
||||
assert!(output["content"].is_null());
|
||||
assert_eq!(
|
||||
owner.publications(),
|
||||
2,
|
||||
"executor and adapter each fence publication"
|
||||
);
|
||||
assert!(
|
||||
tools
|
||||
.call_tool(TOOL_A, r#"{"text":""}"#, context("empty", "batch-a"))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
owner.publications(),
|
||||
3,
|
||||
"guest error passes the executor fence but never publishes through the adapter"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pure_component_owner_denial_applies_to_cold_and_cached_artifacts() {
|
||||
for behavior in [Behavior::DenyBegin, Behavior::DenyAdmit] {
|
||||
let fixture = Fixture::new();
|
||||
for session in ["cold", "cache-hit"] {
|
||||
let authorizations = fixture.source.authorizations.load(Ordering::SeqCst);
|
||||
let owner = Owner::new(behavior);
|
||||
let feature = fixture
|
||||
.prepare(&["installation_a"], owner.clone(), session)
|
||||
.await;
|
||||
let (engine, _) = install(feature);
|
||||
let result = engine
|
||||
.tool_server_handle()
|
||||
.call_tool(TOOL_A, INPUT, context("denied", "batch"))
|
||||
.await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"{session}: pure code is not an execution lease"
|
||||
);
|
||||
assert_eq!(owner.consumers().len(), 1);
|
||||
assert_eq!(owner.publications(), 0);
|
||||
let admits = owner
|
||||
.requests()
|
||||
.iter()
|
||||
.filter(|request| {
|
||||
matches!(
|
||||
request,
|
||||
PluginHostRequest::Authorize {
|
||||
operation: PluginHostOperation::Admit,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.count();
|
||||
assert_eq!(admits, usize::from(behavior == Behavior::DenyAdmit));
|
||||
assert_eq!(
|
||||
fixture.source.fetches.load(Ordering::SeqCst),
|
||||
1,
|
||||
"{session}: second preparation must use the verified cache entry"
|
||||
);
|
||||
assert!(fixture.source.authorizations.load(Ordering::SeqCst) > authorizations);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn adapter_late_publish_denial_withholds_successful_guest_output() {
|
||||
let fixture = Fixture::new();
|
||||
let owner = Owner::new(Behavior::DenyLatePublish);
|
||||
let feature = fixture
|
||||
.prepare(&["installation_a"], owner.clone(), "session-a")
|
||||
.await;
|
||||
let (engine, _) = install(feature);
|
||||
let error = engine
|
||||
.tool_server_handle()
|
||||
.call_tool(TOOL_A, INPUT, context("late", "batch"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("Plugin result publication refused")
|
||||
);
|
||||
assert_eq!(
|
||||
owner.publications(),
|
||||
2,
|
||||
"guest/executor fence passed before adapter denial"
|
||||
);
|
||||
assert_eq!(
|
||||
owner.consumers().len(),
|
||||
1,
|
||||
"publication refusal must not retry the call"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consumer_identity_survives_batch_changes_but_isolates_sessions_and_calls() {
|
||||
let fixture = Fixture::new();
|
||||
let owner = Owner::new(Behavior::Allow);
|
||||
// Re-preparation simulates a restored adapter, not just reuse of a live Tool.
|
||||
for (session, call, batch) in [
|
||||
("session-a", "provider-call", "batch-before-restore"),
|
||||
("session-a", "provider-call", "batch-after-restore"),
|
||||
("session-b", "provider-call", "batch-after-restore"),
|
||||
("session-a", "different-call", "batch-after-restore"),
|
||||
] {
|
||||
let feature = fixture
|
||||
.prepare(&["installation_a"], owner.clone(), session)
|
||||
.await;
|
||||
let (engine, _) = install(feature);
|
||||
engine
|
||||
.tool_server_handle()
|
||||
.call_tool(TOOL_A, INPUT, context(call, batch))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let consumers = owner.consumers();
|
||||
assert_eq!(consumers[0], consumers[1]);
|
||||
assert_ne!(consumers[0], consumers[2]);
|
||||
assert_ne!(consumers[0], consumers[3]);
|
||||
}
|
||||
|
||||
struct FailInstall;
|
||||
|
||||
impl FeatureModule for FailInstall {
|
||||
fn descriptor(&self) -> FeatureDescriptor {
|
||||
FeatureDescriptor::builtin("late-failure", "Injected install failure")
|
||||
}
|
||||
|
||||
fn install(&self, _: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
Err(FeatureInstallError::Install(
|
||||
"injected after Plugin staging".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn late_install_failure_rolls_back_plugin_tools_and_stop_hook_only() {
|
||||
let fixture = Fixture::new();
|
||||
let owner = Owner::new(Behavior::Allow);
|
||||
let mut engine: Engine<NoLlm, Mutable, ()> = Engine::new_annotated(NoLlm);
|
||||
let mut hooks = HookRegistryBuilder::new();
|
||||
let mut existing = FeatureRegistryBuilder::new();
|
||||
existing.add_module(
|
||||
fixture
|
||||
.prepare(&["installation_c"], owner.clone(), "existing")
|
||||
.await,
|
||||
);
|
||||
let report = existing.install_into_engine(&mut engine, &mut hooks);
|
||||
assert!(!report.has_errors(), "{}", report.error_message());
|
||||
engine.tool_server_handle().flush_pending();
|
||||
|
||||
let candidate = fixture
|
||||
.prepare(&["installation_a"], owner.clone(), "candidate")
|
||||
.await;
|
||||
let mut registry = FeatureRegistryBuilder::new();
|
||||
registry.add_module(candidate.clone());
|
||||
registry.add_module(FailInstall);
|
||||
let report = registry.install_into_engine(&mut engine, &mut hooks);
|
||||
assert!(report.has_errors());
|
||||
assert!(
|
||||
report.plan_error.is_none(),
|
||||
"failure must happen during installation"
|
||||
);
|
||||
assert!(
|
||||
report
|
||||
.reports
|
||||
.iter()
|
||||
.all(|report| !report.installed && report.installed_tools.is_empty())
|
||||
);
|
||||
let tools = engine.tool_server_handle();
|
||||
tools.flush_pending();
|
||||
assert!(tools.get_tool(TOOL_C).is_some());
|
||||
assert!(tools.get_tool(TOOL_A).is_none());
|
||||
assert!(owner.requests().is_empty());
|
||||
|
||||
hooks.build().on_worker_stopping(&stopping()).await.unwrap();
|
||||
assert!(
|
||||
tools
|
||||
.call_tool(TOOL_C, INPUT, context("existing", "batch"))
|
||||
.await
|
||||
.is_err(),
|
||||
"preexisting stop hook must survive rollback"
|
||||
);
|
||||
let (retry, _) = install(candidate);
|
||||
retry
|
||||
.tool_server_handle()
|
||||
.call_tool(TOOL_A, INPUT, context("retry", "batch"))
|
||||
.await
|
||||
.unwrap();
|
||||
// A leaked staged stop hook would have stopped the candidate before this call.
|
||||
assert_eq!(owner.consumers().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn active_cancel_and_stopping_send_owner_cancel_and_refuse_reentry() {
|
||||
for stop in [false, true] {
|
||||
let fixture = Fixture::new();
|
||||
let owner = Owner::new(Behavior::HoldAdmit);
|
||||
let feature = fixture
|
||||
.prepare(&["installation_a"], owner.clone(), "session-a")
|
||||
.await;
|
||||
let (engine, hooks) = install(feature);
|
||||
let tools = engine.tool_server_handle();
|
||||
let (_, tool) = tools.get_tool(TOOL_A).unwrap();
|
||||
let call_context = context("active", "batch-a");
|
||||
let (result, ()) = tokio::time::timeout(Duration::from_secs(10), async {
|
||||
tokio::join!(
|
||||
tools.call_tool(TOOL_A, INPUT, call_context.clone()),
|
||||
async {
|
||||
owner.entered.acquire().await.unwrap().forget();
|
||||
if stop {
|
||||
hooks.on_worker_stopping(&stopping()).await.unwrap();
|
||||
} else {
|
||||
tool.cancel_execution(&call_context).await.unwrap();
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("cancellation must settle the active owner wait");
|
||||
assert!(result.is_err());
|
||||
let requests = owner.requests();
|
||||
let cancels = requests
|
||||
.iter()
|
||||
.filter_map(|request| match request {
|
||||
PluginHostRequest::Cancel { invocation_id } => Some(invocation_id),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
cancels.len(),
|
||||
1,
|
||||
"local cancellation must also reach the owner"
|
||||
);
|
||||
let consumers = owner.consumers();
|
||||
let PluginConsumer::ToolCall { tool_call_id, .. } = &consumers[0] else {
|
||||
panic!("expected a Tool-call consumer")
|
||||
};
|
||||
assert_eq!(cancels[0], &format!("owner-installation_a-{tool_call_id}"));
|
||||
assert_eq!(owner.publications(), 0);
|
||||
|
||||
let begins = consumers.len();
|
||||
// A fresh batch cannot revive the cancelled stable consumer. The fake
|
||||
// owner denies it; stopping instead fences all new calls locally.
|
||||
let retry = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
tools.call_tool(TOOL_A, INPUT, context("active", "batch-b")),
|
||||
)
|
||||
.await
|
||||
.expect("cancelled consumer must not reenter admission");
|
||||
assert!(retry.is_err());
|
||||
assert_eq!(owner.consumers().len(), begins + usize::from(!stop));
|
||||
if stop {
|
||||
assert!(
|
||||
tools
|
||||
.call_tool(TOOL_A, INPUT, context("new-call", "batch-b"))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
owner.consumers().len(),
|
||||
begins,
|
||||
"stopped Tool must not ask Begin"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4232,6 +4232,21 @@ where
|
||||
&self.worker().manifest
|
||||
}
|
||||
|
||||
/// Stable Session identity used to correlate restored Plugin Tool calls.
|
||||
pub fn session_id(&self) -> SessionId {
|
||||
self.worker().session_id()
|
||||
}
|
||||
|
||||
/// Bind exact Host-prepared contributions without exposing execution or
|
||||
/// reopening a candidate whose cleanup has begun.
|
||||
pub fn bind_plugin_tools(
|
||||
&mut self,
|
||||
tools: crate::plugin::PluginToolsFeature,
|
||||
) -> Result<(), WorkerError> {
|
||||
self.worker_mut()?.bind_plugin_tools(tools);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retain the same Workdir resource owner for explicit cleanup, including
|
||||
/// after discard fails. This does not grant Worker input or execution access.
|
||||
pub fn workdir_cleanup(&self) -> RestoreCandidateWorkdirCleanup {
|
||||
@@ -4485,6 +4500,7 @@ pub struct Worker<C: LlmClient, St: Store> {
|
||||
/// Host-owned projection of Worker sessions explicitly granted to this Worker.
|
||||
/// The provider reauthorizes every capture and never derives authority from model input.
|
||||
worker_observation_provider: Option<Arc<dyn WorkerObservationProvider>>,
|
||||
plugin_tools: Option<crate::plugin::PluginToolsFeature>,
|
||||
/// Parsed system-prompt template awaiting first-turn materialisation.
|
||||
/// Retained until the complete initial seed and active metadata are durable.
|
||||
system_prompt_template: Option<SystemPromptTemplate>,
|
||||
@@ -4853,6 +4869,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
tracker: None,
|
||||
task_feature: TaskFeature::new(),
|
||||
worker_observation_provider: None,
|
||||
plugin_tools: None,
|
||||
system_prompt_template: None,
|
||||
feature_instructions: Vec::new(),
|
||||
alerter: None,
|
||||
@@ -5367,6 +5384,16 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind Host-prepared exact Plugin contributions before controller start.
|
||||
/// This handle is never persisted in a Manifest or inherited by a SubWorker.
|
||||
pub fn bind_plugin_tools(&mut self, tools: crate::plugin::PluginToolsFeature) {
|
||||
self.plugin_tools = Some(tools);
|
||||
}
|
||||
|
||||
pub(crate) fn plugin_tools(&self) -> Option<crate::plugin::PluginToolsFeature> {
|
||||
self.plugin_tools.clone()
|
||||
}
|
||||
|
||||
/// Install enabled feature modules into the Worker host surfaces.
|
||||
pub fn install_features(
|
||||
&mut self,
|
||||
@@ -9579,6 +9606,7 @@ where
|
||||
tracker: None,
|
||||
task_feature: TaskFeature::new(),
|
||||
worker_observation_provider: None,
|
||||
plugin_tools: None,
|
||||
system_prompt_template: common.system_prompt_template,
|
||||
feature_instructions: common.feature_instructions,
|
||||
alerter: None,
|
||||
@@ -9987,6 +10015,7 @@ where
|
||||
tracker: None,
|
||||
task_feature: TaskFeature::new(),
|
||||
worker_observation_provider: None,
|
||||
plugin_tools: None,
|
||||
system_prompt_template: common.system_prompt_template,
|
||||
feature_instructions: common.feature_instructions,
|
||||
alerter: None,
|
||||
@@ -10452,6 +10481,7 @@ where
|
||||
tracker: None,
|
||||
task_feature,
|
||||
worker_observation_provider: None,
|
||||
plugin_tools: None,
|
||||
// Restore replays the saved system_prompt verbatim — no
|
||||
// template re-render on resume.
|
||||
system_prompt_template: None,
|
||||
|
||||
Reference in New Issue
Block a user