Skip to main content

runx_runtime/execution/
operator_context.rs

1// rust-style-allow: large-file - transitive preflight discovery keeps traversal,
2// admission provenance, limits, and its focused fixtures in one auditable module.
3//! Runtime-owned preflight expansion for the complete skill chain shown to an
4//! operator before execution. Discovery uses the same validated graph, child
5//! skill, registry admission, and context-skill loaders as execution.
6
7use std::collections::{BTreeMap, BTreeSet};
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use runx_contracts::{ContextEntry, JsonObject, JsonValue, sha256_prefixed};
12use runx_parser::{ExecutionGraph, GraphStep, SkillRunnerDefinition, SourceKind, ValidatedSkill};
13use serde::{Deserialize, Serialize};
14
15use crate::RuntimeError;
16use crate::services::{WorkspaceEnv, merge_inferred_tool_roots};
17use crate::tool_catalogs::{ToolCatalogError, ToolInspectOptions, resolve_local_tool};
18
19use super::graph::{
20    LoadedStepSkill, LoadedStepSkillDefinition, LoadedStepSkillRegistryProvenance,
21    StepSkillLoadOptions, load_step_skill,
22};
23use super::skill_context::load_context_skills;
24use super::skill_front::SkillRunError;
25use super::skill_front::runner_manifest::{
26    load_runner_manifest, resolve_skill_dir, selected_runner,
27};
28
29const MAX_CHAIN_DEPTH: usize = 16;
30const MAX_CHAIN_NODES: usize = 128;
31const MAX_CHAIN_CONTENT_BYTES: usize = 4 * 1024 * 1024;
32const OPERATOR_CONTEXT_CREATED_AT: &str = "operator-context-preflight";
33
34#[derive(Clone, Debug)]
35pub struct SkillOperatorContextOptions {
36    env: BTreeMap<String, String>,
37    cwd: PathBuf,
38    max_depth: usize,
39    max_nodes: usize,
40    max_content_bytes: usize,
41}
42
43impl SkillOperatorContextOptions {
44    #[must_use]
45    pub fn new(env: BTreeMap<String, String>, cwd: PathBuf) -> Self {
46        Self {
47            env,
48            cwd,
49            max_depth: MAX_CHAIN_DEPTH,
50            max_nodes: MAX_CHAIN_NODES,
51            max_content_bytes: MAX_CHAIN_CONTENT_BYTES,
52        }
53    }
54}
55
56#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
57pub struct SkillOperatorContextChain {
58    pub entry: SkillOperatorContextNode,
59    pub node_count: usize,
60    pub content_bytes: usize,
61    pub max_depth: usize,
62    pub max_nodes: usize,
63    pub max_content_bytes: usize,
64}
65
66#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
67pub struct SkillOperatorContextNode {
68    pub node_path: String,
69    pub package: SkillOperatorContextPackage,
70    pub skill_markdown: SkillOperatorContextDocument,
71    pub runner: SkillOperatorContextRunner,
72    pub steps: Vec<SkillOperatorContextStep>,
73    pub tools: Vec<SkillOperatorContextTool>,
74    pub terminal: SkillOperatorContextTerminal,
75}
76
77#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
78pub struct SkillOperatorContextPackage {
79    pub directory: PathBuf,
80    pub reference: Option<String>,
81    pub source: String,
82    pub source_label: String,
83    pub registry: Option<SkillOperatorContextRegistry>,
84}
85
86#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
87pub struct SkillOperatorContextRegistry {
88    pub reference: String,
89    pub source: String,
90    pub source_label: String,
91    pub skill_id: String,
92    pub version: String,
93    pub digest: String,
94    pub package_digest: Option<String>,
95    pub trust_tier: String,
96}
97
98#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
99pub struct SkillOperatorContextDocument {
100    pub path: Option<PathBuf>,
101    pub source_label: String,
102    pub sha256: String,
103    pub content: String,
104}
105
106#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
107pub struct SkillOperatorContextRunner {
108    pub name: String,
109    pub source_type: String,
110    pub selection: String,
111    pub requested_name: Option<String>,
112    pub raw: JsonValue,
113    pub allowed_tools: Vec<String>,
114}
115
116#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
117pub struct SkillOperatorContextStep {
118    pub node_path: String,
119    pub id: String,
120    pub target: SkillOperatorContextTarget,
121    pub raw: JsonValue,
122    pub mutating: bool,
123    pub allowed_tools: Vec<String>,
124    pub context_skills: Vec<SkillOperatorContextContextSkill>,
125    pub tool_refs: Vec<String>,
126    pub child: Option<Box<SkillOperatorContextNode>>,
127}
128
129#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(tag = "kind", rename_all = "snake_case")]
131pub enum SkillOperatorContextTarget {
132    Skill {
133        reference: String,
134        runner: Option<String>,
135    },
136    Tool {
137        name: String,
138    },
139    Run {
140        source_type: String,
141    },
142}
143
144#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
145pub struct SkillOperatorContextContextSkill {
146    pub reference: String,
147    pub source: String,
148    pub name: String,
149    pub document: SkillOperatorContextDocument,
150}
151
152#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
153pub struct SkillOperatorContextTool {
154    pub name: String,
155    pub source: String,
156    pub path: Option<PathBuf>,
157    pub sha256: Option<String>,
158    pub content: Option<String>,
159}
160
161#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(rename_all = "snake_case")]
163pub enum SkillOperatorContextTerminal {
164    ExpandedGraph,
165    Runner,
166    LegacyMarkdown,
167}
168
169pub fn load_skill_operator_context_chain(
170    skill_path: &Path,
171    selected_runner_name: Option<&str>,
172    options: SkillOperatorContextOptions,
173) -> Result<SkillOperatorContextChain, SkillRunError> {
174    let skill_dir = resolve_skill_dir(skill_path)?;
175    let manifest = load_runner_manifest(&skill_dir)?;
176    let runner = selected_runner(&manifest, selected_runner_name)?.clone();
177    let workspace = WorkspaceEnv::new(options.env.clone(), options.cwd.clone());
178    let env = workspace.skill_env_for_skill(&skill_dir);
179    let mut state = ExpansionState::new(options);
180    let entry = state.expand_runner_node(NodeInput {
181        node_path: "entry".to_owned(),
182        package: local_package(&skill_dir, None),
183        skill_dir,
184        runner,
185        requested_runner: selected_runner_name.map(str::to_owned),
186        env,
187        depth: 0,
188    })?;
189    Ok(SkillOperatorContextChain {
190        entry,
191        node_count: state.node_count,
192        content_bytes: state.content_bytes,
193        max_depth: state.options.max_depth,
194        max_nodes: state.options.max_nodes,
195        max_content_bytes: state.options.max_content_bytes,
196    })
197}
198
199struct ExpansionState {
200    options: SkillOperatorContextOptions,
201    node_count: usize,
202    content_bytes: usize,
203    ancestry: BTreeSet<String>,
204}
205
206struct NodeInput {
207    node_path: String,
208    package: SkillOperatorContextPackage,
209    skill_dir: PathBuf,
210    runner: SkillRunnerDefinition,
211    requested_runner: Option<String>,
212    env: BTreeMap<String, String>,
213    depth: usize,
214}
215
216impl ExpansionState {
217    fn new(options: SkillOperatorContextOptions) -> Self {
218        Self {
219            options,
220            node_count: 0,
221            content_bytes: 0,
222            ancestry: BTreeSet::new(),
223        }
224    }
225
226    fn expand_runner_node(
227        &mut self,
228        input: NodeInput,
229    ) -> Result<SkillOperatorContextNode, SkillRunError> {
230        self.admit_node(input.depth)?;
231        let identity = node_identity(&input.package, &input.skill_dir, &input.runner.name)?;
232        if !self.ancestry.insert(identity.clone()) {
233            return Err(blocked(format!(
234                "operator context chain contains a cycle at {} ({identity})",
235                input.node_path
236            )));
237        }
238
239        let result = self.build_runner_node(&input);
240        self.ancestry.remove(&identity);
241        result
242    }
243
244    fn build_runner_node(
245        &mut self,
246        input: &NodeInput,
247    ) -> Result<SkillOperatorContextNode, SkillRunError> {
248        let skill_markdown = self.load_document(&input.skill_dir.join("SKILL.md"))?;
249        let raw = JsonValue::Object(input.runner.raw.clone());
250        self.add_bytes(serialized_bytes(&raw)?)?;
251        let graph = input.runner.source.graph.as_ref();
252        let tool_names = referenced_tools(&input.runner, graph);
253        let tools = self.load_tools(&input.skill_dir, &tool_names, &input.env)?;
254        let steps = match graph {
255            Some(graph) => self.expand_graph_steps(
256                &input.node_path,
257                &input.skill_dir,
258                graph,
259                &input.env,
260                input.depth,
261            )?,
262            None => Vec::new(),
263        };
264        let terminal = if graph.is_some() {
265            SkillOperatorContextTerminal::ExpandedGraph
266        } else {
267            SkillOperatorContextTerminal::Runner
268        };
269        Ok(SkillOperatorContextNode {
270            node_path: input.node_path.clone(),
271            package: input.package.clone(),
272            skill_markdown,
273            runner: runner_context(&input.runner, input.requested_runner.as_deref(), raw),
274            steps,
275            tools,
276            terminal,
277        })
278    }
279
280    fn expand_legacy_node(
281        &mut self,
282        node_path: String,
283        package: SkillOperatorContextPackage,
284        skill_dir: PathBuf,
285        skill: ValidatedSkill,
286        env: BTreeMap<String, String>,
287        depth: usize,
288    ) -> Result<SkillOperatorContextNode, SkillRunError> {
289        self.admit_node(depth)?;
290        let identity = node_identity(&package, &skill_dir, &skill.name)?;
291        if !self.ancestry.insert(identity.clone()) {
292            return Err(blocked(format!(
293                "operator context chain contains a cycle at {node_path} ({identity})"
294            )));
295        }
296        let result = (|| {
297            let skill_markdown = self.load_document(&skill_dir.join("SKILL.md"))?;
298            let raw = JsonValue::Object(skill.raw.frontmatter.clone());
299            self.add_bytes(serialized_bytes(&raw)?)?;
300            let allowed_tools = skill.allowed_tools.clone().unwrap_or_default();
301            let graph = skill.source.graph.as_ref();
302            let mut tool_names = allowed_tools.iter().cloned().collect::<BTreeSet<_>>();
303            if let Some(graph) = graph {
304                for step in &graph.steps {
305                    tool_names.extend(step_tool_refs(step));
306                }
307            }
308            let tools = self.load_tools(&skill_dir, &tool_names, &env)?;
309            let steps = match graph {
310                Some(graph) => {
311                    self.expand_graph_steps(&node_path, &skill_dir, graph, &env, depth)?
312                }
313                None => Vec::new(),
314            };
315            let terminal = if graph.is_some() {
316                SkillOperatorContextTerminal::ExpandedGraph
317            } else {
318                SkillOperatorContextTerminal::LegacyMarkdown
319            };
320            Ok(SkillOperatorContextNode {
321                node_path,
322                package,
323                skill_markdown,
324                runner: SkillOperatorContextRunner {
325                    name: skill.name,
326                    source_type: skill.source.source_type.to_string(),
327                    selection: "legacy-markdown".to_owned(),
328                    requested_name: None,
329                    raw,
330                    allowed_tools,
331                },
332                steps,
333                tools,
334                terminal,
335            })
336        })();
337        self.ancestry.remove(&identity);
338        result
339    }
340
341    fn expand_graph_steps(
342        &mut self,
343        parent_path: &str,
344        graph_dir: &Path,
345        graph: &ExecutionGraph,
346        env: &BTreeMap<String, String>,
347        depth: usize,
348    ) -> Result<Vec<SkillOperatorContextStep>, SkillRunError> {
349        graph
350            .steps
351            .iter()
352            .map(|step| self.expand_graph_step(parent_path, graph_dir, step, env, depth))
353            .collect()
354    }
355
356    // rust-style-allow: long-function - one graph-step expansion must resolve
357    // its exclusive target, attached context, child provenance, and recursion together.
358    fn expand_graph_step(
359        &mut self,
360        parent_path: &str,
361        graph_dir: &Path,
362        step: &GraphStep,
363        env: &BTreeMap<String, String>,
364        depth: usize,
365    ) -> Result<SkillOperatorContextStep, SkillRunError> {
366        let node_path = format!("{parent_path}.{}", step.id);
367        let raw = contract_value(step, "serializing operator context graph step")?;
368        let mut context_skills = Vec::new();
369        let mut child = None;
370        let target = if let Some(reference) = &step.skill {
371            let loaded = load_step_skill(graph_dir, step, StepSkillLoadOptions { env })?;
372            context_skills = self.load_step_context(graph_dir, step, env)?;
373            if !context_skills.is_empty()
374                && !matches!(
375                    loaded.source.source_type,
376                    SourceKind::Agent | SourceKind::AgentStep
377                )
378            {
379                return Err(RuntimeError::InvalidRunStep {
380                    step_id: step.id.clone(),
381                    reason: "context_skills is only supported for agent and agent-task steps"
382                        .to_owned(),
383                }
384                .into());
385            }
386            child = Some(Box::new(self.expand_loaded_child(
387                node_path.clone(),
388                reference,
389                step.runner.as_deref(),
390                loaded,
391                env,
392                depth + 1,
393            )?));
394            SkillOperatorContextTarget::Skill {
395                reference: reference.clone(),
396                runner: step.runner.clone(),
397            }
398        } else if let Some(name) = &step.tool {
399            SkillOperatorContextTarget::Tool { name: name.clone() }
400        } else if let Some(run) = &step.run {
401            context_skills = self.load_step_context(graph_dir, step, env)?;
402            SkillOperatorContextTarget::Run {
403                source_type: run
404                    .get("type")
405                    .and_then(JsonValue::as_str)
406                    .unwrap_or("agent-task")
407                    .to_owned(),
408            }
409        } else {
410            return Err(blocked(format!(
411                "operator context graph step '{}' has no target",
412                step.id
413            )));
414        };
415        let tool_refs = step_tool_refs(step);
416        Ok(SkillOperatorContextStep {
417            node_path,
418            id: step.id.clone(),
419            target,
420            raw,
421            mutating: step.mutating,
422            allowed_tools: step.allowed_tools.clone().unwrap_or_default(),
423            context_skills,
424            tool_refs,
425            child,
426        })
427    }
428
429    fn expand_loaded_child(
430        &mut self,
431        node_path: String,
432        reference: &str,
433        requested_runner: Option<&str>,
434        loaded: LoadedStepSkill,
435        env: &BTreeMap<String, String>,
436        depth: usize,
437    ) -> Result<SkillOperatorContextNode, SkillRunError> {
438        let package = loaded_package(&loaded, reference);
439        let mut child_env = env.clone();
440        merge_inferred_tool_roots(&mut child_env, &loaded.directory);
441        match loaded.definition {
442            LoadedStepSkillDefinition::Runner(runner) => self.expand_runner_node(NodeInput {
443                node_path,
444                package,
445                skill_dir: loaded.directory,
446                runner,
447                requested_runner: requested_runner.map(str::to_owned),
448                env: child_env,
449                depth,
450            }),
451            LoadedStepSkillDefinition::Legacy(skill) => self.expand_legacy_node(
452                node_path,
453                package,
454                loaded.directory,
455                skill,
456                child_env,
457                depth,
458            ),
459        }
460    }
461
462    fn load_step_context(
463        &mut self,
464        graph_dir: &Path,
465        step: &GraphStep,
466        env: &BTreeMap<String, String>,
467    ) -> Result<Vec<SkillOperatorContextContextSkill>, SkillRunError> {
468        let entries = load_context_skills(
469            &step.id,
470            graph_dir,
471            &step.context_skills,
472            env,
473            OPERATOR_CONTEXT_CREATED_AT,
474        )?;
475        step.context_skills
476            .iter()
477            .zip(entries)
478            .map(|(reference, entry)| self.context_skill(reference, entry))
479            .collect()
480    }
481
482    fn context_skill(
483        &mut self,
484        reference: &str,
485        entry: ContextEntry,
486    ) -> Result<SkillOperatorContextContextSkill, SkillRunError> {
487        let source = context_string(&entry.data, "source")?;
488        let name = context_string(&entry.data, "name")?;
489        let content = context_string(&entry.data, "content")?;
490        let sha256 = context_string(&entry.data, "sha256")?;
491        let path = entry
492            .data
493            .get("path")
494            .and_then(JsonValue::as_str)
495            .map(PathBuf::from);
496        let source_label = entry
497            .data
498            .get("source_label")
499            .and_then(JsonValue::as_str)
500            .or_else(|| path.as_ref().and_then(|value| value.to_str()))
501            .unwrap_or(source.as_str())
502            .to_owned();
503        self.add_bytes(content.len())?;
504        Ok(SkillOperatorContextContextSkill {
505            reference: reference.to_owned(),
506            source,
507            name,
508            document: SkillOperatorContextDocument {
509                path,
510                source_label,
511                sha256,
512                content,
513            },
514        })
515    }
516
517    fn load_document(
518        &mut self,
519        path: &Path,
520    ) -> Result<SkillOperatorContextDocument, SkillRunError> {
521        let content = fs::read_to_string(path)
522            .map_err(|source| RuntimeError::io(format!("reading {}", path.display()), source))?;
523        self.add_bytes(content.len())?;
524        Ok(SkillOperatorContextDocument {
525            path: Some(path.to_path_buf()),
526            source_label: path.to_string_lossy().into_owned(),
527            sha256: sha256_prefixed(content.as_bytes()),
528            content,
529        })
530    }
531
532    fn load_tools(
533        &mut self,
534        skill_dir: &Path,
535        names: &BTreeSet<String>,
536        env: &BTreeMap<String, String>,
537    ) -> Result<Vec<SkillOperatorContextTool>, SkillRunError> {
538        if names.is_empty() {
539            return Ok(Vec::new());
540        }
541        names
542            .iter()
543            .map(
544                |name| match resolve_referenced_local_tool(skill_dir, name, env)? {
545                    Some((path, content)) => {
546                        self.add_bytes(content.len())?;
547                        Ok(SkillOperatorContextTool {
548                            name: name.clone(),
549                            source: "local-manifest".to_owned(),
550                            path: Some(path),
551                            sha256: Some(sha256_prefixed(content.as_bytes())),
552                            content: Some(content),
553                        })
554                    }
555                    None => Err(blocked(format!(
556                        "operator context could not resolve required local tool '{name}'"
557                    ))),
558                },
559            )
560            .collect()
561    }
562
563    fn admit_node(&mut self, depth: usize) -> Result<(), SkillRunError> {
564        if depth > self.options.max_depth {
565            return Err(blocked(format!(
566                "operator context chain exceeds maximum depth {}",
567                self.options.max_depth
568            )));
569        }
570        self.node_count = self
571            .node_count
572            .checked_add(1)
573            .ok_or_else(|| blocked("operator context node count overflow"))?;
574        if self.node_count > self.options.max_nodes {
575            return Err(blocked(format!(
576                "operator context chain exceeds maximum node count {}",
577                self.options.max_nodes
578            )));
579        }
580        Ok(())
581    }
582
583    fn add_bytes(&mut self, bytes: usize) -> Result<(), SkillRunError> {
584        self.content_bytes = self
585            .content_bytes
586            .checked_add(bytes)
587            .ok_or_else(|| blocked("operator context content byte count overflow"))?;
588        if self.content_bytes > self.options.max_content_bytes {
589            return Err(blocked(format!(
590                "operator context chain exceeds maximum content bytes {}",
591                self.options.max_content_bytes
592            )));
593        }
594        Ok(())
595    }
596}
597
598fn runner_context(
599    runner: &SkillRunnerDefinition,
600    requested_runner: Option<&str>,
601    raw: JsonValue,
602) -> SkillOperatorContextRunner {
603    let selection = if requested_runner.is_some() {
604        "requested"
605    } else if runner.default {
606        "default"
607    } else {
608        "only"
609    };
610    SkillOperatorContextRunner {
611        name: runner.name.clone(),
612        source_type: runner.source.source_type.to_string(),
613        selection: selection.to_owned(),
614        requested_name: requested_runner.map(str::to_owned),
615        raw,
616        allowed_tools: runner.allowed_tools.clone().unwrap_or_default(),
617    }
618}
619
620fn local_package(skill_dir: &Path, reference: Option<String>) -> SkillOperatorContextPackage {
621    SkillOperatorContextPackage {
622        directory: skill_dir.to_path_buf(),
623        reference,
624        source: "local-path".to_owned(),
625        source_label: skill_dir.to_string_lossy().into_owned(),
626        registry: None,
627    }
628}
629
630fn loaded_package(loaded: &LoadedStepSkill, reference: &str) -> SkillOperatorContextPackage {
631    match loaded.registry.as_ref() {
632        Some(registry) => SkillOperatorContextPackage {
633            directory: loaded.directory.clone(),
634            reference: Some(reference.to_owned()),
635            source: registry.source.clone(),
636            source_label: registry.source_label.clone(),
637            registry: Some(registry_context(registry)),
638        },
639        None => local_package(&loaded.directory, Some(reference.to_owned())),
640    }
641}
642
643fn registry_context(value: &LoadedStepSkillRegistryProvenance) -> SkillOperatorContextRegistry {
644    SkillOperatorContextRegistry {
645        reference: value.reference.clone(),
646        source: value.source.clone(),
647        source_label: value.source_label.clone(),
648        skill_id: value.skill_id.clone(),
649        version: value.version.clone(),
650        digest: value.digest.clone(),
651        package_digest: value.package_digest.clone(),
652        trust_tier: value.trust_tier.clone(),
653    }
654}
655
656fn node_identity(
657    package: &SkillOperatorContextPackage,
658    skill_dir: &Path,
659    runner_name: &str,
660) -> Result<String, SkillRunError> {
661    if let Some(registry) = &package.registry {
662        return Ok(format!(
663            "registry:{}@{}:{}:{}",
664            registry.skill_id,
665            registry.version,
666            registry
667                .package_digest
668                .as_deref()
669                .unwrap_or(&registry.digest),
670            runner_name
671        ));
672    }
673    let canonical = fs::canonicalize(skill_dir).map_err(|source| {
674        RuntimeError::io(
675            format!("canonicalizing skill directory {}", skill_dir.display()),
676            source,
677        )
678    })?;
679    Ok(format!("local:{}:{runner_name}", canonical.display()))
680}
681
682fn referenced_tools(
683    runner: &SkillRunnerDefinition,
684    graph: Option<&ExecutionGraph>,
685) -> BTreeSet<String> {
686    let mut names = runner
687        .allowed_tools
688        .iter()
689        .flatten()
690        .cloned()
691        .collect::<BTreeSet<_>>();
692    if let Some(graph) = graph {
693        for step in &graph.steps {
694            names.extend(step_tool_refs(step));
695        }
696    }
697    names
698}
699
700fn step_tool_refs(step: &GraphStep) -> Vec<String> {
701    let mut names = step
702        .allowed_tools
703        .clone()
704        .unwrap_or_default()
705        .into_iter()
706        .collect::<BTreeSet<_>>();
707    if let Some(tool) = &step.tool {
708        names.insert(tool.clone());
709    }
710    names.into_iter().collect()
711}
712
713fn resolve_referenced_local_tool(
714    skill_dir: &Path,
715    name: &str,
716    env: &BTreeMap<String, String>,
717) -> Result<Option<(PathBuf, String)>, SkillRunError> {
718    let options = ToolInspectOptions {
719        root: env
720            .get("RUNX_CWD")
721            .or_else(|| env.get("RUNX_PROJECT_DIR"))
722            .map(PathBuf::from)
723            .unwrap_or_else(|| skill_dir.to_path_buf()),
724        tool_ref: name.to_owned(),
725        source: None,
726        search_from_directory: skill_dir.to_path_buf(),
727        tool_roots: env
728            .get("RUNX_TOOL_ROOTS")
729            .map(|value| {
730                std::env::split_paths(value)
731                    .filter(|path| !path.as_os_str().is_empty())
732                    .collect()
733            })
734            .unwrap_or_default(),
735        fixture_catalog_enabled: false,
736        allow_explicit_manifest_path: true,
737    };
738    match resolve_local_tool(&options) {
739        Ok(resolution) => {
740            let content = fs::read_to_string(&resolution.manifest_path).map_err(|source| {
741                RuntimeError::io(
742                    format!("reading {}", resolution.manifest_path.display()),
743                    source,
744                )
745            })?;
746            Ok(Some((resolution.manifest_path, content)))
747        }
748        Err(ToolCatalogError::NotFound(_)) => Ok(None),
749        Err(ToolCatalogError::InvalidRequest(message))
750            if message.contains("must include a namespace") =>
751        {
752            Ok(None)
753        }
754        Err(error) => Err(blocked(format!(
755            "operator context could not resolve tool '{name}': {error}"
756        ))),
757    }
758}
759
760fn context_string(data: &JsonObject, field: &str) -> Result<String, SkillRunError> {
761    data.get(field)
762        .and_then(JsonValue::as_str)
763        .map(str::to_owned)
764        .ok_or_else(|| {
765            blocked(format!(
766                "resolved context skill is missing string field {field}"
767            ))
768        })
769}
770
771fn contract_value(
772    value: &impl serde::Serialize,
773    operation: &str,
774) -> Result<JsonValue, SkillRunError> {
775    let value =
776        serde_json::to_value(value).map_err(|source| RuntimeError::json(operation, source))?;
777    serde_json::from_value(value)
778        .map_err(|source| RuntimeError::json("normalizing operator context value", source).into())
779}
780
781fn serialized_bytes(value: &JsonValue) -> Result<usize, SkillRunError> {
782    serde_json::to_vec(value)
783        .map(|bytes| bytes.len())
784        .map_err(|source| RuntimeError::json("serializing operator context content", source).into())
785}
786
787fn blocked(message: impl Into<String>) -> SkillRunError {
788    SkillRunError::Invalid(message.into())
789}
790
791#[cfg(test)]
792mod tests {
793    use std::error::Error;
794
795    use tempfile::tempdir;
796
797    use super::*;
798
799    #[test]
800    fn operator_context_expands_local_child_agent_runner() -> Result<(), Box<dyn Error>> {
801        let temp = tempdir()?;
802        let entry = temp.path().join("entry");
803        let child = entry.join("child");
804        write_skill(&entry, "entry", "# Entry")?;
805        write_skill(&child, "child", "# Child contract")?;
806        write_file(
807            &child.join("X.yaml"),
808            r#"skill: child
809runners:
810  review:
811    default: true
812    type: agent-task
813    agent: reviewer
814    task: review
815"#,
816        )?;
817        write_file(
818            &entry.join("X.yaml"),
819            r#"skill: entry
820runners:
821  main:
822    default: true
823    type: graph
824    graph:
825      name: entry
826      steps:
827        - id: review
828          skill: ./child
829"#,
830        )?;
831
832        let chain = load_skill_operator_context_chain(
833            &entry,
834            None,
835            SkillOperatorContextOptions::new(BTreeMap::new(), temp.path().to_path_buf()),
836        )?;
837        let child = chain.entry.steps[0].child.as_ref().ok_or("missing child")?;
838        assert_eq!(child.node_path, "entry.review");
839        assert_eq!(child.runner.name, "review");
840        assert!(child.skill_markdown.content.contains("# Child contract"));
841        assert_eq!(child.terminal, SkillOperatorContextTerminal::Runner);
842        Ok(())
843    }
844
845    #[test]
846    fn operator_context_uses_child_graph_dir_for_inner_context() -> Result<(), Box<dyn Error>> {
847        let temp = tempdir()?;
848        let entry = temp.path().join("entry");
849        let child = entry.join("child");
850        let rubric = child.join("context/rubric");
851        write_skill(&entry, "entry", "# Entry")?;
852        write_skill(&child, "child", "# Child")?;
853        write_skill(&rubric, "rubric", "child-local rubric")?;
854        write_file(
855            &child.join("X.yaml"),
856            r#"skill: child
857runners:
858  graph:
859    default: true
860    type: graph
861    graph:
862      name: child
863      steps:
864        - id: judge
865          run:
866            type: agent-task
867            agent: reviewer
868            task: judge
869          context_skills:
870            - ./context/rubric
871"#,
872        )?;
873        write_entry_graph(&entry, "./child", "")?;
874
875        let chain = load_skill_operator_context_chain(
876            &entry,
877            None,
878            SkillOperatorContextOptions::new(BTreeMap::new(), temp.path().to_path_buf()),
879        )?;
880        let child = chain.entry.steps[0].child.as_ref().ok_or("missing child")?;
881        let context = &child.steps[0].context_skills[0];
882        assert_eq!(context.reference, "./context/rubric");
883        assert!(context.document.content.contains("child-local rubric"));
884        assert_eq!(context.document.path, Some(rubric.join("SKILL.md")));
885        Ok(())
886    }
887
888    #[test]
889    fn operator_context_uses_parent_graph_dir_for_child_agent_context() -> Result<(), Box<dyn Error>>
890    {
891        let temp = tempdir()?;
892        let entry = temp.path().join("entry");
893        let child = entry.join("child");
894        let rubric = entry.join("context/rubric");
895        write_skill(&entry, "entry", "# Entry")?;
896        write_skill(&child, "child", "# Child")?;
897        write_skill(&rubric, "rubric", "parent-local rubric")?;
898        write_file(
899            &child.join("X.yaml"),
900            r#"skill: child
901runners:
902  agent:
903    default: true
904    type: agent-task
905    agent: reviewer
906    task: judge
907"#,
908        )?;
909        write_entry_graph(
910            &entry,
911            "./child",
912            "          context_skills:\n            - ./context/rubric\n",
913        )?;
914
915        let chain = load_skill_operator_context_chain(
916            &entry,
917            None,
918            SkillOperatorContextOptions::new(BTreeMap::new(), temp.path().to_path_buf()),
919        )?;
920        let context = &chain.entry.steps[0].context_skills[0];
921        assert!(context.document.content.contains("parent-local rubric"));
922        assert_eq!(context.document.path, Some(rubric.join("SKILL.md")));
923        Ok(())
924    }
925
926    #[test]
927    fn operator_context_rejects_context_on_child_graph() -> Result<(), Box<dyn Error>> {
928        let temp = tempdir()?;
929        let entry = temp.path().join("entry");
930        let child = entry.join("child");
931        write_skill(&entry, "entry", "# Entry")?;
932        write_skill(&child, "child", "# Child")?;
933        write_skill(&entry.join("context/rubric"), "rubric", "rubric")?;
934        write_file(
935            &child.join("X.yaml"),
936            r#"skill: child
937runners:
938  graph:
939    default: true
940    type: graph
941    graph:
942      name: child
943      steps:
944        - id: judge
945          run:
946            type: agent-task
947            agent: reviewer
948            task: judge
949"#,
950        )?;
951        write_entry_graph(
952            &entry,
953            "./child",
954            "          context_skills:\n            - ./context/rubric\n",
955        )?;
956
957        let error = operator_context_error(
958            load_skill_operator_context_chain(
959                &entry,
960                None,
961                SkillOperatorContextOptions::new(BTreeMap::new(), temp.path().to_path_buf()),
962            ),
963            "child graph context must fail",
964        )?;
965        assert!(
966            error
967                .to_string()
968                .contains("context_skills is only supported for agent and agent-task steps")
969        );
970        Ok(())
971    }
972
973    #[test]
974    fn operator_context_rejects_registry_child_without_registry_env() -> Result<(), Box<dyn Error>>
975    {
976        let temp = tempdir()?;
977        let entry = temp.path().join("entry");
978        write_skill(&entry, "entry", "# Entry")?;
979        write_entry_graph(&entry, "registry:acme/child@1.0.0", "")?;
980
981        let error = operator_context_error(
982            load_skill_operator_context_chain(
983                &entry,
984                None,
985                SkillOperatorContextOptions::new(BTreeMap::new(), temp.path().to_path_buf()),
986            ),
987            "missing registry env must fail",
988        )?;
989        assert!(
990            error
991                .to_string()
992                .contains("RUNX_REGISTRY_DIR is not configured")
993        );
994        Ok(())
995    }
996
997    #[test]
998    fn operator_context_includes_admitted_registry_child_provenance() -> Result<(), Box<dyn Error>>
999    {
1000        use crate::registry::{
1001            IngestSkillOptions, RegistryPackageFile, create_file_registry_store,
1002            ingest_skill_markdown,
1003        };
1004
1005        let temp = tempdir()?;
1006        let registry_dir = temp.path().join("registry");
1007        let store = create_file_registry_store(&registry_dir);
1008        ingest_skill_markdown(
1009            &store,
1010            "---\nname: registry-child\n---\n# Registry Child\n",
1011            IngestSkillOptions {
1012                owner: Some("acme".to_owned()),
1013                version: Some("1.0.0".to_owned()),
1014                created_at: Some("2026-07-12T00:00:00Z".to_owned()),
1015                profile_document: Some(
1016                    "skill: registry-child\nrunners:\n  agent:\n    default: true\n    type: agent-task\n    agent: reviewer\n    task: review\n"
1017                        .to_owned(),
1018                ),
1019                package_files: vec![RegistryPackageFile {
1020                    path: "references/rubric.md".to_owned(),
1021                    content: "registry package rubric".to_owned(),
1022                }],
1023                ..IngestSkillOptions::default()
1024            },
1025        )?;
1026        let entry = temp.path().join("entry");
1027        write_skill(&entry, "entry", "# Entry")?;
1028        write_entry_graph(&entry, "registry:acme/registry-child@1.0.0", "")?;
1029        let env = [(
1030            "RUNX_REGISTRY_DIR".to_owned(),
1031            registry_dir.to_string_lossy().into_owned(),
1032        )]
1033        .into_iter()
1034        .collect();
1035
1036        let chain = load_skill_operator_context_chain(
1037            &entry,
1038            None,
1039            SkillOperatorContextOptions::new(env, temp.path().to_path_buf()),
1040        )?;
1041        let child = chain.entry.steps[0]
1042            .child
1043            .as_ref()
1044            .ok_or("missing registry child")?;
1045        let registry = child
1046            .package
1047            .registry
1048            .as_ref()
1049            .ok_or("missing registry provenance")?;
1050        assert_eq!(registry.reference, "registry:acme/registry-child@1.0.0");
1051        assert_eq!(registry.skill_id, "acme/registry-child");
1052        assert_eq!(registry.version, "1.0.0");
1053        assert!(registry.digest.starts_with("sha256:"));
1054        assert!(registry.package_digest.is_some());
1055        assert_eq!(registry.trust_tier, "community");
1056        assert!(!registry.source.is_empty());
1057        assert!(!registry.source_label.is_empty());
1058        Ok(())
1059    }
1060
1061    #[test]
1062    fn operator_context_rejects_cycles_and_depth_overflow() -> Result<(), Box<dyn Error>> {
1063        let temp = tempdir()?;
1064        let entry = temp.path().join("entry");
1065        write_skill(&entry, "entry", "# Entry")?;
1066        write_entry_graph(&entry, ".", "")?;
1067        let error = operator_context_error(
1068            load_skill_operator_context_chain(
1069                &entry,
1070                None,
1071                SkillOperatorContextOptions::new(BTreeMap::new(), temp.path().to_path_buf()),
1072            ),
1073            "cycle must fail",
1074        )?;
1075        assert!(error.to_string().contains("contains a cycle"));
1076
1077        let mut previous = temp.path().join("deep-entry");
1078        write_skill(&previous, "deep-entry", "# Deep")?;
1079        let root = previous.clone();
1080        for index in 0..=MAX_CHAIN_DEPTH {
1081            let next = previous.join(format!("child-{index}"));
1082            write_skill(&next, &format!("child-{index}"), "# Child")?;
1083            write_entry_graph(&previous, &format!("./child-{index}"), "")?;
1084            previous = next;
1085        }
1086        write_file(
1087            &previous.join("X.yaml"),
1088            "skill: terminal\nrunners:\n  agent:\n    default: true\n    type: agent-task\n    agent: reviewer\n    task: done\n",
1089        )?;
1090        let error = operator_context_error(
1091            load_skill_operator_context_chain(
1092                &root,
1093                None,
1094                SkillOperatorContextOptions::new(BTreeMap::new(), temp.path().to_path_buf()),
1095            ),
1096            "depth overflow must fail",
1097        )?;
1098        assert!(error.to_string().contains("exceeds maximum depth"));
1099        Ok(())
1100    }
1101
1102    #[test]
1103    fn operator_context_allows_repeated_dag_child_and_enforces_size_limits()
1104    -> Result<(), Box<dyn Error>> {
1105        let temp = tempdir()?;
1106        let entry = temp.path().join("entry");
1107        let child = entry.join("child");
1108        write_skill(&entry, "entry", "# Entry")?;
1109        write_skill(&child, "child", "# Child")?;
1110        write_file(
1111            &child.join("X.yaml"),
1112            "skill: child\nrunners:\n  agent:\n    default: true\n    type: agent-task\n    agent: reviewer\n    task: review\n",
1113        )?;
1114        write_file(
1115            &entry.join("X.yaml"),
1116            "skill: entry\nrunners:\n  main:\n    default: true\n    type: graph\n    graph:\n      name: entry\n      steps:\n        - id: first\n          skill: ./child\n        - id: second\n          skill: ./child\n",
1117        )?;
1118        let chain = load_skill_operator_context_chain(
1119            &entry,
1120            None,
1121            SkillOperatorContextOptions::new(BTreeMap::new(), temp.path().to_path_buf()),
1122        )?;
1123        assert_eq!(chain.node_count, 3);
1124        assert!(chain.entry.steps.iter().all(|step| step.child.is_some()));
1125
1126        let mut options =
1127            SkillOperatorContextOptions::new(BTreeMap::new(), temp.path().to_path_buf());
1128        options.max_content_bytes = 1;
1129        let error = operator_context_error(
1130            load_skill_operator_context_chain(&entry, None, options),
1131            "content size limit must fail",
1132        )?;
1133        assert!(error.to_string().contains("maximum content bytes"));
1134
1135        let mut options =
1136            SkillOperatorContextOptions::new(BTreeMap::new(), temp.path().to_path_buf());
1137        options.max_nodes = 1;
1138        let error = operator_context_error(
1139            load_skill_operator_context_chain(&entry, None, options),
1140            "node count limit must fail",
1141        )?;
1142        assert!(error.to_string().contains("maximum node count"));
1143        Ok(())
1144    }
1145
1146    #[test]
1147    fn operator_context_surfaces_local_tool_manifest_and_mutating_step()
1148    -> Result<(), Box<dyn Error>> {
1149        let temp = tempdir()?;
1150        let entry = temp.path().join("entry");
1151        write_skill(&entry, "entry", "# Entry")?;
1152        write_file(
1153            &entry.join("tools/example/record/manifest.json"),
1154            r#"{
1155  "name": "example.record",
1156  "source": {
1157    "type": "cli-tool",
1158    "command": "true",
1159    "args": [],
1160    "input_mode": "none"
1161  }
1162}
1163"#,
1164        )?;
1165        write_file(
1166            &entry.join("X.yaml"),
1167            "skill: entry\nrunners:\n  main:\n    default: true\n    type: graph\n    graph:\n      name: entry\n      steps:\n        - id: record\n          tool: example.record\n          mutation: true\n          idempotency_key: record-1\n",
1168        )?;
1169
1170        let chain = load_skill_operator_context_chain(
1171            &entry,
1172            None,
1173            SkillOperatorContextOptions::new(BTreeMap::new(), temp.path().to_path_buf()),
1174        )?;
1175        assert!(chain.entry.steps[0].mutating);
1176        assert_eq!(chain.entry.steps[0].tool_refs, ["example.record"]);
1177        assert_eq!(chain.entry.tools.len(), 1);
1178        assert_eq!(chain.entry.tools[0].name, "example.record");
1179        assert_eq!(chain.entry.tools[0].source, "local-manifest");
1180        assert!(
1181            chain.entry.tools[0]
1182                .content
1183                .as_deref()
1184                .is_some_and(|content| content.contains("cli-tool"))
1185        );
1186        Ok(())
1187    }
1188
1189    fn write_skill(dir: &Path, name: &str, body: &str) -> Result<(), Box<dyn Error>> {
1190        fs::create_dir_all(dir)?;
1191        write_file(
1192            &dir.join("SKILL.md"),
1193            &format!("---\nname: {name}\n---\n{body}\n"),
1194        )
1195    }
1196
1197    fn write_entry_graph(dir: &Path, child_ref: &str, extra: &str) -> Result<(), Box<dyn Error>> {
1198        write_file(
1199            &dir.join("X.yaml"),
1200            &format!(
1201                "skill: entry\nrunners:\n  main:\n    default: true\n    type: graph\n    graph:\n      name: entry\n      steps:\n        - id: child\n          skill: {child_ref}\n{extra}"
1202            ),
1203        )
1204    }
1205
1206    fn write_file(path: &Path, content: &str) -> Result<(), Box<dyn Error>> {
1207        if let Some(parent) = path.parent() {
1208            fs::create_dir_all(parent)?;
1209        }
1210        fs::write(path, content)?;
1211        Ok(())
1212    }
1213
1214    fn operator_context_error(
1215        result: Result<SkillOperatorContextChain, SkillRunError>,
1216        message: &'static str,
1217    ) -> Result<SkillRunError, Box<dyn Error>> {
1218        match result {
1219            Ok(_) => Err(message.into()),
1220            Err(error) => Ok(error),
1221        }
1222    }
1223}