Skip to main content

roder_api/
process_extension.rs

1//! Process-hosted extension contract (roadmap phases 64, 95, and 97).
2//!
3//! A process extension is a non-Rust child process that registers ordinary
4//! extension services (inference engines, event sinks, tool providers,
5//! subagent dispatchers, task executors) through a manifest and speaks
6//! newline-delimited JSON-RPC 2.0 over stdio. These DTOs are the canonical
7//! protocol: the Rust host serializes them as-is and child implementations
8//! (e.g. the Python POCs, the Cursor SDK TypeScript extension) must
9//! round-trip them without raw unowned JSON.
10//!
11//! Method names (host -> child requests unless noted):
12//! - `extension/initialize`
13//! - `inference/listModels`
14//! - `inference/streamTurn`
15//! - `inference/event` (child -> host notification)
16//! - `subagents/definitions`
17//! - `subagents/dispatch`
18//! - `subagents/event` (child -> host notification)
19//! - `subagents/cancel`
20//! - `tasks/spec`
21//! - `tasks/execute`
22//! - `tasks/event` (child -> host notification)
23//! - `tasks/cancel`
24//! - `tools/call`
25//! - `events/handle` (host -> child notification)
26//! - `extension/event` (child -> host notification)
27//! - `extension/shutdown`
28
29use std::collections::{BTreeMap, BTreeSet};
30
31use serde::{Deserialize, Serialize};
32
33use crate::events::{EventEnvelope, ThreadId, TurnId};
34use crate::extension::ProvidedService;
35use crate::inference::{AgentInferenceRequest, InferenceEvent, ModelDescriptor};
36use crate::tools::ToolSpec;
37
38mod dispatch;
39
40pub use dispatch::{
41    ProcessSubagentCancelParams, ProcessSubagentDefinitionsParams,
42    ProcessSubagentDefinitionsResult, ProcessSubagentDispatchAck, ProcessSubagentDispatchParams,
43    ProcessSubagentEvent, ProcessSubagentEventNotification, ProcessTaskCancelParams,
44    ProcessTaskEvent, ProcessTaskEventNotification, ProcessTaskExecuteAck,
45    ProcessTaskExecuteParams, ProcessTaskSpecParams, ProcessTaskSpecResult,
46};
47
48/// Protocol version spoken by the host; children must echo a compatible
49/// version from `extension/initialize`. Bumped to 0.2.0 when subagent
50/// dispatcher, task executor (phase 95), and tool provider (phase 97)
51/// services were added.
52pub const PROCESS_EXTENSION_PROTOCOL_VERSION: &str = "0.2.0";
53
54pub const METHOD_INITIALIZE: &str = "extension/initialize";
55pub const METHOD_LIST_MODELS: &str = "inference/listModels";
56pub const METHOD_STREAM_TURN: &str = "inference/streamTurn";
57pub const METHOD_INFERENCE_EVENT: &str = "inference/event";
58pub const METHOD_SUBAGENTS_DEFINITIONS: &str = "subagents/definitions";
59pub const METHOD_SUBAGENTS_DISPATCH: &str = "subagents/dispatch";
60pub const METHOD_SUBAGENTS_EVENT: &str = "subagents/event";
61pub const METHOD_SUBAGENTS_CANCEL: &str = "subagents/cancel";
62pub const METHOD_TASKS_SPEC: &str = "tasks/spec";
63pub const METHOD_TASKS_EXECUTE: &str = "tasks/execute";
64pub const METHOD_TASKS_EVENT: &str = "tasks/event";
65pub const METHOD_TASKS_CANCEL: &str = "tasks/cancel";
66pub const METHOD_TOOLS_CALL: &str = "tools/call";
67pub const METHOD_EVENTS_HANDLE: &str = "events/handle";
68pub const METHOD_EXTENSION_EVENT: &str = "extension/event";
69pub const METHOD_SHUTDOWN: &str = "extension/shutdown";
70
71/// One `[[process_extensions]]` config entry. `env` is an explicit
72/// allowlist — the host never forwards its full environment.
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
74#[serde(rename_all = "snake_case")]
75pub struct ProcessExtensionConfig {
76    pub id: String,
77    #[serde(default = "default_enabled")]
78    pub enabled: bool,
79    /// Path to the extension manifest TOML (registry source of truth).
80    pub manifest: String,
81    pub command: String,
82    #[serde(default)]
83    pub args: Vec<String>,
84    #[serde(default)]
85    pub cwd: Option<String>,
86    #[serde(default)]
87    pub env: BTreeMap<String, String>,
88    /// Milliseconds the host waits for spawn + initialize before failing.
89    #[serde(default = "default_startup_timeout_ms")]
90    pub startup_timeout_ms: u64,
91    /// Event kinds forwarded to the child (prefix match; empty = none).
92    #[serde(default)]
93    pub event_filter: ProcessEventFilter,
94}
95
96fn default_enabled() -> bool {
97    true
98}
99
100fn default_startup_timeout_ms() -> u64 {
101    10_000
102}
103
104/// Prefix filter over canonical event kinds, e.g. `["turn.", "inference."]`.
105#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
106pub struct ProcessEventFilter {
107    #[serde(default)]
108    pub kinds: Vec<String>,
109}
110
111impl ProcessEventFilter {
112    pub fn matches(&self, kind: &str) -> bool {
113        self.kinds.iter().any(|prefix| kind.starts_with(prefix))
114    }
115}
116
117/// The manifest TOML shipped next to a process extension.
118#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
119pub struct ProcessExtensionManifest {
120    pub id: String,
121    pub name: String,
122    pub version: String,
123    /// Semver requirement against [`crate::extension::SUPPORTED_EXTENSION_API_VERSION`].
124    pub api_version: String,
125    #[serde(default)]
126    pub description: Option<String>,
127    pub provides: Vec<ProcessProvidedService>,
128    #[serde(default)]
129    pub required_capabilities: Vec<String>,
130    /// How to launch the child process. Required for extensions shipped in
131    /// Roder packages (the package layer builds a [`ProcessExtensionConfig`]
132    /// from it); optional for `[[process_extensions]]` config entries, which
133    /// declare the launch command in config.
134    #[serde(default)]
135    pub launch: Option<crate::packages::PackageExtensionLaunch>,
136}
137
138/// A manifest service declaration; mirrors [`ProvidedService`] variants the
139/// process host supports. Tool providers declare their [`ToolSpec`]s
140/// statically so the registry can be built deterministically without
141/// spawning the child.
142#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
143#[serde(rename_all = "snake_case", tag = "type")]
144pub enum ProcessProvidedService {
145    InferenceEngine { id: String },
146    EventSink { id: String },
147    SubagentDispatcher { id: String },
148    TaskExecutor { id: String },
149    ToolProvider { id: String, tools: Vec<ToolSpec> },
150}
151
152impl ProcessProvidedService {
153    pub fn service_id(&self) -> &str {
154        match self {
155            ProcessProvidedService::InferenceEngine { id } => id,
156            ProcessProvidedService::EventSink { id } => id,
157            ProcessProvidedService::SubagentDispatcher { id } => id,
158            ProcessProvidedService::TaskExecutor { id } => id,
159            ProcessProvidedService::ToolProvider { id, .. } => id,
160        }
161    }
162}
163
164impl From<&ProcessProvidedService> for ProvidedService {
165    fn from(service: &ProcessProvidedService) -> Self {
166        match service {
167            ProcessProvidedService::InferenceEngine { id } => {
168                ProvidedService::InferenceEngine(id.clone())
169            }
170            ProcessProvidedService::EventSink { id } => ProvidedService::EventSink(id.clone()),
171            ProcessProvidedService::SubagentDispatcher { id } => {
172                ProvidedService::SubagentDispatcher(id.clone())
173            }
174            ProcessProvidedService::TaskExecutor { id } => {
175                ProvidedService::TaskExecutor(id.clone())
176            }
177            ProcessProvidedService::ToolProvider { id, .. } => {
178                ProvidedService::ToolProvider(id.clone())
179            }
180        }
181    }
182}
183
184/// `extension/initialize` params (host -> child).
185#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
186#[serde(rename_all = "camelCase")]
187pub struct ProcessInitializeParams {
188    pub protocol_version: String,
189    pub api_version: String,
190    pub extension_id: String,
191    pub cwd: String,
192    pub granted_capabilities: Vec<String>,
193    /// Redacted, non-secret config the host chooses to share.
194    pub config: serde_json::Value,
195    pub event_filter: ProcessEventFilter,
196}
197
198/// `extension/initialize` result (child -> host).
199#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
200#[serde(rename_all = "camelCase")]
201pub struct ProcessInitializeResult {
202    pub protocol_version: String,
203    /// Echo of the manifest the child believes it implements.
204    pub extension_id: String,
205    pub services: Vec<ProcessProvidedService>,
206    /// FNV-1a checksum (hex) of the manifest TOML bytes the child shipped.
207    pub manifest_checksum: String,
208}
209
210/// `inference/listModels` params.
211#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
212#[serde(rename_all = "camelCase")]
213pub struct ProcessListModelsParams {
214    pub engine_id: String,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
218#[serde(rename_all = "camelCase")]
219pub struct ProcessListModelsResult {
220    pub models: Vec<ModelDescriptor>,
221}
222
223/// `inference/streamTurn` params: a canonical request plus turn provenance.
224#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
225#[serde(rename_all = "camelCase")]
226pub struct ProcessStreamTurnParams {
227    pub engine_id: String,
228    pub stream_id: String,
229    pub thread_id: ThreadId,
230    pub turn_id: TurnId,
231    pub request: AgentInferenceRequest,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
235#[serde(rename_all = "camelCase")]
236pub struct ProcessStreamTurnAck {
237    pub stream_id: String,
238}
239
240/// `tools/call` params (host -> child): one invocation of a tool the
241/// manifest declared under a `tool_provider` service.
242#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
243#[serde(rename_all = "camelCase")]
244pub struct ProcessToolCallParams {
245    pub provider_id: String,
246    pub tool_name: String,
247    pub call_id: String,
248    pub thread_id: ThreadId,
249    pub turn_id: TurnId,
250    pub arguments: serde_json::Value,
251}
252
253/// `tools/call` result (child -> host): the subset of the native
254/// [`crate::tools::ToolResult`] a child populates. `content` becomes the
255/// model-visible text, optional `data` carries a structured payload, and
256/// `is_error` marks a tool-level failure without failing the turn.
257#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
258#[serde(rename_all = "camelCase")]
259pub struct ProcessToolCallResult {
260    pub content: String,
261    pub is_error: bool,
262    #[serde(default)]
263    pub data: serde_json::Value,
264}
265
266/// `inference/event` notification payload (child -> host). The host routes
267/// by `stream_id` and converts `event` into the runtime inference stream.
268#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
269#[serde(rename_all = "camelCase")]
270pub struct ProcessInferenceEventNotification {
271    pub stream_id: String,
272    pub event: InferenceEvent,
273}
274
275/// `events/handle` notification payload (host -> child).
276#[derive(Debug, Clone, Serialize, Deserialize)]
277#[serde(rename_all = "camelCase")]
278pub struct ProcessEventsHandleNotification {
279    pub envelope: EventEnvelope,
280}
281
282/// `extension/event` notification payload (child -> host): a typed,
283/// extension-owned event. Payloads must already be redacted by the child;
284/// the host additionally enforces a size cap before re-emitting.
285#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
286#[serde(rename_all = "camelCase")]
287pub struct ProcessExtensionOwnedEvent {
288    pub extension_id: String,
289    pub event_kind: String,
290    pub schema_version: u32,
291    pub payload: serde_json::Value,
292}
293
294/// Validates the child's initialize echo against the configured manifest.
295/// Mismatches fail closed: the host refuses to register services from a
296/// child that disagrees about identity, API, or provided services.
297pub fn validate_initialize_echo(
298    manifest: &ProcessExtensionManifest,
299    manifest_toml: &str,
300    result: &ProcessInitializeResult,
301) -> anyhow::Result<()> {
302    anyhow::ensure!(
303        result.protocol_version == PROCESS_EXTENSION_PROTOCOL_VERSION,
304        "process extension {} speaks protocol {:?} but the host requires {:?}",
305        manifest.id,
306        result.protocol_version,
307        PROCESS_EXTENSION_PROTOCOL_VERSION
308    );
309    anyhow::ensure!(
310        result.extension_id == manifest.id,
311        "process extension echoed id {:?} but the manifest declares {:?}",
312        result.extension_id,
313        manifest.id
314    );
315    anyhow::ensure!(
316        result.services == manifest.provides,
317        "process extension {} echoed services {:?} but the manifest declares {:?}",
318        manifest.id,
319        result.services,
320        manifest.provides
321    );
322    let expected = manifest_checksum(manifest_toml);
323    anyhow::ensure!(
324        result.manifest_checksum == expected,
325        "process extension {} echoed manifest checksum {:?} but the configured manifest hashes \
326         to {:?}; the child is running against a different manifest",
327        manifest.id,
328        result.manifest_checksum,
329        expected
330    );
331    Ok(())
332}
333
334/// Validates the manifest against the host's supported extension API.
335pub fn validate_manifest(manifest: &ProcessExtensionManifest) -> anyhow::Result<()> {
336    anyhow::ensure!(
337        !manifest.id.trim().is_empty(),
338        "process extension manifest is missing an id"
339    );
340    anyhow::ensure!(
341        !manifest.provides.is_empty(),
342        "process extension {} declares no provided services",
343        manifest.id
344    );
345    let requirement = semver::VersionReq::parse(&manifest.api_version).map_err(|err| {
346        anyhow::anyhow!(
347            "process extension {} has invalid api_version {:?}: {err}",
348            manifest.id,
349            manifest.api_version
350        )
351    })?;
352    let supported = semver::Version::parse(crate::extension::SUPPORTED_EXTENSION_API_VERSION)?;
353    anyhow::ensure!(
354        requirement.matches(&supported),
355        "process extension {} requires extension API {:?} but the host supports {}",
356        manifest.id,
357        manifest.api_version,
358        supported
359    );
360    for service in &manifest.provides {
361        let ProcessProvidedService::ToolProvider { id, tools } = service else {
362            continue;
363        };
364        validate_tool_provider(&manifest.id, id, tools)?;
365    }
366    Ok(())
367}
368
369/// Light JSON-schema-ish validation of a declared tool provider: names must
370/// be non-empty and unique within the provider, and `parameters` must be a
371/// JSON schema object (`"type": "object"`), which is what models require.
372fn validate_tool_provider(
373    extension_id: &str,
374    provider_id: &str,
375    tools: &[ToolSpec],
376) -> anyhow::Result<()> {
377    anyhow::ensure!(
378        !tools.is_empty(),
379        "process extension {extension_id} tool provider {provider_id} declares no tools"
380    );
381    let mut names = BTreeSet::new();
382    for tool in tools {
383        anyhow::ensure!(
384            !tool.name.trim().is_empty(),
385            "process extension {extension_id} tool provider {provider_id} declares a tool with \
386             an empty name"
387        );
388        anyhow::ensure!(
389            names.insert(tool.name.as_str()),
390            "process extension {extension_id} tool provider {provider_id} declares tool {:?} \
391             more than once",
392            tool.name
393        );
394        let is_object_schema = tool
395            .parameters
396            .get("type")
397            .and_then(serde_json::Value::as_str)
398            == Some("object");
399        anyhow::ensure!(
400            is_object_schema,
401            "process extension {extension_id} tool {:?} parameters must be a JSON schema object \
402             (declare `type = \"object\"`)",
403            tool.name
404        );
405    }
406    Ok(())
407}
408
409/// FNV-1a 64-bit checksum (hex) of the manifest bytes. Not cryptographic —
410/// it only detects manifest drift between host config and child package.
411pub fn manifest_checksum(manifest_toml: &str) -> String {
412    const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
413    const PRIME: u64 = 0x0000_0100_0000_01b3;
414    let mut hash = OFFSET;
415    for byte in manifest_toml.as_bytes() {
416        hash ^= u64::from(*byte);
417        hash = hash.wrapping_mul(PRIME);
418    }
419    format!("{hash:016x}")
420}