Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Checks every pull request and every push to main: ruff's lint (the order of imports with it), mypy's types, and the tests that
# load no weights and touch no network (the ones that need the models, Docker's images, a vLLM server or pi skip).
# mypy and the tests import the whole app, so the job first installs every package uv.lock pins, torch among them.
# A second job builds How it works with pygments alone, as pages.yml does after the merge.
name: Checks

# A push to a pull request's branch is checked once, as the pull request: on a push to any branch as well, both ran,
# neither cancelled the other (their concurrency groups differ by the event), and torch was installed twice.
on:
push:
branches: [main]
pull_request:

permissions:
contents: read

concurrency:
group: checks-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true

jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v10.3.0
with:
version-file: pyproject.toml # its required-version: no uv older than the fix for the revoked emSign certificates
- id: sync
run: uv sync --locked
# Each runs even when the one before failed, so that one push reports all three.
- run: uv run ruff check
- if: ${{ !cancelled() && steps.sync.outcome == 'success' }}
run: uv run mypy
- if: ${{ !cancelled() && steps.sync.outcome == 'success' }}
run: uv run pytest

# What pages.yml runs before it publishes How it works, word for word: keep the two the same. The job above has
# every package installed, so it would not notice app/guide.py, app/render.py, agent/i18n.py, agent/model_families/
# or a package's __init__.py importing torch, transformers, streamlit or httpx; only the Pages build on main would.
# --isolated keeps uv from reusing a .venv that already has pygments, and with it the whole app: there is none in a
# fresh checkout, but there is in a checkout where these are run by hand.
guide:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v10.3.0
with:
version-file: pyproject.toml # its required-version: no uv older than the fix for the revoked emSign certificates
- run: uv run --isolated --no-project --with pygments --with pytest pytest tests/test_guide.py
# Runs even when the tests failed, so that one push reports both.
- if: ${{ !cancelled() }}
run: uv run --isolated --no-project --with pygments python -m app.guide _site
5 changes: 3 additions & 2 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ jobs:
# The page needs only pygments, not torch and the rest of the app, and its tests only pytest
# besides. They fail when an edit moved or removed a line the page points at, or changed code it
# quotes since its text was last reread (tests/guide_excerpts.txt); the page already up then stays.
- run: uv run --no-project --with pygments --with pytest pytest tests/test_guide.py
- run: uv run --no-project --with pygments python -m app.guide _site
# checks.yml's guide job runs these two on every pull request, so keep the two the same.
- run: uv run --isolated --no-project --with pygments --with pytest pytest tests/test_guide.py
- run: uv run --isolated --no-project --with pygments python -m app.guide _site
- uses: actions/configure-pages@v6
- uses: actions/upload-pages-artifact@v5
with:
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
.venv/
__pycache__/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.impeccable/
_site/
/models/
.DS_Store
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22
95 changes: 66 additions & 29 deletions AGENTS.md

Large diffs are not rendered by default.

105 changes: 74 additions & 31 deletions README.md

Large diffs are not rendered by default.

105 changes: 74 additions & 31 deletions README.zh-TW.md

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions agent/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""The agent: everything that runs a turn, and nothing a person sees.

harnesses/ is what runs the loop, behind one interface: simple_harness.py is this project's own loop (SimpleHarness):
context, model, parse, tool, observation, stop, with one event per stage for the app to draw; pi_harness.py hands the
loop to pi, a coding agent, and turns what pi reports into the same events. tools.py has the tools SimpleHarness runs,
with run_python's Docker sandbox built from sandbox/. media.py turns the files attached to a question into what the
model reads. model_families/ is everything model-specific, and model_backends/ is what runs the model, behind one interface.
i18n.py has the page's two languages, which the errors the model reads use too.

Nothing here imports Streamlit, and importing the package loads nothing heavy: the app's guide imports model_families
and i18n with pygments alone.
"""
7 changes: 7 additions & 0 deletions agent/harnesses/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""What runs the agent loop, behind the one interface the app knows.

harness.py is that interface (Harness) and the events every harness sends. simple_harness.py is this project's own
harness (SimpleHarness), which runs the model through a Backend (model_backends/); pi_harness.py hands the loop to pi, a
coding agent that runs its own (PiHarness). pi_extension.ts is the pi extension through which the page sees pi's
requests and asks before pi runs a tool, and model_endpoint.py serves the page's own model to pi over HTTP.
"""
56 changes: 56 additions & 0 deletions agent/harnesses/harness.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""The interface between the page and whatever runs the agent loop.

The page (app/page.py) runs one turn at a time and reaches the loop only through a Harness: it makes one with a
function that appends each event to the turn's list, calls run() in a background thread, and passes the user's
clicks on with stop() and decide(). render.py draws the list. This project's own harness is
simple_harness.SimpleHarness, which runs the model through a Backend (model_backends/backend.py), so it knows every token;
pi_harness.PiHarness hands the loop to pi, a coding agent that runs its own and says what it does as JSON. Another
agent that reports its loop as events, such as the Claude Agent SDK or Codex, would be one more; this module says
what it has to send.

A harness that runs the model through a Backend knows the prompt the model reads and each token it writes; one that
asks a model's API sees only the text that comes back. So, like a token event's details (backend.TOKEN_DETAILS), most
events are sent only by a harness that knows what they say, and render.py draws a step from whichever it has: the page
never shows a prompt or a number the harness did not see.

Nothing here imports anything, so a harness can be written, and the interface read, without the rest.
"""

from typing import Protocol, runtime_checkable

# The events every harness sends: run_start first, a step_start each time around the loop, and run_end last (after
# an error event when something failed). run_start carries which harness ran the turn (`harness`: "simple", "pi"), the
# model, its family (a key of model_families.FAMILIES, or None when the harness does not know how the model writes its turns),
# the turn's settings and the tools the model gets; run_end the stop reason, the final reply and the conversation.
ALWAYS = ("run_start", "step_start", "run_end")
# The events a harness sends in a step when it knows what they say, in the order they come:
# request the request sent to the model's API, as JSON, from a harness that asks one (pi)
# context the prompt as the model reads it, from a harness that renders it (SimpleHarness, or pi's model endpoint)
# prefill, token the prompt read, then each token (backend.TOKEN_FIELDS), from a harness that runs the model itself
# delta a piece of the model's output as it streams, with its channel, from one that sees text, not tokens (pi)
# model_end the model's turn ended: why, and how many tokens, with their speed when it was measured
# parsed what the harness read in it: the thinking, the reply, the tool calls
# tool_call a call it will run (or reports as failed), with its environment
# approval_request, approval the call held for the user, and the user's answer
# tool_start, tool_result the call running in its environment, and what came back
# error what went wrong, before run_end
SOMETIMES = ("request", "context", "prefill", "token", "delta", "model_end", "parsed", "tool_call", "approval_request", "approval",
"tool_start", "tool_result", "error")


@runtime_checkable
class Harness(Protocol):
"""What runs one turn. The page makes one per question (app.page.LiveRun) and runs one at a time."""

id: str

def run(self) -> None:
"""Run the turn to its end, sending its events. It returns after run_end and never raises: a failure is an
error event followed by run_end."""

def stop(self) -> None:
"""Stop the turn as soon as it can: the user pressed Stop. It is called from another thread, and run() still
ends with run_end."""

def decide(self, call_id: str, approved: bool) -> None:
"""The user's answer to the approval_request for the call `call_id`. It is called from another thread."""
Loading
Loading