Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
5d1bdf7
fix: add random suffix to log filenames to prevent collision
Apr 16, 2026
513fdfe
fix(gates): race web task unconditionally when web dashboard exists
Apr 17, 2026
0510008
fix(gates): race web task unconditionally when web dashboard exists
Apr 17, 2026
9ece7a6
fix(web): stop busy-looping on stale gate_response messages
Apr 17, 2026
6f91d01
fix(web): stop busy-looping on stale gate_response messages
Apr 17, 2026
d68f2ea
test(web): cover stale gate_response discard path
Apr 17, 2026
c588c17
test(web): cover stale gate_response discard path
Apr 17, 2026
6f9d8d9
style: ruff format test_server.py
Apr 17, 2026
834afe0
style: ruff format test_server.py
Apr 17, 2026
4fb7998
feat(composition): dynamic sub-workflow inputs via input_mapping (#101)
Apr 20, 2026
793e55a
feat(composition): allow sub-workflows in for_each groups (#102)
Apr 20, 2026
0e3e6a0
feat(composition): allow self-referential sub-workflows with depth tr…
Apr 20, 2026
1353d89
feat(config): add optional metadata dict to workflow definition
Apr 21, 2026
7f6bc16
feat(cli): add --metadata flag for runtime metadata injection
Apr 21, 2026
22a82bd
test: add metadata schema and loader tests
Apr 21, 2026
ebdcb8a
fix: wrap long help string to satisfy E501 line-length lint
Apr 21, 2026
81e5720
style: ruff format workflow.py
Apr 21, 2026
c96a05f
style: ruff format workflow.py
Apr 21, 2026
637b34a
Merge branch 'feat/input-mapping' into validation/composition-features
Apr 21, 2026
ce6ede1
Merge branch 'feat/for-each-workflows' into validation/composition-fe…
Apr 21, 2026
4f02351
Merge branch 'feat/self-referential-workflows' into validation/compos…
Apr 21, 2026
0d45c4a
feat: add run_id and log_file for deterministic run linking
Apr 22, 2026
7768dc4
fix(composition): forward parent agent outputs to sub-workflow context
Apr 22, 2026
9bb21dd
feat: breadcrumb navigation and depth-isolated subworkflow rendering
Apr 22, 2026
b8850b3
feat: subworkflow node visual, detail panel, and context-aware rendering
Apr 22, 2026
8c0974b
Merge remote-tracking branch 'fork/feat/web-breadcrumb-navigation' in…
Apr 22, 2026
bc9ce81
build: rebuild frontend assets with breadcrumb navigation
Apr 22, 2026
aec33e3
fix: replace getViewedContext() selectors with stable hooks
Apr 22, 2026
9578650
Merge branch 'feat/web-breadcrumb-navigation' into install-combined
Apr 22, 2026
593a052
fix(composition): forward parent agent outputs to sub-workflow context
Apr 22, 2026
5bf485e
Merge branch 'validation/composition-features' into install-combined
Apr 22, 2026
4f2fcf7
Merge branch 'fix/event-log-filename-collision' into install-combined
Apr 22, 2026
224242a
Merge branch 'fix/gate-response-queue-spin' into install-combined
Apr 22, 2026
42a599a
Merge branch 'feat/input-mapping' into install-combined
Apr 22, 2026
4d26773
Merge branch 'fix/gate-race-start-web-task-unconditionally' into inst…
Apr 22, 2026
9e9263f
fix: Stop button now reliably stops subworkflows
Apr 22, 2026
f4c7962
Merge branch 'feat/web-breadcrumb-navigation' into install-combined
Apr 22, 2026
44ac601
feat: enrich human gates with Markdown rendering and file links
Apr 23, 2026
ea16988
Merge pull request #1 from PolyphonyRequiem/feature/human-gate-enrich…
PolyphonyRequiem Apr 23, 2026
23e9b68
fix: guard against proactor accept-loop race on Windows (Python 3.14+)
Apr 24, 2026
7e3b940
Merge pull request #2 from PolyphonyRequiem/fix/proactor-shutdown-race
PolyphonyRequiem Apr 24, 2026
45189bc
fix: remove unused Request import to pass ruff lint
Apr 24, 2026
3d96869
fix(web): clear stale graph edges when navigating between workflow la…
Apr 24, 2026
3285c27
feat(gates): auto-linkify bare file paths and URLs in gate prompts
Apr 24, 2026
9754de3
feat: add system metadata to workflow_started event and checkpoints
Apr 24, 2026
2f85884
feat: add visual workflow designer (conductor designer)
Apr 24, 2026
1335541
fix: deduplicate edge IDs in designer graph derivation
Apr 24, 2026
7f02363
fix(engine): workflow.input in explicit mode + subworkflow_with_input…
Apr 25, 2026
a8dcb27
feat(engine): add workflow.dir, workflow.file, workflow.name template…
Apr 25, 2026
1af7149
feat(engine): auto-parse script agent JSON stdout into output fields
Apr 25, 2026
3f96419
fix(engine): use type-appropriate zero values for optional input defa…
Apr 25, 2026
cd8d0d7
feat(validate): catch template reference errors before runtime
Apr 25, 2026
fcdf24e
feat(validate): warn on undeclared workflow.input refs in explicit mode
Apr 25, 2026
ad51865
fix(engine): support 2-part workflow.input in explicit context mode
Apr 25, 2026
e93ab99
Merge branch 'install-combined'
Apr 25, 2026
bd1c7e1
feat(web): add URL query param deep-linking for agent and subworkflow…
Apr 26, 2026
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
5 changes: 3 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ dist/
downloads/
eggs/
.eggs/
lib/
lib64/
/lib/
/lib64/
parts/
sdist/
var/
Expand Down Expand Up @@ -80,3 +80,4 @@ Thumbs.db

# Frontend
src/conductor/web/frontend/node_modules/
src/conductor/designer/frontend/node_modules/
78 changes: 78 additions & 0 deletions src/conductor/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,17 @@ def run(
help="Workflow inputs in name=value format. Can be repeated.",
),
] = None,
raw_metadata: Annotated[
list[str] | None,
typer.Option(
"--metadata",
"-m",
help=(
"Workflow metadata in key=value format. "
"Merged on top of YAML metadata. Can be repeated."
),
),
] = None,
dry_run: Annotated[
bool,
typer.Option(
Expand Down Expand Up @@ -300,12 +311,14 @@ def run(

Execute a multi-agent workflow defined in the specified YAML file.
Workflow inputs can be provided using --input flags.
Metadata can be provided using --metadata flags (merged on top of YAML metadata).

\b
Examples:
conductor run workflow.yaml
conductor run workflow.yaml --input question="What is Python?"
conductor run workflow.yaml -i question="Hello" -i context="Programming"
conductor run workflow.yaml --metadata tracker=ado -m work_item_id=1814
conductor run workflow.yaml --provider copilot
conductor run workflow.yaml --dry-run
conductor run workflow.yaml --skip-gates
Expand Down Expand Up @@ -377,6 +390,11 @@ def run(
# Also parse --input.name=value style from sys.argv
inputs.update(InputCollector.extract_from_args())

# Parse --metadata key=value flags (separate from inputs)
cli_metadata: dict[str, str] = {}
if raw_metadata:
cli_metadata.update(parse_input_flags(raw_metadata))

# Resolve log file path
resolved_log_file: Path | None = None
if log_file is not None:
Expand All @@ -398,6 +416,7 @@ def run(
log_file=resolved_log_file,
no_interactive=True, # Always non-interactive in background
web_port=web_port,
metadata=cli_metadata,
)
console.print(f"[bold cyan]Dashboard:[/bold cyan] {url}")
console.print(
Expand All @@ -422,6 +441,7 @@ def run(
web=web,
web_port=web_port,
web_bg=web_bg,
metadata=cli_metadata,
)
)

Expand Down Expand Up @@ -1045,3 +1065,61 @@ def update() -> None:
except Exception as e:
print_error(e)
raise typer.Exit(code=1) from None


@app.command()
def designer(
workflow: Annotated[
str | None,
typer.Argument(
help="Optional workflow YAML file to open for editing.",
),
] = None,
port: Annotated[
int,
typer.Option(
"--port",
help="Port to run the designer server on (0 = auto-select).",
),
] = 0,
no_open: Annotated[
bool,
typer.Option(
"--no-open",
help="Don't auto-open the browser.",
),
] = False,
host: Annotated[
str,
typer.Option(
"--host",
help="Host to bind the server to.",
),
] = "127.0.0.1",
) -> None:
"""Launch the visual workflow designer.

Opens a web-based drag-and-drop editor for creating and editing
conductor workflow YAML files.

\b
Examples:
conductor designer # Create new workflow
conductor designer workflow.yaml # Edit existing file
conductor designer --port 3000 # Use specific port
conductor designer workflow.yaml --no-open # Don't open browser
"""
from conductor.designer.cli import run_designer

try:
run_designer(
workflow_path=workflow,
host=host,
port=port,
no_open=no_open,
)
except KeyboardInterrupt:
console.print("\n[dim]Designer stopped.[/dim]")
except Exception as e:
print_error(e)
raise typer.Exit(code=1) from None
7 changes: 7 additions & 0 deletions src/conductor/cli/bg_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ def launch_background(
log_file: Path | None = None,
no_interactive: bool = True,
web_port: int = 0,
metadata: dict[str, str] | None = None,
) -> str:
"""Fork a detached child process running the workflow with a web dashboard.
Expand All @@ -77,6 +78,7 @@ def launch_background(
log_file: Optional log file path.
no_interactive: Whether to disable interactive mode (always True for bg).
web_port: Desired port (0 = auto-select).
metadata: Optional CLI metadata key=value pairs.
Returns:
The dashboard URL (e.g. ``http://127.0.0.1:8080``).
Expand Down Expand Up @@ -107,6 +109,11 @@ def launch_background(
for key, value in inputs.items():
cmd.extend(["--input", f"{key}={_serialize_value(value)}"])

# Forward metadata
if metadata:
for key, value in metadata.items():
cmd.extend(["--metadata", f"{key}={value}"])

if provider_override:
cmd.extend(["--provider", provider_override])

Expand Down
12 changes: 11 additions & 1 deletion src/conductor/cli/pid.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,21 @@ def pid_dir() -> Path:
return d


def write_pid_file(pid: int, port: int, workflow_path: str | Path) -> Path:
def write_pid_file(
pid: int,
port: int,
workflow_path: str | Path,
run_id: str = "",
log_file: str = "",
) -> Path:
"""Write a PID file for a background workflow process.

Args:
pid: Process ID of the background child.
port: TCP port the web dashboard is listening on.
workflow_path: Path to the workflow YAML file.
run_id: Unique run identifier (from event log subscriber).
log_file: Path to the JSONL event log file.

Returns:
Path to the created PID file.
Expand All @@ -58,6 +66,8 @@ def write_pid_file(pid: int, port: int, workflow_path: str | Path) -> Path:
"port": port,
"workflow": str(workflow_path),
"started_at": datetime.now(UTC).isoformat(),
"run_id": run_id,
"log_file": log_file,
}

filepath.write_text(json.dumps(data, indent=2))
Expand Down
18 changes: 17 additions & 1 deletion src/conductor/cli/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -990,6 +990,7 @@ async def run_workflow_async(
web: bool = False,
web_port: int = 0,
web_bg: bool = False,
metadata: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Execute a workflow asynchronously.

Expand All @@ -1003,6 +1004,7 @@ async def run_workflow_async(
web: If True, start a real-time web dashboard.
web_port: Port for the web dashboard (0 = auto-select).
web_bg: If True, auto-shutdown dashboard after workflow + client disconnect.
metadata: Optional CLI metadata to merge on top of YAML-declared metadata.

Returns:
The workflow output as a dictionary.
Expand Down Expand Up @@ -1032,7 +1034,13 @@ async def run_workflow_async(
from conductor.web.server import WebDashboard

bg_mode = web_bg or os.environ.get("CONDUCTOR_WEB_BG") == "1"
dashboard = WebDashboard(emitter, host="127.0.0.1", port=web_port, bg=bg_mode)
dashboard = WebDashboard(
emitter,
host="127.0.0.1",
port=web_port,
bg=bg_mode,
workflow_root=Path(workflow_path).resolve().parent,
)

try:
await dashboard.start()
Expand All @@ -1054,6 +1062,10 @@ async def run_workflow_async(
config = load_config(workflow_path)
verbose_log_timing("Configuration loaded", time.time() - load_start)

# Merge CLI metadata on top of YAML-declared metadata
if metadata:
config.workflow.metadata.update(metadata)

# Log workflow details
verbose_log(f"Workflow: {config.workflow.name}")
verbose_log(f"Entry point: {config.workflow.entry_point}")
Expand Down Expand Up @@ -1116,6 +1128,10 @@ async def run_workflow_async(
event_emitter=emitter,
keyboard_listener=listener,
web_dashboard=dashboard,
run_id=event_log_subscriber.run_id if event_log_subscriber else "",
log_file=str(event_log_subscriber.path) if event_log_subscriber else "",
dashboard_port=(dashboard._actual_port if dashboard is not None else None),
bg_mode=web_bg or os.environ.get("CONDUCTOR_WEB_BG") == "1",
)

# Share interrupt_event with dashboard so POST /api/stop can abort agents
Expand Down
15 changes: 14 additions & 1 deletion src/conductor/cli/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ def validate_workflow(

try:
config = load_config(workflow_path)
return True, config
except ConductorError as e:
# Display structured error
display_validation_error(e, workflow_path, output_console)
Expand All @@ -56,6 +55,20 @@ def validate_workflow(
)
return False, None

# Semantic validation: cross-field references, template refs, etc.
try:
from conductor.config.validator import validate_workflow_config

warnings = validate_workflow_config(config, workflow_path=workflow_path)
if warnings:
for warning in warnings:
output_console.print(f" [yellow]⚠[/yellow] {warning}")
except ConductorError as e:
display_validation_error(e, workflow_path, output_console)
return False, None

return True, config


def display_validation_error(
error: ConductorError,
Expand Down
57 changes: 57 additions & 0 deletions src/conductor/config/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,36 @@ class AgentDef(BaseModel):
workflow: ./research-pipeline.yaml
"""

input_mapping: dict[str, str] | None = None
"""Optional mapping of sub-workflow input names to Jinja2 expressions.

Each key is a sub-workflow input parameter name. Each value is a Jinja2
template expression evaluated against the parent workflow's context.

When present, the rendered values are passed as the sub-workflow's inputs
instead of forwarding the parent's workflow.input.* values.

Only valid for type='workflow' agents.

Example::

input_mapping:
work_item_id: "{{ task_manager.output.current_issue_id }}"
title: "{{ task_manager.output.current_issue_title }}"
"""

max_depth: int | None = Field(None, ge=1, le=10)
"""Per-agent sub-workflow depth limit.

Overrides the global MAX_SUBWORKFLOW_DEPTH (10) with a tighter bound.
Only valid for type='workflow' agents. Useful for self-referential
workflows to set an explicit recursion limit.

Example::

max_depth: 3 # Allow at most 3 levels of recursion
"""

max_session_seconds: float | None = Field(None, ge=1.0)
"""Maximum wall-clock duration for this agent's session in seconds.

Expand Down Expand Up @@ -529,6 +559,10 @@ def validate_agent_type(self) -> AgentDef:
raise ValueError("human_gate agents require 'options'")
if not self.prompt:
raise ValueError("human_gate agents require 'prompt'")
if self.input_mapping:
raise ValueError("human_gate agents cannot have 'input_mapping'")
if self.max_depth is not None:
raise ValueError("human_gate agents cannot have 'max_depth'")
elif self.type == "script":
if not self.command:
raise ValueError("script agents require 'command'")
Expand All @@ -555,6 +589,10 @@ def validate_agent_type(self) -> AgentDef:
raise ValueError("script agents cannot have 'max_agent_iterations'")
if self.retry is not None:
raise ValueError("script agents cannot have 'retry'")
if self.input_mapping:
raise ValueError("script agents cannot have 'input_mapping'")
if self.max_depth is not None:
raise ValueError("script agents cannot have 'max_depth'")
elif self.type == "workflow":
if not self.workflow:
raise ValueError("workflow agents require 'workflow' path")
Expand All @@ -578,6 +616,18 @@ def validate_agent_type(self) -> AgentDef:
raise ValueError("workflow agents cannot have 'max_agent_iterations'")
if self.retry is not None:
raise ValueError("workflow agents cannot have 'retry'")
else:
# Regular agent or human_gate — input_mapping is not valid
if self.input_mapping:
raise ValueError(
f"'{self.type or 'agent'}' agents cannot have 'input_mapping' "
"(only workflow agents support input_mapping)"
)
if self.max_depth is not None:
raise ValueError(
f"'{self.type or 'agent'}' agents cannot have 'max_depth' "
"(only workflow agents support max_depth)"
)
return self


Expand Down Expand Up @@ -738,6 +788,13 @@ class WorkflowDef(BaseModel):
hooks: HooksConfig | None = None
"""Lifecycle event hooks."""

metadata: dict[str, Any] = Field(default_factory=dict)
"""Arbitrary key-value metadata for external tooling (dashboards, trackers, etc.).

Included verbatim in the ``workflow_started`` event so downstream
consumers can use it for enrichment without parsing the YAML source.
"""


class WorkflowConfig(BaseModel):
"""Complete workflow configuration file."""
Expand Down
Loading
Loading