1use 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 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(®istry.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(®istry_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}