Skip to content

Let the sidebar hand the agent loop to pi, which can ask the page's own model - #8

Merged
cyyeh merged 7 commits into
mainfrom
pi-harness
Oct 26, 2026
Merged

cyyeh merged 7 commits into
mainfrom
pi-harness

Conversation

@cyyeh

@cyyeh cyyeh commented Oct 26, 2026 •

Copy link
Copy Markdown
Owner

What changes for the user

The sidebar has a new switch at the top, What runs the loop: the built-in harness, as before, or pi, a coding agent. With pi picked, The model pi asks chooses which model pi asks:

  • This page's model (the default): Gemma 4 E2B or Qwen3.5 0.8B, on transformers or vLLM, with the same model menu, What runs the model and decoding settings as the built-in harness. pi's steps show the string the model read and every token, with its probability and candidates, as the built-in harness's do.
  • pi's own models: one pi has credentials for (Claude, GPT…). No model loads here; a step shows the request pi sent, the text as it streams, the provider's token counts and the cost at pi's prices. Tokens and probabilities do not show, since an API sends none.

Either way, each step shows the request pi sent the model: pi's system prompt, its tools and the conversation as JSON, with what the step added marked.

How

One interface for whatever runs the loop. agent/harnesses/harness.py defines Harness (run, stop, decide) and which events every harness sends (ALWAYS) and which only one that knows them (SOMETIMES). harness/agent.py moves to agent/harnesses/simple_harness.py and Run becomes SimpleHarness, to sit beside SimpleBackend; run_start now says which harness ran the turn (simple or pi). LiveRun takes a function that makes the harness (harness_for()).

pi. agent/harnesses/pi_harness.py starts pi in RPC mode for each question (pi --mode rpc, JSONL on stdin and stdout):

  • in the conversation's folder and on its session, so the next question continues the conversation, and without the user's extensions, skills, prompt templates or context files, so the page draws pi's own loop;
  • it sets the model and the thinking level and turns pi's auto-retry off, each command only once pi has answered the last (pi handles commands concurrently: a prompt sent beside set_model goes to the old model);
  • one of pi's turns is one step; message_end becomes parsed, tool executions become tool_call, tool_start and tool_result; on a provider's model, message_update becomes a new event, delta (text, thinking and tool-call arguments by channel), and message_end also model_end with the provider's counts;
  • pi has no step limit, so the page aborts it at the turn past Max steps; Stop sends abort, and a pi that does not settle is killed. An abort also wakes a request to the page's model that waits for its step, and the endpoint turns away a request that belongs to no step once the turn is being stopped, so the model is free for the next question at once.

The extension. agent/harnesses/pi_extension.ts is loaded into every pi the page starts. It reports each request pi sends the model (before_provider_request, as a notification, which in RPC mode is an extension_ui_request on stdout) and becomes a new event, request; it registers the page's model as the provider agent-loop; and with Ask me before running a tool it opens a confirm dialog before every tool call, which the page answers on stdin.

The page's model for pi. agent/harnesses/model_endpoint.py serves the model in memory as an OpenAI-compatible chat completions endpoint on 127.0.0.1, behind a token, only while a turn is served, one request at a time. Each request becomes the conversation SimpleHarness keeps (arguments as dicts, thinking under the family's key, tool results named), is rendered with the chat template, encoded and generated; its context, prefill, token and model_end events go into pi's step. A request waits for the step pi opened for it, since pi's records and its HTTP request reach the page by different ways. The reply streams back to pi: thinking as reasoning_content, the reply as content, the tool calls the family parses as tool_calls. Decoding is the sidebar's; Thinking mode reaches the model as pi's thinking level, which pi sends as chat_template_kwargs.enable_thinking.

app + agent. With more than one harness, the package that runs a turn is no longer named after one of its parts: harness/ is agent/, beside app/. The agent is the harness that runs its loop, the model's family, what runs the model and the tools: agent/harnesses/ (the Harness interface, SimpleHarness, PiHarness, the pi extension and the model endpoint), agent/model_backends/ (was harness/backends/), agent/model_families/ (was harness/families/), and tools.py, media.py, i18n.py and sandbox/ as before. Imports follow (from agent import model_families), the sandbox builds with docker build -t agent-loop-sandbox agent/sandbox (an image built before still works), tests/test_families.py is tests/test_model_families.py, and AGENTS.md, the READMEs, How it works and the docstrings describe the two parts as app and agent.

The page. With pi picked, the intro on the Loop tab says what shows of the model pi asks: every token on this page's model, text and no probabilities on pi's own. render.py draws a run without a family, a context or tokens, and a step's request (in the step card when there is no context, and folded under the context in the Context tab when there is). The Model tab shows the page's model card, or pi's model's.

Docs. How it works tells the harnesses apart with the same tabs as the backends and the models: a new kind, What runs the loop, with a tab for the built-in harness and one for pi, and, where pi asking this page's model and pi asking one of its own differ, pi-page and pi-provider as narrower kinds of pi (as vllm-docker is of vllm). Picking one switches every harness tab on the page. Twelve of the 18 sections have them (overview, sending a question, preparing a run, context, model, parse, stop, writing the call back, checks, environment, observation, the page), with pi's code in its tabs; what the built-in harness and pi on this page's model share (the chat template, the parsers, the backends) stays outside them, since tabs do not nest. The event table has the delta and request rows. The READMEs (both languages) get "pi as the harness", and the layout, event list, tests table and known limitations follow. AGENTS.md has a rule for pi and the endpoint. docs/screenshot-en.png and docs/screenshot-zh.png are retaken from a real Gemma run (on MPS), with What runs the loop at the top of the sidebar.

Checks. Ruff lints and mypy checks types, both set up in pyproject.toml (no formatter), and .github/workflows/checks.yml runs them and uv run pytest on Ubuntu on every push and pull request. The rename had left 22 files' imports out of order, which ruff now sorts and keeps sorted. Bringing mypy to zero changed no behavior: each backend (and FakeLLM) typed its drafter as None alone, so none of them was a Backend to a type checker; the rest is annotations, narrowing where a value is known not to be None, a generic pick(), and format_func=names.__getitem__, which returns a str where dict.get may not. tests/test_layout.py checks the rule that nothing in agent/ imports app or Streamlit, at the top or inside a function. A clone from before the rename keeps an empty-looking harness/ of the __pycache__ folders git ignores; the READMEs say git clean -fdX harness removes it. .nvmrc picks Node 22 for pi with nvm.

Testing

  • After the checks commit: uv run ruff check and uv run mypy report nothing; uv run pytest 1005 passed, 79 skipped (the 20 new are test_layout.py); RUN_MODEL_TESTS=1 uv run pytest tests/test_real_model.py tests/test_chat_template.py 102 passed, 5 skipped, since the commit touches simple_backend.py and media.py, if only in annotations; the guide builds and tests/test_guide.py passes with pygments alone, after rereading every section whose quoted code changed
  • uv run pytest: 985 passed, 79 skipped (the usual real-model, real-server and Docker ones, plus the real-pi tests), before and after the rename to agent/
  • tests/fake_pi.py speaks pi 1.4's RPC protocol from a script and, for the page's model, asks the endpoint over HTTP as pi does, so the default suite runs pi on both kinds of model with neither Node nor a model: test_pi_harness.py, test_model_endpoint.py (the endpoint over HTTP with FakeLLM behind it), pi scenarios in test_render.py (every prefix renders, escaping, an English page with no Chinese) and test_app.py
  • RUN_PI_TESTS=1 uv run pytest tests/test_pi_harness.py: all 46 pass with the real pi 1.4.0 (Node 22.22), again after the rename, against a scripted OpenAI-compatible model on 127.0.0.1 and against the endpoint with FakeLLM behind it (thinking on and off, and asking first up to the step limit)
  • RUN_MODEL_TESTS=1 uv run pytest tests/test_real_model.py, before the review fixes, which touch neither parsing nor prompts nor decoding (real Gemma 4 E2B with its drafter, and Qwen3.5 0.8B, on an Apple M5, with the real pi): 62 passed, 3 skipped; the new test has the real pi ask each model through the endpoint, call bash and answer. After the rename: RUN_MODEL_TESTS=1 uv run pytest tests/test_real_model.py tests/test_chat_template.py tests/test_tools.py, 212 passed, 3 skipped, with the sandbox image built (the two real-pi tests rerun after putting pi 1.4.0 back on the machine, where they had failed on an older pi 0.74.2 that rejects --session-id)
  • By hand, the real pi through the endpoint: Qwen3.5 0.8B and Gemma 4 E2B on transformers, and Qwen3.5 0.8B on vLLM 0.32 in Docker (CPU), each calling bash and answering, every token drawn; and the page itself in a browser, pi on Gemma
  • The guide builds with pygments alone, and tests/test_guide.py passes that way too; the excerpts in tests/guide_excerpts.txt were rewritten after rereading every section they named, and in the rename every changed line differs only in a path or module name

Not tested: vLLM in Docker and against a real server after the rename (RUN_VLLM_DOCKER_TESTS, VLLM_TEST_URL; the default suite fakes both); pi against a real provider (Claude, GPT…), which needs credentials and costs money; Gemma 4 on vLLM in Docker, whose first start on a CPU takes over ten minutes.

Known limits

  • On the page's model, a tool call the family cannot parse is not sent to pi, and pi's turn ends there: unlike the built-in harness, nothing tells the model what it wrote wrong.
  • pi's system prompt and tools are written for large models. In a browser run at temperature 1, Gemma 4 E2B answered "What time is it in Taipei right now?" without calling bash; asked to use bash, both small models do.

🤖 Generated with Claude Code

cyyeh and others added 2 commits October 26, 2026 15:29
What runs the loop is now a choice, like what runs the model: the built-in
harness, or pi, a coding agent that asks a model it has credentials for
(Claude, GPT…). The page draws pi's steps in the same diagram, step cards
and event list, from the events pi reports, without tokens or a prompt.

Harness
- harness/harnesses/harness.py: Harness, what the page asks of whatever
  runs the loop (run, stop, decide), and which events every harness sends
  and which only one that knows them.
- harness/agent.py moves to harness/harnesses/simple_harness.py, and Run
  becomes SimpleHarness, beside SimpleBackend; run_start says which harness
  ran the turn ("simple" or "pi").
- LiveRun takes a function that makes the harness (harness_for()).

pi
- harness/harnesses/pi_harness.py: PiHarness starts pi in RPC mode for each
  question, in the conversation's folder and on its session, without the
  user's extensions, skills or context files; it sets the model and the
  thinking level and turns pi's retries off before the question goes, and
  turns pi's records into the page's events. One of pi's turns is a step;
  its streamed text is a new event, delta. pi has no step limit, so the page
  aborts it past Max steps.
