Skip to content
Merged
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
7 changes: 5 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,6 @@ Thumbs.db

# Frontend
src/conductor/web/frontend/node_modules/
src/conductor/designer/frontend/node_modules/

.playwright-mcp/
36 changes: 36 additions & 0 deletions docs/workflow-syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,42 @@ agents:
when: "{{ approval_gate.choice == 'reject' }}"
```

#### Markdown in Gate Prompts

Gate prompts support full **Markdown formatting**. In the terminal, prompts are rendered with Rich Markdown (headings, bold, lists, code blocks). In the web dashboard, prompts render as styled HTML with interactive features:

- **Headings, bold, lists, code blocks** — all standard Markdown syntax is rendered
- **Tables** — GitHub Flavored Markdown (GFM) pipe tables are supported
- **File links** — relative file paths in the prompt (e.g., `./src/plan.md`) are auto-detected and rendered as clickable links that open in VS Code
- **URLs** — bare `http://` and `https://` URLs are auto-linked

```yaml
agents:
- name: review_gate
type: human_gate
description: "Review the generated plan"
prompt: |
## Review Required

The planner produced the following artifacts:

| File | Purpose |
|------|---------|
| ./output/plan.md | Implementation plan |
| ./output/timeline.md | Delivery timeline |

Please review the files above and choose how to proceed.
See also: https://wiki.example.com/review-guidelines

options:
- name: approve
description: "Looks good — proceed"
- name: revise
description: "Needs changes"
```

The auto-linkify processor is Markdown-aware: it skips fenced code blocks, inline code spans, and existing markdown links. File paths are validated against the workflow root directory (path traversal is blocked).

### Script Steps

Script steps run shell commands as workflow steps, capturing stdout, stderr, and exit code. Use them to integrate shell scripts, run tests, or invoke external tools without an AI agent.
Expand Down
8 changes: 7 additions & 1 deletion src/conductor/cli/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -1069,7 +1069,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 Down
20 changes: 17 additions & 3 deletions src/conductor/engine/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
TimeoutError as ConductorTimeoutError,
)
from conductor.executor.agent import AgentExecutor
from conductor.executor.linkify import linkify_markdown
from conductor.executor.script import ScriptExecutor, ScriptOutput
from conductor.executor.template import TemplateRenderer
from conductor.gates.human import (
Expand Down Expand Up @@ -407,6 +408,11 @@ def __init__(
# without inferring parentage from activeContextPath.
self._dashboard_context_path: list[str] = list(_dashboard_context_path or [])

@property
def _workflow_dir(self) -> Path | None:
"""Resolved parent directory of the workflow file, or None if unset."""
return Path(self.workflow_path).resolve().parent if self.workflow_path else None

def _build_pricing_overrides(self) -> dict[str, ModelPricing] | None:
"""Build pricing overrides from workflow cost configuration.

Expand Down Expand Up @@ -1053,7 +1059,9 @@ async def _handle_gate_with_web(
"""
# If no web dashboard at all, use CLI only.
if self._web_dashboard is None:
return await self.gate_handler.handle_gate(agent, agent_context)
return await self.gate_handler.handle_gate(
agent, agent_context, base_dir=self._workflow_dir
)

# Race CLI vs web input. We start the web task unconditionally (not only
# when a client is currently connected), because the human often opens
Expand All @@ -1062,7 +1070,7 @@ async def _handle_gate_with_web(
# in the dashboard pushes a message to ``_gate_response_queue`` that
# nobody is awaiting, and the workflow hangs forever.
cli_task = asyncio.create_task(
self.gate_handler.handle_gate(agent, agent_context),
self.gate_handler.handle_gate(agent, agent_context, base_dir=self._workflow_dir),
name="gate_cli",
)
web_task = asyncio.create_task(
Expand Down Expand Up @@ -1778,13 +1786,19 @@ async def _execute_loop(self, current_agent_name: str) -> dict[str, Any]:
for o in (agent.options or [])
]

# Render prompt and auto-linkify paths/URLs for markdown display
rendered_prompt = self.renderer.render(agent.prompt, agent_context)
rendered_prompt = linkify_markdown(
rendered_prompt, base_dir=self._workflow_dir
)

self._emit(
"gate_presented",
{
"agent_name": agent.name,
"options": [o.value for o in (agent.options or [])],
"option_details": gate_options_data,
"prompt": self.renderer.render(agent.prompt, agent_context),
"prompt": rendered_prompt,
},
)

Expand Down
20 changes: 5 additions & 15 deletions src/conductor/executor/linkify.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,12 @@
".sh",
".bat",
".ps1",
".plan.md",
}
)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important — extension allowlist already drifting between modules.

This LINKABLE_EXTENSIONS (21 entries, includes .plan.md) and web/server.py's _FILE_ALLOWED_EXTENSIONS (20 entries, missing .plan.md) are intended to stay in sync — the docstring above even says so. They've already drifted on this very PR.

Suggest: have server.py import this constant directly, e.g. from conductor.executor.linkify import LINKABLE_EXTENSIONS.

Side note: the .plan.md entry here is dead anyway — Path.suffix returns only .md for foo.plan.md, so it's already covered by the .md entry in server.py. Consolidating to one source of truth makes this kind of confusion go away.


# Pre-computed tuple for fast str.endswith() checks (no Python-level loop).
_LINKABLE_SUFFIXES = tuple(LINKABLE_EXTENSIONS)

# ---------------------------------------------------------------------------
# Regex patterns
# ---------------------------------------------------------------------------
Expand All @@ -64,16 +66,6 @@
r"https?://[^\s)<>\]\[\"'`]+"
)

