diff --git a/README.md b/README.md index d53988c5..2f79f495 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # CoreCoder -**The nanoGPT of coding agents. A 1.3k-line engine inside 2,735 readable lines of pure Python: understand how a coding agent actually works, then fork your own.** +**The nanoGPT of coding agents. A 1.3k-line engine inside 2,816 readable lines of pure Python: understand how a coding agent actually works, then fork your own.** *learn from it · fork it · ship something better* @@ -12,7 +12,7 @@ [![Python](https://img.shields.io/badge/python-3.10+-blue)](https://python.org) [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE) [![Tests](https://github.com/he-yufeng/CoreCoder/actions/workflows/ci.yml/badge.svg)](https://github.com/he-yufeng/CoreCoder/actions) -[![engine](https://img.shields.io/badge/engine-1309_LoC-blue)](article/00-index_EN.md) +[![engine](https://img.shields.io/badge/engine-1322_LoC-blue)](article/00-index_EN.md) [![essays](https://img.shields.io/badge/source--reading-8_bilingual-orange)](article/00-index_EN.md) @@ -25,7 +25,7 @@ | | CoreCoder | Claude Code | aider | nanoGPT | |---|---|---|---|---| -| Lines of code | ~1,309 engine / 2,735 total | hundreds of thousands (closed) | tens of thousands of Python | ~600 (two files) | +| Lines of code | ~1,322 engine / 2,816 total | hundreds of thousands (closed) | tens of thousands of Python | ~600 (two files) | | Time to read it all | one afternoon | can't (closed) | a few days of slogging | one afternoon | | Breakpoint, change, rerun? | yes, every line | no | yes, but there's a lot | yes | | What it's for | understand one, then fork your own | production coding assistant | terminal pair-programming | minimal GPT for teaching | @@ -36,9 +36,9 @@ The nanoGPT column is there as a reference point: minimal, readable, but it teac I've always felt coding agents get talked about as if they were arcane. Strip a tool like Claude Code or Cursor all the way down and the core is a `while` loop wrapped around a large model, plus seven or eight tools that let it actually do things. The hard part was never the loop; it's everything the loop has to cope with once it meets the real world. CoreCoder is the minimal version that writes that core out honestly. -The engine (loop, model interface, context, tools, sessions) is 1,309 lines once you drop blank lines and comments. Counting the outer CLI, config and packaging too, the whole package is 25 files: 2,735 physical lines, 2,205 net, every one short enough to read in a single sitting. The growth since the original 1,161-line snapshot went into visible features: plan mode, hooks and checkpoints, each documented below. +The engine (loop, model interface, context, tools, sessions) is 1,322 lines once you drop blank lines and comments. Counting the outer CLI, config and packaging too, the whole package is 25 files: 2,816 physical lines, 2,276 net, every one short enough to read in a single sitting. The growth since the original 1,161-line snapshot went into visible features: plan mode, hooks and checkpoints, each documented below. -And it really runs: reads and writes files, executes shell, spawns sub-agents, compacts context in three tiers, and tells you the tokens and dollars a run burned whenever you ask. Anything that would mutate your disk or run a command stops for your consent first. 215 tests, all green. But the point of it running isn't to become your daily driver. It runs so the walkthrough can't lie: a reference that shows how an agent works has to actually work. +And it really runs: reads and writes files, executes shell, spawns sub-agents, compacts context in three tiers, and tells you the tokens and dollars a run burned whenever you ask. Anything that would mutate your disk or run a command stops for your consent first. 224 tests, all green. But the point of it running isn't to become your daily driver. It runs so the walkthrough can't lie: a reference that shows how an agent works has to actually work. The code came out of a public teardown: open analyses have already exposed a lot of the load-bearing architecture inside production agents like Claude Code. I took the most essential layer and rewrote it honestly, in as little code as I could. So reading CoreCoder is roughly like reading a runnable, annotated take on how that kind of agent works, except it's only a minimal reimplementation, sitting right there on your machine for you to take apart and change. @@ -88,15 +88,15 @@ Laid out flat, the whole project is this big. Skim it before you clone and you'l ``` corecoder/ ├── agent.py agent loop + parallel tool exec 240 lines ← start here -├── llm.py streaming client + retry + cost 332 lines +├── llm.py streaming client + retry + cost 349 lines ├── context.py three-tier context compaction 220 lines ├── session.py save / resume + path-traversal guard 97 lines ├── permissions.py consent for mutating tools 75 lines ├── hooks.py Pre/PostToolUse shell hooks 87 lines ├── shell.py POSIX shell routing (Git Bash on Windows) 61 lines -├── mcp.py MCP stdio client for external tools 208 lines +├── mcp.py MCP stdio client for external tools 237 lines ├── prompt.py system prompt 41 lines -├── cli.py REPL + slash commands + one-shot 358 lines +├── cli.py REPL + slash commands + one-shot 393 lines ├── config.py env-var config 55 lines ├── checkpoints.py /undo snapshot and restore 93 lines ├── demo.py offline end-to-end demo 101 lines @@ -165,7 +165,7 @@ I also wrote a bilingual source-reading series, one intro plus eight parts, each Once you understand it, the natural next step is to fork. Getting started doesn't take much: -- **Swap in a model you actually use.** It's the two env vars from above; `llm.py` (332 lines) is the entry point for all provider adaptation. +- **Swap in a model you actually use.** It's the two env vars from above; `llm.py` (349 lines) is the entry point for all provider adaptation. - **Add a tool of your own.** Write a new file against the tool base class in `tools/base.py` (32 lines): run tests, fetch a page, call an LSP, whatever. The end of the second essay walks you through your first one by hand. - **Rewrite the system prompt.** `prompt.py` is all of 41 lines; change one line and you'll watch the agent's temperament shift. It's the cheapest "change one thing, see a result" in the whole project. - **Import it as a library.** The top level exports `Agent`, `LLM`, and `Config`, ready to embed in your own program: @@ -198,10 +198,11 @@ Inside the REPL, `/help` lists everything; these are the ones you'll reach for: /undo revert the most recent file change /plan toggle plan mode (read-only, then a plan to approve) /save /sessions save / list sessions +/mcp MCP server status; /mcp reconnect revives a dead one quit / exit exit (Ctrl+C cancels the current round) ``` -Session IDs are sanitized to safe characters before they become filenames, every archive lands under `~/.corecoder/sessions`, and a malicious session name can't traverse out. Undo history persists the same way: checkpoints land in `~/.corecoder/checkpoints.json`, so `/undo` still reaches back after a restart. +Session IDs are sanitized to safe characters before they become filenames, every archive lands under `~/.corecoder/sessions`, and a malicious session name can't traverse out. Undo history persists the same way: checkpoints land in `~/.corecoder/checkpoints.json`, so `/undo` still reaches back after a restart. The cost estimate behind `/tokens` reads a built-in price table, and `~/.corecoder/pricing.json` overrides any entry in it, so a new model or a moved price lands without waiting for a release. ## Permissions @@ -265,7 +266,7 @@ Drop a `mcp.json` under `~/.corecoder` and tools from any MCP server join the ag } ``` -Each configured server starts as a subprocess at launch, handshakes, and lists its tools; every one is registered as `mcp____`, so hook matchers and the consent gate treat it exactly like a built-in. MCP tools stay out of the read-only set, meaning the agent asks before running one. The handshake gets fifteen seconds, a call gets sixty, and a server that dies or never answers fails that one call as an ordinary tool result instead of killing the loop. The client speaks the tools slice of the protocol (initialize, tools/list, tools/call) and nothing else, which keeps the whole thing inside `mcp.py` at about 200 lines. With no `mcp.json` there is no MCP and nothing changes. +Each configured server starts as a subprocess at launch, handshakes, and lists its tools; every one is registered as `mcp____`, so hook matchers and the consent gate treat it exactly like a built-in. MCP tools stay out of the read-only set, meaning the agent asks before running one. The handshake gets fifteen seconds, a call gets sixty, and a server that dies or never answers fails that one call as an ordinary tool result instead of killing the loop. The REPL's `/mcp` lists every server with its state, and `/mcp reconnect ` starts a dead one fresh: new process, new handshake, and the tools the agent already holds work again with no restart. The client speaks the tools slice of the protocol (initialize, tools/list, tools/call) and nothing else, which keeps the whole thing inside `mcp.py` at about 240 lines. With no `mcp.json` there is no MCP and nothing changes. ## Related Projects @@ -279,7 +280,7 @@ If working through CoreCoder was useful, here are a few other tools I've built a ## Contributing / License -Before you send anything, run `pytest tests/ -q` (215 tests), `ruff check`, and `compileall`, and make sure they're green. MIT licensed: fork it, learn from it, ship something better. A mention of this project is appreciated. +Before you send anything, run `pytest tests/ -q` (224 tests), `ruff check`, and `compileall`, and make sure they're green. MIT licensed: fork it, learn from it, ship something better. A mention of this project is appreciated. --- diff --git a/README_CN.md b/README_CN.md index a9d43d75..e2ca4da2 100644 --- a/README_CN.md +++ b/README_CN.md @@ -2,7 +2,7 @@ # CoreCoder -**编程 agent 里的 nanoGPT。1.3k 行引擎、整包 2735 行纯 Python 全部一口气可读,读懂一个 coding agent 到底怎么运作,再 fork 出你自己的。** +**编程 agent 里的 nanoGPT。1.3k 行引擎、整包 2816 行纯 Python 全部一口气可读,读懂一个 coding agent 到底怎么运作,再 fork 出你自己的。** *learn from it · fork it · ship something better* @@ -12,7 +12,7 @@ [![Python](https://img.shields.io/badge/python-3.10+-blue)](https://python.org) [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE) [![Tests](https://github.com/he-yufeng/CoreCoder/actions/workflows/ci.yml/badge.svg)](https://github.com/he-yufeng/CoreCoder/actions) -[![engine](https://img.shields.io/badge/engine-1309_LoC-blue)](article/) +[![engine](https://img.shields.io/badge/engine-1322_LoC-blue)](article/) [![源码导读](https://img.shields.io/badge/源码导读-8篇双语-orange)](article/) @@ -36,9 +36,9 @@ nanoGPT 那一列是拿来对照的:它最小、可读,但教的是训一个 我一直觉得 coding agent 被讲得太玄了。把 Claude Code、Cursor 这类工具扒到底,核心是一个 while 循环套着一个大模型,外加七八个让它能真正动手的工具。难的从来不是这个循环,而是循环跑进真实世界以后要兜的那些底。CoreCoder 就是把这个核心老老实实写出来的最小版本。 -引擎部分(循环、模型接口、上下文、工具、会话)去掉空行和注释是 1309 行。连最外层的 CLI、配置、打包一起算,整个包 25 个文件、物理 2735 行、净 2205 行,每个文件都短到能一口气读完。自 1161 行快照之后的增长都花在了看得见的功能上:plan mode、hooks、checkpoints,下文各有交代。 +引擎部分(循环、模型接口、上下文、工具、会话)去掉空行和注释是 1322 行。连最外层的 CLI、配置、打包一起算,整个包 25 个文件、物理 2816 行、净 2276 行,每个文件都短到能一口气读完。自 1161 行快照之后的增长都花在了看得见的功能上:plan mode、hooks、checkpoints,下文各有交代。 -它真能跑:读写文件、执行 shell、派子 agent、分三层压上下文,还能随时把这趟烧掉的 token 和美元数报给你。任何要动你磁盘、要跑命令的调用,都会先停下来等你点头,215 个测试是绿的。但能跑不是为了劝你拿去日用,而是为了让这份「注释」不撒谎:一个解释 agent 怎么运作的范例,自己得真能运作。 +它真能跑:读写文件、执行 shell、派子 agent、分三层压上下文,还能随时把这趟烧掉的 token 和美元数报给你。任何要动你磁盘、要跑命令的调用,都会先停下来等你点头,224 个测试是绿的。但能跑不是为了劝你拿去日用,而是为了让这份「注释」不撒谎:一个解释 agent 怎么运作的范例,自己得真能运作。 代码来自一次公开拆解。公开的源码分析里,Claude Code 这类生产级 agent 暴露出不少关键架构,我挑出最核心的一层,用尽量少的代码诚实地复写了一遍。所以读 CoreCoder,约等于读一份基于公开源码分析的「可运行注释版」:讲的是这类 agent 的核心思路,而它本身只是最小复写,就摆在你机器上,随你拆、随你改。 @@ -88,15 +88,15 @@ corecoder -p "给 parse_config() 加错误处理" # 一次性模式,干完 ``` corecoder/ ├── agent.py agent 主循环 + 并行工具执行 240 行 ← 从这里开始读 -├── llm.py 流式客户端 + 重试 + 成本统计 332 行 +├── llm.py 流式客户端 + 重试 + 成本统计 349 行 ├── context.py 三层上下文压缩 220 行 ├── session.py 会话存盘 / 续聊 + 路径穿越防护 97 行 ├── permissions.py 改动类工具的用户授权 75 行 ├── hooks.py 工具调用前后的用户 shell 钩子 87 行 ├── shell.py POSIX shell 路由(Windows 走 Git Bash) 61 行 -├── mcp.py MCP stdio 客户端,接外部工具 208 行 +├── mcp.py MCP stdio 客户端,接外部工具 237 行 ├── prompt.py 系统提示词 41 行 -├── cli.py REPL + 斜杠命令 + 一次性模式 358 行 +├── cli.py REPL + 斜杠命令 + 一次性模式 393 行 ├── config.py 环境变量配置 55 行 ├── checkpoints.py /undo 快照与回滚 93 行 ├── demo.py 离线端到端演示 101 行 @@ -165,7 +165,7 @@ def chat(self, user_input): 读懂之后,最自然的下一步就是 fork。起手不用伤筋动骨: -- **换个你常用的模型。** 就是上面那两个环境变量,`llm.py`(332 行)是所有 provider 适配的入口。 +- **换个你常用的模型。** 就是上面那两个环境变量,`llm.py`(349 行)是所有 provider 适配的入口。 - **加一件你自己的工具。** 照 `tools/base.py`(32 行)的工具基类写个新文件,跑测试、抓网页、调 LSP 都行,第二篇文章末尾手把手带你写第一个。 - **改系统提示词。** `prompt.py` 才 41 行,改一句就能看到 agent 的脾气变了,是门槛最低的「改一处就有反馈」。 - **直接当库 import。** 顶层导出了 `Agent`、`LLM`、`Config`,能嵌进你自己的程序: @@ -198,10 +198,11 @@ README 只给方向,每条的代码细节第七篇接着讲。挑一个动手 /undo 撤销最近一次文件改动 /plan 开关计划模式(只读摸底,再交出待批准的计划) /save /sessions 保存 / 列出会话 +/mcp MCP 服务器状态;/mcp reconnect <名字> 拉起挂掉的一台 quit / exit 退出(Ctrl+C 取消当前回合) ``` -会话 ID 会先清洗成安全字符再拿去当文件名,存档统统落在 `~/.corecoder/sessions` 里,恶意会话名穿越不出去。undo 历史也一样落盘:快照写在 `~/.corecoder/checkpoints.json`,重启之后 `/undo` 照样能往回撤。 +会话 ID 会先清洗成安全字符再拿去当文件名,存档统统落在 `~/.corecoder/sessions` 里,恶意会话名穿越不出去。undo 历史也一样落盘:快照写在 `~/.corecoder/checkpoints.json`,重启之后 `/undo` 照样能往回撤。`/tokens` 的费用估算读的是内置价目表,在 `~/.corecoder/pricing.json` 里写自己的价格就会盖掉对应条目,新模型、调价都不用等发版。 ## 权限 @@ -264,7 +265,7 @@ REPL 里 `/plan` 开关计划模式。开着的时候,提示符变成 `(plan)` } ``` -每个配好的服务器在启动时拉起一个子进程,握手、列出工具;每件工具都注册成 `mcp__<服务器>__<工具>`,钩子匹配和授权闸对它和内建工具一视同仁。MCP 工具不在只读名单里,模型要调,得先问过你。握手给十五秒,一次调用给六十秒;服务器挂了或者迟迟不应,那一次调用就以普通工具结果的形式报错,循环照常往下走。客户端只实现协议里工具那一小片(initialize、tools/list、tools/call),别的一概不碰,所以整块实现收在 `mcp.py` 一个文件里,两百行出头。没有 `mcp.json` 就没有 MCP,一切照旧。 +每个配好的服务器在启动时拉起一个子进程,握手、列出工具;每件工具都注册成 `mcp__<服务器>__<工具>`,钩子匹配和授权闸对它和内建工具一视同仁。MCP 工具不在只读名单里,模型要调,得先问过你。握手给十五秒,一次调用给六十秒;服务器挂了或者迟迟不应,那一次调用就以普通工具结果的形式报错,循环照常往下走。REPL 里的 `/mcp` 列出每台服务器的状态,`/mcp reconnect <名字>` 把挂掉的重新拉起:新进程、重新握手,agent 手里已有的工具不用重启就恢复可用。客户端只实现协议里工具那一小片(initialize、tools/list、tools/call),别的一概不碰,所以整块实现收在 `mcp.py` 一个文件里,两百四十行上下。没有 `mcp.json` 就没有 MCP,一切照旧。 ## 相关项目 @@ -278,7 +279,7 @@ REPL 里 `/plan` 开关计划模式。开着的时候,提示符变成 `(plan)` ## 贡献 / License -动手之前先跑一遍 `pytest tests/ -q`(215 个测试)、`ruff check` 和 `compileall`,绿了再提。MIT License,欢迎 fork 拿去造更好的东西,能在 README 里留一句出处就更好。 +动手之前先跑一遍 `pytest tests/ -q`(224 个测试)、`ruff check` 和 `compileall`,绿了再提。MIT License,欢迎 fork 拿去造更好的东西,能在 README 里留一句出处就更好。 --- diff --git a/article/00-index.md b/article/00-index.md index 067fab08..21eeee44 100644 --- a/article/00-index.md +++ b/article/00-index.md @@ -24,12 +24,12 @@ CoreCoder 做的事,是把这套骨架压到一千行出头的纯 Python。准 1. [一个 agent 的本体,是一个 while 循环](01-the-loop.md)。整个 agent 最核心的东西,是一个「问模型、跑工具、把结果喂回去、再问」的循环。我们看 CoreCoder 的 `agent.py`(240 行)怎么把它写明白,以及打断、轮次上限、半截工具调用回填这些真实世界的麻烦各自怎么收场。 2. [工具系统:让模型安全地动手](02-tools.md)。模型本身只会吐字,是工具让它能读文件、写文件、跑命令。这篇讲 CoreCoder 的七个工具,重点是那个看似平平无奇、实则是 Claude Code 关键创新的「唯一性搜索替换」编辑,以及 bash 的安全闸。v0.6.0 之后还补了一节「门之外」:plan 模式、hooks、MCP 这三个进阶件怎么挂在一次调用的前后。末尾教你写第一个自己的工具。 -3. [接入任意大模型,顺便把钱算清楚](03-llm-and-cost.md)。`llm.py`(332 行,引擎里最大的文件)怎么用一套 OpenAI 兼容接口接住 DeepSeek、Qwen、Kimi、本地 Ollama,怎么做指数退避重试,怎么在流式输出里顺手把 token 和美元成本统计出来。 +3. [接入任意大模型,顺便把钱算清楚](03-llm-and-cost.md)。`llm.py`(349 行,引擎里最大的文件)怎么用一套 OpenAI 兼容接口接住 DeepSeek、Qwen、Kimi、本地 Ollama,怎么做指数退避重试,怎么在流式输出里顺手把 token 和美元成本统计出来。 4. [用有限的窗口扛住一个长任务](04-context.md)。上下文窗口是 agent 的硬约束。`context.py`(220 行)实现了三层压缩,从轻到重。这篇还会讲一个特别容易踩、API 一定报错的坑:孤儿 tool 消息。这是我做这个项目时真改过的 bug。 5. [并行执行与子 agent](05-parallel-and-subagents.md)。模型一次返回多个工具调用时,CoreCoder 用线程池并发跑。这篇老实讲这个简化版相对 Claude Code 的流式执行器差在哪,并发又会引入什么新麻烦,以及子 agent 为什么不准递归。 6. [把它跑成一个真正的命令行工具](06-session-and-cli.md)。会话存盘、断点续聊、斜杠命令、一次性模式。`session.py` 里有个不起眼但很要命的安全细节:怎么防住用恶意会话名做路径穿越。 7. [Fork CoreCoder,搭一个你自己的 coding agent](07-build-your-own.md)。收尾的实操篇。从 clone 到换成你常用的模型,到加一个真正有用的自定义工具,到改系统提示词调教它的风格,到打包发布。读完前六篇你已经懂了原理,这篇让你真有一个东西。 -8. [不动主循环的三种加法:MCP、钩子与计划模式](08-extensibility.md)。给 agent 加东西的三条正路:`mcp.py`(208 行)把远程工具接成与内建同权,hooks 用退出码 2 否决一次调用却永远杀不死循环,计划模式用一个布尔量压住包括 `--yes` 在内的整个权限层。最后一节把三块收拢回同一条契约:工具同形、错误是普通返回值、循环不被外围杀死。 +8. [不动主循环的三种加法:MCP、钩子与计划模式](08-extensibility.md)。给 agent 加东西的三条正路:`mcp.py`(237 行)把远程工具接成与内建同权,hooks 用退出码 2 否决一次调用却永远杀不死循环,计划模式用一个布尔量压住包括 `--yes` 在内的整个权限层。最后一节把三块收拢回同一条契约:工具同形、错误是普通返回值、循环不被外围杀死。 不想按顺序也行。想搞懂「它凭什么敢自动跑命令」直接跳第二篇;想接自己的模型直接跳第三篇;只想赶紧 fork 出个能用的,第七篇是自洽的。 diff --git a/article/00-index_EN.md b/article/00-index_EN.md index 02e4c1ac..748e8f34 100644 --- a/article/00-index_EN.md +++ b/article/00-index_EN.md @@ -24,12 +24,12 @@ The first six are about understanding. Each one fixes on a single subsystem of t 1. [An agent is, at heart, a while loop](01-the-loop_EN.md). The most central thing in the whole agent is a loop: ask the model, run a tool, feed the result back, ask again. We look at how CoreCoder's `agent.py` (240 lines) writes it out plainly, and how real-world headaches like interruption, a round cap, and backfilling half-finished tool calls each get handled. 2. [The tool system: letting the model act, safely](02-tools_EN.md). The model on its own only emits text. Tools are what let it read files, write files, run commands. This piece covers CoreCoder's seven tools, with the spotlight on the seemingly unremarkable unique search-and-replace edit that is in fact one of Claude Code's key innovations, plus bash's safety gate. Since v0.6.0 it also carries a "beyond the gates" section on how the three advanced pieces — plan mode, hooks, and MCP — hang off the moments around a call. At the end I'll have you write your first tool. -3. [Plug in any model, and get the bill right while you're at it](03-llm-and-cost_EN.md). How `llm.py` (332 lines, the largest single file in the engine) uses one OpenAI-compatible interface to catch DeepSeek, Qwen, Kimi, and local Ollama, how it does exponential-backoff retry, and how it tallies tokens and dollar cost right inside the streaming output. +3. [Plug in any model, and get the bill right while you're at it](03-llm-and-cost_EN.md). How `llm.py` (349 lines, the largest single file in the engine) uses one OpenAI-compatible interface to catch DeepSeek, Qwen, Kimi, and local Ollama, how it does exponential-backoff retry, and how it tallies tokens and dollar cost right inside the streaming output. 4. [Surviving a long task in a finite window](04-context_EN.md). The context window is the agent's hard constraint. `context.py` (220 lines) implements three layers of compression, lightest to heaviest. This piece also covers a trap that's easy to hit and that the API will always reject: the orphaned tool message. That's a bug I actually fixed while building this project. 5. [Parallel execution and sub-agents](05-parallel-and-subagents_EN.md). When the model returns several tool calls at once, CoreCoder runs them concurrently on a thread pool. This piece is honest about where this simplified version falls short of Claude Code's streaming executor, what new trouble concurrency brings in, and why a sub-agent is not allowed to recurse. 6. [Turning it into a real command-line tool](06-session-and-cli_EN.md). Session save, resume, slash commands, one-shot mode. `session.py` holds an unremarkable but critical security detail: how to stop a malicious session name from turning into a path traversal. 7. [Fork CoreCoder and build your own coding agent](07-build-your-own_EN.md). The hands-on finale. From clone, to switching to the model you actually use, to adding a genuinely useful custom tool, to tuning the system prompt to shape its style, to packaging and release. After the first six pieces you understand the principles; this one leaves you with something real. -8. [Three ways to extend without touching the loop: MCP, hooks, and plan mode](08-extensibility_EN.md). The three right ways to grow an agent: `mcp.py` (208 lines) wires remote tools in as first-class citizens, hooks veto a single call with exit code 2 yet can never kill the loop, and plan mode is one boolean that outranks the entire permission layer including `--yes`. The coda folds all three back into one contract: uniform tools, errors as ordinary return values, and a loop its perimeter cannot kill. +8. [Three ways to extend without touching the loop: MCP, hooks, and plan mode](08-extensibility_EN.md). The three right ways to grow an agent: `mcp.py` (237 lines) wires remote tools in as first-class citizens, hooks veto a single call with exit code 2 yet can never kill the loop, and plan mode is one boolean that outranks the entire permission layer including `--yes`. The coda folds all three back into one contract: uniform tools, errors as ordinary return values, and a loop its perimeter cannot kill. You don't have to read in order. Want to understand how it dares to run commands on its own? Jump to piece two. Want to plug in your own model? Jump to piece three. Just want to fork something usable fast? Piece seven stands on its own. diff --git a/article/03-llm-and-cost.md b/article/03-llm-and-cost.md index 28cb9311..af025c00 100644 --- a/article/03-llm-and-cost.md +++ b/article/03-llm-and-cost.md @@ -2,7 +2,7 @@ 前两篇讲的是循环和工具,也就是 agent 的手脚。这一篇讲大脑接口:模型怎么接进来,流式输出怎么处理,provider 抽风了怎么扛,以及一个被很多教程跳过、但你上线后第一天就会关心的问题,这一轮到底花了多少钱。 -对应的文件是 `corecoder/llm.py`,332 行,是引擎里最大的单文件。它大,是因为它替你扛下了和真实 API 打交道时所有不优雅的部分。 +对应的文件是 `corecoder/llm.py`,349 行,是引擎里最大的单文件。它大,是因为它替你扛下了和真实 API 打交道时所有不优雅的部分。 ## 一个赌注:大家都长得像 OpenAI @@ -158,12 +158,14 @@ _PRICING = { } ``` +这张表只是默认值。`_load_pricing()` 会用 `~/.corecoder/pricing.json`(同样的形状)覆盖它,新模型、调价都不用等发版;文件不存在或者写坏了,就静默落回内置表。 + `estimated_cost` 这个 property 拿累计 token 乘上对应单价: ```python @property def estimated_cost(self) -> float | None: - pricing = _PRICING.get(self.model) + pricing = _load_pricing().get(self.model) if not pricing: return None input_rate, output_rate = pricing diff --git a/article/03-llm-and-cost_EN.md b/article/03-llm-and-cost_EN.md index 5d8ba92a..fd0d3e81 100644 --- a/article/03-llm-and-cost_EN.md +++ b/article/03-llm-and-cost_EN.md @@ -2,7 +2,7 @@ The last two pieces covered the loop and the tools, the agent's hands and feet. This piece covers the brain's interface: how the model gets plugged in, how streaming output is handled, how to survive a provider acting up, and a question many tutorials skip but that you'll care about on day one after going live, namely how much this round actually cost. -The file is `corecoder/llm.py`, 332 lines, the largest single file in the engine. It's large because it carries, on your behalf, all the inelegant parts of dealing with a real API. +The file is `corecoder/llm.py`, 349 lines, the largest single file in the engine. It's large because it carries, on your behalf, all the inelegant parts of dealing with a real API. ## A bet: everyone looks like OpenAI @@ -158,12 +158,14 @@ _PRICING = { } ``` +That table is only the default. `_load_pricing()` overlays it with `~/.corecoder/pricing.json` in the same shape, so a new model or a moved price lands without waiting for a release; a missing or broken file quietly means the built-ins. + The `estimated_cost` property takes the accumulated tokens and multiplies by the matching unit price: ```python @property def estimated_cost(self) -> float | None: - pricing = _PRICING.get(self.model) + pricing = _load_pricing().get(self.model) if not pricing: return None input_rate, output_rate = pricing diff --git a/article/06-session-and-cli.md b/article/06-session-and-cli.md index 6be0a7c4..d19fd2d2 100644 --- a/article/06-session-and-cli.md +++ b/article/06-session-and-cli.md @@ -1,6 +1,6 @@ # 把它跑成一个真正的命令行工具 -前五篇拆的是 agent 的内脏:循环、工具、模型接口、上下文压缩、并发与子 agent。这些零件再漂亮,你也没法直接用,因为还缺一层皮,一个让人能坐下来跟它对话、能存盘、能续聊、能查状态的命令行界面。这一篇讲这层皮,对应 `cli.py`(358 行)和 `session.py`(97 行)。 +前五篇拆的是 agent 的内脏:循环、工具、模型接口、上下文压缩、并发与子 agent。这些零件再漂亮,你也没法直接用,因为还缺一层皮,一个让人能坐下来跟它对话、能存盘、能续聊、能查状态的命令行界面。这一篇讲这层皮,对应 `cli.py`(393 行)和 `session.py`(97 行)。 它不光是「锦上添花的 UI」。这层皮里藏着一个很值得讲的安全细节,我们留到后面压轴。 @@ -50,6 +50,7 @@ REPL 里认一批斜杠命令,它们不发给模型,而是直接操作 agent /diff 列出本次会话改过的文件 /save 把会话存到磁盘 /sessions 列出已存的会话 +/mcp 查看 MCP 服务器状态,重连挂掉的服务器 ``` 这些命令把前几篇讲的内核能力暴露成了用户能直接拨的开关。`/tokens` 调的是第三篇那个 `estimated_cost`,`/compact` 调的是第四篇那个 `maybe_compress`,`/diff` 读的是第二篇 `edit_file` 一直在维护的那个「改过的文件」集合。内核早就把这些能力准备好了,CLI 只是给每个能力配了一个顺手的入口。 diff --git a/article/06-session-and-cli_EN.md b/article/06-session-and-cli_EN.md index a2294769..9dbc918d 100644 --- a/article/06-session-and-cli_EN.md +++ b/article/06-session-and-cli_EN.md @@ -1,6 +1,6 @@ # Turning it into a real command-line tool -The first five pieces dissected the agent's internals: the loop, the tools, the model interface, context compression, parallelism and sub-agents. As lovely as these parts are, you can't use them directly, because one more layer is missing, a skin, a command-line interface where someone can sit down and talk to it, save, resume, and check status. This piece covers that skin, corresponding to `cli.py` (358 lines) and `session.py` (97 lines). +The first five pieces dissected the agent's internals: the loop, the tools, the model interface, context compression, parallelism and sub-agents. As lovely as these parts are, you can't use them directly, because one more layer is missing, a skin, a command-line interface where someone can sit down and talk to it, save, resume, and check status. This piece covers that skin, corresponding to `cli.py` (393 lines) and `session.py` (97 lines). It's not just "a nice-to-have UI." Hidden in this skin is a security detail well worth discussing, which we save for the finale. @@ -50,6 +50,7 @@ The REPL recognizes a set of slash commands that aren't sent to the model but di /diff list files changed this session /save save the session to disk /sessions list saved sessions +/mcp MCP server status, and reconnect a dead one ``` These commands expose the core capabilities from the previous pieces as switches the user can flip directly. `/tokens` calls piece three's `estimated_cost`, `/compact` calls piece four's `maybe_compress`, `/diff` reads the "changed files" set that piece two's `edit_file` has been maintaining all along. The core prepared these capabilities long ago, and the CLI just gives each one a handy entry point. diff --git a/article/08-extensibility.md b/article/08-extensibility.md index 4a0902e3..621714ad 100644 --- a/article/08-extensibility.md +++ b/article/08-extensibility.md @@ -1,6 +1,6 @@ # 不动主循环的三种加法:MCP、钩子与计划模式 -第七篇结束的时候,你手里已经有一个能跑的 agent 了。接下来你几乎一定会想做三件事:给它接新工具(接进来一个文件系统服务、一个数据库服务),在工具调用前后插一段自己的逻辑(每次跑 bash 前先过一道检查),以及让它偶尔「只许看、不许动」(先出个方案再开工)。这三件事对应 v0.6.0 新增的三块代码:`mcp.py`(208 行)、`hooks.py`(87 行)、以及 `agent.py` 里一个五十行的布尔量。它们的共同点是:都不碰第一到第六篇讲的那个主循环。这一篇讲为什么「不碰主循环」不是克制,而是这三块功能能成立的前提。 +第七篇结束的时候,你手里已经有一个能跑的 agent 了。接下来你几乎一定会想做三件事:给它接新工具(接进来一个文件系统服务、一个数据库服务),在工具调用前后插一段自己的逻辑(每次跑 bash 前先过一道检查),以及让它偶尔「只许看、不许动」(先出个方案再开工)。这三件事对应 v0.6.0 新增的三块代码:`mcp.py`(237 行)、`hooks.py`(87 行)、以及 `agent.py` 里一个五十行的布尔量。它们的共同点是:都不碰第一到第六篇讲的那个主循环。这一篇讲为什么「不碰主循环」不是克制,而是这三块功能能成立的前提。 ## MCP:把「协议」裁成「能用的那一片」 @@ -17,7 +17,7 @@ MCP(Model Context Protocol)是让 agent 调用外部工具服务的标准。 真正体现工程的是两个线程问题。第一,工具调用是并发的(第五篇),几个线程同时调同一个 server 时,请求和响应怎么对上号?`mcp.py` 起了一个守护线程独占 stdout,每读到一个响应就按 request id 停进 `_responses` 字典,调用方拿自己的 id 去等: ```python -threading.Thread(target=self._read_loop, daemon=True).start() +threading.Thread(target=self._read_loop, args=(self._proc,), daemon=True).start() ``` 第二,stdin 只有一个,两个线程的请求体不能在字节流里搅在一起,所以写操作走一把 `_write_lock`。一个读线程按 id 分发,一把写锁保顺序,stdio 这种「一根管子双向说话」的协议最常用的处理就是这个形状,不用上队列也不用上事件循环。 @@ -29,7 +29,7 @@ threading.Thread(target=self._read_loop, daemon=True).start() # error string; it never kills the loop ``` -失败只是这一次工具调用返回一个错误字符串,模型读到「这个服务现在不好使」然后自己想办法,主循环连一个异常都看不到。这个选择和第三篇讲模型接口时的原则是同一条:外围世界永远可能坏,内核不能跟着死。 +失败只是这一次工具调用返回一个错误字符串,模型读到「这个服务现在不好使」然后自己想办法,主循环连一个异常都看不到。这个选择和第三篇讲模型接口时的原则是同一条:外围世界永远可能坏,内核不能跟着死。服务器真挂了也不是只能重启会话:REPL 里的 `/mcp` 列出每台服务器的状态,`/mcp reconnect <名字>` 起一个新进程、重新握手。agent 手里已有的工具挂在 client 上而不是进程上,新握手一落地就恢复可用。 ## 钩子:能否决,但永远杀不死循环 diff --git a/article/08-extensibility_EN.md b/article/08-extensibility_EN.md index 0da2844d..db5e38dc 100644 --- a/article/08-extensibility_EN.md +++ b/article/08-extensibility_EN.md @@ -1,6 +1,6 @@ # Three Ways to Extend Without Touching the Loop: MCP, Hooks, and Plan Mode -By the end of article seven you had a working agent. Almost immediately you will want three things: plug in new tools (a filesystem service, a database service), run your own logic around tool calls (a check before every bash run), and occasionally make the agent read-only (a plan before any work). Those three wants are exactly the three additions in v0.6.0: `mcp.py` (208 lines), `hooks.py` (87 lines), and one boolean in `agent.py` about fifty lines deep. What they share is that none of them touches the main loop from articles one through six. This piece is about why "not touching the loop" is not restraint but the precondition that makes all three possible. +By the end of article seven you had a working agent. Almost immediately you will want three things: plug in new tools (a filesystem service, a database service), run your own logic around tool calls (a check before every bash run), and occasionally make the agent read-only (a plan before any work). Those three wants are exactly the three additions in v0.6.0: `mcp.py` (237 lines), `hooks.py` (87 lines), and one boolean in `agent.py` about fifty lines deep. What they share is that none of them touches the main loop from articles one through six. This piece is about why "not touching the loop" is not restraint but the precondition that makes all three possible. ## MCP: cutting the protocol down to the slice an agent uses @@ -17,7 +17,7 @@ Each server is a subprocess speaking JSON-RPC 2.0, one message per line over std The real engineering is two threading problems. First, tool calls run concurrently (article five), so when several threads call the same server, how do responses find their callers? A daemon thread owns stdout and parks every response in a `_responses` dict under its request id; the caller waits on its own id: ```python -threading.Thread(target=self._read_loop, daemon=True).start() +threading.Thread(target=self._read_loop, args=(self._proc,), daemon=True).start() ``` Second, stdin is a single pipe, and two requests must never interleave their bytes, so a `_write_lock` serializes writes. One reader demultiplexing by id, one lock sequencing writes: that is the standard shape for talking both ways over one pipe, no queues and no event loop needed. @@ -29,7 +29,7 @@ The most deliberate part is failure handling. What happens when a server hangs, # error string; it never kills the loop ``` -The failure surfaces as an ordinary error string from that one tool call. The model reads "this service is unavailable" and works around it; the main loop never even sees an exception. It is the same principle article three established for the model interface: the outside world can always break, and the loop must not break with it. +The failure surfaces as an ordinary error string from that one tool call. The model reads "this service is unavailable" and works around it; the main loop never even sees an exception. It is the same principle article three established for the model interface: the outside world can always break, and the loop must not break with it. And when a server does die, it stays down only until you ask otherwise: `/mcp` in the REPL lists every server with its state, and `/mcp reconnect ` spawns a fresh process and redoes the handshake. The tools the agent already holds route through the client, not the process, so they work again the moment the new handshake lands. ## Hooks: allowed to veto, never allowed to kill diff --git a/corecoder/cli.py b/corecoder/cli.py index df399e01..e0c4156f 100644 --- a/corecoder/cli.py +++ b/corecoder/cli.py @@ -292,6 +292,9 @@ def _newline(event): for s in sessions: console.print(f" [cyan]{s['id']}[/cyan] ({s['model']}, {s['saved_at']}) {s['preview']}") continue + if user_input == "/mcp" or user_input.startswith("/mcp "): + _mcp(user_input[4:].strip()) + continue # an unknown /command shouldn't be sent to the model as a prompt if user_input.startswith("/"): @@ -329,6 +332,37 @@ def on_reasoning(tok, streamed=streamed): console.print(f"\n[red]Error: {e}[/red]") +def _mcp(arg: str): + """The /mcp command: server states, or `reconnect ` to revive one.""" + from . import mcp + parts = arg.split() + if not parts: + if not mcp._live_clients: + console.print("[dim]No MCP servers running (configure ~/.corecoder/mcp.json).[/dim]") + for client in mcp._live_clients: + if client.error is None: + console.print(f" [cyan]{client.name}[/cyan]: alive, {len(client.tools)} tools") + else: + console.print( + f" [cyan]{client.name}[/cyan]: [red]dead[/red] ({client.error})" + f", revive with /mcp reconnect {client.name}" + ) + return + if len(parts) == 2 and parts[0] == "reconnect": + client = next((c for c in mcp._live_clients if c.name == parts[1]), None) + if client is None: + console.print(f"[red]No MCP server named '{parts[1]}'.[/red]") + return + try: + client.reconnect() + except (mcp.MCPError, OSError) as e: + console.print(f"[red]Reconnect failed:[/red] {e}") + else: + console.print(f"[green]Reconnected:[/green] [cyan]{client.name}[/cyan], {len(client.tools)} tools") + return + console.print("[yellow]Usage: /mcp [reconnect ][/yellow]") + + def _show_help(): console.print(Panel( "[bold]Commands:[/bold]\n" @@ -343,6 +377,7 @@ def _show_help(): " /plan Toggle plan mode: read-only, then a plan to approve\n" " /save Save session to disk\n" " /sessions List saved sessions\n" + " /mcp MCP server status; /mcp reconnect revives a dead one\n" " quit Exit CoreCoder\n" "\n" "[bold]Input:[/bold]\n" diff --git a/corecoder/llm.py b/corecoder/llm.py index 37f57629..f3f45fcb 100644 --- a/corecoder/llm.py +++ b/corecoder/llm.py @@ -12,6 +12,7 @@ import json import time from dataclasses import dataclass, field +from pathlib import Path from openai import APIConnectionError, APIError, APITimeoutError, BadRequestError, OpenAI, RateLimitError @@ -81,6 +82,22 @@ def message(self) -> dict: "kimi-k2.5": (0.6, 3), } +# same shape as the table above; entries here win over the built-ins +PRICING_FILE = Path.home() / ".corecoder" / "pricing.json" + + +def _load_pricing() -> dict: + """Built-in prices overlaid with the user's pricing.json, so a new model + or a price change lands without waiting for a release. A missing or + broken file just means the built-ins.""" + pricing = dict(_PRICING) + try: + overrides = json.loads(PRICING_FILE.read_text(encoding="utf-8")) + pricing.update({m: (float(r[0]), float(r[1])) for m, r in overrides.items()}) + except (OSError, ValueError, TypeError, KeyError, IndexError, AttributeError): + pass + return pricing + def _adapt_rejected_param(params: dict, exc: BadRequestError) -> bool: """Translate or drop one parameter a provider rejected with a 400. @@ -117,8 +134,8 @@ def __init__( @property def estimated_cost(self) -> float | None: - """Rough cost estimate in USD. Returns None if model not in pricing table.""" - pricing = _PRICING.get(self.model) + """Rough cost estimate in USD. Returns None if the model has no known price.""" + pricing = _load_pricing().get(self.model) if not pricing: return None input_rate, output_rate = pricing diff --git a/corecoder/mcp.py b/corecoder/mcp.py index 85a568aa..3b37f8ac 100644 --- a/corecoder/mcp.py +++ b/corecoder/mcp.py @@ -9,6 +9,7 @@ remote tool joins the agent as `mcp____`, so consent, hooks and the main loop treat them exactly like the built-ins. A server that hangs or dies fails that one call as an ordinary error string; it never kills the loop. +The REPL's /mcp shows each server's state and reconnects a dead one by hand. """ import atexit @@ -46,21 +47,31 @@ class MCPClient: def __init__(self, name: str, command: str, args: list = (), env: dict | None = None): self.name = name self.call_timeout = CALL_TIMEOUT + self._command = command + self._args = args + self._env = env + self._next_id = 0 + self._dead: MCPError | None = None + self._responses: dict[int, dict] = {} + self._cond = threading.Condition() + self._write_lock = threading.Lock() + self._start() + + def _start(self): + """Spawn the server and run the startup handshake.""" self._proc = subprocess.Popen( - [command, *args], + [self._command, *self._args], stdin=subprocess.PIPE, stdout=subprocess.PIPE, - env={**os.environ, **(env or {})}, # servers inherit the user env, config overrides + env={**os.environ, **(self._env or {})}, # servers inherit the user env, config overrides encoding="utf-8", errors="replace", bufsize=1, # line buffered: the transport is newline-delimited JSON ) - self._next_id = 0 - self._dead: MCPError | None = None - self._responses: dict[int, dict] = {} - self._cond = threading.Condition() - self._write_lock = threading.Lock() - threading.Thread(target=self._read_loop, daemon=True).start() + threading.Thread(target=self._read_loop, args=(self._proc,), daemon=True).start() + with self._cond: + self._dead = None # a fresh process starts clean + self._responses.clear() try: self._request("initialize", { "protocolVersion": PROTOCOL_VERSION, @@ -74,6 +85,23 @@ def __init__(self, name: str, command: str, args: list = (), env: dict | None = self.close() # a half-started server must not leak raise + @property + def error(self) -> MCPError | None: + """What killed the server, or None while it is healthy.""" + return self._dead + + def reconnect(self): + """Start a fresh process for a dead or hung server and redo the + handshake. The mcp__ tools the agent already holds route through this + client, so they work again as soon as the handshake lands.""" + self.close() + try: + self._start() + except (MCPError, OSError) as e: + with self._cond: + self._dead = e if isinstance(e, MCPError) else MCPError(str(e)) + raise + def call_tool(self, tool_name: str, arguments: dict) -> str: """Run one remote tool and return its text content.""" result = self._request( @@ -137,9 +165,9 @@ def _write(self, msg: dict): except OSError as e: raise MCPError(f"MCP server {self.name!r} is not writable: {e}") from e - def _read_loop(self): + def _read_loop(self, proc: subprocess.Popen): try: - for line in self._proc.stdout: + for line in proc.stdout: try: msg = json.loads(line) except json.JSONDecodeError: @@ -153,8 +181,9 @@ def _read_loop(self): except (OSError, ValueError): pass with self._cond: - # stdout closing means the server is gone for good - if self._dead is None: + # stdout closing means the server is gone for good; a reader left + # over from before a reconnect must not mark the new process dead + if proc is self._proc and self._dead is None: self._dead = MCPError(f"MCP server {self.name!r} exited") self._cond.notify_all() diff --git a/tests/conftest.py b/tests/conftest.py index 644409c8..c2807d1e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ import pytest from corecoder import Config, checkpoints, cli +from corecoder import llm as llm_module from corecoder.tools import ALL_TOOLS @@ -15,6 +16,12 @@ def _checkpoints_isolated(tmp_path, monkeypatch): checkpoints.clear() +@pytest.fixture(autouse=True) +def _pricing_isolated(tmp_path, monkeypatch): + """Cost estimates must never read the real ~/.corecoder/pricing.json.""" + monkeypatch.setattr(llm_module, "PRICING_FILE", tmp_path / "pricing.json") + + def get_tool(name: str): """Look up a tool by name.""" for t in ALL_TOOLS: diff --git a/tests/test_core.py b/tests/test_core.py index ae74519b..821ea5e1 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,5 +1,6 @@ """Tests for core modules: config, context, session, imports.""" +import json import re import tempfile from pathlib import Path @@ -65,7 +66,8 @@ def test_doc_python_line_references_are_current(): r"((?:corecoder/)?(?:tools/)?[a-z_]+\.py)[^()\n]{0,25}?(\d+)\s*(?:lines|行)(?!\s*(?:上下|多|来))" ) actual = { - str(p.relative_to(root)): len(p.read_text(encoding="utf-8").splitlines()) + # POSIX form so the forward-slash references resolve on Windows too + p.relative_to(root).as_posix(): len(p.read_text(encoding="utf-8").splitlines()) for p in (root / "corecoder").rglob("*.py") } @@ -262,6 +264,37 @@ def test_cost_estimation_unknown_model(): assert llm.estimated_cost is None +def _llm_at(model, prompt_tokens=1_000_000, completion_tokens=500_000): + from corecoder.llm import LLM + llm = LLM.__new__(LLM) + llm.model = model + llm.total_prompt_tokens = prompt_tokens + llm.total_completion_tokens = completion_tokens + return llm + + +def test_pricing_json_overrides_and_extends_the_builtin_table(monkeypatch, tmp_path): + from corecoder import llm as llm_module + custom = tmp_path / "pricing.json" + custom.write_text( + json.dumps({"gpt-5.4": [100, 200], "my-own-model": [1, 2]}), encoding="utf-8" + ) + monkeypatch.setattr(llm_module, "PRICING_FILE", custom) + + assert _llm_at("gpt-5.4").estimated_cost == 100 + 100 # the override wins over (2.5, 15) + assert _llm_at("my-own-model").estimated_cost == 1 + 1 # a model the built-ins never heard of + + +@pytest.mark.parametrize("content", ["{not json", '{"gpt-5.4": "cheap"}', '[1, 2]']) +def test_a_broken_pricing_json_falls_back_to_the_builtin_table(monkeypatch, tmp_path, content): + from corecoder import llm as llm_module + broken = tmp_path / "pricing.json" + broken.write_text(content, encoding="utf-8") + monkeypatch.setattr(llm_module, "PRICING_FILE", broken) + + assert _llm_at("gpt-5.4").estimated_cost == 2.5 + 7.5 + + # --- Changed files tracking --- def test_edit_tracks_changed_files(tmp_path): diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 2d8a6f85..15c5593e 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -17,6 +17,7 @@ from corecoder.llm import LLMResponse, ToolCall from corecoder.mcp import MCPError, load_mcp_tools from corecoder.permissions import Permission +from tests.conftest import repl_with FAKE_SERVER = """ import json, os, sys, time @@ -196,3 +197,66 @@ def test_hooks_match_mcp_tool_names(mcp_config): assert agent.chat("go") == "done" assert "mcp frozen" in agent.messages[2]["content"] + + +def test_mcp_command_lists_each_server_and_its_state(mcp_config, monkeypatch, capsys): + agent = _agent([], load_mcp_tools(mcp_config)) + + repl_with(monkeypatch, agent, ["/mcp", "quit"]) + + out = capsys.readouterr().out + assert "fake" in out and "alive" in out and "4 tools" in out + + +def test_mcp_command_marks_a_dead_server_with_its_error(mcp_config, monkeypatch, capsys): + tools = load_mcp_tools(mcp_config) + crash = next(t for t in tools if t.name == "mcp__fake__crash") + with pytest.raises(MCPError, match="exited"): + crash.execute() + + repl_with(monkeypatch, _agent([], tools), ["/mcp", "quit"]) + + out = capsys.readouterr().out + assert "dead" in out and "exited" in out and "/mcp reconnect fake" in out + + +def test_mcp_reconnect_revives_a_dead_server(mcp_config, monkeypatch, capsys): + tools = load_mcp_tools(mcp_config) + echo = next(t for t in tools if t.name == "mcp__fake__echo") + crash = next(t for t in tools if t.name == "mcp__fake__crash") + with pytest.raises(MCPError, match="exited"): + crash.execute() + with pytest.raises(MCPError, match="exited"): + echo.execute(text="while dead") + + repl_with(monkeypatch, _agent([], tools), ["/mcp reconnect fake", "quit"]) + + assert "Reconnected" in capsys.readouterr().out + # the tool object the agent grabbed before the crash works again untouched + assert echo.execute(text="back") == "echo: back" + + +def test_mcp_reconnect_failure_keeps_the_server_dead(mcp_config, monkeypatch, capsys): + tools = load_mcp_tools(mcp_config) + crash = next(t for t in tools if t.name == "mcp__fake__crash") + with pytest.raises(MCPError, match="exited"): + crash.execute() + client = mcp._live_clients[0] + client._command = "not-a-real-binary-xyz" # the server can no longer be spawned + + repl_with(monkeypatch, _agent([], tools), ["/mcp reconnect fake", "quit"]) + + assert "Reconnect failed" in capsys.readouterr().out + assert client.error is not None + + +def test_mcp_command_with_no_servers_or_bad_arguments(monkeypatch, capsys): + agent = _agent([], []) + + repl_with(monkeypatch, agent, ["/mcp", "/mcp reconnect ghost", "/mcp frobnicate", "quit"]) + + out = capsys.readouterr().out + assert "No MCP servers running" in out + assert "No MCP server named 'ghost'" in out + assert "Usage: /mcp" in out + assert agent.messages == [] # none of it reached the model