- harness/harnesses/pi_approve.ts: a pi extension that asks before every
  tool call; its dialog becomes the page's Allow and Reject, and a refusal
  reaches the model in the turn's language.
- The sidebar: What runs the loop, then pi's models (pi.probe), its tools,
  its thinking level, Ask me before running a tool and Max steps. The
  Context tab says pi shows no prompt; the Model tab shows pi's model,
  settings and tools, which run on this computer without a sandbox.
- render.py draws a run without a family, a context or tokens: the streamed
  text by channel, the provider's token counts, and pi's own wording for
  its stops and its diagram.

Tests and docs
- tests/fake_pi.py speaks pi 1.4's RPC protocol from a script, so the
  default suite needs neither Node nor a model; test_pi_harness.py covers
  PiHarness, and with RUN_PI_TESTS=1 runs the real pi against a scripted
  model on 127.0.0.1 (passes with pi 1.4.0). test_render.py and
  test_app.py cover pi's runs and the page with pi picked.
- How it works gets a section on pi and the delta event; the READMEs,
  AGENTS.md and the guide's excerpts follow the move and the rename.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uest pi sends

With pi picked under What runs the loop, The model pi asks now picks either
this page's model (the default) or one of pi's own. On the page's model,
Gemma 4 E2B or Qwen3.5 0.8B on transformers or vLLM, pi's steps show the
string the model read and every token, as the built-in harness's do; on
pi's own models, each step's context section shows the request pi sent.

The page's model for pi
- harness/harnesses/model_endpoint.py serves the model in memory as an
  OpenAI-compatible chat completions endpoint on 127.0.0.1, behind a
  token, only while a turn is served. Each request becomes the
  conversation SimpleHarness keeps, is rendered with the chat template,
  encoded and generated; its context, prefill, token and model_end events
  go into pi's step, and the reply streams back to pi (thinking as
  reasoning_content, the tool calls the family parses as tool_calls).
- A request waits for the step pi opened for it, since pi's records and
  its HTTP request reach the page by different ways.
- The sidebar keeps the model menu, What runs the model and the decoding
  settings for it; Thinking mode reaches the model as pi's thinking level.

The request pi sends
- pi_approve.ts becomes pi_extension.ts, loaded into every pi: it reports
  each request pi sends the model (before_provider_request) as a
  notification, registers the page's model as the provider agent-loop,
  and, with Ask me before running a tool, asks before each call.
- A new event, request: the request as JSON, image bytes left out, with
  what the step added marked. It is the context section of a step on a
  provider's model, and folded under the context on the page's model.

Screenshots and docs
- docs/screenshot-en.png and docs/screenshot-zh.png are retaken from a
  real Gemma run (on MPS), with What runs the loop at the top of the
  sidebar.
- How it works: the pi section in tabs, one per kind of model pi asks,
  with the endpoint's code; the request event in the event table. The
  READMEs and AGENTS.md follow.

Tests
- test_model_endpoint.py asks the endpoint over HTTP with FakeLLM behind
  it; fake_pi.py asks it as pi does, so test_pi_harness.py, test_render.py
  and test_app.py cover pi on the page's model in the default suite.
- With RUN_PI_TESTS=1 the real pi 1.4.0 asks the endpoint too, and with
  RUN_MODEL_TESTS=1 it asks real Gemma 4 E2B and Qwen3.5 0.8B.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cyyeh cyyeh changed the title Let the sidebar hand the agent loop to pi, behind one Harness interface Let the sidebar hand the agent loop to pi, which can ask the page's own model Oct 26, 2026
How it works now treats what runs the loop as it treats what runs the
model and the model: a part of a section that differs by harness is told
once for each, in tabs, and picking one switches every set on the page.

- A new tab kind, What runs the loop: Built-in harness (SimpleHarness)
  and pi (PiHarness), with pi-page and pi-provider as narrower kinds of
  pi, as vllm-docker is of vllm, where pi asking this page's model and
  pi asking one of its own differ. guide.js already syncs narrower kinds.
- The overview, sending a question, preparing a run, context, model,
  parse, stop, writing the call back, checks, environment, observation
  and the page each get harness tabs, with pi's code in its tabs. The
  separate section on pi is spread into them, so the guide has 18
  sections again.
- Tabs do not nest, so what the built-in harness and pi on this page's
  model share (the chat template, the parsers, the backends) stays
  outside the harness tabs, saying it is for both.
- The READMEs and AGENTS.md say the guide's tabs cover the harness too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@cyyeh cyyeh left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review 摘要

結論:功能大致實作正確,架構也清楚:一個 Harness 介面,事件是唯一的介面,render 只畫收到的事件、不補沒看到的東西。內建 harness 沒有看到回歸。

需要處理的有三件:

  • 一個小的並行問題:已重現,修法只要兩行。
  • 幾處第二個 commit 之後過時、使用者看得到的文字。
  • 兩條沒有測試的路徑。

驗證了什麼

測試與 guide

  • uv run pytest:919 passed, 131 skipped,沒有失敗。skip 比 PR 描述的多,是因為這台機器沒有模型權重和 sandbox image。
  • 只用 pygments:tests/test_guide.py 112 passed,python -m app.guide _site 也建得出來。

對照 pi 1.4.0 的文件和 openai-completions provider 的原始碼,PR 依賴的行為都成立:

  • prompt 在被接受後就回 response(disposition: "started"),事件之後才串流。
  • tool_execution_start 在 extension 的 tool_call 之前,所以 confirm 來時 call_id 已經在 _calls 裡。
  • notify 在 RPC 模式是 fire-and-forget 的 extension_ui_request。
  • thinkingFormat: "chat-template" 配 $var: "thinking.enabled" 是文件寫的用法。
  • 端點出錯時送的 finish_reason(error: …)pi 不認得,會變成 stopReason: "error" 和 Provider finish_reason: …,所以錯誤確實會顯示在頁面上。
  • reasoning_content 會被讀成 thinking。

另外用 FakePi 跑了兩條沒有測試的路徑

  • pi 問網頁的模型時的允許/拒絕:結果正常,拒絕的原因用這一輪的語言回到模型。
  • pi 問網頁的模型時的步數上限:發現下面第 1 個問題。

沒有跑

  • RUN_PI_TESTS=1:這個環境用 npm 裝 pi 一直卡住,也不允許執行下載來的程式。
  • RUN_MODEL_TESTS=1:這台機器沒有權重。

這兩項的結果請以 PR 描述為準。

問題(細節見 inline)

  1. _abort() 沒有 notify _unserved:pi 問網頁的模型、跑到步數上限時,模型端點可能抱著 ep._lock 白等 WAIT_FOR_STEP(10 秒),下一個問題會被卡住。已重現:整輪 10.12 秒,加上 notify 後 0.45 秒。
  2. 過時的 UI 文字:PI_INTRO、「用什麼跑迴圈」的說明、page.py 的 docstring、pi 的模型卡和工具卡。在預設設定(pi 問這個網頁的模型)下,這些文字說的和畫面相反,例如「脈絡分頁是空的」、「沒有 token 和機率」。

測試覆蓋度

整體覆蓋得不錯:

  • FakePi 走的是真的 RPC 協定和真的 HTTP 端點。
  • 每個事件前綴都會 render,轉義和「英文頁沒有中文」也都有測。

缺口:

  • pi 問網頁的模型 + 「執行工具前先問我」:FakePi 和真的 pi 都沒有測。asking() 只用 provider 的模型;真的 pi 的 approve 參數也只用在 scripted provider 上。我用 FakePi 試過,行為是對的,建議補成測試。
  • pi 問網頁的模型 + 步數上限:沒有測試,而正好是第 1 個問題出現的路徑。
  • 介紹文字的內容:依 pi_source 該說什麼沒有被斷言,所以文字過時了也沒有測試失敗。

設計建議(不擋 merge)

  • pi 預設的工具有 bash、edit、write,都在這台電腦上執行、沒有沙箱,而「執行工具前先問我」預設是關的。
  • 配上 E2B、0.8B 這樣的小模型時風險更高,而且有 bash 時,需要 Python 的範例就能直接按。
  • 可以考慮選 pi 時把「執行工具前先問我」預設打開。側欄已經有警語,所以這是取捨,不算 bug。

Generated by Claude Code

Comment thread agent/harnesses/pi_harness.py
Comment thread app/render.py Outdated
Comment thread app/page.py Outdated
Comment thread app/render.py Outdated
Comment thread agent/harnesses/pi_harness.py
… the model pi asks

Review of #8.

Aborting pi:
- `_abort()` now wakes a request that waits for its step (`_serving_step`). pi's request for
  the turn past Max steps can reach the endpoint before the page has read that turn's start;
  it then waited out WAIT_FOR_STEP (10 s) holding the endpoint's lock, so the next question's
  first request waited too.
- The endpoint turns away a request that belongs to no step once the turn is being stopped,
  instead of rendering, encoding and prefilling it for a pi that has given up on it.

Text that predated pi asking the page's model:
- The intro on the Loop tab now depends on the model pi asks: on this page's model it says
  every token and the string the model reads show; on pi's own models, that the Context tab
  holds pi's request and that no probabilities show.
- What runs the loop's help, page.py's docstring, pi's model card and its tools card no
  longer say the prompt and the tools' schemas stay out of view.

Tests:
- pi on the page's model at the step limit, with the request arriving before the page reads
  the turn's start: the turn ends at once, the request is turned away and the endpoint is
  free; with RUN_PI_TESTS=1, the same with the real pi, asking first.
- Ask me before running a tool on the page's model: Allow and Reject, and what the model
  reads of each.
- The intro says what shows for each model pi asks.
- FakePi ends a message in an HTTP error, as pi's provider does.

How it works §18 says the abort wakes the waiting request; tests/guide_excerpts.txt was
rewritten after rereading it. Both READMEs' test tables name the new tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cyyeh

cyyeh commented Oct 26, 2026

Copy link
Copy Markdown
Owner Author

謝謝 review。每一點都對照了程式碼,全部確認正確,c735e8e 修正,細節回在各個 inline comment。

問題

  1. _abort() 沒有 notify _unserved:已修。另外,端點會直接拒絕中止時不屬於任何一步的請求,不再白做 prefill。
  2. 過時的 UI 文字:PI_INTRO(現在依 pi_source 分兩種)、「用什麼跑迴圈」的說明、page.py 的 docstring、pi 的模型卡和工具卡,都已修正。
  3. nit:已補空行。

