Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ make validate-examples # validate all examples
- **Route evaluation**: First matching `when` condition wins; no `when` = always matches
- **Tool resolution**: `null` = all workflow tools, `[]` = none, `[list]` = subset
- **Reasoning effort**: `runtime.default_reasoning_effort` sets a workflow-wide default; per-agent `reasoning.effort` overrides it. Allowed values: `low`, `medium`, `high`, `xhigh`. Each provider translates the unified value to its native API (Copilot: `reasoning_effort` on the session, validated against the model's `supported_reasoning_efforts`; Claude: extended thinking with budget mapping low=2048, medium=8192, high=16384, xhigh=32768 tokens, with `temperature` coerced to 1.0 and `max_tokens` bumped to fit the budget). See `examples/reasoning-effort.yaml`.
- **Terminate steps** (`type: terminate`): explicit terminal step with `status` (`success` | `failed`), Jinja2 `reason`, and optional `output_template` (a `dict[str, str]` that replaces `workflow.output:` when set; each value is rendered then passed through `_maybe_parse_json` so `"true"` becomes `True`, `"42"` becomes `42`, JSON literals are parsed). Reaching a terminate step ends the workflow immediately (no routes evaluated after). `success` → CLI exit 0, dashboard ✅, `workflow_completed { termination_reason, terminated_by, is_explicit: true, status }`; runs `on_complete` hook. `failed` → CLI exit 1 (with rendered output JSON still printed to stdout for downstream tooling), dashboard ❌, raises `WorkflowTerminated` (subclass of `ExecutionError`), emits `workflow_failed { error_type: "WorkflowTerminated", is_explicit: true, status, output }`, runs `on_error` hook, and **does not** save an on-failure checkpoint (explicit terminations are intentionally non-resumable). Terminate steps cannot have `routes`, `tools`, `output`, `prompt`, `model`, etc.; cannot be used as parallel-group members or as a for_each inline agent (route to one from those groups' `routes:` instead). Inside a sub-workflow, a `status: failed` terminate is downgraded at the parent boundary to `SubworkflowTerminatedError` (also a subclass of `ExecutionError`) preserving the child's rendered `terminated_output` / `terminated_reason` / `terminated_by` as structured attributes — the parent treats it as a normal sub-workflow failure (its own `workflow_failed` does NOT inherit `is_explicit: true`). For more detail see `examples/terminate.yaml`, `docs/workflow-syntax.md` (Terminate Steps section), and `plugins/conductor/skills/conductor/references/authoring.md`.

### Debugging `--web-bg` failures

Expand Down
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased](https://github.com/microsoft/conductor/compare/v0.1.17...HEAD)

### Added
- New `type: terminate` workflow step that explicitly ends the workflow with
a structured `status` (`success` | `failed`) and Jinja2-rendered `reason`,
plus an optional `output_template` (`dict[str, str]`) that replaces the
workflow-level `output:` mapping for that termination path. Reaching a
terminate step ends the workflow immediately (no routes evaluated after).
`status: success` returns the rendered output cleanly (CLI exit 0,
dashboard ✅, emits `workflow_completed { termination_reason, terminated_by,
is_explicit: true, status: "success" }`); `status: failed` raises a new
`WorkflowTerminated` exception (`ExecutionError` subclass), gives the CLI a
non-zero exit code while still printing the rendered output JSON to stdout
for downstream tooling, and intentionally **skips** the on-failure
checkpoint save because explicit termination is not a resumable transient
failure. Inside a sub-workflow, a failed terminate is downgraded at the
parent boundary to a new `SubworkflowTerminatedError` (also an
`ExecutionError`) preserving the child's rendered `terminated_output` /
`terminated_reason` / `terminated_by` as structured attributes, so the
parent treats it as a normal sub-workflow failure (its own
`workflow_failed` does NOT inherit `is_explicit: true`) while debugging
surfaces can still inspect what the child intended to emit. Schema
validation rejects `routes`, `tools`, `output`, `prompt`, `model`,
`provider`, and the other agent-only fields on terminate steps, and
conversely rejects `status` / `reason` / `output_template` on every other
step type so authors who forget `type: terminate` get a clear error
instead of silently dropped fields. Terminate cannot be used as a
parallel-group member or as a `for_each` inline agent — route to one
from those groups' `routes:` instead. The example workflow lives at
`examples/terminate.yaml`
([#219](https://github.com/microsoft/conductor/issues/219)).

## [0.1.17](https://github.com/microsoft/conductor/compare/v0.1.16...v0.1.17) - 2026-05-21

### Added
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Conductor makes multi-agent workflows — code review pipelines, research-then-s
- **Parallel execution** - Run agents concurrently (static groups or dynamic for-each)
- **Sub-workflow composition** - Reusable sub-workflows with templated `input_mapping`, usable inside `for_each` groups for dynamic fan-out
- **Script steps** - Run shell commands and route on exit code or parsed JSON stdout
- **Terminate steps** - Explicit terminal step with `status` (`success`/`failed`) and structured `reason` — distinguishable from the default `$end` path in CLI exit codes, dashboard state, and event logs
- **Dialog mode** - Agents can pause for multi-turn conversation when uncertain
- **Reasoning effort** - Unified `reasoning.effort` (low/medium/high/xhigh) per agent or workflow-wide, translated to each provider's native API
- **Workspace instructions** - Auto-discover and inject `AGENTS.md` / `CLAUDE.md` / `.github/copilot-instructions.md` into every agent's prompt
Expand Down Expand Up @@ -300,6 +301,7 @@ See the [`examples/`](./examples/) directory for complete workflows:
| [parallel-research.yaml](./examples/parallel-research.yaml) | Static parallel execution |
| [design-review.yaml](./examples/design-review.yaml) | Human gate with loop pattern |
| [script-step.yaml](./examples/script-step.yaml) | Script step with exit_code routing |
| [terminate.yaml](./examples/terminate.yaml) | Explicit `type: terminate` with success and failure paths |

**More examples and running instructions:** [examples/README.md](./examples/README.md)

Expand Down
4 changes: 2 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,8 @@ agents:

Per-agent overrides always win over the workflow-wide default. The
`reasoning.effort` field is **only** valid on standard `agent`-type agents; it
is rejected on `script`, `human_gate`, and `workflow` agents (which do not call
a model).
is rejected on `script`, `human_gate`, `workflow`, and `terminate` agents
(none of which call a model).

### Per-provider translation

Expand Down
54 changes: 53 additions & 1 deletion docs/workflow-syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ Agents are defined in the `agents` list. Each agent represents a unit of work.
agents:
- name: string # Required: Unique agent identifier
description: string # Optional: Purpose description
type: agent # agent | human_gate | script | workflow (default: agent)
type: agent # agent | human_gate | script | workflow | terminate (default: agent)
model: string # Optional: Model identifier (e.g., 'claude-sonnet-4.5')

prompt: | # Required for type=agent: Agent instructions
Expand Down Expand Up @@ -371,6 +371,58 @@ parallel:

**Restrictions** — workflow steps cannot have `prompt`, `model`, `provider`, `tools`, `system_prompt`, `command`, or `options`.

### Terminate Steps

Terminate steps end the workflow with an explicit `status` (`success` or `failed`) and a structured `reason`. Reaching a terminate step ends execution immediately — no routes are evaluated after — and produces a CLI exit code, dashboard state, and event payload that downstream tooling can distinguish from a generic crash.

```yaml
agents:
- name: precheck
type: script
command: bash
args: ["-c", "echo '{\"action\":\"abort\",\"reason\":\"unsafe input\"}'"]
output:
action: { type: string }
reason: { type: string }
routes:
- when: "action == 'abort'"
to: abort_unsafe
- when: "action == 'noop'"
to: noop_exit
- to: main_pipeline

# Soft success — workflow ends cleanly, exit 0, dashboard ✅.
- name: noop_exit
type: terminate
status: success
reason: "Document already up to date; no edits needed."

# Hard failure with reason — workflow ends, exit 1, dashboard ❌.
- name: abort_unsafe
type: terminate
status: failed
reason: "{{ precheck.output.reason }}"
output_template: # optional; replaces workflow.output
aborted: "true" # rendered then JSON-coerced to True
stage: precheck
reason: "{{ precheck.output.reason }}"
```

**Behaviour**

| `status` | CLI exit code | Dashboard | Event | Resumable? |
|----------|---------------|-----------|-------|------------|
| `success` | `0` | ✅ | `workflow_completed { termination_reason, terminated_by, is_explicit: true, status: "success" }` | n/a (clean exit) |
| `failed` | `1` | ❌ | `workflow_failed { error_type: "WorkflowTerminated", termination_reason, terminated_by, is_explicit: true, status: "failed", output }` | **No** — explicit terminations skip the on-failure checkpoint |

**Final output** — when `output_template:` is set, it *replaces* the workflow-level `output:` mapping for this termination path. Each rendered value is passed through the same JSON-coercion helper used elsewhere in the engine, so `"true"` becomes `True`, `"42"` becomes `42`, and JSON literals are parsed. When `output_template:` is omitted, the workflow-level `output:` is rendered as on any other terminal path.

**Restrictions** — terminate steps cannot have `routes`, `tools`, `output`, `prompt`, `model`, `provider`, `system_prompt`, `command`, `args`, `env`, `working_dir`, `timeout`, `timeout_seconds`, `max_session_seconds`, `max_agent_iterations`, `max_depth`, `retry`, `dialog`, `reasoning`, `workflow`, `input_mapping`, or `options`. They cannot appear as members of a parallel group or as a `for_each` inline agent — route to them from those groups' `routes:` instead.

**Sub-workflow boundary** — a `status: failed` terminate inside a sub-workflow is downgraded to a `SubworkflowTerminatedError` (subclass of `ExecutionError`) at the parent boundary so the parent treats it as a normal sub-workflow failure (its own `workflow_failed` does NOT inherit `is_explicit: true`). The child's rendered output, reason, and terminate step name are preserved on the wrapper as `terminated_output`, `terminated_reason`, and `terminated_by` for `on_error` hooks and debugging surfaces. A `status: success` terminate inside a sub-workflow returns its rendered output cleanly and the parent continues with its next routes.

See [`examples/terminate.yaml`](../examples/terminate.yaml) for a complete worked example with all three paths.

### Dialog Mode

Dialog mode allows agents to conditionally pause after execution and enter a free-form conversation with the user. An LLM evaluator examines the agent's output against user-defined criteria and decides whether to initiate a dialog.
Expand Down
24 changes: 24 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,30 @@ conductor run examples/design-review.yaml --input requirement="Build a REST API"
conductor run examples/design-review.yaml --input requirement="Build a REST API" --skip-gates
```

## Explicit Termination

### terminate.yaml

A workflow with multiple legitimate end states beyond "the last agent finished," using `type: terminate` steps to surface each outcome distinctly. Demonstrates:

- `status: success` — early-exit when there's nothing to do (CLI exit 0, dashboard ✅)
- `status: failed` — refuse-to-run on unsafe input (CLI exit 1, dashboard ❌, emits `workflow_failed` with `is_explicit: true` and `error_type: WorkflowTerminated`)
- Pass-through normal pipeline for the "do the work" path
- Optional `output_template:` that replaces the workflow-level `output:` for a termination path

```bash
# Success path — soft no-op, exit 0
conductor run examples/terminate.yaml --input document_state=current

# Failure path — hard refusal, exit 1 with structured reason
conductor run examples/terminate.yaml --input document_state=unsafe

# Normal pipeline (no terminate hit)
conductor run examples/terminate.yaml --input document_state=stale
```

CI / dashboard / notification tooling can read `is_explicit: true` from the JSONL event log to distinguish an intentional termination from a generic crash.

## Reasoning Effort

### reasoning-effort.yaml
Expand Down
122 changes: 122 additions & 0 deletions examples/terminate.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Terminate Step Example
#
# This example demonstrates `type: terminate` steps, which end the workflow
# with an explicit status (success/failed) and a structured reason. Reaching
# a terminate step ends the workflow immediately:
#
# - status: success → CLI exit code 0, dashboard ✅,
# emits `workflow_completed` with
# `termination_reason`, `terminated_by`,
# and `is_explicit: true`.
# - status: failed → CLI exit code 1, dashboard ❌,
# emits `workflow_failed` with the same metadata
# plus `error_type: WorkflowTerminated`.
# No checkpoint is saved (explicit termination
# is not resumable).
#
# A terminate step's optional `output_template:` replaces the workflow-level
# `output:` mapping for that termination path. When omitted, the workflow's
# `output:` is rendered as on any normal `$end` path.
#
# Usage:
# conductor run examples/terminate.yaml --input document_state=stale
# conductor run examples/terminate.yaml --input document_state=current
# conductor run examples/terminate.yaml --input document_state=unsafe

workflow:
name: terminate-demo
description: Demonstrates type=terminate with success and failure paths
version: "1.0.0"
entry_point: precheck

input:
document_state:
type: string
description: Simulated state of an upstream document. One of `current`, `stale`, or `unsafe`.
default: stale

runtime:
provider: copilot

limits:
max_iterations: 10

agents:
- name: precheck
type: script
description: Classify the upstream document state and route accordingly
command: bash
args:
- "-c"
- |
state="{{ workflow.input.document_state }}"
case "$state" in
current)
echo '{"action":"noop","reason":"Document already up to date; no edits needed."}'
;;
stale)
echo '{"action":"refresh","reason":"Document is stale; running refresh pipeline."}'
;;
*)
echo '{"action":"abort","reason":"Document state \"'"$state"'\" is not safe to process."}'
;;
esac
output:
action:
type: string
description: One of `noop`, `refresh`, or `abort`.
reason:
type: string
description: Human-readable explanation of the chosen action.
routes:
- when: "action == 'noop'"
to: noop_exit
- when: "action == 'abort'"
to: abort_unsafe
- to: refresh

# Success termination — workflow completes cleanly with status=success.
# The CLI exits 0 and the dashboard shows ✅.
- name: noop_exit
type: terminate
description: No work needed; document already current
status: success
reason: "{{ precheck.output.reason }}"
output_template:
result: "no-op"
reason: "{{ precheck.output.reason }}"

# Failure termination — workflow ends with status=failed. The CLI exits 1
# and the dashboard shows ❌. Downstream tooling (CI, notifications) can
# distinguish this from a generic exception via `is_explicit: true` in the
# `workflow_failed` event.
- name: abort_unsafe
type: terminate
description: Refuse to run downstream steps on unsafe input
status: failed
reason: "{{ precheck.output.reason }}"
output_template:
result: "aborted"
stage: "precheck"
reason: "{{ precheck.output.reason }}"

# Normal path — runs the "real" pipeline and lets the workflow-level
# `output:` mapping render the final result.
- name: refresh
model: claude-haiku-4.5
description: Refresh the stale document (simulated)
prompt: |
The upstream document needs to be refreshed.

Reason: {{ precheck.output.reason }}

Briefly summarize, in one sentence, what a refresh pipeline would do
for a stale document. Reply with JSON: {"summary": "..."}.
output:
summary:
type: string
routes:
- to: $end

output:
result: "{{ refresh.output.summary | default('') }}"
1 change: 1 addition & 0 deletions plugins/conductor/skills/conductor/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ For runtime config, context modes, limits, and cost tracking, see [references/au
| `routes` | Where agent goes next (`$end` to finish, `self` to loop) |
| `type: script` | Shell command step (captures stdout, stderr, exit_code; JSON stdout is auto-merged) |
| `type: workflow` | Sub-workflow agent — runs another YAML file as a black box (supports `input_mapping`, `max_depth`) |
| `type: terminate` | Explicit terminal step with `status` (`success`/`failed`), Jinja `reason`, optional `output_template` — controls CLI exit code, dashboard state, and emits `is_explicit: true` in `workflow_completed`/`workflow_failed` |
| `parallel` | Static parallel groups (fixed agent list) |
| `for_each` | Dynamic parallel groups (runtime-determined array; supports `type: workflow` agents) |
| `human_gate` | Pauses for user decision with options (Markdown + auto-linkified paths/URLs) |
Expand Down
Loading
Loading