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
15 changes: 15 additions & 0 deletions src/conductor/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,19 @@ def run(
help="Path to instruction file(s) to prepend to all agent prompts. Can be repeated.",
),
] = None,
print_loaded_instructions: Annotated[
bool,
typer.Option(
"--print-loaded-instructions",
help=(
"Print the resolved list of workspace instruction files (with "
"their scope and reason for inclusion) to stderr before running "
"the workflow. Useful for debugging why an instruction file is "
"or isn't being picked up by --workspace-instructions. Has no "
"effect unless --workspace-instructions is also set."
),
),
] = False,
) -> None:
"""Run a workflow from a YAML file.

Expand Down Expand Up @@ -477,6 +490,7 @@ def run(
metadata=cli_metadata,
workspace_instructions=workspace_instructions,
cli_instructions=raw_instructions,
print_loaded_instructions=print_loaded_instructions,
)
if is_verbose():
console.print(f"[bold cyan]Dashboard:[/bold cyan] {launch.url}")
Expand Down Expand Up @@ -506,6 +520,7 @@ def run(
metadata=cli_metadata,
workspace_instructions=workspace_instructions,
cli_instructions=raw_instructions,
print_loaded_instructions=print_loaded_instructions,
)
)

Expand Down
7 changes: 7 additions & 0 deletions src/conductor/cli/bg_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,7 @@ def launch_background(
metadata: dict[str, str] | None = None,
workspace_instructions: bool = False,
cli_instructions: list[str] | None = None,
print_loaded_instructions: bool = False,
) -> BackgroundLaunch:
"""Fork a detached child process running the workflow with a web dashboard.