測試覆蓋度的缺口,都補上了

  • pi 問網頁的模型 + 步數上限:
    • FakePi:讓請求比 turn_start 先到,檢查這一輪馬上結束、請求被拒、端點的 lock 空出來。
    • 真 pi(RUN_PI_TESTS=1):同樣的情境,同時打開「執行工具前先問我」。
    • 兩個測試在拿掉 notify 時都會失敗。
  • pi 問網頁的模型 + 「執行工具前先問我」:允許和拒絕各一次,也檢查模型讀到的是工具結果,還是這一輪語言的拒絕原因。
  • 介紹文字的內容:test_pis_intro_says_what_shows_of_the_model_pi_asks。

跑過的

  • uv run pytest:985 passed, 79 skipped。
  • 真 pi 1.4.0(Node 22.22)跑 RUN_PI_TESTS=1 uv run pytest tests/test_pi_harness.py:46 passed。
  • 只用 pygments:tests/test_guide.py 120 passed,guide 也建得出來。
  • How it works §18 引用的 _abort 變了,重讀兩種語言的段落、補了一句後,重寫了 tests/guide_excerpts.txt。
  • RUN_MODEL_TESTS=1 這次沒有重跑:這些修正沒有動到解析、prompt 或解碼。

設計建議(選 pi 時預設打開「執行工具前先問我」)

這次沒有改:

  • 兩個 harness 的側欄共用同一個 approve_tools,要讓 pi 預設打開,得先有依 harness 分開的預設值。
  • 側欄和工具卡都已經寫明 pi 的工具沒有沙箱。

這是取捨,留待決定;要改的話可以另開一個 commit。


Generated by Claude Code

@cyyeh cyyeh left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

第二輪 review(c735e8e)

結論:上一輪提出的問題都修好了,也都驗證過,這一輪沒有新的問題。從 review 這邊看,這個 PR 沒有待處理的事項。

上一輪的問題

  • _abort() 沒有 notify _unserved:已修。
    • 我把 notify 暫時拿掉,test_the_step_limit_on_the_pages_model_does_not_keep_the_request_past_it_waiting 會失敗,整輪跑 10.16 秒;放回去就通過。這個測試確實守住了這個問題。
  • 端點拒絕不屬於任何一步、而這一輪正在停止的請求(503):這是新加的行為。
    • 對照了 pi 1.4.0 的 retryProviderRequest:重試可以被中止,預設 maxRetries 是 0。
    • 就算 abort 送到之前 pi 先收到 503,重試的請求也會立刻再收到 503。
    • 這個 turn 的 _open 已經是 False,所以那則錯誤訊息不會變成 error 事件,停止原因仍是 max_steps。
  • 過時的 UI 文字:PI_INTRO 現在依 pi_source 分成兩種;「用什麼跑迴圈」的說明、page.py 的 docstring、模型卡和工具卡都改了。內容和畫面一致,也有測試檢查內容。
  • nit:空行已補上。

這一輪另外看了什麼

  • 98f91a2(How it works 依 harness 分頁)
    • 逐段讀了所有 pi 分頁的英文說明(overview、submit、prepare、context、model、parse、stop、record、check、execute、observe、ui),都和程式碼一致。
    • 在 Chromium 裡實際點了建好的頁面:pi-page 和 pi-provider 是同層的兩個子分頁,不會互相誤判。
      • 選其中一個時,只有 simple/pi 的分頁組會跟著切到 pi。
      • 在 pi 和 simple 之間切換時,三個分頁的組會停在選過的子分頁。
      • 沒有 JS 錯誤。
  • 新測試:網頁模型的允許/拒絕、步數上限(FakePi,以及 RUN_PI_TESTS=1 的真 pi)、介紹文字的內容。補上了上一輪指出的缺口。

跑過的

  • uv run pytest:932 passed, 132 skipped,沒有失敗。skip 比你那邊多,是因為這台機器沒有權重和 sandbox image。
  • 只用 pygments:tests/test_guide.py 120 passed,python -m app.guide _site 建得出來。
  • 沒有跑:RUN_PI_TESTS=1(這個環境裝不了 pi)和 RUN_MODEL_TESTS=1(沒有權重),以你回報的結果為準。

設計建議

選 pi 時預設打開「執行工具前先問我」:同意你的理由,兩個 harness 的側欄共用同一個 approve_tools,要改就得先把預設值依 harness 分開。這個建議不擋 merge。


Generated by Claude Code

@cyyeh
cyyeh marked this pull request as ready for review October 26, 2026 09:25
…milies

The project's two parts are now app/ and agent/. The agent is everything that runs a
turn: the harness that runs its loop, the model's family, what runs the model, and the
tools. The harness is one part of it, beside the model, so the package that holds all of
them is no longer named after it.

- harness/ is agent/, harness/backends/ is agent/model_backends/ and harness/families/ is
  agent/model_families/; harnesses/, tools.py, media.py, i18n.py and sandbox/ keep their
  names. Imports follow (`from agent import model_families`,
  `from agent.model_backends.backend import Backend`), and so does every
  `families.X` (`model_families.REVISIONS`, `model_families.VLLM_SPECULATIVE`).
- tests/test_families.py is tests/test_model_families.py.
- The sandbox builds with `docker build -t agent-loop-sandbox agent/sandbox`, which the
  sidebar now says too. Only a comment in its Dockerfile changed, so an image built
  before still works and SANDBOX_VERSION stays.
