Skip to content

feat(evaluator-sdk): agent-skill injection for FabricContainerRuntime#841

Open
SandyChapman wants to merge 1 commit into
fabric-multi-skill-injection/schapmanfrom
fabric-container-skill-injection/schapman
Open

feat(evaluator-sdk): agent-skill injection for FabricContainerRuntime#841
SandyChapman wants to merge 1 commit into
fabric-multi-skill-injection/schapmanfrom
fabric-container-skill-injection/schapman

Conversation

@SandyChapman

Copy link
Copy Markdown
Contributor

What

Adds agent-skill injection to the sandboxed FabricContainerRuntime, so an isolated (Docker) Fabric eval can run WITH an agentskills.io skill — previously only the host FabricAgentRuntime supported skills (limitation L1 from the LAB-examples gap analysis). It mirrors the host runtime's skill design and extends the same two-mode contract to the container.

How

  • __init__ gains a skill: AgentSkill | None; new with_skill() clone method so an A/B eval derives baseline (with_skill(None)) and treated (with_skill(skill)) runtimes from one instance.
  • The injection mode is resolved once per run (the adapter is constant across the taskset) via Fabric's capability planner (_resolve_skill_mode). nemo_fabric is imported lazily on the host only when a skill is set, so the no-skill container path stays dependency-free.
  • New stage_skill_seed() in skills.py — the sandboxed sibling of install_skill. Instead of a host copytree (the container has no host workspace), it renders the bundle into the sandbox seed set (SandboxSpec.files) at the harness's in-sandbox discovery path:
    • native (hermes / any adapter that accepts: ["skills"]) → /in/skills/<name>/ + a skills.paths profile overlay (preserving any pre-configured skill paths, last-wins);
    • codex<workspace>/.agents/skills/<name>/ (self-discovery), no overlay.
  • The codex bundle is scrubbed from the downloaded /out/workspace before it's exposed as evidence (so injected files don't skew workspace-reading metrics). Native staging lives under /in and is never downloaded, so it needs no cleanup.
  • Introduces SkillMode = Literal["native", "codex_skills_dir"], threaded through both runtimes and SkillProvenance.mode.

Text bundles only: the seed set is dict[str, str], so a binary file raises SkillInjectionError (the host install_skill copytree path still handles binary bundles).

Tests

  • 8 new unit cases in test_fabric_container_runtime.py mirroring the host skill-injection suite: native seed-set + overlay, preconfigured-path preservation, runtime-discovered adapter, codex seed + evidence exclusion, fail-fast on an unsupported adapter, no-skill-no-probe, and with_skill copy. Full fabric suite (container + host + skills) = 76 passing; ruff + format clean; ty clean; SDK mirror re-vendored (make vendor).
  • Live-validated (native / hermes): ran one task twice through the runtime against a real Docker sandbox — baseline vs. a skill mandating an unguessable marker token. The marker appeared only in the treated output, confirming the agent genuinely picks up the injected skill. Provenance (mode=native, staged path, content hash) stamped correctly; no leaked containers; native skill correctly absent from workspace evidence.
  • Codex mode is unit-tested only. A full sandboxed codex run needs the codex CLI in the image, which the Fabric sandbox image does not provision yet (hermes+relay only) — separate from this change.

Notes

  • The codex branch is a workaround for a gap in Fabric's codex adapter, not a missing abstraction: the codex fabric-adapter.json declares accepts: ["models", "telemetry"] (no skills), so Fabric routes codex skills unsupported, while the codex CLI itself self-discovers .agents/skills/. When the codex adapter adds skills, this branch can be deleted and codex flows through the same native path.

@github-actions github-actions Bot added the feat label Jul 22, 2026
@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor
Suite Lines Covered Line Rate Branch Rate
Unit Tests 26546/34202 77.6% 61.9%
Integration Tests 15213/32827 46.3% 18.6%

@SandyChapman
SandyChapman force-pushed the fabric-container-skill-injection/schapman branch from ce8121f to 0ed94f6 Compare July 22, 2026 12:22
@SandyChapman
SandyChapman changed the base branch from main to fabric-multi-skill-injection/schapman July 22, 2026 12:22
Stacked on #816 (multi-skill for the host FabricAgentRuntime). Brings the same
multi-skill contract to the sandboxed FabricContainerRuntime, so an isolated
(Docker) Fabric eval can inject a SET of agentskills.io skills — the container
L1 follow-up #816 called out.