Expand All @@ -527,6 +528,9 @@ def launch_background(
metadata: Optional CLI metadata key=value pairs.
workspace_instructions: Whether to auto-discover workspace instruction files.
cli_instructions: Optional list of instruction file paths.
print_loaded_instructions: Whether to forward ``--print-loaded-instructions``
to the background child. Output goes to the child's captured stderr
log, not to the parent's TTY.

Returns:
A ``BackgroundLaunch`` describing the launch (dashboard URL,
Expand Down Expand Up @@ -582,6 +586,9 @@ def launch_background(
for instr_path in cli_instructions:
cmd.extend(["--instructions", instr_path])

if print_loaded_instructions:
cmd.append("--print-loaded-instructions")

return _spawn_bg_child(cmd=cmd, web_port=web_port, pid_workflow_ref=workflow_path)


Expand Down
92 changes: 91 additions & 1 deletion src/conductor/cli/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from conductor.providers.registry import ProviderRegistry

if TYPE_CHECKING:
from conductor.config.instructions import DiscoveredInstruction
from conductor.config.schema import ProviderSettings
from conductor.events import WorkflowEvent

Expand Down Expand Up @@ -1371,6 +1372,77 @@ async def _execute_with_stop_signal(
raise ExecutionError(stop_message)


def _emit_loaded_instructions_debug(start_dir: Path | None, enabled: bool) -> None:
"""Discover-and-print the workspace instructions list when the debug flag
is enabled and auto-discovery actually ran.

Extracted from :func:`run_workflow_async` so the branch is directly
testable without spinning up the full workflow runner. ``start_dir`` must
be the same path that :func:`build_instructions_preamble` was given as
``auto_discover_dir`` — sharing the path is what guarantees the printed
list cannot drift from what was actually loaded.

Args:
start_dir: The auto-discovery start directory used by the loader, or
``None`` when auto-discovery did not run. ``None`` short-circuits
so the debug print is a no-op (matching the contract that this
flag is "meaningful only when --workspace-instructions is set").
enabled: Whether ``--print-loaded-instructions`` was passed on the
CLI. ``False`` short-circuits.
"""
if not enabled or start_dir is None:
return
from conductor.config.instructions import discover_workspace_instructions_detailed

detailed = discover_workspace_instructions_detailed(start_dir)
_print_loaded_instructions(detailed)


def _print_loaded_instructions(detailed: list[DiscoveredInstruction]) -> None:
"""Emit a human-readable summary of discovered workspace instruction files
to stderr.

Format:

.. code-block:: text

[workspace-instructions] 4 file(s) loaded from CWD:
AGENTS.md
source=AGENTS.md reason=file-convention
.github/instructions/csharp-coding-standards.instructions.md
source=.github/instructions reason=scope-overlap applyTo='**/*.cs'
...

Goes to stderr (not stdout) so it doesn't pollute JSON output. Uses a
plain-print rather than the rich console so it's reliably available in
background/non-TTY launchers.

``DiscoveredInstruction`` is imported under ``TYPE_CHECKING`` only: this
module has ``from __future__ import annotations``, so the annotation is
never evaluated at import time, and the lazy import inside
:func:`_emit_loaded_instructions_debug` is what actually keeps the
discovery code path out of the module graph when the flag is unused.
"""
import sys

from conductor.config.instructions import ALWAYS_ON_SCOPE

if not detailed:
print("[workspace-instructions] 0 files discovered from CWD.", file=sys.stderr)
return
print(
f"[workspace-instructions] {len(detailed)} file(s) discovered from CWD:",
file=sys.stderr,
)
for d in detailed:
print(f" {d.path}", file=sys.stderr)
scope_part = f" applyTo={d.scope!r}" if d.scope and d.scope != ALWAYS_ON_SCOPE else ""
print(
f" source={d.source} reason={d.reason}{scope_part}",
file=sys.stderr,
)


async def run_workflow_async(
workflow_path: Path,
inputs: dict[str, Any],
Expand All @@ -1385,6 +1457,7 @@ async def run_workflow_async(
metadata: dict[str, str] | None = None,
workspace_instructions: bool = False,
cli_instructions: list[str] | None = None,
print_loaded_instructions: bool = False,
) -> dict[str, Any]:
"""Execute a workflow asynchronously.

Expand All @@ -1401,6 +1474,9 @@ async def run_workflow_async(
metadata: Optional CLI metadata to merge on top of YAML-declared metadata.
workspace_instructions: If True, auto-discover workspace instruction files.
cli_instructions: Optional list of instruction file paths from CLI.
print_loaded_instructions: If True, print the resolved instruction file
list (with scope and inclusion reason) to stderr before running.
No-op unless ``workspace_instructions`` is also True.

Returns:
The workflow output as a dictionary.
Expand Down Expand Up @@ -1503,8 +1579,16 @@ async def run_workflow_async(
if workspace_instructions or cli_instructions or config.workflow.instructions:
from conductor.config.instructions import build_instructions_preamble

# Compute the auto-discovery start dir once and share it between
# the loader and the --print-loaded-instructions debug dump. If
# these diverged (e.g. the cwd changed between the two calls, or
# one was passed a different path), the printed list would no
# longer reflect what was actually loaded — defeating the whole
# purpose of the debug flag.
start_dir = Path.cwd() if workspace_instructions else None

instructions_preamble = build_instructions_preamble(
auto_discover_dir=Path.cwd() if workspace_instructions else None,
auto_discover_dir=start_dir,
yaml_instructions=config.workflow.instructions or None,
cli_instruction_paths=cli_instructions,
)
Expand All @@ -1514,6 +1598,12 @@ async def run_workflow_async(
style="cyan",
)

# --print-loaded-instructions: dump the resolved discovery list to
# stderr for debugging. Only meaningful when auto-discovery ran,
# and must use the same start_dir as the loader above so the
# printed list cannot silently drift from what was loaded.
_emit_loaded_instructions_debug(start_dir, print_loaded_instructions)

# Convert MCP servers from workflow config to SDK format
mcp_servers = await _build_mcp_servers(config)

Expand Down
Loading
Loading