Skip to content
Open
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
21 changes: 19 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,9 @@ ANTHROPIC_API_KEY = "sk-ant-..."

## Runners

The factory supports multiple CLI backends via the runner abstraction (`factory/runners/`). By default, it uses Claude Code (`claude` CLI). Bob Shell (`bob` CLI) and OpenAI Codex (`codex` CLI) are also supported as switchable alternatives.
The factory supports multiple CLI backends via the runner abstraction (`factory/runners/`). By default, it uses Claude Code (`claude` CLI). Bob Shell (`bob` CLI), OpenAI Codex (`codex` CLI), and Qwen Code (`qwen` CLI) are also supported as switchable alternatives.

**Runner selection:** Set `FACTORY_RUNNER=codex` (or `bob`) to switch backends, or pass `--runner codex` to individual commands. Default is `claude`.
**Runner selection:** Set `FACTORY_RUNNER=codex` (or `bob`, `qwen`) to switch backends, or pass `--runner codex` to individual commands. Default is `claude`.

**Bob Shell specifics:**
- Requires `BOBSHELL_API_KEY` environment variable to be set
Expand Down Expand Up @@ -157,6 +157,23 @@ CODEX_API_KEY = "..."
```
Then run: `factory ceo /path/to/project --profile codex`

**Qwen Code specifics:**
- Auth via `DASHSCOPE_API_KEY` or `QWEN_API_KEY` environment variable (or set via config.toml profile). Warns if missing.
- CLI flags mirror Claude Code: `--append-system-prompt`, `-p`, `--model`
- Headless mode uses `--yolo` for auto-approve and `--output-format text`
- Model selection via `--model` flag (e.g., `qwen3-coder`)
- Install: `npm install -g @qwen-code/qwen-code`

**Qwen dry-run mode:** Set `FACTORY_QWEN_DRY_RUN=1` to test Qwen integration without spending tokens.

**Qwen config profile example** (`~/.factory/config.toml`):
```toml
[credentials.qwen]
FACTORY_RUNNER = "qwen"
DASHSCOPE_API_KEY = "sk-..."
```
Then run: `factory ceo /path/to/project --profile qwen`

**Important:** Target projects should add `.factory/` to their `.gitignore`. The factory writes experiment data, usage logs, and potentially sensitive auth files (`.factory/.bob_auth`) to this directory. These are project-local artifacts that should not be committed to version control.

## Running the factory
Expand Down
8 changes: 6 additions & 2 deletions factory/runners/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,30 @@
from factory.runners.claude import ClaudeRunner
from factory.runners.codex import CodexRunner, is_codex_dry_run
from factory.runners.protocol import Runner
from factory.runners.qwen import QwenRunner, is_qwen_dry_run

__all__ = [
"Runner",
"ClaudeRunner",
"BobRunner",
"CodexRunner",
"QwenRunner",
"get_runner",
"RunnerName",
"is_dry_run",
"is_codex_dry_run",
"is_qwen_dry_run",
"should_stream",
"stream_subprocess",
]

RunnerName = Literal["claude", "bob", "codex"]
RunnerName = Literal["claude", "bob", "codex", "qwen"]

_RUNNERS: dict[str, type[Runner]] = {
"claude": ClaudeRunner, # type: ignore[dict-item]
"bob": BobRunner, # type: ignore[dict-item]
"codex": CodexRunner, # type: ignore[dict-item]
"qwen": QwenRunner, # type: ignore[dict-item]
}


Expand All @@ -42,7 +46,7 @@ def get_runner(name: str | None = None, project_path: Path | None = None) -> Run
3. Default to "claude"

Args:
name: Runner name ("claude", "bob", or "codex").
name: Runner name ("claude", "bob", "codex", or "qwen").
project_path: Path to the project. Passed to BobRunner for cycle state lookup.

Raises:
Expand Down
169 changes: 169 additions & 0 deletions factory/runners/qwen.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
"""QwenRunner — Qwen Code CLI backend implementation."""

from __future__ import annotations

import asyncio
import logging
import os
import subprocess
from pathlib import Path

from factory.runners._stream import should_stream, stream_subprocess

logger = logging.getLogger(__name__)

_auth_warned = False


def _warn_auth() -> None:
"""Log a warning if neither DASHSCOPE_API_KEY nor QWEN_API_KEY is set (once per process)."""
global _auth_warned # noqa: PLW0603
if _auth_warned:
return
if os.environ.get("DASHSCOPE_API_KEY") or os.environ.get("QWEN_API_KEY"):
_auth_warned = True
return
_auth_warned = True
logger.warning(
"Neither DASHSCOPE_API_KEY nor QWEN_API_KEY is set. "
"Qwen Code may fail to authenticate. "
"Set one directly or add it to a config.toml credential profile: "
'[credentials.qwen] DASHSCOPE_API_KEY = "sk-..."'
)


def _make_qwen_env() -> dict[str, str]:
"""Build subprocess env: strip VIRTUAL_ENV."""
return {k: v for k, v in os.environ.items() if k != "VIRTUAL_ENV"}


def is_qwen_dry_run() -> bool:
"""Return True if Qwen dry-run mode is enabled."""
from factory.user_config import resolve

val = resolve("qwen_dry_run", env_var="FACTORY_QWEN_DRY_RUN") or ""
return val.lower() in ("1", "true", "yes")


class QwenRunner:
"""Runner implementation for Qwen Code CLI."""

name: str = "qwen"

async def headless(
self,
prompt: str,
task: str,
cwd: Path,
*,
timeout: float = 600.0,
model: str | None = None,
dangerously_skip_permissions: bool = True,
role: str = "unknown",
session_name: str | None = None,
) -> tuple[str, int]:
"""Run a headless Qwen Code invocation.

Returns (stdout, return_code).
"""
_ = session_name
if is_qwen_dry_run():
return self._dry_run_response(role, cwd, task)

_warn_auth()

cmd = [
"qwen",
"--append-system-prompt", prompt,
"-p", task,
"--yolo",
"--output-format", "text",
]
if model:
cmd.extend(["--model", model])

logger.info("QwenRunner headless: cwd=%s, model=%s, role=%s", cwd, model, role)

env = _make_qwen_env()

stream = should_stream()
prefix = f"[qwen:{role}]" if stream else None

try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=env,
)
stdout_bytes, stderr_bytes = await asyncio.wait_for(
stream_subprocess(proc, stream=stream, prefix=prefix),
timeout=timeout,
)
except asyncio.TimeoutError:
proc.kill() # type: ignore[union-attr]
await proc.wait() # type: ignore[union-attr]
logger.error("QwenRunner timed out after %ss", timeout)
return f"Agent timed out after {timeout}s", 1
except FileNotFoundError:
logger.error("'qwen' CLI not found on PATH")
return "Error: 'qwen' CLI not found on PATH", 1

stdout = stdout_bytes.decode()
stderr = stderr_bytes.decode()

if proc.returncode != 0:
logger.warning("QwenRunner exited with code %d: %s", proc.returncode, stderr[:200])

return stdout, proc.returncode or 0

def interactive_run(
self,
prompt: str,
task: str,
cwd: Path,
*,
model: str | None = None,
role: str = "ceo",
dangerously_skip_permissions: bool = False,
session_name: str | None = None,
) -> int:
"""Run an interactive Qwen Code session as a subprocess.

Returns the exit code so the caller can clean up in a finally block.
"""
_ = role, session_name

if is_qwen_dry_run():
print("[DRY-RUN] Would exec: qwen (interactive)")
print(f"[DRY-RUN] Task: {task[:200]}...")
return 0

_warn_auth()

cmd = ["qwen", "--append-system-prompt", prompt]
if dangerously_skip_permissions:
cmd.append("--yolo")
cmd.append(task)
if model:
cmd.extend(["--model", model])

logger.info("QwenRunner interactive_run: cwd=%s", cwd)

env = _make_qwen_env()
result = subprocess.run(cmd, cwd=cwd, env=env)
return result.returncode

def _dry_run_response(self, role: str, cwd: Path, task: str) -> tuple[str, int]:
"""Return a stub response for dry-run mode."""
response = (
f"[DRY-RUN] QwenRunner would have executed:\n"
f" role: {role}\n"
f" cwd: {cwd}\n"
f" task: {task[:100]}...\n"
f"\n"
f"Dry-run stub response: Task acknowledged."
)
logger.info("QwenRunner dry-run: role=%s, cwd=%s", role, cwd)
return response, 0
Loading
Loading