Skip to content

feat(qwen): add Qwen Code (qwen_cli) provider (#376)#412

Open
SparkyWen wants to merge 5 commits into
awslabs:mainfrom
SparkyWen:feat/qwen-cli-provider
Open

feat(qwen): add Qwen Code (qwen_cli) provider (#376)#412
SparkyWen wants to merge 5 commits into
awslabs:mainfrom
SparkyWen:feat/qwen-cli-provider

Conversation

@SparkyWen

Copy link
Copy Markdown
Contributor

Qwen Code (qwen_cli) provider

Adds the Qwen Code (qwen_cli) provider so CAO can drive qwen as a first-class agent — supervisor or worker — including cross-provider orchestration (handoff / assign / send_message).

Closes #376.

Provider

  • src/cli_agent_orchestrator/providers/qwen_cli.py — launches qwen --approval-mode yolo with a per-terminal --mcp-config, --append-system-prompt (CAO role + skills + security prompt when tool-restricted), model selection, full status detection (PROCESSING / IDLE / COMPLETED / ERROR / WAITING_USER_ANSWER), response extraction, and first-run dialog handling (theme / folder-trust).
  • Auth is user-managed: OpenAI-compatible / DashScope credentials (~/.qwen/.env or ~/.qwen/settings.json) or qwen-oauth.
  • Registered in the provider factory, --provider CLI choices, cross-provider resolution, and the README provider table.

Native send_message tool collision (fixed)

qwen-code ships a native send_message tool (its team / background-task messaging feature) whose bare name shadows cao-mcp-server's send_message, which qwen surfaces under the prefixed name mcp__cao-mcp-server__send_message. A worker told to "send_message" its result back matched the native tool → No active team and no task_id providedassign / handoff callbacks from a qwen worker never routed back to the supervisor.

Launch now passes --exclude-tools send_message, dropping the colliding native tool so mcp__cao-mcp-server__send_message is the only send-message-shaped tool the model can pick. CAO never uses qwen-code's native team messaging, so nothing is lost. Full analysis in the #376 comment thread.

Verified end-to-end: a qwen worker's assign result now routes back to the supervisor (supervisor receives [Message from terminal <qwen_id>] and combines it with the other worker's result).

Tests

  • test/providers/test_qwen_cli_unit.py — command building, status detection, response extraction, per-terminal MCP config, and test_build_command_excludes_native_send_message.

Docs

  • docs/qwen-cli.md (provider guide), design doc, README provider table + valid --provider values.

Note on branch contents

The branch also carries a small related fix, fix(config): tolerant env_bool() (so CAO_PYTE_STATUS=1 reliably enables pyte), and the qwen docs commits. Happy to split any of these out if preferred.

SparkyWen and others added 5 commits July 9, 2026 14:40
Add Qwen Code (`qwen`) — Alibaba's Gemini-CLI-derived Ink TUI coding
agent — as a first-class CAO provider, modeled on the sibling
antigravity_cli provider.

- QwenCliProvider: launches `qwen --approval-mode yolo` with
  `--append-system-prompt` role injection, `--model`, and a per-terminal
  `--mcp-config` file carrying CAO_TERMINAL_ID; footer-anchored status
  detection with pyte stale-footer resolution (get_status_from_screen).
- wiring: ProviderType enum, manager branch, launch workspace set,
  terminal_service (runtime skill prompt + soft enforcement),
  tool_mapping (gemini-style native names), /agents/providers endpoint.
- tests: unit tests at 100% provider coverage against real captured qwen
  TUI fixtures; TestQwenCli* e2e classes across handoff/assign/
  send_message/allowed_tools/supervisor_orchestration/skills; provider
  manager test; dedicated CI workflow.
- docs: docs/qwen-cli.md, README provider table + enumerations, CHANGELOG.

Auth is user-managed (OpenAI-compatible env or qwen-oauth); tool
restrictions are soft (SECURITY_PROMPT) under yolo.

Verified live on a real cao-server (tmux backend): the qwen terminal
reaches IDLE, launches with the correct command + per-terminal MCP config,
transitions idle->processing->completed, its cao-mcp-server connects with
the matching CAO_TERMINAL_ID, and a cross-provider inbox message from a
claude_code terminal is delivered and submitted into the qwen worker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ot silently off)

Boolean env flags were parsed as `os.environ.get(...).lower() == "true"`, so
the common truthy spellings `1` / `yes` / `on` evaluated to False. For
CAO_PYTE_STATUS that silently DISABLED pyte screen-detection — which qwen_cli
and antigravity_cli require (their raw pipe-pane retains a stale "esc to
cancel" → false PROCESSING) — so a finished qwen turn was never detected
COMPLETED and the blocking handoff/assign hung the full timeout.

- add env_bool(name, default) in constants.py: accepts 1/true/yes/on and
  0/false/no/off (case- and whitespace-insensitive); unset, empty, or
  unrecognized values fall back to the default rather than flipping the flag.
- route all four boolean env sites through it: CAO_PYTE_STATUS,
  CAO_EAGER_INBOX_DELIVERY (constants.py), CAO_ENABLE_WORKING_DIRECTORY,
  CAO_ENABLE_SENDER_ID_INJECTION (mcp_server/server.py).
- correct the CAO_PYTE_STATUS comment to list all opt-in providers
  (claude_code, kimi_cli, qwen_cli, antigravity_cli).

TDD: 24 new tests in test/test_constants.py::TestEnvBool cover the
truthy/falsy spellings, default fallback, and a CAO_PYTE_STATUS=1 regression.
Full suite: 3841 passed / 21 skipped; black + isort clean.

Verified live: a claude_code->qwen_cli handoff that hung on CAO_PYTE_STATUS=1
returns the worker's result in ~9s once pyte is actually enabled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ints

Reframe qwen auth around the simplest path: configure qwen once (like any
standalone qwen user) and every CAO-spawned worker inherits it — no export
before cao-server, no --env. qwen loads ~/.qwen/.env natively (bundled
dotenv), and an already-set process env var still wins, so the existing
--env / export flows remain as overrides.

- primary path: a one-time ~/.qwen/.env with just OPENAI_API_KEY (+ base URL);
  model defaults via the profile `model:` field, so the only secret a user
  plugs in is the API key.
- region tables: shared DashScope (China / International-Singapore) and the
  Model Studio workspace-gateway region codes (cn-beijing, ap-southeast-1,
  us-east-1, eu-central-1, ap-northeast-1).
- fix stale guidance that said `CAO_PYTE_STATUS=1` (the value that silently
  disabled pyte): note pyte is required for qwen_cli, on by default, and the
  flag now accepts 1/true/yes/on.

Verified live: with a credential-free cao-server (no OPENAI_* in env) and no
--env, a qwen worker authenticated purely from ~/.qwen/.env and completed a
real turn.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…er callbacks (awslabs#376)

qwen-code registers a native `send_message` tool (its team / background-task
messaging feature). Its bare name collides with cao-mcp-server's `send_message`,
which qwen surfaces under the prefixed name `mcp__cao-mcp-server__send_message`.
When a CAO worker is told to "send_message" its result back to the supervisor,
the model matches the shorter native tool and calls it, failing with
"No active team and no task_id provided" — so assign/handoff callbacks from a
qwen worker never route back and the supervisor waits forever.

Pass `--exclude-tools send_message` on launch so the colliding native tool is
dropped, leaving `mcp__cao-mcp-server__send_message` as the only
send-message-shaped tool the model can pick. CAO orchestration never uses
qwen-code's native team messaging, so nothing is lost.

Verified: with the flag, `/mcp` tool listing shows only the MCP send_message,
and a qwen worker's assign result now routes back to the supervisor end-to-end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@anilkmr-a2z anilkmr-a2z left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review summary: Well-structured provider that mirrors the Antigravity CLI sibling closely. 2 must-fix findings (correctness), 1 nit (test assertion imprecision).

cfg = server_config.model_dump(exclude_none=True)
entry = {
"command": cfg.get("command", ""),
"args": cfg.get("args", []),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

must-fix (correctness) -- The design doc (section 5) declares paste_enter_count = 1 (Ink single-Enter submit), and the unit test asserts in (1, 2), but the implementation never overrides the BaseProvider default of 2. For an Ink TUI where single-Enter submits after bracketed paste, sending a second Enter injects a stray newline that either starts a new turn (doubling input) or is silently consumed. The Antigravity CLI sibling (same Ink scaffold) also relies on the base default of 2, but the design doc here explicitly documents 1.

Fix: Add a class-level override:

@property
def paste_enter_count(self) -> int:
    return 1

Or, if live testing confirms double-Enter is correct for qwen, update the design doc to match reality and tighten the test assertion to == 2.

r"|(?:trust (?:this )?folder|Do you trust the files in this folder)"
r"|(?:Get started|Sign in with)"
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

must-fix (correctness) -- _handle_startup_dialog is synchronous (time.sleep in a while loop), but it is called from async initialize(). On the default asyncio event loop this blocks the entire event loop for up to startup_prompt_handler_timeout seconds (server setting, typically 30-60s). The sibling antigravity_cli has the same pattern (inherited tech debt), so this is not a regression introduced by this PR alone, but worth flagging because a qwen startup that hits the theme picker on first install will freeze the CAO server's request handling for up to 30s.

For parity with the sibling this is acceptable as-is, but please leave a # TODO: convert to async polling (same tech debt as antigravity_cli) comment so the next contributor knows the intent.



def test_paste_enter_count_is_valid():
assert make_provider().paste_enter_count in (1, 2)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit (test precision) -- assert make_provider().paste_enter_count in (1, 2) passes for both possible values and therefore validates nothing -- it will never fail regardless of whether the provider overrides the base default. Either assert the exact expected value (== 1 per the design doc, or == 2 if double-Enter is validated), or remove the test if the value is intentionally unspecified.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feat] Qwen Code (qwen) provider adapter

2 participants