# Bare file path: contains at least one /, ends with a known extension.
# Must start at a word boundary or line start. Avoids matching inside
# URLs (already handled) by requiring no scheme prefix.
_FILE_PATH_RE = re.compile(
r"(?<![a-zA-Z0-9_/\\])" # not preceded by path-like chars (avoids partial matches)
r"(?!https?://)" # not a URL
r"(?:[a-zA-Z0-9_.][a-zA-Z0-9_./-]*[a-zA-Z0-9_]" # path chars with at least one /
r")"
)

# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -255,11 +247,9 @@ def _try_linkify_path(token: str, base_dir: Path | None) -> str | None:
return None

# Build markdown link with forward slashes (for dashboard API)
link_target = normalized
return f"{prefix}[{stripped}]({link_target}){suffix}"
return f"{prefix}[{stripped}]({normalized}){suffix}"


def _has_linkable_extension(path: str) -> bool:
"""Check if a path ends with a known linkable extension."""
lower = path.lower()
return any(lower.endswith(ext) for ext in LINKABLE_EXTENSIONS)
return path.lower().endswith(_LINKABLE_SUFFIXES)
14 changes: 11 additions & 3 deletions src/conductor/gates/human.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,17 @@
from typing import TYPE_CHECKING, Any

from rich.console import Console
from rich.markdown import Markdown as RichMarkdown
from rich.panel import Panel
from rich.prompt import IntPrompt, Prompt

from conductor.exceptions import HumanGateError
from conductor.executor.linkify import linkify_markdown
from conductor.executor.template import TemplateRenderer

if TYPE_CHECKING:
from pathlib import Path

from conductor.config.schema import AgentDef, GateOption


Expand Down Expand Up @@ -72,6 +76,7 @@ async def handle_gate(
self,
agent: AgentDef,
context: dict[str, Any],
base_dir: Path | None = None,
) -> GateResult:
"""Handle a human gate interaction.

Expand All @@ -81,6 +86,8 @@ async def handle_gate(
Args:
agent: The human_gate agent definition.
context: Current workflow context for template rendering.
base_dir: Optional directory for resolving relative file paths
in the rendered prompt into clickable markdown links.

Returns:
GateResult with selected option, route, and any additional input.
Expand All @@ -94,8 +101,9 @@ async def handle_gate(
suggestion="Add 'options' list to the human_gate agent",
)

# Render the prompt with context
# Render the prompt with context and auto-linkify paths/URLs
prompt_text = self.renderer.render(agent.prompt, context)
prompt_text = linkify_markdown(prompt_text, base_dir=base_dir)

# If skip_gates is enabled, auto-select first option
if self.skip_gates:
Expand Down Expand Up @@ -131,11 +139,11 @@ async def _display_and_select(
Returns:
The selected GateOption.
"""
# Display the prompt in a styled panel
# Display the prompt in a styled panel (render as Markdown for rich formatting)
self.console.print()
self.console.print(
Panel(
prompt_text,
RichMarkdown(prompt_text),
title="[bold cyan]Decision Required[/bold cyan]",
border_style="cyan",
)
Expand Down
7 changes: 0 additions & 7 deletions src/conductor/web/frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading