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
46 changes: 46 additions & 0 deletions docs/mcp-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,52 @@ uv run conductor run workflow.yaml --input.question="test"
> ```
> See [copilot-sdk#163](https://github.com/github/copilot-sdk/issues/163) for status.

## Working Directory

You can specify a default directory where stdio-based MCP servers and agent sessions execute. This is useful for workflows interacting with local repositories or specific folders.

The working directory is configured in the workflow `runtime` block or on individual agents:

```yaml
workflow:
runtime:
working_dir: "/path/to/default/workspace" # Global default
```

Or on a specific agent:

```yaml
agents:
- name: code_expert
working_dir: "/path/to/specific/repo"
```

### Precedence and Resolution

When determining the active working directory, Conductor follows this precedence:

1. **Agent level:** The agent's own `working_dir` configuration.
2. **Runtime level:** The global `workflow.runtime.working_dir` default.
3. **Fallback:** If neither is set, Conductor falls back to the current directory of the parent process (`os.getcwd()`).

Both levels support dynamic values using Jinja2 templates. You can resolve the path at runtime using outputs from previous steps:

```yaml
agents:
- name: find_repo
type: set
value: "/repositories/my-project"

- name: git_agent
working_dir: "{{ find_repo.output }}"
prompt: "List the last commits in the repository."
```

Relative paths in `working_dir` resolve against the parent directory of the workflow YAML file. If the workflow has no path (e.g. constructed dynamically in memory), they resolve against the current process directory. Conductor lexically normalizes the resolved path. A missing target directory causes Conductor to raise an execution error before any provider call.

> ⚠️ **Warning: Working directory is NOT a sandbox**
> Setting the working directory doesn't restrict filesystem access. It only sets the default path where the agent session and stdio MCP subprocesses run. The model can still read or write files outside this directory if it uses absolute paths or parent directory traversals (e.g., `../`). Avoid relying on this setting to sandbox untrusted model execution.

## OAuth Authentication (HTTP/SSE)

For HTTP and SSE servers that require OAuth, Conductor can automatically discover OAuth requirements and fetch Azure AD tokens.
Expand Down
41 changes: 41 additions & 0 deletions docs/workflow-syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ workflow:
# provider-backed agent unless it declares
# its own `context_tier`.
# See docs/configuration.md#context-tier.

working_dir: "/path/to/cwd" # Optional: global default working directory for LLM agents
# and their MCP servers. Relative paths resolve against the
# parent directory of the workflow YAML file.
```

**Workflow metadata** is included verbatim in the `workflow_started` event and lets downstream consumers (dashboards, queue runners, observability tools) adapt without parsing the YAML. CLI `--metadata key=value` flags merge on top of YAML metadata (CLI wins on conflicts).
Expand Down Expand Up @@ -272,6 +276,43 @@ agents:

`output_mode` is only valid on provider-backed agents (the default type). It cannot be set on `script`, `human_gate`, or `workflow` agents.

### Working Directory

Regular LLM agents (provider-backed agents) and their MCP servers run in a specific working directory:

```yaml
agents:
- name: repository_analyst
working_dir: "./my-project-repo" # Optional: working directory (Jinja2 template)
prompt: |
Examine the repository files and list any issues.
```

The `working_dir` field can be defined globally in `workflow.runtime.working_dir` or overridden on individual agents.

#### Precedence and Path Resolution

1. **Precedence:** The agent-level `working_dir` overrides the global `workflow.runtime.working_dir`. If neither is configured, the current directory of the parent process (`os.getcwd()`) is used.
2. **Jinja2 Rendering:** Both agent-level and runtime-level configurations support Jinja2 template rendering. This allows dynamic paths, such as directories derived from previous steps: `working_dir: "{{ find_repo.output.path }}"`.
3. **Relative Paths:** Relative paths are resolved against the directory containing the workflow YAML file. When the workflow file location is unknown, relative paths resolve against the current process directory.
4. **Lexical Normalization:** Paths are normalized lexically using `os.path.normpath`. The engine does not resolve symlinks dynamically.

#### Symlink Semantics

Because paths are normalized lexically instead of resolving to their real paths:
- Different symlink aliases pointing to the same folder are treated as distinct paths.
- For the Claude provider, distinct paths trigger separate MCP manager connections. This spawns separate MCP server subprocesses for each unique path alias.

#### Key Restrictions and Exclusions