- AGENTS.md, both READMEs, How it works and the package docstrings describe the two
  parts as app and agent ("The agent never knows the app"). All the guide's code
  references point at the new paths. In tests/guide_excerpts.txt every changed line
  differs only in a path or module name, which the guide's text was reread and fixed for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@cyyeh cyyeh left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review a8d22f3(harness/ → agent/ 改名)

結論:沒有發現新問題,可以合併。 這個 commit 只改名,行為沒有變。以下是檢查過的項目。

改名是否完整

  • 在所有 tracked 檔案裡(包括 workflow、compose、Dockerfile、兩份 README 和 AGENTS.md)搜尋舊的路徑和模組名:harness/backends、harness/families、from harness、harness.*、families.X、test_families。一個都沒剩。
  • 只用 pygments 建出 guide,HTML 裡的 GitHub 連結有 172 個指向 agent/、31 個指向 app/、1 個指向 tests/,沒有指向 harness/ 的。連結用的是 commit sha,所以合併前也點得開。
  • tests/guide_excerpts.txt:把舊版套上同一套改名規則再跟新版 diff,只剩一處差異,是一行註解裡的 (families/) → (model_families/),一樣是路徑。commit message 說「每一行只差在路徑或模組名」,屬實。

搬家後的路徑

  • 用 __file__ 算路徑的三處,深度都沒變:simple_backend.ROOT 和 MODELS_DIR、vllm_docker.COMPOSE_DIR(都是 parents[2]),以及 pi_harness.EXTENSION(with_name)。三處也都有測試把關:MODELS_DIR 那個測試會讀根目錄的 .gitignore,test_vllm_docker.py 從 COMPOSE_DIR 讀 compose 檔。
  • sandbox 的 Dockerfile 和兩個 compose 檔只改了註解,所以 SANDBOX_VERSION 和 vLLM CPU image 的 tag 都不用升,之前建好的 image 照樣能用。
  • agent 這個 top-level 名字跟 .venv 裡的套件不衝突(查過每個套件的 top_level.txt 和 RECORD)。

AGENTS.md 的規則

  • 用 AST 掃過 agent/**/*.py,沒有任何檔案 import app、streamlit 或舊的 harness。
  • import agent, agent.model_families, agent.i18n, app 之後,torch、transformers、streamlit、httpx、numpy、av 都還沒被載入。

跑過的

  • uv run pytest:932 passed、132 skipped,一共 1064 個,跟 PR 說明的 985 + 79 一樣多。skip 全部出自這台機器的環境:沒有下載模型、沒有 Docker 和 sandbox image,也沒設 RUN_MODEL_TESTS、RUN_PI_TESTS、VLLM_TEST_URL、RUN_VLLM_DOCKER_TESTS。
  • 只用 pygments 跑 tests/test_guide.py:120 passed。python -m app.guide _site 建得出 zh/、en/ 和 index.html。
  • 沒跑到的:真的 pi(這個環境不允許從 npm 安裝)、真模型、chat template(模型沒下載),還有 Docker 和 vLLM。PR 說明寫了改名後這些都跑過,而這個 commit 也沒動到它們的邏輯。

測試覆蓋度

改名本身的覆蓋是夠的:

  • 漏改一處舊路徑,import 就會失敗。
  • guide 的每個參照由 test_guide.py 對到行。
  • test_tools.py 檢查 docker build -t agent-loop-sandbox agent/sandbox 這個字串。
  • MODELS_DIR 和 COMPOSE_DIR 的位置有測試。

有一個缺口,改名前就存在,不擋這個 PR:AGENTS.md 的「The agent never knows the app」沒有測試把關。

  • 「import 不載入重的套件」這條,Pages workflow 只用 pygments 跑 test_guide.py,算是間接檢查到了。
  • 「agent/ 不 import app/ 或 Streamlit」這條,目前只能靠人眼,這次是我手動掃的。

可以考慮加一個小測試:走過 agent/**/*.py 的 AST,斷言沒有 import app 或 streamlit。

nit(可選)

  • import 順序:改名前 harness.backends < harness.families < harness.harnesses 剛好照字母排。改名後 agent.model_* 排到了 agent.harnesses、agent.i18n 前面,有 15 個檔案不再照字母排,例如 agent/harnesses/pi_harness.py、app/page.py、tests/test_pi_harness.py。專案沒有 linter,這純粹是外觀,調不調都可以。
  • 已經 clone 的人:pull 之後,git 忽略的 harness/**/__pycache__/ 會留在原地,看起來像還有一個空的 harness/ 資料夾。沒有程式會 import 它,Python 也不會載入 __pycache__ 裡沒有原始檔的 .pyc,所以無害,只是可能讓人困惑。

Generated by Claude Code

…nt never imports the app

The rename to agent/ left 22 files' imports out of order (agent.model_* now sorts before
agent.harnesses), nothing checked the rule that agent/ never imports app/ or Streamlit, and
there was no linter or type checker at all. Now:

Lint
- Ruff, set up in pyproject.toml: pyflakes and pycodestyle's errors, import order, bugbear and
  pyupgrade. No formatter, so it never rewrites a line it has nothing to say about; zip()'s
  strict= and named lambdas are left off, with the reason beside them.
- Imports sorted everywhere (tests/'s helpers sort with agent and app, as src), three unused
  imports gone, Callable and Iterator from collections.abc, two loop variables marked unused.
- An f-string in test_render.py reused its quotes, which only Python 3.12 parses, though
  requires-python says 3.11.

Types
- mypy, at its default strictness, with the bodies of agent/'s and app/'s unannotated
  functions checked too (SimpleHarness.run, the page); the tests only where annotated.
  Stubs for PyYAML, Pygments and openpyxl in the dev group.
- No behavior changes. SimpleBackend, VLLMBackend and FakeLLM were not Backends to a type
  checker: each typed its drafter (and VLLMBackend its drafter_id) as None alone. Encoded.ids
  is Any, the form each backend's generate() takes; pick() is generic, as the guide already
  called it with tuples. The rest is annotations on empty dicts and lists, Sequence where a
  default is (), asserts where a value is known not to be None (a tool call without errors
  has a tool, generate() ends with done, Popen's pipes), PiHarness.pi for the pi run()
  started, and format_func=names.__getitem__, which returns a str where dict.get may not.

CI
- .github/workflows/checks.yml runs ruff check, mypy and uv run pytest on Ubuntu on every
  push and pull request, each even when the one before failed.

The agent never knows the app
- tests/test_layout.py reads every file of agent/ for an import of app or streamlit, at the
  top or inside a function, and checks that the check itself sees each way of importing.

Also
- .gitignore: .mypy_cache/, .ruff_cache/ and .impeccable/.
- .nvmrc picks Node 22 for pi with nvm (`nvm use`), which the READMEs' pi section and
  AGENTS.md now say; nvm keeps global packages per version, so pi is installed under it.
- The READMEs tell a clone from before the rename that the empty-looking harness/ it keeps is
  only the __pycache__ folders git ignores, which `git clean -fdX harness` removes.
- AGENTS.md and both READMEs list the commands, the workflow and the new test.
- How it works: the guide's text was reread for every changed excerpt (annotations, a renamed
  local, the drafting condition now `mtp is not None`); none of it became untrue, and
  tests/guide_excerpts.txt is rewritten. One reference follows the line it points at.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@cyyeh cyyeh left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review b8e5ff1(ruff、mypy、CI 和 tests/test_layout.py)

結論:沒有發現新問題,可以合併。 上次 review 提的三點都處理了。commit 說「沒有行為改變」,我逐處對過,屬實。最後有一個建議和兩個 nit,都不擋合併。

上次提的三點

  • import 順序:ruff 的 I 規則加上 CI,以後順序亂了就會被抓到。
  • 「agent 不 import app」沒有測試:tests/test_layout.py 補上了。
    • 它也測了檢查本身(test_the_check_sees_every_way_of_importing)。
    • 寫在函式裡的 import 抓得到;relative import 則跳過,這沒問題,因為它跑不出 agent/。
  • 舊 clone 留下的 harness/:README 裡的 git clean -fdX harness,我在一個暫時的 repo 裡試過。它會刪掉整個 harness/,而且只刪 git 忽略的檔案。

「沒有行為改變」:逐處確認

為了型別而改的地方,凡是可能改到行為的,我都對過:

  • SimpleBackend.generate():if drafting 改成 if mtp is not None。mtp 只有在 drafting 為真時才是 dict,兩者等價,解碼迴圈不變。
  • _encoded_media() 多了 encoder and:encoder 是 None 時,args 本來就是空的,結果一樣。
  • 新加的 assert:assert done is not None(SimpleHarness 和 model_endpoint)、assert tool is not None。
    • 前者只在 generate() 的迴圈正常結束後才執行到。
    • 後者只在 errors 是空的時候才執行到;而 tool is None 一定會產生錯誤。
    • 萬一 assert 失敗,AssertionError 一樣會被 except Exception 接住、送出 error 事件,跟原本的 AttributeError 走同一條路。
  • PiHarness.pi:改成 self.pi 的地方,都在 run() 建好 Process 之後才會執行;finally 裡還是用 self._pi 判斷。
  • _message_end 的 stopReason or "":reason 只拿來比較和查表,None 和 "" 都會落到 "end_turn"。
  • PiAgent.model 從 None 改成 {}:用到它的地方都在 if who.llm 的 else 分支,那時一定有 pi 的模型。
  • loop_panel 多了 or run is None:因為 data = run.data() if run else None,data 不是 None 時 run 一定也不是,所以等價。
  • format_func=X.__getitem__:選項都是 list(X),不會 KeyError。
  • guide.statement_end():ast.parse 產生的節點都有 end_lineno,結果不變。只用 pygments 建 guide 也通過。

跑過的

  • ruff:uv sync --locked 之後,uv run ruff check → All checks passed。
  • mypy:uv run mypy → no issues found in 44 source files。這 44 個正好是全部:agent/ 18、app/ 4、tests/ 21,加上 streamlit_app.py。整個 repo 沒有任何 type: ignore。
  • pytest:uv run pytest 952 passed、132 skipped。
    • 比上次多 20 個,正好是 test_layout.py 的 18 + 2。
    • skip 的跟上次一樣,全是這台機器的環境:沒有模型、沒有 sandbox image、沒有 pi。
  • guide:只用 pygments,tests/test_guide.py 120 passed,guide 也建得出來。
  • CI:這個 commit 的兩個 check 都成功,各約 3.5 分鐘。

建議:讓 checks.yml 也只用 pygments 跑一次 guide 的測試

AGENTS.md 還有一條規則:agent/__init__.py、agent/model_families/、agent/i18n.py 和 app/__init__.py 不能 import 重的套件。目前只有 pages.yml 檢查它,而 pages.yml 只在 push 到 main 時跑。所以違反這條規則的 PR 會通過 checks.yml,合併之後 Pages 的建置才會失敗。

我在一個 worktree 的 agent/i18n.py 加了一行 import httpx 試過:

  • 裝了所有套件(checks.yml 的環境):tests/test_guide.py 通過。
  • 只有 pygments(pages.yml 的環境):ModuleNotFoundError: No module named 'httpx'。

在 checks.yml 加一個 step,合併前就能抓到:

      - if: ${{ !cancelled() }}
        run: uv run --no-project --with pygments --with pytest pytest tests/test_guide.py

這個 step 不靠 uv sync,所以不用等 sync 成功。本機跑了約 90 秒。

nit(可選)

  • CI 每次 push 跑兩次:on 同時有 push(所有分支)和 pull_request,concurrency group 又含 event_name,兩個 run 不會互相取消。所以 push 到 PR 分支時,同樣的 job 會跑兩次,每次都裝一次 torch;這個 commit 就有兩個 check。把 push 限制在 branches: [main],就只會跑一次。
  • .nvmrc 寫的是 22:pi 1.4 要 Node 22.19 以上,但 nvm use 會挑已經裝好的最新 22.x。如果那個版本比 22.19 舊,pi 就跑不起來。README 改寫成 nvm install 會比較穩:它會照 .nvmrc 裝好最新的 22.x 再切過去。

Generated by Claude Code

…ach push once

- checks.yml has a guide job that runs what pages.yml runs before it publishes How it works: tests/test_guide.py
  and the build, with pygments and pytest alone. The check job installs every package, so a pull request that had
  app/guide.py, app/render.py, agent/i18n.py or agent/model_families/ import torch, transformers, streamlit or
  httpx passed it, and only the Pages build on main failed, after the merge.
- Both workflows run those commands with --isolated. Without it, uv reuses a .venv that already has pygments, and
  with it the whole app, so the same commands run by hand in a synced checkout passed with `import httpx` in
  agent/i18n.py. A fresh checkout in CI has no .venv; the flag makes a run by hand mean the same. AGENTS.md lists
  the command.
- checks.yml runs on pushes to main only, besides pull requests. A push to a pull request's branch ran every job
  twice, once per event (the concurrency group differs by the event, so neither cancelled the other), and installed
  torch twice. A push to a branch without a pull request is no longer checked.
- The READMEs say `nvm install` instead of `nvm use`: .nvmrc names 22, and nvm use picks the newest 22 already
  installed, which can be older than the 22.19 pi 1.4 needs; nvm install installs the newest 22 and switches to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cyyeh

cyyeh commented Oct 26, 2026

Copy link
Copy Markdown
Owner Author

謝謝 review。建議和兩個 nit 都對照過,全部確認,a8232b9 修正。

建議:checks.yml 也只用 pygments 跑 guide 的測試

  • 獨立的 guide job:沒有加在 check 裡當一個 step,而是另開一個 job,和 pages.yml 一樣先跑 tests/test_guide.py,再建出 guide。它不用等 torch 裝好,和 check 同時跑。兩個 workflow 的註解都提醒兩邊的指令要保持一樣。
  • 加了 --isolated:在已經 uv sync 過的 checkout 裡,uv run --no-project --with pygments 會沿用 .venv。裡面已經有 pygments,也有整個 app,所以在本機照建議的指令跑,agent/i18n.py 加了 import httpx 還是 120 個全過。
    • CI 是全新的 checkout,沒有 .venv,原本的指令在那裡沒問題。
    • 加上 --isolated,手動跑的結果才會和 CI 一樣。兩個 workflow 都加了,AGENTS.md 的 Commands 也列了這行。
  • 驗證(本機,加了 --isolated):
    • 沒改動:tests/test_guide.py 120 passed,guide 建得出來。
    • agent/i18n.py 加一行 import httpx:測試和建置都報 ModuleNotFoundError: No module named 'httpx'。

nit

  • CI 跑兩次:push 限制在 branches: [main]。b8e5ff1 跑了兩個 Checks,a8232b9 只有一個(pull_request)。代價是推到還沒開 PR 的分支,就不會再跑檢查。
  • .nvmrc:兩份 README 改成 nvm install,註解寫了 nvm use 為什麼不夠。AGENTS.md 裡 RUN_PI_TESTS 那行也一起改了。.nvmrc 還是 22。

AGENTS.md 和兩份 README 講 CI 什麼時候跑、跑什麼的段落都跟著更新了。

跑過的

  • uv run ruff check:All checks passed。
  • uv run pytest:1005 passed、79 skipped。
  • actionlint:兩個 workflow 都沒有問題。
  • CI(a8232b9):只有一個 run,check 成功(183 秒),guide 和它同時跑,也成功(85 秒)。
  • 這次只改了 workflow 和文件,沒有重跑 RUN_MODEL_TESTS=1 和 RUN_PI_TESTS=1。

Generated by Claude Code

@cyyeh
cyyeh merged commit ba85374 into main Oct 26, 2026
3 checks passed
@cyyeh
cyyeh deleted the pi-harness branch October 26, 2026 11:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant