Skip to content

Harness

harness

Harness abstraction for AI agent CLI tools.

A harness encapsulates everything specific to a particular agent CLI (Claude Code, OpenCode, Codex, etc.): how to build the command, what env vars it needs, where credentials are mounted, and how to parse its output.

EFFORT_NONE = 'none' module-attribute

Effort override value meaning "pass no effort flag".

AGENT_EFFORT_ENV_VAR = 'AGENT_REASONING_EFFORT' module-attribute

Env var carrying the effective reasoning effort into the agent environment.

Backends export it next to AGENT_MODEL so skills can read the effort in use without knowing the harness. Like AGENT_MODEL it is output only: :meth:Harness.resolve_efforts never reads it, so an agentic-ci run started inside an agent does not inherit the outer run's effort.

Harness

Bases: ABC

Base class for agent CLI harnesses.

auth_mode property

Return 'api-key' if ANTHROPIC_API_KEY is set, else 'vertex'.

name abstractmethod property

Human-readable name for log messages.

registry_key = '' class-attribute instance-attribute

Key of this harness in agentic_ci.models.MODEL_REGISTRY.

models property

Model ids and effort levels for this harness, from the registry.

supports_otel property

Whether the agent CLI supports OTEL telemetry export.

autoupdater_env_var property

Env var name to disable auto-updates.

auth_mode_for_env(env=None)

Return the authentication mode selected by env.

Source code in src/agentic_ci/harness.py
def auth_mode_for_env(self, env: Mapping[str, str] | None = None) -> str:
    """Return the authentication mode selected by *env*."""
    credential_env = env if env is not None else os.environ
    if credential_env.get("ANTHROPIC_API_KEY"):
        return "api-key"
    return "vertex"

validate_credentials(env=None, *, allow_auth_file=False)

Fail early when harness-specific credentials are unavailable.

Harnesses without additional validation requirements use this no-op implementation.

Source code in src/agentic_ci/harness.py
def validate_credentials(
    self,
    env: Mapping[str, str] | None = None,
    *,
    allow_auth_file: bool = False,
) -> None:
    """Fail early when harness-specific credentials are unavailable.

    Harnesses without additional validation requirements use this no-op
    implementation.
    """

build_args(prompt, model, extra_args=None, otel_endpoint=None, externally_sandboxed=False) abstractmethod

Build the CLI argument list to run inside the container.

externally_sandboxed is True when the backend already isolates the agent (OpenShell), so a harness may drop its own approval prompts and inner sandbox. Harnesses that always bypass permissions ignore it.

Source code in src/agentic_ci/harness.py
@abstractmethod
def build_args(
    self,
    prompt: str,
    model: str,
    extra_args: list[str] | None = None,
    otel_endpoint: str | None = None,
    externally_sandboxed: bool = False,
) -> list[str]:
    """Build the CLI argument list to run inside the container.

    ``externally_sandboxed`` is True when the backend already isolates the
    agent (OpenShell), so a harness may drop its own approval prompts and
    inner sandbox. Harnesses that always bypass permissions ignore it.
    """

build_env_args(env=None) abstractmethod

Return ['--env', 'K=V', ...] pairs for podman run (PodmanBackend only).

Container-image ENV vars (config dirs, AGENT_TOOL) are already set in the Containerfile, so this method should not override them.

Source code in src/agentic_ci/harness.py
@abstractmethod
def build_env_args(self, env: Mapping[str, str] | None = None) -> list[str]:
    """Return ['--env', 'K=V', ...] pairs for ``podman run`` (PodmanBackend only).

    Container-image ENV vars (config dirs, AGENT_TOOL) are already
    set in the Containerfile, so this method should not override them.
    """

build_env_script_lines(otel_port=None, otel_rate_file=None, traceparent=None, env=None) abstractmethod

Return export K=V lines for the env script (OpenShellBackend only).

OpenShell extracts the container filesystem but drops OCI ENV metadata, so every required env var must be re-injected here. Config dirs use /sandbox/... paths per OpenShell convention.

Source code in src/agentic_ci/harness.py
@abstractmethod
def build_env_script_lines(
    self,
    otel_port: int | None = None,
    otel_rate_file: str | None = None,
    traceparent: str | None = None,
    env: Mapping[str, str] | None = None,
) -> list[str]:
    """Return ``export K=V`` lines for the env script (OpenShellBackend only).

    OpenShell extracts the container filesystem but drops OCI ENV
    metadata, so every required env var must be re-injected here.
    Config dirs use ``/sandbox/...`` paths per OpenShell convention.
    """

build_otel_exec_env(otel_port=None, traceparent=None) abstractmethod

Return ['--env', 'K=V', ...] pairs for podman exec when OTEL is enabled.

Source code in src/agentic_ci/harness.py
@abstractmethod
def build_otel_exec_env(
    self, otel_port: int | None = None, traceparent: str | None = None
) -> list[str]:
    """Return ['--env', 'K=V', ...] pairs for podman exec when OTEL is enabled."""

credential_mount_target() abstractmethod

Container-side home directory for credential mounts.

Source code in src/agentic_ci/harness.py
@abstractmethod
def credential_mount_target(self) -> str:
    """Container-side home directory for credential mounts."""

create_stream_processor(pid=0) abstractmethod

Return a stream processor for this harness's output format.

Source code in src/agentic_ci/harness.py
@abstractmethod
def create_stream_processor(self, pid: int = 0) -> Any:
    """Return a stream processor for this harness's output format."""

image_env_var() abstractmethod

Env var name for the fallback container image.

Source code in src/agentic_ci/harness.py
@abstractmethod
def image_env_var(self) -> str:
    """Env var name for the fallback container image."""

model_env_var() abstractmethod

Env var name for the model override.

Source code in src/agentic_ci/harness.py
@abstractmethod
def model_env_var(self) -> str:
    """Env var name for the model override."""

default_model()

Default model when no --model flag or env var is set.

Source code in src/agentic_ci/harness.py
def default_model(self) -> str:
    """Default model when no --model flag or env var is set."""
    return self.models.default

default_model_tiers()

Registry low/medium/high routing tiers for this harness.

The high tier matches :meth:default_model so routed runs never regress hard tasks. SkillConfig.model_tiers overrides entries.

Source code in src/agentic_ci/harness.py
def default_model_tiers(self) -> dict[str, ModelTier]:
    """Registry ``low``/``medium``/``high`` routing tiers for this harness.

    The ``high`` tier matches :meth:`default_model` so routed runs never
    regress hard tasks. ``SkillConfig.model_tiers`` overrides entries.
    """
    return dict(self.models.tiers)

effort_env_var() abstractmethod

Env var name for the reasoning effort override.

Source code in src/agentic_ci/harness.py
@abstractmethod
def effort_env_var(self) -> str:
    """Env var name for the reasoning effort override."""

subagent_effort_env_var()

Env var name for the sub-agent effort override (None = no separate knob).

Source code in src/agentic_ci/harness.py
def subagent_effort_env_var(self) -> str | None:
    """Env var name for the sub-agent effort override (``None`` = no separate knob)."""
    return None

resolve_efforts(effort=None, env=None)

Return the effective (effort, subagent_effort) for a run.

Precedence for the main effort: explicit effort argument, then the :meth:effort_env_var value, then the registry default_effort. The sub-agent effort is the :meth:subagent_effort_env_var value, then the registry subagent_effort, then the main effort; it is None for harnesses without a separate sub-agent knob. The literal value none selects "no effort flag". Values are validated by :meth:build_effort_args.

Source code in src/agentic_ci/harness.py
def resolve_efforts(
    self, effort: str | None = None, env: Mapping[str, str] | None = None
) -> tuple[str | None, str | None]:
    """Return the effective ``(effort, subagent_effort)`` for a run.

    Precedence for the main effort: explicit *effort* argument, then the
    :meth:`effort_env_var` value, then the registry ``default_effort``.
    The sub-agent effort is the :meth:`subagent_effort_env_var` value,
    then the registry ``subagent_effort``, then the main effort; it is
    ``None`` for harnesses without a separate sub-agent knob. The literal
    value ``none`` selects "no effort flag". Values are validated by
    :meth:`build_effort_args`.
    """
    config_env = env if env is not None else os.environ
    main = effort or config_env.get(self.effort_env_var()) or self.models.default_effort
    if main == EFFORT_NONE:
        main = None
    sub_var = self.subagent_effort_env_var()
    if sub_var is None:
        return main, None
    sub = config_env.get(sub_var) or self.models.subagent_effort or main
    if sub == EFFORT_NONE:
        sub = None
    return main, sub

build_effort_args(effort, subagent_effort=None)

Return CLI args that set reasoning effort for this harness.

Returns [] when both values are None. Raises ValueError for a value outside the registry's efforts set, so an invalid override fails before the agent starts. The result is passed to build_args() through extra_args.

Source code in src/agentic_ci/harness.py
def build_effort_args(
    self, effort: str | None, subagent_effort: str | None = None
) -> list[str]:
    """Return CLI args that set reasoning effort for this harness.

    Returns ``[]`` when both values are ``None``. Raises ``ValueError``
    for a value outside the registry's ``efforts`` set, so an invalid
    override fails before the agent starts. The result is passed to
    ``build_args()`` through ``extra_args``.
    """
    for label, value in (("effort", effort), ("sub-agent effort", subagent_effort)):
        if value is not None and value not in self.models.efforts:
            raise ValueError(
                f"Unsupported {self.name} {label} {value!r}; "
                f"expected one of {sorted(self.models.efforts)} or {EFFORT_NONE!r}"
            )
    if effort is None and subagent_effort is None:
        return []
    return self.effort_args(effort, subagent_effort)

effort_args(effort, subagent_effort) abstractmethod

CLI fragment for already-validated efforts.

Harnesses without a sub-agent knob ignore subagent_effort.

Source code in src/agentic_ci/harness.py
@abstractmethod
def effort_args(self, effort: str | None, subagent_effort: str | None) -> list[str]:
    """CLI fragment for already-validated efforts.

    Harnesses without a sub-agent knob ignore *subagent_effort*.
    """

classifier_effort()

Effort for the classifier run, from the registry.

None means normal resolution (effort env var, else default_effort).

Source code in src/agentic_ci/harness.py
def classifier_effort(self) -> str | None:
    """Effort for the classifier run, from the registry.

    ``None`` means normal resolution (effort env var, else ``default_effort``).
    """
    return self.models.classifier_effort

build_classifier_args(max_turns)

Extra CLI args that bound the classifier run.

Default is no bound; harnesses whose CLI has a turn cap override this.

Source code in src/agentic_ci/harness.py
def build_classifier_args(self, max_turns: int) -> list[str]:
    """Extra CLI args that bound the classifier run.

    Default is no bound; harnesses whose CLI has a turn cap override this.
    """
    return []

build_local_env(otel_port=None, otel_rate_file=None, traceparent=None, env=None) abstractmethod

Return env vars as a plain dict for direct (local) execution.

Unlike build_env_args (podman --env format) or build_env_script_lines (OpenShell export format), this returns a dict suitable for merging into os.environ and passing to subprocess.Popen(env=...).

Source code in src/agentic_ci/harness.py
@abstractmethod
def build_local_env(
    self,
    otel_port: int | None = None,
    otel_rate_file: str | None = None,
    traceparent: str | None = None,
    env: Mapping[str, str] | None = None,
) -> dict[str, str]:
    """Return env vars as a plain dict for direct (local) execution.

    Unlike build_env_args (podman --env format) or build_env_script_lines
    (OpenShell export format), this returns a dict suitable for merging
    into os.environ and passing to subprocess.Popen(env=...).
    """

write_sandbox_config(config_dir, otel_enabled=False)

Write agent-specific config files to the sandbox config dir.

Called by backends before container start. Default is a no-op.

Source code in src/agentic_ci/harness.py
def write_sandbox_config(self, config_dir, otel_enabled=False):
    """Write agent-specific config files to the sandbox config dir.

    Called by backends before container start. Default is a no-op.
    """

sandbox_config_mounts(config_dir)

Return list of (host_path, container_path) for config file mounts.

Called by backends to mount config files written by write_sandbox_config(). Default returns empty list.

Source code in src/agentic_ci/harness.py
def sandbox_config_mounts(self, config_dir):
    """Return list of (host_path, container_path) for config file mounts.

    Called by backends to mount config files written by write_sandbox_config().
    Default returns empty list.
    """
    return []

ClaudeCodeHarness

Bases: Harness

Claude Code CLI harness.

auth_mode_for_env(env=None)

Return 'oauth' when CLAUDE_CODE_OAUTH_TOKEN is set and ANTHROPIC_API_KEY is not.

ANTHROPIC_API_KEY still wins, matching Claude Code's own authentication precedence. Only this harness selects 'oauth': a subscription token is a Claude Code credential, and OpenCode and Codex have no path to use it.

Source code in src/agentic_ci/harness.py
def auth_mode_for_env(self, env: Mapping[str, str] | None = None) -> str:
    """Return 'oauth' when CLAUDE_CODE_OAUTH_TOKEN is set and ANTHROPIC_API_KEY is not.

    ANTHROPIC_API_KEY still wins, matching Claude Code's own authentication
    precedence. Only this harness selects 'oauth': a subscription token is a
    Claude Code credential, and OpenCode and Codex have no path to use it.
    """
    credential_env = env if env is not None else os.environ
    mode = super().auth_mode_for_env(credential_env)
    if mode == "vertex" and credential_env.get("CLAUDE_CODE_OAUTH_TOKEN"):
        return "oauth"
    return mode

OpenCodeHarness

Bases: Harness

OpenCode CLI harness.

build_otel_exec_env(otel_port=None, traceparent=None)

Return OTel env vars for OpenCode.

See docs/otel-configuration.md for why these differ from Claude Code.

Source code in src/agentic_ci/harness.py
def build_otel_exec_env(self, otel_port=None, traceparent=None):
    """Return OTel env vars for OpenCode.

    See docs/otel-configuration.md for why these differ from Claude Code.
    """
    if not otel_port:
        return []
    env = [
        "--env",
        f"OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:{otel_port}",
        "--env",
        "OTEL_EXPORTER_OTLP_PROTOCOL=http/json",
        "--env",
        # Flush spans immediately -- OpenCode's process.exit() kills the
        # Node.js process before the batch processor can drain its queue.
        "OTEL_BSP_SCHEDULE_DELAY=0",
    ]
    if traceparent:
        env.extend(["--env", f"TRACEPARENT={traceparent}"])
    return env

CodexHarness

Bases: Harness

OpenAI Codex CLI harness.

auth_mode property

Codex uses OpenAI credentials, not Anthropic or Vertex.

auth_mode_for_env(env=None)

Codex always uses its OpenAI-compatible authentication path.

Source code in src/agentic_ci/harness.py
def auth_mode_for_env(self, env=None) -> str:
    """Codex always uses its OpenAI-compatible authentication path."""
    return "openai"

run_settings_toml(model, extra_args=None, otel_endpoint=None)

Return the config.toml lines that mirror a run's command-line settings.

Covers the model, the update check, the reasoning efforts that :meth:effort_args put in extra_args, and the OTLP exporters, so a codex exec the agent starts without those flags (such as the implement and review agents a skill dispatches) uses the same model, effort and telemetry. Every line is a top-level key or dotted key. No credential is ever included.

Source code in src/agentic_ci/harness.py
def run_settings_toml(self, model, extra_args=None, otel_endpoint=None):
    """Return the ``config.toml`` lines that mirror a run's command-line settings.

    Covers the model, the update check, the reasoning efforts that
    :meth:`effort_args` put in *extra_args*, and the OTLP exporters, so a
    ``codex exec`` the agent starts without those flags (such as the
    implement and review agents a skill dispatches) uses the same model,
    effort and telemetry. Every line is a top-level key or dotted key. No
    credential is ever included.
    """
    lines = [
        f"model = {_toml_string(model)}",
        "check_for_update_on_startup = false",
    ]
    overrides = _codex_config_overrides(extra_args)
    for key in _CODEX_EFFORT_KEYS:
        match = _CODEX_EFFORT_VALUE_RE.fullmatch(overrides.get(key, ""))
        if match:
            lines.append(f"{key} = {_toml_string(match.group(1) or match.group(2))}")
    if otel_endpoint:
        lines.extend(f"{key} = {value}" for key, value in self._otel_settings(otel_endpoint))
    return "\n".join(lines) + "\n"

create_harness(name)

Create a harness instance by name.

Source code in src/agentic_ci/harness.py
def create_harness(name: str) -> Harness:
    """Create a harness instance by name."""
    if name == "claude-code":
        return ClaudeCodeHarness()
    elif name == "opencode":
        return OpenCodeHarness()
    elif name == "codex":
        return CodexHarness()
    else:
        raise ValueError(
            f"Unknown harness: {name!r}. Choose 'claude-code', 'opencode', or 'codex'."
        )