qcr/
├── crates/
│ ├── qcr-core/ # Library: all logic, types, tools, LLM clients
│ └── qcr-cli/ # Binary: CLI, TUI, headless/print dispatch
├── .github/workflows/ # CI (ci.yml) and release (release.yml)
├── .kiro/specs/ # Project spec and task backlog
├── docs/ # This documentation
└── tasks/ # In-progress todo files
All agent logic lives here as a library crate, making it testable without a terminal.
crates/qcr-core/src/
├── lib.rs # Public re-exports, VERSION constant
├── error.rs # Error hierarchy (Qwen Code RustError and variants)
├── a2a/
│ ├── mod.rs # A2A protocol module re-exports
│ ├── types.rs # AgentCard, Task, Message, Part, Artifact, JSON-RPC types
│ ├── store.rs # TaskStore — in-memory A2A task storage
│ ├── bridge.rs # AgentEvent → A2A stream event conversion
│ └── server.rs # HTTP handlers: agent card, JSON-RPC dispatch, SSE streaming
├── agui/
│ ├── mod.rs # AG-UI protocol module re-exports
│ ├── types.rs # RunAgentInput, AgUiEvent, AgUiEventType, messages
│ ├── bridge.rs # AgentEvent → AG-UI event conversion
│ └── server.rs # HTTP handler: SSE run endpoint
├── config/
│ ├── mod.rs # load_config(), apply_env_overrides(), global/project paths
│ └── types.rs # Qwen Code RustConfig, ModelConfig, ProviderConfig, McpServerConfig, MemorixConfig, ServeConfig, A2aConfig
├── llm/
│ ├── mod.rs # Module re-exports
│ ├── types.rs # Message, StreamEvent, GenerateContentRequest/Response
│ ├── traits.rs # ContentGenerator trait (generate + stream)
│ ├── sse.rs # SseParser: bytes stream → SseEvent stream
│ ├── factory.rs # create_generator() — picks provider from config
│ ├── openai.rs # OpenAI-compatible provider (also covers DashScope, Groq, etc.)
│ ├── anthropic.rs # Anthropic Messages API + SSE
│ ├── gemini.rs # Google Gemini generateContent + streamGenerateContent
│ ├── azure.rs # Azure OpenAI provider
│ ├── copilot.rs # GitHub Copilot provider (OAuth token exchange + chat completions)
│ ├── copilot_responses.rs # Copilot Responses API (/responses) for codex/o3/o4-mini models
│ └── qwen_oauth.rs # Qwen with DashScope OAuth flow
├── tools/
│ ├── traits.rs # Tool trait, ToolOutput, ToolTier (Core/Standard/Extended)
│ ├── registry.rs # ToolRegistry — register, get, subset, declarations, tiered, deferred tools
│ └── builtin/
│ ├── mod.rs # register_builtin_tools(), register_task_tool(), register_tool_search(), etc.
│ ├── tool_search.rs # ToolSearchTool — fetch deferred MCP tool schemas on-demand
│ ├── read_file.rs # ReadFileTool
│ ├── write_file.rs # WriteFileTool
│ ├── edit.rs # EditFileTool (find-and-replace + unified diff)
│ ├── edit_file.rs # EditFileTool (alternative impl)
│ ├── shell.rs # ShellTool
│ ├── glob.rs # GlobTool
│ ├── grep.rs # GrepTool
│ ├── ls.rs # ListDirectoryTool
│ ├── web_fetch.rs # WebFetchTool (HTTP → stripped text)
│ ├── web_search.rs # WebSearchTool (DuckDuckGo HTML parsing)
│ ├── lsp.rs # LspTool (JSON-RPC over stdio)
│ ├── skill.rs # SkillTool (loads skill markdown via SkillManager)
│ ├── memory.rs # MemoryTool (read/write/append QCR.md files)
│ ├── task.rs # TaskTool (spawns sub-agent, supports plugin agents)
│ ├── todo_write.rs # TodoWriteTool (session task list → ~/.qcr/todos/)
│ ├── undo.rs # UndoEditTool (pop UndoStack, restore file, show diff)
│ ├── oracle.rs # OracleTool (second-opinion single-turn LLM call)
│ ├── format_file.rs # FormatFileTool (rustfmt/prettier/ruff/gofmt/shfmt/…)
│ ├── find_and_edit.rs# FindAndEditTool (multi-file regex replace, dry-run)
│ ├── mermaid.rs # MermaidTool (validate + write .md/.mmd, optional mmdc)
│ ├── now.rs # NowTool (current datetime in RFC 3339)
│ ├── thinking.rs # ThinkingTool (visible chain-of-thought scratchpad)
│ ├── create_directory.rs # CreateDirectoryTool (mkdir -p with write-safety)
│ ├── copy_path.rs # CopyPathTool (recursive copy with safety guards)
│ ├── move_path.rs # MovePathTool (move/rename with safety guards)
│ ├── delete_path.rs # DeletePathTool (safety-first deletion with deny-list)
│ ├── path_utils.rs # Shared path safety guards (resolve, protect delete/write)
│ ├── cron_create.rs # CronCreateTool (schedule recurring/one-shot tasks)
│ ├── cron_list.rs # CronListTool (list active scheduled tasks)
│ ├── cron_delete.rs # CronDeleteTool (cancel a task by ID)
│ ├── ask_user.rs # AskUserQuestionTool (multi-choice question → user answer)
│ └── config_tool.rs # ConfigTool (runtime config get/set/list with persistence)
├── conversation/
│ ├── mod.rs # Re-exports
│ ├── agent_loop.rs # run_agent_loop() — the core agentic turn loop
│ ├── events.rs # AgentEvent enum (TextDelta, ToolExecuting, Done, …)
│ ├── approval.rs # ApprovalHandler trait, AutoApproval, DenyAll, InteractiveApproval
│ ├── compression.rs # ChatCompressionService — summarize older turns
│ └── user_question.rs # UserQuestionRequest type for AskUserQuestion channel
├── session/
│ └── mod.rs # Session, save/load/list/delete to ~/.qcr/sessions/
├── scheduler/
│ └── mod.rs # Scheduler — session-scoped cron engine (tick, jitter, expiry, 50-task cap)
├── mcp/
│ └── mod.rs # McpClientManager — JSON-RPC stdio, McpTool wrapper
├── memory/
│ └── mod.rs # QCR.md loader — load_memory_for_prompt(), memory_path()
├── memorix_client/
│ └── mod.rs # MemorixClient — HTTP client for memorix semantic memory API
├── team/
│ ├── mod.rs # Re-exports: TeamConfig, TaskManager, Mailbox, TeamError
│ ├── error.rs # TeamError — all domain errors (14 variants)
│ ├── config.rs # TeamConfig, TeamMember, TeamRole, MemberStatus — team membership, atomic JSON persistence
│ ├── tasks.rs # TaskManager, TeamTask, TaskStatus — shared task list with fs4 file-lock claim protocol
│ └── mailbox.rs # Mailbox, MailboxMessage, MessageKind — file-based inter-agent messaging (7 kinds, FIFO)
├── subagent/
│ └── mod.rs # SubAgentManager — spawn isolated agent loops
├── plugins/
│ ├── mod.rs # Re-exports, plugin system overview
│ ├── metadata.rs # PluginJson, PluginAuthor — parse plugin.json
│ ├── loader.rs # Plugin, PluginScope, load_plugin() — load one plugin dir
│ ├── registry.rs # PluginRegistry — discover, deduplicate, register all plugins
│ ├── commands.rs # PluginCommand, PluginCommandRegistry — slash commands
│ ├── agents.rs # PluginAgent, PluginAgentRegistry — sub-agent definitions
│ └── hooks.rs # HookRunner, HookEvent, HookContext, HookDecision — lifecycle hooks
├── skills/
│ └── mod.rs # SkillManager — load .md files with YAML frontmatter
├── serve/
│ └── mod.rs # Unified axum HTTP server composing A2A + AG-UI routes, AgentRunner trait
├── services/
│ ├── mod.rs
│ ├── fs.rs # FileSystemService
│ ├── shell.rs # ShellExecutionService (tokio::process, timeout)
│ ├── git.rs # GitService (repo root, diff, status via git2)
│ └── undo.rs # UndoStack — per-file LIFO stack, MAX_UNDO_LEVELS=5, Arc<Mutex<>>
├── prompts/
│ ├── mod.rs # PromptContext, build_system_prompt(), collect_environment()
│ ├── default.md # Built-in default system prompt template (116 lines, 11 sections)
│ └── assembler.rs # Assemble prompt sections: base → memory → agents-md → memorix → steering → custom
├── i18n/
│ └── mod.rs # I18n — embedded locale JSON, t() / t_with()
└── util/
└── logging.rs # tracing-subscriber setup, QCR_DEBUG env
Plugins are discovered at startup from two directories:
~/.qcr/plugins/— global scope.qcr/plugins/— project-local scope (takes precedence on name collision)
Each plugin directory is loaded by load_plugin() in plugins::loader. The
PluginRegistry collects all successfully loaded plugins and exposes methods
to register their components into the rest of qcr:
PluginRegistry::discover(global_dir, project_dir)
├── register_skills(&mut SkillManager)
├── build_command_registry() → PluginCommandRegistry
├── build_agent_registry() → PluginAgentRegistry
├── build_hook_runner(project_hooks_json) → HookRunner
└── collect_mcp_servers() → HashMap<name, McpServerConfig>
The HookRunner is passed into LoopConfig so the agent loop can fire hooks
at each lifecycle point (SessionStart, PreToolUse, PostToolUse, Stop, …).
Hook commands receive a JSON object on stdin and can influence the agent via
exit codes and JSON stdout (same protocol as Claude Code hooks).
See docs/plugins.md for the full plugin and hooks reference.
Thin binary crate. Parses CLI args, then dispatches to one of four run modes.
crates/qcr-cli/src/
├── main.rs # Entry point, subcommand dispatch
├── cli.rs # clap derive structs: Cli, Command, ConfigAction, etc.
├── app.rs # run_tui / run_headless / run_print / run_agent_turn
├── keybindings.rs # Customisable key bindings (load from ~/.claude/keybindings.json)
├── acp/
│ ├── mod.rs # run_acp_server() entry point — creates AgentSideConnection over stdio
│ ├── agent.rs # Qwen Code RustAgent implementing the SDK Agent trait
│ ├── bootstrap.rs # build_registry, build_loop_config, build_system_prompt_for_acp
│ ├── convert.rs # Internal SessionUpdate → SDK SessionUpdate conversion
│ ├── session.rs # Per-session state and registry (AcpSession)
│ └── transport.rs # ndjson frame reader/writer
└── commands/
└── mod.rs # SlashCommand: /clear /help /model /plan /compact /title /sessions
main()
└─ app::run_print(cli)
└─ run_agent_turn(cli, prompt)
├─ load_config(project_root) // layered: defaults → global → project → env
├─ create_generator(&config) // factory picks provider
├─ register_builtin_tools()
├─ load_memory_for_prompt() // QCR.md files
├─ load_agents_md() // AGENTS.md / AGENT.md
├─ load_steering_files() // .qcr/steering/*.md
├─ recall_for_prompt() // memorix semantic search (if enabled)
├─ build_system_prompt(PromptContext { … }) // assembler.rs
├─ mpsc::channel::<AgentEvent>
├─ tokio::spawn(printer task) // reads events, prints to stdout
├─ run_agent_loop(
│ &mut history,
│ generator,
│ ®istry,
│ approval,
│ system_prompt,
│ &loop_config,
│ &tx, // AgentEvent sender
│ )
└─ store_session_end() // memorix auto-store (if enabled)
Each turn:
0. Auto-compression: if history > threshold (default 30 msgs) and
estimated tokens > 30K, compress older messages into summary
1. Build GenerateContentRequest (history + cached tool declarations + system_prompt)
2. generator.stream(request) → Stream<StreamEvent>
3. Consume stream (each `stream.next()` wrapped in `tokio::time::timeout(120s)`):
- TextDelta → accumulate text, emit AgentEvent::TextDelta
- ThinkingDelta → emit AgentEvent::ThinkingDelta
- ToolCallStart → record tool call start
- ToolCallDelta → accumulate arguments
- ToolCallEnd → tool call complete
- Usage → record token counts
- Done → break stream
- Timeout (120s) → emit AgentEvent::Error("stream stalled"), return Err
4. If tool calls present:
a. approval_handler.approve(tool_name, tool_input) → bool
b. If approved: tool.execute(params) → ToolOutput
c. Truncate oversized tool results (>50K chars) to prevent context blowout
d. Emit AgentEvent::ToolExecuting / ToolResult
e. Append ToolResultMessage to history
f. Loop back to step 1
5. If no tool calls: emit AgentEvent::Done, return
run_tui(cli)
├─ load_config + create_generator + register_builtin_tools
├─ load_memory_for_prompt() // QCR.md files
├─ load_agents_md() // AGENTS.md / AGENT.md
├─ load_steering_files() // .qcr/steering/*.md
├─ recall_for_prompt() // memorix semantic search (if enabled)
├─ build_system_prompt(PromptContext { … }) // assembler.rs
├─ App::new(model, provider)
├─ terminal setup (crossterm raw mode + alternate screen)
├─ run_event_loop(terminal, app, generator, registry, ...)
│ ├─ tokio::select!:
│ │ ├─ crossterm::event::EventStream → TuiEvent::Key
│ │ ├─ agent_rx.recv() → TuiEvent::Agent
│ │ │ └─ None (sender dropped) → reset processing + push error msg
│ │ └─ tick_interval.tick() → TuiEvent::Tick
│ ├─ TuiEvent::Key → handle_key_event (input, slash commands, submit)
│ ├─ TuiEvent::Agent → handle_agent_event (update app state, set dirty=true)
│ ├─ TuiEvent::Tick → spinner + re-render
│ └─ [drain loop] → try_recv() all pending agent events before next draw
│ ├─ batches burst tokens into one redraw cycle (prevents render lag)
│ ├─ Terminal event during drain → teardown + optional auto-continue
│ └─ Disconnected → reset processing + push error msg
└─ store_session_end() // memorix auto-store (if enabled)
reqwest response bytes_stream
└─ SseParser (buffered line reader)
└─ SseEvent { event, data, id }
└─ openai_sse_to_stream_events()
└─ StreamState::process_chunk() (accumulates tool call deltas)
└─ Vec<StreamEvent> (TextDelta / ToolCallStart / ToolCallDelta / …)
└─ state.pending Vec (drained at top of unfold loop)
└─ agent_loop consumes stream
Key invariant: the unfold drains state.pending before reading the next SSE chunk, so no events are lost even when a single SSE chunk produces multiple StreamEvents.
memorix_client provides a thin async HTTP client that talks to the memorix
REST API (default http://localhost:8420). It is used at two lifecycle points:
Session Start
└─ recall_for_prompt(&config.memorix, project_name, query)
┌─ tokio::join! (parallel) ─────────────────────────────┐
│ POST /session { tenant, project, agent, objective } │ ← P11: session with goal
│ GET /context { tenant, project, agent, limit } │ ← includes active tasks
└────────────────────────────────────────────────────────┘
← pre-formatted markdown (sessions + tasks + typed memories, ~500 tokens)
→ wrapped in <memorix-context> block
Fallback (if 404): POST /search { tenant, project, agent, query, top_k }
← Vec<MemoryResult> → formatted as <memories project="..."> block
→ injected into system prompt after QCR.md, before base prompt
Session End
└─ store_session_end(&config.memorix, project_name, last_assistant_message)
build_session_summary() → "[project] [date] summary..."
POST /memory {
tenant, project, agent, text, confidence: 0.9,
tags: ["auto-session"],
title: "[project] date session",
memory_type: "session_summary",
topic_key: "session/project/date" ← dedup by day
}
→ fire-and-forget, failures logged via tracing::warn
Task CRUD (P11, programmatic)
└─ MemorixClient methods (not yet auto-wired, available for integration):
POST /task { title, priority, session_id, tenant, project, agent } → task_id
PATCH /task/:id { status, tenant, project, agent }
GET /tasks ?tenant&project&agent&status&session_id → Vec<MemorixTask>
All memorix operations are non-critical. If memorix is unreachable, disabled, or returns errors, qcr behaves identically to before — no errors surface to the user or LLM. The client enforces a 2s connect timeout and 5s request timeout.
Configuration is via MemorixConfig in config/types.rs, exposed as the
memorix field on Qwen Code RustConfig. The QCR_MEMORIX_URL env var overrides
memorix.url. See docs/configuration.md for field details.
Layers are merged in order (later layers win):
1. Defaults (Qwen Code RustConfig::default())
2. Global file: ~/.qcr/settings.json
3. Project file: <project_root>/.qcr/settings.json
4. Environment variables:
QCR_MODEL, QCR_PROVIDER, QCR_MAX_TOKENS, QCR_TEMPERATURE,
OPENAI_API_KEY, ANTHROPIC_API_KEY, DASHSCOPE_API_KEY, OPENROUTER_API_KEY,
OPENAI_API_BASE, QCR_API_BASE,
QCR_APPROVAL_MODE, QCR_MAX_TURNS, QCR_MEMORIX_URL, QCR_DEBUG
Every tool implements the Tool trait:
#[async_trait]
pub trait Tool: Send + Sync {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn parameters_schema(&self) -> Value; // JSON Schema object
fn requires_approval(&self) -> bool; // default true
fn is_read_only(&self) -> bool; // default false
fn tier(&self) -> ToolTier; // default Standard (Core/Standard/Extended)
async fn execute(&self, params: Value) -> Result<ToolOutput, ToolError>;
}ToolOutput has content: String, is_error: bool, and display: Option<ToolDisplay>. The content is fed back to the LLM as a ToolResultMessage; display carries structured data (e.g. TodoListDisplay) for richer TUI rendering.
The ToolRegistry holds Arc<dyn Tool> objects keyed by name. The agent loop calls registry.to_declarations() to build the tools list sent to the LLM, then routes each ToolCallEnd to the matching tool. Tool declarations are cached across turns within a loop to avoid regenerating 30+ schemas every turn.
Deferred tools: MCP tools are registered as deferred via registry.register_deferred(). Deferred tools are excluded from to_declarations() (saving tokens) but remain executable. Their names appear in the <available-deferred-tools> prompt block. The ToolSearch tool lets the LLM fetch full schemas on-demand.
Tools are classified into three tiers for token-budget-aware selection:
| Tier | Tools | Purpose |
|---|---|---|
| Core | ReadFile, WriteFile, EditFile, EditTool, Shell, Grep, Glob, ListDirectory, Memory, Thinking | Always included (~10 essential tools) |
| Standard | WebFetch, WebSearch, AskUser, TodoWrite, GetSkill, Task, Config, CopyPath, MovePath, DeletePath, CreateDirectory | Included by default |
| Extended | Browser, Mermaid, Lsp, Now, FormatFile, FindAndEdit, UndoEdit, CronCreate, CronList, CronDelete | Specialized, can be excluded to save ~8-15K tokens/request |
Use registry.to_declarations_tiered(ToolTier::Core) for lightweight sub-agent contexts.
UndoStack (in services/undo.rs) provides per-file LIFO undo for all write operations. It is stored as Arc<Mutex<UndoStack>> and shared across tools.
EditFileTool,WriteFileTool, and the line-range editor each callpush(path, old_content)before writing.UndoEditToolcallspop(path)to restore the previous content and emits a unified diff.- Up to 5 undo levels are retained per file (
MAX_UNDO_LEVELS). - The
/undoslash command is a thin wrapper that invokesUndoEditToolwith the most-recently-touched path.
prompts/assembler.rs builds the final system prompt from a PromptContext struct. Two entry points:
build_system_prompt(&ctx)→ singleString(backward compat for all providers)build_system_prompt_blocks(&ctx)→SystemPromptBlocks(structured blocks with caching hints)
Block 1 (cacheable):
1. base ← prompts/default.md (built-in template with placeholder substitution)
Block 2 (dynamic, not cacheable):
2. sandbox ← Sandbox mode rules (if active)
3. memory ← QCR.md files loaded by load_memory_for_prompt()
4. agents_md ← AGENTS.md / AGENT.md loaded by load_agents_md()
5. memorix_recall ← semantic search results from MemorixClient
6. steering ← .qcr/steering/*.md files (XML-wrapped, size-limited)
7. plugins_context ← plugin listing + skill descriptions (lazy, no instructions)
8. deferred_tools ← <available-deferred-tools> block (MCP tool names)
9. custom_instructions← user-supplied text from config or --system-prompt flag
SystemPromptBlocks splits the prompt into cacheable and non-cacheable blocks:
- Anthropic:
systemfield sent as array of{type: "text", text, cache_control: {type: "ephemeral"}}blocks. Cache tokens reported viacache_read_input_tokens/cache_creation_input_tokens. - OpenAI/Azure/Copilot: system message sent as structured content array with
cache_controlon cacheable blocks (via sharedbuild_openai_system_message()helper). OpenAI auto-caches by prefix matching. - Gemini: uses
system_instructionas plain string (no caching API in responses).
Skills are listed as name + description only in the <plugins> section. Full instructions are loaded on-demand via the GetSkill tool. Saves ~15-25K tokens/turn with 7 built-in skills.
MCP tools are registered as deferred — their schemas are excluded from the API tools array. A <available-deferred-tools> block lists their names in the prompt. The ToolSearch built-in tool fetches full schemas on-demand.
Placeholder substitution in default.md replaces {{DATETIME}}, {{OS}}, {{SHELL}}, {{CWD}}, {{GIT_BRANCH}}, {{GIT_STATUS}} from collect_environment() at assembly time.
Steering files (.qcr/steering/*.md) are loaded sorted, each capped at 100 KB, whitespace-only files skipped. They wrap each file in <steering file="name.md">…</steering> tags.
OracleConfig in config/types.rs configures the second-opinion LLM used by OracleTool:
oracle:
enabled: bool (default false)
provider: String (default: same as main provider)
model: String (default: same as main model)
When oracle.enabled is false, OracleTool falls back to the main provider. This allows routing advisory queries to a cheaper/faster model without changing the main conversation model.
Qwen Code RustError
├── Config(ConfigError) — file not found, parse error, missing required field
├── Auth(AuthError) — missing API key, invalid credentials, OAuth failure
├── Llm(LlmError) — HTTP error, API error, rate limit, stream error
├── Tool(ToolError) — not found, invalid params, execution failed, timeout
├── Conversation(ConversationError) — loop detected, max turns
├── Mcp(McpError) — server connection, protocol error
├── Session(SessionError) — save/load/delete failure
├── Team(TeamError) — team coordination errors
└── Serve(ServeError) — HTTP server bind, A2A task errors, JSON-RPC errors
All variants implement std::error::Error + Send + Sync + 'static.
Sessions are stored as JSON at ~/.qcr/sessions/<id>.json.
pub struct Session {
pub id: String,
pub title: Option<String>,
pub model: Option<String>,
pub provider: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub messages: Vec<Message>,
pub metadata: HashMap<String, Value>,
}The TUI auto-saves after each agent turn. Sessions can be resumed with qcr session resume <id> or the --resume <id> flag.
qcr serve starts a unified HTTP server (axum) that exposes two interoperability protocols:
Implements the Google A2A protocol for agent-to-agent communication:
GET /.well-known/agent.json → AgentCard (identity, capabilities, skills)
POST /a2a → JSON-RPC 2.0 dispatch:
message/send → sync task execution, returns completed Task
message/stream → SSE stream of TaskStatusUpdate / TaskArtifactUpdate
tasks/get → retrieve task by ID
tasks/cancel → cancel running task
Architecture: JsonRpcRequest → A2A Server → TaskStore + AgentRunner → AgentEvent stream → A2A Bridge → SSE/JSON-RPC Response
Implements the CopilotKit AG-UI protocol for frontend integration:
POST /ag-ui/run → SSE event stream:
RUN_STARTED, RUN_FINISHED, RUN_ERROR
TEXT_MESSAGE_START/CONTENT/END
TOOL_CALL_START/ARGS/END/RESULT
REASONING_MESSAGE_START/CONTENT/END
STATE_SNAPSHOT, STEP_STARTED/FINISHED
Architecture: RunAgentInput → AG-UI Server → AgentRunner → AgentEvent stream → AG-UI Bridge → SSE Events
Both protocols share:
AgentRunnertrait — abstraction for running the agent loop. The CLI provides a real implementation; tests use a mock.ServeState— shared axum state containing config, TaskStore, AgentRunner, and tool names.- Event bridges — convert
AgentEventstream to protocol-specific wire format events. - CORS — configurable via
serve.cors_originsin settings.
Frontend App <──AG-UI──> qcr serve <──A2A──> Other Agents
│
└── ContentGenerator + ToolRegistry + AgentLoop
│
└──MCP──> Tool Servers
Minimal React + TypeScript chat UI that connects to qcr serve via the AG-UI SSE endpoint.
qcr serve (:4200) → POST /ag-ui/run → SSE → JS Adapter → <Thread />
Stack: Vite + React 19 + @assistant-ui/react + @assistant-ui/react-ui. No CopilotKit, no Vercel AI SDK.
The adapter (src/ag-ui-runtime.ts) reads the SSE stream, accumulates content parts (text, reasoning, tool-call), and yields ChatModelRunResult snapshots to the useLocalRuntime hook. The <Thread /> component from @assistant-ui/react-ui renders the full chat interface.
cd examples/web-ui && npm install && npm run dev # http://localhost:3000McpClientManager connects to MCP servers defined in config. Each server runs as a child process communicating over stdio with JSON-RPC 2.0.
On startup: discover_and_register() sends initialize + tools/list to each server and wraps each returned tool as a McpTool (implements Tool trait). MCP tools are registered as deferred — their full JSON schemas are not sent in the API tools array, saving significant tokens. The ToolSearch built-in tool fetches schemas on-demand when the LLM needs to invoke a deferred tool. Failures are isolated per server and logged as warnings.
Scheduler provides session-scoped cron scheduling via Arc<Scheduler> shared
between the TUI tick loop and the three cron tools (CronCreate, CronList,
CronDelete). The /loop slash command is syntactic sugar that converts a
human-friendly interval into a cron expression and calls scheduler.create().
Key properties:
- Tick: called every 100ms from the TUI event loop; returns due prompts
- Cron parser: custom 5-field parser (minute, hour, day-of-month, month, day-of-week) with
*, ranges, steps, and lists - Jitter: deterministic offset per task (SHA-256 of task ID), up to 10% of period capped at 15 minutes
- Expiry: recurring tasks auto-expire after 3 days; one-shot tasks delete after firing
- Capacity: maximum 50 tasks per session
- Disable: set
QCR_DISABLE_CRON=1to create a disabled scheduler (tools return errors,/loopis rejected)
Tasks are stored in a Mutex<Vec<ScheduledTask>> and never touch the filesystem.
TaskTool spawns an isolated run_agent_loop via SubAgentManager. The
sub-agent gets its own history, a snapshot of the tool registry (without
TaskTool itself, preventing recursive spawning), and its own event channel.
Results are collected and returned as a ToolOutput string to the parent agent.
When a plugin agent name is provided via the agent parameter, TaskTool
looks up the PluginAgent in the PluginAgentRegistry and applies its
system_prompt, tools restriction, and max_turns to the sub-agent. This
allows plugins to define specialised sub-agents (e.g. a code reviewer, a
test writer) that the LLM can delegate to by name. An explicit tools
parameter on the Task call overrides the agent's tool list.
SubAgentManager drives the agent loop and collects events to build a
SubAgentResult containing the accumulated output, turn count, and tool
call count.
The team/ module provides file-based coordination primitives for running
multiple Qwen Code Rust instances as a collaborative team. All state is on disk so
multiple OS processes can participate without a central coordinator.
Enable via QCR_EXPERIMENTAL_AGENT_TEAMS=1 or team.enabled = true in
settings. See docs/agent-teams.md for the full user guide.
TeamConfig (team/config.rs)
Describes team membership. Stored atomically at
~/.qcr/teams/{team-name}/config.json.
name— unique team namelead_id— agent ID of the team leadmembers[]— list ofTeamMember(name, agent_id, role, status)TeamRole:Lead|TeammateMemberStatus:Idle|Running|Done|Failed
All writes go through write_tmp → rename to prevent partial reads.
TaskManager (team/tasks.rs)
Manages the shared task list. Each task is a JSON file under
~/.qcr/tasks/{team-name}/{task-id}.json.
TeamTaskfields:id,title,status,assigned_to,depends_on[],created_at,claimed_at,completed_atTaskStatus:Pending|InProgress|Completed- Claim protocol:
claim()acquires anfs4exclusive file lock before reading and writing; exactly one agent wins when multiple agents race. - Dependency resolution:
is_claimable()checks alldepends_onentries live from disk; blocked tasks returnTaskBlocked. - Atomic writes: all file updates use
write_tmp → rename.
Task lifecycle:
Pending ──(claim)──> InProgress ──(complete)──> Completed
^
blocked if any depends_on entry is not yet Completed
Mailbox (team/mailbox.rs)
File-based inter-agent messaging. Each agent has an inbox directory at
~/.qcr/teams/{team-name}/mailbox/{agent-id}/. Messages are written
atomically and deleted after being read.
MailboxMessagefields:id,kind,from_agent_id,from_name,to_agent_id,content,sent_atMessageKind:Message|ShutdownRequest|ShutdownAck|PlanApprovalRequest|PlanApprovalResponse|Broadcast|TaskCompleted- Filenames are
{timestamp_ns}_{uuid}.json— FIFO ordering without a central queue. receive_all()/poll_once()delete messages after processing.
~/.qcr/
├── teams/
│ └── {team-name}/
│ ├── config.json
│ └── mailbox/
│ └── {agent-id}/
│ └── {ts}_{uuid}.json
└── tasks/
└── {team-name}/
└── {task-id}.json
| Operation | Guarantee |
|---|---|
| Task claim | Exclusive fs4 file lock; exactly one agent wins per task |
| Task/config write | Atomic write_tmp + rename; readers never see partial files |
| Message delivery | Atomic write per message; FIFO order within a sender |
| Message read | Delete-after-read; each message processed exactly once |
TeamError integrates into Qwen Code RustError::Team via #[from]. Key variants:
| Variant | Meaning |
|---|---|
FeatureNotEnabled |
Feature flag not set |
TeamAlreadyExists |
Team name collision |
TaskAlreadyClaimed |
Another agent won the file-lock race |
TaskBlocked |
Unsatisfied depends_on entries |
ActiveTeammates |
Cleanup attempted while teammates still Running |
LockTimeout |
Could not acquire fs4 lock within the configured timeout |