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.
# 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 examplesReleases 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.
-
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 viaapp.add_typer(...)—registry,plugin(→list/fetch),mcp(→serve),checkpoint(→list), andgate(→respond) — withrich_help_panel=organising the root--helpinto 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 commandscheckpointsandgate-respondremain 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 (includingguide, rich_help_panel="Interact"), and the hiddencheckpoints/gate-responddeprecation aliases.statusalso lists recently-completed runs, not just live ones (R1) — see the fleet.py bullet below for the identical change tofleet list;status --jsonkeeps its existingrunningarray unchanged and adds a siblingcompletedarray, and--liverestores the exact pre-change scope on both the table and the JSON payload.guide.py-guide_impl(text, port, token)behindconductor guide(issue #400): resolves the dashboard port viapid.py::scan_pid_files()when--portis omitted (read-only, matchingapp.py::status's reasoning), POSTs{"text": ...}toPOST /api/guidance, and maps 403/409/422/connect-error the same waycli/gate.py::_gate_respond_impldoes.guideis a flat top-level command (not aguide respondsub-app) since there is exactly one verbcheckpoint.py-checkpointgroup (checkpoint list) + shared_list_checkpoints_impl(modeled onregistry.py)gate.py-gategroup (gate respond) + shared_gate_respond_impl(modeled onregistry.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-registrygroup (list/add/remove/set-default/update/show)plugin.py-plugingroup (list/fetch). Deliberately noupdate: a floating ref self-updates and a pinned one is meant not to.fetchexisting as a separate verb is what keepsconductor validateoff the networkmcp.py-mcpgroup (mcp serve, issue #432): starts an MCP server over stdio exposing every configured registry's workflows as tools (--registry/--allow/--deny/--workflow-dirnarrow the exposed set;--toolsets/--max-direct-tools/--max-wait-seconds/--tool-prefix/--max-concurrent-runs/--introspect-fullcontrol behavior).no_args_is_help=Truelikecheckpoint/gate— this group has no bare-invocation default, unlikefleet. Every reference toconductor.mcp.*is a lazy import insideserve()'s body, deliberately:conductor.mcp.__init__(the existing MCP client package,mcp/manager.py) eagerly imports the fullmcpSDK as a side effect of its own__init__.py, andcli/app.pyimports everycli/*.pysub-app module on everyconductorinvocation — so a top-level import here would pay that SDK-import cost for every command, not justmcp serve. Seedocs/mcp-server.mdfor the user-facing guide andsrc/conductor/mcp/serve/below for the server itself.fleet.py-fleetgroup (list,prune). The one deliberate deviation from thecheckpoint/gate/registrysub-app pattern:fleet_appsetsinvoke_without_command=Truerather thanno_args_is_help=True, because the bareconductor 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 optionaltuiextra; aTEXTUAL_AVAILABLEflag (checked only in the bare-invocation callback, mirroringproviders/aca.py'sAZURE_IDENTITY_AVAILABLE) prints an install hint and exits non-zero rather than raisingImportErrorwhentextualisn't installed. That hint comes frominstall_hint.py::install_command("tui")and is printed withsoft_wrap=Trueso rich never breaks the copy-pasteable command across lines — do not re-hardcodepip install 'conductor-cli[tui]', which cannot work (issue #441).fleet list/fleet pruneneed no optional dependency.fleet listalso lists recently-completed runs, not just live ones (R1) — a contract change to what the command means, bounded by[fleet.retention].keep_lastand sourced fromfleet/records.py::read_terminal_records;--liverestores the exact pre-change scope. Seesrc/conductor/fleet/below for the TUI itself.doctor.py-doctordiagnostics rendering (thin presentation layer overproviders/diagnostics.py)run.py- Workflow execution command with verbose logging helpersbg_runner.py- Background process forking for--web-bgmode. Captures the detached child's stdout/stderr to$TMPDIR/conductor/conductor-<name>-<ts>-<runid>.bg.{stderr,stdout}.logso silent crashes (uncaught Python exceptions,faulthandlerdumps) leave a forensic trail — DEVNULL is not used for stdout/stderr. PassesCONDUCTOR_RUN_ID,CONDUCTOR_BG_STDERR_LOG, andCONDUCTOR_BG_STDOUT_LOGto the child via env so the child'sEventLogSubscribershares a run id with the bg log files and surfaces both paths inworkflow_startedsystem metadata. Returns aBackgroundLaunchdataclass (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_launchwaits for the dashboard to become reachable and then pollsconductor.fleet.records.read_run_record(run_id)until the child has written its own record (matched onmode/port, and eitherpidequality 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_serverre-probe), the gate logs a warning naming the run id and stderr log and lets the launch proceed withrun_record_written=Falserather than terminating a healthy workflow over a failed diagnostic write —cli/app.py::_print_web_bg_no_run_record_noticesurfaces this to the user (verbose mode only, alongside the other--web-bgnotices) and thefleetTUI's New Run screen does the same via a warningnotify(). 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.pidfile 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 reinstatedwrite_pid_file.launch_background_resumeadopts 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, checksproc.poll()on every iteration of its socket-connect loop (keyword-onlyprocparam), 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 thewrite_pid_fileit replaced, since the child only writes the record once it is executing. Stage two,_wait_for_workflow_start, pollsGET /api/infofor up toCONDUCTOR_WEB_BG_START_TIMEOUTseconds (default 30,0disables the probe) until the payload carries astarted_atkey (not truthiness — it can legitimately be0), proving the engine actually emittedworkflow_startedrather than just its HTTP server coming up (issue #410).StartProbeenumerates the four outcomes (STARTED/CHILD_EXITED/PORT_CONFLICT/TIMED_OUT), mirroring theLiveness/Identityenum pattern incli/pid.py/cli/app.py. ACHILD_EXITEDwith a non-zero code (orPORT_CONFLICT, when/api/inforeports 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 onpid, since a resumed launch can carry a checkpoint's originalrun_id) and raisesRuntimeErrorwith a bounded stderr-log tail (_tail_log); a clean exit-0 or aTIMED_OUTwith the child still alive both return normally — the latter asworkflow_started=False, whichcli/app.pysurfaces as a "still initializing" note rather than a failure.still_runningis re-polled after the gate returns so a sub-second run is never advertised with a live dashboard URL. Identity, notPopen.pid, is what stage two compares against (issue #444).Popen.pidis not always the pid of the process that ends up running the workflow: a trampolinesys.executable(e.g. a Windowsuv tool install, the documented install path) re-execs into a different one, so comparing stage two's/api/infopayload againstproc.pidproduced a falsePORT_CONFLICTon every port under that install path — and, one stage earlier, made the run-record poll'spid == proc.pidcheck 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_recordaccepts a record whosepiddiffers fromproc.pidwhen the record is fresh (_record_is_fresh: itsstarted_atparses to a timezone-aware timestamp at or afterlaunched_at, captured immediately before_spawn_detached) — freshness is what a stale-record concern actually needs, andproc.pidequality was never it under a trampoline. Once a record is confirmed, itspid(notproc.pid) is carried forward asconfirmed_child_pidand handed to_wait_for_workflow_start, which classifies the dashboard's reported identity (pid first,run_idas fallback) via_classify_dashboard_identity/_DashboardIdentity(mirroringcli/app.py'sIdentity/_confirm_identity). A mismatch (FOREIGN) is only fatal whenconfirmed_child_pidis notNone; 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-fatalTIMED_OUTnote. ThePORT_CONFLICTerror message now names the foreign pid captured before_terminate_childruns (via the probe loop's own last-seen payload) rather than probing/api/infoafter the child is already dead, which previously always rendered(PID unknown). Termination reaches the whole process tree, not justPopen.pid(issue #447). On Windows,_spawn_detached_windowscreates the child suspended (CREATE_SUSPENDED) and assigns it to a fresh job object (_create_job_object,JOB_OBJECT_LIMIT_BREAKAWAY_OKset, deliberately notJOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSEso the tree outlives the launcher) before resuming its primary thread (ResumeThread, always called from afinallyso a failed job assignment can never leave the child permanently suspended) — this closes the window a trampolinesys.executablecould otherwise re-exec through before the job could be created._WindowsDetachedProcess(thePopen-shaped wrapper this requires, since CPython's ownPopencloses the child's thread handle before__init__returns, making suspend-then-assign impossible on top of it) exposesterminate_tree()(TerminateJobObject, reaching every process in the job regardless of exec depth). On POSIX,start_new_session=Truealready makes the child a process-group leader, soos.killpgis 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_childnow returns a_TerminationOutcome(confirmed: bool,surviving_pids: tuple[int, ...]) rather than a bareNone: 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.pidand, if different,confirmed_child_pid) actually confirms the outcome instead of assuming it._remove_dead_child_recordis now keyed on the confirmed pid (via the shared_cleanup_record_after_terminationhelper) — but only fires once that pid is confirmed dead by the sweep, so a surviving orphan keeps the run record that isconductor stop's only remaining handle on it._finalize_background_launchhas 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_noterenders the corresponding half of each failure message: "The background process was terminated." only whenconfirmedis true; otherwise a warning naming the surviving pid(s) and pointing atconductor status/conductor stop --port. A keyword-onlycwd: Path | None = None(issue #477, defaultNone) threads through_spawn_detached_posix/_spawn_detached_windows/_spawn_detached/_spawn_bg_child/launch_background/launch_background_resumetosubprocess.Popen(cwd=...)(POSIX) /_winapi.CreateProcess'scurrent_directoryargument (Windows, both call sites) -- the child's ownos.getcwd()is whatengine/workflow.pystamps assystem.cwd.Noneon every existing CLI path, so--web-bg/resume --web-bgare unaffected; only the Fleet TUI's New Run screen (fleet/launch.py) supplies one._spawn_bg_childvalidatescwdis an existing directory before opening the bg log files, raising a namedRuntimeErrorrather than letting a bad path reachPopenas an indistinguishableFileNotFoundError. Bothlaunch_backgroundandlaunch_background_resumealways invoke the child with-P(not a conditional env var), so the interpreter never puts the child's cwd onsys.path[0]-- a chosen directory containing a strayconductor/package would otherwise shadow the installed one -- without leaking aPYTHONSAFEPATHenv var to the workflow's owntype: scriptsteps, which rebuild their environment fromos.environ(seeexecutor/script.py). The workflow path reachinglaunch_backgroundmust be absolute once a caller setscwd-- seefleet/launch.pybelow, 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.pidfile before Fleet Manager D2 removedwrite_pid_file) can still be discovered and cleaned up bystop/fleet list. Every current run path usesconductor.fleet.records(run_id-keyed JSON records) instead — seefleet/records.pybelow.self_run.py- Answers "is this run record the run I am executing inside?" forconductor stop's self-exclusion (issue #399: an agent smoke-testingstopmust not terminate its own workflow). Three signals, first match wins: (1)CONDUCTOR_RUN_IDorCONDUCTOR_SELF_RUN_IDmatching the record'srun_id— the former is set on a--web-bgchild, 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/procfor signal 3); the two are separate names becauseengine/event_log.pyreadsCONDUCTOR_RUN_IDas "adopt this run id", which a nestedconductor runmust not do; (2)CONDUCTOR_WEB_BG/CONDUCTOR_WEB_PORTmatching the record's port, but only when the record has norun_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>/statusPPid:walk +os.getsid(0)), POSIX-only — Windows relies on signals 1–2 alone.partition_own_runsplits run records intoothers/own;stoptargetsothersunless--allow-selfis passed. Since the Fleet Manager it operates onRunRecords rather than PID-file dicts (read_run_records()already surfaces legacy.pidfiles in that shape), andreasonsis 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 venvuv tool install --forceis trying to recreate, which fails with "Access is denied".conductor updateprints the OS-appropriate install-script one-liner;conductor update --applyspawns the installer detached (Windows: new console window; POSIX:os.execvpereplace) and exits the current process so file locks release. It also prints_print_extras_note— the extrasinstall_hint.py::installed_extras()reads out of the uv receipt — because--forcerewrites 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 asconductor-cli[<extras>] @ <source>. The startup hint is suppressed byCONDUCTOR_NO_UPDATE_CHECK=1,--silent,--help/--version, and theupdatesubcommand 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!filetag supportvalidator.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.jsonand.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 parsesmcpServersin 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 onlyplugins/errors.py) soskills/registry.pycan import it without closing a cycleagents.py- Parsesagents/*.agent.mdintoPluginAgentspecs named<plugin>:<agent>.tools:entries are the host CLI's vocabulary (read,ado/*) and are forwarded verbatim;user-invocableis 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). Reusesskills/registry.py::is_path_entryandexpand_skills_rootso 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@pluginstays a path), thenplugin@marketplace, then a bare installed namesources.py- Parses theplugin_sources:source grammar (issue #380) into aPluginSource. Deliberately does not reuseis_path_entry: that returnsTruefor anything containing/, soowner/repowould 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 modulemarketplace.py- Reads a marketplace catalog out of a checkout. Recognises both.claude-plugin/marketplace.jsonand.github/plugin/marketplace.json, which — verified against a real repository shipping both — anchor their per-pluginsourcedifferently (repo-root-relative vspluginRoot-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 needsplugin:rather than having one picked for it. A catalog is fetched content, so asourceorpluginRootescaping the checkout is refusedfetch.py- Acquisition (issue #380).git ls-remoteto resolve a floating ref,git clone --depth 1into 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]>/, matchingregistry/cache.py::get_cache_baserather than the$XDG_CACHE_HOMEthe issue sketched, so there is one cache root. The_refs/<slug>.jsonpointer 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 equalsresolution.py- The single composition point between acquisition and resolution, analogous toskills/discovery.py::resolve_effective_skills. Bothconductor runandconductor validatecome through it and differ only inallow_networkerrors.py-PluginError(ValueError)base +PluginNotFoundError/PluginManifestError/PluginSourceError/PluginFetchError/PluginSourceUnavailableError, mirroringskills/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 andconductor runwill fetch it — soconfig/validator.pydowngrades it to a warning instead of an errorconductor/plugins/__init__.pyre-exports nothing on purpose —skills.registry→plugins.manifestandplugins.registry→skills.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 bothSKILL.mdandagents/*.agent.md. A leaf module (likeduration.py) raising plainValueErrorsubclasses, 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- Resolvesskills:entries to on-disk directories — built-in names (probing editable-install + wheel-install layouts) and filesystem paths, including expanding askills/root into its children. Alsoresolve_skill_plugin, which maps a directory to the Claude Code plugin that owns itdiscovery.py- Scans well-known locations for installed skills (runtime.skill_discovery) andresolve_effective_skills, the single composition point for declared + discovered skills used by bothAgentExecutorandconductor validatefrontmatter.py- Parses and validatesSKILL.mdYAML frontmatter with ruamel.yaml, requiringname+description. Exists because both downstream CLIs skip an unparseable skill silently; called fromresolve_skillssoconductor runis covered too, not justconductor validateloader.py- ReadsSKILL.md+references/*.mdfor providers that require eager preamble injection; wraps each skill in<skill name="...">tags inside a<skills>envelope. Bounded byruntime.skill_injection._read_fileraisesSkillManifestErrorrather than logging and skipping — and sincelru_cachenever memoizes a raising call, a transient read error is retried instead of frozen for the runerrors.py-SkillError(ValueError), the shared base forSkillNotFoundError/SkillPluginError/SkillManifestError. Its own module soregistryandfrontmattercan 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_budgetforgot one, and an unreadablereferences/*.mdescapedconductor validateas a traceback)- Built-in skills live under
plugins/conductor/skills/<name>/(bundled into the wheel via hatchlingforce-include)
-
engine/: Workflow execution orchestration
workflow.py- MainWorkflowEngineclass that orchestrates agent execution, parallel groups, for-each groups, and routingcontext.py-WorkflowContextmanages accumulated agent outputs with three modes: accumulate, last_only, explicitrouter.py- Route evaluation with Jinja2 templates and simpleeval expressionslimits.py- Safety enforcement (max iterations, timeout)checkpoint.py- Checkpoint save/load/list/cleanup + resume support.save_checkpoint(error=..., trigger=...)writes a top-leveltriggertypedCheckpointTrigger = Literal["failure", "periodic"];error=None(periodic) writes nullfailure.error_type/message. NoCHECKPOINT_VERSIONbump —triggeris additive and any unknown/missing on-disk value normalizes to"failure"on load.rotate_periodic_checkpoints/cleanup_periodic_for_runboth delegate to_delete_periodic_checkpoints(..., keep_last, action)(cleanup == rotate withkeep_last=0), scoped totrigger == "periodic"and an exactrun_idmatch so failure checkpoints and other runs' files are never touched.find_latest_checkpointreturnslist_checkpoints(...)[0](newest by microsecondcreated_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 fromcli/run.py::_execute_with_stop_signalafter the cancelled task is drained — it writes a best-effort checkpoint and emits a singleworkflow_failed(flaggedstopped_by_user: true, pluscheckpoint_pathorcheckpoint_unavailable_reason).handle_dashboard_stopis idempotent via a dedicated_dashboard_stop_handledflag (not_last_checkpoint_path, which periodic checkpoints also set). Issues #244, #245. The listing command isconductor checkpoint list(the flatconductor checkpointsis a hidden deprecated alias, issue #275).validator.py-OutputValidatorruns the optional per-agentvalidator:block (issue #220): a second LLM call (synthetic agent viaprovider.execute, no tools,{passed, issues}schema) that grades the primary output againstcriteria. Fail-open on error/parse failure. The engine helperWorkflowEngine._apply_validator(inworkflow.py) wires it into the main loop, parallel groups, and for-each loops; emitsagent_validator_start/agent_validator_complete/agent_validation_failed; records a separate"<agent> (validator)"usage row; and re-runs the primary once with a## Validation feedbacksection on failure (max_retrieshard-capped at 1), continuing the provider's in-memory conversation when the primary output carries acontinuation_stateand rebuilding the prompt statelessly otherwise (see the Validator block pattern bullet).guidance.py-GuidanceChannel(issue #400): alist[str]buffer +asyncio.Event, the inbound half of the existing outbound guidance machinery (WorkflowContext.user_guidance→get_guidance_prompt_section()→AgentExecutor.execute(guidance_section=...)).submit(text)appends and sets the event (returns pending count);drain()pops everything and clears the event. AlsoMAX_GUIDANCE_CHARS+validate_guidance_text(text)— the shared "non-empty after stripping, at most 10,000 characters" check, called from bothPOST /api/guidance(web/server.py) andresume --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 (likeduration.py), soweb/server.pynever imports fromengine/to hand the engine a sink callable.
-
executor/: Agent execution
agent.py-AgentExecutorhandles prompt rendering, tool resolution, and output validation for single agentsscript.py-ScriptExecutorruns shell commands as workflow steps, capturing stdout/stderr/exit_codeset_step.py-SetExecutorevaluates Jinja2 expressions fortype: setsteps and binds typed values into the workflow context (no LLM, no subprocess). Supports singlevalue:and multivalues:forms with auto / explicitoutput_type:coercion.wait.py-WaitExecutorpauses workflow execution for a parsed duration viaasyncio.sleep. Races the sleep against the engine'sinterrupt_eventso Esc/Ctrl+G cancels in-flight waits immediately; the workflow-levellimits.timeout_secondsalso cancels it viaLimitEnforcer.wait_for_with_timeout. Output contract is strictly{"waited_seconds": float}per issue #218.template.py- Jinja2 template renderingoutput.py- JSON output parsing and schema validation.validate_outputis deliberately strict with no coercion — it also validatessetandscriptstep output, where silently reshaping an authored value would be surprising. Response normalization belongs inproviders/_output_shape.pyinstead. Noteparse_json_outputraisesValidationErrorfor JSON syntax errors, so callers cannot distinguish syntax from schema failures by exception type alone.
-
duration.py:
parse_duration(value)shared helper. Accepts plainint/floatseconds, or strings withms/s/m/hsuffix. RaisesValueError(nests cleanly inside PydanticValidationError). 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 fleetrun_idcontract (issue #435). A stdlib-only leaf (likeduration.py/rundir.py) with no conductor imports, soengine/event_log.py(an always-on module) can depend on it without importingconductor.fleet.records, which would drag inconductor.cli.pidand, viaconductor.cli.__init__'sfrom conductor.cli.app import app, close an import cycle — the same hazardrundir.py's docstring documents. Before this module existed,fleet/records.pyandengine/event_log.pyeach 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 checkpointrun_idthe 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_SOURCEis exported as a source string (not just a compiled pattern) so filename parsers that need to anchor arun_idinside 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.pyre-exportsis_valid_run_idfor 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 (likeduration.py) with no conductor imports, deliberately top-level rather than undercli/becausegates/andproviders/need it and must not import fromcli/.make_consolelocksmarkup=False, inverting Rich's default so an interpolated runtime value is literal unless it asks to be styled; it rejects amarkup=kwarg rather than allowing an override — on the constructor and onprint/log, since rich's per-callmarkup=Truewould 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. Readingspans[0].styleand re-applying it — the obvious alternative — collapses the nesting. A value that is already aTextis 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 becauseText.joinrequires every part to already be aText, while the common shape here is acontent_lineslist mixing conductor-styled fragments with plain runtime values.rich.markup.escapeis deliberately unused throughout: the parser treats\[as an escaped bracket, so\[0-9\]+renders as[0-9\]+whether escaped or not, whereas aTextis byte-exact. See the Console Output section under Code Style. -
providers/: SDK provider abstraction
base.py-AgentProviderABC definingexecute(),validate_connection(),close()_output_shape.py-normalize_agent_output(content, schema)— the single entry point providers call beforevalidate_output(issue #343). It raisesValidationErrorwhen the parsed response is not a JSON object (a bare42/null/array), becausevalidate_outputwould otherwise either raiseTypeErrorfrom a membership test (numbers, booleans, null) or report a misleading "missing required field" (strings, arrays). It then appliesunwrap_scalar_wrappers: fires only when the schema declaresstring/number/boolean, adictarrived, and exactly one candidate slot has the expected type. Candidate slots are the field's own name plus the genericvalue/resultkeys, deduped so a field literally namedvalueorresultisn'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 ofexecutor/output.pyon 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 itsemit_outputtool and never echoes the schema, so its instruction text stays inclaude.py::_build_recovery_instruction.copilot.py- GitHub Copilot SDK implementation. By default spawns a nestedcopilotruntime viaCopilotClient()(in_build_client, called from_ensure_client_started). When a runtime connection is resolved (runtime.provider.runtime_urlorCOPILOT_PROVIDER_RUNTIME_URL, optionalruntime_token/COPILOT_PROVIDER_RUNTIME_TOKEN), it instead buildsCopilotClient(connection=RuntimeConnection.for_uri(url, connection_token=token))to connect to an already-runningcopilot --headlessprocess; the SDK skips spawning for URI connections and itsstop()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_processhandle) 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/_startedbefore 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_processreads_cli_process(the spawned-child handle,Nonefor URI and FFI connections) while_fix_pipe_blocking_modereads_process(the transport handle — aSocketWrapperin TCP mode, an_FfiProcessAdapterwith its ownpoll()in FFI mode); both are correct for their own purpose, and unifying the two reads onto_processwould make FFI mode look like a killable child process.claude.py- Anthropic Claude API provider usingpydantic-ai(AnthropicModel) and the internal_pydantic_aipackage (converters,events,mcp_toolset,agent_builder,interrupt,retry,structured_output,usage)claude_agent_sdk.py- Claude Agent SDK implementation (usesclaude-agent-sdkpackage)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.sentinelis keyword-only and required, since the two gates use different onesdialog.py- Dialog-mode gate. On a tty the main turn reads throughread_multiline_lines, so a pasted block is one turn rather than one turn per line; off a tty it falls back to single-linePrompt.ask, where the sentinel has no effect — the_reads_multiline_turn()predicate states that rule once, and_display_dialog_startgates 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 stoprun-record scope,conductor fleet) — seedocs/fleet.mdfor the user-facing guide. Fixes the bug where a plainconductor run(no--web-bg) was invisible toconductor stop/discovery: every run path now writes arun_id-keyed JSON record (not the legacy port-keyed.pidfilecli/pid.pyused to write) describing its mode/PID/workflow/port, so foreground,--web-bg, and--webruns are all discoverable the same way.records.py-RunRecord(nine fields, no more — a tenthttyfield was considered and rejected as POSIX-only withpidalready sufficient;modeis aRunMode = Literal["fg", "fg-web", "bg"]so the single write site is checked byty, and an unrecognised mode read from disk normalises to"bg"rather than raising — a raise reaches_read_and_pruneascorrupt, 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 passcli.pid.is_process_aliveand tolerates legacy port-keyed.pidfiles (surfaced asmode="bg"records) alongside corrupt/partial JSON. Every Windows sharing-violation-prone operation —os.replaceon write,os.unlinkon remove (_safe_unlink),os.renameinto quarantine on self-cleanup (_delete_if_unchanged), andos.linkon 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 asFalserather than silently succeeding. Also definesTerminalRunRecord(R1, DD1, P3, G5): a small tombstonecli/run.py'sfinallyblock writes toterminal_records_dir()/<run_id>.json— a dedicated subdirectory of the run-records directory, deliberately not alongside the live record, sinceread_run_records()'s andremove_run_record_for_current_process()'s non-recursive*.jsonglobs 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), renderedoutput:, error type/message on failure, usage totals, and the run's event-log/capture-log paths, so arun_idanswers "how did that run finish" after its process has exited — the artifact bothconductor status/conductor fleet list's completed-runs section (R1, seecli/app.py/cli/fleet.pybelow) and the MCP server'sconductor_run_status/conductor_run_events/conductor_run_logstools (mcp/serve/, below) read from.write_terminal_record/read_terminal_record/read_terminal_records/remove_terminal_recordmirror the live-record API; pruning isfleet/retention.py's job (DD13), matched byrun_idto 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'sHistoryEntryrows againstengine/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 viaos.path.realpath, since both sides ultimately derive fromtempfile.gettempdir()), withrun_idas a documented fallback that is refused whenever that id is ambiguous across the scanned entries (a nestedconductorinvocation inheritsCONDUCTOR_RUN_ID, so two logs can share one id). Gating a row's Resume availability is checkpoint existence + the checkpoint's recordedworkflow_pathexisting on disk — neveroutcome: anunknownrow from a crash is exactly the case a periodic checkpoint exists for, and acompletedrow 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 offleet/history.pydeliberately — that module's docstring is emphatic that History is derived from event logs alone, andbuild_history_entriesis patched by name in several existing tests.summary.py- Derives aRunSummary(status/current-step/elapsed/tokens/cost/gate) from a streamed, uncapped read of a run's JSONL event log (issue #485) —stream_event_logis 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 secondworkflow_started— reported the wrong topology). The Runs screen's ~2s poll passeskeep_types=_SUMMARY_EVENT_TYPESso 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_eventsis generation-aware: a resumed run always writes a second rootworkflow_startedinto the same log (the engine's own re-emit, or the dashboard-seeding path's synthesized copy), and reaching one resetsstatus/gate/open steps and overwritestopology/workflow_name/cwd/inputswith 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:Truewhenever the record has a dashboard port (fg-web/bg),Falseformode == "fg".history.py- Enumerates every retained run directly from$TMPDIR/conductor/*.events.jsonlfiles (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 constraintsummary.pyfollows, sharpened here since there is no run record to fall back on at all. The returned list is bounded by[fleet.retention].keep_lastand, independently, by a fixed 200-entry display cap (so akeep_lastconfigured 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 tosummary.stream_event_log(issue #485), which generalized the uncapped-streaming approach History pioneered first; it re-derives its own corrupt-vs-empty distinction frompath.stat().st_sizerather 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_eventstakes the latest rootworkflow_started's timestamp asstarted_at(issue #485, Q2) — mirroringsummary.py's generation-reset — so a resumed run'sduration_secondsfallback (ended_at - started_at, used absent an engine-reportedelapsed) measures the current attempt, not the idle gap since the original one;_scan_history_eventsmust therefore stay a single forward pass over itsIterableargument (issue #436). EachHistoryEntryis then enriched (_enrich_with_terminal_record, R1) with its renderedoutput:and failure error type/message fromrecords.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 (reusingregistry/resolver.py::resolve_ref+registry/cache.py::resolve_and_fetch, the same pairconductor showuses) and callscli.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 callscli.bg_runner.launch_background_resume(workflow_path=None, checkpoint_path=...)directly rather than re-implementing detached spawning, since aHistoryEntrycarries no workflow path of its own and the checkpoint is the only route to one. Bothresolve_workflowandlaunch_workflow/launch_resumetake the Fleet Manager's launch directory as an explicit argument (issue #477) --base_diron the former,cwdon the latter two, forwarded tocli.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_diris the one place that directory is actually decided.resolve_workflowjoins a relative file reference ontobase_dir(a registry reference and an absolute reference both ignore it) and always returns an absoluteResolvedWorkflow.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_backgroundputsstr(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), mirroringCheckpointManager.rotate_periodic_checkpoints'skeep_lastvocabulary. Never deletes thecheckpoints/subdirectory or an event log a live/resuming run still references; a retained/live log's.bg.stderr.log/.bg.stdout.logcompanions are kept or pruned alongside it.maybe_prune_event_logs()is the opportunistic-startup-sweep wrappercli/run.pycalls, gated by[fleet.retention].enabledand never raising.tui/- The Textual app (app.py,Screenpush/pop stack) and its screens (screens/runs.pyhome;screens/run_detail.py;screens/step_detail.py;screens/providers.py;screens/registries.py;screens/new_run.py;screens/history.py;screens/splash.py), plusactions.py(shared stop/kill/gate-respond logic, reusingcli/app.py::stop_recordsandcli/gate.py::_gate_respond_implrather 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 explicitCONDUCTOR_FLEET_NO_ANIM, on by explicitCONDUCTOR_FLEET_ANIM, off by default on a detected RDP session (SESSIONNAMEstartingRDP-Tcp) viais_remote_session(), and on otherwise —CONDUCTOR_FLEET_NO_ANIMalways 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_ANIMis 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 — explicitCONDUCTOR_FLEET_NO_ANIMor detection — also sets Textual's ownApp.animation_level = "none"inapp.py::on_mount, issue #462),art.py,widgets.py, andnotify.py(terminal bell / OSC 9 notifications, debounced to fire once per status transition). Optional: imported only byconductor fleet's bare invocation, gated behind thetuiextra (floortextual>=8.0—widgets.py::BlockFooteris written against 8.xFootergroup rendering). The install command for that extra is resolved per install context byinstall_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 theawait— the patternscreens/new_run.py::action_resolve/action_launch,screens/step_detail.py::load_stepandscreens/registries.py::load_workflowsestablished first, now alsoscreens/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_registriesreads one localregistries.tomlinline, andproviders.py::load_providersis a@workmethod awaiting a natively asyncgather()— note thatgather()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 theirrefresh_runs()/refresh_detail()entry point as a synchronous dispatcher (still callable fromon_mount,set_interval, and action handlers) guarded by a plain_refreshingboolean, 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@workmethods directly. The flag is set in the dispatcher, not as the worker's first line, because a@workbody doesn't start until Textual schedules it; and it is released in afinally, 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'sfinally) passesexplicit=Trueand is coalesced via_refresh_pendingrather than dropped: those callers promise the table reflects what they just did, and the scan they collide with started before it. Every screen showstheme.py::loading_text()incompose()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, soruns.pydistinguishes an empty fleet from one whose summaries all failed (RunScan.failed/seen_run_ids) andhistory.pyfollowsstep_detail.py/registries.py's red-error-line convention.RunScan.seen_run_idscarries 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 athreading.Event, and then needs a second keypress to resolve it, must use a plainawait pilot.pause()between the two, nevertests/test_fleet/conftest.py::settle—settleawaitsapp.workers.wait_for_complete(), and the suspended@workmethod won't finish until that second keypress, so awaiting it first deadlocks the test.history.pyalso correlates checkpoints in the sameload_historyworker as a second thread hop (issue #460,conductor.fleet.resume.correlate_checkpoints), gating itsr/Resume binding viacheck_actionthe same wayruns.pygatesg/Gate -- hidden outright when the highlighted row has no correlated checkpoint, refreshed onon_data_table_row_highlightedso 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-scorewidget. Everything else in the preview pane (the gate section, theProgress N/Mheader), and the footer'srefresh_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_diris an app-level, process-lifetime-only Textualreactive[Path](issue #477) -- noconfig.tomlkey, no state file, reset to the process's cwd on the nextconductor fleet-- read byscreens/new_run.py(the base a relative workflow reference resolves against, and thecwda launched run's detached child inherits) and mutated only throughFleetApp.set_launch_dir, matching this module'spush_*/return_to_runsone-named-mutation-site convention.actions.py::DirectoryPickerModal(opened via the sharedchange_launch_directory, bound todon Runs andctrl+d--priority=True, sinceInputbinds 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 aNodeHighlightedfor the tree's own root as soon as the backgroundDirectoryTreeload 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::BlockFooterpassesshow_command_palette=FalsetoFooter.__init__(a real 8.x kwarg) so the Runs footer's docked^p palettekey -- notctrl+pitself, which still opens it -- is hidden, reclaiming the columnsd Dirneeded onceProviders/Registrieswere also shortened toProv/Regs.
-
mcp/:
manager.pyis the existing MCP client (Conductor calling MCP tools,runtime.mcp_servers) — untouched by the server work below, thoughpyproject.toml'smcp>=1.28.1,<2bound protects it too (amcp2.0.0 lock refresh renames the camelCase attributes it reads, e.g.Tool.inputSchema->input_schema, turning every MCP tool connection into a runtimeAttributeErrorwith no import error to catch it).serve/-conductor mcp serve(issue #432,cli/mcp.pyabove): 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 detachedconductor run, never executing a workflow in-process), andfleet/(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 ofmcp.types.Tools (DD3): the four-rung exposure ladder (--deny>--allow> workflowmcp.expose> default-on, DD4), a three-tier schema resolution ladder with a permissivedegradedfallback 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.pyare the catalogue's naming (bare names, registry-qualified only on collision, DD10), description-sanitizing (NFR4), and JSON-Schema-generation (fromWorkflowDef.input; nooutputSchema, DD5) helpers.pinning.pycomputes and re-checks the DD6 identity.server.pywires the frozen catalogue ontomcp.server.lowlevel.Serverand runs it overmcp.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 theintrospect/diagnosetoolsets, decided once at startup and never per-request (DD3) — dispatches tointrospect.py(conductor_run_eventswith tool payloads reduced to{name, status, byte_size}unless--introspect-full, R4;conductor_node_detail;conductor_plan_tree) anddiagnose.py(conductor_doctor,conductor_validate_workflow, andconductor_run_logs—ResourceLinks and bounded metadata only, never file contents, DD12).invoke.pylaunches a generated workflow tool's run and resolves its reserved_wait_secondsparameter (FR5) — never passesskip_gates=Truetolaunch_background(DD11, asserted in a code comment naming the decision), and enforces--max-concurrent-runsvia an in-processLaunchTracker(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_secondsdescription — three equally-weighted options with no recommendation — passed_wait_seconds: 300unprompted 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 carriesport, anobserveblock of terminal commands, and the capture-log paths so the caller has something to report instead of a reason to wait; and the immediatenextaction namesconductor fleetbeforeconductor_await_run, which it conditions on the user having actually asked to block.server.py::_DISCOVERY_TOOLShand-writes a second copy of that same parameter's schema, sotests/test_mcp/test_serve_toolgen.py::TestWaitSecondsSteersTowardBackgroundasserts both copies steer identically. Every command inobserveis resolved against the real Typer app bytests/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 themode: syncbranch, so no invocation can block at all.runs.pyis therunstoolset (conductor_run_status/conductor_await_run/conductor_cancel_run/conductor_list_runs), resolving arun_idthrough a live record, else thefleet/records.py::TerminalRunRecord, else an event-log fallback for a crashed run — the same three sources named which one answered.discovery.pyis 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'sBaseHTTPMiddleware/@app.middleware("http"), neither of which ever sees WebSocket scopes — a decorator-style middleware would leave/wscompletely unguarded) registered viaapp.add_middleware(...)on bothserver.pyandreplay.py. EnforcesHost/Originvalidation on everyhttp/websocketscope (a presentOriginmust match; an absent one is allowed, since httpx/curl/conductor gate respondsend none), then token auth + a JSONContent-Typeon mutating HTTP routes, then token auth on the/wshandshake — closing it viawebsocket.closein reply towebsocket.connect, beforeaccept(), so a rejected socket can never send any message type (gate_response,dialog_message,dialog_decline,iteration_limit_responseall included).CONDUCTOR_WEB_ALLOW_ORIGINS(comma-separated full origins) extends the allowlist for a dev server (e.g. Vite'shttp://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)letsCONDUCTOR_GATE_TOKENoverride it, preserving the pre-#397 escape hatch.write_token_file/read_token_file/remove_token_filepersist that token at~/.conductor/runs/dashboard-<port>.token(mode0600on 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)readsAuthorization: Bearerfirst, then thetokenquery param — the latter exists only because a browser cannot set handshake headers, and is an acceptable exposure only because both dashboard apps run uvicorn withlog_level="warning"(no access logs to leak the query string into).server.py- FastAPI + uvicorn server with WebSocket broadcasting, late-joiner state replay, andPOST /api/stop+POST /api/killendpoints./api/stopinterrupts/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_stopand drained byset_interrupt_eventso the startup window takes the graceful pause path instead of a progress-losing hard stop./api/killhard-stops the run. Whenever a stop/kill actually terminates the run (cancels the engine task), it routes throughhandle_dashboard_stopso a best-effort checkpoint is written (or its absence explained) — seehandle_dashboard_stopabove (issue #245).start()mints and writes the run's token file once_actual_portresolves (works for both--weband--web-bg, sincecli/run.pycallsstart()/stop()on both the run and resume paths and the--web-bgchild goes through the same code);stop()removes it.GET /readsstatic/index.htmland injects<script>window.__CONDUCTOR_TOKEN__="...">before</head>(HTMLResponserather than the old plainFileResponse), keeping the existingCache-Control: no-cache._gate_token_oknow 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 (viaOriginHostGuard) already authenticated the connection, so an unauthenticated socket never reaches the loop that readsgate_response/dialog_message/dialog_decline/iteration_limit_responseat 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 withmake build-frontend(outputs tostatic/); unit tests viamake 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) readswindow.__CONDUCTOR_TOKEN__(getToken()), builds theAuthorizationheader for mutating fetches (authHeaders()), and appends?token=to the/wsURL (withToken(), since the WebSocket handshake can't carry custom headers).ReplayDashboard's/injects nothing, sogetToken()returningundefinedthere is expected and harmless — that app has no mutating routes or/wsto authenticate. In dev,vite.config.ts'stransformIndexHtmlhook (scoped toapply: 'serve', not the production build) injectsprocess.env.CONDUCTOR_GATE_TOKENthe same way, and the/wsproxy entry needschangeOrigin: true(the/apiproxy already had it) or the handshake arrives withHost: localhost:5173and failsOriginHostGuard's host check.static/- Built dashboard assets served byserver.py(generated fromfrontend/bymake 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, plusread_receipt()/installed_extras()which parse<sys.prefix>/uv-receipt.toml. A stdlib-only leaf (likeduration.py,console.py,rundir.py) deliberately top-level rather than undercli/, becauseproviders/needs it and must not import fromcli/. Three branches: a uv receipt →uv tool install --force '<spec>'; an editabledirect_url.json→uv 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 andconductor-cliis 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 barepipdoes not manage a pipx venv, where it would succeed while installing a second copy the user never runs) with the git URL fromdirect_url.jsonappended when there is one, so apip/pipx-from-git install — which is what actually lands in theUNKNOWNbranch — still resolves. PEP 610'sarchive_infois 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 (--forcerewrites the tool's whole requirement set, anduv syncis exact unless given--inexact, so naming only the new extra uninstalls the others); the recorded install source is reused (the receipt'sgit/directory/urlkey, elsedirect_url.json, else upstream pinned tov<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 isreadable=Falserather 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# WARNINGshell comment naming the receipt (carried onInstallEnvironmentso 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; andinstall_commandnever raises, since it runs inside aProviderError(...)argument expression where an exception deletes the diagnosis instead of degrading the hint. Branch logic lives in the purerender_install_command(extra, InstallEnvironment)so all three contexts are unit-testable without a real install;detect_environment()is the impure half, andInstallEnvironment.__post_init__drops extras/source on the editable branch so the type cannot hold state the renderer ignores.install.sh/install.ps1parse the same receipt in shell for the same reason — seereceipt_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 (likeduration.py,console.py) soweb/auth.pycan depend on it without importingconductor.cli.pid, which would drag in the whole Typer app (conductor/cli/__init__.pydoesfrom conductor.cli.app import app, andapp.pyitself reachesconductor.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 viafrom 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, mirroringregistry/config.py::get_config_path), read with stdlibtomllib. Read-only in v1: noconductor config set, no in-process writer — hand-edited, documented indocs/configuration.md. A missing file yields defaults; a malformed file raises for an explicit reader (conductor fleet prunewith no--keep-lastoverride) but is swallowed by the opportunistic startup sweep (conductor.fleet.retention.maybe_prune_event_logs), since a machine-wide settings file must never breakconductor run. -
telemetry/: Safe optional initialization and event-driven OpenTelemetry tracing. Activated via the standard
OTEL_EXPORTER_OTLP_ENDPOINTenvironment variable. It builds a unified trace tree by nesting native provider spans directly under Conductor's orchestration spans, checking each provider'snative_otel_spanscapability 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 thehttp/protobuforhttp/jsonOTLP protocol. Standard gRPC (the default protocol) disables native Copilot spans and triggers a single per-run warning. The dynamicnative_otel_spans_activefield 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.
- CLI parses YAML via
config/loader.py→WorkflowConfig WorkflowEngineinitializes with config and provider- Engine loops: find agent/parallel/for-each/script/set/wait → execute → evaluate routes → next
- Parallel groups execute agents concurrently with context isolation (deep copy snapshot)
- For-each groups resolve source arrays at runtime, inject loop variables (
{{ item }},{{ _index }},{{ _key }}) - Script steps run shell commands via asyncio subprocess, expose stdout/stderr/exit_code to context
- Set steps render Jinja2 expressions and bind typed values to context (no LLM, no subprocess) via the shared
WorkflowEngine._run_set_stephelper, which enforcesoutput:schema in all three positions (main loop, parallel group, for-each iteration) and emitsset_started/set_completed/set_failed - Wait steps pause via
asyncio.sleep(cancellable by interrupt or workflow timeout); expose{"waited_seconds": float}to context - Routes evaluated via
Routerusing Jinja2 or simpleeval expressions - Final output built from templates in
output:section
-
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
whencondition wins; nowhen= always matches -
Tool resolution:
null= all workflow tools,[]= none,[list]= subset -
Set step typing:
output_typedefaults toauto(safe YAML parse with_to_json_safenormalisation —datetime/date/time→ ISO 8601, non-string dict keys and other non-JSON-safe values raiseExecutionError). Explicitstring/number/integer/boolean/list/dictonly valid on singlevalue:.WorkflowContext.storeaccepts any JSON-safe value (scalars/lists fromsetsteps in addition to the dicts produced by LLM / script / gate / parallel-group outputs);_add_agent_inputreturns the scalar verbatim forstep.outputand raises a clearKeyErrorforstep.output.fieldshorthand on non-dict outputs. -
Reasoning effort:
runtime.default_reasoning_effortsets a workflow-wide default; per-agentreasoning.effortoverrides it. Allowed values:low,medium,high,xhigh,max. Each provider translates the unified value to its native API (Copilot:reasoning_efforton the session, validated against the model'ssupported_reasoning_efforts; Claude: extended thinking with budget mapping low=2048, medium=8192, high=16384, xhigh=32768, max=59904 tokens, withtemperaturecoerced to 1.0 andmax_tokensbumped to fit the budget).maxis Copilot/Claude-only — the Hermes provider advertises only the first four levels inCAPABILITIES.reasoning_effortand re-checks the resolved effort against that tuple at execute time (in addition to the staticconductor validatecross-check), somaxis rejected on Hermes both statically and at runtime, including when it only resolves tomaxafter Jinja template rendering. Seeexamples/reasoning-effort.yaml. -
Context-window bar (
context_window_used/context_window_maxonagent_completed/parallel_agent_completed, issue #412):context_window_usedis sourced fromAgentOutput.last_call_input_tokens(a single call's prompt size), neverinput_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; whenused > max(impossible for one real API call), it drops both toNone, logs at debug on every occurrence, and also logs at warning once per run (via a_context_window_anomaly_warnedlatch, matching the_pricing_hook_failed_warned/_budget_unpriced_warnedpattern) — 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'sassistant.usagededup (below) keys onapi_call_id, falling back toprovider_call_idthenservice_request_idwhen the SDK omits it, since all three are independently optional per the SDK schema. -
Periodic checkpoints (
runtime.checkpoint, issue #244): opt-inCheckpointConfig(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_nameis the step about to run — so a periodic checkpoint reuses failure-checkpointcurrent_agentsemantics and resume continues forward with no special-casing. Gated via the_periodic_checkpoints_activeproperty (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_agentORevery_secondsthrottle; first save always fires)._save_checkpoint_on_failureand the periodic path share_write_checkpoint(error, trigger)(which best-effort-guards providerget_session_ids()so it never raises). The periodic save wraps write+emit+rotate; on any failure it calls_record_periodic_checkpoint_failure()which emits acheckpoint_save_failedevent (consecutive-failure count; surfaced byConsoleEventSubscriber+ JSONL + dashboard) so a recovery-reliant user isn't silently left without checkpoints. After a save the engine callsrotate_periodic_checkpoints; at a terminal non-resumable outcome (clean completion viarun()/resume(), or an explicitstatus: failedterminate)_cleanup_run_periodic_checkpoints()deletes the run's periodic checkpoints (an unexpected failure leaves them in place alongside the failure checkpoint).conductor checkpoint listshows aTriggercolumn and—for periodic rows' error type. Seeexamples/periodic-checkpoints.yamlanddocs/workflow-syntax.md(Periodic Checkpoints section). -
Skills:
runtime.skills: [entry, ...]sets a workflow-wide default list enabled for every provider-backed agent; per-agentskills: [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 bareconductorcan 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 (holdsSKILL.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 fromWorkflowEngine._workflow_dir), mirroring_resolve_agent_working_dir—normpath, notresolve(), so symlink aliases stay distinct. Paths are trusted input: the same YAML can already run arbitrary shell viatype: script, so no allowlist applies.AgentDef.validate_skillsonly 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 resolvedSKILL.mdmust have valid YAML frontmatter declaringnameanddescription— checked insideresolve_skills(viaskills/frontmatter.py, parsed with ruamel.yaml, not PyYAML) rather than only inconductor validate, becauseconductor runnever 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 viaAgentProvider.supports_native_skills(readable without instantiating a provider viaproviders/capabilities.py::uses_native_skills, which returnsNonewhen it cannot be determined so callers skip rather than guess): Copilot (True) registers the skill directory on the SDK session viaskill_directories(progressive disclosure viaSKILL.mdfrontmatter); Claude Agent SDK (True) is also native but goes through the Claude Code plugin surface —providers/claude_agent_sdk.py::_resolve_skill_pluginsmaps each resolved directory back to the plugin that owns it (skills/registry.py::resolve_skill_pluginwalks up for.claude-plugin/plugin.json), registers that root viaClaudeAgentOptions.pluginsand enables the skill by its<plugin>:<skill>name viaClaudeAgentOptions.skills. Because that SDK has no bare skill-directory option, a path skill outside a plugin is unreachable there —config/validator.pynow refuses it statically (naming both remedies) instead of letting it fail as a runtimeProviderError; the identical skill works oncopilotuntouched. Claude and Hermes (False) eagerly inject every enabled skill'sSKILL.mdplusreferences/*.mdinto the rendered prompt inside<skills><skill name="...">...</skill></skills>tags. That is expensive — the bundledconductorskill alone is ~132KB (~33K tokens), paid on every call and every retry — soruntime.skill_injection(SkillInjectionConfig:warn_bytesdefault 64KB,max_bytesdefault 160KB, either nullable) bounds it, enforced both inAgentExecutorand statically inconductor validate, measured against the exact string prepended and reported with a per-skill breakdown. The defaults deliberately straddle the bundled skill so enabling it onclaudewarns rather than breaking —max_byteswas raised from 128KB to 160KB when the skill outgrew the original ceiling, and must keep tracking it; awarn_bytesabovemax_bytesis rejected as unreachable. Native providers are exempt. Providers also declareskills: boolon theirProviderCapabilitiesdescriptor soconductor validatecatches skills-against-unsupported-provider mismatches —hermesdeclaresTrue(it reaches skills through the provider-agnostic eager-injection path inAgentExecutor; it previously omitted the field, defaulting toFalse, while its ownexecute()docstring described injection working), andacais the oneFalse(skill directories are host paths the in-sandbox runner cannot read).AgentExecutor._reject_unsupported_skillsnow enforces askills=Falsedeclaration at run time too, becauseconductor runnever calls the static validator — otherwise the declaration held only at validate time while the eager-injection path happily injected anyway. Built-in skills live underplugins/conductor/skills/<name>/and are bundled into the wheel via the hatchlingforce-includeentries inpyproject.toml— both the skill body andplugins/conductor/.claude-plugin/, because without the manifest no plugin root resolves and every skills-enabled agent onclaude-agent-sdkfails with aProviderError. 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/skillswalked 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 bugruntime.pluginsfixes 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, shippingagents/+ MCP but noskills/, were never discovered at all). Every mapped location is a skills root, so both expand through the sameregistry.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 keepsenable_config_discoveryoff on Copilot (it would additionally auto-load MCP servers from.mcp.json) andsetting_sourcesempty by default on claude-agent-sdk (opt-in per workflow viaruntime.provider.setting_sources, see Providers below). Sources scan in a fixed canonical order (project→personal) 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 andskills: []remains the one opt-out; note the inherited case can produce skills from an emptyruntime.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 skillclaude-agent-sdkcannot 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/hermesrefuse discovery outright (measured 260KB ≈ 65K tokens, well over the defaultmax_bytes, and machine-dependent — there is no limit to tune). That refusal is enforced twice, inconfig/validator.pyand again inAgentExecutor._reject_discovery_without_native_skills, becauseconductor runnever calls the static validator — the same reason_reject_unsupported_skillsexists. Explicit entries beat discovered ones on a name collision, which fires immediately in practice because installing Conductor's own plugin puts a secondconductorskill on the machine.ResolvedSkill.discovered: boolcarries 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_discoverylists 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 ownskills: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. Seeexamples/skills-self-improving-workflow.yaml,examples/skills-discovery.yaml, anddocs/workflow-syntax.md(Skills section). -
Plugins (
runtime.plugins/ per-agentplugins:, issue #378): the plugin is the unit of opt-in. A plugin ships up to three things Conductor can use —skills/,agents/*.agent.mdsubagents, and MCP servers — and they are written to work together, so a plugin'sSKILL.mdroutinely dispatches toprs:code-revieweror calls anadoMCP 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 aPluginDefobject 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 matchesskills:exactly (omitted = inheritruntime.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 syntacticis_path_entryrule as skills; an uninstalled name errors naming the searched roots, and an ambiguous one errors rather than picking a winner (two marketplaces shippinggitare 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'splugin_directories,ClaudeAgentOptions.plugins) and both are all-or-nothing. Empirically, Copilot'sexcluded_toolshides an MCP tool from the model but does not stop the server subprocess launching (proved with a startup marker file), somcp: falsebuilt on it would be a cosmetic filter sold as a guarantee — and forado --authentication azclithe 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 unconditionalstrict_mcp_config=True). Deconstructed, each component rides the surface Conductor already uses for it, so plugin MCP inheritsruntime.tool_outputlimits, 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 anMCPServerDef, so it carries no per-servertools:filter;mcp: falseis the control. Two newexecute()kwargs carry the non-skill components —custom_agentsandextra_mcp_servers— accepted by every provider and honored by the native ones, mirroring howskill_directoriesalready works; they are per-execute becauseplugins:is per-agent while providers are cached per type andself._mcp_serversis construction-time. Plugin skills merge into the existingskill_directorieschannel viaexecutor/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 secondconductorskill on the machine.custom_agentsaccepts the qualified<plugin>:<agent>name — verified against a live Copilot session, wheremyplug:quokkaappeared among launchable agent types — so namespacing survives deconstruction and two plugins shipping areviewagent do not collide. Onclaude-agent-sdkthe same specs become inlineClaudeAgentOptions.agents(AgentDefinitionis keyed by name and has nonamefield; Copilot'sinferhas no counterpart and is dropped).CAPABILITIES.pluginsgates the feature —copilotandclaude-agent-sdkTrue,claude/hermes/acaFalse, 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, inconfig/validator.pyand again inAgentExecutor._reject_unsupported_plugins, becauseconductor runnever calls the static validator — the same reason_reject_unsupported_skillsexists. One declared carve-out: onclaude-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 askillsfilter and none for the rest. Soagents: falsealongsideskills: truefor one plugin is refused rather than quietly granting more than the YAML declared (the same reasoning that refuses a narrowed per-servertools:filter there); the identical config works oncopilot. Both the refusal and the matchinghooks/wording (exposed to the CLI rather than the false not loaded) branch onAgentProvider.skills_require_plugin_root, which is a truthful description of the mechanism rather than a provider-name check, and both are enforced twice —config/validator.pyandAgentExecutor._reject_unfilterable_agents— becauseconductor runnever calls the validator.hooks/andcommands/are otherwise dropped loudly via aconductor validatewarning — 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'sSKILL.mdnames those tools), so a collision between two plugins, or withruntime.mcp_servers, is an error namingmcp: falseas the remedy — refused in the provider merge helpers as well as inconfig/validator.py, sinceconductor runskips 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, matchingresolve_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 discovered —plugins: [prs]reads the installed roots, but that is resolution (the author wrote the name, a miss is a hard error) not discovery;enable_config_discoverystays off on Copilot andsetting_sourcesempty by default on claude-agent-sdk.cli/validate.py::_report_pluginsprints 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.jsonwithagents/*.md; Copilot writes.github/plugin/plugin.jsonwithagents/*.agent.md— and the candidate-file rule used to be hardcoded to the Copilot suffix regardless of which manifest actually matched, so aprovider: copilotworkflow 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 — andread_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-catalogplugin_sourcesmarketplace (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 onProviderCapabilities.plugin_flavor(required wheneverplugins=True, enforced by amodel_validator) —copilotdeclares"copilot",claude-agent-sdkdeclares"claude"— resolved viaplugin_flavor_for(provider_name)at the two name-keyed call sites (config/validator.py,cli/plugin.py,cli/validate.py) and viatype(self.provider).CAPABILITIESat 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'sextraKnownMarketplaces(plugins/copilot_settings.py::read_copilot_marketplaces, directory-source entries only, scoped toflavor == "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 namingplugin_sourcesas 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, aPluginManifestErrorsubclass) — this keeps the Claude build's broader*.mdcandidate rule strictly additive, since no file that already errored under the old Copilot-only rule can newly start warning. Seeexamples/plugins.yaml(with the self-containedexamples/demo-plugin/) anddocs/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_sourcesmaps a marketplace name to a source andplugins:references it asprs@acme. The split (acquisition vs activation) is not invented — the Copilot CLI's ownsettings.jsonseparatesextraKnownMarketplacesfromenabledPlugins, 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 thatprs@acmemeans the same thing whetheracmewas 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: qualifygit@acmeinstead of falling back to a path. Entry classification is three-way and ordered — path, thenplugin@marketplace, then bare name — so./tools/my@pluginstays a path. Source classification is separate fromis_path_entryon purpose: that helper returnsTruefor anything containing/, so reusing it would turn everyowner/repointo a relative directory lookup; a source is local only by prefix. Both repository shapes resolve — amarketplace.jsoncatalog or aplugin.jsonsingle plugin, withplugin:for one that is both — and the two catalog conventions anchor their per-pluginsourcedifferently (.claude-pluginrepo-root-relative,.github/pluginpluginRoot-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 viagit ls-remote, matchingregistry/version_resolver.py's existing treatment of workflow registries. This deliberately drops the issue's sketchedconductor.lockand 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 andconductor plugin listprints the counts on demand. Network posture:conductor run/resumeacquire 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 fetchprimes the cache as its own step, which is the entire reasonconductor validatecan stay off the network;conductor plugin listreads 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 andconductor runheals 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, apath: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 unreferencedplugin_sourcesentry is reported as dead config. There is noplugin update: floating refs self-update and pinned ones are meant not to.WorkflowEngine._ensure_plugin_marketplacesresolves sources atrun()/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. Seeexamples/plugin-sources.yamlanddocs/workflow-syntax.md(Plugins section). -
Terminate steps (
type: terminate): explicit terminal step withstatus(success|failed), Jinja2reason, and optionaloutput_template(adict[str, str]that replacesworkflow.output:when set; each value is rendered then passed through_maybe_parse_jsonso"true"becomesTrue,"42"becomes42, 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 ❌, raisesWorkflowTerminated(subclass ofExecutionError), emitsworkflow_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 haveroutes,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, astatus: failedterminate is downgraded at the parent boundary toSubworkflowTerminatedError(also a subclass ofExecutionError) preserving the child's renderedterminated_output/terminated_reason/terminated_byas structured attributes — the parent treats it as a normal sub-workflow failure (its ownworkflow_faileddoes NOT inheritis_explicit: true). For more detail seeexamples/terminate.yaml,docs/workflow-syntax.md(Terminate Steps section), andplugins/conductor/skills/conductor/references/authoring.md. -
Structured
runtime.provider(Copilot custom routing):runtime.provideraccepts either the bare string shorthand (provider: copilot) or a structuredProviderSettingsobject that routes the Copilot SDK at OpenAI-compatible / Azure / Anthropic endpoints (Ollama, vLLM, LM Studio, Azure OpenAI, etc.). Object fields:name(defaults tocopilot),type(openai|azure|anthropic),wire_api(completions|responses),base_url,api_key,bearer_token,headers,azure.api_version.api_keyandbearer_tokenareSecretStr(redacted inmodel_dump/ dashboard / event logs). The model is frozen after construction. Custom routing activates only when at least one non-namefield is set in YAML — ambientOPENAI_*env vars never divert default routing on their own. Once activated, missing fields fall back from env vars in this order:base_url←COPILOT_PROVIDER_BASE_URL→OPENAI_BASE_URL;api_key←COPILOT_PROVIDER_API_KEY(only — ambientOPENAI_API_KEYis intentionally NOT a fallback to avoid credential leaks);bearer_token←COPILOT_PROVIDER_BEARER_TOKEN. The schema rejects every non-namefield whenname != "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/azurecannot stand alone withoutbase_url/api_key/bearer_token; emptyheaders, emptySecretStr, andazure: {api_version: null}are rejected. The resolver raisesProviderErrorwhen 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 wholeProviderSettings(logs a notice when YAML had structured fields). Seeexamples/copilot-local-llm.yaml. -
Connect to an existing Copilot runtime (Copilot):
runtime.provider.runtime_url(Copilot-only) points the provider at an already-runningcopilot --headlessprocess instead of spawning a nested one. Agents share the authenticated runtime process while retaining separate SDK sessions. Optionalruntime_token(SecretStr, redacted, requiresruntime_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 fromhas_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_tokenwithoutruntime_url; empty or whitespace-only runtime values; either field whenname != "copilot". Provider layer:_resolve_runtime_connection()(YAML then env) and_build_client()(incopilot.py). Seeexamples/copilot-existing-runtime.yamlanddocs/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_validatorrunsOutputValidator(engine/validator.py) — a second LLM call (synthetic agent viaprovider.execute,tools=[],{passed, issues}output schema) grading the output againstvalidator.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). Onpassed: falseandmax_retries > 0, the primary re-runs once viaexecutor.executewith a## Validation feedbacksection (the issues) appended toguidance_section; the second output is final (no second validation loop). When the primary output carries acontinuation_state, the re-run instead continues the completed conversation:_apply_validatorpasses the state back with the feedback alone as the next user turn, andAgentExecutorskips re-rendering the prompt and the skills/instructions prefix.continuation_stateis provider-opaque and in-memory only (never serialized to checkpoints or event logs); today the Pydantic AI-based providers (claude,openai) set it tooutcome.result.all_messages()andhermessets it to the run'sresult["messages"], whilecopilot/claude-agent-sdk/acaleave itNoneand 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 byagent.validator and not output.partial; for-each passes ausage_labelso the row matches<group>[<key>]). Emitsagent_validator_start/agent_validator_complete { passed, issues, errored, tokens, cost_usd }/agent_validation_failed { issues, will_retry }through the per-agentevent_callback(so for-each events carryitem_key). Cost: the validation call and any discarded first attempt are recorded as a separate"<agent> (validator)"usage row (primary row = effective output). Rejected onscript/human_gate/workflow/wait/set/terminatetypes. Frontend: event types inweb/frontend/src/types/events.ts, store handlers +NodeData.validator_*fields inweb/frontend/src/stores/workflow-store.ts, detail UI inweb/frontend/src/components/detail/ValidatorDetail.tsx(rebuild withmake build-frontend). Seeexamples/validator.yamlanddocs/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_guidance→get_guidance_prompt_section()→AgentExecutor.execute(guidance_section=...)), which previously only the TTY Esc/Ctrl+G interrupt could reach.WorkflowEngineowns 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 toWebDashboard.set_guidance_sink()(the exact mirror ofset_interrupt_event()— pushed into the engine rather than polled, because the engine's_web_dashboardis duck-typed and stubbed across the test suite);resume --guidancedoes not go through it — it callsadd_user_guidancedirectly 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 toself.contextand emitsguidance_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'swhile 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 misleadingwith_guidance: true._handle_web_pausereturnsWebPauseOutcome(handled, guidance)(replacing a barebool) 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_resumedgainswith_guidance: bool.POST /api/guidance(web/server.py) validates (CONDUCTOR_GATE_TOKENvia the same_gate_token_okcheck as/api/gate-respond; 409 afterworkflow_completed; 422 for empty-after-strip or overengine/guidance.py::MAX_GUIDANCE_CHARS=10,000, shared withresume --guidanceviaengine/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 viascan_pid_files()and POSTs with the same token header asconductor gate respond. Replay asymmetry:guidance_receivedis added to_REPLAY_INTERACTIVE_SKIP_TYPES(it is the opening half of a pair whose closer isguidance_applied— replaying a still-pending submission would show a phantom "pending" entry forever), butguidance_appliedis deliberately not filtered, sinceWorkflowContext.from_dictrestoresuser_guidanceand that correction really is still in effect on the resumed run. Parallel and for-each group members now also render with the currentguidance_section(previously alwaysNone), so a correction submitted mid-run reaches every subsequent member too. Seedocs/cli-reference.md(conductor guide) andplugins/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 areenabled(defaults totrue),max_chars(defaults to50000, minimum1000),spill_to_file(defaults totrue), andspill_dir(defaults tonullto 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 viamax_charsormax_agent_iterationsto 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 viaMCPManagerToolset, which applies character-limit truncation and spill-to-file logic while emittingagent_tool_output_truncatedevents. Copilot uses its native SDKlarge_outputcapability, mappingmax_charsto bytes (meaning multibyte UTF-8 characters like CJK/emoji may truncate earlier). Theagent_tool_output_truncatedevent is Claude-only because the Copilot SDK doesn't expose a truncation hook. This config is ignored byclaude-agent-sdk(managed via native CLIMAX_MCP_OUTPUT_TOKENS) and doesn't apply tohermes(no MCP tools). Seeexamples/tool-output-limits.yamlanddocs/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 throughwsStatus'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. Insteadworkflow-store.ts'ssetWsStatustrackswsDisconnectedSince: a timestamp set only on a fresh drop from'connected', preserved through that churn, and cleared once reconnected.lib/reconnect.ts's pureisReconnectStuck()(unit-tested) compares that timestamp againstRECONNECT_WARNING_THRESHOLD_MS(60s), gated onworkflowStatus === 'running'and notreplayMode.hooks/use-reconnect-warning.tsticks this once a second (mirroringStatusBar'sidleSecondspattern; itself untested per the existing convention for timer-dependent hooks) andcomponents/layout/ReconnectWarningBanner.tsxrenders 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 rootworkflow_startedevent,--web-bgruns only) falling back tosystem.log_file(the always-on structured*.events.jsonlevent log written byEventLogSubscriberfor every run, unrelated to the separate--log-filedebug-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'slayoutTopLevelnormalizes each rebuild's bounding box to origin, so growing one container shiftsminX/minYand therefore every node's position on the canvas, while the camera is never touched (thefitViewprop is initial-render only; React Flow'sfitViewQueuedis diff-guarded and cleared after the first fit). The world slid under a fixed viewport.lib/graph-anchor.tscompensates the camera instead:toAbsolutePositionsresolves each node's canvas-absolute top-left by foldingparentIdchains — load-bearing because React Flow stores a nested node'spositionrelative to its parent, so a node inside an expanded container keeps a byte-identicalpositionwhile its container moves — andanchoredViewportpans 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 bothdata.childContextKeyanddata.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.nextAnchorHintis the pure state machine deciding that hint; it holds the last clicked container for at mostMAX_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 resettingexpandedContexts, so the budget is bounded.WorkflowGraphis split behind aReactFlowProvider(there was none before; everyuseReactFlow()call site was a child of<ReactFlow>) so the rebuild effect, which lives outside<ReactFlow>, can reachsetViewport.lib/camera-authority.tskeeps the two camera owners from fighting: an instantsetViewporttakes d3-zoom's non-transition path, which callsinterrupt(), so a rebuild landing inside an animatedfitViewwould cancel it mid-flight and leave its promise permanently unsettled — every animated fit therefore callsclaimCameraForAnimation(duration)and the rebuild effect yields whileisCameraAnimating(). NoteanchoredViewportreturningnullon a zero delta is load-bearing, not merely informative: it suppresses the no-opsetViewportthat 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. Seeexamples/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(...), notBaseHTTPMiddleware/@app.middleware("http")— neither sees WebSocket scopes) validatingHost(required, must name the bound machine) andOrigin(only checked when present — httpx/curl/conductor gate respondsend 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_TOKENoverrides it (resolve_expected_token). Required on every mutating route (/api/stop,/api/kill,/api/resume,/api/gate-respond,/api/guidance) and on the/wshandshake — the single auth point for the socket, since an unauthenticated connection is closed (websocket.close, code 1008) in reply towebsocket.connect, beforeaccept(), 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/jsonrequired on every mutating route (415 otherwise), including the bodyless control POSTs. Token discovery:WebDashboard.start()writes a0600file (POSIX; on Windows the mode is not honoured and the file relies on the user-profile NTFS ACL instead) at~/.conductor/runs/dashboard-<port>.tokenonce the port resolves (works for both--weband--web-bg, sincecli/run.pycallsstart()/stop()on both the run and resume paths);stop()removes it.conductor gate respond,conductor guide, andconductor stop's graceful-kill rung all resolve a token via the sharedresolve_cli_token(port, token):--token>CONDUCTOR_GATE_TOKEN> the token file. Seedocs/cli-reference.md(Environment Variables, and the Authentication sections underconductor gate respond/conductor guide) and theweb/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(defaulttrue; 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_secondsresolves to),read_only/destructive(surfaced as the generated tool'sreadOnlyHint/destructiveHintannotations), andestimated_minutes(a client-side hint, must be positive).conductor validatereports an unknown key inside it as a schema error, not silence (FR11) — it cannot ride on the existing untypedmetadata: dict. Seeexamples/mcp-serve.yamlanddocs/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) andsrc/conductor/mcp/serve/above for the server that reads it. R1 — this feature's terminal run record is a scope change toconductor statusandconductor 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--liverestoring the exact pre-change scope. See thefleet/records.py(TerminalRunRecord) bullet above and theCHANGELOG.mdentry for the full description of what changed. -
Context compaction: Always-on client-side context window compaction for the
claudeandopenaiproviders. Compaction is triggered proactively using the reserve-based formulatrigger = window - (output_limit + buffer)and targets a clamped 55% hysteresis ceiling. In this formula, theoutput_limitresolves to the minimum of the effectivemax_tokenssent to the API (from settings or defaults) and the model output cap (from provider-cap). The tool-output-derivedbufferis calculated as2 * ceil(max_chars / 4) + 15,000tokens 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 asdensity_tokensandtrigger_reasonon the start event, withdegraded_estimators/still_over_windowon the complete event and anagent_compaction_skippedevent 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'smax_agent_iterationsbudget, which doesn't get refunded. The dashboard context bar relies on provider-only limits and may disagree with the compaction window.
When a conductor run --web-bg (or resume --web-bg) child dies before
the dashboard becomes reachable, or crashes mid-run, look at:
- The child's captured stderr log, printed alongside the dashboard URL
on a successful launch and included in every
RuntimeErrormessage on a failed launch (with a bounded tail of its contents inlined via_tail_log). The path is also stamped into the child'sworkflow_startedevent undersystem.bg_stderr_logand surfaced in the web dashboard. - The matching
.events.jsonlfile 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. - For an apparent silent crash, search the events JSONL for a
workflow_failedevent; theis_base_exceptionflag tells you whether the failure escaped the engine's normalExceptionhandling (e.g. aSystemExitfrom a misbehaving library). - 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/infohadn't yet reported aworkflow_startedevent when the (default 30s)CONDUCTOR_WEB_BG_START_TIMEOUTdeadline 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 to0to disable the wait entirely. - If a launch-gate
RuntimeErrorreads "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. Useconductor statusorconductor 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 mirror source structure in tests/:
test_cli/- CLI command tests, e2e tests.test_markup_guards.pyis the one that keeps issue #406 closed: it readssrc/conductorwithastand fails with file:line across eight rules — a bareConsoleor aConsolesubclass (A), an interpolatedPaneltitle orPrompt(B), an f-string intoText.from_markup(C), a markup literal at a print/cell sink (D), aTextthrough the builtinprint(E) or into an f-string (F), unescaped brackets intyperhelp text (G), and any use ofrich.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.pycovers the same ground behaviourally, driving the real commands — the two layers are not redundant: the guard alone cannot provestyledrenders correctly, and the behavioural tests alone cannot stop the next call site, which is the actual failure mode heretest_config/- Schema validation, loader teststest_engine/- Workflow, router, context, limits teststest_executor/- Agent, template, output teststest_providers/- Provider implementation teststest_integration/- Full workflow execution teststest_gates/- Human gate teststest_skills/- Skill registry, frontmatter parsing, path entries, injection budget, loader, schema field, and executor/engine-integration tests.test_engine_integration.pyis load-bearing: anAgentExecutorbuilt directly in a test is handedworkflow_dirandskill_injectionby the test itself, so only an engine-level test can catch the engine failing to supply themtest_plugins/- Manifest parsing (both conventions, all threemcpServersforms), 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 intest_sources.py/test_marketplace.py/test_fetch.py— the fetch tests run real git againstfile://repositories built by a fixture, because mockingsubprocesswould test the mock: annotated-tag dereferencing, shallow SHA fetch, and the unreachable-remote fallback are all properties of git itself.conftest.pybuilds every plugin tree on disk and takeshomeas a fixture, so no test reads the developer's real~.test_executor_integration.pyandtest_engine_integration.pyare 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 atexecutethey are simply gone — a negative assertion could not tell a working path from a dropped onetest_install_hint.py- The optional-extra install resolver (issue #441). The three context branches are covered against the purerender_install_command, so no test needs a real install; receipt parsing is covered against fixtureuv-receipt.tomlfiles.tests/test_integration/test_install_script_extras.pyis its shell counterpart and executes the realinstall.shhelpers (sourcing the script with its trailingmainstripped) rather than grepping them — the failure mode there is a quoting orsed-pattern mistake that reading the file does not catch;install.ps1's helpers are extracted with PowerShell's own parser and executed wherever a nativepwshexists, with a parity class feeding both implementations the same receipttest_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 intocli/run.py(test_run_record_wiring.py), terminal bell/OSC 9 notification debouncing (test_notify.py), and TextualApp.run_test()pilot tests per screen (test_tui_runs.py,test_tui_run_detail.py,test_tui_step_detail.py,test_tui_drilldown.pyfor 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 themcpSDK version bound (test_sdk_bound.py, issue #432/DD0) alongsideconductor 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 andtools/liststability 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 theintrospect/diagnosetoolsets including R4's default tool-payload reduction (test_serve_introspect.py,test_serve_diagnose.py).conftest.pyprovides the shared fixture registry every catalogue/server test builds against.
Use pytest.mark.performance for performance tests (exclude with -m "not performance").
When writing integration tests that construct WorkflowConfig programmatically, follow these conventions (see tests/test_engine/test_limits.py for canonical examples):
AgentDefusesprompt=(notinstructions=),output={"key": OutputField(type="string")}(dict, not list), androutes=[RouteDef(...)](not raw dicts).WorkflowDefrequiresentry_point=and placeslimits=insideworkflow=.agents=andoutput=are top-level onWorkflowConfig.- The engine entry point is
await engine.run({})(notexecute). - To test with controlled token/cost data, patch
provider.executeto return a customAgentOutputwith explicitinput_tokens,output_tokens, andmodelfields.
When adding new fields to LimitEnforcer:
- Transient fields (reset each run): add to
from_dict()as parameters sourced from the current workflow config, liketimeout_seconds,budget_usd,budget_mode. Update the call site incli/run.py→resume_workflow_async(). - Persistent fields (survive across resume): add to both
to_dict()andfrom_dict()deserialization, likemax_iterations,current_iteration,execution_history.
- 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
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 locksmarkup=False, so a plain string is literal unless it asks to be styled. This covers plain prints,Panelbodies,Tablecells, headers, titles and captions, andRuletitles.markupis not overridable — passing it to the constructor, toprintor tolograisesTypeError, because rich's per-callmarkup=Truewould otherwise reopen the whole defect from one line. SubclassMarkupFreeConsolerather than rich'sConsoleso the refusal is inherited (seecli/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 aTextis spliced in with its styling intact, so pre-styled fragments compose. UseText.from_markup("...")when there is nothing to interpolate, andconductor.console.join(sep, parts)to join a list mixingstrandText(Text.joinrequires every part to be aTextalready). Panel(title=),Panel(subtitle=)andPrompt/Confirm/IntPromptprompts must be handed aText(rule B). Rich callsText.from_markupon those unconditionally (rich/panel.py,rich/prompt.py), somarkup=Falsenever reaches them. This is the trap that made #387 incomplete: it fixed the panel body and left thetitle=f-string one line away.- Never let a
Textreach a plain-string context — an f-string (rule F),str(), or the builtinprint(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. Usestyled("{}{}", ...)orjoin(...)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 aTextavoids 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 aText, because Typer takes astr. Forgetting costconductor run --helpthe 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.
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_startwith{"turn": "awaiting_model"}— immediately before each API callagent_turn_startwith{"turn": N}— at the start of each agentic loop iterationagent_message— for text content in responsesagent_reasoning— for reasoning/thinking contentagent_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:318is 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 andValidationError, appliesproviders/_output_shape.py::unwrap_scalar_wrappersbefore validating, and re-prompts via a schema-specific correction message distinct from the syntax one (Copilot and Hermes shareproviders/_recovery_prompt.py::build_parse_recovery_prompt; Claude has its own tool-oriented wording). On budget exhaustion, re-raise the originalValidationError(it names the field and expected type); reserveProviderErrorfor syntax failures. Each attempt emitsagent_parse_recoveryviaproviders/_event_format.py::emit_parse_recovery_event. Two traps:parse_json_outputwraps syntax errors inValidationErrortoo, 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_usepath, which returns to the agentic loop rather than being a final answer. - Plugin components (issue #378):
execute()acceptscustom_agentsandextra_mcp_serversalongsideskill_directories. Every provider takes all three; a provider declaringCAPABILITIES.plugins=Truemust 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
AgentOutputstructure 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.effortfield (low|medium|high|xhigh|max), translate it to the native API (Copilotreasoning_efforton the session; Claude extendedthinkingbudget), validate that the selected model supports the requested effort, and raiseValidationErrorwith a clear message when it does not. The one declared exception is Hermes, whoseCAPABILITIES.reasoning_effortomitsmax(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 viaagent_reasoningevents so the dashboard, JSONL logger, and console subscriber render it consistently. - Model pricing hook (issue #265):
AgentProvider.get_model_pricing(model) -> ModelPricing | Noneis an optional hook (base default returnsNone) that lets a provider supply live per-model rates. Cost resolution order inengine/pricing.py::get_pricingis workflowcost.pricingoverride → provider hook →DEFAULT_PRICING→None. The engine bridges the async hook to the syncUsageTracker.recordviaWorkflowEngine._ensure_pricing_resolved(agent, model)(called before everyrecord(); resolves each model once, caches on the tracker). Only Copilot implements it (derives USD from the SDK'sbilling.token_pricesin AI Credits,100 credits = $1via_COPILOT_USD_PER_CREDIT); it must never raise (fall back to the table).WorkflowUsageexposesunpriced_agents/unpriced_models/has_unpricedso 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_tokensare optional hooks (base default returnsNone) 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, andcopilotimplement both;hermes,aca, andclaude-agent-sdklegitimately returnNonefrom 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_tokenswith the prompt-token size of the most recent single API call in the execution, or leave itNone— it must never be an aggregate. This is distinct frominput_tokens, which sums every call for billing and is not comparable to a model's context-window cap. Per-provider asymmetry: Copilot'sassistant.usageevent and pydantic-ai'sRequestUsage.input_tokensalready 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 suminput_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 freshAIAgentwhose usage never reaches the outer result) reportsNonerather than guessing, which is a first-class "hide the bar" signal, not a fallback to the wrong number. - Cache-inclusive token accounting:
AgentOutput.input_tokensis the total prompt across every call and must always includecache_read_tokensandcache_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 ownUsageBasedocuments the identical convention and normalizes the providers that don't); the Claude Agent SDK's Anthropic-shaped usage dict reports cached tokens outside its owninput_tokens, so that provider folds them in viaclaude_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_costthen 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 a0.0cache rate inDEFAULT_PRICINGmeans "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 matchgenai_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 leavesinput_tokensexclusive under-bills instead; a provider that populates neither reports cache-free totals and is unaffected (hermes), andacasatisfies 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 declaressupports_continuation=True, populatesAgentOutput.continuation_stateon every completed (non-partial) output, and when handed that state back onexecute()continues the completed conversation, treatingrendered_promptas the sole next user turn. A provider that cannot must ignore the kwarg (with an "Ignored." docstring entry and adel, matching the other unused kwargs) and leave the field atNone— the first-class "rebuild the prompt statelessly" signal the executor branches on. Today the Pydantic AI-based providers (claude,openai) declare it withoutcome.result.all_messages()as the value, andhermesdeclares it with the run'sresult["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 typedobjectat 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.
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()torun_agent_pipeline()with anOpenAIChatModelbackend. - Env-resolution rule: YAML
api_keyandbase_urltake precedence overOPENAI_API_KEYandOPENAI_BASE_URL. Ambient env vars never reroute an unconfigured provider (e.g.provider: copilotis never diverted byOPENAI_*variables). Once a custombase_urlis in effect — from YAML or fromOPENAI_BASE_URL— the ambientOPENAI_API_KEYis not forwarded to it and construction raisesValidationErrorunlessapi_keywas passed explicitly, so a personal credential never reaches an endpoint the author did not pair it with (the same reasoning behindcopilot.pyrefusing ambientOPENAI_API_KEY).self._api_keyis deliberately leftNoneon the ambient path rather than being written back, which keepsagent_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_eventsisFalse: pydantic-ai only builds aThinkingPartfromreasoning/reasoning_content, a DeepSeek/Moonshot field, soapi.openai.comnever emitsagent_reasoningon this backend. It would becomeTrueon anOpenAIResponsesModelbackend. - Reasoning effort: Declares
("low", "medium", "high")and rejectsxhigh/maxwith aValidationError.xhigharrived 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 wayhermes.pyomitsmax). Effort is additionally validated per model at agent-build time viapydantic_ai.profiles.openai.openai_model_profile(...).openai_supports_reasoning, so a non-reasoning model likegpt-4ofails before the request instead of returning a 400 mid-run; an unavailable profile attribute yieldsNone, which skips the check rather than guessing. - Temperature 0..2: The per-provider ceiling lives on
ProviderCapabilities.max_temperature(1.0for copilot/claude/hermes/aca/claude-agent-sdk,Nonefor openai) and is enforced byfactory.py::_enforce_temperature, the choke pointrun,resumeandvalidateall share — the schema bound alone would not coverconductor run, which never calls the cross-reference validator.
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-sdkpackage does not retry API failures (429s, 5xx, network errors) internally — its built-in retry logic covers only filesystem operations. Conductor wraps SDK errors inProviderErrorand usesstop_reason/ error subtype to setis_retryable, so workflow-levelretry:configuration drives all retry behavior. Plan for transient failures with explicitretry:blocks in your workflow. - MCP servers (issue #335): workflow-level
runtime.mcp_serversare supported._translate_mcp_serversmaps eachMCPServerDef-derived dict onto the SDK'sMcpStdioServerConfig/McpHttpServerConfig/McpSSEServerConfigshapes, and the provider passes them viaClaudeAgentOptions. Four details are load-bearing:- Translation runs once in
__init__rather than perexecutecall. Note providers are constructed lazily (ProviderRegistry.get_provider←WorkflowEngine._get_executor_for_agent), so a bad server config surfaces when the first agent on this provider runs, not atconductor validate— which does not inspect per-servertools:filters at all. - The config is written to a
0600temp 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 stdioenvvalues and http/sseAuthorizationheaders to anything that can read/proc/<pid>/cmdline. The write happens insideexecute'stry, so thefinallyreclaims the file on every exit path; the finally alsoaclose()s the SDK iterator first, so theclaudesubprocess is gone before its config file is. The file must use the{"mcpServers": {...}}envelope — the CLI rejects a bare mapping. strict_mcp_config=Trueis set unconditionally, including when the workflow declares no servers: otherwise the CLI loads project.mcp.json, user-global, and plugin-provided servers, andpermission_modebypasses 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 droppedtimeoutonly warns, since losing it cannot widen tool access.
- Translation runs once in
- 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'stoolsoption governs built-in tools only, andallowed_toolsis 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 withtools: []runs with no built-in tools beyond theSkillloader when skills are enabled (MCP servers still attach); omittingtools:grants the fullclaude_codepreset. - Runtime config:
temperatureandmax_tokensare rejected at the factory — the CLI controls sampling behavior. - Working directory (issue #348): the engine-resolved
agent.working_dir/runtime.working_diris forwarded, asClaudeAgentOptions.cwd.- The SDK applies it as the
claudesubprocess's cwd (_internal/transport/subprocess_cli.pyas of 0.2.87 passes it toopen_processand setsPWD), so stdio MCP servers pick it up by inheriting it from that subprocess. There is deliberately no per-server stamping as incopilot.py::_mcp_servers_for_cwd: the SDK'sMcpStdioServerConfighas no cwd field, so_translate_mcp_serversis 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 verbatim —
WorkflowEngine._resolve_agent_working_dirhas already rendered, absolutized, normalised, and existence-checked it, and re-resolving here would collapse the symlink aliases the engine preserves on purpose. TheClaudeAgentOptions(...)construction lives insideexecute'stryso theos.getcwd()fallback can't escape as a bareOSErrorwhen the process cwd has been deleted (copilot.pyresolves 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'sCLIConnectionError("Working directory does not exist: <path>"), wrapped inProviderError. That is only defensible because_classify_startup_failurespecial-cases it —CLIConnectionErrorotherwise yields firewall/binary advice andis_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.jsonand.claude/tree would be looked for. The unconditionalstrict_mcp_config=Truestops a.mcp.jsonthere from injecting undeclared servers, andsetting_sources=[](empty by default; see Skills below) stops the CLI loadingCLAUDE.md, project settings, and hooks from it — so cwd no longer drags ambient instructions in unless the workflow opts in viaruntime.provider.setting_sources, which is exactly a request to load them from that directory.add_dirs(the SDK's--add-dirpassthrough) is a separate axis, carrying the per-agentsettings_dirand nothing else — see Target-repository skills below.
- The SDK applies it as the
- Target-repository skills (
settings_dir): the per-agentAgentDef.settings_diris the only source ofClaudeAgentOptions.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-filesystemdiscards the directories in its own argv and permits cwd alone; cwd is simultaneously what theprojecttier 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 deriveadd_dirsfrom a server's directory arguments to compensate:--add-dirtakes 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. Whatadd_dirsdoes do, measured: a named directory's.claude/skillsbecome listed and invocable with cwd elsewhere entirely — and only those, notCLAUDE.md,.claude/rules/*.md,.claude/settings.json(so noenvand nohooks— 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-resolvedprojecttier loads, not a replacement for it. It carries a second, unconditional effect the skills framing hides:add_dirsis "additional directories Claude can access" per the SDK's own contract, so asettings_dirwidens the model's built-inRead/Edit/Bashto that tree with no settings tier enabled at all (measured againstclaudeCLI 2.1.263 atpermission_mode: "default"withsetting_sourcesunset: a read outside cwd is refused without it and succeeds with it; an agent omittingtools:runs underbypassPermissions, where the grant is unobservable because reads already succeed). It does not widen what an MCP server permits.capabilities.py::settings_dirgates the field andconfig/validator.pyerrors against a provider that cannot apply it — enforced twice, withAgentExecutor._reject_unsupported_settings_dirrepeating it at run time, becauseconductor runnever calls the static validator — warning when the agent's session will not enable theprojecttier (also twice:config/validator.pyat validate, andclaude_agent_sdk.py::executeat 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 afor_eachmember 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 theprojecttier is otherwise present: an agent withskills: []under auser/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_directoryresolves both fields so they cannot drift, andsettings_diris per-agent only (noruntime.counterpart: the repository whose conventions apply is what varies between steps).tests/test_integration/test_mcp_roots_negotiation.pypins the negotiation rule itself against the real server with no LLM — two runs differing only in whether the client advertisesroots— since every option here rests on it (markedreal_api: it fetches the server from npm and pins upstream's behaviour, not Conductor's). The resolved value is emitted onagent_started/parallel_agent_started/for_each_agent_startedalongsideworking_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 inexecute:plugins=[{"type": "local", "path": <plugin root>}]+skills=["<plugin>:<skill>"]. The SDK has no skill-directory surface, so_resolve_skill_pluginsmaps each directory back to the plugin that owns it viaskills/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'sskills/directory, requiresSKILL.mdto exist and its frontmatternameto 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--allowedToolsvalue. A skill under no plugin root returnsNone; a plugin that is present but unusable raisesSkillPluginError, which the provider re-raises as aProviderErrorcarrying 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 isis_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 atconductor validate.setting_sources=[]by default, for the same reasonstrict_mcp_config=Trueis unconditional a few lines away — but opt-in per workflow viaruntime.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=[]andskills=Noneare not interchangeable upstream:Nonemeans "CLI defaults apply", and settingskillswhile leavingsetting_sourcesatNonemakes 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'sinitializecontrol 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 theSkilltool, but their files stay readable. Opting a workflow in (runtime.provider.setting_sources: [project], the motivating case being an agent whoseworking_diris a target repo shipping its own.claude/skills— the CLI has--plugin-dirbut 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 whileworking_diris per agent. Two couplings follow:_resolve_skill_filterresolvesskillsto"all"when a tier is enabled and the workflow named none itself (tier-discovered skills never pass throughskill_names, so[]would load the repo's skills and then hide every one), and a per-agentskills: []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_configtherefore grants back the singleSkilltool when skills are enabled. No permission bypass is needed — the SDK auto-allows it viaallowed_tools:Skill(<name>)per declared skill, or the bareSkillon 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, sotests/test_skills/test_executor_integration.py::TestSkillDirectoriesReachTheProviderasserts directories actually arrive atexecute. 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 inlineClaudeAgentOptions.agents(_build_sdk_agentsmaps the sharedCustomAgentConfig-shaped specs ontoAgentDefinition, which is keyed by name and so has nonamefield of its own; Copilot'sinferhas no counterpart and is dropped). ReturnsNonerather than{}when there are none — unlikeskills, 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_configtemp-file path as the workflow's own, translated per call rather than in__init__becauseplugins:is per-agent; the unconditionalstrict_mcp_config=Truesuppresses whatever a registered plugin root would otherwise contribute, which is precisely what makesmcp: falsemean something here. The one declared carve-out: reaching a plugin's skills requires registering its root via thepluginsoption, which the SDK documents as providing "custom commands, agents, skills, and hooks" — with askillsfilter and none for the rest. Soagents: falsealongsideskills: truefor the same plugin is refused inconfig/validator.pyrather than silently granting more than the workflow declared, and thehooks/warning here says exposed to the CLI rather than not loaded, which would be false. The identical config works untouched oncopilot. - Session continuity (
session_key):session_continuity=True. A per-agentAgentDef.session_keyreuses 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 indocs/workflow-syntax.md(Session Continuity), withexamples/claude-agent-sdk-session-key.yaml._session_idsis 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_sessionprefers an id recorded this run over a checkpoint-restored one and passes it asClaudeAgentOptions.resume;fork_session=Falseis explicit because a fork would issue a new id and strand the map on a dead session. OnlyAssistantMessage/ResultMessage(_SESSION_ID_MESSAGES) update the map — hook, task, and stream frames carrysession_ids of their own, and recording one would shadow the real session with a transcript-less id.- The transcript guard is load-bearing.
--resumeagainst a transcript the CLI cannot find aborts it withProcessErrorbefore running the agent, so a naive passthrough would turn an ordinary first iteration, a pruned transcript, or a movedworking_dirinto a hard failure._session_transcript_existschecks the exact path the CLI uses,<CLAUDE_CONFIG_DIR or ~/.claude>/projects/<project_key_for_directory(cwd)>/<id>.jsonl.get_session_infoanswers a different question — it derives a summary and returnsNonewhen 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--resumethrough 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 recordedcwdrealpath-matches ours. Any exception degrades to "start fresh". - Checkpointing reuses the duck-typed
get_session_ids/set_resume_session_idshooks fromcopilot.py, so no CLI or checkpoint-schema change was needed.copilot_session_idsis 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 (copilotandhermesstill shadow each other — pre-existing).checkpoint_resumestaysFalse: that flag is a blanket promise the startup banner reads out, and agents without a key — the default — carry nothing across. session_keyis rejected onscript/human_gate/questions/workflow/wait/set/terminate, is whitespace-stripped withmin_length=1, and is never Jinja2-rendered —validate_session_key_is_literalrejects{{ ... }}rather than letting it become one literal key shared by everyfor_eachiteration._check_agent_capabilitiesgates it onsession_continuity, andconfig/validator.pyrejects a key shared by concurrent executions (two parallel members, or a keyed for-each agent withmax_concurrent > 1), which would leave twoclaudeprocesses appending to one transcript.
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_TOKEN → gh auth token →
ProviderError. 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-agenttools:allowlist is forwarded to the runner in the request body, but the in-containerCopilotProviderit 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 withmcp_tools=True(the full configuredmcp_serversset is always forwarded), there is no allowlist value the runner can honor — not eventools: []— soconfig/validator.pyrejects any explicittools:on anaca-backed agent, not just a non-empty one (review follow-up, #284 E7). This mirrors the same declared carve-out onclaude_agent_sdk.pyandhermes.py.hermes.pydeclaresmcp_tools=False(nothing is ever forwarded regardless of the list), sotools: []genuinely disables all tools and stays valid for it.claude_agent_sdk.pynow behaves likeacawhenever the workflow declaresmcp_servers, and likehermeswhen it does not.working_dir=False: this capability field means "applies the generic, host-resolvedagent.working_dir/runtime.working_dir" — a host filesystem path the engine resolves against the workflow file's directory.acanever reads that field; it only honors the separate, container-relativesandbox.working_dirblock (documented indocs/providers/aca.md#workflow-configuration), which has no meaning as a host path and is not gated by this capability.interrupt/max_session_secondsare declaredTruehost-side but not fully backed by the shipped runner MVP (epic E4): the runner has no/interruptendpoint 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), andmax_session_secondsenforcement is only a best-effort, Copilot-internal timeout (the wrappedCopilotProvider's ownIdleRecoveryConfigcheck) — there is no independent runner-level guard. Stopping anaca-backed agent today eventually stops the host from waiting, not the sandbox from computing. Seedocs/providers/aca.md#known-gaps-runner-mvp.plugins=False: same reason asskills=Falseabove — a plugin's skill directories,agents/*.agent.mdfiles, and.mcp.jsonare 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 forconductor resumeto restore. A resumed workflow re-runs theaca-backed agent from scratch rather than continuing an interrupted sandbox session — the same postureclaude_agent_sdk.pyandhermes.pydeclare, 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__.pybinds127.0.0.1by default (the container image'sDockerfilesetsACA_RUNNER_HOST=0.0.0.0explicitly, so it is unaffected). (2)aca_runner/auth.py::resolve_runner_tokenreads the opt-inACA_RUNNER_AUTH_TOKEN; when set,/executerequires a matchingX-Conductor-Runner-Tokenheader (compared via the existingweb/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-Tokenis chosen overAuthorizationbecause the ACA session gateway consumes that header itself (it carries the AAD token).GET /healthstays unauthenticated (the image's ownHEALTHCHECKsends no header) but reportsauth_requiredandauth_token_present— the latter is header presence only, never validity, so it cannot become a brute-force oracle — lettingAcaRuntimeProvider._warn_on_auth_skew(alongside the existing_warn_on_version_skew, both insidevalidate_connection()'scontextlib.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_settingsrejects anyinner_provider_settingskey outsideALLOWED_INNER_PROVIDER_SETTINGS_KEYS(base_url/api_key/bearer_token/github_token— the fourAcaRuntimeProvider ._resolve_inner_provider_settingsactually produces), closing offruntime_url/headersinjection that nobase_urlallowlist alone would catch;ACA_RUNNER_ALLOWED_BASE_URLSadditionally restricts which BYOKbase_urlvalues are accepted. (4) The runner token header is merged into theAuthorizationheaders dict at the three runner-forwarded call sites only (execute(),_send_interrupt(),validate_connection()) — not_stop_session(), whoseDELETE {endpoint}/sessionis 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)identifieris 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 containerHEALTHCHECKsends none at all) — seedocs/projects/aca/aca-provider.design.md's Identifier as a capability bullet anddocs/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.
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-levelruntime.mcp_serversis not forwardedworkflow_tools_passthrough— per-agenttools:allowlist is not enforcedstreaming_events— events emitted only at completion (not incrementally)agent_reasoning_events— no thinking/reasoning event surfacingreasoning_effort— provider has no reasoning-effort conceptstructured_output: "prompt_injection"— schema enforced via prompt injection onlyinterrupt— mid-call interrupt not honored (still cancels between iterations)max_session_seconds— wall-clock session timeout silently ignoredcheckpoint_resume— session state does not surviveconductor resumesession_continuity— per-agentsession_keynot honored (every execution starts a fresh session)
Non-negotiable rules experimental providers MUST uphold:
AgentProviderlifecycle (validate_connection/execute/close).AgentOutputshape on every successful execution (fields may beNone).- Raise real exceptions on real errors — no silent failure swallowing.
- Declare accurate
ProviderCapabilitiesmatching observed behavior. - Declare
skillsaccurately. Skills are not an allowed carve-out — a provider reachesskills=Trueeither natively (supports_native_skills=True, forwarding the resolved skill directories to its SDK in whatever shape that SDK accepts) or viaAgentExecutor's eager preamble injection, which is provider-agnostic. DeclareFalseonly when neither path can work (e.g.aca, where skill directories are host paths the in-sandbox runner cannot read).config/validator.pycross-checks per-agentskills:and inheritedruntime.skillsagainst this flag, so an inaccurateFalseturns into a spurious validate error and an inaccurateTruesilently drops the skill content at run time. - Declare
pluginsaccurately. Unlikeskills, 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 reachesplugins=Trueonly by honoring all three ofskill_directories,custom_agentsandextra_mcp_serversonexecute(). DeclaringTruewhile 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 withmax_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.
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 (autoor 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— theinstructions_preambleis 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 freshrunto apply it before, so it has noruncounterpart to mirror.
Implementation parity rules:
- The async helpers (
run_workflow_asyncandresume_workflow_asyncincli/run.py) must wire up the same event emitter, JSONL event log subscriber, console event subscriber, andWebDashboardlifecycle. - The
WorkflowEngineconstructor 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-bgcallslaunch_background()andresume --web-bgcallslaunch_background_resume(). Both must forward equivalent options and write a PID file viacli/pid.py. - Note: on resume, the dashboard is seeded with prior events before it starts accepting clients. The CLI prepends a fresh
workflow_startedevent built from the current workflow YAML (viaWorkflowEngine.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 anevent_log_pathand the file exists) or synthesises minimal*_started/*_completedpairs from the restoredWorkflowContext(replay_synthetic_from_context()). The resumed engine's ownworkflow_startedemit is suppressed viaengine.suppress_workflow_started_emit()so the dashboard sees exactly one rootworkflow_started(nowfDepthdouble-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 frontendwfDepthstays 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'sresume_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, forisPaused/iterationLimitGateonly, 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 logsagent_pausedwith noagent_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_messageis left unfiltered only because it is inert oncedialog_startedis filtered — both renderers ofnode.dialog_messages(DialogEngagementPromptviaDetailPanel,DialogDetailviaDialogOverlay) gate ondialog_active/activeDialog, which onlydialog_startedsets, so replayed dialog transcripts are not visible on a resumed dashboard.components/layout/Header.tsxadditionally hides all live-control buttons whenreplayModeis set, sinceReplayDashboardserves no/api/stop|/api/resume|/api/kill;replayModeis latched inhooks/use-replay.tson mount (that hook only mounts onceApp's/api/replay/infoprobe confirmed replay) rather than when/api/stateresolves, so a slow or failed event fetch cannot leave the live controls rendered. The resumedEventLogSubscriberopens the original JSONL in append mode (when available) so a multi-resume session produces one continuous log file andrun_idstays stable for log-correlation tools.
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:
- Resolves the MCP manager for the agent's working directory.
- Builds a backend-specific
build_agent_fn(toolsets, *, max_parse_recovery_attempts)closure. - Converts the provider-level
RetryConfigto the internalPydanticRetryConfigrepresentation. - 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-aware temperature bounds:
RuntimeConfig.temperaturewidens the schema upper bound to2.0because OpenAI supports it, but most providers (copilot, claude, hermes, aca, claude-agent-sdk) cap at1.0.config/validator.pyenforces the per-provider bound atconductor validatetime so high temperatures do not fail mysteriously at the SDK boundary. Only theopenaiprovider is exempt; override agents toopenaior keeptemperature <= 1.0for the others. - Provider registration completeness: adding a new provider requires updating
providers/factory.py::ProviderTypeandproviders/diagnostics.py::_CREDENTIAL_SPECS(andproviders/capabilities.py::_PROVIDER_CLASS_PATHSfor validate-time capability checks). Latent forwarding bugs happen whenProviderRegistry.get_or_create_providerdoes not pass new runtime fields tocreate_provider. - Provider creation is serialized per registry: providers are cached per type, and
ProviderRegistry._get_or_create_providerholds one per-registryasyncio.Lockacrosscreate_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:
ProviderSettingsforname="openai"rejectstype,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.