Skip to content
Draft
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
36 changes: 36 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,42 @@ conductor run examples/reasoning-effort.yaml \

See [Reasoning Effort](../docs/configuration.md#reasoning-effort) for the per-provider translation, supported models, and validation rules.

## Error Routing

### error-routing.yaml

A script-driven probe that emits a typed Conductor error envelope via
the language-neutral `CONDUCTOR_ERROR_OUT` contract, then routes by
`on_error: <kind>` instead of a fragile `exit_code` check. Demonstrates:

- Writing `{conductor_error: true, kind, message, details}` from a
script and exiting 0
- Declaring `raises:` on the node so undeclared kinds are normalised
to `internal.undeclared_kind` instead of leaking through
- Routing by `on_error: <kind>` (`external.git.drift` →
rescue agent, `external.api.rate_limited` → backoff agent)
- Reading `{{ probe.error.kind }}`, `{{ probe.error.message }}`,
`{{ probe.error.details.* }}` from the routed-to handler

```bash
conductor run examples/error-routing.yaml --input simulated_failure=drift
conductor run examples/error-routing.yaml --input simulated_failure=rate_limited
conductor run examples/error-routing.yaml --input simulated_failure=ok
```

### error-routing-helpers.yaml

Companion example showing the same routing flow but raising the
envelope via the shipped Python helper
(`conductor.helpers.error.conductor_error.raise_kind`). PowerShell,
Bash, Node, and .NET helpers are all available under
[`src/conductor/helpers/error/`](../src/conductor/helpers/error/) and
follow the same shape.

```bash
conductor run examples/error-routing-helpers.yaml
```

## Multi-Agent Workflows

### research-assistant.yaml
Expand Down
83 changes: 83 additions & 0 deletions examples/error-routing-helpers.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Error Routing with Helpers
#
# Companion to `error-routing.yaml`. Demonstrates the same `on_error`
# flow but uses the engine-agnostic helper modules shipped under
# `src/conductor/helpers/error/` instead of hand-writing the envelope.
#
# Each helper is ~15 lines of language-native code that:
#
# 1. Reads `$CONDUCTOR_ERROR_OUT` from the environment.
# 2. Writes `{conductor_error: true, kind, message, details?}` to it.
# 3. Returns — leaving exit-code management to the caller.
#
# Nothing here is auto-loaded. Scripts must explicitly source / import
# the helper they want (PowerShell `Import-Module`, Bash `source`,
# Python `import`, Node `import`, .NET `using`). Scripts that don't
# want a helper write the JSON themselves — it's three lines.
#
# This example uses the Python and PowerShell helpers because they're
# the two engines most commonly invoked from conductor on developer
# machines. Bash, Node, and .NET equivalents follow the same shape.
#
# Usage:
# conductor run examples/error-routing-helpers.yaml

workflow:
name: error-routing-helpers-demo
description: |
Same typed-error routing as error-routing.yaml, but raising via
the per-engine helpers shipped under conductor.helpers.error.
entry_point: probe_python

runtime:
provider: copilot

context:
mode: accumulate

limits:
max_iterations: 20

agents:
- name: probe_python
type: script
command: python
args:
- -c
- |
import sys
# The helper is shipped with the conductor wheel and is
# importable when conductor is installed. Adjust the import
# path if you're running from a source checkout.
from conductor.helpers.error import conductor_error

conductor_error.raise_kind(
"external.git.fetch_failed",
"git fetch origin returned 128",
details={"remote": "origin", "exit": 128},
)
sys.exit(0)
raises:
- external.git.fetch_failed
routes:
- to: rescue
on_error: external.git.fetch_failed
- to: $end

- name: rescue
model: gpt-4o-mini
prompt: |
A git fetch failure was raised via the Python helper.
kind: {{ probe_python.error.kind }}
message: {{ probe_python.error.message }}
remote: {{ probe_python.error.details.remote }}
exit: {{ probe_python.error.details.exit }}
Suggest a single recovery step.
routes:
- to: $end
output:
next_step:
type: string

output:
next_step: "{{ rescue.output.next_step }}"
139 changes: 139 additions & 0 deletions examples/error-routing.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# Error Routing Workflow
#
# This example demonstrates Phase 1 of typed `on_error` routing
# (issue #227). It shows how a `type: script` node can write a typed
# error envelope to $CONDUCTOR_ERROR_OUT and have the engine pick a
# matching route by `on_error: <kind>` instead of falling through to a
# generic exit_code branch.
#
# The shape of the contract is intentionally simple:
#
# 1. Conductor sets CONDUCTOR_ERROR_OUT to a per-invocation temp file
# path in the script's environment.
# 2. The script writes
# `{"conductor_error": true, "kind": "<dotted>", "message": "..."}`
# to that path and exits 0.
# 3. Conductor reads the file, treats the node as raised, and
# evaluates routes by matching `on_error` against the envelope's
# `kind`. The first matching route wins (same rule as `when:`).
#
# A node that declares `raises:` is opting in to typed errors — at
# runtime, any kind not in the declared list is rewritten to
# `internal.undeclared_kind` (with the original kind preserved under
# `details.original_kind`) so authors who care about the contract get
# loud feedback when a script "lies".
#
# Usage:
# conductor run examples/error-routing.yaml
#
# Try toggling the `simulated_failure` workflow input between
# "drift", "rate_limited", and "ok" to see different route arms fire.

workflow:
name: error-routing-demo
description: |
Demonstrates typed on_error routing: a probe step either succeeds
or writes a typed envelope, and downstream agents react based on
the kind without using fragile exit_code checks.
entry_point: probe

input:
simulated_failure:
type: string
description: One of "ok", "drift", "rate_limited"
default: drift

runtime:
provider: copilot

context:
mode: accumulate

limits:
max_iterations: 20

agents:
- name: probe
type: script
command: python
args:
- -c
- |
import json, os, sys
mode = os.environ.get("MODE", "ok")
if mode == "ok":
print(json.dumps({"sha": "abc123"}))
sys.exit(0)
envelope = {
"ok": {},
"drift": {
"conductor_error": True,
"kind": "external.git.drift",
"message": "remote SHA does not match expected",
"details": {"expected": "abc123", "actual": "def456"},
},
"rate_limited": {
"conductor_error": True,
"kind": "external.api.rate_limited",
"message": "GitHub returned 429",
"details": {"retry_after_s": 30},
},
}[mode]
with open(os.environ["CONDUCTOR_ERROR_OUT"], "w") as f:
json.dump(envelope, f)
sys.exit(0)
env:
MODE: "{{ workflow.input.simulated_failure }}"
raises:
- external.git.drift
- external.api.rate_limited
routes:
- to: rescue_drift
on_error: external.git.drift
- to: backoff_and_retry
on_error: external.api.rate_limited
- to: continue_happy
output:
sha:
type: string

- name: continue_happy
model: gpt-4o-mini
prompt: |
The git probe reported sha={{ probe.output.sha }}. Acknowledge.
routes:
- to: $end
output:
acknowledgment:
type: string

- name: rescue_drift
model: gpt-4o-mini
prompt: |
A git drift was detected.
kind: {{ probe.error.kind }}
message: {{ probe.error.message }}
details: {{ probe.error.details }}
Recommend a recovery plan in two sentences.
routes:
- to: $end
output:
recovery_plan:
type: string

- name: backoff_and_retry
model: gpt-4o-mini
prompt: |
A rate-limit hit was detected.
retry_after_s: {{ probe.error.details.retry_after_s }}
Describe a sensible backoff strategy in one sentence.
routes:
- to: $end
output:
strategy:
type: string

output:
acknowledgment: "{{ continue_happy.output.acknowledgment | default('') }}"
recovery_plan: "{{ rescue_drift.output.recovery_plan | default('') }}"
strategy: "{{ backoff_and_retry.output.strategy | default('') }}"
56 changes: 56 additions & 0 deletions src/conductor/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,52 @@ def print_error(error: Exception) -> None:
console.print(panel)


def print_unhandled_workflow_error(error: Any) -> None:
"""Print an :class:`UnhandledWorkflowError` summary to stderr.

Renders the typed halt distinctly from generic failures so operators
and CI tooling can recognise it at a glance: shows the failing leaf
node, the kind, the message, and the path to the ``errors.jsonl``
artefact (read off the engine attribute set in
:meth:`WorkflowEngine._execute_loop`'s ``UnhandledWorkflowError``
arm) when available.
"""
envelope = error.envelope or {}
frames = error.frames or []
node = frames[0].get("node", "<unknown>") if frames else "<unknown>"
kind = envelope.get("kind", "<unknown>")
message = envelope.get("message", "")

content = Text()
content.append("Node: ", style="bold")
content.append(f"{node}\n", style="cyan")
content.append("Kind: ", style="bold")
content.append(f"{kind}\n", style="yellow")
content.append("Message: ", style="bold")
content.append(f"{message}\n", style="white")

details = envelope.get("details")
if details:
import json as _json

content.append("Details: ", style="bold")
content.append(_json.dumps(details, default=str), style="dim")
content.append("\n")

errors_path = getattr(error, "errors_jsonl_path", None)
if errors_path:
content.append("Halt log: ", style="bold")
content.append(f"{errors_path}\n", style="dim")

panel = Panel(
content,
title="[bold red]❌ Workflow halted: unhandled typed error[/bold red]",
border_style="red",
padding=(1, 2),
)
console.print(panel)


def version_callback(value: bool) -> None:
"""Display version information and exit."""
if value:
Expand Down Expand Up @@ -469,6 +515,11 @@ def run(
output_console.print_json(json.dumps(result))

except Exception as e:
from conductor.exceptions import UnhandledWorkflowError

if isinstance(e, UnhandledWorkflowError):
print_unhandled_workflow_error(e)
raise typer.Exit(code=3) from None
print_error(e)
raise typer.Exit(code=1) from None

Expand Down Expand Up @@ -877,6 +928,11 @@ def resume(
output_console.print_json(json.dumps(result))

except Exception as e:
from conductor.exceptions import UnhandledWorkflowError

if isinstance(e, UnhandledWorkflowError):
print_unhandled_workflow_error(e)
raise typer.Exit(code=3) from None
print_error(e)
raise typer.Exit(code=1) from None

Expand Down
Loading
Loading