Skip to content

Latest commit

 

History

History
732 lines (584 loc) · 35.2 KB

File metadata and controls

732 lines (584 loc) · 35.2 KB

Architecture

Crate Layout

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

qcr-core

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

Plugin System

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.


qcr-cli

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

Data Flow

Print / Headless Mode

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,
            │      &registry,
            │      approval,
            │      system_prompt,
            │      &loop_config,
            │      &tx,               // AgentEvent sender
            │  )
            └─ store_session_end()         // memorix auto-store (if enabled)

Agent Loop (run_agent_loop)

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

TUI Mode

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)

SSE Streaming Pipeline

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 Integration (Semantic Memory)

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.


Configuration Layering

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

Tool Execution

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.

Tool Tiers

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.


Undo Stack

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 call push(path, old_content) before writing.
  • UndoEditTool calls pop(path) to restore the previous content and emits a unified diff.
  • Up to 5 undo levels are retained per file (MAX_UNDO_LEVELS).
  • The /undo slash command is a thin wrapper that invokes UndoEditTool with the most-recently-touched path.

System Prompt Assembly

prompts/assembler.rs builds the final system prompt from a PromptContext struct. Two entry points:

  • build_system_prompt(&ctx) → single String (backward compat for all providers)
  • build_system_prompt_blocks(&ctx)SystemPromptBlocks (structured blocks with caching hints)

Assembly order

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

Prompt Caching (v0.1.20+)

SystemPromptBlocks splits the prompt into cacheable and non-cacheable blocks:

  • Anthropic: system field sent as array of {type: "text", text, cache_control: {type: "ephemeral"}} blocks. Cache tokens reported via cache_read_input_tokens / cache_creation_input_tokens.
  • OpenAI/Azure/Copilot: system message sent as structured content array with cache_control on cacheable blocks (via shared build_openai_system_message() helper). OpenAI auto-caches by prefix matching.
  • Gemini: uses system_instruction as plain string (no caching API in responses).

Lazy Skill Loading (v0.1.20+)

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.

Deferred MCP Tools (v0.1.20+)

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

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.


Error Hierarchy

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.


Session Persistence

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.


A2A & AG-UI Protocol Server

qcr serve starts a unified HTTP server (axum) that exposes two interoperability protocols:

A2A (Agent-to-Agent)

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

AG-UI (Agent-User Interface)

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

Shared Infrastructure

Both protocols share:

  • AgentRunner trait — 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 AgentEvent stream to protocol-specific wire format events.
  • CORS — configurable via serve.cors_origins in settings.

Protocol Stack

Frontend App  <──AG-UI──>  qcr serve  <──A2A──>  Other Agents
                                │
                                └── ContentGenerator + ToolRegistry + AgentLoop
                                         │
                                         └──MCP──>  Tool Servers

Examples

Web UI (examples/web-ui/)

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:3000

MCP Integration

McpClientManager 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 (Session-Scoped Cron)

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=1 to create a disabled scheduler (tools return errors, /loop is rejected)

Tasks are stored in a Mutex<Vec<ScheduledTask>> and never touch the filesystem.


Sub-Agents

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.


Agent Teams (Experimental)

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.

Components

TeamConfig (team/config.rs)

Describes team membership. Stored atomically at ~/.qcr/teams/{team-name}/config.json.

  • name — unique team name
  • lead_id — agent ID of the team lead
  • members[] — list of TeamMember (name, agent_id, role, status)
  • TeamRole: Lead | Teammate
  • MemberStatus: 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.

  • TeamTask fields: id, title, status, assigned_to, depends_on[], created_at, claimed_at, completed_at
  • TaskStatus: Pending | InProgress | Completed
  • Claim protocol: claim() acquires an fs4 exclusive file lock before reading and writing; exactly one agent wins when multiple agents race.
  • Dependency resolution: is_claimable() checks all depends_on entries live from disk; blocked tasks return TaskBlocked.
  • 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.

  • MailboxMessage fields: id, kind, from_agent_id, from_name, to_agent_id, content, sent_at
  • MessageKind: 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.

File Layout

~/.qcr/
├── teams/
│   └── {team-name}/
│       ├── config.json
│       └── mailbox/
│           └── {agent-id}/
│               └── {ts}_{uuid}.json
└── tasks/
    └── {team-name}/
        └── {task-id}.json

Concurrency Guarantees

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

Error Variants (TeamError)

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