- **Rejected Step Types:** The `working_dir` field is strictly rejected on `wait`, `set`, `terminate`, `human_gate`, and `workflow` (sub-workflow) step types. Defining `working_dir` on these steps raises a `ValidationError` at load time.

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.

This lists rejected step types and implies every other step type resolves working_dir the same way the LLM-agent path does. script steps don't -- ScriptExecutor renders agent.working_dir on its own, with no runtime.working_dir fallback, no absolutize-against-the-workflow-file, and no pre-flight existence check (a missing directory surfaces as a raw subprocess error, not the ExecutionError the LLM path raises). A one-line callout here would keep someone who sets runtime.working_dir globally from assuming it also reaches script steps.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 3bd94ca — added a Script Steps bullet right after Rejected Step Types, documenting that script steps honor only their own working_dir (no runtime.working_dir fallback, relative paths resolve against the conductor process cwd, missing dirs surface as a subprocess startup error).

On the behavioral divergence itself: do you think it's worth a separate issue to decide whether ScriptExecutor should be brought to parity with the LLM-agent resolution (runtime fallback + workflow-file-relative absolutize + pre-flight existence check)? That's a behavior change beyond docs, and I'm happy to do the corresponding work if you and the maintainers want it.

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.

This sounds like a good follow-up on its own -- please go ahead and open an issue for it, and thanks for offering to pick it up. I'd rather keep this PR scoped to LLM agents and their MCP servers as it stands.

- **Script Steps:** `script` steps honor only their own `working_dir` field, rendered as a Jinja2 template. `workflow.runtime.working_dir` is not applied; relative paths are passed to the subprocess as-is and therefore resolve against the Conductor process cwd, not the workflow file directory; missing directories surface as subprocess startup `ExecutionError`s rather than the LLM-agent pre-provider working-dir check.
- **Dialog Turns:** The working directory isn't applied to dialog turns in the current version. Multi-turn interactions run in the process default directory.
- **Sub-Workflows:** A sub-workflow doesn't inherit the parent's working directory configuration. Instead, any relative paths in the child workflow resolve against the child workflow's own file directory.

> ⚠️ **Warning: Working directory is NOT a sandbox**
> Setting `working_dir` doesn't restrict the model's filesystem access. The model can still read and write files outside this directory if it uses absolute paths or parent directory traversals (e.g., `../`). Avoid relying on this configuration to sandbox untrusted model execution.

### Human Gates

Human gates pause workflow execution for user input:
Expand Down
48 changes: 48 additions & 0 deletions examples/working-dir.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Working Directory Example
#
# Demonstrates dynamically setting the working directory for an agent and its
# MCP servers using the outputs of a set step.
#
# The workflow:
# 1. set_step - a set step that calculates the target directory path.
# 2. llm_agent - an LLM agent running with its `working_dir` bound to the
# path output of the set step, using an MCP tool in that directory.
#
# Usage:
# conductor run examples/working-dir.yaml

workflow:
name: working-dir-demo
description: "Demonstrates dynamic working directory configuration for agents and MCP servers"
version: "1.0.0"
entry_point: set_step

runtime:
provider: copilot
mcp_servers:
mock-search:
command: npx
args: ["-y", "open-websearch@latest"]
tools: ["*"]

agents:
- name: set_step
type: set
description: Calculate the directory path for the LLM agent
values:
path: "." # Resolves to the directory containing the workflow YAML (examples/)
routes:
- to: llm_agent

- name: llm_agent
description: Runs in the dynamically calculated working directory
model: claude-haiku-4.5
working_dir: "{{ set_step.output.path }}"
prompt: |
You are running in the working directory: {{ set_step.output.path }}.
Please analyze the files in this directory.
routes:
- to: $end

output:
resolved_path: "{{ set_step.output.path }}"
5 changes: 5 additions & 0 deletions src/conductor/cli/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -2220,6 +2220,11 @@ async def resume_workflow_async(
# Pass stored session IDs to registry for Copilot session resume
if cp.copilot_session_ids:
registry.set_resume_session_ids(cp.copilot_session_ids)
# Pass the sessions' original working directories so the provider
# can skip resuming a session whose cwd changed since creation.
# Pre-cwd checkpoints carry an empty mapping (legacy behavior).
if cp.copilot_session_cwds:
registry.set_resume_session_cwds(cp.copilot_session_cwds)

# Set up interrupt listener if interactive mode is enabled
# Disabled in --web mode since the CLI isn't used for interaction
Expand Down
25 changes: 24 additions & 1 deletion src/conductor/config/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -763,7 +763,16 @@ class AgentDef(BaseModel):
"""Environment variables for script subprocess."""

working_dir: str | None = None
"""Working directory for script subprocess execution."""
"""Working directory for the script subprocess OR a provider-backed agent
session and its MCP servers.

On ``type: script`` steps it sets the subprocess cwd. On provider-backed
LLM agents it is resolved by the engine (Jinja-rendered, then relative
paths resolve against the workflow file's directory) and applied to the
provider session cwd and all of the agent's stdio MCP servers. Falls back
to ``runtime.working_dir`` when unset on the agent. Rejected on
wait/set/terminate/human_gate/workflow step types.
"""

stdin: str | None = None
"""Payload written to the script subprocess's stdin (script type only).
Expand Down Expand Up @@ -1180,6 +1189,8 @@ def validate_agent_type(self) -> AgentDef:
)
if self.output_mode is not None:
raise ValueError("human_gate agents cannot have 'output_mode'")
if self.working_dir:
raise ValueError("human_gate agents cannot have 'working_dir'")
elif self.type == "script":
if not self.command:
raise ValueError("script agents require 'command'")
Expand Down Expand Up @@ -1263,6 +1274,8 @@ def validate_agent_type(self) -> AgentDef:
raise ValueError("workflow agents cannot have 'output_type' (only 'set' agents do)")
if self.output_mode is not None:
raise ValueError("workflow agents cannot have 'output_mode'")
if self.working_dir:
raise ValueError("workflow agents cannot have 'working_dir'")
elif self.type == "wait":
if self.duration is None:
raise ValueError("wait agents require 'duration'")
Expand Down Expand Up @@ -2072,6 +2085,16 @@ def _coerce_provider(cls, value: Any) -> Any:
``create_session`` ``context_tier`` param). Other providers ignore it.
"""

working_dir: str | None = None
"""Workflow-wide default working directory for provider-backed agents.

Acts as the fallback for every LLM agent that does not set its own
``working_dir`` (agent value wins). Supports Jinja2 templating and is
resolved by the engine against the workflow file's directory before
reaching the provider. ``conductor validate`` errors when the resolved
provider declares ``capabilities.working_dir=False``.
"""


class WorkflowDef(BaseModel):
"""Top-level workflow configuration."""
Expand Down
38 changes: 38 additions & 0 deletions src/conductor/config/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -1582,6 +1582,7 @@ def _validate_provider_capabilities(
# no matter where future call sites are added.
runtime_default_effort = config.workflow.runtime.default_reasoning_effort
runtime_max_session_seconds = config.workflow.runtime.max_session_seconds
runtime_working_dir = config.workflow.runtime.working_dir

# Cache per provider name so we don't re-resolve for every agent.
cache: dict[str, ProviderCapabilities] = {}
Expand Down Expand Up @@ -1730,6 +1731,17 @@ def _check_agent_capabilities(
f"(capabilities.max_session_seconds=False)."
)

# working_dir: a provider that cannot apply the directory would
# silently run the agent (and its MCP servers) in the wrong cwd —
# the same class of silently-dropped operational intent as
# max_session_seconds.
if agent.working_dir is not None and not caps.working_dir:
errors.append(
f"Agent '{agent.name}' sets working_dir={agent.working_dir!r} "
f"but provider '{provider_name}' does not apply agent working "
f"directories (capabilities.working_dir=False)."
)

# All provider-backed agents that run at workflow scope: top-level agents
# PLUS for_each inline agents (``ForEachDef.agent``), which inherit the
# workflow-level ``mcp_servers`` / ``max_session_seconds`` and run with
Expand Down Expand Up @@ -1795,6 +1807,32 @@ def _check_agent_capabilities(
f"max_session_seconds."
)

# ----- Workflow-level: working_dir -----
# A runtime-wide working_dir is inherited by every LLM agent that does
# not set its own. A provider that cannot apply it would silently run
# those agents in the wrong directory — error against every resolved
# provider that actually receives the setting.
if runtime_working_dir is not None:
providers_inheriting_working_dir: dict[str, list[str]] = {}
for agent in all_llm_agents:
# A per-agent working_dir overrides the runtime default; that
# case is checked in ``_check_agent_capabilities`` instead.
if agent.working_dir is not None:
continue
pname = _resolved_provider_name(agent, default_provider)
providers_inheriting_working_dir.setdefault(pname, []).append(agent.name)
for pname, agent_names in providers_inheriting_working_dir.items():
pcaps = _caps_for(pname)
if pcaps is not None and not pcaps.working_dir:
errors.append(
f"Workflow declares 'runtime.working_dir'={runtime_working_dir!r} "
f"but provider '{pname}' does not apply agent working directories "
f"(capabilities.working_dir=False) and is used by agent(s): "
f"{sorted(agent_names)!r}. Override these agents to a provider "
f"with working-directory support, or remove the workflow-level "
f"working_dir."
)

# ----- Per-agent checks -----
for agent in config.agents:
if not _is_llm_agent(agent):
Expand Down
15 changes: 15 additions & 0 deletions src/conductor/engine/checkpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ class CheckpointData:
context: Serialized ``WorkflowContext`` state.
limits: Serialized ``LimitEnforcer`` state.
copilot_session_ids: Mapping of agent names to Copilot session IDs.
copilot_session_cwds: Mapping of agent names to the working directory
their Copilot session was created with. Persisted so resume can
detect a changed cwd and start a fresh session instead of
resuming into the wrong directory. Empty for checkpoints written
by a version of Conductor that predated this field.
file_path: Path where the checkpoint file is stored.
instructions_preamble: Workspace instructions preamble that was
active during the original run, or ``None``.
Expand Down Expand Up @@ -116,6 +121,7 @@ class CheckpointData:
context: dict[str, Any]
limits: dict[str, Any]
copilot_session_ids: dict[str, str] = field(default_factory=dict)
copilot_session_cwds: dict[str, str] = field(default_factory=dict)
file_path: Path = field(default_factory=lambda: Path())
instructions_preamble: str | None = None
"""Workspace instructions preamble that was active during the original run."""
Expand Down Expand Up @@ -176,6 +182,7 @@ def save_checkpoint(
error: BaseException | None,
inputs: dict[str, Any],
copilot_session_ids: dict[str, str] | None = None,
copilot_session_cwds: dict[str, str] | None = None,
system_metadata: dict[str, Any] | None = None,
instructions_preamble: str | None = None,
run_id: str = "",
Expand All @@ -201,6 +208,10 @@ def save_checkpoint(
for a periodic / non-failure checkpoint.
inputs: Workflow inputs.
copilot_session_ids: Optional mapping of agent names to session IDs.
copilot_session_cwds: Optional mapping of agent names to the
working directory their session was created with. Persisted
alongside the session IDs so resume can skip stale sessions
whose cwd no longer matches the agent's resolved cwd.
system_metadata: Optional system metadata captured at workflow start.
instructions_preamble: Optional workspace instructions preamble to persist.
run_id: Original run identifier (from ``EventLogSubscriber``).
Expand Down Expand Up @@ -255,6 +266,7 @@ def save_checkpoint(
"context": _make_json_serializable(context.to_dict()),
"limits": _make_json_serializable(limits.to_dict()),
"copilot_session_ids": copilot_session_ids or {},
"copilot_session_cwds": copilot_session_cwds or {},
"system": system_metadata or {},
"instructions_preamble": instructions_preamble,
"run_id": run_id,
Expand Down Expand Up @@ -373,6 +385,9 @@ def load_checkpoint(checkpoint_path: Path) -> CheckpointData:
context=data["context"],
limits=data["limits"],
copilot_session_ids=data.get("copilot_session_ids", {}),
# Backward compatible: pre-cwd checkpoints have no such key and
# load with an empty mapping (legacy resume-by-id behavior).
copilot_session_cwds=data.get("copilot_session_cwds", {}),
file_path=checkpoint_path,
instructions_preamble=data.get("instructions_preamble"),
run_id=data.get("run_id", "") or "",
Expand Down
1 change: 1 addition & 0 deletions src/conductor/engine/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ def _build_validator_agent(self, agent: AgentDef) -> AgentDef:
system_prompt=VALIDATOR_SYSTEM_PROMPT.format(criteria=agent.validator.criteria),
tools=[],
output=_VALIDATOR_OUTPUT_SCHEMA,
working_dir=agent.working_dir,
)

def _parse(self, content: Any) -> tuple[bool, list[str], bool]:
Expand Down
Loading
Loading