- ctor takes `skills=` (a list); `with_skills()` is additive and chainable and
  `with_skill()` is a thin wrapper, matching the host. Duplicate skill names are
  rejected up front via require_unique_skill_names.
- New `stage_skills_seed()` in skills.py — the plural, containerized sibling of
  install_skills: loops stage_skill_seed per bundle into the sandbox seed set and,
  for native mode, merges every bundle into ONE skills.paths overlay (Fabric applies
  skills.paths last-wins). No on-disk rollback needed — the seed set is in-memory.
- _run_task threads a provenance list; codex cleanup scrubs every staged bundle from
  the downloaded workspace evidence. Trial metadata gains a `skills` list while
  keeping the lone `skill` field (one-skill run, else None) via _skill_metadata, so
  SkillUsedMetric and single-skill consumers are unchanged.
- SkillMode = Literal[...] threaded through the new plural helpers and both runtimes.

Native/hermes path validated live against a real Docker sandbox (A/B marker test).
Codex mode is unit-tested only — the sandbox image does not provision the codex CLI.

Signed-off-by: Sandy Chapman <schapman@nvidia.com>
@SandyChapman
SandyChapman force-pushed the fabric-container-skill-injection/schapman branch from 0ed94f6 to 83e14d8 Compare July 22, 2026 12:33
@SandyChapman
SandyChapman marked this pull request as ready for review July 22, 2026 12:41
@SandyChapman
SandyChapman requested review from a team as code owners July 22, 2026 12:41
self._skills: list[AgentSkill] = list(skills or [])
require_unique_skill_names(self._skills)

def with_skills(self, skills: Sequence[AgentSkill]) -> FabricContainerRuntime:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one idea to reduce some duplication and also re-use for other runtimes in the future: store skills state in a standalone class

# skills.py
@dataclass(frozen=True)
class SkillSet:
    skills: tuple[AgentSkill, ...] = ()

    def __post_init__(self) -> None:
        require_unique_skill_names(self.skills)

    def with_skills(self, skills: Sequence[AgentSkill]) -> SkillSet:
        combined = (*self.skills, *skills)
        require_unique_skill_names(combined)
        return SkillSet(skills=combined)

    def with_skill(self, skill: AgentSkill) -> SkillSet:
        return self.with_skills([skill])

class XYZRuntime:
   # runtime.py / container_runtime.py
   def __init__(..., skills: Sequence[AgentSkill] | None = None) -> None:
       ...
       self._skill_set = SkillSet(skills=tuple(skills or ()))
   def with_skills(self, skills: Sequence[AgentSkill]) -> FabricAgentRuntime:  # or FabricContainerRuntime
      """Return a copy with ``skills`` added; ``self`` is not modified."""
      clone = copy.copy(self)
      clone._skill_set = self._skill_set.with_skills(skills)
      return clone
  def with_skill(self, skill: AgentSkill) -> FabricAgentRuntime:
      return self.with_skills([skill])

skill_provenances: list[SkillProvenance] = []
try:
seed_files, profile_paths = self._seed_files(task)
seed_files, profile_paths, skill_provenances = self._seed_files(task, skill_mode)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Task files can overwrite potentially Codex injected skill files if their path matches:

skills files:

myskills/code-review/
└── SKILL.md

task files:

task.inputs = {
    "instruction": "Review this change",
    "files": {
        ".agents/skills/code-review/SKILL.md": """<inline_skill>"""

Is it something we should guard against? Hash will also point to the injected skill, but the content will be different

Example:

reserved = PurePosixPath(".agents/skills") / skill.name

for rel_path in task.inputs.get("files", {}):
    seed_path = PurePosixPath(rel_path)
    if seed_path == reserved or reserved in seed_path.parents:
        raise SkillInjectionError(
            f"task seed collides with injected skill path {reserved}"
        )

try:
bundle[rel] = path.read_text(encoding="utf-8")
except UnicodeDecodeError as exc:
raise SkillInjectionError(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we injected a binary file (image, for example, as a part of skills assets), this will error out. Should we allow passing binary assets?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants