24 KiB
Qwen Integration Follow-ups
Tracks pending work and known limitations for the Qwen Code harness
(harness: qwen, driving qwen --acp).
What works today
omnigent run --harness qwen/executor.harness: qwen(aliasqwen-code).- ACP executor: streaming turns, system-prompt folding, session-not-found reset, missing-binary handling.
- Permission gating (
session/request_permission): routed through Omnigent's TOOL_CALL policy + human-consent elicitation (_decide_permission), mirroring claude-sdk — a hard policy DENY rejects, otherwise the user is asked; default-deny on policy-ASK with no handler. Standalone/test use (no bridges wired) falls back to allow. omnigent setup→ Qwen Code row: installs the CLI and guides auth (env vars or interactive/auth).- Auth via the CLI's own ambient credentials (see Auth model below).
- Provider / gateway routing (clean env). A spec
auth:/providers:entry is translated toHARNESS_QWEN_GATEWAY_*vars and the executor exportsOPENAI_BASE_URL/OPENAI_API_KEY(from the gateway's bearer-token command, run once at session start) /OPENAI_MODELinto theqwen --acpsubprocess. Verified end-to-end against an OpenAI-compatible endpoint. Caveat: this is authoritative only when qwen has no conflicting ambient~/.qwen/settings.json— see Pending work for the precedence limitation. - Cost / token tracking. Per-turn token usage is parsed from qwen's ACP
stream and emitted on
TurnComplete.usage(and fed to the cost observer). qwen rides usage out-of-band on anagent_message_chunkwhose text is empty and whose_meta.usagecarries{inputTokens, outputTokens, totalTokens, thoughtTokens, cachedReadTokens}(qwen-codeemitUsageMetadata). The executor sums these across a turn's internal model calls and splitscachedReadTokensout ofinput_tokens(qwen'sinputTokensis cache- inclusive; cost wants the non-cached portion) — see_accumulate_usage. Verified end-to-end against a liveqwen --acpturn. - Context status. The UI context meter shows used/total for qwen. The
numerator (per-turn context consumed) comes from
_meta.usage.totalTokensvia cost/token tracking above; the denominator (the model's context-window limit) comes from a curated Qwen lookup inget_model_context_window(_QWEN_CONTEXT_WINDOWS) — qwen models are absent from litellm and the MLflow catalog, so without it they fell back to the wrong 128K default (qwen3-coder-plus is 1M). A spec'sexecutor.context_windowstill overrides; unrecognized qwen models keep the 128K fallback. - In-session model selection (
/model). Switching models mid-session works. The model is fixed in theqwen --acpsubprocess env (HARNESS_QWEN_MODEL) at spawn, so on a/modelchange the runner'sHarnessProcessManagerrespawns the harness with the new value — a freshQwenExecutorthen opens a newsession/newcarrying the new model. Context survives the respawn because the first turn of the new session replays the prior conversation (see History replay below). - History replay on a fresh ACP session. When the
qwen --acpsubprocess is (re)spawned — first turn, a/modelswitch, or aSession not foundreset — qwen holds none of the earlier conversation (it lived in the dead process).run_turnnormally sends only the latest user turn, so the first turn of any fresh session folds the prior transcript into the prompt as a labeledConversation so far:block (_history_prefix), mirroringClaudeSDKExecutor._build_prompt. Keeps a mid-conversation model switch from dropping the thread. (Same fix applied to the goose ACP harness.) - OS sandbox. When the spec's
os_env.sandboxis notnone, the wholeqwenprocess tree is wrapped in the platform sandbox (bwrap / seatbelt) at spawn (_sandbox_launch_path), confining qwen's own file/shell tools to the spec's read/write roots — an OS-level guarantee independent of the per-tool permission gate. - File I/O delegation (
fs/*). When anos_envis configured, the executor advertisesclientCapabilities.fsininitialize, so qwen routes its file reads/writes back to us asfs/read_text_file/fs/write_text_filerequests (qwen'sAcpFileSystemServiceswaps in only when the capability is set). The handlers execute the I/O through the OmnigentOSEnvironment, so the spec's sandbox read/write roots are enforced at the Python layer — and the bytes flow through Omnigent rather than qwen touching disk directly. Disabled (qwen uses its own tools) when there's noos_envor it's aforkenv (a forked tree's path would diverge from the qwen subprocess cwd). Binary/non-UTF-8 reads are refused; missing-file reads map to qwen's ENOENT code. (Same fix applied to the goose ACP harness.) See the Pending item below for what's still out of scope (event recording / TOOL_RESULT-phase content policy).
Pending work
Functionality not yet supported, by priority. (How to build each lives in code comments; this is the what, not the how.)
High
-
Native TUI variant (
qwen-native/native-qwen). Implemented — the liveqwenTUI runs in a runner-owned tmux pane embedded in the web UI, driven byomnigent qwen. Unlike the goose/cursortmux send-keysnative harnesses, it uses qwen's built-in remote-control protocol: web-UI turns are appended to qwen's--input-file(a{"type":"submit"}line, routed through the samesubmitQuerypath the keyboard uses, so it renders in the TUI), and the transcript is mirrored back by tailing qwen's structured--json-fileevent stream (Anthropic stream-json shape). Interrupt/stop still go through the pane (Escape/ kill) since the input-file watcher has no interrupt command. Seedocs/QWEN_NATIVE_DESIGN.md. Still a follow-up (PR2): usage parsing from theresult/assistantevents is not yet emitted onTurnComplete.usage(see the status-line item below for the model/ring/cost consequences). -
Tool-approval elicitation card (TUI → web). Implemented — qwen's in-terminal tool-approval prompt now also renders as an approval card in the web chat, and answering either surface resolves the other. qwen emits a structured
{"type":"control_request","request":{"subtype":"can_use_tool", "tool_name","tool_use_id","input"},"request_id"}on--json-fileand accepts a{"type":"confirmation_response","request_id","allowed"}on--input-file, coexisting with its own TUI prompt (whichever answers first wins; qwen'sdual-output.mdconfirmscontrol_requestis emitted whenever a tool needs approval — the earlier "default mode doesn't emit these" note was wrong).- Mirror:
omnigent/qwen_native_permissions.py—supervise_qwen_approval_mirrortails the same--json-filethe transcript forwarder reads (seeded at EOF so only new prompts park), POSTs eachcan_use_toolto the server'sqwen-permission-requesthook, and on the web verdict writesconfirmation_responseto the input file (no keystrokes). It's the structured analog of cursor-native's pane-scraping mirror. Wired alongside the forwarder under one supervised task inrunner/app.py::_auto_create_qwen_terminal(_supervise_qwen_native_bridges). - Server hook:
POST /v1/sessions/{id}/hooks/qwen-permission-request(qwen_permission_request_hook, modeled on the cursor hook) publishes the standardresponse.elicitation_request(policy_name=qwen_native_permission,phase=pre_tool_use) and parks via_publish_and_wait_for_harness_elicitation. This always surfaces a card whenever the TUI prompts — the explicit goal — rather than routing through/policies/evaluate(which would auto-resolve and skip the card when no TOOL_CALL policy matches qwen's tool names). - Loser release: qwen emits a
control_responsefor arequest_idwhether the TUI or an externalconfirmation_responseanswered. The mirror watches for it: if it lands while the web card is still parked (TUI answered first), it POSTsexternal_elicitation_resolvedto clear the card and skips the staleconfirmation_response; if the card answered first, the task is already done and thecontrol_responsejust cleans up. Still worth a live E2E to confirm timing under a realqwen --acpturn.
- Mirror:
-
Composer status line: real model + context ring (Web UI). For native-qwen the composer's model/effort chip is currently hidden (web UI flag
nativeVendorOwnsModelinchatStore.sessionBindingPatch→ComposerStatusLineinweb/src/pages/ChatPage.tsx). It was showing the bound spec's default model (claude-sonnet-4-6) because the qwen-native-ui spec sets no model and qwen picks its model inside the vendor TUI (OpenAI-compat env / qwen's own/model), so Omnigent'sllmModelwas a misleading default. Hiding it is the interim; the real fix is to surface qwen's actual model (and effort/approval-mode if meaningful). The data is already on qwen's--json-filestream —assistantmessage events carrymessage.model(e.g.openai/gpt-oss-120b:free) and thesystem/session_startevent carries model metadata. The forwarder (omnigent/qwen_native_forwarder.py) could parse it and report it onto the session so the chip reflects qwen's reality.- Context ring + cost tracking also missing, same root cause: native-qwen
doesn't yet parse/forward token usage, so
tokensUsed/contextWindowstay null (the ring renders only whencontextWindow > 0 && tokensUsed != null) and the session cost stays $0 (cost is derived from per-turn usage × model price). The usage is on the stream, though — verified live (qwenv0.18.2): each turn's finalassistantevent carriesmessage.usage({input_tokens, output_tokens, cache_read_input_tokens, total_tokens}), so the forwarder could parse it and POSTexternal_session_usage. The ACPqwenharness already does this — see "Cost / token tracking" in What works today (_accumulate_usage); native-qwen needs the equivalent off the--json-filestream. Parseresult.usage(input_tokens/output_tokens/cache_read_input_tokens/total_tokens) inomnigent/qwen_native_forwarder.py, splitcache_read_input_tokensout ofinput_tokens(qwen'sinput_tokensis cache-inclusive; cost wants the non-cached portion), and report it onto the session so the cost observer + context ring pick it up; the context-window limit comes from the curated_QWEN_CONTEXT_WINDOWSlookup. One usage-parsing change feeds the model chip, the ring, and cost together.
- Context ring + cost tracking also missing, same root cause: native-qwen
doesn't yet parse/forward token usage, so
-
Restore qwen's TUI history on resume.
omni qwen --resume <conv_id>used to relaunch a blankqwenTUI (only the web chat kept history, via the forwarder). Fixed, using the sameexternal_session_idconvention as claude-/codex-/pi-native (so it's consistent and fork-capable):_auto_create_qwen_terminalpersists the qwen session id on the Omnigent session (_persist_qwen_external_session_id→PATCH /v1/sessions/{id}), reads it back from the snapshot (launch_config.external_session_id) on the next launch, and it's stamped asomnigent.fork.source_external_session_idfor fork history carry-over. qwen is cleaner than claude/codex here — it lets us assign the id via--session-id, so we mint a deterministic one (qwen_session_id_for_conversation, UUIDv5 of theconv_id) up front instead of capturing a vendor-generated id, and a failed persist self-heals (the id is recomputable). Launch is fresh--session-id <id>the first time,--resume <id>once qwen has an on-disk recording — the recording check (qwen_session_recording_exists, scoped to the launch workspace's qwen project slug at~/.qwen/projects/<slug>/chats/<id>.jsonl) is the--resumeguard, since--resumeon an id not recorded under that cwd shows qwen's blocking "No saved session found" screen — qwen resolves--resumeper-project, so the check must be workspace-scoped, not a cross-project glob (also keeps never-messaged / pre-convention sessions on the clean fresh path). No forwarder change needed: verified that on--resumeqwen restores history into the TUI from its own checkpoint and emits only new events to--json-file, so the transcript is never re-mirrored — qwen sidesteps the double-mirror problem that forced goose-native to start fresh. -
Carry history into qwen on fork / switch-agent (incl. cross-harness). Forking a session — or switching its agent — into qwen-native now seeds the new qwen session with the prior conversation, the same way claude-/codex-/pi-native do. qwen-native is registered in
_FORK_HISTORY_NATIVE_HARNESSES(server/routes/sessions.py), so both the fork and switch-agent routes stampomnigent.fork.carry_historyand clearexternal_session_idon the clone. On the clone's first launch,_auto_create_qwen_terminalcalls_build_qwen_fork_recording, which fetches the clone's copied Omnigent items (fetch_all_session_items_for_pi_resume— harness-neutral) and rebuilds qwen's on-disk recording viaqwen_session_records_from_session_items+write_qwen_session_recording, then forces--resume. Because it rebuilds from Omnigent items (not the source's vendor transcript), it works cross-harness (claude/pi/codex → qwen). Key on-disk-format finding: qwen resolves--resume <id>from three files, not the.jsonlalone — it also needschats/<id>.runtime.json(session index entry) and the projectmeta.json; a bare recording yields the blocking "No saved session found" screen (verified on v0.18.2). The synthesized recording emits onlyuser/assistantmessage records (thesystemsnapshot records qwen writes live are optional for resume); tool calls are dropped (text turns carry the context). The rebuild is gated on a NULLexternal_session_idso it runs only on the first launch — once the minted id is persisted, later relaunches take the normal resume path and never clobber qwen's live recording (which by then holds post-fork turns). The minted id is the clone's own deterministicqwen_session_id_for_conversation, so the resume path recomputes it. Mirrors pi-native's fork rebuild (_resolve_pi_external_session_idcase 2).
Medium
-
Compaction via
/compact(web → TUI), with spinner + divider. Implemented, mirroring cursor-native PR #1259 — the web composer's/compactnow drives qwen's/compressin the TUI, with a "Compacting conversation…" spinner that resolves to the "Conversation compacted" divider when qwen actually finishes. Works for both explicit/compactand auto-compaction.- Server (existing, harness-agnostic):
/compact→ forwards{"type": "compact"}to the bound runner; a 200 means the control was handled in the terminal (server skips its own AP-side compaction, which 400s on the LLM-less native pseudo-agent). - Runner (
_handle_qwen_native_compact): publishesresponse.compaction.in_progress(raises the spinner), submits/compressvia the input file (submit_user_message), returns 200; on failure publishesresponse.compaction.failed(dismisses the spinner) + 503. Unlike cursor's bracketed-paste, qwen's input-filesubmitroutes throughRemoteInputWatcher→submitQuery(the keyboard's own path), which processes the slash command directly — no autocomplete-dropdown trap, and no/compressuser bubble on the stream (verified live,qwenv0.18.2). - Completion signal — the chat recording, not the stream. qwen emits no
compression event on the
--json-filestream (session_start'ssupported_eventsomits it; the green "compressed from…" TUI line is an internaladdItem, never streamed). But it writes a{"type":"system", "subtype":"chat_compression","systemPayload":{"info":{originalTokenCount, newTokenCount,compressionStatus}}}record to its on-disk recording (~/.qwen/projects/<slug>/chats/<id>.jsonl) the instant compression finishes.supervise_qwen_compaction_mirrortails that recording (seeded at EOF so a resumed session's prior records don't re-fire) and POSTsexternal_compaction_status—completedoncompressionStatus == 1,failedon theCOMPRESSION_FAILED_*codes (2/3) — which the server republishes asresponse.compaction.completed/failed. - Note on the ACP
qwenharness: the in-process executor compresses internally over ACP and is opaque to us (same boundary as the LLM-phase policy exclusion below), so this item is native-qwen only. - Follow-up: the context ring won't shrink after compaction until usage is
forwarded as
external_session_usage(see the "Composer status line" item) — the recording'snewTokenCountcould feed that.
- Server (existing, harness-agnostic):
-
Provider routing: settings.json precedence + token refresh. The base injection now works (see What works today), but two gaps remain before it's robust on a developer machine:
- Ambient settings win. qwen prefers a user-level
~/.qwen/settings.json(security.auth.selectedType+modelProviders) over the injectedOPENAI_*env vars, so on a host where someone ranqwen /auth, the spec's gateway is silently ignored. qwen exposes no config-dir flag, so making the gateway authoritative needs HOME / config-dir isolation for the subprocess. - No token refresh. The bearer token is snapshotted once at session start; qwen has no refresh hook, so a short-lived rotating token (Databricks gateway) can expire over a long session. Static keys / stable gateways are unaffected.
- Ambient settings win. qwen prefers a user-level
-
Databricks path. Verify the
databricks-*profile route end-to-end (the env plumbing exists; only the OpenAI-compatible gateway has been tested). The profile route derives the base URL + auth from ucode state, so it depends on ucode provisioning aqwenagent for the workspace. To test:- Quick (no ucode): point a gateway straight at Databricks' OpenAI-compatible
serving endpoint —
gateway_base_url = https://<host>/serving-endpoints,gateway_auth_command = databricks auth token --profile <p> --output json | jq -r .access_token,model = <served-endpoint-name>— run a turn from a cleanHOME(so~/.qwen/settings.jsoncan't take precedence). - Full route: spec with
executor.profile: <db-profile>(or adatabricks-*model), thenomni run; confirm the runner log'sqwen gateway routing:line shows the Databricks base URL + profile.
- Quick (no ucode): point a gateway straight at Databricks' OpenAI-compatible
serving endpoint —
-
Omnigent tools. Qwen-native now exposes the shared Omnigent MCP relay (
omnigent.claude_native_bridge serve-mcp,mcpServers.omnigent,trust: true) to qwen via the--mcp-config <path>launch flag (the claude-native model). qwen connects to it on boot,/mcplists it, and the model can call Omnigent's builtin tools (sys_*,load_skill,web_fetch, …). The config lives in the per-session bridge dir, not the workspace, so we drop no file in the user's repo, concurrent same-workspace sessions can't collide, and CLI-provided servers are ungated (no "Untrusted MCP server" prompt → no pre-approval step). The token + config are written byqwen_native_bridge.write_mcp_config; the live tool surface is advertised by thetool_relay.jsonthatensure_comment_relaywrites. Thebridge.jsonbearer token is written through_ensure_secure_bridge_dir(the same owner-only ancestor validation the shared relay applies to token-bearing trees). Permission gating on qwen's own tool calls already works. -
File I/O recording / content policy. Omnigent now executes delegated file reads/writes through the
OSEnvironment(see "File I/O delegation" in What works today), so the bytes flow through Omnigent and the sandbox roots are enforced. Still missing on top of that: (a) emitting the I/O into Omnigent's event stream (ToolCall-style records) so it shows in history, and (b) running TOOL_RESULT-phase content policy on the read/written content. Both build on the_handle_fs_read/_handle_fs_writehandlers — the byte-level hook now exists; this is wiring the recording/policy layers onto it.
LLM-phase policy (
PHASE_LLM_REQUEST/PHASE_LLM_RESPONSE) is intentionally out of scope: qwen's model calls happen internally over ACP and are opaque to us. Only tool-call-phase policy is feasible, and it is wired.
Low
- More attachment types. Text files and images now reach the agent; still unsupported are binary documents (PDF, etc.) and audio input.
- Session resilience: cancel a turn mid-flight, recover when the
qwensubprocess crashes, and resume a session across separate runs.- Done in this pass: dead qwen-native terminals now recreate on attach instead of failing 4404, so the embedded pane recovers after a crash or deferred-start failure.
- Vision/audio quality depends on the model: text-only routes (e.g.
qwen3-coder:free) can't see forwarded images. Worth surfacing model capability to users picking an agent.
Known limitations & behavior
Model capability vs. file attachments
Tool-calling reliability depends on the model. Weak/free routes (notably
qwen/qwen3-coder:free) lose the tool-calling thread when a message carries a
file attachment: instead of emitting a structured tool call (which would reach
our session/request_permission gate), they narrate the shell command as prose
(e.g. printing Command: rm … as text). The omni run is deterministic about
this — every input_file turn skips policy/elicitation; every text-only turn
reaches them. qwen3-coder-plus keeps tool-calling across the same prompts.
Mitigation: _text_from_blocks fences inlined file content with a labeled
--- attached file: <name> --- header/footer so the model reads it as an
attachment, not instructions (bare-appending raw content reproduced the
prose-narration leak even on :free). This reduces but does not eliminate the
fragility — for reliable tool use with attachments, prefer a stronger model.
Auth model
Qwen has no CLI login — its auth subcommand was removed (qwen login
doesn't exist; qwen auth status prints "removed" and exits 0). Auth is:
- Headless / ACP: env vars —
OPENAI_API_KEY+OPENAI_BASE_URL+OPENAI_MODEL, orBAILIAN_CODING_PLAN_API_KEY, orOPENROUTER_API_KEY. - Interactive: run
qwenand use/auth(API key or Alibaba Cloud Coding Plan), persisted under~/.qwen/.
Qwen OAuth was discontinued 2026-04-15; the installed CLI may still mention it
(version skew), but the service is gone. The HarnessInstallSpec deliberately
leaves login_args / logout_args / status_args unset so
harness_cli_logged_in/login/logout stay no-ops for qwen.
ACP constraints
- Qwen runs its own tools internally (not yet bridged — see Pending work).
- Qwen assigns its own
sessionId; ours is a hint. - ACP has no system-prompt field, so the spec
prompt:is folded into the first user turn. - Server-initiated requests are dispatched by method:
request_permissiongoes through the policy + elicitation gate (see What works today); everything else (includingfs/*) → JSON-RPC method-not-found. We do not advertiseclientCapabilities.fsininitialize, so qwen never delegates file ops to us — it uses its own file tools. (fs delegation handlers were removed as dead code; re-add them with the capability — see Pending work.)
Reference
ACP session lifecycle (qwen --acp, JSON-RPC over NDJSON)
initialize— capability handshake (once per subprocess).session/new { cwd, mcpServers }— server returns its ownsessionId.session/prompt { sessionId, prompt }— streamingsession/updatenotifications flow back; the final response resolves the request.- The subprocess is kept alive across turns (no per-turn respawn).
Model override
Spec model → provider default → catalog default; /model overrides via
HARNESS_QWEN_MODEL.
Env vars consumed by the harness wrap
HARNESS_QWEN_MODEL, HARNESS_QWEN_CWD, HARNESS_QWEN_PATH,
HARNESS_QWEN_OS_ENV. (Gateway/Databricks vars are computed but not yet
consumed — see Pending work. No skills-bridge vars are emitted.)