Skip to content

Latest commit

 

History

History
485 lines (384 loc) · 18.2 KB

File metadata and controls

485 lines (384 loc) · 18.2 KB

Development Guide

v0.1.17 — Memorix P11 upgrade (session objectives, task CRUD, parallel recall). Copilot Responses API, model failover, tool tiers, smart compression.

Prerequisites

  • Rust 1.85+ (edition 2024)
  • macOS or Linux (Windows supported but not primary)
  • just (optional, for Justfile shortcuts)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
cargo install just   # optional

Building

cargo build                    # debug
cargo build --release          # optimized
cargo build --release --bin qcr

Via Justfile:

just build
just release

Running Tests

cargo test --workspace                          # all tests
cargo test -p qcr-core --lib                   # core unit tests only
cargo test -p qcr-cli --test headless_test        # E2E headless tests
cargo test -p qcr-cli --test file_creation_test
cargo test -p qcr-cli --test acp_integration      # ACP ndjson stdio protocol tests

Test count baseline: 1948+ tests (core + cli + ACP integration + doc tests).

Running E2E Tests

E2E tests spin up a wiremock mock LLM server. No real API key is needed — the binary receives env vars pointing to localhost. Tests run fully offline.

cargo test -p qcr-cli 2>&1 | grep "test result"

Live Testing

OPENAI_API_BASE=https://aiproxies.win/v1 \
OPENAI_API_KEY=<key> \
QCR_MODEL=gemini-2.5-flash \
QCR_PROVIDER=openai \
QCR_APPROVAL_MODE=never \
./target/debug/qcr --print "Say hello"

Debug Logging

qcr writes structured trace logs to ~/.qcr/qcr.log when the QCR_LOG environment variable is set. Logs are written to a file rather than stderr so they do not pollute the TUI.

# Capture warnings and errors (recommended for provider debugging)
QCR_LOG=warn qcr --provider copilot --model claude-sonnet-4.6 "your prompt"

# Capture all debug output from the core library
QCR_LOG=qcr_core=debug qcr --provider copilot "your prompt"

# Follow the log in another terminal
tail -f ~/.qcr/qcr.log

The filter syntax follows tracing-subscriber EnvFilter:

Filter Captures
warn Warnings and errors from all modules
qcr_core=debug All debug output from qcr-core
qcr_core::llm=debug LLM request/response bodies only
qcr_core::llm::copilot=debug Copilot provider specifically
off Nothing (default when QCR_LOG is unset)

On a 400 error from a provider, the log includes the full serialised request body and the raw response body — useful for diagnosing wire-format issues.

Linting

cargo clippy --workspace -- -D warnings
cargo fmt --all --check

Via Justfile:

just lint
just fmt

Benchmarks

cargo bench --no-run -p qcr-core          # compile-check only (fast)
cargo bench -p qcr-core                   # run all benchmarks
cargo bench -p qcr-core --bench sse_parsing   # run one target

# Quick smoke-test with short sample size
cargo bench -p qcr-core --bench sse_parsing -- --sample-size 10 --warm-up-time 1 --measurement-time 2

Nine bench targets in crates/qcr-core/benches/: sse_parsing, session_serialization, glob_search, skill_loading, i18n_lookup, agent_loop, tool_execution, config_loading, provider_pipeline.

See docs/benchmarks.md for the full guide: writing new benchmarks, shared helpers (MockGenerator, CountingAllocator), regression gating with scripts/bench_compare.py, and the initial baseline results.

Project Structure

qcr/
├── crates/
│   ├── qcr-core/          # library crate — all business logic
│   │   ├── src/
│   │   │   ├── lib.rs
│   │   │   ├── config/     # layered config loading
│   │   │   ├── conversation/   # agent loop, approval, compression
│   │   │   ├── error.rs    # error hierarchy
│   │   │   ├── i18n/       # locale support
│   │   │   ├── llm/        # providers, SSE parser, types
│   │   │   ├── mcp/        # MCP client manager
│   │   │   ├── memory/     # QCR.md loader + AGENTS.md loader + steering files
│   │   │   ├── memorix_client/ # memorix HTTP client (P11: sessions, tasks, context)
│   │   │   ├── plugins/    # plugin system (commands, agents, hooks, MCP)
│   │   │   ├── prompts/    # built-in system prompt template + assembler + embedded skill/template files
│   │   │   ├── services/   # fs, git, shell, undo stack
│   │   │   ├── session/    # session persistence
│   │   │   ├── skills/     # skill manager
│   │   │   ├── subagent/   # sub-agent spawning
│   │   │   ├── telemetry/  # usage telemetry
│   │   │   ├── tools/      # trait + registry + built-ins
│   │   │   └── util/       # logging
│   │   └── benches/        # criterion benchmarks
│   │       ├── common/     # shared helpers (MockGenerator, CountingAllocator, fixtures)
│   │       ├── agent_loop.rs
│   │       ├── config_loading.rs
│   │       ├── glob_search.rs
│   │       ├── i18n_lookup.rs
│   │       ├── provider_pipeline.rs
│   │       ├── session_serialization.rs
│   │       ├── skill_loading.rs
│   │       ├── sse_parsing.rs
│   │       └── tool_execution.rs
│   └── qcr-cli/           # binary crate — TUI + CLI
│       └── src/
│           ├── main.rs
│           ├── app.rs      # TUI app state + event loop
│           ├── cli.rs      # clap argument structs
│           ├── keybindings.rs  # customisable key bindings (load from ~/.claude/keybindings.json)
│           ├── acp/        # ACP (Agent Client Protocol) mode for IDE integration
│           │   ├── mod.rs        # run_acp_server() entry point
│           │   ├── agent.rs      # Qwen Code RustAgent — implements SDK Agent trait
│           │   ├── bootstrap.rs  # build_registry, build_loop_config, build_system_prompt_for_acp
│           │   ├── convert.rs    # SessionUpdate → SDK SessionUpdate conversion
│           │   ├── session.rs    # per-session state (AcpSession)
│           │   └── transport.rs  # ndjson frame reader/writer
│           └── commands/   # slash commands: /clear /help /model /plan /compact /undo /spec /title /sessions
├── examples/
│   └── web-ui/             # React + assistant-ui chat UI (connects to qcr serve AG-UI)
├── .github/workflows/
│   ├── ci.yml              # build + test + lint matrix
│   ├── bench.yml           # benchmark regression gate (PR + main baseline upload)
│   └── release.yml         # cross-platform binary releases on tag
├── .kiro/specs/            # project spec and task backlog
├── docs/                   # this documentation
├── scripts/
│   └── bench_compare.py    # criterion JSON regression comparison tool
├── tasks/                  # session todo files
├── Cargo.toml              # workspace manifest
└── Justfile

Adding a New Built-in Tool

  1. Create crates/qcr-core/src/tools/builtin/<name>.rs:
use async_trait::async_trait;
use serde_json::{Value, json};
use crate::error::ToolError;
use crate::tools::traits::{Tool, ToolOutput};

pub struct MyTool;

#[async_trait]
impl Tool for MyTool {
    fn name(&self) -> &str { "MyTool" }
    fn description(&self) -> &str { "Does something useful." }

    fn parameters_schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "input": { "type": "string", "description": "The input." }
            },
            "required": ["input"]
        })
    }

    fn requires_approval(&self) -> bool { false }
    fn is_read_only(&self) -> bool { true }

    async fn execute(&self, params: Value) -> Result<ToolOutput, ToolError> {
        let input = params["input"].as_str()
            .ok_or_else(|| ToolError::InvalidParams {
                tool: "MyTool".into(),
                message: "missing 'input'".into(),
            })?;
        Ok(ToolOutput::ok(format!("Result: {input}")))
    }
}
  1. Add pub mod my_tool; to crates/qcr-core/src/tools/builtin/mod.rs.

Built-in tools in crates/qcr-core/src/tools/builtin/:

File Tool Notes
read_file.rs ReadFileTool line-range support
write_file.rs WriteFileTool pushes to UndoStack before write
edit.rs / edit_file.rs EditFileTool find-and-replace, unified diff, pushes to UndoStack
shell.rs ShellTool timeout, working dir override
glob.rs GlobTool gitignore-aware
grep.rs GrepTool regex, case-insensitive flag
ls.rs ListDirectoryTool type + size metadata
web_fetch.rs WebFetchTool HTTP → stripped plain text
web_search.rs WebSearchTool DuckDuckGo HTML parse
lsp.rs LspTool one-shot JSON-RPC over stdio
skill.rs SkillTool loads .md via SkillManager (built-ins + disk + plugins)
memory.rs MemoryTool read/write/append QCR.md
task.rs TaskTool sub-agent, optional plugin agent
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/taplo/stylua
find_and_edit.rs FindAndEditTool multi-file regex replace, dry-run mode
mermaid.rs MermaidTool validate + write .md/.mmd, optional mmdc render
ask_user.rs AskUserQuestionTool multi-choice question → user answer via TUI channel
config_tool.rs ConfigTool runtime config get/set/list with persistence
  1. Register in register_builtin_tools():
let _ = registry.register(Arc::new(my_tool::MyTool));
  1. Add tests inside the module under #[cfg(test)].

  2. Run cargo test -p qcr-core --lib tools::builtin::my_tool to verify.

Adding a Built-in Skill

Built-in skills are compiled into the binary via include_str! and are always available without any .qcr/skills/ directory. User-provided skills with the same name always take precedence (loaded from disk first; built-ins use insert_if_absent).

  1. Create the skill body in crates/qcr-core/src/prompts/:
---
name: my-skill
description: Short description shown in skill listings.
tags: [tag1, tag2]
---

# My Skill

Instruction content here...
  1. Expose a constant in crates/qcr-core/src/prompts/mod.rs:
pub const BUILTIN_MY_SKILL: &str = include_str!("my-skill.md");
  1. Register in SkillManager::register_builtin_skills() in crates/qcr-core/src/skills/mod.rs:
self.insert_if_absent(Skill {
    name: "my-skill".to_string(),
    description: "Short description.".to_string(),
    instructions: crate::prompts::BUILTIN_MY_SKILL.to_string(),
    tags: vec!["tag1".to_string(), "tag2".to_string()],
    source_path: PathBuf::from("<built-in>"),
});
  1. Add tests in skills/mod.rs under #[cfg(test)]:

    • my_skill_registered_by_defaultmgr.get("my-skill").is_some()
    • my_skill_not_overwritten_when_user_skill_exists — user skill wins
    • my_skill_listed_in_available — appears in mgr.list()
  2. Add a constant test in prompts/mod.rs:

#[test]
fn builtin_my_skill_not_empty() {
    assert!(!BUILTIN_MY_SKILL.is_empty());
}
  1. Wire is automatic — register_builtin_skills() is already called in app.rs after load_from_dir() in all three entry points (TUI, headless, ACP session).

Built-in skills shipped with qcr:

Skill name File Description
spec-driven-development prompts/skill.md Requirements → Design → Tasks → E2E Verify pipeline for complex features. Five phases including unconditional Phase 5 verification gate.
agent-browser skills/agent-browser/SKILL.md Browser automation via agent-browser CLI — navigate, click, fill, screenshot.
dogfood skills/dogfood/SKILL.md Systematic QA / exploratory testing. Produces structured bug reports with screenshots and repro steps.
e2e-fix skills/e2e-fix/SKILL.md Run a user flow, find bugs, fix them in code, re-run to verify. Loops until all issues resolved.
electron skills/electron/SKILL.md Automate Electron desktop apps via Chrome DevTools Protocol.
slack skills/slack/SKILL.md Interact with Slack workspaces using browser automation.
vercel-sandbox skills/vercel-sandbox/SKILL.md Run agent-browser + Chrome inside Vercel Sandbox microVMs.

User-provided or plugin skills with the same name always take precedence — built-ins are inserted with insert_if_absent and are never overwritten.

Multi-Feature Decomposition & E2E Verification (v0.1.9)

When the user's request involves multiple independent features or is clearly too large for a single spec, the system prompt instructs the agent to:

  1. Decompose — create one .kiro/specs/<feature-slug>/ spec per feature via the spec-driven-development skill.
  2. Track — maintain a master TodoWrite listing all specs in dependency order.
  3. Execute one at a time — never interleave tasks from different specs.
  4. Verify before continuing — after all Phase 4 tasks are done for a spec, run Phase 5:
    • Web/UI project: load GetSkill("e2e-fix"), run the verification loop, fix all bugs until 0 remain.
    • Library/CLI: run the full test suite; write extra integration tests if coverage is thin.
  5. Gate is unconditional — the verify-before-continue gate applies regardless of auto-continue state.

