Skip to content

Latest commit

 

History

History
684 lines (581 loc) · 194 KB

File metadata and controls

684 lines (581 loc) · 194 KB

AGENTS.md

Project Overview

Conductor is a CLI tool for defining and running multi-agent workflows with the GitHub Copilot SDK. Workflows are defined in YAML and support parallel execution, conditional routing, loop-back patterns, and human-in-the-loop gates.

Common Commands

# Install dependencies
make install          # or: uv sync
make dev              # install with dev dependencies

# Run tests
make test                                           # all tests
uv run pytest tests/test_engine/test_workflow.py   # single file
uv run pytest -k "test_parallel"                   # pattern match

# Run tests with coverage
make test-cov

# Lint and format
make lint             # check only
make format           # auto-fix and format

# Type check
make typecheck

# Run all checks (lint + typecheck)
make check

# Run a workflow
uv run conductor run workflow.yaml --input question="What is Python?"

# Run with web dashboard
uv run conductor run workflow.yaml --web --input question="What is Python?"

# Run in background (prints dashboard URL and exits)
uv run conductor run workflow.yaml --web-bg --input question="What is Python?"

# Stop a background workflow
uv run conductor status                 # list background workflows, never stops one
uv run conductor status --json          # machine-readable
uv run conductor stop                  # auto-stop if one running, list if multiple
uv run conductor stop --port 8080      # stop specific port
uv run conductor stop --all            # stop all background workflows

# Update conductor
uv run conductor update                # check for updates and print the install-script command
uv run conductor update --apply        # launch the installer automatically (conductor exits to release file locks)

# Resume a failed workflow from checkpoint
uv run conductor resume workflow.yaml                  # resume from latest checkpoint
uv run conductor resume workflow.yaml --web            # resume with dashboard
uv run conductor resume workflow.yaml --web-bg         # resume with background dashboard
uv run conductor resume workflow.yaml --provider copilot
uv run conductor resume workflow.yaml -m tracker=ado
uv run conductor checkpoint list       # list available checkpoints

# Acquire and inspect a workflow's git-backed plugins
uv run conductor plugin fetch workflow.yaml   # prime the cache (the CI step)
uv run conductor plugin list workflow.yaml    # what a run would load; cache-only

# Validate a workflow
uv run conductor validate examples/simple-qa.yaml
make validate-examples    # validate all examples

Releasing

Releases are tag-triggered: pushing a v* tag runs .github/workflows/release.yml, which lints, typechecks, tests (Python 3.12 + 3.13), builds the package, and creates a GitHub Release with artifacts and auto-generated notes. The maintainer prepares a release-prep PR (chore(release): cut X.Y.Z) that bumps version in pyproject.toml, finalizes CHANGELOG.md (Unreleased → versioned section), and re-locks uv.lock (uv lock); after it merges, tag the merge commit on main and push the tag. The version lives only in pyproject.toml (read at runtime via importlib.metadata); there is no separate __version__ to edit. The default bump is the patch ("build") number. See docs/release-checklist.md for the full step-by-step checklist.

Architecture

Core Package Structure (src/conductor/)

  • cli/: Typer-based CLI. Hot-path verbs stay flat (run, resume, validate, show, stop, replay, update, doctor, guide); the long tail is grouped under noun sub-apps registered via app.add_typer(...)registry, plugin (→ list / fetch), mcp (→ serve), checkpoint (→ list), and gate (→ respond) — with rich_help_panel= organising the root --help into Run & Recover / Author & Inspect / Environment / Interact / State sections (rendered order; determined by first-occurrence order of commands in the Click command list). The old flat commands checkpoints and gate-respond remain as hidden deprecated aliases that print a stderr deprecation warning (noting removal in a future release) and forward to the new grouped commands via a shared impl (issue #275).

    • app.py - Main entry point, defines the Typer application, flat commands (including guide, rich_help_panel="Interact"), and the hidden checkpoints/gate-respond deprecation aliases. status also lists recently-completed runs, not just live ones (R1) — see the fleet.py bullet below for the identical change to fleet list; status --json keeps its existing running array unchanged and adds a sibling completed array, and --live restores the exact pre-change scope on both the table and the JSON payload.
    • guide.py - guide_impl(text, port, token) behind conductor guide (issue #400): resolves the dashboard port via pid.py::scan_pid_files() when --port is omitted (read-only, matching app.py::status's reasoning), POSTs {"text": ...} to POST /api/guidance, and maps 403/409/422/connect-error the same way cli/gate.py::_gate_respond_impl does. guide is a flat top-level command (not a guide respond sub-app) since there is exactly one verb
    • checkpoint.py - checkpoint group (checkpoint list) + shared _list_checkpoints_impl (modeled on registry.py)
    • gate.py - gate group (gate respond) + shared _gate_respond_impl (modeled on registry.py). Also consumed directly by the Fleet Manager TUI's gate-resolve action (conductor.fleet.tui.actions.resolve_gate) — see the fleet bullet below.
    • registry.py - registry group (list / add / remove / set-default / update / show)
    • plugin.py - plugin group (list / fetch). Deliberately no update: a floating ref self-updates and a pinned one is meant not to. fetch existing as a separate verb is what keeps conductor validate off the network
    • mcp.py - mcp group (mcp serve, issue #432): starts an MCP server over stdio exposing every configured registry's workflows as tools (--registry/--allow/--deny/--workflow-dir narrow the exposed set; --toolsets/--max-direct-tools/--max-wait-seconds/--tool-prefix/--max-concurrent-runs/--introspect-full control behavior). no_args_is_help=True like checkpoint/gate — this group has no bare-invocation default, unlike fleet. Every reference to conductor.mcp.* is a lazy import inside serve()'s body, deliberately: conductor.mcp.__init__ (the existing MCP client package, mcp/manager.py) eagerly imports the full mcp SDK as a side effect of its own __init__.py, and cli/app.py imports every cli/*.py sub-app module on every conductor invocation — so a top-level import here would pay that SDK-import cost for every command, not just mcp serve. See docs/mcp-server.md for the user-facing guide and src/conductor/mcp/serve/ below for the server itself.
    • fleet.py - fleet group (list, prune). The one deliberate deviation from the checkpoint/gate/registry sub-app pattern: fleet_app sets invoke_without_command=True rather than no_args_is_help=True, because the bare conductor fleet (no subcommand) launches the interactive Textual TUI (Fleet Manager E7) — the TUI is the feature here, not a missing default. Do not "fix" this to match the other three sub-apps. The TUI is behind the optional tui extra; a TEXTUAL_AVAILABLE flag (checked only in the bare-invocation callback, mirroring providers/aca.py's AZURE_IDENTITY_AVAILABLE) prints an install hint and exits non-zero rather than raising ImportError when textual isn't installed. That hint comes from install_hint.py::install_command("tui") and is printed with soft_wrap=True so rich never breaks the copy-pasteable command across lines — do not re-hardcode pip install 'conductor-cli[tui]', which cannot work (issue #441). fleet list/fleet prune need no optional dependency. fleet list also lists recently-completed runs, not just live ones (R1) — a contract change to what the command means, bounded by [fleet.retention].keep_last and sourced from fleet/records.py::read_terminal_records; --live restores the exact pre-change scope. See src/conductor/fleet/ below for the TUI itself.
    • doctor.py - doctor diagnostics rendering (thin presentation layer over providers/diagnostics.py)
    • run.py - Workflow execution command with verbose logging helpers
    • bg_runner.py - Background process forking for --web-bg mode. Captures the detached child's stdout/stderr to $TMPDIR/conductor/conductor-<name>-<ts>-<runid>.bg.{stderr,stdout}.log so silent crashes (uncaught Python exceptions, faulthandler dumps) leave a forensic trail — DEVNULL is not used for stdout/stderr. Passes CONDUCTOR_RUN_ID, CONDUCTOR_BG_STDERR_LOG, and CONDUCTOR_BG_STDOUT_LOG to the child via env so the child's EventLogSubscriber shares a run id with the bg log files and surfaces both paths in workflow_started system metadata. Returns a BackgroundLaunch dataclass (url, stderr_log, stdout_log, run_id, workflow_started, still_running, run_record_written). The launch health gate's run-record poll is a readiness signal, not a kill switch (Fleet Manager D2, hardened by issue #435): _finalize_background_launch waits for the dashboard to become reachable and then polls conductor.fleet.records.read_run_record(run_id) until the child has written its own record (matched on mode/port, and either pid equality or freshness — see below). If that poll's own 15s deadline passes while the child is confirmed alive and its dashboard is still reachable (a fresh 1s _wait_for_server re-probe), the gate logs a warning naming the run id and stderr log and lets the launch proceed with run_record_written=False rather than terminating a healthy workflow over a failed diagnostic write — cli/app.py::_print_web_bg_no_run_record_notice surfaces this to the user (verbose mode only, alongside the other --web-bg notices) and the fleet TUI's New Run screen does the same via a warning notify(). Only a child that is actually dead, or whose dashboard has gone unreachable, still fails the launch (terminate + raise), unchanged from before. The parent no longer writes a .pid file itself — write_pid_file (cli/pid.py) was removed as part of the original D2 change, since that was its only call site. Do not restore parent-side PID writing; a future bg-launch change belongs in this poll gate, not a reinstated write_pid_file. launch_background_resume adopts the run id from the resolved checkpoint (_peek_resume_run_id) instead of minting a fresh one, so the run record, /api/info, the events JSONL, and the capture-log filenames all agree on one id across a resume. Readiness is three-staged. Stage one, _wait_for_server, checks proc.poll() on every iteration of its socket-connect loop (keyword-only proc param), so a child that dies before binding the port is reported in well under a second instead of after the full 15s timeout. Stage one-and-a-half is the run-record poll above — a stronger signal than the write_pid_file it replaced, since the child only writes the record once it is executing. Stage two, _wait_for_workflow_start, polls GET /api/info for up to CONDUCTOR_WEB_BG_START_TIMEOUT seconds (default 30, 0 disables the probe) until the payload carries a started_at key (not truthiness — it can legitimately be 0), proving the engine actually emitted workflow_started rather than just its HTTP server coming up (issue #410). StartProbe enumerates the four outcomes (STARTED / CHILD_EXITED / PORT_CONFLICT / TIMED_OUT), mirroring the Liveness/Identity enum pattern in cli/pid.py/cli/app.py. A CHILD_EXITED with a non-zero code (or PORT_CONFLICT, when /api/info reports an identity that positively does not match this launch's confirmed identity — see below) removes the child's run record via _remove_dead_child_record (identity-checked on pid, since a resumed launch can carry a checkpoint's original run_id) and raises RuntimeError with a bounded stderr-log tail (_tail_log); a clean exit-0 or a TIMED_OUT with the child still alive both return normally — the latter as workflow_started=False, which cli/app.py surfaces as a "still initializing" note rather than a failure. still_running is re-polled after the gate returns so a sub-second run is never advertised with a live dashboard URL. Identity, not Popen.pid, is what stage two compares against (issue #444). Popen.pid is not always the pid of the process that ends up running the workflow: a trampoline sys.executable (e.g. a Windows uv tool install, the documented install path) re-execs into a different one, so comparing stage two's /api/info payload against proc.pid produced a false PORT_CONFLICT on every port under that install path — and, one stage earlier, made the run-record poll's pid == proc.pid check never match the child's own record either, which is why the two symptoms (the spurious "did not report a run record" note, then the bogus port error) appeared together. _confirmed_pid_from_record accepts a record whose pid differs from proc.pid when the record is fresh (_record_is_fresh: its started_at parses to a timezone-aware timestamp at or after launched_at, captured immediately before _spawn_detached) — freshness is what a stale-record concern actually needs, and proc.pid equality was never it under a trampoline. Once a record is confirmed, its pid (not proc.pid) is carried forward as confirmed_child_pid and handed to _wait_for_workflow_start, which classifies the dashboard's reported identity (pid first, run_id as fallback) via _classify_dashboard_identity/_DashboardIdentity (mirroring cli/app.py's Identity/_confirm_identity). A mismatch (FOREIGN) is only fatal when confirmed_child_pid is not None; an unconfirmed mismatch (confirmed_child_pid is None — the issue #435 downgrade path, or a resume whose predicted run id never matched) keeps polling instead of killing a possibly-healthy run on unproven suspicion, degrading at worst to the existing non-fatal TIMED_OUT note. The PORT_CONFLICT error message now names the foreign pid captured before _terminate_child runs (via the probe loop's own last-seen payload) rather than probing /api/info after the child is already dead, which previously always rendered (PID unknown). Termination reaches the whole process tree, not just Popen.pid (issue #447). On Windows, _spawn_detached_windows creates the child suspended (CREATE_SUSPENDED) and assigns it to a fresh job object (_create_job_object, JOB_OBJECT_LIMIT_BREAKAWAY_OK set, deliberately not JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE so the tree outlives the launcher) before resuming its primary thread (ResumeThread, always called from a finally so a failed job assignment can never leave the child permanently suspended) — this closes the window a trampoline sys.executable could otherwise re-exec through before the job could be created. _WindowsDetachedProcess (the Popen-shaped wrapper this requires, since CPython's own Popen closes the child's thread handle before __init__ returns, making suspend-then-assign impossible on top of it) exposes terminate_tree() (TerminateJobObject, reaching every process in the job regardless of exec depth). On POSIX, start_new_session=True already makes the child a process-group leader, so os.killpg is the equivalent — gated by _SPAWNED_GROUP_LEADERS, a registry of pids this module actually spawned, so it is never called against an arbitrary pid. _terminate_child now returns a _TerminationOutcome (confirmed: bool, surviving_pids: tuple[int, ...]) rather than a bare None: after the tree kill and the original single-handle terminate/wait/kill ladder, a final identity-checked sweep (conductor.cli.pid.is_process_alive/terminate_process) over every pid this call knows about (proc.pid and, if different, confirmed_child_pid) actually confirms the outcome instead of assuming it. _remove_dead_child_record is now keyed on the confirmed pid (via the shared _cleanup_record_after_termination helper) — but only fires once that pid is confirmed dead by the sweep, so a surviving orphan keeps the run record that is conductor stop's only remaining handle on it. _finalize_background_launch has five terminate-and-raise branches; only the three with a pid to key on (dashboard-unreachable, CHILD_EXITED, PORT_CONFLICT) call _cleanup_record_after_termination. The other two — both inside the run-record-poll wait, one on a read failure and one on the poll's own 15s deadline going fatal — terminate without removing anything, because neither ever had a confirmed pid to begin with: no record was ever read successfully (or ever confirmed) on those paths, so there is nothing yet keyed to a pid for the helper to clean up. _termination_note renders the corresponding half of each failure message: "The background process was terminated." only when confirmed is true; otherwise a warning naming the surviving pid(s) and pointing at conductor status / conductor stop --port. A keyword-only cwd: Path | None = None (issue #477, default None) threads through _spawn_detached_posix/_spawn_detached_windows/_spawn_detached/_spawn_bg_child/launch_background/launch_background_resume to subprocess.Popen(cwd=...) (POSIX) / _winapi.CreateProcess's current_directory argument (Windows, both call sites) -- the child's own os.getcwd() is what engine/workflow.py stamps as system.cwd. None on every existing CLI path, so --web-bg/resume --web-bg are unaffected; only the Fleet TUI's New Run screen (fleet/launch.py) supplies one. _spawn_bg_child validates cwd is an existing directory before opening the bg log files, raising a named RuntimeError rather than letting a bad path reach Popen as an indistinguishable FileNotFoundError. Both launch_background and launch_background_resume always invoke the child with -P (not a conditional env var), so the interpreter never puts the child's cwd on sys.path[0] -- a chosen directory containing a stray conductor/ package would otherwise shadow the installed one -- without leaking a PYTHONSAFEPATH env var to the workflow's own type: script steps, which rebuild their environment from os.environ (see executor/script.py). The workflow path reaching launch_background must be absolute once a caller sets cwd -- see fleet/launch.py below, which is what actually absolutises it.
    • pid.py - Legacy PID file utilities, retained only so a still-running pre-upgrade background process (one that wrote its .pid file before Fleet Manager D2 removed write_pid_file) can still be discovered and cleaned up by stop/fleet list. Every current run path uses conductor.fleet.records (run_id-keyed JSON records) instead — see fleet/records.py below.
    • self_run.py - Answers "is this run record the run I am executing inside?" for conductor stop's self-exclusion (issue #399: an agent smoke-testing stop must not terminate its own workflow). Three signals, first match wins: (1) CONDUCTOR_RUN_ID or CONDUCTOR_SELF_RUN_ID matching the record's run_id — the former is set on a --web-bg child, the latter is exported by every run (cli/run.py) into its own environment so descendants inherit it, which is the only working signal for a foreground run off Linux (no port for signal 2, no /proc for signal 3); the two are separate names because engine/event_log.py reads CONDUCTOR_RUN_ID as "adopt this run id", which a nested conductor run must not do; (2) CONDUCTOR_WEB_BG/CONDUCTOR_WEB_PORT matching the record's port, but only when the record has no run_id (the pre-#411 compatibility path — a record with a present-but-different id never falls back to this signal); (3) process ancestry (/proc/<pid>/status PPid: walk + os.getsid(0)), POSIX-only — Windows relies on signals 1–2 alone. partition_own_run splits run records into others/own; stop targets others unless --allow-self is passed. Since the Fleet Manager it operates on RunRecords rather than PID-file dicts (read_run_records() already surfaces legacy .pid files in that shape), and reasons is keyed by PID rather than port because a foreground run has no port.
    • update.py - Update check and version comparison. Upgrades are delegated to the install script (install.ps1/install.sh); in-process self-upgrade was removed because on Windows the running Python interpreter sits inside the venv uv tool install --force is trying to recreate, which fails with "Access is denied". conductor update prints the OS-appropriate install-script one-liner; conductor update --apply spawns the installer detached (Windows: new console window; POSIX: os.execvpe replace) and exits the current process so file locks release. It also prints _print_extras_note — the extras install_hint.py::installed_extras() reads out of the uv receipt — because --force rewrites the tool's entire requirement set and an upgrade that named no extras used to silently uninstall [tui]/[aca] (issue #441); both install scripts read the same receipt and rebuild the source as conductor-cli[<extras>] @ <source>. The startup hint is suppressed by CONDUCTOR_NO_UPDATE_CHECK=1, --silent, --help/--version, and the update subcommand itself.
  • config/: YAML loading and Pydantic schema validation

    • schema.py - Pydantic models for all workflow YAML structures (WorkflowConfig, AgentDef, ParallelGroup, ForEachDef, etc.)
    • loader.py - YAML parsing with environment variable resolution (${VAR:-default}) and !file tag support
    • validator.py - Cross-reference validation (agent names, routes, parallel groups)
  • plugins/: Plugin resolution — the plugin as the unit of opt-in (issue #378)

    • manifest.py - The one definition of what a plugin root looks like. Recognises both .claude-plugin/plugin.json and .github/plugin/plugin.json (the latter was the gap: 12 of 13 plugins on an ordinary machine use it, and both resolve at runtime, so recognising only the former was Conductor's own bug). Also parses mcpServers in all three forms — a string path like ".mcp.json" (what every MCP-shipping plugin actually writes, so handling only the inline object would find zero servers on all of them), an inline object, or a conventional root .mcp.json. Deliberately a leaf (imports only plugins/errors.py) so skills/registry.py can import it without closing a cycle
    • agents.py - Parses agents/*.agent.md into PluginAgent specs named <plugin>:<agent>. tools: entries are the host CLI's vocabulary (read, ado/*) and are forwarded verbatim; user-invocable is deliberately not mapped (it gates a human menu, not model dispatch)
    • registry.py - resolve_plugins(entries, base_dir, home, marketplaces, declared_sources)ResolvedPlugin (skills, agents, mcp_servers, dropped, disabled). Reuses skills/registry.py::is_path_entry and expand_skills_root so plugins add no second opinion about what a path entry or a skill directory is. Entry classification is three-way and ordered: path first (so ./tools/my@plugin stays a path), then plugin@marketplace, then a bare installed name
    • sources.py - Parses the plugin_sources: source grammar (issue #380) into a PluginSource. Deliberately does not reuse is_path_entry: that returns True for anything containing /, so owner/repo would silently become a relative directory lookup. A local path is recognised by prefix (~, ., absolute) instead. Derives the three-segment <host>/<owner>/<repo> cache key, stripping credentials and ports so a token in a URL never becomes a directory name. A leaf module
    • marketplace.py - Reads a marketplace catalog out of a checkout. Recognises both .claude-plugin/marketplace.json and .github/plugin/marketplace.json, which — verified against a real repository shipping both — anchor their per-plugin source differently (repo-root-relative vs pluginRoot-relative), so both anchors are tried and whichever holds a plugin manifest wins. Catalog vs single-plugin is auto-detected; a repository that is both needs plugin: rather than having one picked for it. A catalog is fetched content, so a source or pluginRoot escaping the checkout is refused
    • fetch.py - Acquisition (issue #380). git ls-remote to resolve a floating ref, git clone --depth 1 into a temp dir published by atomic rename, readiness sentinel written last so a concurrent reader never sees a half-clone. Cache at $CONDUCTOR_HOME/cache/plugins/<host>/<owner>/<repo>/<sha[:12]>/, matching registry/cache.py::get_cache_base rather than the $XDG_CACHE_HOME the issue sketched, so there is one cache root. The _refs/<slug>.json pointer is load-bearing: without a record of what a floating ref last meant, the offline fallback has no checkout to choose. Ref patterns include the ^{} variants because git only emits the dereferenced line when asked — without them an annotated tag resolves to the tag object, a SHA no checkout ever equals
    • resolution.py - The single composition point between acquisition and resolution, analogous to skills/discovery.py::resolve_effective_skills. Both conductor run and conductor validate come through it and differ only in allow_network
    • errors.py - PluginError(ValueError) base + PluginNotFoundError / PluginManifestError / PluginSourceError / PluginFetchError / PluginSourceUnavailableError, mirroring skills/errors.py. The last is its own class because it is the one plugin failure that is not a problem with the workflow — the source is declared and well-formed and conductor run will fetch it — so config/validator.py downgrades it to a warning instead of an error
    • conductor/plugins/__init__.py re-exports nothing on purpose — skills.registryplugins.manifest and plugins.registryskills.registry, so an eager re-export would close that loop at import time
  • frontmatter.py: split_frontmatter(text) — the shared --- fenced-YAML splitter used by both SKILL.md and agents/*.agent.md. A leaf module (like duration.py) raising plain ValueError subclasses, so each caller phrases its own message. Exists because both formats are silently skipped by the downstream CLIs when the block fails to parse

  • skills/: Skill registry and loader (opt-in, bundled skill content)

    • registry.py - Resolves skills: entries to on-disk directories — built-in names (probing editable-install + wheel-install layouts) and filesystem paths, including expanding a skills/ root into its children. Also resolve_skill_plugin, which maps a directory to the Claude Code plugin that owns it
    • discovery.py - Scans well-known locations for installed skills (runtime.skill_discovery) and resolve_effective_skills, the single composition point for declared + discovered skills used by both AgentExecutor and conductor validate
    • frontmatter.py - Parses and validates SKILL.md YAML frontmatter with ruamel.yaml, requiring name + description. Exists because both downstream CLIs skip an unparseable skill silently; called from resolve_skills so conductor run is covered too, not just conductor validate
    • loader.py - Reads SKILL.md + references/*.md for providers that require eager preamble injection; wraps each skill in <skill name="..."> tags inside a <skills> envelope. Bounded by runtime.skill_injection. _read_file raises SkillManifestError rather than logging and skipping — and since lru_cache never memoizes a raising call, a transient read error is retried instead of frozen for the run
    • errors.py - SkillError(ValueError), the shared base for SkillNotFoundError / SkillPluginError / SkillManifestError. Its own module so registry and frontmatter can both use it without a cycle. Resolution and manifest failures originate in different modules but reach the same handlers, so call sites catch the one base rather than enumerating subclasses (_check_skill_injection_budget forgot one, and an unreadable references/*.md escaped conductor validate as a traceback)
    • Built-in skills live under plugins/conductor/skills/<name>/ (bundled into the wheel via hatchling force-include)
  • engine/: Workflow execution orchestration

    • workflow.py - Main WorkflowEngine class that orchestrates agent execution, parallel groups, for-each groups, and routing
    • context.py - WorkflowContext manages accumulated agent outputs with three modes: accumulate, last_only, explicit
    • router.py - Route evaluation with Jinja2 templates and simpleeval expressions
    • limits.py - Safety enforcement (max iterations, timeout)
    • checkpoint.py - Checkpoint save/load/list/cleanup + resume support. save_checkpoint(error=..., trigger=...) writes a top-level trigger typed CheckpointTrigger = Literal["failure", "periodic"]; error=None (periodic) writes null failure.error_type/message. No CHECKPOINT_VERSION bump — trigger is additive and any unknown/missing on-disk value normalizes to "failure" on load. rotate_periodic_checkpoints / cleanup_periodic_for_run both delegate to _delete_periodic_checkpoints(..., keep_last, action) (cleanup == rotate with keep_last=0), scoped to trigger == "periodic" and an exact run_id match so failure checkpoints and other runs' files are never touched. find_latest_checkpoint returns list_checkpoints(...)[0] (newest by microsecond created_at, not filename) so resume-latest isn't fooled by same-second periodic checkpoints. The engine saves a checkpoint inside its main-loop exception handlers; for a dashboard Stop/Kill that cancels the engine task from the CLI wrapper (bypassing those handlers), WorkflowEngine.handle_dashboard_stop(message) is invoked from cli/run.py::_execute_with_stop_signal after the cancelled task is drained — it writes a best-effort checkpoint and emits a single workflow_failed (flagged stopped_by_user: true, plus checkpoint_path or checkpoint_unavailable_reason). handle_dashboard_stop is idempotent via a dedicated _dashboard_stop_handled flag (not _last_checkpoint_path, which periodic checkpoints also set). Issues #244, #245. The listing command is conductor checkpoint list (the flat conductor checkpoints is a hidden deprecated alias, issue #275).
    • validator.py - OutputValidator runs the optional per-agent validator: block (issue #220): a second LLM call (synthetic agent via provider.execute, no tools, {passed, issues} schema) that grades the primary output against criteria. Fail-open on error/parse failure. The engine helper WorkflowEngine._apply_validator (in workflow.py) wires it into the main loop, parallel groups, and for-each loops; emits agent_validator_start / agent_validator_complete / agent_validation_failed; records a separate "<agent> (validator)" usage row; and re-runs the primary once with a ## Validation feedback section on failure (max_retries hard-capped at 1), continuing the provider's in-memory conversation when the primary output carries a continuation_state and rebuilding the prompt statelessly otherwise (see the Validator block pattern bullet).
    • guidance.py - GuidanceChannel (issue #400): a list[str] buffer + asyncio.Event, the inbound half of the existing outbound guidance machinery (WorkflowContext.user_guidanceget_guidance_prompt_section()AgentExecutor.execute(guidance_section=...)). submit(text) appends and sets the event (returns pending count); drain() pops everything and clears the event. Also MAX_GUIDANCE_CHARS + validate_guidance_text(text) — the shared "non-empty after stripping, at most 10,000 characters" check, called from both POST /api/guidance (web/server.py) and resume --guidance (cli/run.py::parse_guidance_flags) so a CLI-supplied entry can't skip the bound the HTTP path enforces. A leaf module with no conductor imports (like duration.py), so web/server.py never imports from engine/ to hand the engine a sink callable.
  • executor/: Agent execution

    • agent.py - AgentExecutor handles prompt rendering, tool resolution, and output validation for single agents
    • script.py - ScriptExecutor runs shell commands as workflow steps, capturing stdout/stderr/exit_code
    • set_step.py - SetExecutor evaluates Jinja2 expressions for type: set steps and binds typed values into the workflow context (no LLM, no subprocess). Supports single value: and multi values: forms with auto / explicit output_type: coercion.
    • wait.py - WaitExecutor pauses workflow execution for a parsed duration via asyncio.sleep. Races the sleep against the engine's interrupt_event so Esc/Ctrl+G cancels in-flight waits immediately; the workflow-level limits.timeout_seconds also cancels it via LimitEnforcer.wait_for_with_timeout. Output contract is strictly {"waited_seconds": float} per issue #218.
    • template.py - Jinja2 template rendering
    • output.py - JSON output parsing and schema validation. validate_output is deliberately strict with no coercion — it also validates set and script step output, where silently reshaping an authored value would be surprising. Response normalization belongs in providers/_output_shape.py instead. Note parse_json_output raises ValidationError for JSON syntax errors, so callers cannot distinguish syntax from schema failures by exception type alone.
  • duration.py: parse_duration(value) shared helper. Accepts plain int/float seconds, or strings with ms/s/m/h suffix. Raises ValueError (nests cleanly inside Pydantic ValidationError). Rejects booleans. Bounds enforcement (e.g. > 0, 24h cap) lives in callers so the parser can be reused.

  • run_id.py: RUN_ID_PATTERN_SOURCE / is_valid_run_id() / new_run_id() — the single fleet run_id contract (issue #435). A stdlib-only leaf (like duration.py/rundir.py) with no conductor imports, so engine/event_log.py (an always-on module) can depend on it without importing conductor.fleet.records, which would drag in conductor.cli.pid and, via conductor.cli.__init__'s from conductor.cli.app import app, close an import cycle — the same hazard rundir.py's docstring documents. Before this module existed, fleet/records.py and engine/event_log.py each enforced their own copy of the rule (a broad path-safe pattern vs. a narrower hex-only one that also lowercased its input), so a checkpoint run_id the fleet accepted could be silently folded into a different value by the event log, making a parent's launch-gate poll (cli/bg_runner.py::_finalize_background_launch) look for a key a resumed child never wrote and kill an otherwise-healthy run. RUN_ID_PATTERN_SOURCE is exported as a source string (not just a compiled pattern) so filename parsers that need to anchor a run_id inside a larger regex (fleet/history.py, fleet/retention.py, fleet/records.py's own _LOG_STEM_TIMESTAMP_RE) interpolate it directly instead of restating the charset. fleet/records.py re-exports is_valid_run_id for backward compatibility with existing importers (cli/bg_runner.py, the test suite) rather than owning the contract itself.

  • console.py: make_console() / styled() / join() — the markup-safety primitives (issue #406). A leaf module (like duration.py) with no conductor imports, deliberately top-level rather than under cli/ because gates/ and providers/ need it and must not import from cli/. make_console locks markup=False, inverting Rich's default so an interpolated runtime value is literal unless it asks to be styled; it rejects a markup= kwarg rather than allowing an override — on the constructor and on print/log, since rich's per-call markup=True would otherwise reopen the defect from a single line. styled("<template>", ...) parses the template's markup — conductor's own literal — while inserting values verbatim. It works by replacing each field with a length-matched filler run, parsing once, then locating each run to substitute the value back: matching the width keeps the parsed template's spans valid, which is what makes nested styling around a placeholder ([bold][red]{}[/red][/bold]) come out right. Reading spans[0].style and re-applying it — the obvious alternative — collapses the nesting. A value that is already a Text is spliced in with its own spans re-anchored, so pre-styled fragments compose (styled("{} {}", CHECK, name)); without that, format() would flatten it and silently drop the colour from every doctor table cell. join(sep, parts) exists because Text.join requires every part to already be a Text, while the common shape here is a content_lines list mixing conductor-styled fragments with plain runtime values. rich.markup.escape is deliberately unused throughout: the parser treats \[ as an escaped bracket, so \[0-9\]+ renders as [0-9\]+ whether escaped or not, whereas a Text is byte-exact. See the Console Output section under Code Style.

  • providers/: SDK provider abstraction

    • base.py - AgentProvider ABC defining execute(), validate_connection(), close()
    • _output_shape.py - normalize_agent_output(content, schema) — the single entry point providers call before validate_output (issue #343). It raises ValidationError when the parsed response is not a JSON object (a bare 42/null/array), because validate_output would otherwise either raise TypeError from a membership test (numbers, booleans, null) or report a misleading "missing required field" (strings, arrays). It then applies unwrap_scalar_wrappers: fires only when the schema declares string/number/boolean, a dict arrived, and exactly one candidate slot has the expected type. Candidate slots are the field's own name plus the generic value/result keys, deduped so a field literally named value or result isn't rejected as ambiguous against itself. Two matches count as ambiguous; any other key shape is ignored. Both are left untouched (same object identity) so the caller re-prompts rather than guessing — this is what stops {"answer": {"error": "..."}} being laundered into an answer. Every unwrap logs a warning, naming discarded sibling keys when there are any. Kept out of executor/output.py on purpose — see the note there.
    • _recovery_prompt.py - build_parse_recovery_prompt(...) — the plain-text re-prompt shared by Copilot and Hermes (issue #343). Both providers correct an unusable response the same way (error + truncated response + rendered schema, with distinct schema-failure vs syntax-failure wording), and that text is covered by the provider-parity rule, so it lives in one place instead of two copies free to drift. Claude is deliberately not a caller: it re-prompts through its emit_output tool and never echoes the schema, so its instruction text stays in claude.py::_build_recovery_instruction.
    • copilot.py - GitHub Copilot SDK implementation. By default spawns a nested copilot runtime via CopilotClient() (in _build_client, called from _ensure_client_started). When a runtime connection is resolved (runtime.provider.runtime_url or COPILOT_PROVIDER_RUNTIME_URL, optional runtime_token / COPILOT_PROVIDER_RUNTIME_TOKEN), it instead builds CopilotClient(connection=RuntimeConnection.for_uri(url, connection_token=token)) to connect to an already-running copilot --headless process; the SDK skips spawning for URI connections and its stop() leaves the externally-owned server running. _resolve_runtime_connection() reads YAML first, then the namespaced env var (env activates on its own — the zero-YAML path for external orchestrators). Runtime transport can be combined with custom model-provider routing. _ensure_client_started() also detects a spawned runtime whose child process has died (issue #483, _runtime_is_dead() polling the SDK's private _cli_process handle) and rebuilds the client under _start_lock (_restart_spawned_runtime) before returning, so the next SDK call lands on a fresh runtime; the rebuild invalidates _client/_started before constructing and starting the replacement, so a failed rebuild (e.g. OOM at spawn) cannot leave the provider believing a never-started client is started. A fixed, non-configurable cap (_MAX_CONSECUTIVE_RUNTIME_RESTARTS) on consecutive restarts with no intervening successful call prevents an infinite crash loop; a broken connection to an externally-owned runtime is never respawned. _spawned_runtime_process reads _cli_process (the spawned-child handle, None for URI and FFI connections) while _fix_pipe_blocking_mode reads _process (the transport handle — a SocketWrapper in TCP mode, an _FfiProcessAdapter with its own poll() in FFI mode); both are correct for their own purpose, and unifying the two reads onto _process would make FFI mode look like a killable child process.
    • claude.py - Anthropic Claude API provider using pydantic-ai (AnthropicModel) and the internal _pydantic_ai package (converters, events, mcp_toolset, agent_builder, interrupt, retry, structured_output, usage)
    • claude_agent_sdk.py - Claude Agent SDK implementation (uses claude-agent-sdk package)
    • factory.py - Provider instantiation
  • gates/: Human-in-the-loop support

    • human.py - Rich terminal UI for human gate interactions. read_multiline_lines(console, sentinel) is the shared blocking multi-line stdin reader behind both the human gate's . sentinel (MULTILINE_SENTINEL) and the dialog gate's /send (DIALOG_SUBMIT_SENTINEL); it returns (text, hit_eof) because an EOF that yielded no text is a deliberate dismissal while the sentinel with no text is an empty submission. sentinel is keyword-only and required, since the two gates use different ones
    • dialog.py - Dialog-mode gate. On a tty the main turn reads through read_multiline_lines, so a pasted block is one turn rather than one turn per line; off a tty it falls back to single-line Prompt.ask, where the sentinel has no effect — the _reads_multiline_turn() predicate states that rule once, and _display_dialog_start gates its sentinel hint on it so the banner never advertises a keystroke the reader ignores. Both submission guards compare against stripped text: the reader drops trailing newlines but keeps a whitespace-only line, so an exact == "" check would dispatch whitespace as a turn and turn Ctrl-D into a submission
  • interrupt/: Interactive workflow interruption (Esc/Ctrl+G to pause)

    • listener.py - Keyboard listener daemon thread for Esc/Ctrl+G detection
  • fleet/: The Fleet Manager (conductor stop run-record scope, conductor fleet) — see docs/fleet.md for the user-facing guide. Fixes the bug where a plain conductor run (no --web-bg) was invisible to conductor stop/discovery: every run path now writes a run_id-keyed JSON record (not the legacy port-keyed .pid file cli/pid.py used to write) describing its mode/PID/workflow/port, so foreground, --web-bg, and --web runs are all discoverable the same way.

    • records.py - RunRecord (nine fields, no more — a tenth tty field was considered and rejected as POSIX-only with pid already sufficient; mode is a RunMode = Literal["fg", "fg-web", "bg"] so the single write site is checked by ty, and an unrecognised mode read from disk normalises to "bg" rather than raising — a raise reaches _read_and_prune as corrupt, which deletes without checking liveness, so a newer Conductor's mode would make an older one delete a live run's record) + write_run_record / read_run_records / read_run_record / remove_run_record / remove_run_record_for_current_process. read_run_records() filters to processes that pass cli.pid.is_process_alive and tolerates legacy port-keyed .pid files (surfaced as mode="bg" records) alongside corrupt/partial JSON. Every Windows sharing-violation-prone operation — os.replace on write, os.unlink on remove (_safe_unlink), os.rename into quarantine on self-cleanup (_delete_if_unchanged), and os.link on quarantine restore (_restore_if_absent) — goes through one bounded retry helper (_retry_on_windows_sharing_violation, issue #486), so a transient reader no longer makes a delete fail outright; a violation that outlasts the retry budget is still logged and reported as False rather than silently succeeding. Also defines TerminalRunRecord (R1, DD1, P3, G5): a small tombstone cli/run.py's finally block writes to terminal_records_dir()/<run_id>.json — a dedicated subdirectory of the run-records directory, deliberately not alongside the live record, since read_run_records()'s and remove_run_record_for_current_process()'s non-recursive *.json globs would otherwise treat a same-directory tombstone as a corrupt live record (deleted regardless of liveness) or race it against the record it is meant to replace. It carries terminal status (success/failed), rendered output:, error type/message on failure, usage totals, and the run's event-log/capture-log paths, so a run_id answers "how did that run finish" after its process has exited — the artifact both conductor status/conductor fleet list's completed-runs section (R1, see cli/app.py/cli/fleet.py below) and the MCP server's conductor_run_status/conductor_run_events/conductor_run_logs tools (mcp/serve/, below) read from. write_terminal_record / read_terminal_record / read_terminal_records / remove_terminal_record mirror the live-record API; pruning is fleet/retention.py's job (DD13), matched by run_id to the same event log so a record never outlives — or is outlived by — the log it points at.
    • resume.py - The fleet layer's first checkpoint consumer (issue #460): correlate_checkpoints(entries) joins History's HistoryEntry rows against engine/checkpoint.py::CheckpointManager.list_checkpoints's on-disk checkpoints for the History screen's Resume action. The join key is the event log path (normalized via os.path.realpath, since both sides ultimately derive from tempfile.gettempdir()), with run_id as a documented fallback that is refused whenever that id is ambiguous across the scanned entries (a nested conductor invocation inherits CONDUCTOR_RUN_ID, so two logs can share one id). Gating a row's Resume availability is checkpoint existence + the checkpoint's recorded workflow_path existing on disk — never outcome: an unknown row from a crash is exactly the case a periodic checkpoint exists for, and a completed row can correlate to a stale checkpoint too (re-executing already-finished work is accepted, mitigated only by surfacing the checkpoint's provenance in the UI, not by hiding the key). Kept out of fleet/history.py deliberately — that module's docstring is emphatic that History is derived from event logs alone, and build_history_entries is patched by name in several existing tests.
    • summary.py - Derives a RunSummary (status/current-step/elapsed/tokens/cost/gate) from a streamed, uncapped read of a run's JSONL event log (issue #485) — stream_event_log is bounded only by the longest single line, not by file size or event count, replacing three separate bounded windows (a 512 KiB tail, a 512 KiB head recovery read, and an 8 MiB full-log cap) that a long or resumed run had already outgrown in production (a real 9.72 MB / 20,361-line log lost its current step, its token/cost totals, and — via the head window never firing for a resumed run's second workflow_started — reported the wrong topology). The Runs screen's ~2s poll passes keep_types=_SUMMARY_EVENT_TYPES so the reader skips an uninteresting line via a cheap regex prefilter without JSON-parsing it (12.5 ms measured against that same 9.72 MB log, vs. 65 ms unfiltered); the run-detail/step-detail screens read unfiltered since they need every event type. _scan_events is generation-aware: a resumed run always writes a second root workflow_started into the same log (the engine's own re-emit, or the dashboard-seeding path's synthesized copy), and reaching one resets status/gate/open steps and overwrites topology/workflow_name/cwd/inputs with the new generation's own — but does not reset token/cost totals, which accumulate across every generation (a resumed run's lifetime usage, not just its latest attempt's). Status is derived from explicit event markers (gate_presented/gate_resolved/workflow_completed/workflow_failed), never inferred from timing alone — the design's own measurement found that unreliable (228 false positives, 0 true positives). gate_resolvable (Fleet Manager D4) is computed once here: True whenever the record has a dashboard port (fg-web/bg), False for mode == "fg".
    • history.py - Enumerates every retained run directly from $TMPDIR/conductor/*.events.jsonl files (not run records, which are already gone by the time a run is history) for the History screen, regardless of outcome. Classifies each log by its terminal event (workflow_completed/workflow_failed); a log with neither is "unknown", never "running" — the same non-inference constraint summary.py follows, sharpened here since there is no run record to fall back on at all. The returned list is bounded by [fleet.retention].keep_last and, independently, by a fixed 200-entry display cap (so a keep_last configured below 1 — "unbounded" for the pruning sweep — still cannot grow the History screen without limit). _read_full_log (a single-pass generator feeding _scan_history_events) now delegates its actual line-reading to summary.stream_event_log (issue #485), which generalized the uncapped-streaming approach History pioneered first; it re-derives its own corrupt-vs-empty distinction from path.stat().st_size rather than a raw non-blank-line count, since the shared reader has no reason to track that for a live run's cheap repeated poll. _scan_history_events takes the latest root workflow_started's timestamp as started_at (issue #485, Q2) — mirroring summary.py's generation-reset — so a resumed run's duration_seconds fallback (ended_at - started_at, used absent an engine-reported elapsed) measures the current attempt, not the idle gap since the original one; _scan_history_events must therefore stay a single forward pass over its Iterable argument (issue #436). Each HistoryEntry is then enriched (_enrich_with_terminal_record, R1) with its rendered output: and failure error type/message from records.py::read_terminal_record(run_id) when one exists — the two fields the design named History as lacking — leaving them unset for a pre-upgrade log with no matching terminal record rather than treating a missing enrichment as corrupt data.
    • launch.py - Resolves a file path or registry reference (reusing registry/resolver.py::resolve_ref + registry/cache.py::resolve_and_fetch, the same pair conductor show uses) and calls cli.bg_runner.launch_background() directly for the New Run screen — never re-implements detached process spawning, per the design's explicit warning that doing so would make a TUI-launched run die with the TUI. launch_resume(checkpoint_path, ...) (issue #460) is the same pattern for the History screen's Resume action: it calls cli.bg_runner.launch_background_resume(workflow_path=None, checkpoint_path=...) directly rather than re-implementing detached spawning, since a HistoryEntry carries no workflow path of its own and the checkpoint is the only route to one. Both resolve_workflow and launch_workflow/launch_resume take the Fleet Manager's launch directory as an explicit argument (issue #477) -- base_dir on the former, cwd on the latter two, forwarded to cli.bg_runner.launch_background()/launch_background_resume() -- rather than ever reading the process cwd, so this module stays a pure function of its arguments; fleet/tui/app.py::FleetApp.launch_dir is the one place that directory is actually decided. resolve_workflow joins a relative file reference onto base_dir (a registry reference and an absolute reference both ignore it) and always returns an absolute ResolvedWorkflow.path -- Path(os.path.abspath(...)), not .resolve(), matching _resolve_agent_working_dir's "normpath, not resolve" convention so a symlinked project directory stays the alias the user typed. Absolutising is not optional: launch_background puts str(workflow_path) straight into a detached child's argv, and a relative path there resolves against whatever cwd that child happens to inherit, not the directory it was typed against.
    • retention.py - prune_event_logs(keep_last, dry_run) bounds $TMPDIR/conductor/*.events.jsonl (Fleet Manager D3), mirroring CheckpointManager.rotate_periodic_checkpoints's keep_last vocabulary. Never deletes the checkpoints/ subdirectory or an event log a live/resuming run still references; a retained/live log's .bg.stderr.log/.bg.stdout.log companions are kept or pruned alongside it. maybe_prune_event_logs() is the opportunistic-startup-sweep wrapper cli/run.py calls, gated by [fleet.retention].enabled and never raising.
    • tui/ - The Textual app (app.py, Screen push/pop stack) and its screens (screens/runs.py home; screens/run_detail.py; screens/step_detail.py; screens/providers.py; screens/registries.py; screens/new_run.py; screens/history.py; screens/splash.py), plus actions.py (shared stop/kill/gate-respond logic, reusing cli/app.py::stop_records and cli/gate.py::_gate_respond_impl rather than duplicating either), theme.py (the single status glyph/label/colour vocabulary — screens must not define local glyph maps), dag.py (step-status chips), anim.py (pure frame→glyph functions; animation is off by explicit CONDUCTOR_FLEET_NO_ANIM, on by explicit CONDUCTOR_FLEET_ANIM, off by default on a detected RDP session (SESSIONNAME starting RDP-Tcp) via is_remote_session(), and on otherwise — CONDUCTOR_FLEET_NO_ANIM always wins. SSH is deliberately not detected: it ships the ANSI byte stream for the local terminal to render (a few hundred bytes per frame), where RDP renders remotely and diffs/encodes/ships changed pixel regions, so only the latter is costly — and the real trigger is a slow link, for which there is no signal, only "SSH at all", which is usually fast. CONDUCTOR_FLEET_NO_ANIM is the remedy there and for VNC/Citrix/xrdp; a negative test pins this so it is not re-added as an oversight; any path that disables animation — explicit CONDUCTOR_FLEET_NO_ANIM or detection — also sets Textual's own App.animation_level = "none" in app.py::on_mount, issue #462), art.py, widgets.py, and notify.py (terminal bell / OSC 9 notifications, debounced to fire once per status transition). Optional: imported only by conductor fleet's bare invocation, gated behind the tui extra (floor textual>=8.0widgets.py::BlockFooter is written against 8.x Footer group rendering). The install command for that extra is resolved per install context by install_hint.py, never hardcoded. No blocking I/O on the event loop (issue #437): every screen that reads the filesystem on a timer, or on a path that can be slow, does it in a worker (@work async def + await asyncio.to_thread(<collector>)), with the render half running on the event loop after the await — the pattern screens/new_run.py::action_resolve/action_launch, screens/step_detail.py::load_step and screens/registries.py::load_workflows established first, now also screens/runs.py::_refresh_worker/_open_dashboard_worker, screens/run_detail.py::_refresh_worker, screens/history.py::load_history, and (not a screen, but the same reasoning) actions.py::kill_runs. Two deliberate exceptions, both documented at their definition: registries.py::load_registries reads one local registries.toml inline, and providers.py::load_providers is a @work method awaiting a natively async gather() — note that gather() still does its offline SDK-availability imports synchronously, so it is not yet an instance of this pattern. The two polled screens (runs.py, run_detail.py) additionally keep their refresh_runs()/refresh_detail() entry point as a synchronous dispatcher (still callable from on_mount, set_interval, and action handlers) guarded by a plain _refreshing boolean, so a tick arriving mid-scan is dropped rather than started alongside the in-flight one — deliberately not @work(exclusive=True), which would cancel the in-flight worker ("newest wins") instead of skipping the newer tick. Load-once screens (history.py::load_history) need no guard and are @work methods directly. The flag is set in the dispatcher, not as the worker's first line, because a @work body doesn't start until Textual schedules it; and it is released in a finally, because without that one transient error stops the screen refreshing for the rest of the session, silently and indistinguishably from a calm fleet. An explicit refresh (_kill_and_refresh, action_resolve_gate's finally) passes explicit=True and is coalesced via _refresh_pending rather than dropped: those callers promise the table reflects what they just did, and the scan they collide with started before it. Every screen shows theme.py::loading_text() in compose() until its first result lands. A failed load is surfaced, never degraded into an empty state — rendering "No runs" or "No run history yet" for an unreadable directory is a positive claim of absence that reads as success, so runs.py distinguishes an empty fleet from one whose summaries all failed (RunScan.failed/seen_run_ids) and history.py follows step_detail.py/registries.py's red-error-line convention. RunScan.seen_run_ids carries every record read, not just the rows that rendered, because the notifier is pruned against it — pruning against the rendered rows made a run whose summary briefly failed look brand-new and re-fire its gate notification. Test caution: a pilot test that presses a key opening a modal (kill confirm, gate options), or that deliberately suspends a worker on a threading.Event, and then needs a second keypress to resolve it, must use a plain await pilot.pause() between the two, never tests/test_fleet/conftest.py::settlesettle awaits app.workers.wait_for_complete(), and the suspended @work method won't finish until that second keypress, so awaiting it first deadlocks the test. history.py also correlates checkpoints in the same load_history worker as a second thread hop (issue #460, conductor.fleet.resume.correlate_checkpoints), gating its r/Resume binding via check_action the same way runs.py gates g/Gate -- hidden outright when the highlighted row has no correlated checkpoint, refreshed on on_data_table_row_highlighted so the footer never shows yesterday's answer as the cursor moves. The frame timer (~10fps, runs.py::_tick) may only repaint what actually moves (issue #462): the animated table cells and the preview pane's #run-preview-score widget. Everything else in the preview pane (the gate section, the Progress N/M header), and the footer's refresh_bindings(), belong to the ~2s data poll (_update_gate_detail) and to selection changes — that is the invariant a future edit is most likely to break by re-adding a convenient _update_gate_detail() call to _tick. app.py::FleetApp.launch_dir is an app-level, process-lifetime-only Textual reactive[Path] (issue #477) -- no config.toml key, no state file, reset to the process's cwd on the next conductor fleet -- read by screens/new_run.py (the base a relative workflow reference resolves against, and the cwd a launched run's detached child inherits) and mutated only through FleetApp.set_launch_dir, matching this module's push_*/return_to_runs one-named-mutation-site convention. actions.py::DirectoryPickerModal (opened via the shared change_launch_directory, bound to d on Runs and ctrl+d -- priority=True, since Input binds it first -- on New Run) is the only way to change it; a bad path is rejected in place (a red message line, modal stays on the stack) rather than dismissed. Its tree→input mirror (on_tree_node_highlighted) fires only while the tree has focus (issue #486): Textual posts a NodeHighlighted for the tree's own root as soon as the background DirectoryTree load lands, with no user interaction at all, and mirroring that automatic highlight silently replaced the prefilled launch directory with its parent before the user ever touched the tree. widgets.py::BlockFooter passes show_command_palette=False to Footer.__init__ (a real 8.x kwarg) so the Runs footer's docked ^p palette key -- not ctrl+p itself, which still opens it -- is hidden, reclaiming the columns d Dir needed once Providers/Registries were also shortened to Prov/Regs.
  • mcp/: manager.py is the existing MCP client (Conductor calling MCP tools, runtime.mcp_servers) — untouched by the server work below, though pyproject.toml's mcp>=1.28.1,<2 bound protects it too (a mcp 2.0.0 lock refresh renames the camelCase attributes it reads, e.g. Tool.inputSchema -> input_schema, turning every MCP tool connection into a runtime AttributeError with no import error to catch it).

    • serve/ - conductor mcp serve (issue #432, cli/mcp.py above): exposes Conductor workflows as MCP tools to any MCP-compatible host over stdio. The organising principle is that the server owns no execution state — it is a protocol adapter over three existing subsystems it never duplicates: the registry (catalogue), cli/bg_runner.py::launch_background (every invocation forks a real detached conductor run, never executing a workflow in-process), and fleet/ (every status/event/log query reads the same records and event logs the TUI does). options.py (ServeOptions, frozen) holds every startup argument — the single place NFR3 ("no tool accepts a filesystem path, URL, or registry source") is checkable at all, since any value not on it did not come from the operator. catalogue.py (build_catalogue) turns configuration into an immutable, byte-identical-per-connection list of mcp.types.Tools (DD3): the four-rung exposure ladder (--deny > --allow > workflow mcp.expose > default-on, DD4), a three-tier schema resolution ladder with a permissive degraded fallback rather than a silent drop (NFR2), and pinning every exposed workflow to an immutable identity (commit SHA or content hash, DD6). naming.py / sanitize.py / toolgen.py are the catalogue's naming (bare names, registry-qualified only on collision, DD10), description-sanitizing (NFR4), and JSON-Schema-generation (from WorkflowDef.input; no outputSchema, DD5) helpers. pinning.py computes and re-checks the DD6 identity. server.py wires the frozen catalogue onto mcp.server.lowlevel.Server and runs it over mcp.server.stdio.stdio_server(), prints the FR10 startup summary to a stderr-bound console (stdout is the JSON-RPC transport, DD9 — nothing else may touch it), and — for the introspect/diagnose toolsets, decided once at startup and never per-request (DD3) — dispatches to introspect.py (conductor_run_events with tool payloads reduced to {name, status, byte_size} unless --introspect-full, R4; conductor_node_detail; conductor_plan_tree) and diagnose.py (conductor_doctor, conductor_validate_workflow, and conductor_run_logsResourceLinks and bounded metadata only, never file contents, DD12). invoke.py launches a generated workflow tool's run and resolves its reserved _wait_seconds parameter (FR5) — never passes skip_gates=True to launch_background (DD11, asserted in a code comment naming the decision), and enforces --max-concurrent-runs via an in-process LaunchTracker (R3; resets on restart, consistent with "the server owns no execution state"). Non-blocking is the intended path and the tool schema has to say so: a model reading the original _wait_seconds description — three equally-weighted options with no recommendation — passed _wait_seconds: 300 unprompted and held its turn open for a five-minute run that was detached the whole time, which reads to a user as "the MCP server runs workflows in the foreground". So the description leads with leave this unset, states that the run is detached either way, and says setting it blocks only the caller's own call; the returned handle carries port, an observe block of terminal commands, and the capture-log paths so the caller has something to report instead of a reason to wait; and the immediate next action names conductor fleet before conductor_await_run, which it conditions on the user having actually asked to block. server.py::_DISCOVERY_TOOLS hand-writes a second copy of that same parameter's schema, so tests/test_mcp/test_serve_toolgen.py::TestWaitSecondsSteersTowardBackground asserts both copies steer identically. Every command in observe is resolved against the real Typer app by tests/test_mcp/test_serve_invoke.py::test_every_observe_command_actually_exists_in_the_cli — a plausible-looking invented one (conductor fleet list --json, which shipped in a draft and does not exist) fails in front of the user at the exact moment they were told how to watch their run. The operator-side hard guarantee is --max-wait-seconds 0: the ceiling applies to every blocking path including the mode: sync branch, so no invocation can block at all. runs.py is the runs toolset (conductor_run_status/conductor_await_run/conductor_cancel_run/conductor_list_runs), resolving a run_id through a live record, else the fleet/records.py::TerminalRunRecord, else an event-log fallback for a crashed run — the same three sources named which one answered. discovery.py is the two-tool fallback (conductor_find_workflow/conductor_run_workflow) served instead of per-workflow tools above --max-direct-tools (FR9, G7).
  • web/: Real-time web dashboard for workflow visualization

    • auth.py - OriginHostGuard (issue #397): a pure-ASGI middleware (not Starlette's BaseHTTPMiddleware / @app.middleware("http"), neither of which ever sees WebSocket scopes — a decorator-style middleware would leave /ws completely unguarded) registered via app.add_middleware(...) on both server.py and replay.py. Enforces Host/Origin validation on every http/websocket scope (a present Origin must match; an absent one is allowed, since httpx/curl/conductor gate respond send none), then token auth + a JSON Content-Type on mutating HTTP routes, then token auth on the /ws handshake — closing it via websocket.close in reply to websocket.connect, before accept(), so a rejected socket can never send any message type (gate_response, dialog_message, dialog_decline, iteration_limit_response all included). CONDUCTOR_WEB_ALLOW_ORIGINS (comma-separated full origins) extends the allowlist for a dev server (e.g. Vite's http://localhost:5173) without disabling the check for anything else. A per-run token is minted automatically (mint_token()) so the protected configuration is the default; resolve_expected_token(minted) lets CONDUCTOR_GATE_TOKEN override it, preserving the pre-#397 escape hatch. write_token_file/read_token_file/remove_token_file persist that token at ~/.conductor/runs/dashboard-<port>.token (mode 0600 on POSIX, atomic temp-file + os.replace; on Windows the mode bits are not honoured, so the file is protected by the user-profile NTFS ACL instead, issue #425) so a separate CLI invocation (conductor gate respond, conductor guide, conductor stop's graceful-kill rung) can discover it without a flag or env var; resolve_cli_token(port, token) is the shared --token > CONDUCTOR_GATE_TOKEN > token-file resolver all three call. token_from_scope(scope) reads Authorization: Bearer first, then the token query param — the latter exists only because a browser cannot set handshake headers, and is an acceptable exposure only because both dashboard apps run uvicorn with log_level="warning" (no access logs to leak the query string into).
    • server.py - FastAPI + uvicorn server with WebSocket broadcasting, late-joiner state replay, and POST /api/stop + POST /api/kill endpoints. /api/stop interrupts/pauses the current agent (a user can then Resume or Kill); if it arrives before the engine binds its interrupt event, it is latched via _pending_stop and drained by set_interrupt_event so the startup window takes the graceful pause path instead of a progress-losing hard stop. /api/kill hard-stops the run. Whenever a stop/kill actually terminates the run (cancels the engine task), it routes through handle_dashboard_stop so a best-effort checkpoint is written (or its absence explained) — see handle_dashboard_stop above (issue #245). start() mints and writes the run's token file once _actual_port resolves (works for both --web and --web-bg, since cli/run.py calls start()/stop() on both the run and resume paths and the --web-bg child goes through the same code); stop() removes it. GET / reads static/index.html and injects <script>window.__CONDUCTOR_TOKEN__="..."> before </head> (HTMLResponse rather than the old plain FileResponse), keeping the existing Cache-Control: no-cache. _gate_token_ok now always requires a match against the resolved token (env override else minted) — the pre-#397 "allow everything when the env var is unset" branch is gone. The WebSocket handler's per-message gate-response check no longer re-checks the token: the handshake (via OriginHostGuard) already authenticated the connection, so an unauthenticated socket never reaches the loop that reads gate_response/dialog_message/dialog_decline/iteration_limit_response at all.
    • frontend/ - React 19 + TypeScript dashboard source (Vite + Tailwind). Renders the workflow DAG with React Flow (@xyflow/react) laid out via dagre, a Zustand store (stores/workflow-store.ts), an agent detail panel, and streaming activity. Build with make build-frontend (outputs to static/); unit tests via make test-frontend (Vitest). Subworkflow nodes support inline expand/collapse (nested React Flow containers, collapsed by default) in addition to double-click drill-down — see issue #314. lib/auth.ts (issue #397) reads window.__CONDUCTOR_TOKEN__ (getToken()), builds the Authorization header for mutating fetches (authHeaders()), and appends ?token= to the /ws URL (withToken(), since the WebSocket handshake can't carry custom headers). ReplayDashboard's / injects nothing, so getToken() returning undefined there is expected and harmless — that app has no mutating routes or /ws to authenticate. In dev, vite.config.ts's transformIndexHtml hook (scoped to apply: 'serve', not the production build) injects process.env.CONDUCTOR_GATE_TOKEN the same way, and the /ws proxy entry needs changeOrigin: true (the /api proxy already had it) or the handshake arrives with Host: localhost:5173 and fails OriginHostGuard's host check.
    • static/ - Built dashboard assets served by server.py (generated from frontend/ by make build-frontend; committed to the repo). Not edited by hand.
  • install_hint.py: install_command(extra) — resolves a working install command for an optional extra (tui / aca / claude-agent-sdk) from the detected install context, plus read_receipt() / installed_extras() which parse <sys.prefix>/uv-receipt.toml. A stdlib-only leaf (like duration.py, console.py, rundir.py) deliberately top-level rather than under cli/, because providers/ needs it and must not import from cli/. Three branches: a uv receipt → uv tool install --force '<spec>'; an editable direct_url.jsonuv sync --inexact --extra <extra>; neither → pip install 'conductor-cli[<extra>]'. The receipt is checked first because a uv tool install writes both files. Every hint used to hardcode the pip form, which cannot work on the documented install path — a uv tool venv is not pip-managed and conductor-cli is not on PyPI, so pip has nothing to resolve against unless the distribution is already installed in the target environment (issue #441). It survives as the last-resort fallback, rendered as <sys.executable> -m pip install (a bare pip does not manage a pipx venv, where it would succeed while installing a second copy the user never runs) with the git URL from direct_url.json appended when there is one, so a pip/pipx-from-git install — which is what actually lands in the UNKNOWN branch — still resolves. PEP 610's archive_info is deliberately not used as a source: a wheel or sdist is one artifact, usually a download since cleaned up, so pinning to it emits a command that fails. Four things are load-bearing: already-recorded extras are unioned with the requested one (--force rewrites the tool's whole requirement set, and uv sync is exact unless given --inexact, so naming only the new extra uninstalls the others); the recorded install source is reused (the receipt's git/directory/url key, else direct_url.json, else upstream pinned to v<version>) so the hint never redirects a fork or a locally-built install at upstream's released tag; a receipt that exists but cannot be read or understood is readable=False rather than an empty extras set, because the two would otherwise render the same command and one of them silently uninstalls the user's extras — that case appends an inline # WARNING shell comment naming the receipt (carried on InstallEnvironment so the renderer stays pure); the two install scripts draw the same line and warn and continue rather than aborting, since a broken receipt is exactly the state a reinstall repairs; and install_command never raises, since it runs inside a ProviderError(...) argument expression where an exception deletes the diagnosis instead of degrading the hint. Branch logic lives in the pure render_install_command(extra, InstallEnvironment) so all three contexts are unit-testable without a real install; detect_environment() is the impure half, and InstallEnvironment.__post_init__ drops extras/source on the editable branch so the type cannot hold state the renderer ignores. install.sh/install.ps1 parse the same receipt in shell for the same reason — see receipt_extras / Get-ReceiptExtras, whose agreement with this module is asserted by a shared-oracle test rather than assumed.

  • rundir.py: runs_dir() -> Path — the single definition of ~/.conductor/runs/ (mkdir(parents=True, exist_ok=True)). A stdlib-only leaf (like duration.py, console.py) so web/auth.py can depend on it without importing conductor.cli.pid, which would drag in the whole Typer app (conductor/cli/__init__.py does from conductor.cli.app import app, and app.py itself reaches conductor.web.server — a genuine import cycle). cli/pid.py::pid_dir() delegates to it too, so both the PID-file registry and the dashboard token files (issue #397) share one directory and one home-isolation seam for tests. Callers reference it via from conductor import rundir; rundir.runs_dir() rather than importing the bare name, so a test can monkeypatch the one attribute (conductor.rundir.runs_dir) and every caller sees the patch — importing the name directly would bind a separate reference that a patch on the defining module can't reach.

  • events.py: Pub/sub event system decoupling workflow execution from rendering (console, web dashboard)

  • exceptions.py: Custom exception hierarchy (ConductorError, ValidationError, ExecutionError, etc.)

  • settings.py: ConductorSettings / FleetSettings / FleetRetentionSettings (Pydantic models) + load_settings() — the machine-wide ~/.conductor/config.toml (honors $CONDUCTOR_HOME, mirroring registry/config.py::get_config_path), read with stdlib tomllib. Read-only in v1: no conductor config set, no in-process writer — hand-edited, documented in docs/configuration.md. A missing file yields defaults; a malformed file raises for an explicit reader (conductor fleet prune with no --keep-last override) but is swallowed by the opportunistic startup sweep (conductor.fleet.retention.maybe_prune_event_logs), since a machine-wide settings file must never break conductor run.

  • telemetry/: Safe optional initialization and event-driven OpenTelemetry tracing. Activated via the standard OTEL_EXPORTER_OTLP_ENDPOINT environment variable. It builds a unified trace tree by nesting native provider spans directly under Conductor's orchestration spans, checking each provider's native_otel_spans capability to prevent duplicate tool spans, and registers a delegating global tracer provider (_DelegatingTracerProvider) to dispatch spans to the active workflow run. Native Copilot spans are captured from the CLI child process when using the http/protobuf or http/json OTLP protocol. Standard gRPC (the default protocol) disables native Copilot spans and triggers a single per-run warning. The dynamic native_otel_spans_active field on agent start events signals if native spans are active. Conductor uses a run-latched OTLP protocol and endpoint shared by the engine and the Copilot client to prevent changes mid-run.

Workflow Execution Flow

  1. CLI parses YAML via config/loader.pyWorkflowConfig
  2. WorkflowEngine initializes with config and provider
  3. Engine loops: find agent/parallel/for-each/script/set/wait → execute → evaluate routes → next
  4. Parallel groups execute agents concurrently with context isolation (deep copy snapshot)
  5. For-each groups resolve source arrays at runtime, inject loop variables ({{ item }}, {{ _index }}, {{ _key }})
  6. Script steps run shell commands via asyncio subprocess, expose stdout/stderr/exit_code to context
  7. Set steps render Jinja2 expressions and bind typed values to context (no LLM, no subprocess) via the shared WorkflowEngine._run_set_step helper, which enforces output: schema in all three positions (main loop, parallel group, for-each iteration) and emits set_started / set_completed / set_failed
  8. Wait steps pause via asyncio.sleep (cancellable by interrupt or workflow timeout); expose {"waited_seconds": float} to context
  9. Routes evaluated via Router using Jinja2 or simpleeval expressions
  10. Final output built from templates in output: section

Key Patterns

  • Context modes: accumulate (all prior outputs), last_only (previous only), explicit (only declared inputs)

  • Failure modes for parallel/for-each: fail_fast, continue_on_error, all_or_nothing

  • Route evaluation: First matching when condition wins; no when = always matches

  • Tool resolution: null = all workflow tools, [] = none, [list] = subset

  • Set step typing: output_type defaults to auto (safe YAML parse with _to_json_safe normalisation — datetime/date/time → ISO 8601, non-string dict keys and other non-JSON-safe values raise ExecutionError). Explicit string/number/integer/boolean/list/dict only valid on single value:. WorkflowContext.store accepts any JSON-safe value (scalars/lists from set steps in addition to the dicts produced by LLM / script / gate / parallel-group outputs); _add_agent_input returns the scalar verbatim for step.output and raises a clear KeyError for step.output.field shorthand on non-dict outputs.

  • Reasoning effort: runtime.default_reasoning_effort sets a workflow-wide default; per-agent reasoning.effort overrides it. Allowed values: low, medium, high, xhigh, max. Each provider translates the unified value to its native API (Copilot: reasoning_effort on the session, validated against the model's supported_reasoning_efforts; Claude: extended thinking with budget mapping low=2048, medium=8192, high=16384, xhigh=32768, max=59904 tokens, with temperature coerced to 1.0 and max_tokens bumped to fit the budget). max is Copilot/Claude-only — the Hermes provider advertises only the first four levels in CAPABILITIES.reasoning_effort and re-checks the resolved effort against that tuple at execute time (in addition to the static conductor validate cross-check), so max is rejected on Hermes both statically and at runtime, including when it only resolves to max after Jinja template rendering. See examples/reasoning-effort.yaml.

  • Context-window bar (context_window_used/context_window_max on agent_completed/parallel_agent_completed, issue #412): context_window_used is sourced from AgentOutput.last_call_input_tokens (a single call's prompt size), never input_tokens (a billing total summed across every call) — reusing the billing figure produced a false ">100%" red bar on any multi-turn agent. WorkflowEngine._context_window_fields() is the single place both LLM-agent emission sites build the pair; when used > max (impossible for one real API call), it drops both to None, logs at debug on every occurrence, and also logs at warning once per run (via a _context_window_anomaly_warned latch, matching the _pricing_hook_failed_warned/_budget_unpriced_warned pattern) — a debug-only record would never reach an operator, and this anomaly means either the provider-reported token count or the looked-up context-window cap is wrong. Copilot's assistant.usage dedup (below) keys on api_call_id, falling back to provider_call_id then service_request_id when the SDK omits it, since all three are independently optional per the SDK schema.

  • Periodic checkpoints (runtime.checkpoint, issue #244): opt-in CheckpointConfig (every_agent: bool, every_seconds: int|None, keep_last: int=5; is_enabled = every_agent or every_seconds is not None). Off by default → failure-only behavior preserved. WorkflowEngine._maybe_save_periodic_checkpoint() is called once at the top of _execute_loop (single choke point), where prior outputs are committed and _current_agent_name is the step about to run — so a periodic checkpoint reuses failure-checkpoint current_agent semantics and resume continues forward with no special-casing. Gated via the _periodic_checkpoints_active property (root engine only, _subworkflow_depth == 0, + is_enabled) and skips the first iteration (limits.current_iteration == 0). The save decision is _periodic_checkpoint_due(now) (every_agent OR every_seconds throttle; first save always fires). _save_checkpoint_on_failure and the periodic path share _write_checkpoint(error, trigger) (which best-effort-guards provider get_session_ids() so it never raises). The periodic save wraps write+emit+rotate; on any failure it calls _record_periodic_checkpoint_failure() which emits a checkpoint_save_failed event (consecutive-failure count; surfaced by ConsoleEventSubscriber + JSONL + dashboard) so a recovery-reliant user isn't silently left without checkpoints. After a save the engine calls rotate_periodic_checkpoints; at a terminal non-resumable outcome (clean completion via run()/resume(), or an explicit status: failed terminate) _cleanup_run_periodic_checkpoints() deletes the run's periodic checkpoints (an unexpected failure leaves them in place alongside the failure checkpoint). conductor checkpoint list shows a Trigger column and for periodic rows' error type. See examples/periodic-checkpoints.yaml and docs/workflow-syntax.md (Periodic Checkpoints section).

  • Skills: runtime.skills: [entry, ...] sets a workflow-wide default list enabled for every provider-backed agent; per-agent skills: [entry, ...] overrides it (tri-state via list presence: omitted = inherit, skills: [] = explicit opt-out, skills: [entry, ...] = explicit set). Each entry is either a registered built-in name or a filesystem path (issue #350). Classification is syntactic — path when it starts with ~/. or contains / or \, otherwise a built-in name — so a bare conductor can never be shadowed by a same-named local directory and resolution never depends on what happens to exist. A path may be a single skill directory (holds SKILL.md) or a root of them, which expands to every immediate child holding one (not recursive); skills/registry.py::resolve_skills(entries, base_dir) does the expansion centrally rather than passing roots through, because eager injection needs a name per skill and claude-agent-sdk needs a <plugin>:<skill> name. Relative paths resolve against the workflow file's directory (AgentExecutor(workflow_dir=...), threaded from WorkflowEngine._workflow_dir), mirroring _resolve_agent_working_dirnormpath, not resolve(), so symlink aliases stay distinct. Paths are trusted input: the same YAML can already run arbitrary shell via type: script, so no allowlist applies. AgentDef.validate_skills only shape-checks path entries (the schema has no base dir) but keeps the eager built-in-name check, so an unknown name still fails at load time as before. Every resolved SKILL.md must have valid YAML frontmatter declaring name and description — checked inside resolve_skills (via skills/frontmatter.py, parsed with ruamel.yaml, not PyYAML) rather than only in conductor validate, because conductor run never calls the static validator; both CLIs skip an unparseable skill silently, which is the bug this closes. The observable contract is the same across providers — "the agent has access to the named skill" — but the mechanism differs via AgentProvider.supports_native_skills (readable without instantiating a provider via providers/capabilities.py::uses_native_skills, which returns None when it cannot be determined so callers skip rather than guess): Copilot (True) registers the skill directory on the SDK session via skill_directories (progressive disclosure via SKILL.md frontmatter); Claude Agent SDK (True) is also native but goes through the Claude Code plugin surface — providers/claude_agent_sdk.py::_resolve_skill_plugins maps each resolved directory back to the plugin that owns it (skills/registry.py::resolve_skill_plugin walks up for .claude-plugin/plugin.json), registers that root via ClaudeAgentOptions.plugins and enables the skill by its <plugin>:<skill> name via ClaudeAgentOptions.skills. Because that SDK has no bare skill-directory option, a path skill outside a plugin is unreachable there — config/validator.py now refuses it statically (naming both remedies) instead of letting it fail as a runtime ProviderError; the identical skill works on copilot untouched. Claude and Hermes (False) eagerly inject every enabled skill's SKILL.md plus references/*.md into the rendered prompt inside <skills><skill name="...">...</skill></skills> tags. That is expensive — the bundled conductor skill alone is ~132KB (~33K tokens), paid on every call and every retry — so runtime.skill_injection (SkillInjectionConfig: warn_bytes default 64KB, max_bytes default 160KB, either nullable) bounds it, enforced both in AgentExecutor and statically in conductor validate, measured against the exact string prepended and reported with a per-skill breakdown. The defaults deliberately straddle the bundled skill so enabling it on claude warns rather than breaking — max_bytes was raised from 128KB to 160KB when the skill outgrew the original ceiling, and must keep tracking it; a warn_bytes above max_bytes is rejected as unreachable. Native providers are exempt. Providers also declare skills: bool on their ProviderCapabilities descriptor so conductor validate catches skills-against-unsupported-provider mismatches — hermes declares True (it reaches skills through the provider-agnostic eager-injection path in AgentExecutor; it previously omitted the field, defaulting to False, while its own execute() docstring described injection working), and aca is the one False (skill directories are host paths the in-sandbox runner cannot read). AgentExecutor._reject_unsupported_skills now enforces a skills=False declaration at run time too, because conductor run never calls the static validator — otherwise the declaration held only at validate time while the eager-injection path happily injected anyway. Built-in skills live under plugins/conductor/skills/<name>/ and are bundled into the wheel via the hatchling force-include entries in pyproject.toml — both the skill body and plugins/conductor/.claude-plugin/, because without the manifest no plugin root resolves and every skills-enabled agent on claude-agent-sdk fails with a ProviderError. Skills are rejected on non-provider-backed step types (script, wait, set, terminate, workflow, human_gate). Discovery (runtime.skill_discovery, issue #362) is the opt-in alternative to enumerating entries: sources: [personal, project] maps onto ~/.copilot/skills + ~/.claude/skills, and .github/skills + .claude/skills walked from the workflow file's directory to the repo root (first ancestor with .git; only the workflow file's own directory is used when none is found, so an unversioned tree cannot sweep in whatever sits above it). A third source, plugins, was removed with issue #378 — it reached into a plugin and took exactly one of the three things it ships, which is the bug runtime.plugins fixes rather than a feature with a gap; it was also wrong more often than it looked (of 13 installed plugins, 3 were silently degraded and the 3 most plugin-like, shipping agents/ + MCP but no skills/, were never discovered at all). Every mapped location is a skills root, so both expand through the same registry.py::expand_skills_root — discovery adds no second opinion about what a skill directory is, and a child that cannot be read is contained there so one stray directory cannot discard its readable siblings. Conductor scans centrally rather than enabling each provider's own discovery, and that is the whole point of the feature: locations are provider-specific, so one flag asking each provider to find its own would surface different skill sets to different agents inside one run. It also keeps enable_config_discovery off on Copilot (it would additionally auto-load MCP servers from .mcp.json) and setting_sources empty by default on claude-agent-sdk (opt-in per workflow via runtime.provider.setting_sources, see Providers below). Sources scan in a fixed canonical order (projectpersonal) independent of YAML order, so reordering cannot change which of two same-named skills wins. Discovery joins the workflow-level default set, so the existing tri-state is unchanged and skills: [] remains the one opt-out; note the inherited case can produce skills from an empty runtime.skills. The organising principle is a strict/lenient asymmetry — the user wrote the explicit entries and did not write the discovered ones: broken frontmatter, a claimed name, an unreadable directory, or a skill claude-agent-sdk cannot load are an error for a declared skill and a warning + skip for a discovered one — with a provider that has no native skill surface at all as the one exception, which errors either way. That last case is not theoretical — only 1 of 13 installed Copilot plugins on a real machine ships .claude-plugin/plugin.json, so erroring would bury a claude-agent-sdk user in failures for content they never wrote. claude/hermes refuse discovery outright (measured 260KB ≈ 65K tokens, well over the default max_bytes, and machine-dependent — there is no limit to tune). That refusal is enforced twice, in config/validator.py and again in AgentExecutor._reject_discovery_without_native_skills, because conductor run never calls the static validator — the same reason _reject_unsupported_skills exists. Explicit entries beat discovered ones on a name collision, which fires immediately in practice because installing Conductor's own plugin puts a second conductor skill on the machine. ResolvedSkill.discovered: bool carries the provenance so callers branch on a field rather than sniffing a string. discover_skills(..., home=...) takes the home directory as a parameter specifically so no test reads the developer's real ~. cli/validate.py::_report_skill_discovery lists the effective set — it resolves rather than merely scanning, so a skill the run would drop is not listed or billed, and it forwards any diagnostic the validator did not already print (the validator only resolves skills for agents that inherit, so a workflow whose agents all declare their own skills: is reported nowhere else) — an ambient set is the one part of a workflow the YAML does not capture, so making it inspectable is part of the feature, not a debugging aid. See examples/skills-self-improving-workflow.yaml, examples/skills-discovery.yaml, and docs/workflow-syntax.md (Skills section).

  • Plugins (runtime.plugins / per-agent plugins:, issue #378): the plugin is the unit of opt-in. A plugin ships up to three things Conductor can use — skills/, agents/*.agent.md subagents, and MCP servers — and they are written to work together, so a plugin's SKILL.md routinely dispatches to prs:code-reviewer or calls an ado MCP tool. Loading only the skills produced an agent that read those instructions correctly and then reached for something never registered, silently. Entries take a string shorthand or a PluginDef object with per-component switches (skills / agents / mcp), coerced by the same string→object pattern as _coerce_provider; all three default on, because defaulting one off recreates the partial load the feature exists to fix. Tri-state inheritance matches skills: exactly (omitted = inherit runtime.plugins, [] = opt out, list = override). An entry is an installed plugin name (globbed under ~/.copilot/installed-plugins/*/<name> and ~/.claude/plugins/*/<name> — the * is the marketplace) or a path, classified by the same syntactic is_path_entry rule as skills; an uninstalled name errors naming the searched roots, and an ambiguous one errors rather than picking a winner (two marketplaces shipping git are different plugins, and choosing silently is how a workflow drifts between machines). Conductor deconstructs a plugin rather than registering its root, and that is the central decision: both SDKs have a whole-plugin surface (Copilot's plugin_directories, ClaudeAgentOptions.plugins) and both are all-or-nothing. Empirically, Copilot's excluded_tools hides an MCP tool from the model but does not stop the server subprocess launching (proved with a startup marker file), so mcp: false built on it would be a cosmetic filter sold as a guarantee — and for ado --authentication azcli the credential use happens at process start, not at tool call. Registering the root also inverts the providers against each other (plugin MCP unavoidable on Copilot, suppressed on claude-agent-sdk by its unconditional strict_mcp_config=True). Deconstructed, each component rides the surface Conductor already uses for it, so plugin MCP inherits runtime.tool_output limits, dashboard tool events, and the same credential/env resolution workflow servers get (mcp_auth.resolve_mcp_servers — skipping it handed the SDK a literal ${VAR} and an unauthenticated URL, so the server attached and then did not work). A plugin server is not an MCPServerDef, so it carries no per-server tools: filter; mcp: false is the control. Two new execute() kwargs carry the non-skill components — custom_agents and extra_mcp_servers — accepted by every provider and honored by the native ones, mirroring how skill_directories already works; they are per-execute because plugins: is per-agent while providers are cached per type and self._mcp_servers is construction-time. Plugin skills merge into the existing skill_directories channel via executor/agent.py::_merge_skills, deduped by name (not directory) with declared entries winning — which fires immediately in practice, since installing Conductor's own plugin puts a second conductor skill on the machine. custom_agents accepts the qualified <plugin>:<agent> name — verified against a live Copilot session, where myplug:quokka appeared among launchable agent types — so namespacing survives deconstruction and two plugins shipping a review agent do not collide. On claude-agent-sdk the same specs become inline ClaudeAgentOptions.agents (AgentDefinition is keyed by name and has no name field; Copilot's infer has no counterpart and is dropped). CAPABILITIES.plugins gates the feature — copilot and claude-agent-sdk True, claude/hermes/aca False, because unlike skills there is no eager-injection fallback: text in a prompt cannot become a subagent or an MCP server. That refusal is enforced twice, in config/validator.py and again in AgentExecutor._reject_unsupported_plugins, because conductor run never calls the static validator — the same reason _reject_unsupported_skills exists. One declared carve-out: on claude-agent-sdk, reaching a plugin's skills requires registering the plugin root, which the SDK documents as also providing "custom commands, agents, skills, and hooks" — with a skills filter and none for the rest. So agents: false alongside skills: true for one plugin is refused rather than quietly granting more than the YAML declared (the same reasoning that refuses a narrowed per-server tools: filter there); the identical config works on copilot. Both the refusal and the matching hooks/ wording (exposed to the CLI rather than the false not loaded) branch on AgentProvider.skills_require_plugin_root, which is a truthful description of the mechanism rather than a provider-name check, and both are enforced twiceconfig/validator.py and AgentExecutor._reject_unfilterable_agents — because conductor run never calls the validator. hooks/ and commands/ are otherwise dropped loudly via a conductor validate warning — hooks are arbitrary shell on tool events with no per-hook filter in either SDK and nothing in Conductor's model to map onto, so silently loading or silently dropping them would both reproduce the invisible divergence this feature removes. MCP server names are never rewritten (Copilot prefixes tool names with them, and a plugin's SKILL.md names those tools), so a collision between two plugins, or with runtime.mcp_servers, is an error naming mcp: false as the remedy — refused in the provider merge helpers as well as in config/validator.py, since conductor run skips the validator and a dropped server would be exactly the silent omission this feature removes. Two plugins shipping a same-named skill are refused for the same reason (skills arrive as one flat name-keyed list, matching resolve_skills' own two-directories-one-name error); a plugin skill losing to a declared one is allowed but reported, not logged at debug where nothing would reach the user. Plugins are never discoveredplugins: [prs] reads the installed roots, but that is resolution (the author wrote the name, a miss is a hard error) not discovery; enable_config_discovery stays off on Copilot and setting_sources empty by default on claude-agent-sdk. cli/validate.py::_report_plugins prints each plugin's component counts and every subagent by name, so a change in what a plugin ships surfaces at validate rather than at run time. Flavor resolution (issue #497): a plugin can be built for either CLI — Claude Code writes .claude-plugin/plugin.json with agents/*.md; Copilot writes .github/plugin/plugin.json with agents/*.agent.md — and the candidate-file rule used to be hardcoded to the Copilot suffix regardless of which manifest actually matched, so a provider: copilot workflow silently got zero subagents from a Claude-built plugin (a real, confirmed configuration: a Copilot build can sit inside a ~/.claude/plugins/ tree via a symlink the Claude CLI never inspects). PluginFlavor = Literal["copilot", "claude"] (plugins/manifest.py) is read off whichever manifest convention actually matched — never guessed from location — and read_plugin_agents(..., flavor=manifest.flavor) picks the candidate rule from that, so parsing is manifest-driven and an agent handed the "wrong" build still gets every subagent it ships. Flavor is threaded as a separate, optional axis (resolve_plugin(..., flavor=...)) that only ever breaks a tie where a genuine choice exists: a dual-catalog plugin_sources marketplace (Marketplace.flavored, populated per catalog convention actually present under a checkout) and a bare name installed once per CLI under one marketplace directory name (_resolve_name_entry's Q3 tie-break) — two different marketplace names sharing a plugin name stay an ambiguity error regardless of flavor. Provider → flavor is a static declaration on ProviderCapabilities.plugin_flavor (required whenever plugins=True, enforced by a model_validator) — copilot declares "copilot", claude-agent-sdk declares "claude" — resolved via plugin_flavor_for(provider_name) at the two name-keyed call sites (config/validator.py, cli/plugin.py, cli/validate.py) and via type(self.provider).CAPABILITIES at the one instance-holding call site (executor/agent.py, not added to _plugin_cache's key since flavor is constant per executor instance). config/validator.py's plugin-resolution cache is keyed by (entries, flavor), not entries alone — the real cross-agent bug the issue named, since two agents on different providers naming one entry list previously shared one cached (and therefore one provider's) resolution. ~/.copilot/settings.json's extraKnownMarketplaces (plugins/copilot_settings.py::read_copilot_marketplaces, directory-source entries only, scoped to flavor == "copilot") is consulted last in _resolve_marketplace_entry — after a declared source and the installed glob — so it can only turn a hard error into a resolution, never override an existing one, and it comes with a printed advisory naming plugin_sources as the standalone remedy. A frontmatter-less agent candidate is a hard error when its name ends .agent.md (an explicit claim to be an agent) and a warn-and-skip otherwise (PluginAgentFrontmatterError, a PluginManifestError subclass) — this keeps the Claude build's broader *.md candidate rule strictly additive, since no file that already errored under the old Copilot-only rule can newly start warning. See examples/plugins.yaml (with the self-contained examples/demo-plugin/) and docs/workflow-syntax.md (Plugins section, "Flavor" subsection).

  • Git-backed plugin sources (runtime.plugin_sources, issue #380): what makes a plugin-using workflow standalone. plugins: alone resolves against machine state, so a shared workflow still needed "first install these" in a README. plugin_sources maps a marketplace name to a source and plugins: references it as prs@acme. The split (acquisition vs activation) is not invented — the Copilot CLI's own settings.json separates extraKnownMarketplaces from enabledPlugins, and the reason is structural: 11 of 13 plugins on an ordinary machine come from one repository, so a URL per entry would either clone it 11 times or silently dedupe 11 refs to one. The load-bearing property is that prs@acme means the same thing whether acme was declared, installed via a CLI, or is a local directory — a declared source registers into the same resolution table the installed roots populate, so git is a source feeding resolution rather than a second code path, and declared beats installed on a name clash. It also gives #378's ambiguity error a second remedy: qualify git@acme instead of falling back to a path. Entry classification is three-way and ordered — path, then plugin@marketplace, then bare name — so ./tools/my@plugin stays a path. Source classification is separate from is_path_entry on purpose: that helper returns True for anything containing /, so reusing it would turn every owner/repo into a relative directory lookup; a source is local only by prefix. Both repository shapes resolve — a marketplace.json catalog or a plugin.json single plugin, with plugin: for one that is both — and the two catalog conventions anchor their per-plugin source differently (.claude-plugin repo-root-relative, .github/plugin pluginRoot-relative, verified against a real repo shipping both), so both anchors are tried. No lockfile — the YAML is the lock. A full 40-char SHA is pinned (fetched once, never re-checked); a tag, branch, or absent ref floats and is re-resolved every run via git ls-remote, matching registry/version_resolver.py's existing treatment of workflow registries. This deliberately drops the issue's sketched conductor.lock and its component-count review signal — an unpinned source can gain a subagent or an MCP server between two runs with nothing to diff — so pinning is the remedy and conductor plugin list prints the counts on demand. Network posture: conductor run/resume acquire up front and in parallel (cli/run.py::_prefetch_plugin_sources, threaded because it shells out to git — an agent must never block mid-run on a clone, and a failure is a configuration error rather than an agent one); conductor plugin fetch primes the cache as its own step, which is the entire reason conductor validate can stay off the network; conductor plugin list reads the cache. An unreachable remote with a warm cache warns and reuses the checkout so offline runs keep working; a cold cache errors naming the fetch verb. Sources are resolved one at a time, not as a batch: resolving them together meant one unfetched source discarded the whole table, so a local directory sitting on disk was reported as "has not been acquired" and the per-agent MCP-clash and dropped-component checks were silently skipped — reinstating the invisible divergence the feature removes. At validate time an unfetched source is a warning, not an error (PluginFetchError_DeferredPluginCheck), because the workflow is not wrong and conductor run heals it — but the warning names the checks it had to skip, since reporting "valid" when whole categories never ran would be the worse lie. A source that is itself broken (a path that does not exist, a path: that escapes the checkout, an unparseable catalog) is an error: no amount of fetching fixes it, and reporting it as a warning blamed the network for the author's typo and then prescribed a command that fails on the same input. A declared source that shadows an installed marketplace of the same name warns, since the two can ship different subagents or a different MCP server. An unreferenced plugin_sources entry is reported as dead config. There is no plugin update: floating refs self-update and pinned ones are meant not to. WorkflowEngine._ensure_plugin_marketplaces resolves sources at run()/resume() when no caller supplied a table, so a directly-constructed engine (as the class docstring's own example shows) is not silently CLI-only; a sub-workflow inherits the parent table and resolves any source it declares itself, which wins on a clash. Plugins remain never discovered — resolving a declared source is resolution (author wrote it, a miss is a hard error), not discovery. Trust: declaring a source is the consent (no prompt, no allowlist), matching #378's treatment of a plugin path, but the docs say plainly that the code is not in the reviewer's tree. See examples/plugin-sources.yaml and docs/workflow-syntax.md (Plugins section).

  • Terminate steps (type: terminate): explicit terminal step with status (success | failed), Jinja2 reason, and optional output_template (a dict[str, str] that replaces workflow.output: when set; each value is rendered then passed through _maybe_parse_json so "true" becomes True, "42" becomes 42, JSON literals are parsed). Reaching a terminate step ends the workflow immediately (no routes evaluated after). success → CLI exit 0, dashboard ✅, workflow_completed { termination_reason, terminated_by, is_explicit: true, status }. failed → CLI exit 1 (with rendered output JSON still printed to stdout for downstream tooling), dashboard ❌, raises WorkflowTerminated (subclass of ExecutionError), emits workflow_failed { error_type: "WorkflowTerminated", is_explicit: true, status, output }, and does not save an on-failure checkpoint (explicit terminations are intentionally non-resumable). Terminate steps cannot have routes, tools, output, prompt, model, etc.; cannot be used as parallel-group members or as a for_each inline agent (route to one from those groups' routes: instead). Inside a sub-workflow, a status: failed terminate is downgraded at the parent boundary to SubworkflowTerminatedError (also a subclass of ExecutionError) preserving the child's rendered terminated_output / terminated_reason / terminated_by as structured attributes — the parent treats it as a normal sub-workflow failure (its own workflow_failed does NOT inherit is_explicit: true). For more detail see examples/terminate.yaml, docs/workflow-syntax.md (Terminate Steps section), and plugins/conductor/skills/conductor/references/authoring.md.

  • Structured runtime.provider (Copilot custom routing): runtime.provider accepts either the bare string shorthand (provider: copilot) or a structured ProviderSettings object that routes the Copilot SDK at OpenAI-compatible / Azure / Anthropic endpoints (Ollama, vLLM, LM Studio, Azure OpenAI, etc.). Object fields: name (defaults to copilot), type (openai|azure|anthropic), wire_api (completions|responses), base_url, api_key, bearer_token, headers, azure.api_version. api_key and bearer_token are SecretStr (redacted in model_dump / dashboard / event logs). The model is frozen after construction. Custom routing activates only when at least one non-name field is set in YAML — ambient OPENAI_* env vars never divert default routing on their own. Once activated, missing fields fall back from env vars in this order: base_urlCOPILOT_PROVIDER_BASE_URLOPENAI_BASE_URL; api_keyCOPILOT_PROVIDER_API_KEY (only — ambient OPENAI_API_KEY is intentionally NOT a fallback to avoid credential leaks); bearer_tokenCOPILOT_PROVIDER_BEARER_TOKEN. The schema rejects every non-name field when name != "copilot" (structured config for other providers is a follow-up). It also rejects anchorless / broken combinations that would silently no-op at the SDK boundary: wire_api / type / headers / azure cannot stand alone without base_url / api_key / bearer_token; empty headers, empty SecretStr, and azure: {api_version: null} are rejected. The resolver raises ProviderError when custom routing is activated but every resolved field is falsy (e.g. expected env vars all unset). Custom routing applies to both agent execution and dialog turns so all sessions hit the same endpoint. --provider <name> CLI override replaces the whole ProviderSettings (logs a notice when YAML had structured fields). See examples/copilot-local-llm.yaml.

  • Connect to an existing Copilot runtime (Copilot): runtime.provider.runtime_url (Copilot-only) points the provider at an already-running copilot --headless process instead of spawning a nested one. Agents share the authenticated runtime process while retaining separate SDK sessions. Optional runtime_token (SecretStr, redacted, requires runtime_url) is the socket connection secret. Both fields fall back to env vars (COPILOT_PROVIDER_RUNTIME_URL / COPILOT_PROVIDER_RUNTIME_TOKEN) which activate the connection on their own (zero-YAML path for external orchestrators). has_external_runtime() is a separate axis from has_custom_routing(); the two can be combined because runtime transport and per-session model routing are independent. has_structured_config() keeps either mode from collapsing to bare-string serialization. Schema rejects: runtime_token without runtime_url; empty or whitespace-only runtime values; either field when name != "copilot". Provider layer: _resolve_runtime_connection() (YAML then env) and _build_client() (in copilot.py). See examples/copilot-existing-runtime.yaml and docs/configuration.md (Connecting to an Existing Copilot Runtime).

  • Validator block (validator: on a provider-backed agent, issue #220): semantic output validation with retry-once. After the primary agent completes, WorkflowEngine._apply_validator runs OutputValidator (engine/validator.py) — a second LLM call (synthetic agent via provider.execute, tools=[], {passed, issues} output schema) grading the output against validator.criteria. Fields: criteria (required, non-empty), model (defaults to the agent's model), max_retries (Field(1, ge=0, le=1) — hard-capped at 1; 0 = report-only). On passed: false and max_retries > 0, the primary re-runs once via executor.execute with a ## Validation feedback section (the issues) appended to guidance_section; the second output is final (no second validation loop). When the primary output carries a continuation_state, the re-run instead continues the completed conversation: _apply_validator passes the state back with the feedback alone as the next user turn, and AgentExecutor skips re-rendering the prompt and the skills/instructions prefix. continuation_state is provider-opaque and in-memory only (never serialized to checkpoints or event logs); today the Pydantic AI-based providers (claude, openai) set it to outcome.result.all_messages() and hermes sets it to the run's result["messages"], while copilot / claude-agent-sdk / aca leave it None and keep the stateless rebuilt-prompt re-run. Fail-open: validator errors / unparseable responses → treated as pass with a logged warning. Wired into the main loop, parallel groups, and for-each loops (guarded by agent.validator and not output.partial; for-each passes a usage_label so the row matches <group>[<key>]). Emits agent_validator_start / agent_validator_complete { passed, issues, errored, tokens, cost_usd } / agent_validation_failed { issues, will_retry } through the per-agent event_callback (so for-each events carry item_key). Cost: the validation call and any discarded first attempt are recorded as a separate "<agent> (validator)" usage row (primary row = effective output). Rejected on script / human_gate / workflow / wait / set / terminate types. Frontend: event types in web/frontend/src/types/events.ts, store handlers + NodeData.validator_* fields in web/frontend/src/stores/workflow-store.ts, detail UI in web/frontend/src/components/detail/ValidatorDetail.tsx (rebuild with make build-frontend). See examples/validator.yaml and docs/workflow-syntax.md (Validator section).

  • Mid-run guidance (issue #400): one channel — engine/guidance.py::GuidanceChannel — feeds every inbound guidance source into the existing outbound machinery (WorkflowContext.user_guidanceget_guidance_prompt_section()AgentExecutor.execute(guidance_section=...)), which previously only the TTY Esc/Ctrl+G interrupt could reach. WorkflowEngine owns one channel (self._guidance), created fresh unless _guidance_channel= is passed; both sub-workflow constructors thread the parent's channel into the child (_execute_subworkflow / _execute_subworkflow_with_inputs), so a paused sub-workflow agent can be corrected too. submit_guidance(text) is the sink handed to WebDashboard.set_guidance_sink() (the exact mirror of set_interrupt_event() — pushed into the engine rather than polled, because the engine's _web_dashboard is duck-typed and stubbed across the test suite); resume --guidance does not go through it — it calls add_user_guidance directly at the CLI layer (see below), applying before the dashboard is seeded rather than queuing for a later drain. add_user_guidance(text, source=...) is the single entry point that actually applies text to self.context and emits guidance_applied — the TTY interrupt path (_handle_interrupt_result, _handle_partial_output) was rewritten onto it too, so an Esc/Ctrl+G correction is now visible in the dashboard and JSONL log, not just in the next prompt. Two drain points: (1) _drain_pending_guidance() at the top of _execute_loop's while True, right after _maybe_save_periodic_checkpoint() — the one choke point every step type passes through, so guidance reaches the next agent, parallel group, for-each group, script, set, or wait step alike; it is root-only (_subworkflow_depth == 0, mirroring _periodic_checkpoints_active) because draining at every depth would let concurrent for-each sub-workflows race for the same queued sentence. (2) a fourth wait-arm (self._guidance.event.wait()) in _handle_web_pause, alongside resume/kill/disconnect — unlike those three it is deliberately not cleared before the wait, so guidance already queued before the pause began resolves the arm immediately instead of requiring a second submission. Since concurrent for-each sub-workflows share one channel and all wake on the same broadcast event, drain() there only reports guidance as applied when it actually returned non-empty text for that caller — a sibling that loses the race (finds nothing left to drain) falls through to the plain-resume outcome instead of emitting a misleading with_guidance: true. _handle_web_pause returns WebPauseOutcome(handled, guidance) (replacing a bare bool) so the caller in the main loop can try _send_guidance_followup (the Copilot-session-resume branch lifted out of _handle_partial_output, shared by both paths) before falling back to a full re-execution; agent_resumed gains with_guidance: bool. POST /api/guidance (web/server.py) validates (CONDUCTOR_GATE_TOKEN via the same _gate_token_ok check as /api/gate-respond; 409 after workflow_completed; 422 for empty-after-strip or over engine/guidance.py::MAX_GUIDANCE_CHARS=10,000, shared with resume --guidance via engine/guidance.py::validate_guidance_text) and calls the sink, latching a pre-sink submission in _pending_guidance (mirroring _pending_stop) for the startup race. conductor guide --text "..." (cli/guide.py) auto-discovers the port via scan_pid_files() and POSTs with the same token header as conductor gate respond. Replay asymmetry: guidance_received is added to _REPLAY_INTERACTIVE_SKIP_TYPES (it is the opening half of a pair whose closer is guidance_applied — replaying a still-pending submission would show a phantom "pending" entry forever), but guidance_applied is deliberately not filtered, since WorkflowContext.from_dict restores user_guidance and that correction really is still in effect on the resumed run. Parallel and for-each group members now also render with the current guidance_section (previously always None), so a correction submitted mid-run reaches every subsequent member too. See docs/cli-reference.md (conductor guide) and plugins/conductor/skills/conductor/references/execution.md.

  • Tool output limits (runtime.tool_output): per-result MCP tool output size limiting. Controls character-size truncation and spill-to-file behavior for individual MCP tool result payloads. Config fields are enabled (defaults to true), max_chars (defaults to 50000, minimum 1000), spill_to_file (defaults to true), and spill_dir (defaults to null to resolve to OS temp directory /conductor/tool-output). Crucially, this limit is a per-result cap applied to each tool result independently, not a cumulative context window budget. Multiple truncated results, combined with prompt and conversation history, can still exceed the model's context window. Tuning should be done via max_chars or max_agent_iterations to keep context consumption in check; cumulative context budgeting is out of scope. Spill files contain the raw tool output (which may include secrets) and are not deleted by Conductor (they persist in the OS temp directory). For Claude, truncation is handled conductor-side via MCPManagerToolset, which applies character-limit truncation and spill-to-file logic while emitting agent_tool_output_truncated events. Copilot uses its native SDK large_output capability, mapping max_chars to bytes (meaning multibyte UTF-8 characters like CJK/emoji may truncate earlier). The agent_tool_output_truncated event is Claude-only because the Copilot SDK doesn't expose a truncation hook. This config is ignored by claude-agent-sdk (managed via native CLI MAX_MCP_OUTPUT_TOKENS) and doesn't apply to hermes (no MCP tools). See examples/tool-output-limits.yaml and docs/mcp-tools.md (Tool output limits section).

  • Dashboard stuck-reconnecting warning (issue #330): the dashboard's WebSocket client (web/frontend/src/hooks/use-websocket.ts) retries forever with exponential backoff on disconnect — it drops through wsStatus 'disconnected' momentarily, then oscillates between 'connecting'/'reconnecting' on every retry cycle, never sitting continuously in any single non-connected status, so a naive "reconnecting for N seconds" timer would reset every cycle. Instead workflow-store.ts's setWsStatus tracks wsDisconnectedSince: a timestamp set only on a fresh drop from 'connected', preserved through that churn, and cleared once reconnected. lib/reconnect.ts's pure isReconnectStuck() (unit-tested) compares that timestamp against RECONNECT_WARNING_THRESHOLD_MS (60s), gated on workflowStatus === 'running' and not replayMode. hooks/use-reconnect-warning.ts ticks this once a second (mirroring StatusBar's idleSeconds pattern; itself untested per the existing convention for timer-dependent hooks) and components/layout/ReconnectWarningBanner.tsx renders an amber banner telling the user the Conductor process may have silently crashed, pointing at whatever log location is available — system.bg_stderr_log/bg_stdout_log (captured from the root workflow_started event, --web-bg runs only) falling back to system.log_file (the always-on structured *.events.jsonl event log written by EventLogSubscriber for every run, unrelated to the separate --log-file debug-output flag), falling back to a generic hint to check the launching terminal. The banner only clears on an actual reconnect, not on a timer, so a page refresh isn't the only way to dismiss the stale "running" impression.

  • Dashboard viewport anchoring across graph re-layout (issue #375): expanding an inline subworkflow used to make the whole graph appear to jump. The layout was never wrong — graph-layout.ts's layoutTopLevel normalizes each rebuild's bounding box to origin, so growing one container shifts minX/minY and therefore every node's position on the canvas, while the camera is never touched (the fitView prop is initial-render only; React Flow's fitViewQueued is diff-guarded and cleared after the first fit). The world slid under a fixed viewport. lib/graph-anchor.ts compensates the camera instead: toAbsolutePositions resolves each node's canvas-absolute top-left by folding parentId chains — load-bearing because React Flow stores a nested node's position relative to its parent, so a node inside an expanded container keeps a byte-identical position while its container moves — and anchoredViewport pans by the negated delta of a node present in both layouts, at unchanged zoom, instantly. Anchor preference is the container owning the single toggled expansion key (matched on both data.childContextKey and data.groupExpansionKey, on either side of the rebuild, since a group only carries the latter once expandable), else the shared node nearest the pane center, tie-broken on node id for determinism. nextAnchorHint is the pure state machine deciding that hint; it holds the last clicked container for at most MAX_STICKY_ANCHOR_REBUILDS (2) rebuilds that toggle nothing, because an expanded child's DAG arrives a beat later and grows the same container again — but a replay scrub also rebuilds without resetting expandedContexts, so the budget is bounded. WorkflowGraph is split behind a ReactFlowProvider (there was none before; every useReactFlow() call site was a child of <ReactFlow>) so the rebuild effect, which lives outside <ReactFlow>, can reach setViewport. lib/camera-authority.ts keeps the two camera owners from fighting: an instant setViewport takes d3-zoom's non-transition path, which calls interrupt(), so a rebuild landing inside an animated fitView would cancel it mid-flight and leave its promise permanently unsettled — every animated fit therefore calls claimCameraForAnimation(duration) and the rebuild effect yields while isCameraAnimating(). Note anchoredViewport returning null on a zero delta is load-bearing, not merely informative: it suppresses the no-op setViewport that would otherwise interrupt an animation on every status-driven rebuild. Known limitation, documented in the module: expanding several subworkflows individually then collapsing them all at once leaves ~20px of horizontal pan that accumulates per repeat, because no single node carries the summed delta needed to invert several separate pins; fit-view clears it. See examples/subworkflow-inline.yaml.

  • Hardened dashboard request surface (issue #397): three independent layers, none individually load-bearing. (1) web/auth.py::OriginHostGuard, a pure-ASGI middleware (app.add_middleware(...), not BaseHTTPMiddleware/@app.middleware("http") — neither sees WebSocket scopes) validating Host (required, must name the bound machine) and Origin (only checked when present — httpx/curl/conductor gate respond send none, and that path must keep working with no extra setup) on every HTTP and WebSocket request. CONDUCTOR_WEB_ALLOW_ORIGINS (comma-separated full origins) is the dev-server escape hatch, additive only. (2) A per-run token, minted automatically (mint_token()) so the protected configuration is the default; CONDUCTOR_GATE_TOKEN overrides it (resolve_expected_token). Required on every mutating route (/api/stop, /api/kill, /api/resume, /api/gate-respond, /api/guidance) and on the /ws handshake — the single auth point for the socket, since an unauthenticated connection is closed (websocket.close, code 1008) in reply to websocket.connect, before accept(), so it can never send any message type. This is strictly stronger than the pre-#397 per-message check, which browsers could never satisfy at all. Read-only routes (/api/state, /api/info, /api/logs, /api/gate-status, /api/files/*, the whole replay app) stay unauthenticated, protected by origin/host only. (3) Content-Type: application/json required on every mutating route (415 otherwise), including the bodyless control POSTs. Token discovery: WebDashboard.start() writes a 0600 file (POSIX; on Windows the mode is not honoured and the file relies on the user-profile NTFS ACL instead) at ~/.conductor/runs/dashboard-<port>.token once the port resolves (works for both --web and --web-bg, since cli/run.py calls start()/stop() on both the run and resume paths); stop() removes it. conductor gate respond, conductor guide, and conductor stop's graceful-kill rung all resolve a token via the shared resolve_cli_token(port, token): --token > CONDUCTOR_GATE_TOKEN > the token file. See docs/cli-reference.md (Environment Variables, and the Authentication sections under conductor gate respond / conductor guide) and the web/ bullets above for the full mechanism.

  • MCP server exposure (mcp: workflow block, conductor mcp serve, issue #432): workflow.mcp (config/schema.py::McpConfig) is a typed, extra="forbid" block — expose (default true; every workflow is a candidate for MCP tool exposure with no editing required, DD4), mode (async/sync/auto; the default a generated tool's omitted _wait_seconds resolves to), read_only / destructive (surfaced as the generated tool's readOnlyHint/destructiveHint annotations), and estimated_minutes (a client-side hint, must be positive). conductor validate reports an unknown key inside it as a schema error, not silence (FR11) — it cannot ride on the existing untyped metadata: dict. See examples/mcp-serve.yaml and docs/mcp-server.md (the user-facing guide: host configuration, the exposure ladder, toolsets, the run lifecycle, and a dedicated Limits section for DD5/DD9/DD11/DD12/R4) and src/conductor/mcp/serve/ above for the server that reads it. R1 — this feature's terminal run record is a scope change to conductor status and conductor fleet list, not an MCP-only addition: every run — MCP-launched or not — now writes a completion tombstone, so both commands (and the Fleet TUI's History screen) gained a completed-runs section as a side effect, with --live restoring the exact pre-change scope. See the fleet/records.py (TerminalRunRecord) bullet above and the CHANGELOG.md entry for the full description of what changed.

  • Context compaction: Always-on client-side context window compaction for the claude and openai providers. Compaction is triggered proactively using the reserve-based formula trigger = window - (output_limit + buffer) and targets a clamped 55% hysteresis ceiling. In this formula, the output_limit resolves to the minimum of the effective max_tokens sent to the API (from settings or defaults) and the model output cap (from provider-cap). The tool-output-derived buffer is calculated as 2 * ceil(max_chars / 4) + 15,000 tokens as a heuristic for worst-case tool results. Resolution prioritizes cascades: provider-advertised metadata (with full pagination for Anthropic and a vendor-field parser for OpenAI-compatible endpoints), public registry, and conservative fallback. A second, density-calibrated estimate guards the hard window against token-dense content (CJK, base64, minified data) the ~4-chars-per-token heuristic undercounts: it matches the heuristic on ordinary prose, so it never compacts a history that is merely large, and when it fires the tier chain is driven directly against that measurement rather than delegated to the inner strategy's own (heuristic) gate. Token telemetry stays on the primary scale — the density value travels as density_tokens and trigger_reason on the start event, with degraded_estimators / still_over_window on the complete event and an agent_compaction_skipped event when both estimators fail. The wrapper operates in a fail-open manner, disabling itself for the rest of the agent execution upon outer failure. Compaction events are emitted to the dashboard and console. A summarizing compaction step runs a nested model call that consumes a request slot from the agent's max_agent_iterations budget, which doesn't get refunded. The dashboard context bar relies on provider-only limits and may disagree with the compaction window.

Debugging --web-bg failures

When a conductor run --web-bg (or resume --web-bg) child dies before the dashboard becomes reachable, or crashes mid-run, look at:

  1. The child's captured stderr log, printed alongside the dashboard URL on a successful launch and included in every RuntimeError message on a failed launch (with a bounded tail of its contents inlined via _tail_log). The path is also stamped into the child's workflow_started event under system.bg_stderr_log and surfaced in the web dashboard.
  2. The matching .events.jsonl file in the same directory — same timestamp and 8-hex run id in the filename, so the three artefacts (.events.jsonl, .bg.stderr.log, .bg.stdout.log) sort together.
  3. For an apparent silent crash, search the events JSONL for a workflow_failed event; the is_base_exception flag tells you whether the failure escaped the engine's normal Exception handling (e.g. a SystemExit from a misbehaving library).
  4. If the CLI printed a "workflow has not reported starting yet" note (issue #410) instead of a failure: the launcher's process is alive and listening, but GET /api/info hadn't yet reported a workflow_started event when the (default 30s) CONDUCTOR_WEB_BG_START_TIMEOUT deadline passed. This is not a failure — check the dashboard or stderr log for slow startup causes (plugin fetch, MCP server startup, provider connection), or raise the timeout / set it to 0 to disable the wait entirely.
  5. If a launch-gate RuntimeError reads "could not confirm termination of PID(s) ..." instead of "The background process was terminated." (issue #447): the launcher's process-tree kill and its final liveness sweep could not confirm every pid it knew about is actually dead — usually a permission error or a process that outlived a job object the launcher failed to create. Use conductor status or conductor stop --port <port> (named in the message) to find and stop the survivor manually; it is not silently left running.

faulthandler is enabled at import time in conductor/__init__.py, so a native crash also dumps a Python stack trace to the captured stderr log. See issue #116.

Tests Structure

Tests mirror source structure in tests/:

  • test_cli/ - CLI command tests, e2e tests. test_markup_guards.py is the one that keeps issue #406 closed: it reads src/conductor with ast and fails with file:line across eight rules — a bare Console or a Console subclass (A), an interpolated Panel title or Prompt (B), an f-string into Text.from_markup (C), a markup literal at a print/cell sink (D), a Text through the builtin print (E) or into an f-string (F), unescaped brackets in typer help text (G), and any use of rich.markup.escape (H). Each rule is a shared predicate called by both the source scan and its negative control, so a control cannot pass against a drifted rule — that had already happened once. Each rule has a negative control, because a source-scanning check that quietly matches nothing reports "all clear" forever. test_markup_injection.py covers the same ground behaviourally, driving the real commands — the two layers are not redundant: the guard alone cannot prove styled renders correctly, and the behavioural tests alone cannot stop the next call site, which is the actual failure mode here
  • test_config/ - Schema validation, loader tests
  • test_engine/ - Workflow, router, context, limits tests
  • test_executor/ - Agent, template, output tests
  • test_providers/ - Provider implementation tests
  • test_integration/ - Full workflow execution tests
  • test_gates/ - Human gate tests
  • test_skills/ - Skill registry, frontmatter parsing, path entries, injection budget, loader, schema field, and executor/engine-integration tests. test_engine_integration.py is load-bearing: an AgentExecutor built directly in a test is handed workflow_dir and skill_injection by the test itself, so only an engine-level test can catch the engine failing to supply them
  • test_plugins/ - Manifest parsing (both conventions, all three mcpServers forms), agent-definition parsing, name/path/@marketplace/ambiguous resolution, component tri-state, schema, validator cross-checks, and provider wiring. Source grammar, catalog anchoring, and fetching live in test_sources.py / test_marketplace.py / test_fetch.py — the fetch tests run real git against file:// repositories built by a fixture, because mocking subprocess would test the mock: annotated-tag dereferencing, shallow SHA fetch, and the unreachable-remote fallback are all properties of git itself. conftest.py builds every plugin tree on disk and takes home as a fixture, so no test reads the developer's real ~. test_executor_integration.py and test_engine_integration.py are load-bearing for the same reason as their skills counterparts, and more so: a plugin's subagents and MCP servers have no fallback delivery path, so if they do not arrive at execute they are simply gone — a negative assertion could not tell a working path from a dropped one
  • test_install_hint.py - The optional-extra install resolver (issue #441). The three context branches are covered against the pure render_install_command, so no test needs a real install; receipt parsing is covered against fixture uv-receipt.toml files. tests/test_integration/test_install_script_extras.py is its shell counterpart and executes the real install.sh helpers (sourcing the script with its trailing main stripped) rather than grepping them — the failure mode there is a quoting or sed-pattern mistake that reading the file does not catch; install.ps1's helpers are extracted with PowerShell's own parser and executed wherever a native pwsh exists, with a parity class feeding both implementations the same receipt
  • test_fleet/ - Fleet Manager tests: run-record read/write/prune tolerance (test_records.py), event-log retention (test_retention.py), RunSummary/status-derivation and gate payload (test_summary.py), History enumeration (test_history.py), New Run resolve/launch (test_launch.py), the run-record-writing integration into cli/run.py (test_run_record_wiring.py), terminal bell/OSC 9 notification debouncing (test_notify.py), and Textual App.run_test() pilot tests per screen (test_tui_runs.py, test_tui_run_detail.py, test_tui_step_detail.py, test_tui_drilldown.py for Providers/Registries, test_tui_new_run.py, test_tui_history.py, test_tui_splash.py) plus shared actions (test_tui_actions.py — stop/kill/gate-resolve) and the presentation helpers (test_tui_theme.py, test_tui_anim.py, test_tui_dag.py)
  • test_mcp/ - The MCP client (test_manager.py, test_manager_truncation.py) and the mcp SDK version bound (test_sdk_bound.py, issue #432/DD0) alongside conductor mcp serve's own suite (issue #432): naming/sanitizing (test_serve_naming.py), tool generation and the catalogue's exposure ladder/schema resolution/pinning (test_serve_toolgen.py, test_serve_catalogue.py, test_serve_pinning.py), the stdio server and tools/list stability across connections (test_serve_server.py), invocation and the always-detached launch/wait/concurrency-cap behavior (test_serve_invoke.py), the run-lifecycle tools (test_serve_runs.py), the discovery-mode fallback pair (test_serve_discovery.py), and the introspect/diagnose toolsets including R4's default tool-payload reduction (test_serve_introspect.py, test_serve_diagnose.py). conftest.py provides the shared fixture registry every catalogue/server test builds against.

Use pytest.mark.performance for performance tests (exclude with -m "not performance").

Test Fixture Patterns

When writing integration tests that construct WorkflowConfig programmatically, follow these conventions (see tests/test_engine/test_limits.py for canonical examples):

  • AgentDef uses prompt= (not instructions=), output={"key": OutputField(type="string")} (dict, not list), and routes=[RouteDef(...)] (not raw dicts).
  • WorkflowDef requires entry_point= and places limits= inside workflow=. agents= and output= are top-level on WorkflowConfig.
  • The engine entry point is await engine.run({}) (not execute).
  • To test with controlled token/cost data, patch provider.execute to return a custom AgentOutput with explicit input_tokens, output_tokens, and model fields.

Resume / Checkpoint Parity

When adding new fields to LimitEnforcer:

  • Transient fields (reset each run): add to from_dict() as parameters sourced from the current workflow config, like timeout_seconds, budget_usd, budget_mode. Update the call site in cli/run.pyresume_workflow_async().
  • Persistent fields (survive across resume): add to both to_dict() and from_dict() deserialization, like max_iterations, current_iteration, execution_history.

Code Style

  • Python 3.12+
  • Ruff for linting/formatting (line length 100)
  • Google-style docstrings
  • Type hints required, checked with ty (Red Knot)
  • Pydantic v2 for data validation
  • async/await for all provider operations

Console Output

Never put a runtime value into a string that Rich will parse as markup. Rich reads [...] in a plain str as a style tag, so a workflow name, an agent name, a plugin name from a cloned repo, a path, or an str(e) that happens to contain a bracketed token is interpreted as styling. In rich a token is a tag when its first character is lowercase, #, / or @, which splits three ways: [0] renders literally, [task1] is silently deleted, and [/etc/x] raises MarkupError out of the print call. The quiet half is the dangerous one — a listing that drops half a name looks like it worked. Note style= does not disable parsing (issues #382, #387, #406).

Each rule below is enforced by a matching rule in tests/test_cli/test_markup_guards.py, which reads the source and reports file:line:

  • Build every console with conductor.console.make_console() (rule A). It locks markup=False, so a plain string is literal unless it asks to be styled. This covers plain prints, Panel bodies, Table cells, headers, titles and captions, and Rule titles. markup is not overridable — passing it to the constructor, to print or to log raises TypeError, because rich's per-call markup=True would otherwise reopen the whole defect from one line. Subclass MarkupFreeConsole rather than rich's Console so the refusal is inherited (see cli/run.py::_SilentAwareConsole).
  • Style with conductor.console.styled("<template>", value, ...). The template is conductor's own literal and is parsed; values are inserted verbatim and never reach the parser. A value that is already a Text is spliced in with its styling intact, so pre-styled fragments compose. Use Text.from_markup("...") when there is nothing to interpolate, and conductor.console.join(sep, parts) to join a list mixing str and Text (Text.join requires every part to be a Text already).
  • Panel(title=), Panel(subtitle=) and Prompt/Confirm/IntPrompt prompts must be handed a Text (rule B). Rich calls Text.from_markup on those unconditionally (rich/panel.py, rich/prompt.py), so markup=False never reaches them. This is the trap that made #387 incomplete: it fixed the panel body and left the title= f-string one line away.
  • Never let a Text reach a plain-string context — an f-string (rule F), str(), or the builtin print (rule E). str(Text) is its plain form, so styling is dropped and any text rich already parsed as a tag is gone outright. This one has the worst record in the codebase: it shipped four separate times during #406 alone, twice destroying data rather than colour. Use styled("{}{}", ...) or join(...) instead.
  • Do not use rich.markup.escape (rule H). It is not byte-exact — the parser treats \[ as an escaped bracket, so an ordinary regex like \[0-9\]+ renders as [0-9\]+ whether or not it was escaped first. Building a Text avoids the parser entirely.
  • Typer's help= / epilog= must escape their brackets as \[ ... ] (rule G). These are outside the console convention — Typer renders them through its own rich console — but they are still markup-parsed. Escaping is the remedy here rather than a Text, because Typer takes a str. Forgetting cost conductor run --help the entire [@registry][@version] syntax, which appears nowhere else in the help output.

The worst outcome of forgetting is now a visible literal [green] in the output rather than a crash or a silent deletion, and rule D of the guard catches that statically. Each rule is a shared predicate called by both the source scan and its negative control, so a control cannot pass while the rule it guards has drifted.

Provider Parity

All providers must maintain feature parity where applicable. Any change to one provider's behavior, contract, or capabilities must be applied to all providers. This includes:

  • Event callbacks: Same event types emitted at the same semantic points
    • agent_turn_start with {"turn": "awaiting_model"} — immediately before each API call
    • agent_turn_start with {"turn": N} — at the start of each agentic loop iteration
    • agent_message — for text content in responses
    • agent_reasoning — for reasoning/thinking content
    • agent_tool_start / agent_tool_complete — around tool executions
  • Retry and error handling: Same retry semantics, error classification (retryable vs. fatal), and timeout behavior
  • Structured-output recovery (issue #343): every provider with an in-session recovery loop must validate the parsed content against the declared schema inside that loop, not after returning. executor/agent.py:318 is only a backstop — by the time it runs, Copilot has already disconnected its SDK session, so a schema-shape failure there is unrecoverable by construction. The loop catches both JSON syntax errors and ValidationError, applies providers/_output_shape.py::unwrap_scalar_wrappers before validating, and re-prompts via a schema-specific correction message distinct from the syntax one (Copilot and Hermes share providers/_recovery_prompt.py::build_parse_recovery_prompt; Claude has its own tool-oriented wording). On budget exhaustion, re-raise the original ValidationError (it names the field and expected type); reserve ProviderError for syntax failures. Each attempt emits agent_parse_recovery via providers/_event_format.py::emit_parse_recovery_event. Two traps: parse_json_output wraps syntax errors in ValidationError too, so the two kinds cannot be told apart by exception type (Hermes splits them by which call failed); and Claude must not validate the _has_mcp_tool_use path, which returns to the agentic loop rather than being a final answer.
  • Plugin components (issue #378): execute() accepts custom_agents and extra_mcp_servers alongside skill_directories. Every provider takes all three; a provider declaring CAPABILITIES.plugins=True must honor all three, since there is no eager-injection fallback for a subagent or an MCP server. A provider that accepts them and drops one reinstates exactly the silent partial load the feature removed.
  • Output contract: Same AgentOutput structure with consistent field population (model, tokens, input_tokens, output_tokens, content)
  • Tool execution: Same MCP tool calling interface and result handling
  • Session management: Same lifecycle (validate_connection(), execute(), close())
  • Reasoning effort: All providers must accept the unified reasoning.effort field (low | medium | high | xhigh | max), translate it to the native API (Copilot reasoning_effort on the session; Claude extended thinking budget), validate that the selected model supports the requested effort, and raise ValidationError with a clear message when it does not. The one declared exception is Hermes, whose CAPABILITIES.reasoning_effort omits max (unverified upstream support) — this is not a parity violation because the provider both declares the narrower tuple and enforces it at execute time, matching the "declare the weaker value and honor it" rule in the Experimental Providers section below. Any reasoning/thinking content the model returns must be surfaced via agent_reasoning events so the dashboard, JSONL logger, and console subscriber render it consistently.
  • Model pricing hook (issue #265): AgentProvider.get_model_pricing(model) -> ModelPricing | None is an optional hook (base default returns None) that lets a provider supply live per-model rates. Cost resolution order in engine/pricing.py::get_pricing is workflow cost.pricing override → provider hook → DEFAULT_PRICINGNone. The engine bridges the async hook to the sync UsageTracker.record via WorkflowEngine._ensure_pricing_resolved(agent, model) (called before every record(); resolves each model once, caches on the tracker). Only Copilot implements it (derives USD from the SDK's billing.token_prices in AI Credits, 100 credits = $1 via _COPILOT_USD_PER_CREDIT); it must never raise (fall back to the table). WorkflowUsage exposes unpriced_agents / unpriced_models / has_unpriced so the CLI summary and dashboard surface ~$X (N agents unpriced) instead of presenting a partial as a complete total.
  • Max token-limit hooks: AgentProvider.get_max_prompt_tokens / get_max_output_tokens are optional hooks (base default returns None) that let a provider supply its SDK-reported per-model input/output caps; compaction uses the output cap to size its output reserve. claude, openai, and copilot implement both; hermes, aca, and claude-agent-sdk legitimately return None from the default (no SDK model-listing surface), which the compaction resolution cascade treats as a first-class "fall through to the registry/fallback" signal rather than an error.
  • Context-window measurement (issue #412): every provider must populate AgentOutput.last_call_input_tokens with the prompt-token size of the most recent single API call in the execution, or leave it None — it must never be an aggregate. This is distinct from input_tokens, which sums every call for billing and is not comparable to a model's context-window cap. Per-provider asymmetry: Copilot's assistant.usage event and pydantic-ai's RequestUsage.input_tokens already include cache reads/writes, so the field is the raw per-call value; the Claude Agent SDK's Anthropic-shaped usage dict reports cached prompt tokens separately, so the provider must sum input_tokens + cache_read_input_tokens + cache_creation_input_tokens. A provider that cannot isolate one call (e.g. Hermes after a parse-recovery run, which spins up a fresh AIAgent whose usage never reaches the outer result) reports None rather than guessing, which is a first-class "hide the bar" signal, not a fallback to the wrong number.
  • Cache-inclusive token accounting: AgentOutput.input_tokens is the total prompt across every call and must always include cache_read_tokens and cache_write_tokens — the cache fields are subsets of it, never additions to it. Copilot and pydantic-ai report it that way natively (pydantic-ai's own UsageBase documents the identical convention and normalizes the providers that don't); the Claude Agent SDK's Anthropic-shaped usage dict reports cached tokens outside its own input_tokens, so that provider folds them in via claude_agent_sdk.py::_read_usage — the same summation the context figure above uses — and surfaces both buckets alongside. Parsing those three keys in one helper is deliberate: reading them inline at each accumulation site is what let the .get() defaults drift apart and double-count the cache. engine/pricing.py::calculate_cost then subtracts the buckets back out before applying the input rate, so each physical token is priced exactly once — but only subtracts a bucket that has a rate to be charged at, because a 0.0 cache rate in DEFAULT_PRICING means "no published rate" (20 of its entries — every GPT, o-series and Gemini model) rather than "free", and subtracting unconditionally would price a cached prompt at nothing. Both halves match genai_prices.types.ModelPrice.calc_price; the one deliberate divergence is that it raises on a negative residual where conductor clamps and logs once per model, since a cost annotation must never abort a run. Billing the buckets additively instead overstates a cached tool-calling run by roughly an order of magnitude. A provider that populates the cache fields but leaves input_tokens exclusive under-bills instead; a provider that populates neither reports cache-free totals and is unaffected (hermes), and aca satisfies it transitively by relaying its in-sandbox provider's already-compliant figures.
  • In-memory continuation (AgentOutput.continuation_state, AgentProvider.supports_continuation): a provider that can resume a completed execution in memory declares supports_continuation=True, populates AgentOutput.continuation_state on every completed (non-partial) output, and when handed that state back on execute() continues the completed conversation, treating rendered_prompt as the sole next user turn. A provider that cannot must ignore the kwarg (with an "Ignored." docstring entry and a del, matching the other unused kwargs) and leave the field at None — the first-class "rebuild the prompt statelessly" signal the executor branches on. Today the Pydantic AI-based providers (claude, openai) declare it with outcome.result.all_messages() as the value, and hermes declares it with the run's result["messages"] (the same list its checkpoint-resume path already persists; withheld when a parse-recovery call produced the output, since that output lives in the recovery conversation the outer result never carries). Three invariants hold everywhere: the value is provider-opaque and typed object at the boundary (providers re-narrow it themselves); it must never be handed to a different provider; and it is never serialized to checkpoints or event logs. The executor refuses state from a provider that does not declare support, since continuing there would send the model the follow-up turn with no task prompt.

When modifying any provider, check all other providers for the same change. The dashboard, JSONL logger, console subscriber, and workflow engine all depend on consistent behavior across providers.

openai.py parity notes

The OpenAI provider (openai.py) implements AgentProvider on the shared Pydantic AI execution loop (src/conductor/providers/_pydantic_ai/runner.py::run_agent_pipeline). It shares toolset bridges, event callbacks, interrupts, and retry contracts with ClaudeProvider, with the following backend specifics:

  • Shared runner: Delegates execute() to run_agent_pipeline() with an OpenAIChatModel backend.
  • Env-resolution rule: YAML api_key and base_url take precedence over OPENAI_API_KEY and OPENAI_BASE_URL. Ambient env vars never reroute an unconfigured provider (e.g. provider: copilot is never diverted by OPENAI_* variables). Once a custom base_url is in effect — from YAML or from OPENAI_BASE_URL — the ambient OPENAI_API_KEY is not forwarded to it and construction raises ValidationError unless api_key was passed explicitly, so a personal credential never reaches an endpoint the author did not pair it with (the same reasoning behind copilot.py refusing ambient OPENAI_API_KEY). self._api_key is deliberately left None on the ambient path rather than being written back, which keeps agent_builder.py's own guard reachable in production instead of only under its unit test.
  • Chat-Completions-only: Exclusively uses the OpenAI Chat Completions API; the OpenAI Responses API is not supported. This is also why CAPABILITIES.agent_reasoning_events is False: pydantic-ai only builds a ThinkingPart from reasoning/reasoning_content, a DeepSeek/Moonshot field, so api.openai.com never emits agent_reasoning on this backend. It would become True on an OpenAIResponsesModel backend.
  • Reasoning effort: Declares ("low", "medium", "high") and rejects xhigh/max with a ValidationError. xhigh arrived with a later model generation than the o-series and is unverified against arbitrary OpenAI-compatible endpoints, so the provider declares the narrower tuple (matching the way hermes.py omits max). Effort is additionally validated per model at agent-build time via pydantic_ai.profiles.openai.openai_model_profile(...).openai_supports_reasoning, so a non-reasoning model like gpt-4o fails before the request instead of returning a 400 mid-run; an unavailable profile attribute yields None, which skips the check rather than guessing.
  • Temperature 0..2: The per-provider ceiling lives on ProviderCapabilities.max_temperature (1.0 for copilot/claude/hermes/aca/claude-agent-sdk, None for openai) and is enforced by factory.py::_enforce_temperature, the choke point run, resume and validate all share — the schema bound alone would not cover conductor run, which never calls the cross-reference validator.

claude_agent_sdk.py parity notes

The Claude Agent SDK provider (claude_agent_sdk.py) is the canonical experimental provider — see the "Experimental Providers" section below for the carve-out policy. It delegates the agentic loop to the claude CLI via the claude-agent-sdk package. This achieves event and output parity but the following are managed by the SDK rather than Conductor:

  • Retry and error handling: The claude-agent-sdk package does not retry API failures (429s, 5xx, network errors) internally — its built-in retry logic covers only filesystem operations. Conductor wraps SDK errors in ProviderError and uses stop_reason / error subtype to set is_retryable, so workflow-level retry: configuration drives all retry behavior. Plan for transient failures with explicit retry: blocks in your workflow.
  • MCP servers (issue #335): workflow-level runtime.mcp_servers are supported. _translate_mcp_servers maps each MCPServerDef-derived dict onto the SDK's McpStdioServerConfig / McpHttpServerConfig / McpSSEServerConfig shapes, and the provider passes them via ClaudeAgentOptions. Four details are load-bearing:
    • Translation runs once in __init__ rather than per execute call. Note providers are constructed lazily (ProviderRegistry.get_providerWorkflowEngine._get_executor_for_agent), so a bad server config surfaces when the first agent on this provider runs, not at conductor validate — which does not inspect per-server tools: filters at all.
    • The config is written to a 0600 temp file (_write_mcp_config) and passed by path. Passing the dict would make the SDK serialize it into a --mcp-config <json> argv element, publishing resolved stdio env values and http/sse Authorization headers to anything that can read /proc/<pid>/cmdline. The write happens inside execute's try, so the finally reclaims the file on every exit path; the finally also aclose()s the SDK iterator first, so the claude subprocess is gone before its config file is. The file must use the {"mcpServers": {...}} envelope — the CLI rejects a bare mapping.
    • strict_mcp_config=True is set unconditionally, including when the workflow declares no servers: otherwise the CLI loads project .mcp.json, user-global, and plugin-provided servers, and permission_mode bypasses approval for whatever they expose.
    • A narrowing per-server tools: filter (anything other than the default ["*"]) is refused, not ignored: forwarding the server unfiltered would grant more tools than declared, the same security regression that justifies refusing the per-agent allowlist. A dropped timeout only warns, since losing it cannot widen tool access.
  • Tool execution: Per-agent tools: allowlists remain unsupported (workflow_tools_passthrough=False). The provider refuses any non-empty per-agent list because workflow tool names do not translate to CLI tool IDs. Note the SDK's tools option governs built-in tools only, and allowed_tools is a permission auto-approve list rather than an availability filter — so honoring an allowlist would require a permission-mode redesign, not just a name mapping. An agent with tools: [] runs with no built-in tools beyond the Skill loader when skills are enabled (MCP servers still attach); omitting tools: grants the full claude_code preset.
  • Runtime config: temperature and max_tokens are rejected at the factory — the CLI controls sampling behavior.
  • Working directory (issue #348): the engine-resolved agent.working_dir / runtime.working_dir is forwarded, as ClaudeAgentOptions.cwd.
    • The SDK applies it as the claude subprocess's cwd (_internal/transport/subprocess_cli.py as of 0.2.87 passes it to open_process and sets PWD), so stdio MCP servers pick it up by inheriting it from that subprocess. There is deliberately no per-server stamping as in copilot.py::_mcp_servers_for_cwd: the SDK's McpStdioServerConfig has no cwd field, so _translate_mcp_servers is left alone. Inheritance is a property of the CLI binary, not of the SDK, so it is documented rather than asserted by a test.
    • The path is passed verbatimWorkflowEngine._resolve_agent_working_dir has already rendered, absolutized, normalised, and existence-checked it, and re-resolving here would collapse the symlink aliases the engine preserves on purpose. The ClaudeAgentOptions(...) construction lives inside execute's try so the os.getcwd() fallback can't escape as a bare OSError when the process cwd has been deleted (copilot.py resolves its cwd inside its try for the same reason).
    • There is no provider-side is_dir() guard: a directory that vanishes after the engine's check surfaces as the SDK's CLIConnectionError("Working directory does not exist: <path>"), wrapped in ProviderError. That is only defensible because _classify_startup_failure special-cases it — CLIConnectionError otherwise yields firewall/binary advice and is_retryable=True, which is wrong for all three launch failures (missing dir; path is a file → ENOTDIR; unreadable dir → EACCES; the latter two reach the SDK's generic "Failed to start Claude Code" arm, not its dedicated one).
    • Knock-on effects: cwd is the project key for the CLI's on-disk transcript directory, and it is where a project .mcp.json and .claude/ tree would be looked for. The unconditional strict_mcp_config=True stops a .mcp.json there from injecting undeclared servers, and setting_sources=[] (empty by default; see Skills below) stops the CLI loading CLAUDE.md, project settings, and hooks from it — so cwd no longer drags ambient instructions in unless the workflow opts in via runtime.provider.setting_sources, which is exactly a request to load them from that directory. add_dirs (the SDK's --add-dir passthrough) is a separate axis, carrying the per-agent settings_dir and nothing else — see Target-repository skills below.
  • Target-repository skills (settings_dir): the per-agent AgentDef.settings_dir is the only source of ClaudeAgentOptions.add_dirs, and it selects which directory's project settings tier contributes skills. It exists because cwd was doing two conflicting jobs on this provider. The CLI supports the MCP Roots protocol and advertises exactly one root — its cwd — so @modelcontextprotocol/server-filesystem discards the directories in its own argv and permits cwd alone; cwd is simultaneously what the project tier resolves against. Narrowing cwd onto a target repository to reach its skills therefore narrowed the agent's only MCP root below any sibling path the step still had to read. Do not derive add_dirs from a server's directory arguments to compensate: --add-dir takes no part in Roots negotiation, it widens the CLI's own file tools, and doing so would also silently widen skill discovery to directories the author named as data. What add_dirs does do, measured: a named directory's .claude/skills become listed and invocable with cwd elsewhere entirely — and only those, not CLAUDE.md, .claude/rules/*.md, .claude/settings.json (so no env and no hooks — the hooks negative is measured with a side-effect probe whose control fires) or .claude/agents, which all keep following cwd. That makes it the skills third of what a cwd-resolved project tier loads, not a replacement for it. It carries a second, unconditional effect the skills framing hides: add_dirs is "additional directories Claude can access" per the SDK's own contract, so a settings_dir widens the model's built-in Read/Edit/Bash to that tree with no settings tier enabled at all (measured against claude CLI 2.1.263 at permission_mode: "default" with setting_sources unset: a read outside cwd is refused without it and succeeds with it; an agent omitting tools: runs under bypassPermissions, where the grant is unobservable because reads already succeed). It does not widen what an MCP server permits. capabilities.py::settings_dir gates the field and config/validator.py errors against a provider that cannot apply it — enforced twice, with AgentExecutor._reject_unsupported_settings_dir repeating it at run time, because conductor run never calls the static validator — warning when the agent's session will not enable the project tier (also twice: config/validator.py at validate, and claude_agent_sdk.py::execute at run time, since the filesystem grant applies even when the skills half no-ops; the run-time half is latched per (resolved directory, cause) — not per agent, because a for_each member is renamed per item and would otherwise warn once per iteration, and not per directory alone, because the remedy differs by cause and one line would prescribe a fix wrong for the agent it does not name; two agents sharing a directory and a cause do collapse to one line). The opt-out remedy assumes the project tier is otherwise present: an agent with skills: [] under a user/local-only tier is told to remove the opt-out, which is necessary but not sufficient, so that author reaches a working config in two steps rather than one. Deliberate -- distinguishing it needs a third cause value and a wider latch key to serve a combination requiring two unusual settings at once. WorkflowEngine._resolve_agent_directory resolves both fields so they cannot drift, and settings_dir is per-agent only (no runtime. counterpart: the repository whose conventions apply is what varies between steps). tests/test_integration/test_mcp_roots_negotiation.py pins the negotiation rule itself against the real server with no LLM — two runs differing only in whether the client advertises roots — since every option here rests on it (marked real_api: it fetches the server from npm and pins upstream's behaviour, not Conductor's). The resolved value is emitted on agent_started / parallel_agent_started / for_each_agent_started alongside working_dir, because it is a trust decision — another repository's conventions plus a filesystem grant — and one the run output would otherwise never mention.
  • Skills (issue #352): supports_native_skills=True. Skills are enabled through the SDK, not prompt injection, and three options move together in execute:
    • plugins=[{"type": "local", "path": <plugin root>}] + skills=["<plugin>:<skill>"]. The SDK has no skill-directory surface, so _resolve_skill_plugins maps each directory back to the plugin that owns it via skills/registry.py::resolve_skill_plugin. That resolver is deliberately strict, because every one of these mistakes otherwise produces a name the CLI silently resolves to nothing: it bounds the upward walk (_PLUGIN_SEARCH_DEPTH), requires the skill to actually live under the candidate's skills/ directory, requires SKILL.md to exist and its frontmatter name to equal the directory name (the CLI resolves by frontmatter name; Conductor sends the directory name), and rejects names outside [A-Za-z0-9_.-]+ since they are joined into a comma-delimited --allowedTools value. A skill under no plugin root returns None; a plugin that is present but unusable raises SkillPluginError, which the provider re-raises as a ProviderError carrying the real reason rather than a blanket "not part of a plugin". Two plugins claiming one qualified name are refused too — deduping the clash away would drop a declared skill. All of it is is_retryable=False: these never become valid on a retry, and a checkout path containing "connection" would otherwise trip the retryability heuristic. Note providers are constructed lazily, so this surfaces when the first agent on this provider runs, not at conductor validate.
    • setting_sources=[] by default, for the same reason strict_mcp_config=True is unconditional a few lines away — but opt-in per workflow via runtime.provider.setting_sources (user/project/local, issue #501). Left unset, the CLI loads user, project, and local settings, which between them bring in ambient skills, CLAUDE.md, and hooks the workflow never declared, varying by machine and launch directory. Conductor surfaces instruction files through its own opt-in --workspace-instructions; settings and hooks have no equivalent. skills=[] and skills=None are not interchangeable upstream: None means "CLI defaults apply", and setting skills while leaving setting_sources at None makes the SDK re-default it to ["user", "project"] — so the two options are coupled and dropping either re-opens the issue. The [] is also invisible in argv (it travels in the SDK's initialize control request), which is why the argv-based tests are paired with options-level assertions. The list is a context filter, not a sandbox: undeclared skills are hidden from the model's listing and rejected by the Skill tool, but their files stay readable. Opting a workflow in (runtime.provider.setting_sources: [project], the motivating case being an agent whose working_dir is a target repo shipping its own .claude/skills — the CLI has --plugin-dir but no --skill-dir) loads that tier with its hooks, so it is only for repositories trusted as much as the workflow itself, and the field is workflow-global while working_dir is per agent. Two couplings follow: _resolve_skill_filter resolves skills to "all" when a tier is enabled and the workflow named none itself (tier-discovered skills never pass through skill_names, so [] would load the repo's skills and then hide every one), and a per-agent skills: [] opts that agent out of the tier entirely — hooks included — keeping it the one opt-out.
    • An explicit tools: [] sends --tools "" (empty base tool set), which would leave a declared skill unreachable; _resolve_tool_config therefore grants back the single Skill tool when skills are enabled. No permission bypass is needed — the SDK auto-allows it via allowed_tools: Skill(<name>) per declared skill, or the bare Skill on the settings-tier path, where the skill names are discovered by the CLI and Conductor has none to scope the grant to (a broader auto-approve, which is part of what enabling a tier asks for).
    • The executor→provider seam (executor/agent.py) is the only thing carrying the feature now that eager injection no longer backs it up on this provider, so tests/test_skills/test_executor_integration.py::TestSkillDirectoriesReachTheProvider asserts directories actually arrive at execute. A negative "no <skills> in the prompt" assertion cannot tell a working native path from one that dropped the skills entirely.
  • Plugins (issue #378): plugins=True, supports_native_plugins=True. A plugin's subagents become inline ClaudeAgentOptions.agents (_build_sdk_agents maps the shared CustomAgentConfig-shaped specs onto AgentDefinition, which is keyed by name and so has no name field of its own; Copilot's infer has no counterpart and is dropped). Returns None rather than {} when there are none — unlike skills, an empty mapping has no opt-out meaning here, so the option stays out of the request entirely. Plugin MCP servers go through the same _write_mcp_config temp-file path as the workflow's own, translated per call rather than in __init__ because plugins: is per-agent; the unconditional strict_mcp_config=True suppresses whatever a registered plugin root would otherwise contribute, which is precisely what makes mcp: false mean something here. The one declared carve-out: reaching a plugin's skills requires registering its root via the plugins option, which the SDK documents as providing "custom commands, agents, skills, and hooks" — with a skills filter and none for the rest. So agents: false alongside skills: true for the same plugin is refused in config/validator.py rather than silently granting more than the workflow declared, and the hooks/ warning here says exposed to the CLI rather than not loaded, which would be false. The identical config works untouched on copilot.
  • Session continuity (session_key): session_continuity=True. A per-agent AgentDef.session_key reuses one Claude session across executions, so an intra-run loop-back keeps the context it already built; without a key every execution starts fresh. User-facing docs live in docs/workflow-syntax.md (Session Continuity), with examples/claude-agent-sdk-session-key.yaml.
    • _session_ids is keyed by (session_key, cwd) — by the authored key rather than the agent name, because sharing one session between agents is the point, and by cwd because the CLI stores transcripts per working directory, so one key under two directories is two sessions. _resolve_resume_session prefers an id recorded this run over a checkpoint-restored one and passes it as ClaudeAgentOptions.resume; fork_session=False is explicit because a fork would issue a new id and strand the map on a dead session. Only AssistantMessage / ResultMessage (_SESSION_ID_MESSAGES) update the map — hook, task, and stream frames carry session_ids of their own, and recording one would shadow the real session with a transcript-less id.
    • The transcript guard is load-bearing. --resume against a transcript the CLI cannot find aborts it with ProcessError before running the agent, so a naive passthrough would turn an ordinary first iteration, a pruned transcript, or a moved working_dir into a hard failure. _session_transcript_exists checks the exact path the CLI uses, <CLAUDE_CONFIG_DIR or ~/.claude>/projects/<project_key_for_directory(cwd)>/<id>.jsonl. get_session_info answers a different question — it derives a summary and returns None when it cannot, so a resumable session can look absent, and it resolves through sibling git worktrees and a global project scan, which is wider than the (session_key, cwd) scoping we promise authors. (The CLI itself resolves --resume through those same fallbacks, so it is more permissive than this guard, not less: a cross-directory resume succeeds. The gate is deliberately stricter, to hold the scoping contract.) It is consulted only as a fallback — it tolerates a hash mismatch for very long paths — and only when its recorded cwd realpath-matches ours. Any exception degrades to "start fresh".
    • Checkpointing reuses the duck-typed get_session_ids / set_resume_session_ids hooks from copilot.py, so no CLI or checkpoint-schema change was needed. copilot_session_ids is a flat dict shared by every provider and our author-chosen keys genuinely collide with Copilot's agent names, so ours are namespaced and carry the cwd: claude-agent-sdk:["<key>", "<cwd>"]. Unrecognised entries are skipped rather than raised on, and the engine merges every active provider's map instead of stopping at the first (copilot and hermes still shadow each other — pre-existing). checkpoint_resume stays False: that flag is a blanket promise the startup banner reads out, and agents without a key — the default — carry nothing across.
    • session_key is rejected on script / human_gate / questions / workflow / wait / set / terminate, is whitespace-stripped with min_length=1, and is never Jinja2-rendered — validate_session_key_is_literal rejects {{ ... }} rather than letting it become one literal key shared by every for_each iteration. _check_agent_capabilities gates it on session_continuity, and config/validator.py rejects a key shared by concurrent executions (two parallel members, or a keyed for-each agent with max_concurrent > 1), which would leave two claude processes appending to one transcript.

aca.py parity notes

The ACA (Azure Container Apps) provider (aca.py) is an experimental provider (issue #284) — see the "Experimental Providers" section below for the carve-out policy. Unlike claude_agent_sdk.py, which delegates the loop to a local CLI subprocess, aca.py delegates the entire agentic loop to a remote sandbox: AcaRuntimeProvider is a thin host-side transport shim that derives a session identifier, authenticates via DefaultAzureCredential, and relays NDJSON event frames from an in-container conductor-agent-runner (which itself wraps a real CopilotProvider) verbatim to event_callback. Because the runner re-emits Conductor's own event vocabulary and forwards a real CopilotProvider's output, this achieves full event and output parity (mcp_tools, streaming_events, agent_reasoning_events, and reasoning_effort are all declared True) — with the following carve-outs:

Inner Copilot credential (DD4). The sandbox's Copilot session can't do interactive OAuth, so the host resolves a credential per request and forwards it in-memory: COPILOT_PROVIDER_BASE_URL (BYOK) → COPILOT_GITHUB_TOKEN/GH_TOKEN/GITHUB_TOKENgh auth tokenProviderError. The gh step (_resolve_gh_cli_token) is what makes the zero-setup path work and mirrors the Copilot CLI's own documented chain; every failure mode (not installed, not signed in, timeout, empty output) means "no token" and falls through rather than raising. Reading the Copilot editor plugins' ~/.config/github-copilot/auth.db is deliberately not implemented — that store belongs to the Copilot Language Server (not the CLI/SDK the runner drives), has changed format twice, and is slated for encryption at rest. Note for tests: any test asserting the "no credential" error must stub the gh subprocess, or it will pick up the developer's real token (see TestAcaCredentialPrecedence._clear_credential_env).

  • workflow_tools_passthrough=False: the per-agent tools: allowlist is forwarded to the runner in the request body, but the in-container CopilotProvider it wraps never applies that list to the SDK session — every tool/MCP server available to the session is callable regardless of the declared allowlist. Combined with mcp_tools=True (the full configured mcp_servers set is always forwarded), there is no allowlist value the runner can honor — not even tools: [] — so config/validator.py rejects any explicit tools: on an aca-backed agent, not just a non-empty one (review follow-up, #284 E7). This mirrors the same declared carve-out on claude_agent_sdk.py and hermes.py. hermes.py declares mcp_tools=False (nothing is ever forwarded regardless of the list), so tools: [] genuinely disables all tools and stays valid for it. claude_agent_sdk.py now behaves like aca whenever the workflow declares mcp_servers, and like hermes when it does not.
  • working_dir=False: this capability field means "applies the generic, host-resolved agent.working_dir / runtime.working_dir" — a host filesystem path the engine resolves against the workflow file's directory. aca never reads that field; it only honors the separate, container-relative sandbox.working_dir block (documented in docs/providers/aca.md#workflow-configuration), which has no meaning as a host path and is not gated by this capability.
  • interrupt/max_session_seconds are declared True host-side but not fully backed by the shipped runner MVP (epic E4): the runner has no /interrupt endpoint yet (the host's in-stream interrupt POST has nowhere to land, so the host falls back to a best-effort session-delete call, itself unsupported for custom-container pools, before giving up waiting — not instantaneous; both cleanup calls use an explicit 10-second per-call timeout), and max_session_seconds enforcement is only a best-effort, Copilot-internal timeout (the wrapped CopilotProvider's own IdleRecoveryConfig check) — there is no independent runner-level guard. Stopping an aca-backed agent today eventually stops the host from waiting, not the sandbox from computing. See docs/providers/aca.md#known-gaps-runner-mvp.
  • plugins=False: same reason as skills=False above — a plugin's skill directories, agents/*.agent.md files, and .mcp.json are all host filesystem paths the in-sandbox runner cannot read.
  • checkpoint_resume=False: ACA dynamic-sessions sessions are ephemeral with no volume mount, so there is nothing in-sandbox for conductor resume to restore. A resumed workflow re-runs the aca-backed agent from scratch rather than continuing an interrupted sandbox session — the same posture claude_agent_sdk.py and hermes.py declare, but for a different underlying reason (remote ephemeral filesystem vs. local CLI process state).
  • Runner hardening (issue #396): the MVP runner's posture depended entirely on the session-gateway network boundary; five independent, none-load-bearing layers now defend in depth, mirroring web/auth.py's issue #397 framing. (1) aca_runner/__main__.py binds 127.0.0.1 by default (the container image's Dockerfile sets ACA_RUNNER_HOST=0.0.0.0 explicitly, so it is unaffected). (2) aca_runner/auth.py::resolve_runner_token reads the opt-in ACA_RUNNER_AUTH_TOKEN; when set, /execute requires a matching X-Conductor-Runner-Token header (compared via the existing web/auth.py::constant_time_match, reused rather than re-derived) and rejects with 401 otherwise — checked before the inner Copilot provider is constructed. X-Conductor-Runner-Token is chosen over Authorization because the ACA session gateway consumes that header itself (it carries the AAD token). GET /health stays unauthenticated (the image's own HEALTHCHECK sends no header) but reports auth_required and auth_token_present — the latter is header presence only, never validity, so it cannot become a brute-force oracle — letting AcaRuntimeProvider._warn_on_auth_skew (alongside the existing _warn_on_version_skew, both inside validate_connection()'s contextlib.suppress(Exception) block) warn when a gateway is silently stripping the header, or when the host has a token configured but the runner doesn't enforce one. (3) aca_runner/auth.py ::check_inner_provider_settings rejects any inner_provider_settings key outside ALLOWED_INNER_PROVIDER_SETTINGS_KEYS (base_url/api_key/ bearer_token/github_token — the four AcaRuntimeProvider ._resolve_inner_provider_settings actually produces), closing off runtime_url/headers injection that no base_url allowlist alone would catch; ACA_RUNNER_ALLOWED_BASE_URLS additionally restricts which BYOK base_url values are accepted. (4) The runner token header is merged into the Authorization headers dict at the three runner-forwarded call sites only (execute(), _send_interrupt(), validate_connection()) — not _stop_session(), whose DELETE {endpoint}/session is an ACA management-plane operation that never reaches the runner, so adding the header there would leak the runner credential to the Azure control plane instead. (5) identifier is reconciled by documentation rather than code: it remains gateway routing metadata the runner never inspects as an authentication signal (the runner has no independent source of truth for which identifier it should be serving, and the container HEALTHCHECK sends none at all) — see docs/projects/aca/aca-provider.design.md's Identifier as a capability bullet and docs/providers/aca.md#security.

Full architecture, the runner /execute//health contract, the NDJSON frame schema, and the credential/security model are documented in docs/providers/aca.md and the source design at docs/projects/aca/aca-provider.design.md.

Experimental Providers

Some providers delegate part of the agentic loop to an upstream SDK or framework and cannot honor every parity rule above. Rather than reject them or let parity silently erode, Conductor formalizes an experimental tier with explicit allowed carve-outs and a static validator that catches workflow ↔ provider mismatches at conductor validate time. See docs/providers/experimental.md for the full stability policy.

Capability declaration. Every provider — stable or experimental — declares a class-level CAPABILITIES: ProviderCapabilities attribute (see src/conductor/providers/capabilities.py). The descriptor is a contract: behavior must match what the provider declares. Lying in the descriptor undermines the framework.

Allowed carve-outs for experimental providers (declared as False / None on the descriptor):

  • mcp_tools — workflow-level runtime.mcp_servers is not forwarded
  • workflow_tools_passthrough — per-agent tools: allowlist is not enforced
  • streaming_events — events emitted only at completion (not incrementally)
  • agent_reasoning_events — no thinking/reasoning event surfacing
  • reasoning_effort — provider has no reasoning-effort concept
  • structured_output: "prompt_injection" — schema enforced via prompt injection only
  • interrupt — mid-call interrupt not honored (still cancels between iterations)
  • max_session_seconds — wall-clock session timeout silently ignored
  • checkpoint_resume — session state does not survive conductor resume
  • session_continuity — per-agent session_key not honored (every execution starts a fresh session)

Non-negotiable rules experimental providers MUST uphold:

  • AgentProvider lifecycle (validate_connection / execute / close).
  • AgentOutput shape on every successful execution (fields may be None).
  • Raise real exceptions on real errors — no silent failure swallowing.
  • Declare accurate ProviderCapabilities matching observed behavior.
  • Declare skills accurately. Skills are not an allowed carve-out — a provider reaches skills=True either natively (supports_native_skills=True, forwarding the resolved skill directories to its SDK in whatever shape that SDK accepts) or via AgentExecutor's eager preamble injection, which is provider-agnostic. Declare False only when neither path can work (e.g. aca, where skill directories are host paths the in-sandbox runner cannot read). config/validator.py cross-checks per-agent skills: and inherited runtime.skills against this flag, so an inaccurate False turns into a spurious validate error and an inaccurate True silently drops the skill content at run time.
  • Declare plugins accurately. Unlike skills, this one is an allowed carve-out, because there is no provider-agnostic fallback: eager injection can carry a skill's text into a prompt but cannot produce a subagent the model dispatches to or an MCP server it calls. A provider reaches plugins=True only by honoring all three of skill_directories, custom_agents and extra_mcp_servers on execute(). Declaring True while dropping one is the worst outcome available — it reinstates exactly the silent partial load issue #378 removed.
  • Provide a smoke test that exercises construct + execute paths against a mocked SDK.
  • Maintain concurrent_safe: true, or fail validation when used in parallel/for_each groups with max_concurrent > 1.

Promotion criteria (experimental → stable) are documented in docs/providers/experimental.md — full parity capabilities, named maintainer, real-API integration test, ≥6 months stable upstream, end-to-end example workflow.

Run / Resume Parity

The run and resume commands must accept the same flags wherever a flag is meaningful for a resumed run. When adding a new flag to run, add it to resume too unless there's a specific reason it cannot apply.

Flags that must be mirrored on both:

  • --provider / -p — runtime provider override
  • --metadata / -m — CLI metadata merged on top of YAML metadata
  • --skip-gates — auto-select first option at human gates
  • --log-file / -l — debug log file path (auto or explicit)
  • --no-interactive — disable Esc-to-pause keyboard listener
  • --web — start the real-time web dashboard
  • --web-port — dashboard port (0 = auto-select)
  • --web-bg — fork a detached process running the workflow + dashboard

Flags intentionally not mirrored on resume (and why):

  • --input / -i — workflow inputs are restored from the checkpoint context; supplying them at resume would conflict.
  • --workspace-instructions, --instructions — the instructions_preamble is persisted in the checkpoint and restored verbatim; re-supplying would be ambiguous.
  • --dry-run — resume executes from a saved point and is incompatible with planning-only output.
  • --guidance — resume-only (issue #400); there is no equivalent "mid-run" moment on a fresh run to apply it before, so it has no run counterpart to mirror.

Implementation parity rules:

  • The async helpers (run_workflow_async and resume_workflow_async in cli/run.py) must wire up the same event emitter, JSONL event log subscriber, console event subscriber, and WebDashboard lifecycle.
  • The WorkflowEngine constructor receives the same kwargs in both paths (event_emitter, web_dashboard, run_context, interrupt_event, keyboard_listener, instructions_preamble).
  • Background-process forking lives in cli/bg_runner.py. run --web-bg calls launch_background() and resume --web-bg calls launch_background_resume(). Both must forward equivalent options and write a PID file via cli/pid.py.
  • Note: on resume, the dashboard is seeded with prior events before it starts accepting clients. The CLI prepends a fresh workflow_started event built from the current workflow YAML (via WorkflowEngine.build_workflow_started_data()) so historical events apply to the correct topology; it then either replays the original JSONL event log (WebDashboard.replay_events_from_jsonl() — when the checkpoint records an event_log_path and the file exists) or synthesises minimal *_started / *_completed pairs from the restored WorkflowContext (replay_synthetic_from_context()). The resumed engine's own workflow_started emit is suppressed via engine.suppress_workflow_started_emit() so the dashboard sees exactly one root workflow_started (no wfDepth double-count). Two disjoint sets of events are filtered on replay. _REPLAY_ROOT_SKIP_TYPES (workflow_started / workflow_completed / workflow_failed / checkpoint_saved / checkpoint_save_failed) is filtered only at root depth — subworkflow-level lifecycle events are preserved so frontend wfDepth stays balanced. _REPLAY_INTERACTIVE_SKIP_TYPES (agent_paused / agent_resumed / iteration_limit_reached / iteration_limit_resolved / dialog_started / dialog_completed) is filtered at every depth, because the control channel is the root dashboard's resume_event / kill_event / gate id no matter which engine emitted the event. Each of those sets a global store latch (isPaused, iterationLimitGate, activeDialog) that only its counterpart event can clear — plus, for isPaused/iterationLimitGate only, a root terminal event, which the first set filters — so replaying the opening half of an unresolved pair latches it on for the whole resumed run. Concretely, a run stopped then killed from the dashboard logs agent_paused with no agent_resumed, so replaying it renders Resume/Kill instead of Stop on the live resumed run, hiding the only graceful stop behind a Kill that hard-stops a healthy workflow. A pause or gate the resumed run genuinely re-enters emits its own fresh event; dialog_message is left unfiltered only because it is inert once dialog_started is filtered — both renderers of node.dialog_messages (DialogEngagementPrompt via DetailPanel, DialogDetail via DialogOverlay) gate on dialog_active/activeDialog, which only dialog_started sets, so replayed dialog transcripts are not visible on a resumed dashboard. components/layout/Header.tsx additionally hides all live-control buttons when replayMode is set, since ReplayDashboard serves no /api/stop|/api/resume|/api/kill; replayMode is latched in hooks/use-replay.ts on mount (that hook only mounts once App's /api/replay/info probe confirmed replay) rather than when /api/state resolves, so a slow or failed event fetch cannot leave the live controls rendered. The resumed EventLogSubscriber opens the original JSONL in append mode (when available) so a multi-resume session produces one continuous log file and run_id stays stable for log-correlation tools.

Pydantic AI Runner Extraction

The shared Pydantic AI execution loop from ClaudeProvider.execute() was extracted into src/conductor/providers/_pydantic_ai/runner.py::run_agent_pipeline(). ClaudeProvider.execute() is now a thin wrapper that:

  1. Resolves the MCP manager for the agent's working directory.
  2. Builds a backend-specific build_agent_fn(toolsets, *, max_parse_recovery_attempts) closure.
  3. Converts the provider-level RetryConfig to the internal PydanticRetryConfig representation.
  4. Delegates to run_agent_pipeline(...).

The runner owns the toolset wiring, retry/interrupt/extract pipeline, partial-output construction, and model-name resolution. To keep existing tests green, the runner imports its Pydantic-AI seam helpers (run_with_interrupt, execute_with_retry, extract_content, etc.) inside run_agent_pipeline() rather than at module scope. This preserves the historical patching surface (conductor.providers._pydantic_ai.interrupt.run_with_interrupt, etc.) that tests rely on.

Provider registration and validation notes

  • Provider-aware temperature bounds: RuntimeConfig.temperature widens the schema upper bound to 2.0 because OpenAI supports it, but most providers (copilot, claude, hermes, aca, claude-agent-sdk) cap at 1.0. config/validator.py enforces the per-provider bound at conductor validate time so high temperatures do not fail mysteriously at the SDK boundary. Only the openai provider is exempt; override agents to openai or keep temperature <= 1.0 for the others.
  • Provider registration completeness: adding a new provider requires updating providers/factory.py::ProviderType and providers/diagnostics.py::_CREDENTIAL_SPECS (and providers/capabilities.py::_PROVIDER_CLASS_PATHS for validate-time capability checks). Latent forwarding bugs happen when ProviderRegistry.get_or_create_provider does not pass new runtime fields to create_provider.
  • Provider creation is serialized per registry: providers are cached per type, and ProviderRegistry._get_or_create_provider holds one per-registry asyncio.Lock across create_provider(...), re-checking the cache inside it, so concurrent agents resolving one type share a single instance rather than each building one and the last write winning — which matters for any provider holding per-instance state. Cached reads stay lock-free.
  • OpenAI provider routing restrictions: ProviderSettings for name="openai" rejects type, wire_api, bearer_token, headers, azure, and Copilot runtime fields with targeted messages because the native OpenAI provider always speaks Chat Completions and does not support Copilot custom routing.