What it does: A general-purpose local agent. The model answers questions by calling tools — web search, page fetch, long-term memory — one JSON tool call per turn, until it produces a final answer.
Architecture (current): A custom lightweight tool-calling loop —
agent.py: run() + llm.py + research_agent/tools/ (@tool registry with
auto-discovery; factories/helpers in tools/base.py). No LangGraph. The
old LangGraph research pipeline (nodes.py / graph.py / state.py) has
been removed; HLD.md §1–§13 preserves it as design history.
Target hardware: Apple Silicon Mac with ~16 GB unified memory.
Core models (configurable via .env):
- LLM:
Jackrong/MLX-Qwen3.5-9B-DeepSeek-V4-Flash-4bitserved bymlx_lm.server(OpenAI-compatible/v1onlocalhost:8080). Chat model only. - Embeddings:
sentence-transformers/all-MiniLM-L6-v2in-process (384d).
| Layer | Technology |
|---|---|
| Agent loop | Custom tool-calling loop (agent.py); no framework |
| Tool registry | @tool decorator + auto-discovery (research_agent/tools/) |
| LLM | mlx_lm.server (OpenAI-compatible), client is langchain_openai.ChatOpenAI |
| Embeddings | langchain_huggingface.HuggingFaceEmbeddings (in-process) |
| Vector DB | Chroma (persisted to ./advanced_memory/) |
| Text splitting | RecursiveCharacterTextSplitter (chunk=1000, overlap=100) |
| Search | DuckDuckGo (ddgs) |
| HTML fetching | trafilatura (clean markdown output) |
| Tracing | LangSmith (optional, via env vars) |
| Configuration | .env + config.py |
tiny-deep-researcher/
├── HLD.md # High-Level Design (§14 documents the current loop)
├── handoff.md # This file
├── README.md # User-facing readme
├── requirements.txt
├── .env # Configuration (edit directly)
│
├── research_agent/
│ ├── __init__.py # re-exports run()
│ ├── __main__.py # Entry point (loads .env, runs CLI)
│ ├── config.py # All env-read constants
│ ├── agent.py # run() — custom tool-calling loop
│ ├── llm.py # system prompt + tool-call parsing
│ ├── memory.py # Chroma add/query, Scratchpad
│ ├── logutil.py # colors, previews, per-step tool-call logging
│ ├── cli.py # Interactive REPL, saves answer to reports/
│ └── tools/
│ ├── __init__.py # @tool registry, auto-discovery, catalog, init_tools
│ ├── base.py # factories + helpers (was tools.py)
│ ├── web_search.py # web_search tool (DuckDuckGo)
│ ├── fetch_page.py # fetch_page tool (trafilatura)
│ ├── memory_tools.py # recall_memory / remember tools
│ └── finalize.py # final_answer tool + sentinel
│
└── scripts/
├── setup.sh # pip install helper
└── serve.sh # Launch mlx_lm.server
messages = [system(build_system_prompt(catalog)), user(query)].- For
stepin1..MAX_AGENT_STEPS(default 12):text, call = llm.chat(messages, llm).- Empty output → append the nudge (
config.AGENT_NUDGE) and retry once; still empty → stop with an error. - No tool call →
textis the final answer; stop. - Tool call → look up
TOOL_REGISTRY; execute with**args.TypeError(bad args) and other exceptions are caught and fed back to the model as the tool result.final_answer(or its sentinel) ends the loop.
- Per-step logging via
logutil.tool_step:step N | tool=<name> | preview. After anyweb_search, the topAUTO_FETCH_TOP_NURLs per search are fetched automatically in parallel (no extra model call) and appended as tool results — the fast research path is: batched searches → auto-fetch → final answer (~2 LLM calls). - Returns
{query, answer, steps, errors}.
Robust for a weak 2-bit local model. The system prompt instructs: to use a tool, reply with ONLY a JSON block; ONE tool call per turn:
```json
{"tool": "<name>", "args": {"<arg>": <value>}}
```
Any other reply is treated as the final answer. Parsing reuses
_extract_json_object (tolerates code fences / surrounding prose).
Create research_agent/tools/<name>.py with a @tool-decorated function.
Submodules are auto-imported on package import; no other wiring. Shared
ResearchTools (llm/embedder/vectorstore/text_splitter) are built once via
build_tools() and passed to tool modules through tools.init_tools().
pip install -r requirements.txt
pip install --upgrade mlx-lm # needs >= 0.31 for the qwen3_5 architecture
mlx_lm.server \
--model "Jackrong/MLX-Qwen3.5-9B-DeepSeek-V4-Flash-4bit" \
--port 8080- Lazy model load: the server binds the port immediately and loads the ~5 GB weights on the first request. Silence after "Download complete" means ready, not stuck; the first generation request pays the load cost.
- No
--chat-template-args: unlike the old 27B, this model's chat template has noenable_thinkingswitch, so the flag is a no-op and has been dropped from the launch command. - The model's native 262K context comes from its
config.json; there is no server flag for it.
- Storage: Chroma persisted to
./advanced_memory/, collectionresearch_memory. - Tools:
remember(text, title)writes chunks;recall_memory(query, top_k)reads with a recency boost1 / (1 + age_hours / 24)on similarity scores. - Chunks below
MEMORY_MIN_CHARSare skipped on write; reads filter byMEMORY_SIMILARITY_THRESHOLD.
All env-read constants live in config.py; edit .env directly. Key keys:
LLM_MODEL, LLM_BASE_URL, LLM_API_KEY, LLM_TIMEOUT, EMBED_MODEL,
SEARCH_RESULTS_PER_QUERY, REQUEST_TIMEOUT, MAX_PAGE_CHARS,
MAX_AGENT_STEPS, MEMORY_DIR, MEMORY_TOP_K,
MEMORY_SIMILARITY_THRESHOLD, MEMORY_MIN_CHARS, CHUNK_SIZE,
CHUNK_OVERLAP. Optional LangSmith: LANGCHAIN_TRACING_V2,
LANGCHAIN_API_KEY, LANGCHAIN_PROJECT.
See HLD.md §13 for the detailed list. Highlights still relevant to the current loop:
- JSONL traffic log — runs are not replayable/debuggable (§13.15).
- Retry-with-backoff around DDG / LLM / Chroma calls (§13.17).
- Token/time telemetry footer at end of
run()(§13.19–13.21). s.jina.ai/bravesearch fallback — DDG is the only backend (§13.6).- Playwright JS fallback for pages trafilatura can't extract (§13.10).
- Gradio web UI (§13.12) and markdown report export (§13.9).
Items tied to the removed LangGraph pipeline (coverage assessment, fact
dedup, sub-agent Send fan-out, etc.) are design history in HLD.md §13.