Detection heuristics (any one triggers decomposition):

  • 2+ distinct named features in the request
  • Scope spans 5+ unrelated files or 3+ distinct concerns
  • Estimated task count exceeds 15

Tool Design Guidelines

  • requires_approval() -> bool: return true for any tool that writes files, runs shell commands, or makes network requests that mutate state.
  • is_read_only() -> bool: return true only for purely read operations (no side effects).
  • Always return Ok(ToolOutput::error(...)) for user-facing errors (bad params, file not found). Use Err(ToolError::...) only for programmer errors or truly unexpected failures.
  • Keep execute() focused. Delegate I/O to tokio::fs / tokio::process directly; no need for an intermediate service layer for simple tools.

Adding a New LLM Provider

  1. Create crates/qcr-core/src/llm/<provider>.rs implementing ContentGenerator:
use async_trait::async_trait;
use crate::llm::traits::ContentGenerator;
use crate::llm::types::{GenerateContentRequest, GenerateContentResponse, StreamEventStream};
use crate::error::LlmError;

pub struct MyProvider { /* fields */ }

#[async_trait]
impl ContentGenerator for MyProvider {
    async fn generate(&self, req: GenerateContentRequest)
        -> Result<GenerateContentResponse, LlmError> { todo!() }

    async fn stream(&self, req: GenerateContentRequest)
        -> Result<StreamEventStream, LlmError> { todo!() }

    fn model_name(&self) -> &str { "my-model" }
}
  1. Wire into crates/qcr-core/src/llm/factory.rs create_generator() match.

  2. Add provider name → constructor mapping and API key env var resolution.

Wire-format rules for OpenAI-compatible providers

When serialising conversation history to the OpenAI chat completions wire format, always emit "content" on every assistant message, even when the turn contains only tool calls and no text. Omitting the field causes a 400 Bad Request from Copilot, Azure, and many OpenAI-compatible proxies.

// Correct — content: null when no text, string when there is text
serde_json::json!({
    "role": "assistant",
    "content": if assistant.text.is_empty() {
        serde_json::Value::Null
    } else {
        serde_json::Value::String(assistant.text.clone())
    },
    // tool_calls appended separately if non-empty
})

// Wrong — omitting content entirely causes 400 Bad Request
serde_json::json!({
    "role": "assistant",
    // "content" field absent ← 400
})

This rule applies to openai.rs, copilot.rs, and azure.rs (fixed in v0.1.2). Add a regression test for any new provider that handles tool calls:

#[test]
fn assistant_tool_call_only_message_has_null_content() {
    let msg = Message::Assistant(AssistantMessage {
        text: "".to_string(),
        thinking: None,
        tool_calls: vec![ToolCall { id: "c1".into(), name: "ReadFile".into(),
            arguments: serde_json::json!({"path": "a.rs"}) }],
    });
    let json = message_to_json(&msg);
    assert_eq!(json["content"], serde_json::Value::Null);
}

Config File Format

Global config lives at ~/.qcr/settings.json. Project config at .qcr/settings.json. Both are Qwen Code RustConfig JSON (see docs/configuration.md).

Quick example:

{
  "model": { "model_id": "gemini-2.5-flash", "provider": "openai" },
  "providers": {
    "openai": {
      "name": "openai",
      "api_key": "sk-...",
      "base_url": "https://aiproxies.win/v1"
    }
  },
  "approval_mode": "suggest"
}

CI

GitHub Actions runs on every push and PR:

  • test job: ubuntu-latest + macos-latest matrix → cargo build + cargo test --workspace
  • lint job: cargo fmt --all --check + cargo clippy --workspace -- -D warnings

See .github/workflows/ci.yml.

Release

Tag a version to trigger cross-platform binary builds:

git tag v0.2.0
git push origin v0.2.0

Builds 6 targets (linux x86_64/aarch64, macOS x86_64/aarch64, Windows x86_64/aarch64) and creates a GitHub Release with compressed archives.

See .github/workflows/release.yml.

Commit Convention

<type>(<scope>): <short description>

feat(tools): add WebFetchTool with HTML stripping
fix(llm): drain SSE pending buffer before reading next chunk
test(e2e): add wiremock headless tests
docs: add architecture overview

Types: feat, fix, test, docs, refactor, perf, chore.