chore: import upstream snapshot with attribution
CI / build (push) Failing after 1s
Test / web-build (push) Failing after 5s
Test / python-smoke (push) Failing after 2s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:12:24 +08:00
commit 33b4ec712e
450 changed files with 80543 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
# API Key (required)
# Get yours at: https://console.anthropic.com/
ANTHROPIC_API_KEY=sk-ant-xxx
# Model ID (required)
MODEL_ID=claude-sonnet-4-6
# Base URL (optional, for Anthropic-compatible providers)
# ANTHROPIC_BASE_URL=https://api.anthropic.com
# =============================================================================
# Anthropic-compatible providers
#
# Provider MODEL_ID Notes Base URL
# --------------- -------------------------- -------------------- -------------------
# Anthropic claude-sonnet-4-6 default (default)
# MiniMax MiniMax-M3 latest M-series see below
# GLM (Zhipu) glm-5.2 latest coding model see below
# Kimi (Moonshot) kimi-k2.7-code coding model see below
# DeepSeek deepseek-v4-pro pro model see below
# deepseek-v4-flash flash model see below
#
# Check provider docs for current availability, pricing, and regional access.
# =============================================================================
# ---- International ----
# MiniMax https://www.minimax.io
# ANTHROPIC_BASE_URL=https://api.minimax.io/anthropic
# MODEL_ID=MiniMax-M3
# MODEL_ID=MiniMax-M2.7
# MODEL_ID=MiniMax-M2.7-highspeed
# GLM (Zhipu) https://z.ai
# ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic
# MODEL_ID=glm-5.2
# Kimi (Moonshot) https://platform.kimi.ai
# ANTHROPIC_BASE_URL=https://api.moonshot.ai/anthropic
# MODEL_ID=kimi-k2.7-code
# MODEL_ID=kimi-k2.7-code-highspeed
# MODEL_ID=kimi-k2.6
# DeepSeek https://platform.deepseek.com
# ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic
# MODEL_ID=deepseek-v4-pro
# MODEL_ID=deepseek-v4-flash
# ---- China mainland ----
# MiniMax https://platform.minimax.io
# ANTHROPIC_BASE_URL=https://api.minimaxi.com/anthropic
# MODEL_ID=MiniMax-M3
# MODEL_ID=MiniMax-M2.7
# MODEL_ID=MiniMax-M2.7-highspeed
# GLM (Zhipu) https://open.bigmodel.cn
# ANTHROPIC_BASE_URL=https://open.bigmodel.cn/api/anthropic
# MODEL_ID=glm-5.2
# Kimi (Moonshot) https://platform.moonshot.cn
# ANTHROPIC_BASE_URL=https://api.moonshot.cn/anthropic
# MODEL_ID=kimi-k2.7-code
# MODEL_ID=kimi-k2.7-code-highspeed
# MODEL_ID=kimi-k2.6
# DeepSeek (no regional split, same endpoint globally)
# ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic
# MODEL_ID=deepseek-v4-pro
# MODEL_ID=deepseek-v4-flash
+34
View File
@@ -0,0 +1,34 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
# actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
# actions/setup-node@v6
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
with:
node-version: 20
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install dependencies
run: npm ci
- name: Type check
run: npx tsc --noEmit
- name: Build
run: npm run build
+49
View File
@@ -0,0 +1,49 @@
name: Test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
python-smoke:
runs-on: ubuntu-latest
steps:
# actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
- name: Set up Python
# actions/setup-python@v6
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405
with:
python-version: "3.11"
- name: Install dependencies
run: pip install -r requirements.txt pytest
- name: Run Python smoke tests
run: python -m pytest tests -q
web-build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
# actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
- name: Set up Node.js
# actions/setup-node@v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
with:
node-version: "20"
cache: "npm"
cache-dependency-path: web/package-lock.json
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
+234
View File
@@ -0,0 +1,234 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
/lib/
/lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py.cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
#poetry.toml
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
#pdm.lock
#pdm.toml
.pdm-python
.pdm-build/
# pixi
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
#pixi.lock
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
# in the .venv directory. It is recommended not to include this directory in version control.
.pixi
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.envrc
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
# .vscode/
# Transcripts (generated by compression agent)
.transcripts/
# Runtime artifacts (generated by agent tests)
.memory/
.task_outputs/
.tasks/
.teams/
.mailboxes/
.worktrees/
.scheduled_tasks.json
# Accidental root npm lockfile; web/package-lock.json is tracked.
/package-lock.json
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Cursor
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
# refer to https://docs.cursor.com/context/ignore-files
.cursorignore
.cursorindexingignore
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
# Web app
web/node_modules/
web/.next/
web/out/
.vercel
.env*.local
test_providers.py
# Internal analysis artifacts (not learning material)
analysis/
analysis_progress.md
+42
View File
@@ -0,0 +1,42 @@
# Contributing
This is a curated teaching repository. Each chapter (`sNN_topic/`) builds one idea on top of the last, and the code is deliberately minimal. The simplifications are intentional, so the lesson stays legible. Contributions are welcome when they serve that goal.
## Before opening a PR
1. Tie it to a specific issue. Say what is wrong and why your change is the right fix. One PR addresses one issue.
2. Keep teaching code minimal. Do not add production hardening, defensive parsing, error handling layers, abstractions, or a test framework unless the chapter is specifically about that. A simplification that looks like a bug is often deliberate, so ask in an issue first.
3. Keep the three languages in sync. Any change to a chapter's `code.py` or a README must be reflected in all three language READMEs in that chapter folder, with the code blocks identical across them.
4. Target the current course files (`sNN_topic/`), not legacy mirrors.
5. Disclose AI assistance. Using an agent is fine. Say so, and make sure you have read and stand behind the change.
## What gets closed without detailed review
To keep the review queue useful, we close:
1. Bulk PRs generated by an agent and opened across many unrelated repositories at once. We cannot responsibly review code the author has not engaged with.
2. Changes that expand a chapter's scope or structure against the teaching design.
3. PRs not tied to an issue, or that restate a deliberate simplification as a bug.
Focused, clearly explained contributions from people who have actually worked with the material are always welcome.
# 贡献指南
这是一个**教学仓库**。每一章(`sNN_topic/`)在上一章的基础上只讲清一个点,代码刻意保持最简。简化是有意为之,为的是让讲解保持清晰。欢迎服务于这个目标的贡献。
## 提 PR 之前
1. 对应一个具体 issue。说清问题是什么、为什么你的改法是对的。一个 PR 只解决一个问题。
2. 教学代码保持最简。不要加生产级的防御解析、错误处理层、抽象或测试框架,除非这一章本身就在讲这些。某处"像 bug"的简化往往是刻意的,先在 issue 里问一声。
3. 三语保持同步。对某章 `code.py` 或 README 的改动,必须同步到该章目录下三种语言的 README,且其中代码块完全一致。
4. 改现行课程文件(`sNN_topic/`),不要改旧的镜像目录。
5. 声明 AI 协助。用 agent 没问题,但请说明,并确保你读过、也认可这份改动。
## 哪些会被直接关闭(不做详细评审)
1. 在大量互不相关的仓库里一次性批量提交、由 agent 生成的 PR。作者自己都没参与的代码,我们无法负责任地评审。
2. 让某章范围或结构超出教学设计的改动。
3. 没有对应 issue、或把刻意的简化当成 bug 来"修"的 PR。
真正用过这份材料、聚焦且讲清理由的贡献,我们始终欢迎。
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 shareAI Lab
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+454
View File
@@ -0,0 +1,454 @@
# Learn Claude Code -- 真の Agent のための Harness Engineering
[English](./README.md) | [中文](./README-zh.md) | [日本語](./README-ja.md)
## Agency はモデルから生まれる。Agent プロダクト = モデル + Harness
コードの話をする前に、一つ明確にしておく。
**Agency -- 知覚し、推論し、行動する能力 -- はモデルの訓練から生まれる。外部コードの編成からではない。** だが実際に動く Agent プロダクトには、モデルと Harness の両方が必要だ。モデルはドライバー、Harness は車。本リポジトリは車の作り方を教える。
### Agency はどこから来るか
Agent の核心にあるのはニューラルネットワークだ -- Transformer、RNN、学習された関数 -- 数十億回の勾配更新を経て、行動系列データの上で環境を知覚し、目標を推論し、行動を起こすことを学んだもの。Agency は周囲のコードから与えられるものではない。訓練を通じてモデルが獲得するものだ。
人間が最もわかりやすい例だ。数百万年の進化的訓練によって形作られた生物的ニューラルネットワーク。感覚で世界を知覚し、脳で推論し、身体で行動する。DeepMind、OpenAI、Anthropic が "Agent" と言うとき、その核心は常に同じことを指している:**訓練によって行動を学んだモデルと、それを特定の環境で機能させるインフラの組み合わせ。**
歴史がその証拠を刻んでいる:
- **2013 -- DeepMind DQN が Atari をプレイ。** 単一のニューラルネットワークが、生のピクセルとスコアだけを受け取り、7 つの Atari 2600 ゲームを学習 -- すべての先行アルゴリズムを超え、3 つで人間の専門家を打ち負かした。2015 年には同じアーキテクチャが [49 ゲームに拡張され、プロのテスターに匹敵](https://www.nature.com/articles/nature14236)、*Nature* に掲載。ゲーム固有のルールなし。決定木なし。一つのモデルが経験から学んだ。そのモデルが Agent だった。
- **2019 -- OpenAI Five が Dota 2 を制覇。** 5 つのニューラルネットワークが 10 ヶ月間で [45,000 年分の Dota 2](https://openai.com/index/openai-five-defeats-dota-2-world-champions/) を自己対戦し、サンフランシスコのライブストリームで **OG** -- TI8 世界王者 -- を 2-0 で撃破。その後の公開アリーナでは 42,729 試合で勝率 99.4%。スクリプト化された戦略なし。メタプログラムされたチーム連携なし。モデルが完全に自己対戦を通じてチームワーク、戦術、リアルタイム適応を学んだ。
- **2019 -- DeepMind AlphaStar が StarCraft II をマスター。** AlphaStar は非公開戦で[プロ選手を 10-1 で撃破](https://deepmind.google/blog/alphastar-mastering-the-real-time-strategy-game-starcraft-ii/)、その後ヨーロッパサーバーで[グランドマスター到達](https://www.nature.com/articles/d41586-019-03298-6) -- 90,000 人中の上位 0.15%。不完全情報、リアルタイム判断、チェスや囲碁を遥かに凌駕する組合せ的行動空間を持つゲーム。Agent とは? モデルだ。訓練されたもの。スクリプトではない。
- **2019 -- Tencent 絶悟が王者栄耀を支配。** Tencent AI Lab の「絶悟」は 2019 年 8 月 2 日、世界チャンピオンカップで [KPL プロ選手を 5v5 で撃破](https://www.jiemian.com/article/3371171.html)。1v1 モードではプロが [15 戦中 1 勝のみ、8 分以上生存不可](https://developer.aliyun.com/article/851058)。訓練強度:1 日 = 人間の 440 年。2021 年までに全ヒーロープールで KPL プロを全面的に上回った。手書きのヒーロー相性表なし。スクリプト化されたチーム編成なし。自己対戦でゲーム全体をゼロから学んだモデル。
- **2024-2025 -- LLM Agent がソフトウェアエンジニアリングを再構築。** Claude、GPT、Gemini -- 人類のコードと推論の全幅で訓練された大規模言語モデル -- がコーディング Agent として展開される。コードベースを読み、実装を書き、障害をデバッグし、チームで協調する。アーキテクチャは先行するすべての Agent と同一:訓練されたモデルが環境に配置され、知覚と行動のツールを与えられる。唯一の違いは、学んだものの規模と解くタスクの汎用性。
すべてのマイルストーンが同じ事実を示している:**Agency -- 知覚し、推論し、行動する能力 -- は訓練によって獲得されるものであり、コードで組み立てるものではない。** しかし同時に、どの Agent も動作するための環境を必要とした:Atari エミュレータ、Dota 2 クライアント、StarCraft II エンジン、IDE とターミナル。モデルが知能を提供し、環境が行動空間を提供する。両方が揃って初めて完全な Agent となる。
### Agent ではないもの
"Agent" という言葉は、プロンプト配管工の産業全体に乗っ取られてしまった。
ドラッグ&ドロップのワークフロービルダー。ノーコード "AI Agent" プラットフォーム。プロンプトチェーン・オーケストレーションライブラリ。すべて同じ幻想を共有している:LLM API 呼び出しを if-else 分岐、ノードグラフ、ハードコードされたルーティングロジックで繋ぎ合わせることが "Agent の構築" だと。
違う。彼らが作ったものはルーブ・ゴールドバーグ・マシンだ -- 過剰に設計された脆い手続き的ルールのパイプライン。LLM は美化されたテキスト補完ノードとして押し込まれているだけ。それは Agent ではない。壮大な妄想を持つシェルスクリプトだ。
**プロンプト配管工式 "Agent" は、モデルを訓練しないプログラマーの妄想だ。** 手続き的ロジックを積み重ねて知能を力技で再現しようとする -- 巨大なルールツリー、ノードグラフ、チェーン・プロンプトの滝 -- そして十分なグルーコードがいつか自律的振る舞いを創発すると祈る。しない。工学的手段で Agency をコーディングすることはできない。Agency は学習されるものであって、プログラムされるものではない。
あのシステムたちは生まれた瞬間から死んでいる:脆弱で、スケールせず、汎化が根本的に不可能。GOFAIGood Old-Fashioned AI、古典的記号 AI)の現代版だ -- 何十年も前に学術界が放棄した記号ルールシステムが、LLM のペンキを塗り直して再登場した。パッケージが違うだけで、同じ袋小路。
### マインドシフト:「Agent を開発する」から Harness を開発する へ
「Agent を開発しています」と言うとき、意味できるのは二つだけだ:
**1. モデルを訓練する。** 強化学習、ファインチューニング、RLHF、その他の勾配ベースの手法で重みを調整する。タスクプロセスデータ -- 実ドメインにおける知覚・推論・行動の実際の系列 -- を収集し、モデルの振る舞いを形成する。DeepMind、OpenAI、Tencent AI Lab、Anthropic が行っていること。これが最も本来的な Agent 開発。
**2. Harness を構築する。** モデルに動作環境を提供するコードを書く。私たちの大半が行っていることであり、このリポジトリの核心。
Harness とは、Agent が特定のドメインで機能するために必要なすべて:
```
Harness = Tools + Knowledge + Observation + Action Interfaces + Permissions
Tools: ファイル I/O、シェル、ネットワーク、データベース、ブラウザ
Knowledge: 製品ドキュメント、ドメイン資料、API 仕様、スタイルガイド
Observation: git diff、エラーログ、ブラウザ状態、センサーデータ
Action: CLI コマンド、API 呼び出し、UI インタラクション
Permissions: サンドボックス、承認ワークフロー、信頼境界
```
モデルが決断する。Harness が実行する。モデルが推論する。Harness がコンテキストを提供する。モデルはドライバー。Harness は車両。
**コーディング Agent の Harness は IDE、ターミナル、ファイルシステム。** 農業 Agent の Harness はセンサーアレイ、灌漑制御、気象データフィード。ホテル Agent の Harness は予約システム、ゲストコミュニケーションチャネル、施設管理 API。Agent -- 知性、意思決定者 -- は常にモデル。Harness はドメインごとに変わる。Agent はドメインを超えて汎化する。
このリポジトリは車両の作り方を教える。コーディング用の車両だ。だが設計パターンはあらゆるドメインに汎化する:農場管理、ホテル運営、工場製造、物流、医療、教育、科学研究。タスクが知覚され、推論され、実行される必要がある場所ならどこでも -- Agent には Harness が要る。
### Harness エンジニアの仕事
このリポジトリを読んでいるなら、あなたはおそらく Harness エンジニアだ -- それは強力なアイデンティティ。以下があなたの本当の仕事:
- **ツールの実装。** Agent に手を与える。ファイル読み書き、シェル実行、API 呼び出し、ブラウザ制御、データベースクエリ。各ツールは Agent が環境内で取れる行動。原子的で、組み合わせ可能で、記述が明確であるように設計する。
- **知識のキュレーション。** Agent にドメイン専門性を与える。製品ドキュメント、アーキテクチャ決定記録、スタイルガイド、規制要件。オンデマンドで読み込み(s07)、前もって詰め込まない。Agent は何が利用可能か知った上で、必要なものを自ら取得すべき。
- **コンテキストの管理。** Agent にクリーンな記憶を与える。サブ Agent 隔離(s06)がノイズの漏洩を防ぐ。コンテキスト圧縮(s08)が履歴の氾濫を防ぐ。タスクシステム(s12)が目標を単一の会話を超えて永続化する。
- **権限の制御。** Agent に境界を与える。ファイルアクセスのサンドボックス化。破壊的操作への承認要求。Agent と外部システム間の信頼境界の実施。安全工学と Harness 工学の交差点。
- **タスクプロセスデータの収集。** Agent があなたの Harness 内で実行するすべての行動系列は訓練シグナル。実デプロイメントの知覚-推論-行動トレースは、次世代 Agent モデルをファインチューニングする原材料。あなたの Harness は Agent に仕えるだけでなく -- Agent を進化させる助けにもなる。
あなたは知性を書いているのではない。知性が住まう世界を構築している。その世界の品質 -- Agent がどれだけ明瞭に知覚でき、どれだけ正確に行動でき、利用可能な知識がどれだけ豊かか -- が、知性がどれだけ効果的に自らを表現できるかを直接決定する。
**優れた Harness を作れ。Agent が残りをやる。**
### なぜ Claude Code か -- Harness Engineering の大師範
なぜこのリポジトリは特に Claude Code を解剖するのか?
Claude Code は私たちが見てきた中で最もエレガントで完成度の高い Agent Harness だからだ。単一の巧妙なトリックのためではなく、それが *しないこと* のために:Agent そのものになろうとしない。硬直的なワークフローを押し付けない。精緻な決定木でモデルを二度推しない。ツール、知識、コンテキスト管理、権限境界をモデルに提供し -- そして道を譲る。
Claude Code の本質を剥き出しにすると:
```
Claude Code = 一つの agent loop
+ ツール (bash, read, write, edit, glob, grep, browser...)
+ オンデマンド skill ロード
+ コンテキスト圧縮
+ サブ Agent スポーン
+ 依存グラフ付きタスクシステム
+ 非同期メールボックスによるチーム協調
+ worktree 分離による並列実行
+ 権限ガバナンス
```
これがすべてだ。これが全アーキテクチャ。すべてのコンポーネントは Harness メカニズム -- Agent が住む世界の一部。Agent そのものは? Claude だ。モデル。Anthropic が人類の推論とコードの全幅で訓練した。Harness が Claude を賢くしたのではない。Claude は元々賢い。Harness が Claude に手と目とワークスペースを与えた。
これが Claude Code が理想的な教材である理由だ:**モデルを信頼し、工学的努力を Harness に集中させるとどうなるかを示している。** このリポジトリの各セッション(s01-s20)は Claude Code アーキテクチャの Harness メカニズムを段階的に分解し、最後に組み直す。終了時には、Claude Code の仕組みだけでなく、あらゆるドメインのあらゆる Agent に適用される Harness 工学の普遍的原則を理解している。
教訓は「Claude Code をコピーせよ」ではない。教訓は:**最高の Agent プロダクトは、自分の仕事が Harness であって Intelligence ではないと理解しているエンジニアが作る。**
---
## ビジョン:宇宙を本物の Agent で満たす
これはコーディング Agent だけの話ではない。
人間が複雑で多段階の判断集約的な仕事をしているすべてのドメインは、Agent が稼働できるドメインだ -- 正しい Harness さえあれば。このリポジトリのパターンは普遍的だ:
```
不動産管理 Agent = モデル + 物件センサー + メンテナンスツール + テナント通信
農業 Agent = モデル + 土壌/気象データ + 灌漑制御 + 作物知識
ホテル運営 Agent = モデル + 予約システム + ゲストチャネル + 施設 API
医学研究 Agent = モデル + 文献検索 + 実験機器 + プロトコル文書
製造 Agent = モデル + 生産ラインセンサー + 品質管理 + 物流
教育 Agent = モデル + カリキュラム知識 + 学生進捗 + 評価ツール
```
ループは常に同じ。ツールが変わる。知識が変わる。権限が変わる。Agent = モデル(LLM)+ 汎用化されたオペレーション環境(Harness)。
このリポジトリを読むすべての Harness エンジニアは、ソフトウェアエンジニアリングを遥かに超えたパターンを学んでいる。知的で自動化された未来のためのインフラストラクチャを構築することを学んでいる。実ドメインにデプロイされた優れた Harness の一つ一つが、Agent が知覚し、推論し、行動できる新たな拠点。
まずワークショップを満たす。次に農場、病院、工場。次に都市。次に惑星。
**Bash is all you need. Real agents are all the universe needs.**
---
```
THE AGENT PATTERN
=================
User --> messages[] --> LLM --> response
|
stop_reason == "tool_use"?
/ \
yes no
| |
execute tools return text
append results
loop back -----------------> messages[]
最小ループ。すべての AI Agent にこのループが必要だ。
モデルがツール呼び出しと停止を決める。
コードはモデルの要求を実行するだけ。
このリポジトリはこのループを囲むすべて --
Agent を特定ドメインで効果的にする Harness -- の作り方を教える。
```
**20 の段階的セッション、シンプルなループから完全な Harness まで。**
**各セッションは 1 つの Harness メカニズムを追加する。各メカニズムには 1 つのモットーがある。**
> **s01**   *"One loop & Bash is all you need"* — 1つのツール + 1つのループ = エージェント
>
> **s02**   *"ツールを足すなら、ハンドラーを1つ足すだけ"* — ループは変わらない。新ツールは dispatch map に登録するだけ
>
> **s03**   *"まず境界を決め、それから自由を与える"* — 実行してよいか、止めるか、ユーザーに聞くかを判断する
>
> **s04**   *"ループの外にフックし、ループは書き換えない"* — メインループを変えずに拡張できる入口を作る
>
> **s05**   *"計画のないエージェントは行き当たりばったり"* — まずステップを書き出し、それから実行
>
> **s06**   *"大きなタスクを分割し、各サブタスクにクリーンなコンテキストを"* — サブ Agent が作業し、結果だけを持ち帰る
>
> **s07**   *"必要な知識を、必要な時に読み込む"* — スキルはまず一覧だけ、必要な時に展開する
>
> **s08**   *"コンテキストはいつか溢れる、空ける手段が要る"* — 4層圧縮、安い方から先に実行
>
> **s09**   *"覚えるべきことを覚え、忘れるべきことを忘れる"* — 3つのサブシステム:選択、抽出、整理
>
> **s10**   *"プロンプトは実行時に組み立てる、ハードコードではない"* — セクション分割 + オンデマンド連結
>
> **s11**   *"エラーは終わりではない、リトライの始まりだ"* — 失敗したら再試行し、空きを作り、別の道を試す
>
> **s12**   *"大きな目標を小タスクに分解し、順序付けし、ディスクに記録する"* — ファイルベースのタスクグラフ、マルチエージェント協調の基盤
>
> **s13**   *"遅い操作はバックグラウンドへ、エージェントは次を考え続ける"* — バックグラウンドスレッドがコマンド実行、完了後に通知を注入
>
> **s14**   *"スケジュールで発火、人間の起動は不要"* — 時間になったら自動でタスクを動かす
>
> **s15**   *"一人で終わらないなら、チームメイトに任せる"* — 永続チームメイト + 非同期メールボックス
>
> **s16**   *"チームメイト間には統一の通信ルールが必要"* — 固定のリクエスト-返信形式で連携する
>
> **s17**   *"チームメイトが自らボードを見て、仕事を取る"* — リーダーが逐一割り振る必要はない
>
> **s18**   *"各自のディレクトリで作業し、互いに干渉しない"* — タスクは目標を管理、worktree はディレクトリを管理、IDで紐付け
>
> **s19**   *"能力不足? MCP でプラグイン"* — 外部ツールを同じツールプールに接続する
>
> **s20**   *"仕組みは多く、ループは一つ"* — すべての仕組みを 1 つの Harness に戻す
---
## コアパターン
```python
def agent_loop(messages):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM,
messages=messages, tools=TOOLS,
)
messages.append({"role": "assistant",
"content": response.content})
if response.stop_reason != "tool_use":
return
results = []
for block in response.content:
if block.type == "tool_use":
output = TOOL_HANDLERS[block.name](**block.input)
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
```
各セッションはこのループの上に 1 つの Harness メカニズムを重ねる -- ループ自体は変わらない。ループは Agent のもの。メカニズムは Harness のもの。
## バージョン状況
このリポジトリには現在、2 つのチュートリアルトラックが共存している:
- **現行トラック:ルート直下の `s01-s20`**
ルート直下の `s01_*` から `s20_*` までが新しい正規版であり、現在推奨する読書経路。各セッションには中国語原文、英語/日本語訳、実行可能な `code.py`、必要に応じた図が含まれる。
- **旧版移行トラック:`docs/``agents/`、現在の `web/`**
これらは旧 12 セッション版を保持している。既存読者、旧リンク、Web プラットフォームのために移行期間中は一時的に残している。
新しく読む場合は、ルート直下の `s01_agent_loop/` から `s20_comprehensive/` までを読む。旧リンクや現在の Web アプリから入った場合は、旧 12 セッション版を読んでいる可能性が高い。旧版と現行版のセッション番号は常に一致しないため、番号を混同しないこと。
### 旧版から現行版への対応
| 旧 12 セッション版 | 現行 20 セッション版 | トピック |
|---|---|---|
| 旧 s01 | 現行 s01 | Agent Loop |
| 旧 s02 | 現行 s02 | Tool Use |
| 旧 s03 | 現行 s05 | TodoWrite |
| 旧 s04 | 現行 s06 | Subagent |
| 旧 s05 | 現行 s07 | Skill Loading |
| 旧 s06 | 現行 s08 | Context Compact |
| 旧 s07 | 現行 s12 | Task System |
| 旧 s08 | 現行 s13 | Background Tasks |
| 旧 s09 | 現行 s15 | Agent Teams |
| 旧 s10 | 現行 s16 | Team Protocols |
| 旧 s11 | 現行 s17 | Autonomous Agents |
| 旧 s12 | 現行 s18 | Worktree Isolation |
| 現行版のみ | s03、s04、s09、s10、s11、s14、s19、s20 | Permission、Hooks、Memory、System Prompt、Error Recovery、Cron、MCP、Comprehensive Agent |
## スコープ (重要)
このリポジトリは Harness 工学の 0->1 学習プロジェクト -- Agent モデルを囲む環境の構築を学ぶ。
学習を優先するため、以下の本番メカニズムは意図的に簡略化または省略している:
- 完全なイベント / Hook バス (例: PreToolUse, SessionStart/End, ConfigChange)。
s12 では教材用に最小の追記型ライフサイクルイベントのみ実装。
- ルールベースの権限ガバナンスと信頼フロー
- セッションライフサイクル制御 (resume/fork) と高度な worktree ライフサイクル制御
- MCP ランタイムの詳細 (transport/OAuth/リソース購読/ポーリング)
このリポジトリの JSONL メールボックス方式は教材用の実装であり、特定の本番内部実装を主張するものではない。
## クイックスタート
### 現行 20 セッション版
```sh
git clone https://github.com/shareAI-lab/learn-claude-code
cd learn-claude-code
pip install -r requirements.txt
cp .env.example .env # .env を編集して ANTHROPIC_API_KEY を入力
python s01_agent_loop/code.py # ここから開始 — 1ループ + bash
python s08_context_compact/code.py # コンテキスト圧縮(複雑章)
python s20_comprehensive/code.py # 終点: 全メカニズムを 1 つのループへ
```
### 旧 12 セッション移行版
```sh
python agents/s01_agent_loop.py
python agents/s12_worktree_task_isolation.py
python agents/s_full.py
```
### Web プラットフォーム
現在の Web プラットフォームはまだ `docs/` の旧 12 セッション版を表示する。現行 20 セッション版はルート直下の `s01-s20` を読む。
```sh
cd web && npm install && npm run dev # http://localhost:3000
```
## 学習パス
主線:動ける → 複雑な仕事ができる → 記憶して回復できる → 長く動ける → 協作できる → 拡張して統合する
```mermaid
flowchart TD
%% カードスタイル
classDef stage1 fill:#E3F2FD,stroke:#1976D2,stroke-width:2px,color:#0D47A1,rx:12,ry:12,text-align:left
classDef stage2 fill:#E8F5E9,stroke:#388E3C,stroke-width:2px,color:#1B5E20,rx:12,ry:12,text-align:left
classDef stage3 fill:#FFF3E0,stroke:#F57C00,stroke-width:2px,color:#E65100,rx:12,ry:12,text-align:left
classDef stage4 fill:#FCE4EC,stroke:#C2185b,stroke-width:2px,color:#880E4F,rx:12,ry:12,text-align:left
classDef stage5 fill:#F3E5F5,stroke:#7B1FA2,stroke-width:2px,color:#4A148C,rx:12,ry:12,text-align:left
classDef stage6 fill:#E0F7FA,stroke:#0097A7,stroke-width:2px,color:#006064,rx:12,ry:12,text-align:left
%% 背景スタイル
classDef groupBox fill:#F8F9FA,stroke:#CED4DA,stroke-width:2px,stroke-dasharray: 5 5,rx:15,ry:15,color:#495057
%% 第1層:1-3段階
subgraph Phase1 ["🌱 段階 1-3:基礎能力の構築(単純から複雑へ)"]
direction LR
S1["<b>第1段階:Agent が動ける</b><br/>━━━━━━━━━━━━━<br/><b>s01 Agent Loop</b><br/>└─ 1つのループ + bash<br/><br/><b>s02 Tool Use</b><br/>└─ 1つのツールから複数へ<br/><br/><b>s03 Permission</b><br/>└─ 実行してよいか判断する<br/><br/><b>s04 Hooks</b><br/>└─ ツール前後に拡張入口を作る"]:::stage1
S2["<b>第2段階:複雑な仕事をこなす</b><br/>━━━━━━━━━━━━━<br/><b>s05 TodoWrite</b><br/>└─ 先に計画し、それから実行<br/><br/><b>s06 Subagent</b><br/>└─ サブ Agent が結果を返す<br/><br/><b>s08 Context Compact</b><br/>└─ 長いコンテキストに空きを作る"]:::stage2
S3["<b>第3段階:記憶して回復する</b><br/>━━━━━━━━━━━━━<br/><b>s09 Memory</b><br/>└─ 覚えるべきことを覚える<br/><br/><b>s10 System Prompt</b><br/>└─ 実行時に組み立てる<br/><br/><b>s11 Error Recovery</b><br/>└─ 再試行し、別の道へ"]:::stage3
S1 ==> S2 ==> S3
end
%% 第2層:4-6段階
subgraph Phase2 ["🚀 段階 4-6:高次能力の進化(長期実行、協作、統合)"]
direction LR
S4["<b>第4段階:長く動くタスク</b><br/>━━━━━━━━━━━━━<br/><b>s12 Task System</b><br/>└─ タスクと依存関係を保存<br/><br/><b>s13 Background Tasks</b><br/>└─ 遅い作業をバックグラウンドへ<br/><br/><b>s14 Cron Scheduler</b><br/>└─ 時間で自動実行"]:::stage4
S5["<b>第5段階:複数 Agent の協作</b><br/>━━━━━━━━━━━━━<br/><b>s15 Agent Teams</b><br/>└─ チームメイト + メールボックス<br/><br/><b>s16 Team Protocols</b><br/>└─ 固定のリクエスト-返信形式<br/><br/><b>s17 Autonomous Agents</b><br/>└─ ボードを見て仕事を取る<br/><br/><b>s18 Worktree Isolation</b><br/>└─ 別ディレクトリで作業"]:::stage5
S6["<b>第6段階:外部能力と統合</b><br/>━━━━━━━━━━━━━<br/><b>s07 Skill Loading</b><br/>└─ スキルを必要時に展開<br/><br/><b>s19 MCP Plugin</b><br/>└─ 外部ツールを同じプールへ<br/><br/><b>s20 Comprehensive Agent</b><br/>└─ すべてを1つのループへ"]:::stage6
S4 ==> S5 ==> S6
end
%% 2つの層を接続
Phase1 ===> Phase2
class Phase1,Phase2 groupBox
```
## 全セッション
| セッション | トピック | キーコンセプト |
|---|---|---|
| [s01](./s01_agent_loop/) | Agent Loop | `messages` / `while True` / `stop_reason` |
| [s02](./s02_tool_use/) | Tool Use | `TOOL_HANDLERS` / dispatch map / 並行性 |
| [s03](./s03_permission/) | Permission | `PermissionRule` / 承認パイプライン |
| [s04](./s04_hooks/) | Hooks | `PreToolUse` / `PostToolUse` / 拡張ポイント |
| [s05](./s05_todo_write/) | TodoWrite | `TodoItem` / 計画してから実行 |
| [s06](./s06_subagent/) | Subagent | `fresh messages[]` / コンテキスト分離 |
| [s07](./s07_skill_loading/) | Skill Loading | `SkillManifest` / オンデマンド注入 |
| [s08](./s08_context_compact/) | Context Compact | snip / micro / budget / auto 4層圧縮 |
| [s09](./s09_memory/) | Memory | selection / extraction / consolidation |
| [s10](./s10_system_prompt/) | System Prompt | ランタイム組立 / セクション連結 |
| [s11](./s11_error_recovery/) | Error Recovery | token 拡張 / fallback モデル / リトライ戦略 |
| [s12](./s12_task_system/) | Task System | `TaskRecord` / `blockedBy` / ディスク永続化 |
| [s13](./s13_background_tasks/) | Background Tasks | スレッド実行 / 通知キュー |
| [s14](./s14_cron_scheduler/) | Cron Scheduler | 永続スケジューリング / セッション限定トリガー |
| [s15](./s15_agent_teams/) | Agent Teams | `MessageBus` / 受信箱 / 権限バブリング |
| [s16](./s16_team_protocols/) | Team Protocols | シャットダウンハンドシェイク / プラン承認 |
| [s17](./s17_autonomous_agents/) | Autonomous Agents | アイドルサイクル / 自動クレーム |
| [s18](./s18_worktree_isolation/) | Worktree Isolation | `WorktreeRecord` / タスク-ディレクトリ紐付け |
| [s19](./s19_mcp_plugin/) | MCP Plugin | マルチトランスポート / チャネルルーティング / ツールプール組み立て |
| [s20](./s20_comprehensive/) | Comprehensive Agent | すべての仕組みを 1 つのループへ |
## プロジェクト構成
```
learn-claude-code/
s01_agent_loop/ # セッションごとに1フォルダ
README.md # 中国語ソース(完全なナラティブ)
README.en.md # 英語訳
README.ja.md # 日本語訳
code.py # 単体実行可能なコード
images/ # SVG ダイアグラム
s02_tool_use/
...
s19_mcp_plugin/
s20_comprehensive/ # 終点セッション
agents/ # 旧 12 セッションの実行可能コピー + s_full.py
skills/ # s07 で使用するスキルファイル
docs/ # 旧 12 セッション文書、移行期間中は保持
web/ # 現在は docs/ の旧版内容を生成・表示
tests/
```
## 次のステップ -- 理解から出荷へ
20 セッションを終えれば、Harness 工学の内部構造を完全に理解している。その知識を活かす 2 つの方法:
### Kode Agent CLI -- オープンソース Coding Agent CLI
> `npm i -g @shareai-lab/kode`
Skill & LSP 対応、Windows 対応、GLM / MiniMax / DeepSeek 等のオープンモデルに接続可能。インストールしてすぐ使える。
GitHub: **[shareAI-lab/Kode-CLI](https://github.com/shareAI-lab/Kode-CLI)**
### Kode Agent SDK -- アプリにエージェント機能を埋め込む
公式 Claude Code Agent SDK は内部で完全な CLI プロセスと通信する -- 同時ユーザーごとに独立のターミナルプロセスが必要。Kode SDK は独立ライブラリでユーザーごとのプロセスオーバーヘッドがなく、バックエンド、ブラウザ拡張、組み込みデバイス等に埋め込み可能。
GitHub: **[shareAI-lab/kode-agent-sdk](https://github.com/shareAI-lab/kode-agent-sdk)**
---
## 姉妹教材: *オンデマンドセッション*から*常時稼働アシスタント*へ
本リポジトリが教える Harness は **使い捨て型** -- ターミナルを開き、Agent にタスクを与え、終わったら閉じる。次のセッションは白紙から始まる。Claude Code のモデル。
[OpenClaw](https://github.com/openclaw/openclaw) は別の可能性を証明した: 同じ agent core の上に 2 つの Harness メカニズムを追加するだけで、Agent は「突かないと動かない」から「30 秒ごとに自分で起きて仕事を探す」に変わる:
- **ハートビート** -- 30 秒ごとに Harness が Agent にメッセージを送り、やることがあるか確認させる。なければスリープ続行、あれば即座に行動。
- **Cron** -- Agent が自ら未来のタスクをスケジュールし、時間が来たら自動実行。
さらにマルチチャネル IM ルーティング (WhatsApp / Telegram / Slack / Discord 等 13+ プラットフォーム)、永続コンテキストメモリ、Soul パーソナリティシステムを加えると、Agent は使い捨てツールから常時稼働のパーソナル AI アシスタントへ変貌する。
**[claw0](https://github.com/shareAI-lab/claw0)** はこれらの Harness メカニズムをゼロから分解する姉妹教材リポジトリ:
```
claw agent = agent core + heartbeat + cron + IM chat + memory + soul
```
```
learn-claude-code claw0
(agent harness コア: (能動的な常時稼働 harness:
ループ、ツール、計画、 ハートビート、cron、IM チャネル、
チーム、worktree 分離) メモリ、Soul パーソナリティ)
```
## ライセンス
MIT
---
**Agency はモデルから生まれる。Harness が Agency を現実にする。優れた Harness を作れ。モデルが残りをやる。**
**Bash is all you need. Real agents are all the universe needs.**
+455
View File
@@ -0,0 +1,455 @@
# Learn Claude Code -- 真正的 Agent Harness 工程
[English](./README.md) | [中文](./README-zh.md) | [日本語](./README-ja.md)
## Agency 来自模型,Agent 产品 = 模型 + Harness
在讨论代码之前,先把一件事说清楚。
**Agency -- 感知、推理、行动的能力 -- 来自模型训练,不是来自外部代码的编排。** 但一个能干活的 agent 产品,需要模型和 harness 缺一不可。模型是驾驶者,harness 是载具。本仓库教你造载具。
### Agency 从哪来
Agent 的核心是一个神经网络 -- Transformer、RNN、一个被训练出来的函数 -- 经过数十亿次梯度更新,在行动序列数据上学会了感知环境、推理目标、采取行动。Agency 这个东西从来不是外面那层代码赋予的,而是模型在训练中学到的。
人类就是最好的例子。一个由数百万年进化训练出来的生物神经网络,通过感官感知世界,通过大脑推理,通过身体行动。当 DeepMind、OpenAI 或 Anthropic 说 "agent" 时,他们说的核心都是同一件事:**一个通过训练学会了行动的模型,加上让它能在特定环境中工作的基础设施。**
历史已经写好了铁证:
- **2013 -- DeepMind DQN 玩 Atari。** 一个神经网络,只接收原始像素和游戏分数,学会了 7 款 Atari 2600 游戏 -- 超越所有先前算法,在其中 3 款上击败人类专家。到 2015 年,同一架构扩展到 [49 款游戏,达到职业人类测试员水平](https://www.nature.com/articles/nature14236),论文发表在 *Nature*。没有游戏专属规则。没有决策树。一个模型,从经验中学习。那个模型就是 agent。
- **2019 -- OpenAI Five 征服 Dota 2。** 五个神经网络,在 10 个月内与自己对战了 [45,000 年的 Dota 2](https://openai.com/index/openai-five-defeats-dota-2-world-champions/),在旧金山直播赛上 2-0 击败了 **OG** -- TI8 世界冠军。随后的公开竞技场中,AI 在 42,729 场比赛中胜率 99.4%。没有脚本化的策略。没有元编程的团队协调逻辑。模型完全通过自我对弈学会了团队协作、战术和实时适应。
- **2019 -- DeepMind AlphaStar 制霸星际争霸 II。** AlphaStar 在闭门赛中 [10-1 击败职业选手](https://deepmind.google/blog/alphastar-mastering-the-real-time-strategy-game-starcraft-ii/),随后在欧洲服务器上达到[宗师段位](https://www.nature.com/articles/d41586-019-03298-6) -- 90,000 名玩家中的前 0.15%。一个信息不完全、实时决策、组合动作空间远超国际象棋和围棋的游戏。Agent 是什么?是模型。训练出来的。不是编出来的。
- **2019 -- 腾讯绝悟统治王者荣耀。** 腾讯 AI Lab 的 "绝悟" 于 2019 年 8 月 2 日世冠杯半决赛上[以 5v5 击败 KPL 职业选手](https://www.jiemian.com/article/3371171.html)。在 1v1 模式下,职业选手 [15 场只赢 1 场,最多坚持不到 8 分钟](https://developer.aliyun.com/article/851058)。训练强度:一天等于人类 440 年。到 2021 年,绝悟在全英雄池 BO5 上全面超越 KPL 职业选手水准。没有手工编写的英雄克制表。没有脚本化的阵容编排。一个从零开始通过自我对弈学习整个游戏的模型。
- **2024-2025 -- LLM Agent 重塑软件工程。** Claude、GPT、Gemini -- 在人类全部代码和推理上训练的大语言模型 -- 被部署为编程 agent。它们阅读代码库,编写实现,调试故障,团队协作。架构与之前每一个 agent 完全相同:一个训练好的模型,放入一个环境,给予感知和行动的工具。唯一的不同是它们学到的东西的规模和解决任务的通用性。
每一个里程碑都指向同一个事实:**Agency -- 那个感知、推理、行动的能力 -- 是训练出来的,不是编出来的。** 但每一个 agent 同时也需要一个环境才能工作:Atari 模拟器、Dota 2 客户端、星际争霸 II 引擎、IDE 和终端。模型提供智能,环境提供行动空间。两者合在一起才是一个完整的 agent。
### Agent 不是什么
"Agent" 这个词已经被一整个提示词水管工产业劫持了。
拖拽式工作流构建器。无代码 "AI Agent" 平台。提示词链编排库。它们共享同一个幻觉:把 LLM API 调用用 if-else 分支、节点图、硬编码路由逻辑串在一起就算是 "构建 Agent" 了。
不是的。它们做出来的东西是鲁布·戈德堡机械 -- 一个过度工程化的、脆弱的过程式规则流水线,LLM 被楔在里面当一个美化了的文本补全节点。那不是 Agent。那是一个有着宏大妄想的 shell 脚本。
**提示词水管工式 "Agent" 是不做模型的程序员的意淫。** 他们试图通过堆叠过程式逻辑来暴力模拟智能 -- 庞大的规则树、节点图、链式提示词瀑布流 -- 然后祈祷足够多的胶水代码能涌现出自主行为。不会的。你不可能通过工程手段编码出 agency。Agency 是学出来的,不是编出来的。
那些系统从诞生之日起就已经死了:脆弱、不可扩展、根本不具备泛化能力。它们是 GOFAIGood Old-Fashioned AI,经典符号 AI)的现代还魂 -- 几十年前就被学界抛弃的符号规则系统,现在喷了一层 LLM 的漆又登场了。换了个包装,同一条死路。
### 心智转换:从 "开发 Agent" 到开发 Harness
当一个人说 "我在开发 Agent" 时,他只可能是两个意思之一:
**1. 训练模型。** 通过强化学习、微调、RLHF 或其他基于梯度的方法调整权重。收集任务过程数据 -- 真实领域中感知、推理、行动的实际序列 -- 用它们来塑造模型的行为。这是 DeepMind、OpenAI、腾讯 AI Lab、Anthropic 在做的事。这是最本义的 Agent 开发。
**2. 构建 Harness。** 编写代码,为模型提供一个可操作的环境。这是我们大多数人在做的事,也是本仓库的核心。
Harness 是 agent 在特定领域工作所需要的一切:
```
Harness = Tools + Knowledge + Observation + Action Interfaces + Permissions
Tools: 文件读写、Shell、网络、数据库、浏览器
Knowledge: 产品文档、领域资料、API 规范、风格指南
Observation: git diff、错误日志、浏览器状态、传感器数据
Action: CLI 命令、API 调用、UI 交互
Permissions: 沙箱隔离、审批流程、信任边界
```
模型做决策。Harness 执行。模型做推理。Harness 提供上下文。模型是驾驶者。Harness 是载具。
**编程 agent 的 harness 是它的 IDE、终端和文件系统。** 农业 agent 的 harness 是传感器阵列、灌溉控制和气象数据。酒店 agent 的 harness 是预订系统、客户沟通渠道和设施管理 API。Agent -- 那个智能、那个决策者 -- 永远是模型。Harness 因领域而变。Agent 跨领域泛化。
这个仓库教你造载具。编程用的载具。但设计模式可以泛化到任何领域:庄园管理、农田运营、酒店运作、工厂制造、物流调度、医疗保健、教育培训、科学研究。只要有一个任务需要被感知、推理和执行 -- agent 就需要一个 harness。
### Harness 工程师到底在做什么
如果你在读这个仓库,你很可能是一名 harness 工程师 -- 这是一个强大的身份。以下是你真正的工作:
- **实现工具。** 给 agent 一双手。文件读写、Shell 执行、API 调用、浏览器控制、数据库查询。每个工具都是 agent 在环境中可以采取的一个行动。设计它们时要原子化、可组合、描述清晰。
- **策划知识。** 给 agent 领域专长。产品文档、架构决策记录、风格指南、合规要求。按需加载(s07),不要前置塞入。Agent 应该知道有什么可用,然后自己拉取所需。
- **管理上下文。** 给 agent 干净的记忆。子 agent 隔离(s06)防止噪声泄露。上下文压缩(s08)防止历史淹没。任务系统(s12)让目标持久化到单次对话之外。
- **控制权限。** 给 agent 边界。沙箱化文件访问。对破坏性操作要求审批。在 agent 和外部系统之间实施信任边界。这是安全工程与 harness 工程的交汇点。
- **收集任务过程数据。** Agent 在你的 harness 中执行的每一条行动序列都是训练信号。真实部署中的感知-推理-行动轨迹是微调下一代 agent 模型的原材料。你的 harness 不仅服务于 agent -- 它还可以帮助进化 agent。
你不是在编写智能。你是在构建智能栖居的世界。这个世界的质量 -- agent 能看得多清楚、行动得多精准、可用知识有多丰富 -- 直接决定了智能能多有效地表达自己。
**造好 Harness。Agent 会完成剩下的。**
### 为什么是 Claude Code -- Harness 工程的大师课
为什么这个仓库专门拆解 Claude Code
因为 Claude Code 是我们所见过的最优雅、最完整的 agent harness 实现。不是因为某个巧妙的技巧,而是因为它 *没做* 的事:它没有试图成为 agent 本身。它没有强加僵化的工作流。它没有用精心设计的决策树去替模型做判断。它给模型提供了工具、知识、上下文管理和权限边界 -- 然后让开了。
把 Claude Code 剥到本质来看:
```
Claude Code = 一个 agent loop
+ 工具 (bash, read, write, edit, glob, grep, browser...)
+ 按需 skill 加载
+ 上下文压缩
+ 子 agent 派生
+ 带依赖图的任务系统
+ 异步邮箱的团队协调
+ worktree 隔离的并行执行
+ 权限治理
```
就这些。这就是全部架构。每一个组件都是 harness 机制 -- 为 agent 构建的栖居世界的一部分。Agent 本身呢?是 Claude。一个模型。由 Anthropic 在人类推理和代码的全部广度上训练而成。Harness 没有让 Claude 变聪明。Claude 本来就聪明。Harness 给了 Claude 双手、双眼和一个工作空间。
这就是 Claude Code 作为教学标本的意义:**它展示了当你信任模型、把工程精力集中在 harness 上时会发生什么。** 本仓库的课程(s01-s20)逐步拆解并重组 Claude Code 架构中的 harness 机制。学完之后,你理解的不只是 Claude Code 怎么工作,而是适用于任何领域、任何 agent 的 harness 工程通用原则。
启示不是 "复制 Claude Code"。启示是:**最好的 agent 产品,出自那些明白自己的工作是 harness 而非 intelligence 的工程师之手。**
---
## 愿景:用真正的 Agent 铺满宇宙
这不只关乎编程 agent。
每一个人类从事复杂、多步骤、需要判断力的工作的领域,都是 agent 可以运作的领域 -- 只要有对的 harness。本仓库中的模式是通用的:
```
庄园管理 agent = 模型 + 物业传感器 + 维护工具 + 租户通信
农业 agent = 模型 + 土壤/气象数据 + 灌溉控制 + 作物知识
酒店运营 agent = 模型 + 预订系统 + 客户渠道 + 设施 API
医学研究 agent = 模型 + 文献检索 + 实验仪器 + 协议文档
制造业 agent = 模型 + 产线传感器 + 质量控制 + 物流系统
教育 agent = 模型 + 课程知识 + 学生进度 + 评估工具
```
循环永远不变。工具在变。知识在变。权限在变。Agent = 模型(LLM) + 泛化的操作环境(Harness)。
每一个读这个仓库的 harness 工程师都在学习远超软件工程的模式。你在学习为一个智能的、自动化的未来构建基础设施。每一个部署在真实领域的好 harness,都是 agent 能够感知、推理、行动的又一个阵地。
先铺满工作室。然后是农田、医院、工厂。然后是城市。然后是星球。
**Bash is all you need. Real agents are all the universe needs.**
---
```
THE AGENT PATTERN
=================
User --> messages[] --> LLM --> response
|
stop_reason == "tool_use"?
/ \
yes no
| |
execute tools return text
append results
loop back -----------------> messages[]
这是最小循环。每个 AI Agent 都需要这个循环。
模型决定何时调用工具、何时停止。
代码只是执行模型的要求。
本仓库教你构建围绕这个循环的一切 --
让 agent 在特定领域高效工作的 harness。
```
**20 个递进式课程, 从简单循环到完整 Harness。**
**每个课程添加一个 harness 机制。每个机制有一句格言。**
> **s01** &nbsp; *"One loop & Bash is all you need"* &mdash; 一个工具 + 一个循环 = 一个 Agent
>
> **s02** &nbsp; *"加一个工具, 只加一个 handler"* &mdash; 循环不用动, 新工具注册进 dispatch map 就行
>
> **s03** &nbsp; *"先划边界, 再给自由"* &mdash; 先判断操作能不能做,要不要问用户
>
> **s04** &nbsp; *"挂在循环上, 不写进循环里"* &mdash; 在工具前后留插口,不改主循环也能扩展
>
> **s05** &nbsp; *"没有计划的 agent 走哪算哪"* &mdash; 先列步骤再动手, 完成率翻倍
>
> **s06** &nbsp; *"大任务拆小, 每个小任务干净的上下文"* &mdash; 子 Agent 自己干活,只把结果带回来
>
> **s07** &nbsp; *"用到时再加载, 别全塞 prompt 里"* &mdash; 技能先列目录,用到时再展开
>
> **s08** &nbsp; *"上下文总会满, 要有办法腾地方"* &mdash; 四层压缩策略, 便宜的先跑贵的后跑
>
> **s09** &nbsp; *"记住该记的, 忘掉该忘的"* &mdash; 三个子系统: 筛选、提取、整理
>
> **s10** &nbsp; *"prompt 是组装出来的, 不是写死的"* &mdash; 分段 + 按需拼接
>
> **s11** &nbsp; *"错误不是终点, 是重试的起点"* &mdash; 出错时会重试、腾空间、换路子
>
> **s12** &nbsp; *"大目标拆成小任务, 排好序, 持久化"* &mdash; 文件持久化的任务图, 多 agent 协作的基础
>
> **s13** &nbsp; *"慢操作丢后台, agent 继续思考"* &mdash; 后台线程跑命令, 完成后注入通知
>
> **s14** &nbsp; *"定时触发, 不需要人推"* &mdash; 按时间自动触发任务
>
> **s15** &nbsp; *"一个搞不定, 组队来"* &mdash; 持久化队友 + 异步邮箱
>
> **s16** &nbsp; *"队友之间要有约定"* &mdash; 用固定的请求-回复格式沟通
>
> **s17** &nbsp; *"队友自己看板, 有活就认领"* &mdash; 不需要领导逐个分配, 自组织
>
> **s18** &nbsp; *"各干各的目录, 互不干扰"* &mdash; 任务管目标, worktree 管目录, 按 ID 绑定
>
> **s19** &nbsp; *"能力不够? 插上 MCP"* &mdash; 把外部工具接进同一个工具池
>
> **s20** &nbsp; *"机制很多,循环一个"* &mdash; 前面所有机制回到一个完整 harness
---
## 核心模式
```python
def agent_loop(messages):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM,
messages=messages, tools=TOOLS,
)
messages.append({"role": "assistant",
"content": response.content})
if response.stop_reason != "tool_use":
return
results = []
for block in response.content:
if block.type == "tool_use":
output = TOOL_HANDLERS[block.name](**block.input)
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
```
每个课程在这个循环之上叠加一个 harness 机制 -- 循环本身始终不变。循环属于 agent。机制属于 harness。
## 版本说明
本仓库现在同时保留两条教程线:
- **新版主线:根目录 `s01-s20`**
根目录下的 `s01_*``s20_*` 是新的主版本,也是当前推荐阅读路径。每章包含完整叙事 README、英文/日文译本、可运行的 `code.py`,以及必要的图示。
- **旧版过渡:`docs/``agents/`、当前 `web/`**
这些仍保留旧 12 章体系,暂时用于已有读者、旧链接和 Web 平台过渡。
新读者请从根目录 `s01_agent_loop/` 读到 `s20_comprehensive/`。如果你是从旧链接或当前 Web 平台进入,大概率看到的是旧 12 章版本。旧版章节号和新版不完全一致,不要混用章节号。
### 旧版到新版的对应关系
| 旧 12 章版本 | 新 20 章版本 | 主题 |
|---|---|---|
| 旧 s01 | 新 s01 | Agent Loop |
| 旧 s02 | 新 s02 | Tool Use |
| 旧 s03 | 新 s05 | TodoWrite |
| 旧 s04 | 新 s06 | Subagent |
| 旧 s05 | 新 s07 | Skill Loading |
| 旧 s06 | 新 s08 | Context Compact |
| 旧 s07 | 新 s12 | Task System |
| 旧 s08 | 新 s13 | Background Tasks |
| 旧 s09 | 新 s15 | Agent Teams |
| 旧 s10 | 新 s16 | Team Protocols |
| 旧 s11 | 新 s17 | Autonomous Agents |
| 旧 s12 | 新 s18 | Worktree Isolation |
| 新版新增 | s03、s04、s09、s10、s11、s14、s19、s20 | Permission、Hooks、Memory、System Prompt、Error Recovery、Cron、MCP、Comprehensive Agent |
## 范围说明 (重要)
本仓库是一个 0->1 的 harness 工程学习项目 -- 构建围绕 agent 模型的工作环境。
为保证学习路径清晰,仓库有意简化或省略了部分生产机制:
- 完整事件 / Hook 总线 (例如 PreToolUse、SessionStart/End、ConfigChange)。
s12 仅提供教学用途的最小 append-only 生命周期事件流。
- 基于规则的权限治理与信任流程
- 会话生命周期控制 (resume/fork) 与更完整的 worktree 生命周期控制
- 完整 MCP 运行时细节 (transport/OAuth/资源订阅/轮询)
仓库中的团队 JSONL 邮箱协议是教学实现,不是对任何特定生产内部实现的声明。
## 快速开始
### 新版 20 章主线
```sh
git clone https://github.com/shareAI-lab/learn-claude-code
cd learn-claude-code
pip install -r requirements.txt
cp .env.example .env # 编辑 .env 填入你的 ANTHROPIC_API_KEY
python s01_agent_loop/code.py # 起点 — 一个循环 + bash
python s08_context_compact/code.py # 上下文压缩(复杂章)
python s20_comprehensive/code.py # 终点章: 全部机制归到一个循环
```
### 旧版 12 章过渡线
```sh
python agents/s01_agent_loop.py
python agents/s12_worktree_task_isolation.py
python agents/s_full.py
```
### Web 平台
当前 Web 平台仍读取 `docs/` 中的旧 12 章内容。新版 20 章请直接阅读根目录 `s01-s20`
```sh
cd web && npm install && npm run dev # http://localhost:3000
```
## 学习路径
主线:能动手 → 能做复杂任务 → 能记住和恢复 → 能长期运行 → 能协作 → 能扩展并合体
```mermaid
flowchart TD
%% 统一定义卡片样式:加入 text-align:left 保证列表不会居中乱飘
classDef stage1 fill:#E3F2FD,stroke:#1976D2,stroke-width:2px,color:#0D47A1,rx:12,ry:12,text-align:left
classDef stage2 fill:#E8F5E9,stroke:#388E3C,stroke-width:2px,color:#1B5E20,rx:12,ry:12,text-align:left
classDef stage3 fill:#FFF3E0,stroke:#F57C00,stroke-width:2px,color:#E65100,rx:12,ry:12,text-align:left
classDef stage4 fill:#FCE4EC,stroke:#C2185b,stroke-width:2px,color:#880E4F,rx:12,ry:12,text-align:left
classDef stage5 fill:#F3E5F5,stroke:#7B1FA2,stroke-width:2px,color:#4A148C,rx:12,ry:12,text-align:left
classDef stage6 fill:#E0F7FA,stroke:#0097A7,stroke-width:2px,color:#006064,rx:12,ry:12,text-align:left
%% 背景框样式
classDef groupBox fill:#F8F9FA,stroke:#CED4DA,stroke-width:2px,stroke-dasharray: 5 5,rx:15,ry:15,color:#495057
%% 第一层:1-3阶段
subgraph Phase1 ["🌱 阶段 1-3:基础能力构建(从简单到复杂)"]
direction LR
S1["<b>第一阶段:让 Agent 能动手</b><br/>━━━━━━━━━━━━━<br/><b>s01 Agent Loop</b><br/>└─ 一个循环 + bash<br/><br/><b>s02 Tool Use</b><br/>└─ 单个到多个工具<br/><br/><b>s03 Permission</b><br/>└─ 判断能不能做<br/><br/><b>s04 Hooks</b><br/>└─ 工具前后留扩展插口"]:::stage1
S2["<b>第二阶段:做复杂任务</b><br/>━━━━━━━━━━━━━<br/><b>s05 TodoWrite</b><br/>└─ 先列计划,再执行<br/><br/><b>s06 Subagent</b><br/>└─ 子节点干活带回结果<br/><br/><b>s08 Context Compact</b><br/>└─ 长下文腾空间"]:::stage2
S3["<b>第三阶段:记住和恢复</b><br/>━━━━━━━━━━━━━<br/><b>s09 Memory</b><br/>└─ 该记记,该忘忘<br/><br/><b>s10 System Prompt</b><br/>└─ 运行时组装<br/><br/><b>s11 Error Recovery</b><br/>└─ 重试换路子"]:::stage3
S1 ==> S2 ==> S3
end
%% 第二层:4-6阶段
subgraph Phase2 ["🚀 阶段 4-6:高阶能力进化(长期、协作与融合)"]
direction LR
S4["<b>第四阶段:让任务长期运行</b><br/>━━━━━━━━━━━━━<br/><b>s12 Task System</b><br/>└─ 任务落盘记依赖<br/><br/><b>s13 Background Tasks</b><br/>└─ 慢操作丢后台<br/><br/><b>s14 Cron Scheduler</b><br/>└─ 按时自动触发"]:::stage4
S5["<b>第五阶段:让多个 Agent 协作</b><br/>━━━━━━━━━━━━━<br/><b>s15 Agent Teams</b><br/>└─ 队友 + 邮箱通信<br/><br/><b>s16 Team Protocols</b><br/>└─ 固定收发格式<br/><br/><b>s17 Autonomous Agents</b><br/>└─ 自己看板认领活<br/><br/><b>s18 Worktree Isolation</b><br/>└─ 隔离目录"]:::stage5
S6["<b>第六阶段:接外部能力合体</b><br/>━━━━━━━━━━━━━<br/><b>s07 Skill Loading</b><br/>└─ 技能按需展开<br/><br/><b>s19 MCP Plugin</b><br/>└─ 外部接进工具池<br/><br/><b>s20 Comprehensive Agent</b><br/>└─ 全机制回单循环"]:::stage6
S4 ==> S5 ==> S6
end
%% 将两个模块连接起来,形成 Z 字形阅读流
Phase1 ===> Phase2
%% 应用背景样式
class Phase1,Phase2 groupBox
```
## 全部章节
| 章节 | 主题 | 关键概念 |
|---|---|---|
| [s01](./s01_agent_loop/) | Agent Loop | `messages` / `while True` / `stop_reason` |
| [s02](./s02_tool_use/) | Tool Use | `TOOL_HANDLERS` / dispatch map / 并发 |
| [s03](./s03_permission/) | Permission | `PermissionRule` / 审批管线 |
| [s04](./s04_hooks/) | Hooks | `PreToolUse` / `PostToolUse` / 扩展点 |
| [s05](./s05_todo_write/) | TodoWrite | `TodoItem` / 先计划后执行 |
| [s06](./s06_subagent/) | Subagent | `fresh messages[]` / 上下文隔离 |
| [s07](./s07_skill_loading/) | Skill Loading | `SkillManifest` / 按需注入 |
| [s08](./s08_context_compact/) | Context Compact | snip / micro / budget / auto 四层压缩 |
| [s09](./s09_memory/) | Memory | selection / extraction / consolidation |
| [s10](./s10_system_prompt/) | System Prompt | 运行时组装 / 分段拼接 |
| [s11](./s11_error_recovery/) | Error Recovery | token 升级 / fallback 模型 / 重试策略 |
| [s12](./s12_task_system/) | Task System | `TaskRecord` / `blockedBy` / 磁盘持久化 |
| [s13](./s13_background_tasks/) | Background Tasks | 线程执行 / 通知队列 |
| [s14](./s14_cron_scheduler/) | Cron Scheduler | 持久化调度 / 会话级触发 |
| [s15](./s15_agent_teams/) | Agent Teams | `MessageBus` / 收件箱 / 权限冒泡 |
| [s16](./s16_team_protocols/) | Team Protocols | 关机握手 / 计划审批 |
| [s17](./s17_autonomous_agents/) | Autonomous Agents | 空闲循环 / 自动认领 |
| [s18](./s18_worktree_isolation/) | Worktree Isolation | `WorktreeRecord` / 任务-目录绑定 |
| [s19](./s19_mcp_plugin/) | MCP Plugin | 多传输 / 通道路由 / 工具池组装 |
| [s20](./s20_comprehensive/) | Comprehensive Agent | 全部机制归到一个循环 |
## 项目结构
```
learn-claude-code/
s01_agent_loop/ # 每章一个文件夹
README.md # 中文源文档(完整叙事)
README.en.md # 英文译本
README.ja.md # 日文译本
code.py # 独立可运行代码
images/ # SVG 流程图
s02_tool_use/
...
s19_mcp_plugin/
s20_comprehensive/ # 终点章
agents/ # 旧 12 章可运行副本 + s_full.py
skills/ # s07 使用的 skill 文件
docs/ # 旧 12 章文档,过渡期保留
web/ # 当前仍基于 docs/ 旧版内容生成
tests/
```
## 学完之后 -- 从理解到落地
20 个课程走完, 你已经从内到外理解了 harness 工程的运作原理。两种方式把知识变成产品:
### Kode Agent CLI -- 开源 Coding Agent CLI
> `npm i -g @shareai-lab/kode`
支持 Skill & LSP, 适配 Windows, 可接 GLM / MiniMax / DeepSeek 等开放模型。装完即用。
GitHub: **[shareAI-lab/Kode-CLI](https://github.com/shareAI-lab/Kode-CLI)**
### Kode Agent SDK -- 把 Agent 能力嵌入你的应用
官方 Claude Code Agent SDK 底层与完整 CLI 进程通信 -- 每个并发用户 = 一个终端进程。Kode SDK 是独立库, 无 per-user 进程开销, 可嵌入后端、浏览器插件、嵌入式设备等任意运行时。
GitHub: **[shareAI-lab/kode-agent-sdk](https://github.com/shareAI-lab/kode-agent-sdk)**
---
## 姊妹教程: 从*被动临时会话*到*主动常驻助手*
本仓库教的 harness 属于 **用完即走** 型 -- 开终端、给 agent 任务、做完关掉, 下次重开是全新会话。Claude Code 就是这种模式。
但 [OpenClaw](https://github.com/openclaw/openclaw) 证明了另一种可能: 在同样的 agent core 之上, 加两个 harness 机制就能让 agent 从 "踹一下动一下" 变成 "自己隔 30 秒醒一次找活干":
- **心跳 (Heartbeat)** -- 每 30 秒 harness 给 agent 发一条消息, 让它检查有没有事可做。没事就继续睡, 有事立刻行动。
- **定时任务 (Cron)** -- agent 可以给自己安排未来要做的事, 到点自动执行。
再加上 IM 多通道路由 (WhatsApp/Telegram/Slack/Discord 等 13+ 平台)、不清空的上下文记忆、Soul 人格系统, agent 就从一个临时工具变成了始终在线的个人 AI 助手。
**[claw0](https://github.com/shareAI-lab/claw0)** 是我们的姊妹教学仓库, 从零拆解这些 harness 机制:
```
claw agent = agent core + heartbeat + cron + IM chat + memory + soul
```
```
learn-claude-code claw0
(agent harness 内核: (主动式常驻 harness:
循环、工具、规划、 心跳、定时任务、IM 通道、
团队、worktree 隔离) 记忆、Soul 人格)
```
## 许可证
MIT
---
**Agency 来自模型。Harness 让 agency 落地。造好 Harness,模型会完成剩下的。**
**Bash is all you need. Real agents are all the universe needs.**
+462
View File
@@ -0,0 +1,462 @@
[English](./README.md) | [中文](./README-zh.md) | [日本語](./README-ja.md)
<a href="https://trendshift.io/repositories/19746" target="_blank"><img src="https://trendshift.io/api/badge/repositories/19746" alt="shareAI-lab%2Flearn-claude-code | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
# Learn Claude Code -- Harness Engineering for Real Agents
## Agency Comes from the Model. An Agent Product = Model + Harness.
Before we write any code, one thing needs to be clear.
**Agency -- the capacity to perceive, reason, and act -- comes from model training, not from external code orchestration.** But a working agent product needs both the model and the harness. The model is the driver. The harness is the vehicle. This repository teaches you how to build the vehicle.
### Where Agency Comes From
At the core of every agent is a neural network -- a Transformer, an RNN, a trained function -- shaped by billions of gradient updates on sequences of perception, reasoning, and action. Agency was never bestowed by the surrounding code. It was learned during training.
Humans are the original proof. A biological neural network, refined by millions of years of evolutionary pressure, perceives the world through senses, reasons through a brain, and acts through a body. When DeepMind, OpenAI, or Anthropic say "agent," they all mean the same core thing: **a model that learned to act through training, plus the infrastructure that lets it operate in a specific environment.**
The historical record is unambiguous:
- **2013 -- DeepMind DQN plays Atari.** A single neural network, receiving only raw pixels and game scores, learned 7 Atari 2600 games -- surpassing prior algorithms and beating human experts in 3 of them. By 2015, scaled to [49 games at professional tester level](https://www.nature.com/articles/nature14236), published in *Nature*. No game-specific rules. One model, learning from experience.
- **2019 -- OpenAI Five conquers Dota 2.** Five neural networks played [45,000 years of Dota 2 against themselves](https://openai.com/index/openai-five-defeats-dota-2-world-champions/) over 10 months, then defeated **OG** -- the TI8 world champions -- 2-0 in a live match. In the public arena, the AI won 99.4% of 42,729 games. No scripted strategies. Models learned teamwork through self-play.
- **2019 -- DeepMind AlphaStar masters StarCraft II.** AlphaStar [beat a professional player 10-1](https://deepmind.google/blog/alphastar-mastering-the-real-time-strategy-game-starcraft-ii/) in closed matches, then reached [Grandmaster rank](https://www.nature.com/articles/d41586-019-03298-6) on the European server -- top 0.15% of 90,000 players. An incomplete-information, real-time game with a combinatorial action space far exceeding chess or Go.
- **2019 -- Tencent Jueyu dominates Honor of Kings.** Tencent AI Lab's "Jueyu" system [defeated KPL professional players in full 5v5](https://www.jiemian.com/article/3371171.html) at the World Champion Cup semifinal. In 1v1 mode, pros [won just 1 out of 15 matches, lasting under 8 minutes at best](https://developer.aliyun.com/article/851058). Training intensity: one day equaled 440 human years. A model that learned the entire game from scratch through self-play.
- **2024-2025 -- LLM agents reshape software engineering.** Claude, GPT, Gemini -- large language models trained on the full breadth of human code and reasoning -- are deployed as coding agents. They read codebases, write implementations, debug failures, and coordinate as teams. The architecture is identical to every previous agent: a trained model, placed in an environment, given tools for perception and action.
Every milestone points to the same fact: **Agency -- the ability to perceive, reason, and act -- is trained, not coded.** But every agent also needs an environment to operate in: an Atari emulator, the Dota 2 client, the StarCraft II engine, an IDE and a terminal. The model supplies the intelligence. The environment supplies the action space. Together they form a complete agent.
### What an Agent Is NOT
The word "agent" has been hijacked by an entire prompt-plumbing industry.
Drag-and-drop workflow builders. No-code "AI Agent" platforms. Prompt-chain orchestration libraries. They share a single delusion: that stringing LLM API calls together with if-else branches, node graphs, and hardcoded routing logic constitutes "building an agent."
It does not. What they produce are Rube Goldberg machines -- over-engineered, brittle, procedural rule pipelines with an LLM wedged in as a glorified text-completion node. That is not an agent. That is a shell script with grandiose pretensions.
You cannot brute-force intelligence by stacking procedural logic -- sprawling rule trees, node graphs, chained prompt waterfalls -- and praying that enough glue code will spontaneously produce autonomous behavior. It will not. You cannot engineer agency into existence. Agency is learned, not coded.
### The Mindshift: From "Building Agents" to Building Harnesses
When someone says "I am building an agent," they can only mean one of two things:
**1. Training a model.** Adjusting weights through reinforcement learning, fine-tuning, RLHF, or another gradient-based method. Collecting trajectory data -- real-world sequences of perception, reasoning, and action in a target domain -- and using it to shape the model's behavior. This is what DeepMind, OpenAI, Tencent AI Lab, and Anthropic do.
**2. Building a harness.** Writing the code that gives a model an operational environment. This is what most of us do, and it is the core of this repository.
A harness is everything an agent needs to work in a specific domain:
```
Harness = Tools + Knowledge + Observation + Action Interfaces + Permissions
Tools: file I/O, shell, network, database, browser
Knowledge: product docs, domain references, API specs, style guides
Observation: git diff, error logs, browser state, sensor data
Action: CLI commands, API calls, UI interactions
Permissions: sandbox isolation, approval workflows, trust boundaries
```
The model decides. The harness executes. The model reasons. The harness provides context. The model is the driver. The harness is the vehicle.
This repository teaches you to build the vehicle. A vehicle for coding. But the design patterns generalize to any domain.
### What Harness Engineers Actually Do
If you are reading this repository, you are most likely a harness engineer. Here is what the job actually entails:
- **Implement tools.** Give the agent hands. File read/write, shell execution, API calls, browser control, database queries. Each tool is one action the agent can take in its environment. Design them atomic, composable, and clearly described.
- **Curate knowledge.** Give the agent domain expertise. Product documentation, architecture decision records, style guides, compliance requirements. Load on demand, not upfront.
- **Manage context.** Give the agent clean memory. Subagent isolation prevents noise leakage. Context compaction prevents history from drowning the present. Task systems let goals persist beyond a single conversation.
- **Control permissions.** Give the agent boundaries. Sandbox file access. Require approval for destructive operations. Enforce trust boundaries between the agent and external systems.
- **Collect trajectory data.** Every action sequence the agent executes in your harness is training signal. Real deployment trajectories are the raw material for fine-tuning the next generation of agent models.
You are not writing intelligence. You are building the world that intelligence inhabits. The quality of that world directly determines how effectively the intelligence can express itself.
**Build the harness well. The model will do the rest.**
### Why Claude Code
Because Claude Code is the most elegant, most complete agent harness implementation we have seen. Not because of any clever trick, but because of what it *does not* do: it does not try to be the agent. It does not impose rigid workflows. It does not substitute hand-crafted decision trees for the model's own judgment. It gives the model tools, knowledge, context management, and permission boundaries -- then gets out of the way.
Strip Claude Code down to its essence:
```
Claude Code = one agent loop
+ tools (bash, read, write, edit, glob, grep, browser...)
+ on-demand skill loading
+ context compaction
+ subagent spawning
+ task system with dependency graphs
+ async mailbox team coordination
+ worktree-isolated parallel execution
+ permission governance
+ hooks extension system
+ memory persistence
+ MCP external capability routing
```
That is it. The agent itself? Claude. A model. Trained by Anthropic on the full breadth of human reasoning and code. The harness did not make Claude smart. Claude was already smart. The harness gave Claude hands, eyes, and a workspace.
The takeaway is not "copy Claude Code." The takeaway is: **the best agent products come from engineers who understand that their job is the harness, not the intelligence.**
---
```
THE AGENT PATTERN
=================
User --> messages[] --> LLM --> response
|
stop_reason == "tool_use"?
/ \
yes no
| |
execute tools return text
append results
loop back -----------------> messages[]
The model decides when to call tools and when to stop.
The code just executes what the model asks for.
This repo teaches you to build everything around this loop --
the harness that makes the agent effective in a specific domain.
```
## Core Pattern
```python
def agent_loop(messages):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM,
messages=messages, tools=TOOLS,
)
messages.append({"role": "assistant",
"content": response.content})
if response.stop_reason != "tool_use":
return
results = []
for block in response.content:
if block.type == "tool_use":
output = TOOL_HANDLERS[block.name](**block.input)
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
```
Every lesson layers one harness mechanism on top of this loop -- the loop itself never changes. The loop belongs to the agent. The mechanisms belong to the harness.
The loop is constant. Tools, knowledge, and permissions change. Agent = Model (LLM) + a generalized operational environment (Harness).
---
## Version Status
This repository currently contains two tutorial tracks:
- **Current track: root-level `s01-s20`**
The root-level `s01_*` ... `s20_*` folders are the new canonical version. Each chapter contains a full narrative README, translations, runnable `code.py`, and diagrams where needed.
- **Legacy transition track: `docs/`, `agents/`, and the current `web/` app**
These still preserve the older 12-lesson version. They are kept temporarily for existing readers, old links, and the web platform while the new 20-lesson track settles.
If you are starting now, read the root-level `s01_agent_loop/` through `s20_comprehensive/` chapters. If you are following an older link or using the current web app, you are likely reading the legacy 12-lesson track. The legacy and current chapter numbers do not always match, so avoid mixing chapter numbers across tracks.
### Legacy-to-Current Mapping
| Legacy 12-lesson track | Current 20-lesson track | Topic |
|---|---|---|
| old s01 | new s01 | Agent Loop |
| old s02 | new s02 | Tool Use |
| old s03 | new s05 | TodoWrite |
| old s04 | new s06 | Subagent |
| old s05 | new s07 | Skill Loading |
| old s06 | new s08 | Context Compact |
| old s07 | new s12 | Task System |
| old s08 | new s13 | Background Tasks |
| old s09 | new s15 | Agent Teams |
| old s10 | new s16 | Team Protocols |
| old s11 | new s17 | Autonomous Agents |
| old s12 | new s18 | Worktree Isolation |
| new only | s03, s04, s09, s10, s11, s14, s19, s20 | Permission, Hooks, Memory, System Prompt, Error Recovery, Cron, MCP, Comprehensive Agent |
---
## Scope
This repository is a 0-to-1 harness engineering learning project: it teaches how to build the working environment around an agent model. To keep the learning path clear, some production mechanisms are intentionally simplified or omitted:
- Full event / hook bus behavior, such as `PreToolUse`, `SessionStart/End`, and `ConfigChange`.
The teaching code uses minimal lifecycle events where needed.
- Rule-based permission governance and full trust workflows.
- Session lifecycle controls such as resume/fork, plus more complete worktree lifecycle handling.
- Full MCP runtime details such as transport, OAuth, resource subscription, and polling.
The JSONL mailbox protocol in this repository is a teaching implementation, not a claim about any specific production internal implementation.
---
## 20 Progressive Lessons
**Each lesson adds one harness mechanism. Each mechanism has a motto.**
> **s01** &nbsp; *"One loop & Bash is all you need"* &mdash; one tool + one loop = one agent
>
> **s02** &nbsp; *"Adding a tool means adding one handler"* &mdash; the loop stays untouched; new tools register into the dispatch map
>
> **s03** &nbsp; *"Set boundaries first, then grant freedom"* &mdash; check what can run, what must stop, and what needs approval
>
> **s04** &nbsp; *"Hook around the loop, never rewrite the loop"* &mdash; add extension points without changing the main loop
>
> **s05** &nbsp; *"An agent without a plan drifts"* &mdash; list the steps before starting; completion rate doubles
>
> **s06** &nbsp; *"Big tasks split small, each subtask gets clean context"* &mdash; subagents do the side work and bring back only the result
>
> **s07** &nbsp; *"Load knowledge on demand, not upfront"* &mdash; list skills first, expand them only when needed
>
> **s08** &nbsp; *"Context always fills up -- have a way to make room"* &mdash; multi-layer compaction strategies buy you infinite sessions
>
> **s09** &nbsp; *"Remember what matters, forget what doesn't"* &mdash; three subsystems: selection, extraction, consolidation
>
> **s10** &nbsp; *"Prompts are assembled at runtime, not hardcoded"* &mdash; section-based concatenation, loaded on demand
>
> **s11** &nbsp; *"Errors aren't the end, they're the start of a retry"* &mdash; retry, make room, or take another path when things fail
>
> **s12** &nbsp; *"Big goals break into small tasks, ordered, persisted to disk"* &mdash; a file-backed task graph that lays the groundwork for multi-agent coordination
>
> **s13** &nbsp; *"Slow ops go background, agent keeps thinking"* &mdash; background threads run commands; notifications inject on completion
>
> **s14** &nbsp; *"Fire on schedule, no human kick needed"* &mdash; trigger tasks automatically by time
>
> **s15** &nbsp; *"Too big for one agent -- delegate to teammates"* &mdash; persistent teammates + async mailboxes
>
> **s16** &nbsp; *"Teammates need shared communication rules"* &mdash; use a fixed request-reply format for coordination
>
> **s17** &nbsp; *"Teammates check the board, claim work themselves"* &mdash; no leader assigning one by one; self-organizing
>
> **s18** &nbsp; *"Each works in its own directory, no interference"* &mdash; tasks own goals, worktrees own directories, bound by ID
>
> **s19** &nbsp; *"Not enough capability? Plug in more via MCP"* &mdash; connect external tools into the same tool pool
>
> **s20** &nbsp; *"Many mechanisms, one loop"* &mdash; all previous mechanisms return to one complete harness
---
## Learning Path
Main line: act → handle complex work → remember and recover → run long tasks → collaborate → extend and assemble.
```mermaid
flowchart TD
%% Card styles
classDef stage1 fill:#E3F2FD,stroke:#1976D2,stroke-width:2px,color:#0D47A1,rx:12,ry:12,text-align:left
classDef stage2 fill:#E8F5E9,stroke:#388E3C,stroke-width:2px,color:#1B5E20,rx:12,ry:12,text-align:left
classDef stage3 fill:#FFF3E0,stroke:#F57C00,stroke-width:2px,color:#E65100,rx:12,ry:12,text-align:left
classDef stage4 fill:#FCE4EC,stroke:#C2185b,stroke-width:2px,color:#880E4F,rx:12,ry:12,text-align:left
classDef stage5 fill:#F3E5F5,stroke:#7B1FA2,stroke-width:2px,color:#4A148C,rx:12,ry:12,text-align:left
classDef stage6 fill:#E0F7FA,stroke:#0097A7,stroke-width:2px,color:#006064,rx:12,ry:12,text-align:left
%% Group style
classDef groupBox fill:#F8F9FA,stroke:#CED4DA,stroke-width:2px,stroke-dasharray: 5 5,rx:15,ry:15,color:#495057
%% Layer 1: stages 1-3
subgraph Phase1 ["🌱 Stages 1-3: Core capabilities (simple to complex)"]
direction LR
S1["<b>1. Let the Agent act</b><br/>━━━━━━━━━━━━━<br/><b>s01 Agent Loop</b><br/>└─ one loop + bash<br/><br/><b>s02 Tool Use</b><br/>└─ one tool to many tools<br/><br/><b>s03 Permission</b><br/>└─ decide what can run<br/><br/><b>s04 Hooks</b><br/>└─ extension points around tools"]:::stage1
S2["<b>2. Handle complex work</b><br/>━━━━━━━━━━━━━<br/><b>s05 TodoWrite</b><br/>└─ plan first, then execute<br/><br/><b>s06 Subagent</b><br/>└─ side work, result back<br/><br/><b>s08 Context Compact</b><br/>└─ make room in long context"]:::stage2
S3["<b>3. Remember and recover</b><br/>━━━━━━━━━━━━━<br/><b>s09 Memory</b><br/>└─ remember what matters<br/><br/><b>s10 System Prompt</b><br/>└─ assemble at runtime<br/><br/><b>s11 Error Recovery</b><br/>└─ retry or change path"]:::stage3
S1 ==> S2 ==> S3
end
%% Layer 2: stages 4-6
subgraph Phase2 ["🚀 Stages 4-6: Advanced capabilities (long-running, collaboration, integration)"]
direction LR
S4["<b>4. Run long tasks</b><br/>━━━━━━━━━━━━━<br/><b>s12 Task System</b><br/>└─ persist tasks and deps<br/><br/><b>s13 Background Tasks</b><br/>└─ send slow work background<br/><br/><b>s14 Cron Scheduler</b><br/>└─ trigger by time"]:::stage4
S5["<b>5. Coordinate many Agents</b><br/>━━━━━━━━━━━━━<br/><b>s15 Agent Teams</b><br/>└─ teammates + mailboxes<br/><br/><b>s16 Team Protocols</b><br/>└─ fixed request-reply format<br/><br/><b>s17 Autonomous Agents</b><br/>└─ claim work from the board<br/><br/><b>s18 Worktree Isolation</b><br/>└─ separate directories"]:::stage5
S6["<b>6. Extend and assemble</b><br/>━━━━━━━━━━━━━<br/><b>s07 Skill Loading</b><br/>└─ expand skills on demand<br/><br/><b>s19 MCP Plugin</b><br/>└─ external tools, one pool<br/><br/><b>s20 Comprehensive Agent</b><br/>└─ all mechanisms, one loop"]:::stage6
S4 ==> S5 ==> S6
end
%% Connect the two layers
Phase1 ===> Phase2
class Phase1,Phase2 groupBox
```
---
## All Chapters
| Chapter | Topic | Key Concepts |
|---|---|---|
| [s01](./s01_agent_loop/) | Agent Loop | `messages` / `while True` / `stop_reason` |
| [s02](./s02_tool_use/) | Tool Use | `TOOL_HANDLERS` / dispatch map / concurrency |
| [s03](./s03_permission/) | Permission System | `PermissionRule` / approval pipeline |
| [s04](./s04_hooks/) | Hook System | `PreToolUse` / `PostToolUse` / extension points |
| [s05](./s05_todo_write/) | TodoWrite | `TodoItem` / plan-then-execute |
| [s06](./s06_subagent/) | Subagent | `fresh messages[]` / context isolation |
| [s07](./s07_skill_loading/) | Skill Loading | `SkillManifest` / on-demand injection |
| [s08](./s08_context_compact/) | Context Compact | snipCompact / microCompact / toolResultBudget / autoCompact |
| [s09](./s09_memory/) | Memory System | selection / extraction / consolidation |
| [s10](./s10_system_prompt/) | System Prompt | runtime assembly / section concatenation |
| [s11](./s11_error_recovery/) | Error Recovery | token escalation / fallback model / retry strategies |
| [s12](./s12_task_system/) | Task System | `TaskRecord` / `blockedBy` / disk persistence |
| [s13](./s13_background_tasks/) | Background Tasks | threaded execution / notification queue |
| [s14](./s14_cron_scheduler/) | Cron Scheduler | durable scheduling / session-scoped triggers |
| [s15](./s15_agent_teams/) | Agent Teams | `MessageBus` / inbox / permission bubbling |
| [s16](./s16_team_protocols/) | Team Protocols | shutdown handshake / plan approval |
| [s17](./s17_autonomous_agents/) | Autonomous Agents | idle cycle / auto-claim / self-organization |
| [s18](./s18_worktree_isolation/) | Worktree Isolation | `WorktreeRecord` / task-directory binding |
| [s19](./s19_mcp_plugin/) | MCP Plugin | multi-transport / channel routing / tool pool assembly |
| [s20](./s20_comprehensive/) | Comprehensive Agent | all mechanisms around one loop |
---
## How to Read
Each chapter is a folder. Open one and you will find:
```
s08_context_compact/
README.md # full narrative with inline code
README.en.md # English translation
README.ja.md # Japanese translation
code.py # standalone runnable implementation
images/ # SVG diagrams (where needed)
```
Read the `README.md` for the core idea and work through the code. Complex chapters have `<details>` folds for deep dives -- open them when you want to go deeper. Simple chapters have 0-1 diagrams, complex chapters have more.
Read from s01 through s20 in order. Each chapter assumes you've read the previous ones and ends with a hook into the next.
---
## Quick Start
### Current 20-Lesson Track
```sh
git clone https://github.com/shareAI-lab/learn-claude-code
cd learn-claude-code
pip install -r requirements.txt
cp .env.example .env # configure ANTHROPIC_API_KEY
python s01_agent_loop/code.py # Start here -- one loop + bash
python s08_context_compact/code.py # Context compaction (complex)
python s20_comprehensive/code.py # Endpoint: all mechanisms in one loop
```
### Legacy 12-Lesson Track
```sh
python agents/s01_agent_loop.py
python agents/s12_worktree_task_isolation.py
python agents/s_full.py
```
### Web Platform
The current web app still renders the legacy `docs/` s01-s12 track. Use the root-level folders for the new s01-s20 track.
```sh
cd web && npm install && npm run dev # http://localhost:3000
```
---
## Project Structure
```
learn-claude-code/
s01_agent_loop/ # one folder per chapter
README.md # Chinese source (complete narrative)
README.en.md # English translation
README.ja.md # Japanese translation
code.py # standalone runnable code
images/ # SVG diagrams
s02_tool_use/
...
s19_mcp_plugin/
s20_comprehensive/ # endpoint chapter
agents/ # legacy 12 runnable copies + s_full.py
skills/ # skill files used by s07
docs/ # legacy 12-lesson docs, kept during transition
web/ # currently renders the legacy docs/ track
tests/
```
---
## What's Next
After 20 lessons, you understand harness engineering from the inside out. Two paths to turn that knowledge into product:
### Kode Agent CLI -- Open-Source Coding Agent CLI
> `npm i -g @shareai-lab/kode`
Skill and LSP support, Windows compatible, works with GLM / MiniMax / DeepSeek and other open models. Install and go.
GitHub: **[shareAI-lab/Kode-CLI](https://github.com/shareAI-lab/Kode-CLI)**
### Kode Agent SDK -- Embed Agent Capabilities in Your Application
A standalone library with no per-user process overhead. Embed it in backends, browser extensions, embedded devices, or any runtime.
GitHub: **[shareAI-lab/kode-agent-sdk](https://github.com/shareAI-lab/kode-agent-sdk)**
---
## Sister Tutorial: From Passive Sessions to Always-On Assistants
The harness taught in this repository is the **use-and-discard** kind -- open a terminal, give the agent a task, close when done, next session starts fresh. Claude Code works this way.
But [OpenClaw](https://github.com/openclaw/openclaw) proves another possibility: on the same agent core, two additional harness mechanisms turn an agent from "poke it and it moves" into "wakes itself every 30 seconds to look for work":
- **Heartbeat** -- every 30 seconds the harness sends the agent a message, letting it check for pending work. Nothing to do? Keep sleeping. Something appeared? Act immediately.
- **Cron** -- the agent can schedule its own future tasks, which fire automatically when the time arrives.
Add IM multi-channel routing (WhatsApp / Telegram / Slack / Discord and 13+ other platforms), persistent context memory, and a Soul personality system, and the agent transforms from a disposable tool into an always-on personal AI assistant.
**[claw0](https://github.com/shareAI-lab/claw0)** is our sister teaching repository, breaking down these harness mechanisms from scratch:
```
claw agent = agent core + heartbeat + cron + IM chat + memory + soul
```
```
learn-claude-code claw0
(agent harness internals: (always-on harness:
loop, tools, planning, heartbeat, cron, IM channels,
teams, worktree isolation) memory, Soul personality)
```
## License
MIT
---
**Agency comes from the model. The harness gives agency a place to land. Build the harness well, and the model will do the rest.**
**Bash is all you need. Real agents are all the universe needs.**
**This is not "copy the source code." This is "grasp the key designs and build it yourself."**
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`shareAI-lab/learn-claude-code`
- 原始仓库:https://github.com/shareAI-lab/learn-claude-code
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+3
View File
@@ -0,0 +1,3 @@
# agents/ - Harness implementations (s01-s12) + full reference (s_full)
# Each file is self-contained and runnable: python agents/s01_agent_loop.py
# The model is the agent. These files are the harness.
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
# Harness: the loop -- the model's first connection to the real world.
"""
s01_agent_loop.py - The Agent Loop
The entire secret of an AI coding agent in one pattern:
while stop_reason == "tool_use":
response = LLM(messages, tools)
execute tools
append results
+----------+ +-------+ +---------+
| User | ---> | LLM | ---> | Tool |
| prompt | | | | execute |
+----------+ +---+---+ +----+----+
^ |
| tool_result |
+---------------+
(loop continues)
This is the core loop: feed tool results back to the model
until the model decides to stop. Production agents layer
policy, hooks, and lifecycle controls on top.
"""
import os
import subprocess
try:
import readline
# #143 UTF-8 backspace fix for macOS libedit
readline.parse_and_bind('set bind-tty-special-chars off')
readline.parse_and_bind('set input-meta on')
readline.parse_and_bind('set output-meta on')
readline.parse_and_bind('set convert-meta off')
readline.parse_and_bind('set enable-meta-keybindings on')
except ImportError:
pass
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
SYSTEM = f"You are a coding agent at {os.getcwd()}. Use bash to solve tasks. Act, don't explain."
TOOLS = [{
"name": "bash",
"description": "Run a shell command.",
"input_schema": {
"type": "object",
"properties": {"command": {"type": "string"}},
"required": ["command"],
},
}]
def run_bash(command: str) -> str:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
try:
r = subprocess.run(command, shell=True, cwd=os.getcwd(),
capture_output=True, text=True, timeout=120)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
except (FileNotFoundError, OSError) as e:
return f"Error: {e}"
# -- The core pattern: a while loop that calls tools until the model stops --
def agent_loop(messages: list):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
# Append assistant turn
messages.append({"role": "assistant", "content": response.content})
# If the model didn't call a tool, we're done
if response.stop_reason != "tool_use":
return
# Execute each tool call, collect results
results = []
for block in response.content:
if block.type == "tool_use":
print(f"\033[33m$ {block.input['command']}\033[0m")
output = run_bash(block.input["command"])
print(output[:200])
results.append({"type": "tool_result", "tool_use_id": block.id,
"content": output})
messages.append({"role": "user", "content": results})
if __name__ == "__main__":
history = []
while True:
try:
query = input("\033[36ms01 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
history.append({"role": "user", "content": query})
agent_loop(history)
response_content = history[-1]["content"]
if isinstance(response_content, list):
for block in response_content:
if hasattr(block, "text"):
print(block.text)
print()
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
# Harness: tool dispatch -- expanding what the model can reach.
"""
s02_tool_use.py - Tools
The agent loop from s01 didn't change. We just added tools to the array
and a dispatch map to route calls.
+----------+ +-------+ +------------------+
| User | ---> | LLM | ---> | Tool Dispatch |
| prompt | | | | { |
+----------+ +---+---+ | bash: run_bash |
^ | read: run_read |
| | write: run_wr |
+----------+ edit: run_edit |
tool_result| } |
+------------------+
Key insight: "The loop didn't change at all. I just added tools."
"""
import os
import subprocess
from pathlib import Path
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
SYSTEM = f"You are a coding agent at {WORKDIR}. Use tools to solve tasks. Act, don't explain."
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def run_bash(command: str) -> str:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=120)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
def run_read(path: str, limit: int = None) -> str:
try:
text = safe_path(path).read_text()
lines = text.splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"]
return "\n".join(lines)[:50000]
except Exception as e:
return f"Error: {e}"
def run_write(path: str, content: str) -> str:
try:
fp = safe_path(path)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(content)
return f"Wrote {len(content)} bytes to {path}"
except Exception as e:
return f"Error: {e}"
def run_edit(path: str, old_text: str, new_text: str) -> str:
try:
fp = safe_path(path)
content = fp.read_text()
if old_text not in content:
return f"Error: Text not found in {path}"
fp.write_text(content.replace(old_text, new_text, 1))
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"
# -- The dispatch map: {tool_name: handler} --
TOOL_HANDLERS = {
"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
}
TOOLS = [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write content to file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
]
def agent_loop(messages: list):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
print(f"> {block.name}:")
print(output[:200])
results.append({"type": "tool_result", "tool_use_id": block.id, "content": output})
messages.append({"role": "user", "content": results})
if __name__ == "__main__":
history = []
while True:
try:
query = input("\033[36ms02 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
history.append({"role": "user", "content": query})
agent_loop(history)
response_content = history[-1]["content"]
if isinstance(response_content, list):
for block in response_content:
if hasattr(block, "text"):
print(block.text)
print()
+211
View File
@@ -0,0 +1,211 @@
#!/usr/bin/env python3
# Harness: planning -- keeping the model on course without scripting the route.
"""
s03_todo_write.py - TodoWrite
The model tracks its own progress via a TodoManager. A nag reminder
forces it to keep updating when it forgets.
+----------+ +-------+ +---------+
| User | ---> | LLM | ---> | Tools |
| prompt | | | | + todo |
+----------+ +---+---+ +----+----+
^ |
| tool_result |
+---------------+
|
+-----------+-----------+
| TodoManager state |
| [ ] task A |
| [>] task B <- doing |
| [x] task C |
+-----------------------+
|
if rounds_since_todo >= 3:
inject <reminder>
Key insight: "The agent can track its own progress -- and I can see it."
"""
import os
import subprocess
from pathlib import Path
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
SYSTEM = f"""You are a coding agent at {WORKDIR}.
Use the todo tool to plan multi-step tasks. Mark in_progress before starting, completed when done.
Prefer tools over prose."""
# -- TodoManager: structured state the LLM writes to --
class TodoManager:
def __init__(self):
self.items = []
def update(self, items: list) -> str:
if len(items) > 20:
raise ValueError("Max 20 todos allowed")
validated = []
in_progress_count = 0
for i, item in enumerate(items):
text = str(item.get("text", "")).strip()
status = str(item.get("status", "pending")).lower()
item_id = str(item.get("id", str(i + 1)))
if not text:
raise ValueError(f"Item {item_id}: text required")
if status not in ("pending", "in_progress", "completed"):
raise ValueError(f"Item {item_id}: invalid status '{status}'")
if status == "in_progress":
in_progress_count += 1
validated.append({"id": item_id, "text": text, "status": status})
if in_progress_count > 1:
raise ValueError("Only one task can be in_progress at a time")
self.items = validated
return self.render()
def render(self) -> str:
if not self.items:
return "No todos."
lines = []
for item in self.items:
marker = {"pending": "[ ]", "in_progress": "[>]", "completed": "[x]"}[item["status"]]
lines.append(f"{marker} #{item['id']}: {item['text']}")
done = sum(1 for t in self.items if t["status"] == "completed")
lines.append(f"\n({done}/{len(self.items)} completed)")
return "\n".join(lines)
TODO = TodoManager()
# -- Tool implementations --
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def run_bash(command: str) -> str:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=120)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
def run_read(path: str, limit: int = None) -> str:
try:
lines = safe_path(path).read_text().splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
return "\n".join(lines)[:50000]
except Exception as e:
return f"Error: {e}"
def run_write(path: str, content: str) -> str:
try:
fp = safe_path(path)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(content)
return f"Wrote {len(content)} bytes"
except Exception as e:
return f"Error: {e}"
def run_edit(path: str, old_text: str, new_text: str) -> str:
try:
fp = safe_path(path)
content = fp.read_text()
if old_text not in content:
return f"Error: Text not found in {path}"
fp.write_text(content.replace(old_text, new_text, 1))
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"
TOOL_HANDLERS = {
"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
"todo": lambda **kw: TODO.update(kw["items"]),
}
TOOLS = [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write content to file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "todo", "description": "Update task list. Track progress on multi-step tasks.",
"input_schema": {"type": "object", "properties": {"items": {"type": "array", "items": {"type": "object", "properties": {"id": {"type": "string"}, "text": {"type": "string"}, "status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}}, "required": ["id", "text", "status"]}}}, "required": ["items"]}},
]
# -- Agent loop with nag reminder injection --
def agent_loop(messages: list):
rounds_since_todo = 0
while True:
# Nag reminder is injected below, alongside tool results
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
used_todo = False
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
try:
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
except Exception as e:
output = f"Error: {e}"
print(f"> {block.name}:")
print(str(output)[:200])
results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)})
if block.name == "todo":
used_todo = True
rounds_since_todo = 0 if used_todo else rounds_since_todo + 1
if rounds_since_todo >= 3:
results.append({"type": "text", "text": "<reminder>Update your todos.</reminder>"})
messages.append({"role": "user", "content": results})
if __name__ == "__main__":
history = []
while True:
try:
query = input("\033[36ms03 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
history.append({"role": "user", "content": query})
agent_loop(history)
response_content = history[-1]["content"]
if isinstance(response_content, list):
for block in response_content:
if hasattr(block, "text"):
print(block.text)
print()
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env python3
# Harness: context isolation -- protecting the model's clarity of thought.
"""
s04_subagent.py - Subagents
Spawn a child agent with fresh messages=[]. The child works in its own
context, sharing the filesystem, then returns only a summary to the parent.
Parent agent Subagent
+------------------+ +------------------+
| messages=[...] | | messages=[] | <-- fresh
| | dispatch | |
| tool: task | ---------->| while tool_use: |
| prompt="..." | | call tools |
| description="" | | append results |
| | summary | |
| result = "..." | <--------- | return last text |
+------------------+ +------------------+
|
Parent context stays clean.
Subagent context is discarded.
Key insight: "Process isolation gives context isolation for free."
"""
import os
import subprocess
from pathlib import Path
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
SYSTEM = f"You are a coding agent at {WORKDIR}. Use the task tool to delegate exploration or subtasks."
SUBAGENT_SYSTEM = f"You are a coding subagent at {WORKDIR}. Complete the given task, then summarize your findings."
# -- Tool implementations shared by parent and child --
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def run_bash(command: str) -> str:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=120)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
except (FileNotFoundError, OSError) as e:
return f"Error: {e}"
def run_read(path: str, limit: int = None) -> str:
try:
lines = safe_path(path).read_text().splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
return "\n".join(lines)[:50000]
except Exception as e:
return f"Error: {e}"
def run_write(path: str, content: str) -> str:
try:
fp = safe_path(path)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(content)
return f"Wrote {len(content)} bytes"
except Exception as e:
return f"Error: {e}"
def run_edit(path: str, old_text: str, new_text: str) -> str:
try:
fp = safe_path(path)
content = fp.read_text()
if old_text not in content:
return f"Error: Text not found in {path}"
fp.write_text(content.replace(old_text, new_text, 1))
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"
TOOL_HANDLERS = {
"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
}
# Child gets all base tools except task (no recursive spawning)
CHILD_TOOLS = [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write content to file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
]
# -- Subagent: fresh context, filtered tools, summary-only return --
def run_subagent(prompt: str) -> str:
sub_messages = [{"role": "user", "content": prompt}] # fresh context
for _ in range(30): # safety limit
response = client.messages.create(
model=MODEL, system=SUBAGENT_SYSTEM, messages=sub_messages,
tools=CHILD_TOOLS, max_tokens=8000,
)
sub_messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
break
results = []
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)[:50000]})
sub_messages.append({"role": "user", "content": results})
# Only the final text returns to the parent -- child context is discarded
return "".join(b.text for b in response.content if hasattr(b, "text")) or "(no summary)"
# -- Parent tools: base tools + task dispatcher --
PARENT_TOOLS = CHILD_TOOLS + [
{"name": "task", "description": "Spawn a subagent with fresh context. It shares the filesystem but not conversation history.",
"input_schema": {"type": "object", "properties": {"prompt": {"type": "string"}, "description": {"type": "string", "description": "Short description of the task"}}, "required": ["prompt"]}},
]
def agent_loop(messages: list):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=PARENT_TOOLS, max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
for block in response.content:
if block.type == "tool_use":
if block.name == "task":
desc = block.input.get("description", "subtask")
prompt = block.input.get("prompt", "")
print(f"> task ({desc}): {prompt[:80]}")
output = run_subagent(prompt)
else:
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
print(f" {str(output)[:200]}")
results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)})
messages.append({"role": "user", "content": results})
if __name__ == "__main__":
history = []
while True:
try:
query = input("\033[36ms04 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
history.append({"role": "user", "content": query})
agent_loop(history)
response_content = history[-1]["content"]
if isinstance(response_content, list):
for block in response_content:
if hasattr(block, "text"):
print(block.text)
print()
+227
View File
@@ -0,0 +1,227 @@
#!/usr/bin/env python3
# Harness: on-demand knowledge -- domain expertise, loaded when the model asks.
"""
s05_skill_loading.py - Skills
Two-layer skill injection that avoids bloating the system prompt:
Layer 1 (cheap): skill names in system prompt (~100 tokens/skill)
Layer 2 (on demand): full skill body in tool_result
skills/
pdf/
SKILL.md <-- frontmatter (name, description) + body
code-review/
SKILL.md
System prompt:
+--------------------------------------+
| You are a coding agent. |
| Skills available: |
| - pdf: Process PDF files... | <-- Layer 1: metadata only
| - code-review: Review code... |
+--------------------------------------+
When model calls load_skill("pdf"):
+--------------------------------------+
| tool_result: |
| <skill> |
| Full PDF processing instructions | <-- Layer 2: full body
| Step 1: ... |
| Step 2: ... |
| </skill> |
+--------------------------------------+
Key insight: "Don't put everything in the system prompt. Load on demand."
"""
import os
import re
import subprocess
import yaml
from pathlib import Path
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
SKILLS_DIR = WORKDIR / "skills"
# -- SkillLoader: scan skills/<name>/SKILL.md with YAML frontmatter --
class SkillLoader:
def __init__(self, skills_dir: Path):
self.skills_dir = skills_dir
self.skills = {}
self._load_all()
def _load_all(self):
if not self.skills_dir.exists():
return
for f in sorted(self.skills_dir.rglob("SKILL.md")):
text = f.read_text()
meta, body = self._parse_frontmatter(text)
name = meta.get("name", f.parent.name)
self.skills[name] = {"meta": meta, "body": body, "path": str(f)}
def _parse_frontmatter(self, text: str) -> tuple:
"""Parse YAML frontmatter between --- delimiters."""
match = re.match(r"^---\n(.*?)\n---\n(.*)", text, re.DOTALL)
if not match:
return {}, text
try:
meta = yaml.safe_load(match.group(1)) or {}
except yaml.YAMLError:
meta = {}
return meta, match.group(2).strip()
def get_descriptions(self) -> str:
"""Layer 1: short descriptions for the system prompt."""
if not self.skills:
return "(no skills available)"
lines = []
for name, skill in self.skills.items():
desc = skill["meta"].get("description", "No description")
tags = skill["meta"].get("tags", "")
line = f" - {name}: {desc}"
if tags:
line += f" [{tags}]"
lines.append(line)
return "\n".join(lines)
def get_content(self, name: str) -> str:
"""Layer 2: full skill body returned in tool_result."""
skill = self.skills.get(name)
if not skill:
return f"Error: Unknown skill '{name}'. Available: {', '.join(self.skills.keys())}"
return f"<skill name=\"{name}\">\n{skill['body']}\n</skill>"
SKILL_LOADER = SkillLoader(SKILLS_DIR)
# Layer 1: skill metadata injected into system prompt
SYSTEM = f"""You are a coding agent at {WORKDIR}.
Use load_skill to access specialized knowledge before tackling unfamiliar topics.
Skills available:
{SKILL_LOADER.get_descriptions()}"""
# -- Tool implementations --
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def run_bash(command: str) -> str:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=120)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
def run_read(path: str, limit: int = None) -> str:
try:
lines = safe_path(path).read_text().splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
return "\n".join(lines)[:50000]
except Exception as e:
return f"Error: {e}"
def run_write(path: str, content: str) -> str:
try:
fp = safe_path(path)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(content)
return f"Wrote {len(content)} bytes"
except Exception as e:
return f"Error: {e}"
def run_edit(path: str, old_text: str, new_text: str) -> str:
try:
fp = safe_path(path)
content = fp.read_text()
if old_text not in content:
return f"Error: Text not found in {path}"
fp.write_text(content.replace(old_text, new_text, 1))
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"
TOOL_HANDLERS = {
"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
"load_skill": lambda **kw: SKILL_LOADER.get_content(kw["name"]),
}
TOOLS = [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write content to file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "load_skill", "description": "Load specialized knowledge by name.",
"input_schema": {"type": "object", "properties": {"name": {"type": "string", "description": "Skill name to load"}}, "required": ["name"]}},
]
def agent_loop(messages: list):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
try:
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
except Exception as e:
output = f"Error: {e}"
print(f"> {block.name}:")
print(str(output)[:200])
results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)})
messages.append({"role": "user", "content": results})
if __name__ == "__main__":
history = []
while True:
try:
query = input("\033[36ms05 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
history.append({"role": "user", "content": query})
agent_loop(history)
response_content = history[-1]["content"]
if isinstance(response_content, list):
for block in response_content:
if hasattr(block, "text"):
print(block.text)
print()
+262
View File
@@ -0,0 +1,262 @@
#!/usr/bin/env python3
# Harness: compression -- clean memory for infinite sessions.
"""
s06_context_compact.py - Compact
Three-layer compression pipeline so the agent can work forever:
Every turn:
+------------------+
| Tool call result |
+------------------+
|
v
[Layer 1: micro_compact] (silent, every turn)
Replace non-read_file tool_result content older than last 3
with "[Previous: used {tool_name}]"
|
v
[Check: tokens > 50000?]
| |
no yes
| |
v v
continue [Layer 2: auto_compact]
Save full transcript to .transcripts/
Ask LLM to summarize conversation.
Replace all messages with [summary].
|
v
[Layer 3: compact tool]
Model calls compact -> immediate summarization.
Same as auto, triggered manually.
Key insight: "The agent can forget strategically and keep working forever."
"""
import json
import os
import subprocess
import time
from pathlib import Path
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
SYSTEM = f"You are a coding agent at {WORKDIR}. Use tools to solve tasks."
THRESHOLD = 50000
TRANSCRIPT_DIR = WORKDIR / ".transcripts"
KEEP_RECENT = 3
PRESERVE_RESULT_TOOLS = {"read_file"}
def estimate_tokens(messages: list) -> int:
"""Rough token count: ~4 chars per token."""
return len(str(messages)) // 4
# -- Layer 1: micro_compact - replace old tool results with placeholders --
def micro_compact(messages: list) -> list:
# Collect (msg_index, part_index, tool_result_dict) for all tool_result entries
tool_results = []
for msg_idx, msg in enumerate(messages):
if msg["role"] == "user" and isinstance(msg.get("content"), list):
for part_idx, part in enumerate(msg["content"]):
if isinstance(part, dict) and part.get("type") == "tool_result":
tool_results.append((msg_idx, part_idx, part))
if len(tool_results) <= KEEP_RECENT:
return messages
# Find tool_name for each result by matching tool_use_id in prior assistant messages
tool_name_map = {}
for msg in messages:
if msg["role"] == "assistant":
content = msg.get("content", [])
if isinstance(content, list):
for block in content:
if hasattr(block, "type") and block.type == "tool_use":
tool_name_map[block.id] = block.name
# Clear old results (keep last KEEP_RECENT). Preserve read_file outputs because
# they are reference material; compacting them forces the agent to re-read files.
to_clear = tool_results[:-KEEP_RECENT]
for _, _, result in to_clear:
if not isinstance(result.get("content"), str) or len(result["content"]) <= 100:
continue
tool_id = result.get("tool_use_id", "")
tool_name = tool_name_map.get(tool_id, "unknown")
if tool_name in PRESERVE_RESULT_TOOLS:
continue
result["content"] = f"[Previous: used {tool_name}]"
return messages
# -- Layer 2: auto_compact - save transcript, summarize, replace messages --
def auto_compact(messages: list, focus: str = "") -> list:
# Save full transcript to disk
TRANSCRIPT_DIR.mkdir(exist_ok=True)
transcript_path = TRANSCRIPT_DIR / f"transcript_{int(time.time())}.jsonl"
with open(transcript_path, "w") as f:
for msg in messages:
f.write(json.dumps(msg, default=str) + "\n")
print(f"[transcript saved: {transcript_path}]")
# Ask LLM to summarize
conversation_text = json.dumps(messages, default=str)[-80000:]
focus_instruction = ""
if focus:
focus_instruction = f" Pay special attention to preserving details about: {focus}."
response = client.messages.create(
model=MODEL,
messages=[{"role": "user", "content":
"Summarize this conversation for continuity. Include: "
"1) What was accomplished, 2) Current state, 3) Key decisions made. "
"Be concise but preserve critical details."
f"{focus_instruction}\n\n" + conversation_text}],
max_tokens=2000,
)
summary = next((block.text for block in response.content if hasattr(block, "text")), "")
if not summary:
summary = "No summary generated."
# Replace all messages with compressed summary
return [
{"role": "user", "content": f"[Conversation compressed. Transcript: {transcript_path}]\n\n{summary}"},
]
# -- Tool implementations --
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def run_bash(command: str) -> str:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=120)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
def run_read(path: str, limit: int = None) -> str:
try:
lines = safe_path(path).read_text().splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
return "\n".join(lines)[:50000]
except Exception as e:
return f"Error: {e}"
def run_write(path: str, content: str) -> str:
try:
fp = safe_path(path)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(content)
return f"Wrote {len(content)} bytes"
except Exception as e:
return f"Error: {e}"
def run_edit(path: str, old_text: str, new_text: str) -> str:
try:
fp = safe_path(path)
content = fp.read_text()
if old_text not in content:
return f"Error: Text not found in {path}"
fp.write_text(content.replace(old_text, new_text, 1))
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"
TOOL_HANDLERS = {
"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
"compact": lambda **kw: "Manual compression requested.",
}
TOOLS = [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write content to file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "compact", "description": "Trigger manual conversation compression.",
"input_schema": {"type": "object", "properties": {"focus": {"type": "string", "description": "What to preserve in the summary"}}}},
]
def agent_loop(messages: list):
while True:
# Layer 1: micro_compact before each LLM call
micro_compact(messages)
# Layer 2: auto_compact if token estimate exceeds threshold
if estimate_tokens(messages) > THRESHOLD:
print("[auto_compact triggered]")
messages[:] = auto_compact(messages)
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
manual_compact = False
compact_focus = ""
for block in response.content:
if block.type == "tool_use":
if block.name == "compact":
manual_compact = True
compact_focus = block.input.get("focus", "")
output = "Compressing..."
else:
handler = TOOL_HANDLERS.get(block.name)
try:
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
except Exception as e:
output = f"Error: {e}"
print(f"> {block.name}:")
print(str(output)[:200])
results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)})
messages.append({"role": "user", "content": results})
# Layer 3: manual compact triggered by the compact tool
if manual_compact:
print("[manual compact]")
messages[:] = auto_compact(messages, focus=compact_focus)
return
if __name__ == "__main__":
history = []
while True:
try:
query = input("\033[36ms06 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
history.append({"role": "user", "content": query})
agent_loop(history)
response_content = history[-1]["content"]
if isinstance(response_content, list):
for block in response_content:
if hasattr(block, "text"):
print(block.text)
print()
+243
View File
@@ -0,0 +1,243 @@
#!/usr/bin/env python3
# Harness: persistent tasks -- goals that outlive any single conversation.
"""
s07_task_system.py - Tasks
Tasks persist as JSON files in .tasks/ so they survive context compression.
Each task has a dependency graph (blockedBy).
.tasks/
task_1.json {"id":1, "subject":"...", "status":"completed", ...}
task_2.json {"id":2, "blockedBy":[1], "status":"pending", ...}
task_3.json {"id":3, "blockedBy":[2], ...}
Dependency resolution:
+----------+ +----------+ +----------+
| task 1 | --> | task 2 | --> | task 3 |
| complete | | blocked | | blocked |
+----------+ +----------+ +----------+
| ^
+--- completing task 1 removes it from task 2's blockedBy
Key insight: "State that survives compression -- because it's outside the conversation."
"""
import json
import os
import subprocess
from pathlib import Path
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
TASKS_DIR = WORKDIR / ".tasks"
SYSTEM = f"You are a coding agent at {WORKDIR}. Use task tools to plan and track work."
# -- TaskManager: CRUD with dependency graph, persisted as JSON files --
class TaskManager:
def __init__(self, tasks_dir: Path):
self.dir = tasks_dir
self.dir.mkdir(exist_ok=True)
self._next_id = self._max_id() + 1
def _max_id(self) -> int:
ids = [int(f.stem.split("_")[1]) for f in self.dir.glob("task_*.json")]
return max(ids) if ids else 0
def _load(self, task_id: int) -> dict:
path = self.dir / f"task_{task_id}.json"
if not path.exists():
raise ValueError(f"Task {task_id} not found")
return json.loads(path.read_text())
def _save(self, task: dict):
path = self.dir / f"task_{task['id']}.json"
path.write_text(json.dumps(task, indent=2, ensure_ascii=False))
def create(self, subject: str, description: str = "") -> str:
task = {
"id": self._next_id, "subject": subject, "description": description,
"status": "pending", "blockedBy": [], "owner": "",
}
self._save(task)
self._next_id += 1
return json.dumps(task, indent=2, ensure_ascii=False)
def get(self, task_id: int) -> str:
return json.dumps(self._load(task_id), indent=2, ensure_ascii=False)
def update(self, task_id: int, status: str = None,
add_blocked_by: list = None, remove_blocked_by: list = None) -> str:
task = self._load(task_id)
if status:
if status not in ("pending", "in_progress", "completed"):
raise ValueError(f"Invalid status: {status}")
task["status"] = status
if status == "completed":
self._clear_dependency(task_id)
if add_blocked_by:
task["blockedBy"] = list(set(task["blockedBy"] + add_blocked_by))
if remove_blocked_by:
task["blockedBy"] = [x for x in task["blockedBy"] if x not in remove_blocked_by]
self._save(task)
return json.dumps(task, indent=2, ensure_ascii=False)
def _clear_dependency(self, completed_id: int):
"""Remove completed_id from all other tasks' blockedBy lists."""
for f in self.dir.glob("task_*.json"):
task = json.loads(f.read_text())
if completed_id in task.get("blockedBy", []):
task["blockedBy"].remove(completed_id)
self._save(task)
def list_all(self) -> str:
tasks = []
files = sorted(
self.dir.glob("task_*.json"),
key=lambda f: int(f.stem.split("_")[1])
)
for f in files:
tasks.append(json.loads(f.read_text()))
if not tasks:
return "No tasks."
lines = []
for t in tasks:
marker = {"pending": "[ ]", "in_progress": "[>]", "completed": "[x]"}.get(t["status"], "[?]")
blocked = f" (blocked by: {t['blockedBy']})" if t.get("blockedBy") else ""
lines.append(f"{marker} #{t['id']}: {t['subject']}{blocked}")
return "\n".join(lines)
TASKS = TaskManager(TASKS_DIR)
# -- Base tool implementations --
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def run_bash(command: str) -> str:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=120)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
def run_read(path: str, limit: int = None) -> str:
try:
lines = safe_path(path).read_text().splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
return "\n".join(lines)[:50000]
except Exception as e:
return f"Error: {e}"
def run_write(path: str, content: str) -> str:
try:
fp = safe_path(path)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(content)
return f"Wrote {len(content)} bytes"
except Exception as e:
return f"Error: {e}"
def run_edit(path: str, old_text: str, new_text: str) -> str:
try:
fp = safe_path(path)
c = fp.read_text()
if old_text not in c:
return f"Error: Text not found in {path}"
fp.write_text(c.replace(old_text, new_text, 1))
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"
TOOL_HANDLERS = {
"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
"task_create": lambda **kw: TASKS.create(kw["subject"], kw.get("description", "")),
"task_update": lambda **kw: TASKS.update(kw["task_id"], kw.get("status"), kw.get("addBlockedBy"), kw.get("removeBlockedBy")),
"task_list": lambda **kw: TASKS.list_all(),
"task_get": lambda **kw: TASKS.get(kw["task_id"]),
}
TOOLS = [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write content to file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "task_create", "description": "Create a new task.",
"input_schema": {"type": "object", "properties": {"subject": {"type": "string"}, "description": {"type": "string"}}, "required": ["subject"]}},
{"name": "task_update", "description": "Update a task's status or dependencies.",
"input_schema": {"type": "object", "properties": {"task_id": {"type": "integer"}, "status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}, "addBlockedBy": {"type": "array", "items": {"type": "integer"}}, "removeBlockedBy": {"type": "array", "items": {"type": "integer"}}}, "required": ["task_id"]}},
{"name": "task_list", "description": "List all tasks with status summary.",
"input_schema": {"type": "object", "properties": {}}},
{"name": "task_get", "description": "Get full details of a task by ID.",
"input_schema": {"type": "object", "properties": {"task_id": {"type": "integer"}}, "required": ["task_id"]}},
]
def agent_loop(messages: list):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
try:
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
except Exception as e:
output = f"Error: {e}"
print(f"> {block.name}:")
print(str(output)[:200])
results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)})
messages.append({"role": "user", "content": results})
if __name__ == "__main__":
history = []
while True:
try:
query = input("\033[36ms07 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
history.append({"role": "user", "content": query})
agent_loop(history)
response_content = history[-1]["content"]
if isinstance(response_content, list):
for block in response_content:
if hasattr(block, "text"):
print(block.text)
print()
+234
View File
@@ -0,0 +1,234 @@
#!/usr/bin/env python3
# Harness: background execution -- the model thinks while the harness waits.
"""
s08_background_tasks.py - Background Tasks
Run commands in background threads. A notification queue is drained
before each LLM call to deliver results.
Main thread Background thread
+-----------------+ +-----------------+
| agent loop | | task executes |
| ... | | ... |
| [LLM call] <---+------- | enqueue(result) |
| ^drain queue | +-----------------+
+-----------------+
Timeline:
Agent ----[spawn A]----[spawn B]----[other work]----
| |
v v
[A runs] [B runs] (parallel)
| |
+-- notification queue --> [results injected]
Key insight: "Fire and forget -- the agent doesn't block while the command runs."
"""
import os
import subprocess
import threading
import uuid
from pathlib import Path
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
SYSTEM = f"You are a coding agent at {WORKDIR}. Use background_run for long-running commands."
# -- BackgroundManager: threaded execution + notification queue --
class BackgroundManager:
def __init__(self):
self.tasks = {} # task_id -> {status, result, command}
self._notification_queue = [] # completed task results
self._lock = threading.Lock()
def run(self, command: str) -> str:
"""Start a background thread, return task_id immediately."""
task_id = str(uuid.uuid4())[:8]
self.tasks[task_id] = {"status": "running", "result": None, "command": command}
thread = threading.Thread(
target=self._execute, args=(task_id, command), daemon=True
)
thread.start()
return f"Background task {task_id} started: {command[:80]}"
def _execute(self, task_id: str, command: str):
"""Thread target: run subprocess, capture output, push to queue."""
try:
r = subprocess.run(
command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=300
)
output = (r.stdout + r.stderr).strip()[:50000]
status = "completed"
except subprocess.TimeoutExpired:
output = "Error: Timeout (300s)"
status = "timeout"
except Exception as e:
output = f"Error: {e}"
status = "error"
self.tasks[task_id]["status"] = status
self.tasks[task_id]["result"] = output or "(no output)"
with self._lock:
self._notification_queue.append({
"task_id": task_id,
"status": status,
"command": command[:80],
"result": (output or "(no output)")[:500],
})
def check(self, task_id: str = None) -> str:
"""Check status of one task or list all."""
if task_id:
t = self.tasks.get(task_id)
if not t:
return f"Error: Unknown task {task_id}"
return f"[{t['status']}] {t['command'][:60]}\n{t.get('result') or '(running)'}"
lines = []
for tid, t in self.tasks.items():
lines.append(f"{tid}: [{t['status']}] {t['command'][:60]}")
return "\n".join(lines) if lines else "No background tasks."
def drain_notifications(self) -> list:
"""Return and clear all pending completion notifications."""
with self._lock:
notifs = list(self._notification_queue)
self._notification_queue.clear()
return notifs
BG = BackgroundManager()
# -- Tool implementations --
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def run_bash(command: str) -> str:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=120)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
def run_read(path: str, limit: int = None) -> str:
try:
lines = safe_path(path).read_text().splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
return "\n".join(lines)[:50000]
except Exception as e:
return f"Error: {e}"
def run_write(path: str, content: str) -> str:
try:
fp = safe_path(path)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(content)
return f"Wrote {len(content)} bytes"
except Exception as e:
return f"Error: {e}"
def run_edit(path: str, old_text: str, new_text: str) -> str:
try:
fp = safe_path(path)
c = fp.read_text()
if old_text not in c:
return f"Error: Text not found in {path}"
fp.write_text(c.replace(old_text, new_text, 1))
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"
TOOL_HANDLERS = {
"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
"background_run": lambda **kw: BG.run(kw["command"]),
"check_background": lambda **kw: BG.check(kw.get("task_id")),
}
TOOLS = [
{"name": "bash", "description": "Run a shell command (blocking).",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write content to file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "background_run", "description": "Run command in background thread. Returns task_id immediately.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "check_background", "description": "Check background task status. Omit task_id to list all.",
"input_schema": {"type": "object", "properties": {"task_id": {"type": "string"}}}},
]
def agent_loop(messages: list):
while True:
# Drain background notifications and inject as system message before LLM call
notifs = BG.drain_notifications()
if notifs and messages:
notif_text = "\n".join(
f"[bg:{n['task_id']}] {n['status']}: {n['result']}" for n in notifs
)
messages.append({"role": "user", "content": f"<background-results>\n{notif_text}\n</background-results>"})
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
try:
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
except Exception as e:
output = f"Error: {e}"
print(f"> {block.name}:")
print(str(output)[:200])
results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)})
messages.append({"role": "user", "content": results})
if __name__ == "__main__":
history = []
while True:
try:
query = input("\033[36ms08 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
history.append({"role": "user", "content": query})
agent_loop(history)
response_content = history[-1]["content"]
if isinstance(response_content, list):
for block in response_content:
if hasattr(block, "text"):
print(block.text)
print()
+403
View File
@@ -0,0 +1,403 @@
#!/usr/bin/env python3
# Harness: team mailboxes -- multiple models, coordinated through files.
"""
s09_agent_teams.py - Agent Teams
Persistent named agents with file-based JSONL inboxes. Each teammate runs
its own agent loop in a separate thread. Communication via append-only inboxes.
Subagent (s04): spawn -> execute -> return summary -> destroyed
Teammate (s09): spawn -> work -> idle -> work -> ... -> shutdown
.team/config.json .team/inbox/
+----------------------------+ +------------------+
| {"team_name": "default", | | alice.jsonl |
| "members": [ | | bob.jsonl |
| {"name":"alice", | | lead.jsonl |
| "role":"coder", | +------------------+
| "status":"idle"} |
| ]} | send_message("alice", "fix bug"):
+----------------------------+ open("alice.jsonl", "a").write(msg)
read_inbox("alice"):
spawn_teammate("alice","coder",...) msgs = [json.loads(l) for l in ...]
| open("alice.jsonl", "w").close()
v return msgs # drain
Thread: alice Thread: bob
+------------------+ +------------------+
| agent_loop | | agent_loop |
| status: working | | status: idle |
| ... runs tools | | ... waits ... |
| status -> idle | | |
+------------------+ +------------------+
5 message types (all declared, not all handled here):
+-------------------------+-----------------------------------+
| message | Normal text message |
| broadcast | Sent to all teammates |
| shutdown_request | Request graceful shutdown (s10) |
| shutdown_response | Approve/reject shutdown (s10) |
| plan_approval_response | Approve/reject plan (s10) |
+-------------------------+-----------------------------------+
Key insight: "Teammates that can talk to each other."
"""
import json
import os
import subprocess
import threading
import time
from pathlib import Path
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
TEAM_DIR = WORKDIR / ".team"
INBOX_DIR = TEAM_DIR / "inbox"
SYSTEM = f"You are a team lead at {WORKDIR}. Spawn teammates and communicate via inboxes."
VALID_MSG_TYPES = {
"message",
"broadcast",
"shutdown_request",
"shutdown_response",
"plan_approval_response",
}
# -- MessageBus: JSONL inbox per teammate --
class MessageBus:
def __init__(self, inbox_dir: Path):
self.dir = inbox_dir
self.dir.mkdir(parents=True, exist_ok=True)
def send(self, sender: str, to: str, content: str,
msg_type: str = "message", extra: dict = None) -> str:
if msg_type not in VALID_MSG_TYPES:
return f"Error: Invalid type '{msg_type}'. Valid: {VALID_MSG_TYPES}"
msg = {
"type": msg_type,
"from": sender,
"content": content,
"timestamp": time.time(),
}
if extra:
msg.update(extra)
inbox_path = self.dir / f"{to}.jsonl"
with open(inbox_path, "a") as f:
f.write(json.dumps(msg) + "\n")
return f"Sent {msg_type} to {to}"
def read_inbox(self, name: str) -> list:
inbox_path = self.dir / f"{name}.jsonl"
if not inbox_path.exists():
return []
messages = []
for line in inbox_path.read_text().strip().splitlines():
if line:
messages.append(json.loads(line))
inbox_path.write_text("")
return messages
def broadcast(self, sender: str, content: str, teammates: list) -> str:
count = 0
for name in teammates:
if name != sender:
self.send(sender, name, content, "broadcast")
count += 1
return f"Broadcast to {count} teammates"
BUS = MessageBus(INBOX_DIR)
# -- TeammateManager: persistent named agents with config.json --
class TeammateManager:
def __init__(self, team_dir: Path):
self.dir = team_dir
self.dir.mkdir(exist_ok=True)
self.config_path = self.dir / "config.json"
self.config = self._load_config()
self.threads = {}
def _load_config(self) -> dict:
if self.config_path.exists():
return json.loads(self.config_path.read_text())
return {"team_name": "default", "members": []}
def _save_config(self):
self.config_path.write_text(json.dumps(self.config, indent=2))
def _find_member(self, name: str) -> dict:
for m in self.config["members"]:
if m["name"] == name:
return m
return None
def spawn(self, name: str, role: str, prompt: str) -> str:
member = self._find_member(name)
if member:
if member["status"] not in ("idle", "shutdown"):
return f"Error: '{name}' is currently {member['status']}"
member["status"] = "working"
member["role"] = role
else:
member = {"name": name, "role": role, "status": "working"}
self.config["members"].append(member)
self._save_config()
thread = threading.Thread(
target=self._teammate_loop,
args=(name, role, prompt),
daemon=True,
)
self.threads[name] = thread
thread.start()
return f"Spawned '{name}' (role: {role})"
def _teammate_loop(self, name: str, role: str, prompt: str):
sys_prompt = (
f"You are '{name}', role: {role}, at {WORKDIR}. "
f"Use send_message to communicate. Complete your task."
)
messages = [{"role": "user", "content": prompt}]
tools = self._teammate_tools()
for _ in range(50):
inbox = BUS.read_inbox(name)
for msg in inbox:
messages.append({"role": "user", "content": json.dumps(msg)})
try:
response = client.messages.create(
model=MODEL,
system=sys_prompt,
messages=messages,
tools=tools,
max_tokens=8000,
)
except Exception:
break
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
break
results = []
for block in response.content:
if block.type == "tool_use":
output = self._exec(name, block.name, block.input)
print(f" [{name}] {block.name}: {str(output)[:120]}")
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(output),
})
messages.append({"role": "user", "content": results})
member = self._find_member(name)
if member and member["status"] != "shutdown":
member["status"] = "idle"
self._save_config()
def _exec(self, sender: str, tool_name: str, args: dict) -> str:
# these base tools are unchanged from s02
if tool_name == "bash":
return _run_bash(args["command"])
if tool_name == "read_file":
return _run_read(args["path"])
if tool_name == "write_file":
return _run_write(args["path"], args["content"])
if tool_name == "edit_file":
return _run_edit(args["path"], args["old_text"], args["new_text"])
if tool_name == "send_message":
return BUS.send(sender, args["to"], args["content"], args.get("msg_type", "message"))
if tool_name == "read_inbox":
return json.dumps(BUS.read_inbox(sender), indent=2)
return f"Unknown tool: {tool_name}"
def _teammate_tools(self) -> list:
# these base tools are unchanged from s02
return [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write content to file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "send_message", "description": "Send message to a teammate.",
"input_schema": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}, "msg_type": {"type": "string", "enum": list(VALID_MSG_TYPES)}}, "required": ["to", "content"]}},
{"name": "read_inbox", "description": "Read and drain your inbox.",
"input_schema": {"type": "object", "properties": {}}},
]
def list_all(self) -> str:
if not self.config["members"]:
return "No teammates."
lines = [f"Team: {self.config['team_name']}"]
for m in self.config["members"]:
lines.append(f" {m['name']} ({m['role']}): {m['status']}")
return "\n".join(lines)
def member_names(self) -> list:
return [m["name"] for m in self.config["members"]]
TEAM = TeammateManager(TEAM_DIR)
# -- Base tool implementations (these base tools are unchanged from s02) --
def _safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def _run_bash(command: str) -> str:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
try:
r = subprocess.run(
command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=120,
)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
def _run_read(path: str, limit: int = None) -> str:
try:
lines = _safe_path(path).read_text().splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
return "\n".join(lines)[:50000]
except Exception as e:
return f"Error: {e}"
def _run_write(path: str, content: str) -> str:
try:
fp = _safe_path(path)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(content)
return f"Wrote {len(content)} bytes"
except Exception as e:
return f"Error: {e}"
def _run_edit(path: str, old_text: str, new_text: str) -> str:
try:
fp = _safe_path(path)
c = fp.read_text()
if old_text not in c:
return f"Error: Text not found in {path}"
fp.write_text(c.replace(old_text, new_text, 1))
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"
# -- Lead tool dispatch (9 tools) --
TOOL_HANDLERS = {
"bash": lambda **kw: _run_bash(kw["command"]),
"read_file": lambda **kw: _run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: _run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: _run_edit(kw["path"], kw["old_text"], kw["new_text"]),
"spawn_teammate": lambda **kw: TEAM.spawn(kw["name"], kw["role"], kw["prompt"]),
"list_teammates": lambda **kw: TEAM.list_all(),
"send_message": lambda **kw: BUS.send("lead", kw["to"], kw["content"], kw.get("msg_type", "message")),
"read_inbox": lambda **kw: json.dumps(BUS.read_inbox("lead"), indent=2),
"broadcast": lambda **kw: BUS.broadcast("lead", kw["content"], TEAM.member_names()),
}
# these base tools are unchanged from s02
TOOLS = [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write content to file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "spawn_teammate", "description": "Spawn a persistent teammate that runs in its own thread.",
"input_schema": {"type": "object", "properties": {"name": {"type": "string"}, "role": {"type": "string"}, "prompt": {"type": "string"}}, "required": ["name", "role", "prompt"]}},
{"name": "list_teammates", "description": "List all teammates with name, role, status.",
"input_schema": {"type": "object", "properties": {}}},
{"name": "send_message", "description": "Send a message to a teammate's inbox.",
"input_schema": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}, "msg_type": {"type": "string", "enum": list(VALID_MSG_TYPES)}}, "required": ["to", "content"]}},
{"name": "read_inbox", "description": "Read and drain the lead's inbox.",
"input_schema": {"type": "object", "properties": {}}},
{"name": "broadcast", "description": "Send a message to all teammates.",
"input_schema": {"type": "object", "properties": {"content": {"type": "string"}}, "required": ["content"]}},
]
def agent_loop(messages: list):
while True:
inbox = BUS.read_inbox("lead")
if inbox:
messages.append({
"role": "user",
"content": f"<inbox>{json.dumps(inbox, indent=2)}</inbox>",
})
response = client.messages.create(
model=MODEL,
system=SYSTEM,
messages=messages,
tools=TOOLS,
max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
try:
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
except Exception as e:
output = f"Error: {e}"
print(f"> {block.name}:")
print(str(output)[:200])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(output),
})
messages.append({"role": "user", "content": results})
if __name__ == "__main__":
history = []
while True:
try:
query = input("\033[36ms09 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
if query.strip() == "/team":
print(TEAM.list_all())
continue
if query.strip() == "/inbox":
print(json.dumps(BUS.read_inbox("lead"), indent=2))
continue
history.append({"role": "user", "content": query})
agent_loop(history)
response_content = history[-1]["content"]
if isinstance(response_content, list):
for block in response_content:
if hasattr(block, "text"):
print(block.text)
print()
+484
View File
@@ -0,0 +1,484 @@
#!/usr/bin/env python3
# Harness: protocols -- structured handshakes between models.
"""
s10_team_protocols.py - Team Protocols
Shutdown protocol and plan approval protocol, both using the same
request_id correlation pattern. Builds on s09's team messaging.
Shutdown FSM: pending -> approved | rejected
Lead Teammate
+---------------------+ +---------------------+
| shutdown_request | | |
| { | -------> | receives request |
| request_id: abc | | decides: approve? |
| } | | |
+---------------------+ +---------------------+
|
+---------------------+ +-------v-------------+
| shutdown_response | <------- | shutdown_response |
| { | | { |
| request_id: abc | | request_id: abc |
| approve: true | | approve: true |
| } | | } |
+---------------------+ +---------------------+
|
v
status -> "shutdown", thread stops
Plan approval FSM: pending -> approved | rejected
Teammate Lead
+---------------------+ +---------------------+
| plan_approval | | |
| submit: {plan:"..."}| -------> | reviews plan text |
+---------------------+ | approve/reject? |
+---------------------+
|
+---------------------+ +-------v-------------+
| plan_approval_resp | <------- | plan_approval |
| {approve: true} | | review: {req_id, |
+---------------------+ | approve: true} |
+---------------------+
Trackers: {request_id: {"target|from": name, "status": "pending|..."}}
Key insight: "Same request_id correlation pattern, two domains."
"""
import json
import os
import subprocess
import threading
import time
import uuid
from pathlib import Path
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
TEAM_DIR = WORKDIR / ".team"
INBOX_DIR = TEAM_DIR / "inbox"
SYSTEM = f"You are a team lead at {WORKDIR}. Manage teammates with shutdown and plan approval protocols."
VALID_MSG_TYPES = {
"message",
"broadcast",
"shutdown_request",
"shutdown_response",
"plan_approval_response",
}
# -- Request trackers: correlate by request_id --
shutdown_requests = {}
plan_requests = {}
_tracker_lock = threading.Lock()
# -- MessageBus: JSONL inbox per teammate --
class MessageBus:
def __init__(self, inbox_dir: Path):
self.dir = inbox_dir
self.dir.mkdir(parents=True, exist_ok=True)
def send(self, sender: str, to: str, content: str,
msg_type: str = "message", extra: dict = None) -> str:
if msg_type not in VALID_MSG_TYPES:
return f"Error: Invalid type '{msg_type}'. Valid: {VALID_MSG_TYPES}"
msg = {
"type": msg_type,
"from": sender,
"content": content,
"timestamp": time.time(),
}
if extra:
msg.update(extra)
inbox_path = self.dir / f"{to}.jsonl"
with open(inbox_path, "a") as f:
f.write(json.dumps(msg) + "\n")
return f"Sent {msg_type} to {to}"
def read_inbox(self, name: str) -> list:
inbox_path = self.dir / f"{name}.jsonl"
if not inbox_path.exists():
return []
messages = []
for line in inbox_path.read_text().strip().splitlines():
if line:
messages.append(json.loads(line))
inbox_path.write_text("")
return messages
def broadcast(self, sender: str, content: str, teammates: list) -> str:
count = 0
for name in teammates:
if name != sender:
self.send(sender, name, content, "broadcast")
count += 1
return f"Broadcast to {count} teammates"
BUS = MessageBus(INBOX_DIR)
# -- TeammateManager with shutdown + plan approval --
class TeammateManager:
def __init__(self, team_dir: Path):
self.dir = team_dir
self.dir.mkdir(exist_ok=True)
self.config_path = self.dir / "config.json"
self.config = self._load_config()
self.threads = {}
def _load_config(self) -> dict:
if self.config_path.exists():
return json.loads(self.config_path.read_text())
return {"team_name": "default", "members": []}
def _save_config(self):
self.config_path.write_text(json.dumps(self.config, indent=2))
def _find_member(self, name: str) -> dict:
for m in self.config["members"]:
if m["name"] == name:
return m
return None
def spawn(self, name: str, role: str, prompt: str) -> str:
member = self._find_member(name)
if member:
if member["status"] not in ("idle", "shutdown"):
return f"Error: '{name}' is currently {member['status']}"
member["status"] = "working"
member["role"] = role
else:
member = {"name": name, "role": role, "status": "working"}
self.config["members"].append(member)
self._save_config()
thread = threading.Thread(
target=self._teammate_loop,
args=(name, role, prompt),
daemon=True,
)
self.threads[name] = thread
thread.start()
return f"Spawned '{name}' (role: {role})"
def _teammate_loop(self, name: str, role: str, prompt: str):
sys_prompt = (
f"You are '{name}', role: {role}, at {WORKDIR}. "
f"Submit plans via plan_approval before major work. "
f"Respond to shutdown_request with shutdown_response."
)
messages = [{"role": "user", "content": prompt}]
tools = self._teammate_tools()
should_exit = False
for _ in range(50):
inbox = BUS.read_inbox(name)
for msg in inbox:
messages.append({"role": "user", "content": json.dumps(msg)})
if should_exit:
break
try:
response = client.messages.create(
model=MODEL,
system=sys_prompt,
messages=messages,
tools=tools,
max_tokens=8000,
)
except Exception:
break
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
break
results = []
for block in response.content:
if block.type == "tool_use":
output = self._exec(name, block.name, block.input)
print(f" [{name}] {block.name}: {str(output)[:120]}")
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(output),
})
if block.name == "shutdown_response" and block.input.get("approve"):
should_exit = True
messages.append({"role": "user", "content": results})
member = self._find_member(name)
if member:
member["status"] = "shutdown" if should_exit else "idle"
self._save_config()
def _exec(self, sender: str, tool_name: str, args: dict) -> str:
# these base tools are unchanged from s02
if tool_name == "bash":
return _run_bash(args["command"])
if tool_name == "read_file":
return _run_read(args["path"])
if tool_name == "write_file":
return _run_write(args["path"], args["content"])
if tool_name == "edit_file":
return _run_edit(args["path"], args["old_text"], args["new_text"])
if tool_name == "send_message":
return BUS.send(sender, args["to"], args["content"], args.get("msg_type", "message"))
if tool_name == "read_inbox":
return json.dumps(BUS.read_inbox(sender), indent=2)
if tool_name == "shutdown_response":
req_id = args["request_id"]
approve = args["approve"]
with _tracker_lock:
if req_id in shutdown_requests:
shutdown_requests[req_id]["status"] = "approved" if approve else "rejected"
BUS.send(
sender, "lead", args.get("reason", ""),
"shutdown_response", {"request_id": req_id, "approve": approve},
)
return f"Shutdown {'approved' if approve else 'rejected'}"
if tool_name == "plan_approval":
plan_text = args.get("plan", "")
req_id = str(uuid.uuid4())[:8]
with _tracker_lock:
plan_requests[req_id] = {"from": sender, "plan": plan_text, "status": "pending"}
BUS.send(
sender, "lead", plan_text, "plan_approval_response",
{"request_id": req_id, "plan": plan_text},
)
return f"Plan submitted (request_id={req_id}). Waiting for lead approval."
return f"Unknown tool: {tool_name}"
def _teammate_tools(self) -> list:
# these base tools are unchanged from s02
return [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write content to file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "send_message", "description": "Send message to a teammate.",
"input_schema": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}, "msg_type": {"type": "string", "enum": list(VALID_MSG_TYPES)}}, "required": ["to", "content"]}},
{"name": "read_inbox", "description": "Read and drain your inbox.",
"input_schema": {"type": "object", "properties": {}}},
{"name": "shutdown_response", "description": "Respond to a shutdown request. Approve to shut down, reject to keep working.",
"input_schema": {"type": "object", "properties": {"request_id": {"type": "string"}, "approve": {"type": "boolean"}, "reason": {"type": "string"}}, "required": ["request_id", "approve"]}},
{"name": "plan_approval", "description": "Submit a plan for lead approval. Provide plan text.",
"input_schema": {"type": "object", "properties": {"plan": {"type": "string"}}, "required": ["plan"]}},
]
def list_all(self) -> str:
if not self.config["members"]:
return "No teammates."
lines = [f"Team: {self.config['team_name']}"]
for m in self.config["members"]:
lines.append(f" {m['name']} ({m['role']}): {m['status']}")
return "\n".join(lines)
def member_names(self) -> list:
return [m["name"] for m in self.config["members"]]
TEAM = TeammateManager(TEAM_DIR)
# -- Base tool implementations (these base tools are unchanged from s02) --
def _safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def _run_bash(command: str) -> str:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
try:
r = subprocess.run(
command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=120,
)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
def _run_read(path: str, limit: int = None) -> str:
try:
lines = _safe_path(path).read_text().splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
return "\n".join(lines)[:50000]
except Exception as e:
return f"Error: {e}"
def _run_write(path: str, content: str) -> str:
try:
fp = _safe_path(path)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(content)
return f"Wrote {len(content)} bytes"
except Exception as e:
return f"Error: {e}"
def _run_edit(path: str, old_text: str, new_text: str) -> str:
try:
fp = _safe_path(path)
c = fp.read_text()
if old_text not in c:
return f"Error: Text not found in {path}"
fp.write_text(c.replace(old_text, new_text, 1))
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"
# -- Lead-specific protocol handlers --
def handle_shutdown_request(teammate: str) -> str:
req_id = str(uuid.uuid4())[:8]
with _tracker_lock:
shutdown_requests[req_id] = {"target": teammate, "status": "pending"}
BUS.send(
"lead", teammate, "Please shut down gracefully.",
"shutdown_request", {"request_id": req_id},
)
return f"Shutdown request {req_id} sent to '{teammate}' (status: pending)"
def handle_plan_review(request_id: str, approve: bool, feedback: str = "") -> str:
with _tracker_lock:
req = plan_requests.get(request_id)
if not req:
return f"Error: Unknown plan request_id '{request_id}'"
with _tracker_lock:
req["status"] = "approved" if approve else "rejected"
BUS.send(
"lead", req["from"], feedback, "plan_approval_response",
{"request_id": request_id, "approve": approve, "feedback": feedback},
)
return f"Plan {req['status']} for '{req['from']}'"
def _check_shutdown_status(request_id: str) -> str:
with _tracker_lock:
return json.dumps(shutdown_requests.get(request_id, {"error": "not found"}))
# -- Lead tool dispatch (12 tools) --
TOOL_HANDLERS = {
"bash": lambda **kw: _run_bash(kw["command"]),
"read_file": lambda **kw: _run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: _run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: _run_edit(kw["path"], kw["old_text"], kw["new_text"]),
"spawn_teammate": lambda **kw: TEAM.spawn(kw["name"], kw["role"], kw["prompt"]),
"list_teammates": lambda **kw: TEAM.list_all(),
"send_message": lambda **kw: BUS.send("lead", kw["to"], kw["content"], kw.get("msg_type", "message")),
"read_inbox": lambda **kw: json.dumps(BUS.read_inbox("lead"), indent=2),
"broadcast": lambda **kw: BUS.broadcast("lead", kw["content"], TEAM.member_names()),
"shutdown_request": lambda **kw: handle_shutdown_request(kw["teammate"]),
"shutdown_response": lambda **kw: _check_shutdown_status(kw.get("request_id", "")),
"plan_approval": lambda **kw: handle_plan_review(kw["request_id"], kw["approve"], kw.get("feedback", "")),
}
# these base tools are unchanged from s02
TOOLS = [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write content to file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "spawn_teammate", "description": "Spawn a persistent teammate.",
"input_schema": {"type": "object", "properties": {"name": {"type": "string"}, "role": {"type": "string"}, "prompt": {"type": "string"}}, "required": ["name", "role", "prompt"]}},
{"name": "list_teammates", "description": "List all teammates.",
"input_schema": {"type": "object", "properties": {}}},
{"name": "send_message", "description": "Send a message to a teammate.",
"input_schema": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}, "msg_type": {"type": "string", "enum": list(VALID_MSG_TYPES)}}, "required": ["to", "content"]}},
{"name": "read_inbox", "description": "Read and drain the lead's inbox.",
"input_schema": {"type": "object", "properties": {}}},
{"name": "broadcast", "description": "Send a message to all teammates.",
"input_schema": {"type": "object", "properties": {"content": {"type": "string"}}, "required": ["content"]}},
{"name": "shutdown_request", "description": "Request a teammate to shut down gracefully. Returns a request_id for tracking.",
"input_schema": {"type": "object", "properties": {"teammate": {"type": "string"}}, "required": ["teammate"]}},
{"name": "shutdown_response", "description": "Check the status of a shutdown request by request_id.",
"input_schema": {"type": "object", "properties": {"request_id": {"type": "string"}}, "required": ["request_id"]}},
{"name": "plan_approval", "description": "Approve or reject a teammate's plan. Provide request_id + approve + optional feedback.",
"input_schema": {"type": "object", "properties": {"request_id": {"type": "string"}, "approve": {"type": "boolean"}, "feedback": {"type": "string"}}, "required": ["request_id", "approve"]}},
]
def agent_loop(messages: list):
while True:
inbox = BUS.read_inbox("lead")
if inbox:
messages.append({
"role": "user",
"content": f"<inbox>{json.dumps(inbox, indent=2)}</inbox>",
})
response = client.messages.create(
model=MODEL,
system=SYSTEM,
messages=messages,
tools=TOOLS,
max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
try:
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
except Exception as e:
output = f"Error: {e}"
print(f"> {block.name}:")
print(str(output)[:200])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(output),
})
messages.append({"role": "user", "content": results})
if __name__ == "__main__":
history = []
while True:
try:
query = input("\033[36ms10 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
if query.strip() == "/team":
print(TEAM.list_all())
continue
if query.strip() == "/inbox":
print(json.dumps(BUS.read_inbox("lead"), indent=2))
continue
history.append({"role": "user", "content": query})
agent_loop(history)
response_content = history[-1]["content"]
if isinstance(response_content, list):
for block in response_content:
if hasattr(block, "text"):
print(block.text)
print()
+586
View File
@@ -0,0 +1,586 @@
#!/usr/bin/env python3
# Harness: autonomy -- models that find work without being told.
"""
s11_autonomous_agents.py - Autonomous Agents
Idle cycle with task board polling, auto-claiming unclaimed tasks, and
identity re-injection after context compression. Builds on s10's protocols.
Teammate lifecycle:
+-------+
| spawn |
+---+---+
|
v
+-------+ tool_use +-------+
| WORK | <----------- | LLM |
+---+---+ +-------+
|
| stop_reason != tool_use
v
+--------+
| IDLE | poll every 5s for up to 60s
+---+----+
|
+---> check inbox -> message? -> resume WORK
|
+---> scan .tasks/ -> unclaimed? -> claim -> resume WORK
|
+---> timeout (60s) -> shutdown
Identity re-injection after compression:
messages = [identity_block, ...remaining...]
"You are 'coder', role: backend, team: my-team"
Key insight: "The agent finds work itself."
"""
import json
import os
import subprocess
import threading
import time
import uuid
from pathlib import Path
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
TEAM_DIR = WORKDIR / ".team"
INBOX_DIR = TEAM_DIR / "inbox"
TASKS_DIR = WORKDIR / ".tasks"
POLL_INTERVAL = 5
IDLE_TIMEOUT = 60
SYSTEM = f"You are a team lead at {WORKDIR}. Teammates are autonomous -- they find work themselves."
VALID_MSG_TYPES = {
"message",
"broadcast",
"shutdown_request",
"shutdown_response",
"plan_approval_response",
}
# -- Request trackers --
shutdown_requests = {}
plan_requests = {}
_tracker_lock = threading.Lock()
_claim_lock = threading.Lock()
# -- MessageBus: JSONL inbox per teammate --
class MessageBus:
def __init__(self, inbox_dir: Path):
self.dir = inbox_dir
self.dir.mkdir(parents=True, exist_ok=True)
def send(self, sender: str, to: str, content: str,
msg_type: str = "message", extra: dict = None) -> str:
if msg_type not in VALID_MSG_TYPES:
return f"Error: Invalid type '{msg_type}'. Valid: {VALID_MSG_TYPES}"
msg = {
"type": msg_type,
"from": sender,
"content": content,
"timestamp": time.time(),
}
if extra:
msg.update(extra)
inbox_path = self.dir / f"{to}.jsonl"
with open(inbox_path, "a") as f:
f.write(json.dumps(msg) + "\n")
return f"Sent {msg_type} to {to}"
def read_inbox(self, name: str) -> list:
inbox_path = self.dir / f"{name}.jsonl"
if not inbox_path.exists():
return []
messages = []
for line in inbox_path.read_text().strip().splitlines():
if line:
messages.append(json.loads(line))
inbox_path.write_text("")
return messages
def broadcast(self, sender: str, content: str, teammates: list) -> str:
count = 0
for name in teammates:
if name != sender:
self.send(sender, name, content, "broadcast")
count += 1
return f"Broadcast to {count} teammates"
BUS = MessageBus(INBOX_DIR)
# -- Task board scanning --
def scan_unclaimed_tasks() -> list:
TASKS_DIR.mkdir(exist_ok=True)
unclaimed = []
for f in sorted(TASKS_DIR.glob("task_*.json")):
task = json.loads(f.read_text())
if (task.get("status") == "pending"
and not task.get("owner")
and not task.get("blockedBy")):
unclaimed.append(task)
return unclaimed
def claim_task(task_id: int, owner: str) -> str:
with _claim_lock:
path = TASKS_DIR / f"task_{task_id}.json"
if not path.exists():
return f"Error: Task {task_id} not found"
task = json.loads(path.read_text())
if task.get("owner"):
existing_owner = task.get("owner") or "someone else"
return f"Error: Task {task_id} has already been claimed by {existing_owner}"
if task.get("status") != "pending":
status = task.get("status")
return f"Error: Task {task_id} cannot be claimed because its status is '{status}'"
if task.get("blockedBy"):
return f"Error: Task {task_id} is blocked by other task(s) and cannot be claimed yet"
task["owner"] = owner
task["status"] = "in_progress"
path.write_text(json.dumps(task, indent=2))
return f"Claimed task #{task_id} for {owner}"
# -- Identity re-injection after compression --
def make_identity_block(name: str, role: str, team_name: str) -> dict:
return {
"role": "user",
"content": f"<identity>You are '{name}', role: {role}, team: {team_name}. Continue your work.</identity>",
}
# -- Autonomous TeammateManager --
class TeammateManager:
def __init__(self, team_dir: Path):
self.dir = team_dir
self.dir.mkdir(exist_ok=True)
self.config_path = self.dir / "config.json"
self.config = self._load_config()
self.threads = {}
def _load_config(self) -> dict:
if self.config_path.exists():
return json.loads(self.config_path.read_text())
return {"team_name": "default", "members": []}
def _save_config(self):
self.config_path.write_text(json.dumps(self.config, indent=2))
def _find_member(self, name: str) -> dict:
for m in self.config["members"]:
if m["name"] == name:
return m
return None
def _set_status(self, name: str, status: str):
member = self._find_member(name)
if member:
member["status"] = status
self._save_config()
def spawn(self, name: str, role: str, prompt: str) -> str:
member = self._find_member(name)
if member:
if member["status"] not in ("idle", "shutdown"):
return f"Error: '{name}' is currently {member['status']}"
member["status"] = "working"
member["role"] = role
else:
member = {"name": name, "role": role, "status": "working"}
self.config["members"].append(member)
self._save_config()
thread = threading.Thread(
target=self._loop,
args=(name, role, prompt),
daemon=True,
)
self.threads[name] = thread
thread.start()
return f"Spawned '{name}' (role: {role})"
def _loop(self, name: str, role: str, prompt: str):
team_name = self.config["team_name"]
sys_prompt = (
f"You are '{name}', role: {role}, team: {team_name}, at {WORKDIR}. "
f"Use idle tool when you have no more work. You will auto-claim new tasks."
)
messages = [{"role": "user", "content": prompt}]
tools = self._teammate_tools()
while True:
# -- WORK PHASE: standard agent loop --
for _ in range(50):
inbox = BUS.read_inbox(name)
for msg in inbox:
if msg.get("type") == "shutdown_request":
self._set_status(name, "shutdown")
return
messages.append({"role": "user", "content": json.dumps(msg)})
try:
response = client.messages.create(
model=MODEL,
system=sys_prompt,
messages=messages,
tools=tools,
max_tokens=8000,
)
except Exception:
self._set_status(name, "idle")
return
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
break
results = []
idle_requested = False
for block in response.content:
if block.type == "tool_use":
if block.name == "idle":
idle_requested = True
output = "Entering idle phase. Will poll for new tasks."
else:
output = self._exec(name, block.name, block.input)
print(f" [{name}] {block.name}: {str(output)[:120]}")
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(output),
})
messages.append({"role": "user", "content": results})
if idle_requested:
break
# -- IDLE PHASE: poll for inbox messages and unclaimed tasks --
self._set_status(name, "idle")
resume = False
polls = IDLE_TIMEOUT // max(POLL_INTERVAL, 1)
for _ in range(polls):
time.sleep(POLL_INTERVAL)
inbox = BUS.read_inbox(name)
if inbox:
for msg in inbox:
if msg.get("type") == "shutdown_request":
self._set_status(name, "shutdown")
return
messages.append({"role": "user", "content": json.dumps(msg)})
resume = True
break
unclaimed = scan_unclaimed_tasks()
if unclaimed:
task = unclaimed[0]
result = claim_task(task["id"], name)
if result.startswith("Error:"):
continue
task_prompt = (
f"<auto-claimed>Task #{task['id']}: {task['subject']}\n"
f"{task.get('description', '')}</auto-claimed>"
)
if len(messages) <= 3:
messages.insert(0, make_identity_block(name, role, team_name))
messages.insert(1, {"role": "assistant", "content": f"I am {name}. Continuing."})
messages.append({"role": "user", "content": task_prompt})
messages.append({"role": "assistant", "content": f"Claimed task #{task['id']}. Working on it."})
resume = True
break
if not resume:
self._set_status(name, "shutdown")
return
self._set_status(name, "working")
def _exec(self, sender: str, tool_name: str, args: dict) -> str:
# these base tools are unchanged from s02
if tool_name == "bash":
return _run_bash(args["command"])
if tool_name == "read_file":
return _run_read(args["path"])
if tool_name == "write_file":
return _run_write(args["path"], args["content"])
if tool_name == "edit_file":
return _run_edit(args["path"], args["old_text"], args["new_text"])
if tool_name == "send_message":
return BUS.send(sender, args["to"], args["content"], args.get("msg_type", "message"))
if tool_name == "read_inbox":
return json.dumps(BUS.read_inbox(sender), indent=2)
if tool_name == "shutdown_response":
req_id = args["request_id"]
with _tracker_lock:
if req_id in shutdown_requests:
shutdown_requests[req_id]["status"] = "approved" if args["approve"] else "rejected"
BUS.send(
sender, "lead", args.get("reason", ""),
"shutdown_response", {"request_id": req_id, "approve": args["approve"]},
)
return f"Shutdown {'approved' if args['approve'] else 'rejected'}"
if tool_name == "plan_approval":
plan_text = args.get("plan", "")
req_id = str(uuid.uuid4())[:8]
with _tracker_lock:
plan_requests[req_id] = {"from": sender, "plan": plan_text, "status": "pending"}
BUS.send(
sender, "lead", plan_text, "plan_approval_response",
{"request_id": req_id, "plan": plan_text},
)
return f"Plan submitted (request_id={req_id}). Waiting for approval."
if tool_name == "claim_task":
return claim_task(args["task_id"], sender)
return f"Unknown tool: {tool_name}"
def _teammate_tools(self) -> list:
# these base tools are unchanged from s02
return [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write content to file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "send_message", "description": "Send message to a teammate.",
"input_schema": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}, "msg_type": {"type": "string", "enum": list(VALID_MSG_TYPES)}}, "required": ["to", "content"]}},
{"name": "read_inbox", "description": "Read and drain your inbox.",
"input_schema": {"type": "object", "properties": {}}},
{"name": "shutdown_response", "description": "Respond to a shutdown request.",
"input_schema": {"type": "object", "properties": {"request_id": {"type": "string"}, "approve": {"type": "boolean"}, "reason": {"type": "string"}}, "required": ["request_id", "approve"]}},
{"name": "plan_approval", "description": "Submit a plan for lead approval.",
"input_schema": {"type": "object", "properties": {"plan": {"type": "string"}}, "required": ["plan"]}},
{"name": "idle", "description": "Signal that you have no more work. Enters idle polling phase.",
"input_schema": {"type": "object", "properties": {}}},
{"name": "claim_task", "description": "Claim a task from the task board by ID.",
"input_schema": {"type": "object", "properties": {"task_id": {"type": "integer"}}, "required": ["task_id"]}},
]
def list_all(self) -> str:
if not self.config["members"]:
return "No teammates."
lines = [f"Team: {self.config['team_name']}"]
for m in self.config["members"]:
lines.append(f" {m['name']} ({m['role']}): {m['status']}")
return "\n".join(lines)
def member_names(self) -> list:
return [m["name"] for m in self.config["members"]]
TEAM = TeammateManager(TEAM_DIR)
# -- Base tool implementations (these base tools are unchanged from s02) --
def _safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def _run_bash(command: str) -> str:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
try:
r = subprocess.run(
command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=120,
)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
def _run_read(path: str, limit: int = None) -> str:
try:
lines = _safe_path(path).read_text().splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
return "\n".join(lines)[:50000]
except Exception as e:
return f"Error: {e}"
def _run_write(path: str, content: str) -> str:
try:
fp = _safe_path(path)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(content)
return f"Wrote {len(content)} bytes"
except Exception as e:
return f"Error: {e}"
def _run_edit(path: str, old_text: str, new_text: str) -> str:
try:
fp = _safe_path(path)
c = fp.read_text()
if old_text not in c:
return f"Error: Text not found in {path}"
fp.write_text(c.replace(old_text, new_text, 1))
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"
# -- Lead-specific protocol handlers --
def handle_shutdown_request(teammate: str) -> str:
req_id = str(uuid.uuid4())[:8]
with _tracker_lock:
shutdown_requests[req_id] = {"target": teammate, "status": "pending"}
BUS.send(
"lead", teammate, "Please shut down gracefully.",
"shutdown_request", {"request_id": req_id},
)
return f"Shutdown request {req_id} sent to '{teammate}'"
def handle_plan_review(request_id: str, approve: bool, feedback: str = "") -> str:
with _tracker_lock:
req = plan_requests.get(request_id)
if not req:
return f"Error: Unknown plan request_id '{request_id}'"
with _tracker_lock:
req["status"] = "approved" if approve else "rejected"
BUS.send(
"lead", req["from"], feedback, "plan_approval_response",
{"request_id": request_id, "approve": approve, "feedback": feedback},
)
return f"Plan {req['status']} for '{req['from']}'"
def _check_shutdown_status(request_id: str) -> str:
with _tracker_lock:
return json.dumps(shutdown_requests.get(request_id, {"error": "not found"}))
# -- Lead tool dispatch (14 tools) --
TOOL_HANDLERS = {
"bash": lambda **kw: _run_bash(kw["command"]),
"read_file": lambda **kw: _run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: _run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: _run_edit(kw["path"], kw["old_text"], kw["new_text"]),
"spawn_teammate": lambda **kw: TEAM.spawn(kw["name"], kw["role"], kw["prompt"]),
"list_teammates": lambda **kw: TEAM.list_all(),
"send_message": lambda **kw: BUS.send("lead", kw["to"], kw["content"], kw.get("msg_type", "message")),
"read_inbox": lambda **kw: json.dumps(BUS.read_inbox("lead"), indent=2),
"broadcast": lambda **kw: BUS.broadcast("lead", kw["content"], TEAM.member_names()),
"shutdown_request": lambda **kw: handle_shutdown_request(kw["teammate"]),
"shutdown_response": lambda **kw: _check_shutdown_status(kw.get("request_id", "")),
"plan_approval": lambda **kw: handle_plan_review(kw["request_id"], kw["approve"], kw.get("feedback", "")),
"idle": lambda **kw: "Lead does not idle.",
"claim_task": lambda **kw: claim_task(kw["task_id"], "lead"),
}
# these base tools are unchanged from s02
TOOLS = [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write content to file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "spawn_teammate", "description": "Spawn an autonomous teammate.",
"input_schema": {"type": "object", "properties": {"name": {"type": "string"}, "role": {"type": "string"}, "prompt": {"type": "string"}}, "required": ["name", "role", "prompt"]}},
{"name": "list_teammates", "description": "List all teammates.",
"input_schema": {"type": "object", "properties": {}}},
{"name": "send_message", "description": "Send a message to a teammate.",
"input_schema": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}, "msg_type": {"type": "string", "enum": list(VALID_MSG_TYPES)}}, "required": ["to", "content"]}},
{"name": "read_inbox", "description": "Read and drain the lead's inbox.",
"input_schema": {"type": "object", "properties": {}}},
{"name": "broadcast", "description": "Send a message to all teammates.",
"input_schema": {"type": "object", "properties": {"content": {"type": "string"}}, "required": ["content"]}},
{"name": "shutdown_request", "description": "Request a teammate to shut down.",
"input_schema": {"type": "object", "properties": {"teammate": {"type": "string"}}, "required": ["teammate"]}},
{"name": "shutdown_response", "description": "Check shutdown request status.",
"input_schema": {"type": "object", "properties": {"request_id": {"type": "string"}}, "required": ["request_id"]}},
{"name": "plan_approval", "description": "Approve or reject a teammate's plan.",
"input_schema": {"type": "object", "properties": {"request_id": {"type": "string"}, "approve": {"type": "boolean"}, "feedback": {"type": "string"}}, "required": ["request_id", "approve"]}},
{"name": "idle", "description": "Enter idle state (for lead -- rarely used).",
"input_schema": {"type": "object", "properties": {}}},
{"name": "claim_task", "description": "Claim a task from the board by ID.",
"input_schema": {"type": "object", "properties": {"task_id": {"type": "integer"}}, "required": ["task_id"]}},
]
def agent_loop(messages: list):
while True:
inbox = BUS.read_inbox("lead")
if inbox:
messages.append({
"role": "user",
"content": f"<inbox>{json.dumps(inbox, indent=2)}</inbox>",
})
response = client.messages.create(
model=MODEL,
system=SYSTEM,
messages=messages,
tools=TOOLS,
max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
try:
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
except Exception as e:
output = f"Error: {e}"
print(f"> {block.name}:")
print(str(output)[:200])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": str(output),
})
messages.append({"role": "user", "content": results})
if __name__ == "__main__":
history = []
while True:
try:
query = input("\033[36ms11 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
if query.strip() == "/team":
print(TEAM.list_all())
continue
if query.strip() == "/inbox":
print(json.dumps(BUS.read_inbox("lead"), indent=2))
continue
if query.strip() == "/tasks":
TASKS_DIR.mkdir(exist_ok=True)
for f in sorted(TASKS_DIR.glob("task_*.json")):
t = json.loads(f.read_text())
marker = {"pending": "[ ]", "in_progress": "[>]", "completed": "[x]"}.get(t["status"], "[?]")
owner = f" @{t['owner']}" if t.get("owner") else ""
print(f" {marker} #{t['id']}: {t['subject']}{owner}")
continue
history.append({"role": "user", "content": query})
agent_loop(history)
response_content = history[-1]["content"]
if isinstance(response_content, list):
for block in response_content:
if hasattr(block, "text"):
print(block.text)
print()
+782
View File
@@ -0,0 +1,782 @@
#!/usr/bin/env python3
# Harness: directory isolation -- parallel execution lanes that never collide.
"""
s12_worktree_task_isolation.py - Worktree + Task Isolation
Directory-level isolation for parallel task execution.
Tasks are the control plane and worktrees are the execution plane.
.tasks/task_12.json
{
"id": 12,
"subject": "Implement auth refactor",
"status": "in_progress",
"worktree": "auth-refactor"
}
.worktrees/index.json
{
"worktrees": [
{
"name": "auth-refactor",
"path": ".../.worktrees/auth-refactor",
"branch": "wt/auth-refactor",
"task_id": 12,
"status": "active"
}
]
}
Key insight: "Isolate by directory, coordinate by task ID."
"""
import json
import os
import re
import subprocess
import time
from pathlib import Path
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
def detect_repo_root(cwd: Path) -> Path | None:
"""Return git repo root if cwd is inside a repo, else None."""
try:
r = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
cwd=cwd,
capture_output=True,
text=True,
timeout=10,
)
if r.returncode != 0:
return None
root = Path(r.stdout.strip())
return root if root.exists() else None
except Exception:
return None
REPO_ROOT = detect_repo_root(WORKDIR) or WORKDIR
SYSTEM = (
f"You are a coding agent at {WORKDIR}. "
"Use task + worktree tools for multi-task work. "
"For parallel or risky changes: create tasks, allocate worktree lanes, "
"run commands in those lanes, then choose keep/remove for closeout. "
"Use worktree_events when you need lifecycle visibility."
)
# -- EventBus: append-only lifecycle events for observability --
class EventBus:
def __init__(self, event_log_path: Path):
self.path = event_log_path
self.path.parent.mkdir(parents=True, exist_ok=True)
if not self.path.exists():
self.path.write_text("")
def emit(
self,
event: str,
task: dict | None = None,
worktree: dict | None = None,
error: str | None = None,
):
payload = {
"event": event,
"ts": time.time(),
"task": task or {},
"worktree": worktree or {},
}
if error:
payload["error"] = error
with self.path.open("a", encoding="utf-8") as f:
f.write(json.dumps(payload) + "\n")
def list_recent(self, limit: int = 20) -> str:
n = max(1, min(int(limit or 20), 200))
lines = self.path.read_text(encoding="utf-8").splitlines()
recent = lines[-n:]
items = []
for line in recent:
try:
items.append(json.loads(line))
except Exception:
items.append({"event": "parse_error", "raw": line})
return json.dumps(items, indent=2)
# -- TaskManager: persistent task board with optional worktree binding --
class TaskManager:
def __init__(self, tasks_dir: Path):
self.dir = tasks_dir
self.dir.mkdir(parents=True, exist_ok=True)
self._next_id = self._max_id() + 1
def _max_id(self) -> int:
ids = []
for f in self.dir.glob("task_*.json"):
try:
ids.append(int(f.stem.split("_")[1]))
except Exception:
pass
return max(ids) if ids else 0
def _path(self, task_id: int) -> Path:
return self.dir / f"task_{task_id}.json"
def _load(self, task_id: int) -> dict:
path = self._path(task_id)
if not path.exists():
raise ValueError(f"Task {task_id} not found")
return json.loads(path.read_text())
def _save(self, task: dict):
self._path(task["id"]).write_text(json.dumps(task, indent=2))
def create(self, subject: str, description: str = "") -> str:
task = {
"id": self._next_id,
"subject": subject,
"description": description,
"status": "pending",
"owner": "",
"worktree": "",
"blockedBy": [],
"created_at": time.time(),
"updated_at": time.time(),
}
self._save(task)
self._next_id += 1
return json.dumps(task, indent=2)
def get(self, task_id: int) -> str:
return json.dumps(self._load(task_id), indent=2)
def exists(self, task_id: int) -> bool:
return self._path(task_id).exists()
def update(self, task_id: int, status: str = None, owner: str = None) -> str:
task = self._load(task_id)
if status:
if status not in ("pending", "in_progress", "completed"):
raise ValueError(f"Invalid status: {status}")
task["status"] = status
if owner is not None:
task["owner"] = owner
task["updated_at"] = time.time()
self._save(task)
return json.dumps(task, indent=2)
def bind_worktree(self, task_id: int, worktree: str, owner: str = "") -> str:
task = self._load(task_id)
task["worktree"] = worktree
if owner:
task["owner"] = owner
if task["status"] == "pending":
task["status"] = "in_progress"
task["updated_at"] = time.time()
self._save(task)
return json.dumps(task, indent=2)
def unbind_worktree(self, task_id: int) -> str:
task = self._load(task_id)
task["worktree"] = ""
task["updated_at"] = time.time()
self._save(task)
return json.dumps(task, indent=2)
def list_all(self) -> str:
tasks = []
for f in sorted(self.dir.glob("task_*.json")):
tasks.append(json.loads(f.read_text()))
if not tasks:
return "No tasks."
lines = []
for t in tasks:
marker = {
"pending": "[ ]",
"in_progress": "[>]",
"completed": "[x]",
}.get(t["status"], "[?]")
owner = f" owner={t['owner']}" if t.get("owner") else ""
wt = f" wt={t['worktree']}" if t.get("worktree") else ""
lines.append(f"{marker} #{t['id']}: {t['subject']}{owner}{wt}")
return "\n".join(lines)
TASKS = TaskManager(REPO_ROOT / ".tasks")
EVENTS = EventBus(REPO_ROOT / ".worktrees" / "events.jsonl")
# -- WorktreeManager: create/list/run/remove git worktrees + lifecycle index --
class WorktreeManager:
def __init__(self, repo_root: Path, tasks: TaskManager, events: EventBus):
self.repo_root = repo_root
self.tasks = tasks
self.events = events
self.dir = repo_root / ".worktrees"
self.dir.mkdir(parents=True, exist_ok=True)
self.index_path = self.dir / "index.json"
if not self.index_path.exists():
self.index_path.write_text(json.dumps({"worktrees": []}, indent=2))
self.git_available = self._is_git_repo()
def _is_git_repo(self) -> bool:
try:
r = subprocess.run(
["git", "rev-parse", "--is-inside-work-tree"],
cwd=self.repo_root,
capture_output=True,
text=True,
timeout=10,
)
return r.returncode == 0
except Exception:
return False
def _run_git(self, args: list[str]) -> str:
if not self.git_available:
raise RuntimeError("Not in a git repository. worktree tools require git.")
r = subprocess.run(
["git", *args],
cwd=self.repo_root,
capture_output=True,
text=True,
timeout=120,
)
if r.returncode != 0:
msg = (r.stdout + r.stderr).strip()
raise RuntimeError(msg or f"git {' '.join(args)} failed")
return (r.stdout + r.stderr).strip() or "(no output)"
def _load_index(self) -> dict:
return json.loads(self.index_path.read_text())
def _save_index(self, data: dict):
self.index_path.write_text(json.dumps(data, indent=2))
def _find(self, name: str) -> dict | None:
idx = self._load_index()
for wt in idx.get("worktrees", []):
if wt.get("name") == name:
return wt
return None
def _validate_name(self, name: str):
if not re.fullmatch(r"[A-Za-z0-9._-]{1,40}", name or ""):
raise ValueError(
"Invalid worktree name. Use 1-40 chars: letters, numbers, ., _, -"
)
def create(self, name: str, task_id: int = None, base_ref: str = "HEAD") -> str:
self._validate_name(name)
if self._find(name):
raise ValueError(f"Worktree '{name}' already exists in index")
if task_id is not None and not self.tasks.exists(task_id):
raise ValueError(f"Task {task_id} not found")
path = self.dir / name
branch = f"wt/{name}"
self.events.emit(
"worktree.create.before",
task={"id": task_id} if task_id is not None else {},
worktree={"name": name, "base_ref": base_ref},
)
try:
self._run_git(["worktree", "add", "-b", branch, str(path), base_ref])
entry = {
"name": name,
"path": str(path),
"branch": branch,
"task_id": task_id,
"status": "active",
"created_at": time.time(),
}
idx = self._load_index()
idx["worktrees"].append(entry)
self._save_index(idx)
if task_id is not None:
self.tasks.bind_worktree(task_id, name)
self.events.emit(
"worktree.create.after",
task={"id": task_id} if task_id is not None else {},
worktree={
"name": name,
"path": str(path),
"branch": branch,
"status": "active",
},
)
return json.dumps(entry, indent=2)
except Exception as e:
self.events.emit(
"worktree.create.failed",
task={"id": task_id} if task_id is not None else {},
worktree={"name": name, "base_ref": base_ref},
error=str(e),
)
raise
def list_all(self) -> str:
idx = self._load_index()
wts = idx.get("worktrees", [])
if not wts:
return "No worktrees in index."
lines = []
for wt in wts:
suffix = f" task={wt['task_id']}" if wt.get("task_id") else ""
lines.append(
f"[{wt.get('status', 'unknown')}] {wt['name']} -> "
f"{wt['path']} ({wt.get('branch', '-')}){suffix}"
)
return "\n".join(lines)
def status(self, name: str) -> str:
wt = self._find(name)
if not wt:
return f"Error: Unknown worktree '{name}'"
path = Path(wt["path"])
if not path.exists():
return f"Error: Worktree path missing: {path}"
r = subprocess.run(
["git", "status", "--short", "--branch"],
cwd=path,
capture_output=True,
text=True,
timeout=60,
)
text = (r.stdout + r.stderr).strip()
return text or "Clean worktree"
def run(self, name: str, command: str) -> str:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
wt = self._find(name)
if not wt:
return f"Error: Unknown worktree '{name}'"
path = Path(wt["path"])
if not path.exists():
return f"Error: Worktree path missing: {path}"
try:
r = subprocess.run(
command,
shell=True,
cwd=path,
capture_output=True,
text=True,
timeout=300,
)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (300s)"
def remove(self, name: str, force: bool = False, complete_task: bool = False) -> str:
wt = self._find(name)
if not wt:
return f"Error: Unknown worktree '{name}'"
self.events.emit(
"worktree.remove.before",
task={"id": wt.get("task_id")} if wt.get("task_id") is not None else {},
worktree={"name": name, "path": wt.get("path")},
)
try:
args = ["worktree", "remove"]
if force:
args.append("--force")
args.append(wt["path"])
self._run_git(args)
if complete_task and wt.get("task_id") is not None:
task_id = wt["task_id"]
before = json.loads(self.tasks.get(task_id))
self.tasks.update(task_id, status="completed")
self.tasks.unbind_worktree(task_id)
self.events.emit(
"task.completed",
task={
"id": task_id,
"subject": before.get("subject", ""),
"status": "completed",
},
worktree={"name": name},
)
idx = self._load_index()
for item in idx.get("worktrees", []):
if item.get("name") == name:
item["status"] = "removed"
item["removed_at"] = time.time()
self._save_index(idx)
self.events.emit(
"worktree.remove.after",
task={"id": wt.get("task_id")} if wt.get("task_id") is not None else {},
worktree={"name": name, "path": wt.get("path"), "status": "removed"},
)
return f"Removed worktree '{name}'"
except Exception as e:
self.events.emit(
"worktree.remove.failed",
task={"id": wt.get("task_id")} if wt.get("task_id") is not None else {},
worktree={"name": name, "path": wt.get("path")},
error=str(e),
)
raise
def keep(self, name: str) -> str:
wt = self._find(name)
if not wt:
return f"Error: Unknown worktree '{name}'"
idx = self._load_index()
kept = None
for item in idx.get("worktrees", []):
if item.get("name") == name:
item["status"] = "kept"
item["kept_at"] = time.time()
kept = item
self._save_index(idx)
self.events.emit(
"worktree.keep",
task={"id": wt.get("task_id")} if wt.get("task_id") is not None else {},
worktree={
"name": name,
"path": wt.get("path"),
"status": "kept",
},
)
return json.dumps(kept, indent=2) if kept else f"Error: Unknown worktree '{name}'"
WORKTREES = WorktreeManager(REPO_ROOT, TASKS, EVENTS)
# -- Base tools (kept minimal, same style as previous sessions) --
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def run_bash(command: str) -> str:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
try:
r = subprocess.run(
command,
shell=True,
cwd=WORKDIR,
capture_output=True,
text=True,
timeout=120,
)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
def run_read(path: str, limit: int = None) -> str:
try:
lines = safe_path(path).read_text().splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
return "\n".join(lines)[:50000]
except Exception as e:
return f"Error: {e}"
def run_write(path: str, content: str) -> str:
try:
fp = safe_path(path)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(content)
return f"Wrote {len(content)} bytes"
except Exception as e:
return f"Error: {e}"
def run_edit(path: str, old_text: str, new_text: str) -> str:
try:
fp = safe_path(path)
c = fp.read_text()
if old_text not in c:
return f"Error: Text not found in {path}"
fp.write_text(c.replace(old_text, new_text, 1))
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"
TOOL_HANDLERS = {
"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
"task_create": lambda **kw: TASKS.create(kw["subject"], kw.get("description", "")),
"task_list": lambda **kw: TASKS.list_all(),
"task_get": lambda **kw: TASKS.get(kw["task_id"]),
"task_update": lambda **kw: TASKS.update(kw["task_id"], kw.get("status"), kw.get("owner")),
"task_bind_worktree": lambda **kw: TASKS.bind_worktree(kw["task_id"], kw["worktree"], kw.get("owner", "")),
"worktree_create": lambda **kw: WORKTREES.create(kw["name"], kw.get("task_id"), kw.get("base_ref", "HEAD")),
"worktree_list": lambda **kw: WORKTREES.list_all(),
"worktree_status": lambda **kw: WORKTREES.status(kw["name"]),
"worktree_run": lambda **kw: WORKTREES.run(kw["name"], kw["command"]),
"worktree_keep": lambda **kw: WORKTREES.keep(kw["name"]),
"worktree_remove": lambda **kw: WORKTREES.remove(kw["name"], kw.get("force", False), kw.get("complete_task", False)),
"worktree_events": lambda **kw: EVENTS.list_recent(kw.get("limit", 20)),
}
TOOLS = [
{
"name": "bash",
"description": "Run a shell command in the current workspace (blocking).",
"input_schema": {
"type": "object",
"properties": {"command": {"type": "string"}},
"required": ["command"],
},
},
{
"name": "read_file",
"description": "Read file contents.",
"input_schema": {
"type": "object",
"properties": {
"path": {"type": "string"},
"limit": {"type": "integer"},
},
"required": ["path"],
},
},
{
"name": "write_file",
"description": "Write content to file.",
"input_schema": {
"type": "object",
"properties": {
"path": {"type": "string"},
"content": {"type": "string"},
},
"required": ["path", "content"],
},
},
{
"name": "edit_file",
"description": "Replace exact text in file.",
"input_schema": {
"type": "object",
"properties": {
"path": {"type": "string"},
"old_text": {"type": "string"},
"new_text": {"type": "string"},
},
"required": ["path", "old_text", "new_text"],
},
},
{
"name": "task_create",
"description": "Create a new task on the shared task board.",
"input_schema": {
"type": "object",
"properties": {
"subject": {"type": "string"},
"description": {"type": "string"},
},
"required": ["subject"],
},
},
{
"name": "task_list",
"description": "List all tasks with status, owner, and worktree binding.",
"input_schema": {"type": "object", "properties": {}},
},
{
"name": "task_get",
"description": "Get task details by ID.",
"input_schema": {
"type": "object",
"properties": {"task_id": {"type": "integer"}},
"required": ["task_id"],
},
},
{
"name": "task_update",
"description": "Update task status or owner.",
"input_schema": {
"type": "object",
"properties": {
"task_id": {"type": "integer"},
"status": {
"type": "string",
"enum": ["pending", "in_progress", "completed"],
},
"owner": {"type": "string"},
},
"required": ["task_id"],
},
},
{
"name": "task_bind_worktree",
"description": "Bind a task to a worktree name.",
"input_schema": {
"type": "object",
"properties": {
"task_id": {"type": "integer"},
"worktree": {"type": "string"},
"owner": {"type": "string"},
},
"required": ["task_id", "worktree"],
},
},
{
"name": "worktree_create",
"description": "Create a git worktree and optionally bind it to a task.",
"input_schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"task_id": {"type": "integer"},
"base_ref": {"type": "string"},
},
"required": ["name"],
},
},
{
"name": "worktree_list",
"description": "List worktrees tracked in .worktrees/index.json.",
"input_schema": {"type": "object", "properties": {}},
},
{
"name": "worktree_status",
"description": "Show git status for one worktree.",
"input_schema": {
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
},
},
{
"name": "worktree_run",
"description": "Run a shell command in a named worktree directory.",
"input_schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"command": {"type": "string"},
},
"required": ["name", "command"],
},
},
{
"name": "worktree_remove",
"description": "Remove a worktree and optionally mark its bound task completed.",
"input_schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"force": {"type": "boolean"},
"complete_task": {"type": "boolean"},
},
"required": ["name"],
},
},
{
"name": "worktree_keep",
"description": "Mark a worktree as kept in lifecycle state without removing it.",
"input_schema": {
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
},
},
{
"name": "worktree_events",
"description": "List recent worktree/task lifecycle events from .worktrees/events.jsonl.",
"input_schema": {
"type": "object",
"properties": {"limit": {"type": "integer"}},
},
},
]
def agent_loop(messages: list):
while True:
response = client.messages.create(
model=MODEL,
system=SYSTEM,
messages=messages,
tools=TOOLS,
max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
try:
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
except Exception as e:
output = f"Error: {e}"
print(f"> {block.name}:")
print(str(output)[:200])
results.append(
{
"type": "tool_result",
"tool_use_id": block.id,
"content": str(output),
}
)
messages.append({"role": "user", "content": results})
if __name__ == "__main__":
print(f"Repo root for s12: {REPO_ROOT}")
if not WORKTREES.git_available:
print("Note: Not in a git repo. worktree_* tools will return errors.")
history = []
while True:
try:
query = input("\033[36ms12 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
history.append({"role": "user", "content": query})
agent_loop(history)
response_content = history[-1]["content"]
if isinstance(response_content, list):
for block in response_content:
if hasattr(block, "text"):
print(block.text)
print()
+740
View File
@@ -0,0 +1,740 @@
#!/usr/bin/env python3
# Harness: all mechanisms combined -- the complete cockpit for the model.
"""
s_full.py - Full Reference Agent
Capstone implementation combining every mechanism from s01-s11.
Session s12 (task-aware worktree isolation) is taught separately.
NOT a teaching session -- this is the "put it all together" reference.
+------------------------------------------------------------------+
| FULL AGENT |
| |
| System prompt (s05 skills, task-first + optional todo nag) |
| |
| Before each LLM call: |
| +--------------------+ +------------------+ +--------------+ |
| | Microcompact (s06) | | Drain bg (s08) | | Check inbox | |
| | Auto-compact (s06) | | notifications | | (s09) | |
| +--------------------+ +------------------+ +--------------+ |
| |
| Tool dispatch (s02 pattern): |
| +--------+----------+----------+---------+-----------+ |
| | bash | read | write | edit | TodoWrite | |
| | task | load_sk | compress | bg_run | bg_check | |
| | t_crt | t_get | t_upd | t_list | spawn_tm | |
| | list_tm| send_msg | rd_inbox | bcast | shutdown | |
| | plan | idle | claim | | | |
| +--------+----------+----------+---------+-----------+ |
| |
| Subagent (s04): spawn -> work -> return summary |
| Teammate (s09): spawn -> work -> idle -> auto-claim (s11) |
| Shutdown (s10): request_id handshake |
| Plan gate (s10): submit -> approve/reject |
+------------------------------------------------------------------+
REPL commands: /compact /tasks /team /inbox
"""
import json
import os
import re
import subprocess
import threading
import time
import uuid
from pathlib import Path
from queue import Queue
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
TEAM_DIR = WORKDIR / ".team"
INBOX_DIR = TEAM_DIR / "inbox"
TASKS_DIR = WORKDIR / ".tasks"
SKILLS_DIR = WORKDIR / "skills"
TRANSCRIPT_DIR = WORKDIR / ".transcripts"
TOKEN_THRESHOLD = 100000
POLL_INTERVAL = 5
IDLE_TIMEOUT = 60
VALID_MSG_TYPES = {"message", "broadcast", "shutdown_request",
"shutdown_response", "plan_approval_response"}
# === SECTION: base_tools ===
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def run_bash(command: str) -> str:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=120)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
def run_read(path: str, limit: int = None) -> str:
try:
lines = safe_path(path).read_text().splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more)"]
return "\n".join(lines)[:50000]
except Exception as e:
return f"Error: {e}"
def run_write(path: str, content: str) -> str:
try:
fp = safe_path(path)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(content)
return f"Wrote {len(content)} bytes to {path}"
except Exception as e:
return f"Error: {e}"
def run_edit(path: str, old_text: str, new_text: str) -> str:
try:
fp = safe_path(path)
c = fp.read_text()
if old_text not in c:
return f"Error: Text not found in {path}"
fp.write_text(c.replace(old_text, new_text, 1))
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"
# === SECTION: todos (s03) ===
class TodoManager:
def __init__(self):
self.items = []
def update(self, items: list) -> str:
validated, ip = [], 0
for i, item in enumerate(items):
content = str(item.get("content", "")).strip()
status = str(item.get("status", "pending")).lower()
af = str(item.get("activeForm", "")).strip()
if not content: raise ValueError(f"Item {i}: content required")
if status not in ("pending", "in_progress", "completed"):
raise ValueError(f"Item {i}: invalid status '{status}'")
if not af: raise ValueError(f"Item {i}: activeForm required")
if status == "in_progress": ip += 1
validated.append({"content": content, "status": status, "activeForm": af})
if len(validated) > 20: raise ValueError("Max 20 todos")
if ip > 1: raise ValueError("Only one in_progress allowed")
self.items = validated
return self.render()
def render(self) -> str:
if not self.items: return "No todos."
lines = []
for item in self.items:
m = {"completed": "[x]", "in_progress": "[>]", "pending": "[ ]"}.get(item["status"], "[?]")
suffix = f" <- {item['activeForm']}" if item["status"] == "in_progress" else ""
lines.append(f"{m} {item['content']}{suffix}")
done = sum(1 for t in self.items if t["status"] == "completed")
lines.append(f"\n({done}/{len(self.items)} completed)")
return "\n".join(lines)
def has_open_items(self) -> bool:
return any(item.get("status") != "completed" for item in self.items)
# === SECTION: subagent (s04) ===
def run_subagent(prompt: str, agent_type: str = "Explore") -> str:
sub_tools = [
{"name": "bash", "description": "Run command.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}},
]
if agent_type != "Explore":
sub_tools += [
{"name": "write_file", "description": "Write file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Edit file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
]
sub_handlers = {
"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"]),
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
}
sub_msgs = [{"role": "user", "content": prompt}]
resp = None
for _ in range(30):
resp = client.messages.create(model=MODEL, messages=sub_msgs, tools=sub_tools, max_tokens=8000)
sub_msgs.append({"role": "assistant", "content": resp.content})
if resp.stop_reason != "tool_use":
break
results = []
for b in resp.content:
if b.type == "tool_use":
h = sub_handlers.get(b.name, lambda **kw: "Unknown tool")
results.append({"type": "tool_result", "tool_use_id": b.id, "content": str(h(**b.input))[:50000]})
sub_msgs.append({"role": "user", "content": results})
if resp:
return "".join(b.text for b in resp.content if hasattr(b, "text")) or "(no summary)"
return "(subagent failed)"
# === SECTION: skills (s05) ===
class SkillLoader:
def __init__(self, skills_dir: Path):
self.skills = {}
if skills_dir.exists():
for f in sorted(skills_dir.rglob("SKILL.md")):
text = f.read_text()
match = re.match(r"^---\n(.*?)\n---\n(.*)", text, re.DOTALL)
meta, body = {}, text
if match:
for line in match.group(1).strip().splitlines():
if ":" in line:
k, v = line.split(":", 1)
meta[k.strip()] = v.strip()
body = match.group(2).strip()
name = meta.get("name", f.parent.name)
self.skills[name] = {"meta": meta, "body": body}
def descriptions(self) -> str:
if not self.skills: return "(no skills)"
return "\n".join(f" - {n}: {s['meta'].get('description', '-')}" for n, s in self.skills.items())
def load(self, name: str) -> str:
s = self.skills.get(name)
if not s: return f"Error: Unknown skill '{name}'. Available: {', '.join(self.skills.keys())}"
return f"<skill name=\"{name}\">\n{s['body']}\n</skill>"
# === SECTION: compression (s06) ===
def estimate_tokens(messages: list) -> int:
return len(json.dumps(messages, default=str)) // 4
def microcompact(messages: list):
indices = []
for i, msg in enumerate(messages):
if msg["role"] == "user" and isinstance(msg.get("content"), list):
for part in msg["content"]:
if isinstance(part, dict) and part.get("type") == "tool_result":
indices.append(part)
if len(indices) <= 3:
return
for part in indices[:-3]:
if isinstance(part.get("content"), str) and len(part["content"]) > 100:
part["content"] = "[cleared]"
def auto_compact(messages: list) -> list:
TRANSCRIPT_DIR.mkdir(exist_ok=True)
path = TRANSCRIPT_DIR / f"transcript_{int(time.time())}.jsonl"
with open(path, "w") as f:
for msg in messages:
f.write(json.dumps(msg, default=str) + "\n")
conv_text = json.dumps(messages, default=str)[-80000:]
resp = client.messages.create(
model=MODEL,
messages=[{"role": "user", "content": f"Summarize for continuity:\n{conv_text}"}],
max_tokens=2000,
)
summary = resp.content[0].text
return [
{"role": "user", "content": f"[Compressed. Transcript: {path}]\n{summary}"},
]
# === SECTION: file_tasks (s07) ===
class TaskManager:
def __init__(self):
TASKS_DIR.mkdir(exist_ok=True)
def _next_id(self) -> int:
ids = [int(f.stem.split("_")[1]) for f in TASKS_DIR.glob("task_*.json")]
return max(ids, default=0) + 1
def _load(self, tid: int) -> dict:
p = TASKS_DIR / f"task_{tid}.json"
if not p.exists(): raise ValueError(f"Task {tid} not found")
return json.loads(p.read_text())
def _save(self, task: dict):
(TASKS_DIR / f"task_{task['id']}.json").write_text(json.dumps(task, indent=2))
def create(self, subject: str, description: str = "") -> str:
task = {"id": self._next_id(), "subject": subject, "description": description,
"status": "pending", "owner": None, "blockedBy": []}
self._save(task)
return json.dumps(task, indent=2)
def get(self, tid: int) -> str:
return json.dumps(self._load(tid), indent=2)
def update(self, tid: int, status: str = None,
add_blocked_by: list = None, remove_blocked_by: list = None) -> str:
task = self._load(tid)
if status:
task["status"] = status
if status == "completed":
for f in TASKS_DIR.glob("task_*.json"):
t = json.loads(f.read_text())
if tid in t.get("blockedBy", []):
t["blockedBy"].remove(tid)
self._save(t)
if status == "deleted":
(TASKS_DIR / f"task_{tid}.json").unlink(missing_ok=True)
return f"Task {tid} deleted"
if add_blocked_by:
task["blockedBy"] = list(set(task["blockedBy"] + add_blocked_by))
if remove_blocked_by:
task["blockedBy"] = [x for x in task["blockedBy"] if x not in remove_blocked_by]
self._save(task)
return json.dumps(task, indent=2)
def list_all(self) -> str:
tasks = [json.loads(f.read_text()) for f in sorted(TASKS_DIR.glob("task_*.json"))]
if not tasks: return "No tasks."
lines = []
for t in tasks:
m = {"pending": "[ ]", "in_progress": "[>]", "completed": "[x]"}.get(t["status"], "[?]")
owner = f" @{t['owner']}" if t.get("owner") else ""
blocked = f" (blocked by: {t['blockedBy']})" if t.get("blockedBy") else ""
lines.append(f"{m} #{t['id']}: {t['subject']}{owner}{blocked}")
return "\n".join(lines)
def claim(self, tid: int, owner: str) -> str:
task = self._load(tid)
task["owner"] = owner
task["status"] = "in_progress"
self._save(task)
return f"Claimed task #{tid} for {owner}"
# === SECTION: background (s08) ===
class BackgroundManager:
def __init__(self):
self.tasks = {}
self.notifications = Queue()
def run(self, command: str, timeout: int = 120) -> str:
tid = str(uuid.uuid4())[:8]
self.tasks[tid] = {"status": "running", "command": command, "result": None}
threading.Thread(target=self._exec, args=(tid, command, timeout), daemon=True).start()
return f"Background task {tid} started: {command[:80]}"
def _exec(self, tid: str, command: str, timeout: int):
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=timeout)
output = (r.stdout + r.stderr).strip()[:50000]
self.tasks[tid].update({"status": "completed", "result": output or "(no output)"})
except Exception as e:
self.tasks[tid].update({"status": "error", "result": str(e)})
self.notifications.put({"task_id": tid, "status": self.tasks[tid]["status"],
"result": self.tasks[tid]["result"][:500]})
def check(self, tid: str = None) -> str:
if tid:
t = self.tasks.get(tid)
return f"[{t['status']}] {t.get('result') or '(running)'}" if t else f"Unknown: {tid}"
return "\n".join(f"{k}: [{v['status']}] {v['command'][:60]}" for k, v in self.tasks.items()) or "No bg tasks."
def drain(self) -> list:
notifs = []
while not self.notifications.empty():
notifs.append(self.notifications.get_nowait())
return notifs
# === SECTION: messaging (s09) ===
class MessageBus:
def __init__(self):
INBOX_DIR.mkdir(parents=True, exist_ok=True)
def send(self, sender: str, to: str, content: str,
msg_type: str = "message", extra: dict = None) -> str:
msg = {"type": msg_type, "from": sender, "content": content,
"timestamp": time.time()}
if extra: msg.update(extra)
with open(INBOX_DIR / f"{to}.jsonl", "a") as f:
f.write(json.dumps(msg) + "\n")
return f"Sent {msg_type} to {to}"
def read_inbox(self, name: str) -> list:
path = INBOX_DIR / f"{name}.jsonl"
if not path.exists(): return []
msgs = [json.loads(l) for l in path.read_text().strip().splitlines() if l]
path.write_text("")
return msgs
def broadcast(self, sender: str, content: str, names: list) -> str:
count = 0
for n in names:
if n != sender:
self.send(sender, n, content, "broadcast")
count += 1
return f"Broadcast to {count} teammates"
# === SECTION: shutdown + plan tracking (s10) ===
shutdown_requests = {}
plan_requests = {}
# === SECTION: team (s09/s11) ===
class TeammateManager:
def __init__(self, bus: MessageBus, task_mgr: TaskManager):
TEAM_DIR.mkdir(exist_ok=True)
self.bus = bus
self.task_mgr = task_mgr
self.config_path = TEAM_DIR / "config.json"
self.config = self._load()
self.threads = {}
def _load(self) -> dict:
if self.config_path.exists():
return json.loads(self.config_path.read_text())
return {"team_name": "default", "members": []}
def _save(self):
self.config_path.write_text(json.dumps(self.config, indent=2))
def _find(self, name: str) -> dict:
for m in self.config["members"]:
if m["name"] == name: return m
return None
def spawn(self, name: str, role: str, prompt: str) -> str:
member = self._find(name)
if member:
if member["status"] not in ("idle", "shutdown"):
return f"Error: '{name}' is currently {member['status']}"
member["status"] = "working"
member["role"] = role
else:
member = {"name": name, "role": role, "status": "working"}
self.config["members"].append(member)
self._save()
threading.Thread(target=self._loop, args=(name, role, prompt), daemon=True).start()
return f"Spawned '{name}' (role: {role})"
def _set_status(self, name: str, status: str):
member = self._find(name)
if member:
member["status"] = status
self._save()
def _loop(self, name: str, role: str, prompt: str):
team_name = self.config["team_name"]
sys_prompt = (f"You are '{name}', role: {role}, team: {team_name}, at {WORKDIR}. "
f"Use idle when done with current work. You may auto-claim tasks.")
messages = [{"role": "user", "content": prompt}]
tools = [
{"name": "bash", "description": "Run command.", "input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file.", "input_schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write file.", "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Edit file.", "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "send_message", "description": "Send message.", "input_schema": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}}, "required": ["to", "content"]}},
{"name": "idle", "description": "Signal no more work.", "input_schema": {"type": "object", "properties": {}}},
{"name": "claim_task", "description": "Claim task by ID.", "input_schema": {"type": "object", "properties": {"task_id": {"type": "integer"}}, "required": ["task_id"]}},
]
while True:
# -- WORK PHASE --
for _ in range(50):
inbox = self.bus.read_inbox(name)
for msg in inbox:
if msg.get("type") == "shutdown_request":
self._set_status(name, "shutdown")
return
messages.append({"role": "user", "content": json.dumps(msg)})
try:
response = client.messages.create(
model=MODEL, system=sys_prompt, messages=messages,
tools=tools, max_tokens=8000)
except Exception:
self._set_status(name, "shutdown")
return
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
break
results = []
idle_requested = False
for block in response.content:
if block.type == "tool_use":
if block.name == "idle":
idle_requested = True
output = "Entering idle phase."
elif block.name == "claim_task":
output = self.task_mgr.claim(block.input["task_id"], name)
elif block.name == "send_message":
output = self.bus.send(name, block.input["to"], block.input["content"])
else:
dispatch = {"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"]),
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"])}
output = dispatch.get(block.name, lambda **kw: "Unknown")(**block.input)
print(f" [{name}] {block.name}: {str(output)[:120]}")
results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)})
messages.append({"role": "user", "content": results})
if idle_requested:
break
# -- IDLE PHASE: poll for messages and unclaimed tasks --
self._set_status(name, "idle")
resume = False
for _ in range(IDLE_TIMEOUT // max(POLL_INTERVAL, 1)):
time.sleep(POLL_INTERVAL)
inbox = self.bus.read_inbox(name)
if inbox:
for msg in inbox:
if msg.get("type") == "shutdown_request":
self._set_status(name, "shutdown")
return
messages.append({"role": "user", "content": json.dumps(msg)})
resume = True
break
unclaimed = []
for f in sorted(TASKS_DIR.glob("task_*.json")):
t = json.loads(f.read_text())
if t.get("status") == "pending" and not t.get("owner") and not t.get("blockedBy"):
unclaimed.append(t)
if unclaimed:
task = unclaimed[0]
self.task_mgr.claim(task["id"], name)
# Identity re-injection for compressed contexts
if len(messages) <= 3:
messages.insert(0, {"role": "user", "content":
f"<identity>You are '{name}', role: {role}, team: {team_name}.</identity>"})
messages.insert(1, {"role": "assistant", "content": f"I am {name}. Continuing."})
messages.append({"role": "user", "content":
f"<auto-claimed>Task #{task['id']}: {task['subject']}\n{task.get('description', '')}</auto-claimed>"})
messages.append({"role": "assistant", "content": f"Claimed task #{task['id']}. Working on it."})
resume = True
break
if not resume:
self._set_status(name, "shutdown")
return
self._set_status(name, "working")
def list_all(self) -> str:
if not self.config["members"]: return "No teammates."
lines = [f"Team: {self.config['team_name']}"]
for m in self.config["members"]:
lines.append(f" {m['name']} ({m['role']}): {m['status']}")
return "\n".join(lines)
def member_names(self) -> list:
return [m["name"] for m in self.config["members"]]
# === SECTION: global_instances ===
TODO = TodoManager()
SKILLS = SkillLoader(SKILLS_DIR)
TASK_MGR = TaskManager()
BG = BackgroundManager()
BUS = MessageBus()
TEAM = TeammateManager(BUS, TASK_MGR)
# === SECTION: system_prompt ===
SYSTEM = f"""You are a coding agent at {WORKDIR}. Use tools to solve tasks.
Prefer task_create/task_update/task_list for multi-step work. Use TodoWrite for short checklists.
Use task for subagent delegation. Use load_skill for specialized knowledge.
Skills: {SKILLS.descriptions()}"""
# === SECTION: shutdown_protocol (s10) ===
def handle_shutdown_request(teammate: str) -> str:
req_id = str(uuid.uuid4())[:8]
shutdown_requests[req_id] = {"target": teammate, "status": "pending"}
BUS.send("lead", teammate, "Please shut down.", "shutdown_request", {"request_id": req_id})
return f"Shutdown request {req_id} sent to '{teammate}'"
# === SECTION: plan_approval (s10) ===
def handle_plan_review(request_id: str, approve: bool, feedback: str = "") -> str:
req = plan_requests.get(request_id)
if not req: return f"Error: Unknown plan request_id '{request_id}'"
req["status"] = "approved" if approve else "rejected"
BUS.send("lead", req["from"], feedback, "plan_approval_response",
{"request_id": request_id, "approve": approve, "feedback": feedback})
return f"Plan {req['status']} for '{req['from']}'"
# === SECTION: tool_dispatch (s02) ===
TOOL_HANDLERS = {
"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
"TodoWrite": lambda **kw: TODO.update(kw["items"]),
"task": lambda **kw: run_subagent(kw["prompt"], kw.get("agent_type", "Explore")),
"load_skill": lambda **kw: SKILLS.load(kw["name"]),
"compress": lambda **kw: "Compressing...",
"background_run": lambda **kw: BG.run(kw["command"], kw.get("timeout", 120)),
"check_background": lambda **kw: BG.check(kw.get("task_id")),
"task_create": lambda **kw: TASK_MGR.create(kw["subject"], kw.get("description", "")),
"task_get": lambda **kw: TASK_MGR.get(kw["task_id"]),
"task_update": lambda **kw: TASK_MGR.update(kw["task_id"], kw.get("status"), kw.get("add_blocked_by"), kw.get("remove_blocked_by")),
"task_list": lambda **kw: TASK_MGR.list_all(),
"spawn_teammate": lambda **kw: TEAM.spawn(kw["name"], kw["role"], kw["prompt"]),
"list_teammates": lambda **kw: TEAM.list_all(),
"send_message": lambda **kw: BUS.send("lead", kw["to"], kw["content"], kw.get("msg_type", "message")),
"read_inbox": lambda **kw: json.dumps(BUS.read_inbox("lead"), indent=2),
"broadcast": lambda **kw: BUS.broadcast("lead", kw["content"], TEAM.member_names()),
"shutdown_request": lambda **kw: handle_shutdown_request(kw["teammate"]),
"plan_approval": lambda **kw: handle_plan_review(kw["request_id"], kw["approve"], kw.get("feedback", "")),
"idle": lambda **kw: "Lead does not idle.",
"claim_task": lambda **kw: TASK_MGR.claim(kw["task_id"], "lead"),
}
TOOLS = [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write content to file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "TodoWrite", "description": "Update task tracking list.",
"input_schema": {"type": "object", "properties": {"items": {"type": "array", "items": {"type": "object", "properties": {"content": {"type": "string"}, "status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}, "activeForm": {"type": "string"}}, "required": ["content", "status", "activeForm"]}}}, "required": ["items"]}},
{"name": "task", "description": "Spawn a subagent for isolated exploration or work.",
"input_schema": {"type": "object", "properties": {"prompt": {"type": "string"}, "agent_type": {"type": "string", "enum": ["Explore", "general-purpose"]}}, "required": ["prompt"]}},
{"name": "load_skill", "description": "Load specialized knowledge by name.",
"input_schema": {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}},
{"name": "compress", "description": "Manually compress conversation context.",
"input_schema": {"type": "object", "properties": {}}},
{"name": "background_run", "description": "Run command in background thread.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}, "timeout": {"type": "integer"}}, "required": ["command"]}},
{"name": "check_background", "description": "Check background task status.",
"input_schema": {"type": "object", "properties": {"task_id": {"type": "string"}}}},
{"name": "task_create", "description": "Create a persistent file task.",
"input_schema": {"type": "object", "properties": {"subject": {"type": "string"}, "description": {"type": "string"}}, "required": ["subject"]}},
{"name": "task_get", "description": "Get task details by ID.",
"input_schema": {"type": "object", "properties": {"task_id": {"type": "integer"}}, "required": ["task_id"]}},
{"name": "task_update", "description": "Update task status or dependencies.",
"input_schema": {"type": "object", "properties": {"task_id": {"type": "integer"}, "status": {"type": "string", "enum": ["pending", "in_progress", "completed", "deleted"]}, "add_blocked_by": {"type": "array", "items": {"type": "integer"}}, "remove_blocked_by": {"type": "array", "items": {"type": "integer"}}}, "required": ["task_id"]}},
{"name": "task_list", "description": "List all tasks.",
"input_schema": {"type": "object", "properties": {}}},
{"name": "spawn_teammate", "description": "Spawn a persistent autonomous teammate.",
"input_schema": {"type": "object", "properties": {"name": {"type": "string"}, "role": {"type": "string"}, "prompt": {"type": "string"}}, "required": ["name", "role", "prompt"]}},
{"name": "list_teammates", "description": "List all teammates.",
"input_schema": {"type": "object", "properties": {}}},
{"name": "send_message", "description": "Send a message to a teammate.",
"input_schema": {"type": "object", "properties": {"to": {"type": "string"}, "content": {"type": "string"}, "msg_type": {"type": "string", "enum": list(VALID_MSG_TYPES)}}, "required": ["to", "content"]}},
{"name": "read_inbox", "description": "Read and drain the lead's inbox.",
"input_schema": {"type": "object", "properties": {}}},
{"name": "broadcast", "description": "Send message to all teammates.",
"input_schema": {"type": "object", "properties": {"content": {"type": "string"}}, "required": ["content"]}},
{"name": "shutdown_request", "description": "Request a teammate to shut down.",
"input_schema": {"type": "object", "properties": {"teammate": {"type": "string"}}, "required": ["teammate"]}},
{"name": "plan_approval", "description": "Approve or reject a teammate's plan.",
"input_schema": {"type": "object", "properties": {"request_id": {"type": "string"}, "approve": {"type": "boolean"}, "feedback": {"type": "string"}}, "required": ["request_id", "approve"]}},
{"name": "idle", "description": "Enter idle state.",
"input_schema": {"type": "object", "properties": {}}},
{"name": "claim_task", "description": "Claim a task from the board.",
"input_schema": {"type": "object", "properties": {"task_id": {"type": "integer"}}, "required": ["task_id"]}},
]
# === SECTION: agent_loop ===
def agent_loop(messages: list):
rounds_without_todo = 0
while True:
# s06: compression pipeline
microcompact(messages)
if estimate_tokens(messages) > TOKEN_THRESHOLD:
print("[auto-compact triggered]")
messages[:] = auto_compact(messages)
# s08: drain background notifications
notifs = BG.drain()
if notifs:
txt = "\n".join(f"[bg:{n['task_id']}] {n['status']}: {n['result']}" for n in notifs)
messages.append({"role": "user", "content": f"<background-results>\n{txt}\n</background-results>"})
# s10: check lead inbox
inbox = BUS.read_inbox("lead")
if inbox:
messages.append({"role": "user", "content": f"<inbox>{json.dumps(inbox, indent=2)}</inbox>"})
# LLM call
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
# Tool execution
results = []
used_todo = False
manual_compress = False
for block in response.content:
if block.type == "tool_use":
if block.name == "compress":
manual_compress = True
handler = TOOL_HANDLERS.get(block.name)
try:
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
except Exception as e:
output = f"Error: {e}"
print(f"> {block.name}:")
print(str(output)[:200])
results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)})
if block.name == "TodoWrite":
used_todo = True
# s03: nag reminder (only when todo workflow is active)
rounds_without_todo = 0 if used_todo else rounds_without_todo + 1
if TODO.has_open_items() and rounds_without_todo >= 3:
results.append({"type": "text", "text": "<reminder>Update your todos.</reminder>"})
messages.append({"role": "user", "content": results})
# s06: manual compress
if manual_compress:
print("[manual compact]")
messages[:] = auto_compact(messages)
return
# === SECTION: repl ===
if __name__ == "__main__":
history = []
while True:
try:
query = input("\033[36ms_full >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
if query.strip() == "/compact":
if history:
print("[manual compact via /compact]")
history[:] = auto_compact(history)
continue
if query.strip() == "/tasks":
print(TASK_MGR.list_all())
continue
if query.strip() == "/team":
print(TEAM.list_all())
continue
if query.strip() == "/inbox":
print(json.dumps(BUS.read_inbox("lead"), indent=2))
continue
history.append({"role": "user", "content": query})
agent_loop(history)
response_content = history[-1]["content"]
if isinstance(response_content, list):
for block in response_content:
if hasattr(block, "text"):
print(block.text)
print()
+116
View File
@@ -0,0 +1,116 @@
# s01: The Agent Loop
`[ s01 ] > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"One loop & Bash is all you need"* -- one tool + one loop = an agent.
>
> **Harness layer**: The loop -- the model's first connection to the real world.
## Problem
A language model can reason about code, but it can't *touch* the real world -- can't read files, run tests, or check errors. Without a loop, every tool call requires you to manually copy-paste results back. You become the loop.
## Solution
```
+--------+ +-------+ +---------+
| User | ---> | LLM | ---> | Tool |
| prompt | | | | execute |
+--------+ +---+---+ +----+----+
^ |
| tool_result |
+----------------+
(loop until stop_reason != "tool_use")
```
One exit condition controls the entire flow. The loop runs until the model stops calling tools.
## How It Works
1. User prompt becomes the first message.
```python
messages.append({"role": "user", "content": query})
```
2. Send messages + tool definitions to the LLM.
```python
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
```
3. Append the assistant response. Check `stop_reason` -- if the model didn't call a tool, we're done.
```python
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
```
4. Execute each tool call, collect results, append as a user message. Loop back to step 2.
```python
results = []
for block in response.content:
if block.type == "tool_use":
output = run_bash(block.input["command"])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
```
Assembled into one function:
```python
def agent_loop(query):
messages = [{"role": "user", "content": query}]
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
for block in response.content:
if block.type == "tool_use":
output = run_bash(block.input["command"])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
```
That's the entire agent in under 30 lines. Everything else in this course layers on top -- without changing the loop.
## What Changed
| Component | Before | After |
|---------------|------------|--------------------------------|
| Agent loop | (none) | `while True` + stop_reason |
| Tools | (none) | `bash` (one tool) |
| Messages | (none) | Accumulating list |
| Control flow | (none) | `stop_reason != "tool_use"` |
## Try It
```sh
cd learn-claude-code
python agents/s01_agent_loop.py
```
1. `Create a file called hello.py that prints "Hello, World!"`
2. `List all Python files in this directory`
3. `What is the current git branch?`
4. `Create a directory called test_output and write 3 files in it`
+99
View File
@@ -0,0 +1,99 @@
# s02: Tool Use
`s01 > [ s02 ] > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"Adding a tool means adding one handler"* -- the loop stays the same; new tools register into the dispatch map.
>
> **Harness layer**: Tool dispatch -- expanding what the model can reach.
## Problem
With only `bash`, the agent shells out for everything. `cat` truncates unpredictably, `sed` fails on special characters, and every bash call is an unconstrained security surface. Dedicated tools like `read_file` and `write_file` let you enforce path sandboxing at the tool level.
The key insight: adding tools does not require changing the loop.
## Solution
```
+--------+ +-------+ +------------------+
| User | ---> | LLM | ---> | Tool Dispatch |
| prompt | | | | { |
+--------+ +---+---+ | bash: run_bash |
^ | read: run_read |
| | write: run_wr |
+-----------+ edit: run_edit |
tool_result | } |
+------------------+
The dispatch map is a dict: {tool_name: handler_function}.
One lookup replaces any if/elif chain.
```
## How It Works
1. Each tool gets a handler function. Path sandboxing prevents workspace escape.
```python
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def run_read(path: str, limit: int = None) -> str:
text = safe_path(path).read_text()
lines = text.splitlines()
if limit and limit < len(lines):
lines = lines[:limit]
return "\n".join(lines)[:50000]
```
2. The dispatch map links tool names to handlers.
```python
TOOL_HANDLERS = {
"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"],
kw["new_text"]),
}
```
3. In the loop, look up the handler by name. The loop body itself is unchanged from s01.
```python
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler \
else f"Unknown tool: {block.name}"
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
```
Add a tool = add a handler + add a schema entry. The loop never changes.
## What Changed From s01
| Component | Before (s01) | After (s02) |
|----------------|--------------------|----------------------------|
| Tools | 1 (bash only) | 4 (bash, read, write, edit)|
| Dispatch | Hardcoded bash call | `TOOL_HANDLERS` dict |
| Path safety | None | `safe_path()` sandbox |
| Agent loop | Unchanged | Unchanged |
## Try It
```sh
cd learn-claude-code
python agents/s02_tool_use.py
```
1. `Read the file requirements.txt`
2. `Create a file called greet.py with a greet(name) function`
3. `Edit greet.py to add a docstring to the function`
4. `Read greet.py to verify the edit worked`
+96
View File
@@ -0,0 +1,96 @@
# s03: TodoWrite
`s01 > s02 > [ s03 ] > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"An agent without a plan drifts"* -- list the steps first, then execute.
>
> **Harness layer**: Planning -- keeping the model on course without scripting the route.
## Problem
On multi-step tasks, the model loses track. It repeats work, skips steps, or wanders off. Long conversations make this worse -- the system prompt fades as tool results fill the context. A 10-step refactoring might complete steps 1-3, then the model starts improvising because it forgot steps 4-10.
## Solution
```
+--------+ +-------+ +---------+
| User | ---> | LLM | ---> | Tools |
| prompt | | | | + todo |
+--------+ +---+---+ +----+----+
^ |
| tool_result |
+----------------+
|
+-----------+-----------+
| TodoManager state |
| [ ] task A |
| [>] task B <- doing |
| [x] task C |
+-----------------------+
|
if rounds_since_todo >= 3:
inject <reminder> into tool_result
```
## How It Works
1. TodoManager stores items with statuses. Only one item can be `in_progress` at a time.
```python
class TodoManager:
def update(self, items: list) -> str:
validated, in_progress_count = [], 0
for item in items:
status = item.get("status", "pending")
if status == "in_progress":
in_progress_count += 1
validated.append({"id": item["id"], "text": item["text"],
"status": status})
if in_progress_count > 1:
raise ValueError("Only one task can be in_progress")
self.items = validated
return self.render()
```
2. The `todo` tool goes into the dispatch map like any other tool.
```python
TOOL_HANDLERS = {
# ...base tools...
"todo": lambda **kw: TODO.update(kw["items"]),
}
```
3. A nag reminder injects a nudge if the model goes 3+ rounds without calling `todo`.
```python
if rounds_since_todo >= 3 and messages:
last = messages[-1]
if last["role"] == "user" and isinstance(last.get("content"), list):
last["content"].insert(0, {
"type": "text",
"text": "<reminder>Update your todos.</reminder>",
})
```
The "one in_progress at a time" constraint forces sequential focus. The nag reminder creates accountability.
## What Changed From s02
| Component | Before (s02) | After (s03) |
|----------------|------------------|----------------------------|
| Tools | 4 | 5 (+todo) |
| Planning | None | TodoManager with statuses |
| Nag injection | None | `<reminder>` after 3 rounds|
| Agent loop | Simple dispatch | + rounds_since_todo counter|
## Try It
```sh
cd learn-claude-code
python agents/s03_todo_write.py
```
1. `Refactor the file hello.py: add type hints, docstrings, and a main guard`
2. `Create a Python package with __init__.py, utils.py, and tests/test_utils.py`
3. `Review all Python files and fix any style issues`
+94
View File
@@ -0,0 +1,94 @@
# s04: Subagents
`s01 > s02 > s03 > [ s04 ] > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"Break big tasks down; each subtask gets a clean context"* -- subagents use independent messages[], keeping the main conversation clean.
>
> **Harness layer**: Context isolation -- protecting the model's clarity of thought.
## Problem
As the agent works, its messages array grows. Every file read, every bash output stays in context permanently. "What testing framework does this project use?" might require reading 5 files, but the parent only needs the answer: "pytest."
## Solution
```
Parent agent Subagent
+------------------+ +------------------+
| messages=[...] | | messages=[] | <-- fresh
| | dispatch | |
| tool: task | ----------> | while tool_use: |
| prompt="..." | | call tools |
| | summary | append results |
| result = "..." | <---------- | return last text |
+------------------+ +------------------+
Parent context stays clean. Subagent context is discarded.
```
## How It Works
1. The parent gets a `task` tool. The child gets all base tools except `task` (no recursive spawning).
```python
PARENT_TOOLS = CHILD_TOOLS + [
{"name": "task",
"description": "Spawn a subagent with fresh context.",
"input_schema": {
"type": "object",
"properties": {"prompt": {"type": "string"}},
"required": ["prompt"],
}},
]
```
2. The subagent starts with `messages=[]` and runs its own loop. Only the final text returns to the parent.
```python
def run_subagent(prompt: str) -> str:
sub_messages = [{"role": "user", "content": prompt}]
for _ in range(30): # safety limit
response = client.messages.create(
model=MODEL, system=SUBAGENT_SYSTEM,
messages=sub_messages,
tools=CHILD_TOOLS, max_tokens=8000,
)
sub_messages.append({"role": "assistant",
"content": response.content})
if response.stop_reason != "tool_use":
break
results = []
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input)
results.append({"type": "tool_result",
"tool_use_id": block.id,
"content": str(output)[:50000]})
sub_messages.append({"role": "user", "content": results})
return "".join(
b.text for b in response.content if hasattr(b, "text")
) or "(no summary)"
```
The child's entire message history (possibly 30+ tool calls) is discarded. The parent receives a one-paragraph summary as a normal `tool_result`.
## What Changed From s03
| Component | Before (s03) | After (s04) |
|----------------|------------------|---------------------------|
| Tools | 5 | 5 (base) + task (parent) |
| Context | Single shared | Parent + child isolation |
| Subagent | None | `run_subagent()` function |
| Return value | N/A | Summary text only |
## Try It
```sh
cd learn-claude-code
python agents/s04_subagent.py
```
1. `Use a subtask to find what testing framework this project uses`
2. `Delegate: read all .py files and summarize what each one does`
3. `Use a task to create a new module, then verify it from here`
+108
View File
@@ -0,0 +1,108 @@
# s05: Skills
`s01 > s02 > s03 > s04 > [ s05 ] > s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"Load knowledge when you need it, not upfront"* -- inject via tool_result, not the system prompt.
>
> **Harness layer**: On-demand knowledge -- domain expertise, loaded when the model asks.
## Problem
You want the agent to follow domain-specific workflows: git conventions, testing patterns, code review checklists. Putting everything in the system prompt wastes tokens on unused skills. 10 skills at 2000 tokens each = 20,000 tokens, most of which are irrelevant to any given task.
## Solution
```
System prompt (Layer 1 -- always present):
+--------------------------------------+
| You are a coding agent. |
| Skills available: |
| - git: Git workflow helpers | ~100 tokens/skill
| - test: Testing best practices |
+--------------------------------------+
When model calls load_skill("git"):
+--------------------------------------+
| tool_result (Layer 2 -- on demand): |
| <skill name="git"> |
| Full git workflow instructions... | ~2000 tokens
| Step 1: ... |
| </skill> |
+--------------------------------------+
```
Layer 1: skill *names* in system prompt (cheap). Layer 2: full *body* via tool_result (on demand).
## How It Works
1. Each skill is a directory containing a `SKILL.md` with YAML frontmatter.
```
skills/
pdf/
SKILL.md # ---\n name: pdf\n description: Process PDF files\n ---\n ...
code-review/
SKILL.md # ---\n name: code-review\n description: Review code\n ---\n ...
```
2. SkillLoader scans for `SKILL.md` files, uses the directory name as the skill identifier.
```python
class SkillLoader:
def __init__(self, skills_dir: Path):
self.skills = {}
for f in sorted(skills_dir.rglob("SKILL.md")):
text = f.read_text()
meta, body = self._parse_frontmatter(text)
name = meta.get("name", f.parent.name)
self.skills[name] = {"meta": meta, "body": body}
def get_descriptions(self) -> str:
lines = []
for name, skill in self.skills.items():
desc = skill["meta"].get("description", "")
lines.append(f" - {name}: {desc}")
return "\n".join(lines)
def get_content(self, name: str) -> str:
skill = self.skills.get(name)
if not skill:
return f"Error: Unknown skill '{name}'."
return f"<skill name=\"{name}\">\n{skill['body']}\n</skill>"
```
3. Layer 1 goes into the system prompt. Layer 2 is just another tool handler.
```python
SYSTEM = f"""You are a coding agent at {WORKDIR}.
Skills available:
{SKILL_LOADER.get_descriptions()}"""
TOOL_HANDLERS = {
# ...base tools...
"load_skill": lambda **kw: SKILL_LOADER.get_content(kw["name"]),
}
```
The model learns what skills exist (cheap) and loads them when relevant (expensive).
## What Changed From s04
| Component | Before (s04) | After (s05) |
|----------------|------------------|----------------------------|
| Tools | 5 (base + task) | 5 (base + load_skill) |
| System prompt | Static string | + skill descriptions |
| Knowledge | None | skills/\*/SKILL.md files |
| Injection | None | Two-layer (system + result)|
## Try It
```sh
cd learn-claude-code
python agents/s05_skill_loading.py
```
1. `What skills are available?`
2. `Load the agent-builder skill and follow its instructions`
3. `I need to do a code review -- load the relevant skill first`
4. `Build an MCP server using the mcp-builder skill`
+124
View File
@@ -0,0 +1,124 @@
# s06: Context Compact
`s01 > s02 > s03 > s04 > s05 > [ s06 ] | s07 > s08 > s09 > s10 > s11 > s12`
> *"Context will fill up; you need a way to make room"* -- three-layer compression strategy for infinite sessions.
>
> **Harness layer**: Compression -- clean memory for infinite sessions.
## Problem
The context window is finite. A single `read_file` on a 1000-line file costs ~4000 tokens. After reading 30 files and running 20 bash commands, you hit 100,000+ tokens. The agent cannot work on large codebases without compression.
## Solution
Three layers, increasing in aggressiveness:
```
Every turn:
+------------------+
| Tool call result |
+------------------+
|
v
[Layer 1: micro_compact] (silent, every turn)
Replace tool_result > 3 turns old
with "[Previous: used {tool_name}]"
|
v
[Check: tokens > 50000?]
| |
no yes
| |
v v
continue [Layer 2: auto_compact]
Save transcript to .transcripts/
LLM summarizes conversation.
Replace all messages with [summary].
|
v
[Layer 3: compact tool]
Model calls compact explicitly.
Same summarization as auto_compact.
```
## How It Works
1. **Layer 1 -- micro_compact**: Before each LLM call, replace old tool results with placeholders.
```python
def micro_compact(messages: list) -> list:
tool_results = []
for i, msg in enumerate(messages):
if msg["role"] == "user" and isinstance(msg.get("content"), list):
for j, part in enumerate(msg["content"]):
if isinstance(part, dict) and part.get("type") == "tool_result":
tool_results.append((i, j, part))
if len(tool_results) <= KEEP_RECENT:
return messages
for _, _, part in tool_results[:-KEEP_RECENT]:
if len(part.get("content", "")) > 100:
part["content"] = f"[Previous: used {tool_name}]"
return messages
```
2. **Layer 2 -- auto_compact**: When tokens exceed threshold, save full transcript to disk, then ask the LLM to summarize.
```python
def auto_compact(messages: list) -> list:
# Save transcript for recovery
transcript_path = TRANSCRIPT_DIR / f"transcript_{int(time.time())}.jsonl"
with open(transcript_path, "w") as f:
for msg in messages:
f.write(json.dumps(msg, default=str) + "\n")
# LLM summarizes
response = client.messages.create(
model=MODEL,
messages=[{"role": "user", "content":
"Summarize this conversation for continuity..."
+ json.dumps(messages, default=str)[:80000]}],
max_tokens=2000,
)
return [
{"role": "user", "content": f"[Compressed]\n\n{response.content[0].text}"},
]
```
3. **Layer 3 -- manual compact**: The `compact` tool triggers the same summarization on demand.
4. The loop integrates all three:
```python
def agent_loop(messages: list):
while True:
micro_compact(messages) # Layer 1
if estimate_tokens(messages) > THRESHOLD:
messages[:] = auto_compact(messages) # Layer 2
response = client.messages.create(...)
# ... tool execution ...
if manual_compact:
messages[:] = auto_compact(messages) # Layer 3
```
Transcripts preserve full history on disk. Nothing is truly lost -- just moved out of active context.
## What Changed From s05
| Component | Before (s05) | After (s06) |
|----------------|------------------|----------------------------|
| Tools | 5 | 5 (base + compact) |
| Context mgmt | None | Three-layer compression |
| Micro-compact | None | Old results -> placeholders|
| Auto-compact | None | Token threshold trigger |
| Transcripts | None | Saved to .transcripts/ |
## Try It
```sh
cd learn-claude-code
python agents/s06_context_compact.py
```
1. `Read every Python file in the agents/ directory one by one` (watch micro-compact replace old results)
2. `Keep reading files until compression triggers automatically`
3. `Use the compact tool to manually compress the conversation`
+131
View File
@@ -0,0 +1,131 @@
# s07: Task System
`s01 > s02 > s03 > s04 > s05 > s06 | [ s07 ] > s08 > s09 > s10 > s11 > s12`
> *"Break big goals into small tasks, order them, persist to disk"* -- a file-based task graph with dependencies, laying the foundation for multi-agent collaboration.
>
> **Harness layer**: Persistent tasks -- goals that outlive any single conversation.
## Problem
s03's TodoManager is a flat checklist in memory: no ordering, no dependencies, no status beyond done-or-not. Real goals have structure -- task B depends on task A, tasks C and D can run in parallel, task E waits for both C and D.
Without explicit relationships, the agent can't tell what's ready, what's blocked, or what can run concurrently. And because the list lives only in memory, context compression (s06) wipes it clean.
## Solution
Promote the checklist into a **task graph** persisted to disk. Each task is a JSON file with status, dependencies (`blockedBy`). The graph answers three questions at any moment:
- **What's ready?** -- tasks with `pending` status and empty `blockedBy`.
- **What's blocked?** -- tasks waiting on unfinished dependencies.
- **What's done?** -- `completed` tasks, whose completion automatically unblocks dependents.
```
.tasks/
task_1.json {"id":1, "status":"completed"}
task_2.json {"id":2, "blockedBy":[1], "status":"pending"}
task_3.json {"id":3, "blockedBy":[1], "status":"pending"}
task_4.json {"id":4, "blockedBy":[2,3], "status":"pending"}
Task graph (DAG):
+----------+
+--> | task 2 | --+
| | pending | |
+----------+ +----------+ +--> +----------+
| task 1 | | task 4 |
| completed| --> +----------+ +--> | blocked |
+----------+ | task 3 | --+ +----------+
| pending |
+----------+
Ordering: task 1 must finish before 2 and 3
Parallelism: tasks 2 and 3 can run at the same time
Dependencies: task 4 waits for both 2 and 3
Status: pending -> in_progress -> completed
```
This task graph becomes the coordination backbone for everything after s07: background execution (s08), multi-agent teams (s09+), and worktree isolation (s12) all read from and write to this same structure.
## How It Works
1. **TaskManager**: one JSON file per task, CRUD with dependency graph.
```python
class TaskManager:
def __init__(self, tasks_dir: Path):
self.dir = tasks_dir
self.dir.mkdir(exist_ok=True)
self._next_id = self._max_id() + 1
def create(self, subject, description=""):
task = {"id": self._next_id, "subject": subject,
"status": "pending", "blockedBy": [],
"owner": ""}
self._save(task)
self._next_id += 1
return json.dumps(task, indent=2)
```
2. **Dependency resolution**: completing a task clears its ID from every other task's `blockedBy` list, automatically unblocking dependents.
```python
def _clear_dependency(self, completed_id):
for f in self.dir.glob("task_*.json"):
task = json.loads(f.read_text())
if completed_id in task.get("blockedBy", []):
task["blockedBy"].remove(completed_id)
self._save(task)
```
3. **Status + dependency wiring**: `update` handles transitions and dependency edges.
```python
def update(self, task_id, status=None,
add_blocked_by=None, remove_blocked_by=None):
task = self._load(task_id)
if status:
task["status"] = status
if status == "completed":
self._clear_dependency(task_id)
if add_blocked_by:
task["blockedBy"] = list(set(task["blockedBy"] + add_blocked_by))
if remove_blocked_by:
task["blockedBy"] = [x for x in task["blockedBy"] if x not in remove_blocked_by]
self._save(task)
```
4. Four task tools go into the dispatch map.
```python
TOOL_HANDLERS = {
# ...base tools...
"task_create": lambda **kw: TASKS.create(kw["subject"]),
"task_update": lambda **kw: TASKS.update(kw["task_id"], kw.get("status")),
"task_list": lambda **kw: TASKS.list_all(),
"task_get": lambda **kw: TASKS.get(kw["task_id"]),
}
```
From s07 onward, the task graph is the default for multi-step work. s03's Todo remains for quick single-session checklists.
## What Changed From s06
| Component | Before (s06) | After (s07) |
|---|---|---|
| Tools | 5 | 8 (`task_create/update/list/get`) |
| Planning model | Flat checklist (in-memory) | Task graph with dependencies (on disk) |
| Relationships | None | `blockedBy` edges |
| Status tracking | Done or not | `pending` -> `in_progress` -> `completed` |
| Persistence | Lost on compression | Survives compression and restarts |
## Try It
```sh
cd learn-claude-code
python agents/s07_task_system.py
```
1. `Create 3 tasks: "Setup project", "Write code", "Write tests". Make them depend on each other in order.`
2. `List all tasks and show the dependency graph`
3. `Complete task 1 and then list tasks to see task 2 unblocked`
4. `Create a task board for refactoring: parse -> transform -> emit -> test, where transform and emit can run in parallel after parse`
+107
View File
@@ -0,0 +1,107 @@
# s08: Background Tasks
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > [ s08 ] > s09 > s10 > s11 > s12`
> *"Run slow operations in the background; the agent keeps thinking"* -- daemon threads run commands, inject notifications on completion.
>
> **Harness layer**: Background execution -- the model thinks while the harness waits.
## Problem
Some commands take minutes: `npm install`, `pytest`, `docker build`. With a blocking loop, the model sits idle waiting. If the user asks "install dependencies and while that runs, create the config file," the agent does them sequentially, not in parallel.
## Solution
```
Main thread Background thread
+-----------------+ +-----------------+
| agent loop | | subprocess runs |
| ... | | ... |
| [LLM call] <---+------- | enqueue(result) |
| ^drain queue | +-----------------+
+-----------------+
Timeline:
Agent --[spawn A]--[spawn B]--[other work]----
| |
v v
[A runs] [B runs] (parallel)
| |
+-- results injected before next LLM call --+
```
## How It Works
1. BackgroundManager tracks tasks with a thread-safe notification queue.
```python
class BackgroundManager:
def __init__(self):
self.tasks = {}
self._notification_queue = []
self._lock = threading.Lock()
```
2. `run()` starts a daemon thread and returns immediately.
```python
def run(self, command: str) -> str:
task_id = str(uuid.uuid4())[:8]
self.tasks[task_id] = {"status": "running", "command": command}
thread = threading.Thread(
target=self._execute, args=(task_id, command), daemon=True)
thread.start()
return f"Background task {task_id} started"
```
3. When the subprocess finishes, its result goes into the notification queue.
```python
def _execute(self, task_id, command):
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=300)
output = (r.stdout + r.stderr).strip()[:50000]
except subprocess.TimeoutExpired:
output = "Error: Timeout (300s)"
with self._lock:
self._notification_queue.append({
"task_id": task_id, "result": output[:500]})
```
4. The agent loop drains notifications before each LLM call.
```python
def agent_loop(messages: list):
while True:
notifs = BG.drain_notifications()
if notifs:
notif_text = "\n".join(
f"[bg:{n['task_id']}] {n['result']}" for n in notifs)
messages.append({"role": "user",
"content": f"<background-results>\n{notif_text}\n"
f"</background-results>"})
response = client.messages.create(...)
```
The loop stays single-threaded. Only subprocess I/O is parallelized.
## What Changed From s07
| Component | Before (s07) | After (s08) |
|----------------|------------------|----------------------------|
| Tools | 8 | 6 (base + background_run + check)|
| Execution | Blocking only | Blocking + background threads|
| Notification | None | Queue drained per loop |
| Concurrency | None | Daemon threads |
## Try It
```sh
cd learn-claude-code
python agents/s08_background_tasks.py
```
1. `Run "sleep 5 && echo done" in the background, then create a file while it runs`
2. `Start 3 background tasks: "sleep 2", "sleep 4", "sleep 6". Check their status.`
3. `Run pytest in the background and keep working on other things`
+125
View File
@@ -0,0 +1,125 @@
# s09: Agent Teams
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > [ s09 ] > s10 > s11 > s12`
> *"When the task is too big for one, delegate to teammates"* -- persistent teammates + async mailboxes.
>
> **Harness layer**: Team mailboxes -- multiple models, coordinated through files.
## Problem
Subagents (s04) are disposable: spawn, work, return summary, die. No identity, no memory between invocations. Background tasks (s08) run shell commands but can't make LLM-guided decisions.
Real teamwork needs: (1) persistent agents that outlive a single prompt, (2) identity and lifecycle management, (3) a communication channel between agents.
## Solution
```
Teammate lifecycle:
spawn -> WORKING -> IDLE -> WORKING -> ... -> SHUTDOWN
Communication:
.team/
config.json <- team roster + statuses
inbox/
alice.jsonl <- append-only, drain-on-read
bob.jsonl
lead.jsonl
+--------+ send("alice","bob","...") +--------+
| alice | -----------------------------> | bob |
| loop | bob.jsonl << {json_line} | loop |
+--------+ +--------+
^ |
| BUS.read_inbox("alice") |
+---- alice.jsonl -> read + drain ---------+
```
## How It Works
1. TeammateManager maintains config.json with the team roster.
```python
class TeammateManager:
def __init__(self, team_dir: Path):
self.dir = team_dir
self.dir.mkdir(exist_ok=True)
self.config_path = self.dir / "config.json"
self.config = self._load_config()
self.threads = {}
```
2. `spawn()` creates a teammate and starts its agent loop in a thread.
```python
def spawn(self, name: str, role: str, prompt: str) -> str:
member = {"name": name, "role": role, "status": "working"}
self.config["members"].append(member)
self._save_config()
thread = threading.Thread(
target=self._teammate_loop,
args=(name, role, prompt), daemon=True)
thread.start()
return f"Spawned teammate '{name}' (role: {role})"
```
3. MessageBus: append-only JSONL inboxes. `send()` appends a JSON line; `read_inbox()` reads all and drains.
```python
class MessageBus:
def send(self, sender, to, content, msg_type="message", extra=None):
msg = {"type": msg_type, "from": sender,
"content": content, "timestamp": time.time()}
if extra:
msg.update(extra)
with open(self.dir / f"{to}.jsonl", "a") as f:
f.write(json.dumps(msg) + "\n")
def read_inbox(self, name):
path = self.dir / f"{name}.jsonl"
if not path.exists(): return "[]"
msgs = [json.loads(l) for l in path.read_text().strip().splitlines() if l]
path.write_text("") # drain
return json.dumps(msgs, indent=2)
```
4. Each teammate checks its inbox before every LLM call, injecting received messages into context.
```python
def _teammate_loop(self, name, role, prompt):
messages = [{"role": "user", "content": prompt}]
for _ in range(50):
inbox = BUS.read_inbox(name)
if inbox != "[]":
messages.append({"role": "user",
"content": f"<inbox>{inbox}</inbox>"})
response = client.messages.create(...)
if response.stop_reason != "tool_use":
break
# execute tools, append results...
self._find_member(name)["status"] = "idle"
```
## What Changed From s08
| Component | Before (s08) | After (s09) |
|----------------|------------------|----------------------------|
| Tools | 6 | 9 (+spawn/send/read_inbox) |
| Agents | Single | Lead + N teammates |
| Persistence | None | config.json + JSONL inboxes|
| Threads | Background cmds | Full agent loops per thread|
| Lifecycle | Fire-and-forget | idle -> working -> idle |
| Communication | None | message + broadcast |
## Try It
```sh
cd learn-claude-code
python agents/s09_agent_teams.py
```
1. `Spawn alice (coder) and bob (tester). Have alice send bob a message.`
2. `Broadcast "status update: phase 1 complete" to all teammates`
3. `Check the lead inbox for any messages`
4. Type `/team` to see the team roster with statuses
5. Type `/inbox` to manually check the lead's inbox
+106
View File
@@ -0,0 +1,106 @@
# s10: Team Protocols
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > [ s10 ] > s11 > s12`
> *"Teammates need shared communication rules"* -- one request-response pattern drives all negotiation.
>
> **Harness layer**: Protocols -- structured handshakes between models.
## Problem
In s09, teammates work and communicate but lack structured coordination:
**Shutdown**: Killing a thread leaves files half-written and config.json stale. You need a handshake: the lead requests, the teammate approves (finish and exit) or rejects (keep working).
**Plan approval**: When the lead says "refactor the auth module," the teammate starts immediately. For high-risk changes, the lead should review the plan first.
Both share the same structure: one side sends a request with a unique ID, the other responds referencing that ID.
## Solution
```
Shutdown Protocol Plan Approval Protocol
================== ======================
Lead Teammate Teammate Lead
| | | |
|--shutdown_req-->| |--plan_req------>|
| {req_id:"abc"} | | {req_id:"xyz"} |
| | | |
|<--shutdown_resp-| |<--plan_resp-----|
| {req_id:"abc", | | {req_id:"xyz", |
| approve:true} | | approve:true} |
Shared FSM:
[pending] --approve--> [approved]
[pending] --reject---> [rejected]
Trackers:
shutdown_requests = {req_id: {target, status}}
plan_requests = {req_id: {from, plan, status}}
```
## How It Works
1. The lead initiates shutdown by generating a request_id and sending through the inbox.
```python
shutdown_requests = {}
def handle_shutdown_request(teammate: str) -> str:
req_id = str(uuid.uuid4())[:8]
shutdown_requests[req_id] = {"target": teammate, "status": "pending"}
BUS.send("lead", teammate, "Please shut down gracefully.",
"shutdown_request", {"request_id": req_id})
return f"Shutdown request {req_id} sent (status: pending)"
```
2. The teammate receives the request and responds with approve/reject.
```python
if tool_name == "shutdown_response":
req_id = args["request_id"]
approve = args["approve"]
shutdown_requests[req_id]["status"] = "approved" if approve else "rejected"
BUS.send(sender, "lead", args.get("reason", ""),
"shutdown_response",
{"request_id": req_id, "approve": approve})
```
3. Plan approval follows the identical pattern. The teammate submits a plan (generating a request_id), the lead reviews (referencing the same request_id).
```python
plan_requests = {}
def handle_plan_review(request_id, approve, feedback=""):
req = plan_requests[request_id]
req["status"] = "approved" if approve else "rejected"
BUS.send("lead", req["from"], feedback,
"plan_approval_response",
{"request_id": request_id, "approve": approve})
```
One FSM, two applications. The same `pending -> approved | rejected` state machine handles any request-response protocol.
## What Changed From s09
| Component | Before (s09) | After (s10) |
|----------------|------------------|------------------------------|
| Tools | 9 | 12 (+shutdown_req/resp +plan)|
| Shutdown | Natural exit only| Request-response handshake |
| Plan gating | None | Submit/review with approval |
| Correlation | None | request_id per request |
| FSM | None | pending -> approved/rejected |
## Try It
```sh
cd learn-claude-code
python agents/s10_team_protocols.py
```
1. `Spawn alice as a coder. Then request her shutdown.`
2. `List teammates to see alice's status after shutdown approval`
3. `Spawn bob with a risky refactoring task. Review and reject his plan.`
4. `Spawn charlie, have him submit a plan, then approve it.`
5. Type `/team` to monitor statuses
+142
View File
@@ -0,0 +1,142 @@
# s11: Autonomous Agents
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > [ s11 ] > s12`
> *"Teammates scan the board and claim tasks themselves"* -- no need for the lead to assign each one.
>
> **Harness layer**: Autonomy -- models that find work without being told.
## Problem
In s09-s10, teammates only work when explicitly told to. The lead must spawn each one with a specific prompt. 10 unclaimed tasks on the board? The lead assigns each one manually. Doesn't scale.
True autonomy: teammates scan the task board themselves, claim unclaimed tasks, work on them, then look for more.
One subtlety: after context compression (s06), the agent might forget who it is. Identity re-injection fixes this.
## Solution
```
Teammate lifecycle with idle cycle:
+-------+
| spawn |
+---+---+
|
v
+-------+ tool_use +-------+
| WORK | <------------- | LLM |
+---+---+ +-------+
|
| stop_reason != tool_use (or idle tool called)
v
+--------+
| IDLE | poll every 5s for up to 60s
+---+----+
|
+---> check inbox --> message? ----------> WORK
|
+---> scan .tasks/ --> unclaimed? -------> claim -> WORK
|
+---> 60s timeout ----------------------> SHUTDOWN
Identity re-injection after compression:
if len(messages) <= 3:
messages.insert(0, identity_block)
```
## How It Works
1. The teammate loop has two phases: WORK and IDLE. When the LLM stops calling tools (or calls `idle`), the teammate enters IDLE.
```python
def _loop(self, name, role, prompt):
while True:
# -- WORK PHASE --
messages = [{"role": "user", "content": prompt}]
for _ in range(50):
response = client.messages.create(...)
if response.stop_reason != "tool_use":
break
# execute tools...
if idle_requested:
break
# -- IDLE PHASE --
self._set_status(name, "idle")
resume = self._idle_poll(name, messages)
if not resume:
self._set_status(name, "shutdown")
return
self._set_status(name, "working")
```
2. The idle phase polls inbox and task board in a loop.
```python
def _idle_poll(self, name, messages):
for _ in range(IDLE_TIMEOUT // POLL_INTERVAL): # 60s / 5s = 12
time.sleep(POLL_INTERVAL)
inbox = BUS.read_inbox(name)
if inbox:
messages.append({"role": "user",
"content": f"<inbox>{inbox}</inbox>"})
return True
unclaimed = scan_unclaimed_tasks()
if unclaimed:
claim_task(unclaimed[0]["id"], name)
messages.append({"role": "user",
"content": f"<auto-claimed>Task #{unclaimed[0]['id']}: "
f"{unclaimed[0]['subject']}</auto-claimed>"})
return True
return False # timeout -> shutdown
```
3. Task board scanning: find pending, unowned, unblocked tasks.
```python
def scan_unclaimed_tasks() -> list:
unclaimed = []
for f in sorted(TASKS_DIR.glob("task_*.json")):
task = json.loads(f.read_text())
if (task.get("status") == "pending"
and not task.get("owner")
and not task.get("blockedBy")):
unclaimed.append(task)
return unclaimed
```
4. Identity re-injection: when context is too short (compression happened), insert an identity block.
```python
if len(messages) <= 3:
messages.insert(0, {"role": "user",
"content": f"<identity>You are '{name}', role: {role}, "
f"team: {team_name}. Continue your work.</identity>"})
messages.insert(1, {"role": "assistant",
"content": f"I am {name}. Continuing."})
```
## What Changed From s10
| Component | Before (s10) | After (s11) |
|----------------|------------------|----------------------------|
| Tools | 12 | 14 (+idle, +claim_task) |
| Autonomy | Lead-directed | Self-organizing |
| Idle phase | None | Poll inbox + task board |
| Task claiming | Manual only | Auto-claim unclaimed tasks |
| Identity | System prompt | + re-injection after compress|
| Timeout | None | 60s idle -> auto shutdown |
## Try It
```sh
cd learn-claude-code
python agents/s11_autonomous_agents.py
```
1. `Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim.`
2. `Spawn a coder teammate and let it find work from the task board itself`
3. `Create tasks with dependencies. Watch teammates respect the blocked order.`
4. Type `/tasks` to see the task board with owners
5. Type `/team` to monitor who is working vs idle
+121
View File
@@ -0,0 +1,121 @@
# s12: Worktree + Task Isolation
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > [ s12 ]`
> *"Each works in its own directory, no interference"* -- tasks manage goals, worktrees manage directories, bound by ID.
>
> **Harness layer**: Directory isolation -- parallel execution lanes that never collide.
## Problem
By s11, agents can claim and complete tasks autonomously. But every task runs in one shared directory. Two agents refactoring different modules at the same time will collide: agent A edits `config.py`, agent B edits `config.py`, unstaged changes mix, and neither can roll back cleanly.
The task board tracks *what to do* but has no opinion about *where to do it*. The fix: give each task its own git worktree directory. Tasks manage goals, worktrees manage execution context. Bind them by task ID.
## Solution
```
Control plane (.tasks/) Execution plane (.worktrees/)
+------------------+ +------------------------+
| task_1.json | | auth-refactor/ |
| status: in_progress <------> branch: wt/auth-refactor
| worktree: "auth-refactor" | task_id: 1 |
+------------------+ +------------------------+
| task_2.json | | ui-login/ |
| status: pending <------> branch: wt/ui-login
| worktree: "ui-login" | task_id: 2 |
+------------------+ +------------------------+
|
index.json (worktree registry)
events.jsonl (lifecycle log)
State machines:
Task: pending -> in_progress -> completed
Worktree: absent -> active -> removed | kept
```
## How It Works
1. **Create a task.** Persist the goal first.
```python
TASKS.create("Implement auth refactor")
# -> .tasks/task_1.json status=pending worktree=""
```
2. **Create a worktree and bind to the task.** Passing `task_id` auto-advances the task to `in_progress`.
```python
WORKTREES.create("auth-refactor", task_id=1)
# -> git worktree add -b wt/auth-refactor .worktrees/auth-refactor HEAD
# -> index.json gets new entry, task_1.json gets worktree="auth-refactor"
```
The binding writes state to both sides:
```python
def bind_worktree(self, task_id, worktree):
task = self._load(task_id)
task["worktree"] = worktree
if task["status"] == "pending":
task["status"] = "in_progress"
self._save(task)
```
3. **Run commands in the worktree.** `cwd` points to the isolated directory.
```python
subprocess.run(command, shell=True, cwd=worktree_path,
capture_output=True, text=True, timeout=300)
```
4. **Close out.** Two choices:
- `worktree_keep(name)` -- preserve the directory for later.
- `worktree_remove(name, complete_task=True)` -- remove directory, complete the bound task, emit event. One call handles teardown + completion.
```python
def remove(self, name, force=False, complete_task=False):
self._run_git(["worktree", "remove", wt["path"]])
if complete_task and wt.get("task_id") is not None:
self.tasks.update(wt["task_id"], status="completed")
self.tasks.unbind_worktree(wt["task_id"])
self.events.emit("task.completed", ...)
```
5. **Event stream.** Every lifecycle step emits to `.worktrees/events.jsonl`:
```json
{
"event": "worktree.remove.after",
"task": {"id": 1, "status": "completed"},
"worktree": {"name": "auth-refactor", "status": "removed"},
"ts": 1730000000
}
```
Events emitted: `worktree.create.before/after/failed`, `worktree.remove.before/after/failed`, `worktree.keep`, `task.completed`.
After a crash, state reconstructs from `.tasks/` + `.worktrees/index.json` on disk. Conversation memory is volatile; file state is durable.
## What Changed From s11
| Component | Before (s11) | After (s12) |
|--------------------|----------------------------|----------------------------------------------|
| Coordination | Task board (owner/status) | Task board + explicit worktree binding |
| Execution scope | Shared directory | Task-scoped isolated directory |
| Recoverability | Task status only | Task status + worktree index |
| Teardown | Task completion | Task completion + explicit keep/remove |
| Lifecycle visibility | Implicit in logs | Explicit events in `.worktrees/events.jsonl` |
## Try It
```sh
cd learn-claude-code
python agents/s12_worktree_task_isolation.py
```
1. `Create tasks for backend auth and frontend login page, then list tasks.`
2. `Create worktree "auth-refactor" for task 1, then bind task 2 to a new worktree "ui-login".`
3. `Run "git status --short" in worktree "auth-refactor".`
4. `Keep worktree "ui-login", then list worktrees and inspect events.`
5. `Remove worktree "auth-refactor" with complete_task=true, then list tasks/worktrees/events.`
+116
View File
@@ -0,0 +1,116 @@
# s01: The Agent Loop
`[ s01 ] s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"One loop & Bash is all you need"* -- 1つのツール + 1つのループ = エージェント。
>
> **Harness 層**: ループ -- モデルと現実世界を繋ぐ最初の接点。
## 問題
言語モデルはコードについて推論できるが、現実世界に触れられない。ファイルを読めず、テストを実行できず、エラーを確認できない。ループがなければ、ツール呼び出しのたびにユーザーが手動で結果をコピーペーストする必要がある。つまりユーザー自身がループになる。
## 解決策
```
+--------+ +-------+ +---------+
| User | ---> | LLM | ---> | Tool |
| prompt | | | | execute |
+--------+ +---+---+ +----+----+
^ |
| tool_result |
+----------------+
(loop until stop_reason != "tool_use")
```
1つの終了条件がフロー全体を制御する。モデルがツール呼び出しを止めるまでループが回り続ける。
## 仕組み
1. ユーザーのプロンプトが最初のメッセージになる。
```python
messages.append({"role": "user", "content": query})
```
2. メッセージとツール定義をLLMに送信する。
```python
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
```
3. アシスタントのレスポンスを追加し、`stop_reason`を確認する。ツールが呼ばれなければ終了。
```python
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
```
4. 各ツール呼び出しを実行し、結果を収集してuserメッセージとして追加。ステップ2に戻る。
```python
results = []
for block in response.content:
if block.type == "tool_use":
output = run_bash(block.input["command"])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
```
1つの関数にまとめると:
```python
def agent_loop(query):
messages = [{"role": "user", "content": query}]
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
for block in response.content:
if block.type == "tool_use":
output = run_bash(block.input["command"])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
```
これでエージェント全体が30行未満に収まる。本コースの残りはすべてこのループの上に積み重なる -- ループ自体は変わらない。
## 変更点
| Component | Before | After |
|---------------|------------|--------------------------------|
| Agent loop | (none) | `while True` + stop_reason |
| Tools | (none) | `bash` (one tool) |
| Messages | (none) | Accumulating list |
| Control flow | (none) | `stop_reason != "tool_use"` |
## 試してみる
```sh
cd learn-claude-code
python agents/s01_agent_loop.py
```
1. `Create a file called hello.py that prints "Hello, World!"`
2. `List all Python files in this directory`
3. `What is the current git branch?`
4. `Create a directory called test_output and write 3 files in it`
+99
View File
@@ -0,0 +1,99 @@
# s02: Tool Use
`s01 > [ s02 ] s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"ツールを足すなら、ハンドラーを1つ足すだけ"* -- ループは変わらない。新ツールは dispatch map に登録するだけ。
>
> **Harness 層**: ツール分配 -- モデルが届く範囲を広げる。
## 問題
`bash`だけでは、エージェントは何でもシェル経由で行う。`cat`は予測不能に切り詰め、`sed`は特殊文字で壊れ、すべてのbash呼び出しが制約のないセキュリティ面になる。`read_file``write_file`のような専用ツールなら、ツールレベルでパスのサンドボックス化を強制できる。
重要な点: ツールを追加してもループの変更は不要。
## 解決策
```
+--------+ +-------+ +------------------+
| User | ---> | LLM | ---> | Tool Dispatch |
| prompt | | | | { |
+--------+ +---+---+ | bash: run_bash |
^ | read: run_read |
| | write: run_wr |
+-----------+ edit: run_edit |
tool_result | } |
+------------------+
The dispatch map is a dict: {tool_name: handler_function}.
One lookup replaces any if/elif chain.
```
## 仕組み
1. 各ツールにハンドラ関数を定義する。パスのサンドボックス化でワークスペース外への脱出を防ぐ。
```python
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def run_read(path: str, limit: int = None) -> str:
text = safe_path(path).read_text()
lines = text.splitlines()
if limit and limit < len(lines):
lines = lines[:limit]
return "\n".join(lines)[:50000]
```
2. ディスパッチマップがツール名とハンドラを結びつける。
```python
TOOL_HANDLERS = {
"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"],
kw["new_text"]),
}
```
3. ループ内で名前によりハンドラをルックアップする。ループ本体はs01から不変。
```python
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler \
else f"Unknown tool: {block.name}"
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
```
ツール追加 = ハンドラ追加 + スキーマ追加。ループは決して変わらない。
## s01からの変更点
| Component | Before (s01) | After (s02) |
|----------------|--------------------|----------------------------|
| Tools | 1 (bash only) | 4 (bash, read, write, edit)|
| Dispatch | Hardcoded bash call | `TOOL_HANDLERS` dict |
| Path safety | None | `safe_path()` sandbox |
| Agent loop | Unchanged | Unchanged |
## 試してみる
```sh
cd learn-claude-code
python agents/s02_tool_use.py
```
1. `Read the file requirements.txt`
2. `Create a file called greet.py with a greet(name) function`
3. `Edit greet.py to add a docstring to the function`
4. `Read greet.py to verify the edit worked`
+96
View File
@@ -0,0 +1,96 @@
# s03: TodoWrite
`s01 > s02 > [ s03 ] s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"計画のないエージェントは行き当たりばったり"* -- まずステップを書き出し、それから実行。
>
> **Harness 層**: 計画 -- 航路を描かずにモデルを軌道に乗せる。
## 問題
マルチステップのタスクで、モデルは途中で迷子になる。作業を繰り返したり、ステップを飛ばしたり、脱線したりする。長い会話になるほど悪化する -- ツール結果がコンテキストを埋めるにつれ、システムプロンプトの影響力が薄れる。10ステップのリファクタリングでステップ1-3を完了した後、残りを忘れて即興を始めてしまう。
## 解決策
```
+--------+ +-------+ +---------+
| User | ---> | LLM | ---> | Tools |
| prompt | | | | + todo |
+--------+ +---+---+ +----+----+
^ |
| tool_result |
+----------------+
|
+-----------+-----------+
| TodoManager state |
| [ ] task A |
| [>] task B <- doing |
| [x] task C |
+-----------------------+
|
if rounds_since_todo >= 3:
inject <reminder> into tool_result
```
## 仕組み
1. TodoManagerはアイテムのリストをステータス付きで保持する。`in_progress`にできるのは同時に1つだけ。
```python
class TodoManager:
def update(self, items: list) -> str:
validated, in_progress_count = [], 0
for item in items:
status = item.get("status", "pending")
if status == "in_progress":
in_progress_count += 1
validated.append({"id": item["id"], "text": item["text"],
"status": status})
if in_progress_count > 1:
raise ValueError("Only one task can be in_progress")
self.items = validated
return self.render()
```
2. `todo`ツールは他のツールと同様にディスパッチマップに追加される。
```python
TOOL_HANDLERS = {
# ...base tools...
"todo": lambda **kw: TODO.update(kw["items"]),
}
```
3. nagリマインダーが、モデルが3ラウンド以上`todo`を呼ばなかった場合にナッジを注入する。
```python
if rounds_since_todo >= 3 and messages:
last = messages[-1]
if last["role"] == "user" and isinstance(last.get("content"), list):
last["content"].insert(0, {
"type": "text",
"text": "<reminder>Update your todos.</reminder>",
})
```
「一度にin_progressは1つだけ」の制約が逐次的な集中を強制し、nagリマインダーが説明責任を生む。
## s02からの変更点
| Component | Before (s02) | After (s03) |
|----------------|------------------|----------------------------|
| Tools | 4 | 5 (+todo) |
| Planning | None | TodoManager with statuses |
| Nag injection | None | `<reminder>` after 3 rounds|
| Agent loop | Simple dispatch | + rounds_since_todo counter|
## 試してみる
```sh
cd learn-claude-code
python agents/s03_todo_write.py
```
1. `Refactor the file hello.py: add type hints, docstrings, and a main guard`
2. `Create a Python package with __init__.py, utils.py, and tests/test_utils.py`
3. `Review all Python files and fix any style issues`
+94
View File
@@ -0,0 +1,94 @@
# s04: Subagents
`s01 > s02 > s03 > [ s04 ] s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"大きなタスクを分割し、各サブタスクにクリーンなコンテキストを"* -- サブエージェントは独立した messages[] を使い、メイン会話を汚さない。
>
> **Harness 層**: コンテキスト隔離 -- モデルの思考の明晰さを守る。
## 問題
エージェントが作業するにつれ、messages配列は膨張し続ける。すべてのファイル読み取り、すべてのbash出力がコンテキストに永久に残る。「このプロジェクトはどのテストフレームワークを使っているか」という質問は5つのファイルを読む必要があるかもしれないが、親に必要なのは「pytest」という答えだけだ。
## 解決策
```
Parent agent Subagent
+------------------+ +------------------+
| messages=[...] | | messages=[] | <-- fresh
| | dispatch | |
| tool: task | ----------> | while tool_use: |
| prompt="..." | | call tools |
| | summary | append results |
| result = "..." | <---------- | return last text |
+------------------+ +------------------+
Parent context stays clean. Subagent context is discarded.
```
## 仕組み
1. 親に`task`ツールを追加する。子は`task`を除くすべての基本ツールを取得する(再帰的な生成は不可)。
```python
PARENT_TOOLS = CHILD_TOOLS + [
{"name": "task",
"description": "Spawn a subagent with fresh context.",
"input_schema": {
"type": "object",
"properties": {"prompt": {"type": "string"}},
"required": ["prompt"],
}},
]
```
2. サブエージェントは`messages=[]`で開始し、自身のループを実行する。最終テキストだけが親に返る。
```python
def run_subagent(prompt: str) -> str:
sub_messages = [{"role": "user", "content": prompt}]
for _ in range(30): # safety limit
response = client.messages.create(
model=MODEL, system=SUBAGENT_SYSTEM,
messages=sub_messages,
tools=CHILD_TOOLS, max_tokens=8000,
)
sub_messages.append({"role": "assistant",
"content": response.content})
if response.stop_reason != "tool_use":
break
results = []
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input)
results.append({"type": "tool_result",
"tool_use_id": block.id,
"content": str(output)[:50000]})
sub_messages.append({"role": "user", "content": results})
return "".join(
b.text for b in response.content if hasattr(b, "text")
) or "(no summary)"
```
子のメッセージ履歴全体(30回以上のツール呼び出し)は破棄される。親は1段落の要約を通常の`tool_result`として受け取る。
## s03からの変更点
| Component | Before (s03) | After (s04) |
|----------------|------------------|---------------------------|
| Tools | 5 | 5 (base) + task (parent) |
| Context | Single shared | Parent + child isolation |
| Subagent | None | `run_subagent()` function |
| Return value | N/A | Summary text only |
## 試してみる
```sh
cd learn-claude-code
python agents/s04_subagent.py
```
1. `Use a subtask to find what testing framework this project uses`
2. `Delegate: read all .py files and summarize what each one does`
3. `Use a task to create a new module, then verify it from here`
+108
View File
@@ -0,0 +1,108 @@
# s05: Skills
`s01 > s02 > s03 > s04 > [ s05 ] s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"必要な知識を、必要な時に読み込む"* -- system prompt ではなく tool_result で注入。
>
> **Harness 層**: オンデマンド知識 -- モデルが求めた時だけ渡すドメイン専門性。
## 問題
エージェントにドメイン固有のワークフローを遵守させたい: gitの規約、テストパターン、コードレビューチェックリスト。すべてをシステムプロンプトに入れると、使われないスキルにトークンを浪費する。10スキル x 2000トークン = 20,000トークン、ほとんどが任意のタスクに無関係だ。
## 解決策
```
System prompt (Layer 1 -- always present):
+--------------------------------------+
| You are a coding agent. |
| Skills available: |
| - git: Git workflow helpers | ~100 tokens/skill
| - test: Testing best practices |
+--------------------------------------+
When model calls load_skill("git"):
+--------------------------------------+
| tool_result (Layer 2 -- on demand): |
| <skill name="git"> |
| Full git workflow instructions... | ~2000 tokens
| Step 1: ... |
| </skill> |
+--------------------------------------+
```
第1層: スキル*名*をシステムプロンプトに(低コスト)。第2層: スキル*本体*をtool_resultに(オンデマンド)。
## 仕組み
1. 各スキルは `SKILL.md` ファイルを含むディレクトリとして配置される。
```
skills/
pdf/
SKILL.md # ---\n name: pdf\n description: Process PDF files\n ---\n ...
code-review/
SKILL.md # ---\n name: code-review\n description: Review code\n ---\n ...
```
2. SkillLoaderが `SKILL.md` を再帰的に探索し、ディレクトリ名をスキル識別子として使用する。
```python
class SkillLoader:
def __init__(self, skills_dir: Path):
self.skills = {}
for f in sorted(skills_dir.rglob("SKILL.md")):
text = f.read_text()
meta, body = self._parse_frontmatter(text)
name = meta.get("name", f.parent.name)
self.skills[name] = {"meta": meta, "body": body}
def get_descriptions(self) -> str:
lines = []
for name, skill in self.skills.items():
desc = skill["meta"].get("description", "")
lines.append(f" - {name}: {desc}")
return "\n".join(lines)
def get_content(self, name: str) -> str:
skill = self.skills.get(name)
if not skill:
return f"Error: Unknown skill '{name}'."
return f"<skill name=\"{name}\">\n{skill['body']}\n</skill>"
```
3. 第1層はシステムプロンプトに配置。第2層は通常のツールハンドラ。
```python
SYSTEM = f"""You are a coding agent at {WORKDIR}.
Skills available:
{SKILL_LOADER.get_descriptions()}"""
TOOL_HANDLERS = {
# ...base tools...
"load_skill": lambda **kw: SKILL_LOADER.get_content(kw["name"]),
}
```
モデルはどのスキルが存在するかを知り(低コスト)、関連する時にだけ読み込む(高コスト)。
## s04からの変更点
| Component | Before (s04) | After (s05) |
|----------------|------------------|----------------------------|
| Tools | 5 (base + task) | 5 (base + load_skill) |
| System prompt | Static string | + skill descriptions |
| Knowledge | None | skills/\*/SKILL.md files |
| Injection | None | Two-layer (system + result)|
## 試してみる
```sh
cd learn-claude-code
python agents/s05_skill_loading.py
```
1. `What skills are available?`
2. `Load the agent-builder skill and follow its instructions`
3. `I need to do a code review -- load the relevant skill first`
4. `Build an MCP server using the mcp-builder skill`
+124
View File
@@ -0,0 +1,124 @@
# s06: Context Compact
`s01 > s02 > s03 > s04 > s05 > [ s06 ] | s07 > s08 > s09 > s10 > s11 > s12`
> *"コンテキストはいつか溢れる、空ける手段が要る"* -- 3層圧縮で無限セッションを実現。
>
> **Harness 層**: 圧縮 -- クリーンな記憶、無限のセッション。
## 問題
コンテキストウィンドウは有限だ。1000行のファイルに対する`read_file`1回で約4000トークンを消費する。30ファイルを読み20回のbashコマンドを実行すると、100,000トークン超。圧縮なしでは、エージェントは大規模コードベースで作業できない。
## 解決策
積極性を段階的に上げる3層構成:
```
Every turn:
+------------------+
| Tool call result |
+------------------+
|
v
[Layer 1: micro_compact] (silent, every turn)
Replace tool_result > 3 turns old
with "[Previous: used {tool_name}]"
|
v
[Check: tokens > 50000?]
| |
no yes
| |
v v
continue [Layer 2: auto_compact]
Save transcript to .transcripts/
LLM summarizes conversation.
Replace all messages with [summary].
|
v
[Layer 3: compact tool]
Model calls compact explicitly.
Same summarization as auto_compact.
```
## 仕組み
1. **第1層 -- micro_compact**: 各LLM呼び出しの前に、古いツール結果をプレースホルダーに置換する。
```python
def micro_compact(messages: list) -> list:
tool_results = []
for i, msg in enumerate(messages):
if msg["role"] == "user" and isinstance(msg.get("content"), list):
for j, part in enumerate(msg["content"]):
if isinstance(part, dict) and part.get("type") == "tool_result":
tool_results.append((i, j, part))
if len(tool_results) <= KEEP_RECENT:
return messages
for _, _, part in tool_results[:-KEEP_RECENT]:
if len(part.get("content", "")) > 100:
part["content"] = f"[Previous: used {tool_name}]"
return messages
```
2. **第2層 -- auto_compact**: トークンが閾値を超えたら、完全なトランスクリプトをディスクに保存し、LLMに要約を依頼する。
```python
def auto_compact(messages: list) -> list:
# Save transcript for recovery
transcript_path = TRANSCRIPT_DIR / f"transcript_{int(time.time())}.jsonl"
with open(transcript_path, "w") as f:
for msg in messages:
f.write(json.dumps(msg, default=str) + "\n")
# LLM summarizes
response = client.messages.create(
model=MODEL,
messages=[{"role": "user", "content":
"Summarize this conversation for continuity..."
+ json.dumps(messages, default=str)[:80000]}],
max_tokens=2000,
)
return [
{"role": "user", "content": f"[Compressed]\n\n{response.content[0].text}"},
]
```
3. **第3層 -- manual compact**: `compact`ツールが同じ要約処理をオンデマンドでトリガーする。
4. ループが3層すべてを統合する:
```python
def agent_loop(messages: list):
while True:
micro_compact(messages) # Layer 1
if estimate_tokens(messages) > THRESHOLD:
messages[:] = auto_compact(messages) # Layer 2
response = client.messages.create(...)
# ... tool execution ...
if manual_compact:
messages[:] = auto_compact(messages) # Layer 3
```
トランスクリプトがディスク上に完全な履歴を保持する。何も真に失われず、アクティブなコンテキストの外に移動されるだけ。
## s05からの変更点
| Component | Before (s05) | After (s06) |
|----------------|------------------|----------------------------|
| Tools | 5 | 5 (base + compact) |
| Context mgmt | None | Three-layer compression |
| Micro-compact | None | Old results -> placeholders|
| Auto-compact | None | Token threshold trigger |
| Transcripts | None | Saved to .transcripts/ |
## 試してみる
```sh
cd learn-claude-code
python agents/s06_context_compact.py
```
1. `Read every Python file in the agents/ directory one by one` (micro-compactが古い結果を置換するのを観察する)
2. `Keep reading files until compression triggers automatically`
3. `Use the compact tool to manually compress the conversation`
+131
View File
@@ -0,0 +1,131 @@
# s07: Task System
`s01 > s02 > s03 > s04 > s05 > s06 | [ s07 ] s08 > s09 > s10 > s11 > s12`
> *"大きな目標を小タスクに分解し、順序付けし、ディスクに記録する"* -- ファイルベースのタスクグラフ、マルチエージェント協調の基盤。
>
> **Harness 層**: 永続タスク -- どの会話よりも長く生きる目標。
## 問題
s03のTodoManagerはメモリ上のフラットなチェックリストに過ぎない: 順序なし、依存関係なし、ステータスは完了か未完了のみ。実際の目標には構造がある -- タスクBはタスクAに依存し、タスクCとDは並行実行でき、タスクEはCとDの両方を待つ。
明示的な関係がなければ、エージェントは何が実行可能で、何がブロックされ、何が同時に走れるかを判断できない。しかもリストはメモリ上にしかないため、コンテキスト圧縮(s06)で消える。
## 解決策
フラットなチェックリストをディスクに永続化する**タスクグラフ**に昇格させる。各タスクは1つのJSONファイルで、ステータス・前方依存(`blockedBy`)を持つ。タスクグラフは常に3つの問いに答える:
- **何が実行可能か?** -- `pending`ステータスで`blockedBy`が空のタスク。
- **何がブロックされているか?** -- 未完了の依存を待つタスク。
- **何が完了したか?** -- `completed`のタスク。完了時に後続タスクを自動的にアンブロックする。
```
.tasks/
task_1.json {"id":1, "status":"completed"}
task_2.json {"id":2, "blockedBy":[1], "status":"pending"}
task_3.json {"id":3, "blockedBy":[1], "status":"pending"}
task_4.json {"id":4, "blockedBy":[2,3], "status":"pending"}
タスクグラフ (DAG):
+----------+
+--> | task 2 | --+
| | pending | |
+----------+ +----------+ +--> +----------+
| task 1 | | task 4 |
| completed| --> +----------+ +--> | blocked |
+----------+ | task 3 | --+ +----------+
| pending |
+----------+
順序: task 1 は 2 と 3 より先に完了する必要がある
並行: task 2 と 3 は同時に実行できる
依存: task 4 は 2 と 3 の両方を待つ
ステータス: pending -> in_progress -> completed
```
このタスクグラフは s07 以降の全メカニズムの協調バックボーンとなる: バックグラウンド実行(s08)、マルチエージェントチーム(s09+)、worktree分離(s12)はすべてこの同じ構造を読み書きする。
## 仕組み
1. **TaskManager**: タスクごとに1つのJSONファイル、依存グラフ付きCRUD。
```python
class TaskManager:
def __init__(self, tasks_dir: Path):
self.dir = tasks_dir
self.dir.mkdir(exist_ok=True)
self._next_id = self._max_id() + 1
def create(self, subject, description=""):
task = {"id": self._next_id, "subject": subject,
"status": "pending", "blockedBy": [],
"owner": ""}
self._save(task)
self._next_id += 1
return json.dumps(task, indent=2)
```
2. **依存解除**: タスク完了時に、他タスクの`blockedBy`リストから完了IDを除去し、後続タスクをアンブロックする。
```python
def _clear_dependency(self, completed_id):
for f in self.dir.glob("task_*.json"):
task = json.loads(f.read_text())
if completed_id in task.get("blockedBy", []):
task["blockedBy"].remove(completed_id)
self._save(task)
```
3. **ステータス遷移 + 依存配線**: `update`がステータス変更と依存エッジを担う。
```python
def update(self, task_id, status=None,
add_blocked_by=None, remove_blocked_by=None):
task = self._load(task_id)
if status:
task["status"] = status
if status == "completed":
self._clear_dependency(task_id)
if add_blocked_by:
task["blockedBy"] = list(set(task["blockedBy"] + add_blocked_by))
if remove_blocked_by:
task["blockedBy"] = [x for x in task["blockedBy"] if x not in remove_blocked_by]
self._save(task)
```
4. 4つのタスクツールをディスパッチマップに追加する。
```python
TOOL_HANDLERS = {
# ...base tools...
"task_create": lambda **kw: TASKS.create(kw["subject"]),
"task_update": lambda **kw: TASKS.update(kw["task_id"], kw.get("status")),
"task_list": lambda **kw: TASKS.list_all(),
"task_get": lambda **kw: TASKS.get(kw["task_id"]),
}
```
s07以降、タスクグラフがマルチステップ作業のデフォルト。s03のTodoは軽量な単一セッション用チェックリストとして残る。
## s06からの変更点
| コンポーネント | Before (s06) | After (s07) |
|---|---|---|
| Tools | 5 | 8 (`task_create/update/list/get`) |
| 計画モデル | フラットチェックリスト (メモリ) | 依存関係付きタスクグラフ (ディスク) |
| 関係 | なし | `blockedBy` エッジ |
| ステータス追跡 | 完了か未完了 | `pending` -> `in_progress` -> `completed` |
| 永続性 | 圧縮で消失 | 圧縮・再起動後も存続 |
## 試してみる
```sh
cd learn-claude-code
python agents/s07_task_system.py
```
1. `Create 3 tasks: "Setup project", "Write code", "Write tests". Make them depend on each other in order.`
2. `List all tasks and show the dependency graph`
3. `Complete task 1 and then list tasks to see task 2 unblocked`
4. `Create a task board for refactoring: parse -> transform -> emit -> test, where transform and emit can run in parallel after parse`
+107
View File
@@ -0,0 +1,107 @@
# s08: Background Tasks
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > [ s08 ] s09 > s10 > s11 > s12`
> *"遅い操作はバックグラウンドへ、エージェントは次を考え続ける"* -- デーモンスレッドがコマンド実行、完了後に通知を注入。
>
> **Harness 層**: バックグラウンド実行 -- モデルが考え続ける間、Harness が待つ。
## 問題
一部のコマンドは数分かかる: `npm install``pytest``docker build`。ブロッキングループでは、モデルはサブプロセスの完了を待って座っている。ユーザーが「依存関係をインストールして、その間にconfigファイルを作って」と言っても、エージェントは並列ではなく逐次的に処理する。
## 解決策
```
Main thread Background thread
+-----------------+ +-----------------+
| agent loop | | subprocess runs |
| ... | | ... |
| [LLM call] <---+------- | enqueue(result) |
| ^drain queue | +-----------------+
+-----------------+
Timeline:
Agent --[spawn A]--[spawn B]--[other work]----
| |
v v
[A runs] [B runs] (parallel)
| |
+-- results injected before next LLM call --+
```
## 仕組み
1. BackgroundManagerがスレッドセーフな通知キューでタスクを追跡する。
```python
class BackgroundManager:
def __init__(self):
self.tasks = {}
self._notification_queue = []
self._lock = threading.Lock()
```
2. `run()`がデーモンスレッドを開始し、即座にリターンする。
```python
def run(self, command: str) -> str:
task_id = str(uuid.uuid4())[:8]
self.tasks[task_id] = {"status": "running", "command": command}
thread = threading.Thread(
target=self._execute, args=(task_id, command), daemon=True)
thread.start()
return f"Background task {task_id} started"
```
3. サブプロセス完了時に、結果を通知キューへ。
```python
def _execute(self, task_id, command):
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=300)
output = (r.stdout + r.stderr).strip()[:50000]
except subprocess.TimeoutExpired:
output = "Error: Timeout (300s)"
with self._lock:
self._notification_queue.append({
"task_id": task_id, "result": output[:500]})
```
4. エージェントループが各LLM呼び出しの前に通知をドレインする。
```python
def agent_loop(messages: list):
while True:
notifs = BG.drain_notifications()
if notifs:
notif_text = "\n".join(
f"[bg:{n['task_id']}] {n['result']}" for n in notifs)
messages.append({"role": "user",
"content": f"<background-results>\n{notif_text}\n"
f"</background-results>"})
response = client.messages.create(...)
```
ループはシングルスレッドのまま。サブプロセスI/Oだけが並列化される。
## s07からの変更点
| Component | Before (s07) | After (s08) |
|----------------|------------------|----------------------------|
| Tools | 8 | 6 (base + background_run + check)|
| Execution | Blocking only | Blocking + background threads|
| Notification | None | Queue drained per loop |
| Concurrency | None | Daemon threads |
## 試してみる
```sh
cd learn-claude-code
python agents/s08_background_tasks.py
```
1. `Run "sleep 5 && echo done" in the background, then create a file while it runs`
2. `Start 3 background tasks: "sleep 2", "sleep 4", "sleep 6". Check their status.`
3. `Run pytest in the background and keep working on other things`
+125
View File
@@ -0,0 +1,125 @@
# s09: Agent Teams
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > [ s09 ] s10 > s11 > s12`
> *"一人で終わらないなら、チームメイトに任せる"* -- 永続チームメイト + 非同期メールボックス。
>
> **Harness 層**: チームメールボックス -- 複数モデルをファイルで協調。
## 問題
サブエージェント(s04)は使い捨てだ: 生成し、作業し、要約を返し、消滅する。アイデンティティもなく、呼び出し間の記憶もない。バックグラウンドタスク(s08)はシェルコマンドを実行するが、LLM誘導の意思決定はできない。
本物のチームワークには: (1)単一プロンプトを超えて存続する永続エージェント、(2)アイデンティティとライフサイクル管理、(3)エージェント間の通信チャネルが必要だ。
## 解決策
```
Teammate lifecycle:
spawn -> WORKING -> IDLE -> WORKING -> ... -> SHUTDOWN
Communication:
.team/
config.json <- team roster + statuses
inbox/
alice.jsonl <- append-only, drain-on-read
bob.jsonl
lead.jsonl
+--------+ send("alice","bob","...") +--------+
| alice | -----------------------------> | bob |
| loop | bob.jsonl << {json_line} | loop |
+--------+ +--------+
^ |
| BUS.read_inbox("alice") |
+---- alice.jsonl -> read + drain ---------+
```
## 仕組み
1. TeammateManagerがconfig.jsonでチーム名簿を管理する。
```python
class TeammateManager:
def __init__(self, team_dir: Path):
self.dir = team_dir
self.dir.mkdir(exist_ok=True)
self.config_path = self.dir / "config.json"
self.config = self._load_config()
self.threads = {}
```
2. `spawn()`がチームメイトを作成し、そのエージェントループをスレッドで開始する。
```python
def spawn(self, name: str, role: str, prompt: str) -> str:
member = {"name": name, "role": role, "status": "working"}
self.config["members"].append(member)
self._save_config()
thread = threading.Thread(
target=self._teammate_loop,
args=(name, role, prompt), daemon=True)
thread.start()
return f"Spawned teammate '{name}' (role: {role})"
```
3. MessageBus: 追記専用のJSONLインボックス。`send()`がJSON行を追記し、`read_inbox()`がすべて読み取ってドレインする。
```python
class MessageBus:
def send(self, sender, to, content, msg_type="message", extra=None):
msg = {"type": msg_type, "from": sender,
"content": content, "timestamp": time.time()}
if extra:
msg.update(extra)
with open(self.dir / f"{to}.jsonl", "a") as f:
f.write(json.dumps(msg) + "\n")
def read_inbox(self, name):
path = self.dir / f"{name}.jsonl"
if not path.exists(): return "[]"
msgs = [json.loads(l) for l in path.read_text().strip().splitlines() if l]
path.write_text("") # drain
return json.dumps(msgs, indent=2)
```
4. 各チームメイトは各LLM呼び出しの前にインボックスを確認し、受信メッセージをコンテキストに注入する。
```python
def _teammate_loop(self, name, role, prompt):
messages = [{"role": "user", "content": prompt}]
for _ in range(50):
inbox = BUS.read_inbox(name)
if inbox != "[]":
messages.append({"role": "user",
"content": f"<inbox>{inbox}</inbox>"})
response = client.messages.create(...)
if response.stop_reason != "tool_use":
break
# execute tools, append results...
self._find_member(name)["status"] = "idle"
```
## s08からの変更点
| Component | Before (s08) | After (s09) |
|----------------|------------------|----------------------------|
| Tools | 6 | 9 (+spawn/send/read_inbox) |
| Agents | Single | Lead + N teammates |
| Persistence | None | config.json + JSONL inboxes|
| Threads | Background cmds | Full agent loops per thread|
| Lifecycle | Fire-and-forget | idle -> working -> idle |
| Communication | None | message + broadcast |
## 試してみる
```sh
cd learn-claude-code
python agents/s09_agent_teams.py
```
1. `Spawn alice (coder) and bob (tester). Have alice send bob a message.`
2. `Broadcast "status update: phase 1 complete" to all teammates`
3. `Check the lead inbox for any messages`
4. `/team`と入力してステータス付きのチーム名簿を確認する
5. `/inbox`と入力してリーダーのインボックスを手動確認する
+106
View File
@@ -0,0 +1,106 @@
# s10: Team Protocols
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > [ s10 ] s11 > s12`
> *"チームメイト間には統一の通信ルールが必要"* -- 1つの request-response パターンが全交渉を駆動。
>
> **Harness 層**: プロトコル -- モデル間の構造化されたハンドシェイク。
## 問題
s09ではチームメイトが作業し通信するが、構造化された協調がない:
**シャットダウン**: スレッドを強制終了するとファイルが中途半端に書かれ、config.jsonが不正な状態になる。ハンドシェイクが必要 -- リーダーが要求し、チームメイトが承認(完了して退出)か拒否(作業継続)する。
**プラン承認**: リーダーが「認証モジュールをリファクタリングして」と言うと、チームメイトは即座に開始する。リスクの高い変更では、実行前にリーダーが計画をレビューすべきだ。
両方とも同じ構造: 一方がユニークIDを持つリクエストを送り、他方がそのIDで応答する。
## 解決策
```
Shutdown Protocol Plan Approval Protocol
================== ======================
Lead Teammate Teammate Lead
| | | |
|--shutdown_req-->| |--plan_req------>|
| {req_id:"abc"} | | {req_id:"xyz"} |
| | | |
|<--shutdown_resp-| |<--plan_resp-----|
| {req_id:"abc", | | {req_id:"xyz", |
| approve:true} | | approve:true} |
Shared FSM:
[pending] --approve--> [approved]
[pending] --reject---> [rejected]
Trackers:
shutdown_requests = {req_id: {target, status}}
plan_requests = {req_id: {from, plan, status}}
```
## 仕組み
1. リーダーがrequest_idを生成し、インボックス経由でシャットダウンを開始する。
```python
shutdown_requests = {}
def handle_shutdown_request(teammate: str) -> str:
req_id = str(uuid.uuid4())[:8]
shutdown_requests[req_id] = {"target": teammate, "status": "pending"}
BUS.send("lead", teammate, "Please shut down gracefully.",
"shutdown_request", {"request_id": req_id})
return f"Shutdown request {req_id} sent (status: pending)"
```
2. チームメイトがリクエストを受信し、承認または拒否で応答する。
```python
if tool_name == "shutdown_response":
req_id = args["request_id"]
approve = args["approve"]
shutdown_requests[req_id]["status"] = "approved" if approve else "rejected"
BUS.send(sender, "lead", args.get("reason", ""),
"shutdown_response",
{"request_id": req_id, "approve": approve})
```
3. プラン承認も同一パターン。チームメイトがプランを提出(request_idを生成)、リーダーがレビュー(同じrequest_idを参照)。
```python
plan_requests = {}
def handle_plan_review(request_id, approve, feedback=""):
req = plan_requests[request_id]
req["status"] = "approved" if approve else "rejected"
BUS.send("lead", req["from"], feedback,
"plan_approval_response",
{"request_id": request_id, "approve": approve})
```
1つのFSM、2つの応用。同じ`pending -> approved | rejected`状態機械が、あらゆるリクエスト-レスポンスプロトコルに適用できる。
## s09からの変更点
| Component | Before (s09) | After (s10) |
|----------------|------------------|------------------------------|
| Tools | 9 | 12 (+shutdown_req/resp +plan)|
| Shutdown | Natural exit only| Request-response handshake |
| Plan gating | None | Submit/review with approval |
| Correlation | None | request_id per request |
| FSM | None | pending -> approved/rejected |
## 試してみる
```sh
cd learn-claude-code
python agents/s10_team_protocols.py
```
1. `Spawn alice as a coder. Then request her shutdown.`
2. `List teammates to see alice's status after shutdown approval`
3. `Spawn bob with a risky refactoring task. Review and reject his plan.`
4. `Spawn charlie, have him submit a plan, then approve it.`
5. `/team`と入力してステータスを監視する
+142
View File
@@ -0,0 +1,142 @@
# s11: Autonomous Agents
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > [ s11 ] s12`
> *"チームメイトが自らボードを見て、仕事を取る"* -- リーダーが逐一割り振る必要はない。
>
> **Harness 層**: 自律 -- 指示なしで仕事を見つけるモデル。
## 問題
s09-s10では、チームメイトは明示的に指示された時のみ作業する。リーダーは各チームメイトを特定のプロンプトでspawnしなければならない。タスクボードに未割り当てのタスクが10個あっても、リーダーが手動で各タスクを割り当てる。これはスケールしない。
真の自律性とは、チームメイトが自分で作業を見つけること: タスクボードをスキャンし、未確保のタスクを確保し、作業し、完了したら次を探す。
もう1つの問題: コンテキスト圧縮(s06)後にエージェントが自分の正体を忘れる可能性がある。アイデンティティ再注入がこれを解決する。
## 解決策
```
Teammate lifecycle with idle cycle:
+-------+
| spawn |
+---+---+
|
v
+-------+ tool_use +-------+
| WORK | <------------- | LLM |
+---+---+ +-------+
|
| stop_reason != tool_use (or idle tool called)
v
+--------+
| IDLE | poll every 5s for up to 60s
+---+----+
|
+---> check inbox --> message? ----------> WORK
|
+---> scan .tasks/ --> unclaimed? -------> claim -> WORK
|
+---> 60s timeout ----------------------> SHUTDOWN
Identity re-injection after compression:
if len(messages) <= 3:
messages.insert(0, identity_block)
```
## 仕組み
1. チームメイトのループはWORKとIDLEの2フェーズ。LLMがツール呼び出しを止めた時(または`idle`ツールを呼んだ時)、IDLEフェーズに入る。
```python
def _loop(self, name, role, prompt):
while True:
# -- WORK PHASE --
messages = [{"role": "user", "content": prompt}]
for _ in range(50):
response = client.messages.create(...)
if response.stop_reason != "tool_use":
break
# execute tools...
if idle_requested:
break
# -- IDLE PHASE --
self._set_status(name, "idle")
resume = self._idle_poll(name, messages)
if not resume:
self._set_status(name, "shutdown")
return
self._set_status(name, "working")
```
2. IDLEフェーズがインボックスとタスクボードをポーリングする。
```python
def _idle_poll(self, name, messages):
for _ in range(IDLE_TIMEOUT // POLL_INTERVAL): # 60s / 5s = 12
time.sleep(POLL_INTERVAL)
inbox = BUS.read_inbox(name)
if inbox:
messages.append({"role": "user",
"content": f"<inbox>{inbox}</inbox>"})
return True
unclaimed = scan_unclaimed_tasks()
if unclaimed:
claim_task(unclaimed[0]["id"], name)
messages.append({"role": "user",
"content": f"<auto-claimed>Task #{unclaimed[0]['id']}: "
f"{unclaimed[0]['subject']}</auto-claimed>"})
return True
return False # timeout -> shutdown
```
3. タスクボードスキャン: pendingかつ未割り当てかつブロックされていないタスクを探す。
```python
def scan_unclaimed_tasks() -> list:
unclaimed = []
for f in sorted(TASKS_DIR.glob("task_*.json")):
task = json.loads(f.read_text())
if (task.get("status") == "pending"
and not task.get("owner")
and not task.get("blockedBy")):
unclaimed.append(task)
return unclaimed
```
4. アイデンティティ再注入: コンテキストが短すぎる(圧縮が起きた)場合にアイデンティティブロックを挿入する。
```python
if len(messages) <= 3:
messages.insert(0, {"role": "user",
"content": f"<identity>You are '{name}', role: {role}, "
f"team: {team_name}. Continue your work.</identity>"})
messages.insert(1, {"role": "assistant",
"content": f"I am {name}. Continuing."})
```
## s10からの変更点
| Component | Before (s10) | After (s11) |
|----------------|------------------|----------------------------|
| Tools | 12 | 14 (+idle, +claim_task) |
| Autonomy | Lead-directed | Self-organizing |
| Idle phase | None | Poll inbox + task board |
| Task claiming | Manual only | Auto-claim unclaimed tasks |
| Identity | System prompt | + re-injection after compress|
| Timeout | None | 60s idle -> auto shutdown |
## 試してみる
```sh
cd learn-claude-code
python agents/s11_autonomous_agents.py
```
1. `Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim.`
2. `Spawn a coder teammate and let it find work from the task board itself`
3. `Create tasks with dependencies. Watch teammates respect the blocked order.`
4. `/tasks`と入力してオーナー付きのタスクボードを確認する
5. `/team`と入力して誰が作業中でアイドルかを監視する
+121
View File
@@ -0,0 +1,121 @@
# s12: Worktree + Task Isolation
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > [ s12 ]`
> *"各自のディレクトリで作業し、互いに干渉しない"* -- タスクは目標を管理、worktree はディレクトリを管理、IDで紐付け。
>
> **Harness 層**: ディレクトリ隔離 -- 決して衝突しない並列実行レーン。
## 問題
s11までにエージェントはタスクを自律的に確保して完了できるようになった。しかし全タスクが1つの共有ディレクトリで走る。2つのエージェントが同時に異なるモジュールをリファクタリングすると衝突する: 片方が`config.py`を編集し、もう片方も`config.py`を編集し、未コミットの変更が混ざり合い、どちらもクリーンにロールバックできない。
タスクボードは*何をやるか*を追跡するが、*どこでやるか*には関知しない。解決策: 各タスクに専用のgit worktreeディレクトリを与える。タスクが目標を管理し、worktreeが実行コンテキストを管理する。タスクIDで紐付ける。
## 解決策
```
Control plane (.tasks/) Execution plane (.worktrees/)
+------------------+ +------------------------+
| task_1.json | | auth-refactor/ |
| status: in_progress <------> branch: wt/auth-refactor
| worktree: "auth-refactor" | task_id: 1 |
+------------------+ +------------------------+
| task_2.json | | ui-login/ |
| status: pending <------> branch: wt/ui-login
| worktree: "ui-login" | task_id: 2 |
+------------------+ +------------------------+
|
index.json (worktree registry)
events.jsonl (lifecycle log)
State machines:
Task: pending -> in_progress -> completed
Worktree: absent -> active -> removed | kept
```
## 仕組み
1. **タスクを作成する。** まず目標を永続化する。
```python
TASKS.create("Implement auth refactor")
# -> .tasks/task_1.json status=pending worktree=""
```
2. **worktreeを作成してタスクに紐付ける。** `task_id`を渡すと、タスクが自動的に`in_progress`に遷移する。
```python
WORKTREES.create("auth-refactor", task_id=1)
# -> git worktree add -b wt/auth-refactor .worktrees/auth-refactor HEAD
# -> index.json gets new entry, task_1.json gets worktree="auth-refactor"
```
紐付けは両側に状態を書き込む:
```python
def bind_worktree(self, task_id, worktree):
task = self._load(task_id)
task["worktree"] = worktree
if task["status"] == "pending":
task["status"] = "in_progress"
self._save(task)
```
3. **worktree内でコマンドを実行する。** `cwd`が分離ディレクトリを指す。
```python
subprocess.run(command, shell=True, cwd=worktree_path,
capture_output=True, text=True, timeout=300)
```
4. **終了処理。** 2つの選択肢:
- `worktree_keep(name)` -- ディレクトリを保持する。
- `worktree_remove(name, complete_task=True)` -- ディレクトリを削除し、紐付けられたタスクを完了し、イベントを発行する。1回の呼び出しで後片付けと完了を処理する。
```python
def remove(self, name, force=False, complete_task=False):
self._run_git(["worktree", "remove", wt["path"]])
if complete_task and wt.get("task_id") is not None:
self.tasks.update(wt["task_id"], status="completed")
self.tasks.unbind_worktree(wt["task_id"])
self.events.emit("task.completed", ...)
```
5. **イベントストリーム。** ライフサイクルの各ステップが`.worktrees/events.jsonl`に記録される:
```json
{
"event": "worktree.remove.after",
"task": {"id": 1, "status": "completed"},
"worktree": {"name": "auth-refactor", "status": "removed"},
"ts": 1730000000
}
```
発行されるイベント: `worktree.create.before/after/failed`, `worktree.remove.before/after/failed`, `worktree.keep`, `task.completed`
クラッシュ後も`.tasks/` + `.worktrees/index.json`から状態を再構築できる。会話メモリは揮発性だが、ファイル状態は永続的だ。
## s11からの変更点
| Component | Before (s11) | After (s12) |
|--------------------|----------------------------|----------------------------------------------|
| Coordination | Task board (owner/status) | Task board + explicit worktree binding |
| Execution scope | Shared directory | Task-scoped isolated directory |
| Recoverability | Task status only | Task status + worktree index |
| Teardown | Task completion | Task completion + explicit keep/remove |
| Lifecycle visibility | Implicit in logs | Explicit events in `.worktrees/events.jsonl` |
## 試してみる
```sh
cd learn-claude-code
python agents/s12_worktree_task_isolation.py
```
1. `Create tasks for backend auth and frontend login page, then list tasks.`
2. `Create worktree "auth-refactor" for task 1, then bind task 2 to a new worktree "ui-login".`
3. `Run "git status --short" in worktree "auth-refactor".`
4. `Keep worktree "ui-login", then list worktrees and inspect events.`
5. `Remove worktree "auth-refactor" with complete_task=true, then list tasks/worktrees/events.`
+118
View File
@@ -0,0 +1,118 @@
# s01: The Agent Loop (Agent 循环)
`[ s01 ] s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"One loop & Bash is all you need"* -- 一个工具 + 一个循环 = 一个 Agent。
>
> **Harness 层**: 循环 -- 模型与真实世界的第一道连接。
## 问题
语言模型能推理代码, 但碰不到真实世界 -- 不能读文件、跑测试、看报错。没有循环, 每次工具调用你都得手动把结果粘回去。你自己就是那个循环。
## 解决方案
```
+--------+ +-------+ +---------+
| User | ---> | LLM | ---> | Tool |
| prompt | | | | execute |
+--------+ +---+---+ +----+----+
^ |
| tool_result |
+----------------+
(loop until stop_reason != "tool_use")
```
一个退出条件控制整个流程。循环持续运行, 直到模型不再调用工具。
## 工作原理
1. 用户 prompt 作为第一条消息。
```python
messages.append({"role": "user", "content": query})
```
2. 将消息和工具定义一起发给 LLM。
```python
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
```
3. 追加助手响应。检查 `stop_reason` -- 如果模型没有调用工具, 结束。
```python
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
```
4. 执行每个工具调用, 收集结果, 作为 user 消息追加。回到第 2 步。
```python
results = []
for block in response.content:
if block.type == "tool_use":
output = run_bash(block.input["command"])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
```
组装为一个完整函数:
```python
def agent_loop(query):
messages = [{"role": "user", "content": query}]
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
for block in response.content:
if block.type == "tool_use":
output = run_bash(block.input["command"])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
```
不到 30 行, 这就是整个 Agent。后面 11 个章节都在这个循环上叠加机制 -- 循环本身始终不变。
## 变更内容
| 组件 | 之前 | 之后 |
|---------------|------------|--------------------------------|
| Agent loop | (无) | `while True` + stop_reason |
| Tools | (无) | `bash` (单一工具) |
| Messages | (无) | 累积式消息列表 |
| Control flow | (无) | `stop_reason != "tool_use"` |
## 试一试
```sh
cd learn-claude-code
python agents/s01_agent_loop.py
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
1. `Create a file called hello.py that prints "Hello, World!"`
2. `List all Python files in this directory`
3. `What is the current git branch?`
4. `Create a directory called test_output and write 3 files in it`
+101
View File
@@ -0,0 +1,101 @@
# s02: Tool Use (工具使用)
`s01 > [ s02 ] s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"加一个工具, 只加一个 handler"* -- 循环不用动, 新工具注册进 dispatch map 就行。
>
> **Harness 层**: 工具分发 -- 扩展模型能触达的边界。
## 问题
只有 `bash` 时, 所有操作都走 shell。`cat` 截断不可预测, `sed` 遇到特殊字符就崩, 每次 bash 调用都是不受约束的安全面。专用工具 (`read_file`, `write_file`) 可以在工具层面做路径沙箱。
关键洞察: 加工具不需要改循环。
## 解决方案
```
+--------+ +-------+ +------------------+
| User | ---> | LLM | ---> | Tool Dispatch |
| prompt | | | | { |
+--------+ +---+---+ | bash: run_bash |
^ | read: run_read |
| | write: run_wr |
+-----------+ edit: run_edit |
tool_result | } |
+------------------+
The dispatch map is a dict: {tool_name: handler_function}.
One lookup replaces any if/elif chain.
```
## 工作原理
1. 每个工具有一个处理函数。路径沙箱防止逃逸工作区。
```python
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def run_read(path: str, limit: int = None) -> str:
text = safe_path(path).read_text()
lines = text.splitlines()
if limit and limit < len(lines):
lines = lines[:limit]
return "\n".join(lines)[:50000]
```
2. dispatch map 将工具名映射到处理函数。
```python
TOOL_HANDLERS = {
"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"],
kw["new_text"]),
}
```
3. 循环中按名称查找处理函数。循环体本身与 s01 完全一致。
```python
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler \
else f"Unknown tool: {block.name}"
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
```
加工具 = 加 handler + 加 schema。循环永远不变。
## 相对 s01 的变更
| 组件 | 之前 (s01) | 之后 (s02) |
|----------------|--------------------|--------------------------------|
| Tools | 1 (仅 bash) | 4 (bash, read, write, edit) |
| Dispatch | 硬编码 bash 调用 | `TOOL_HANDLERS` 字典 |
| 路径安全 | 无 | `safe_path()` 沙箱 |
| Agent loop | 不变 | 不变 |
## 试一试
```sh
cd learn-claude-code
python agents/s02_tool_use.py
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
1. `Read the file requirements.txt`
2. `Create a file called greet.py with a greet(name) function`
3. `Edit greet.py to add a docstring to the function`
4. `Read greet.py to verify the edit worked`
+98
View File
@@ -0,0 +1,98 @@
# s03: TodoWrite (待办写入)
`s01 > s02 > [ s03 ] s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"没有计划的 agent 走哪算哪"* -- 先列步骤再动手, 完成率翻倍。
>
> **Harness 层**: 规划 -- 让模型不偏航, 但不替它画航线。
## 问题
多步任务中, 模型会丢失进度 -- 重复做过的事、跳步、跑偏。对话越长越严重: 工具结果不断填满上下文, 系统提示的影响力逐渐被稀释。一个 10 步重构可能做完 1-3 步就开始即兴发挥, 因为 4-10 步已经被挤出注意力了。
## 解决方案
```
+--------+ +-------+ +---------+
| User | ---> | LLM | ---> | Tools |
| prompt | | | | + todo |
+--------+ +---+---+ +----+----+
^ |
| tool_result |
+----------------+
|
+-----------+-----------+
| TodoManager state |
| [ ] task A |
| [>] task B <- doing |
| [x] task C |
+----------- ------------+
|
if rounds_since_todo >= 3:
inject <reminder> into tool_result
```
## 工作原理
1. TodoManager 存储带状态的项目。同一时间只允许一个 `in_progress`
```python
class TodoManager:
def update(self, items: list) -> str:
validated, in_progress_count = [], 0
for item in items:
status = item.get("status", "pending")
if status == "in_progress":
in_progress_count += 1
validated.append({"id": item["id"], "text": item["text"],
"status": status})
if in_progress_count > 1:
raise ValueError("Only one task can be in_progress")
self.items = validated
return self.render()
```
2. `todo` 工具和其他工具一样加入 dispatch map。
```python
TOOL_HANDLERS = {
# ...base tools...
"todo": lambda **kw: TODO.update(kw["items"]),
}
```
3. nag reminder: 模型连续 3 轮以上不调用 `todo` 时注入提醒。
```python
if rounds_since_todo >= 3 and messages:
last = messages[-1]
if last["role"] == "user" and isinstance(last.get("content"), list):
last["content"].insert(0, {
"type": "text",
"text": "<reminder>Update your todos.</reminder>",
})
```
"同时只能有一个 in_progress" 强制顺序聚焦。nag reminder 制造问责压力 -- 你不更新计划, 系统就追着你问。
## 相对 s02 的变更
| 组件 | 之前 (s02) | 之后 (s03) |
|----------------|------------------|--------------------------------|
| Tools | 4 | 5 (+todo) |
| 规划 | 无 | 带状态的 TodoManager |
| Nag 注入 | 无 | 3 轮后注入 `<reminder>` |
| Agent loop | 简单分发 | + rounds_since_todo 计数器 |
## 试一试
```sh
cd learn-claude-code
python agents/s03_todo_write.py
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
1. `Refactor the file hello.py: add type hints, docstrings, and a main guard`
2. `Create a Python package with __init__.py, utils.py, and tests/test_utils.py`
3. `Review all Python files and fix any style issues`
+96
View File
@@ -0,0 +1,96 @@
# s04: Subagents (Subagent)
`s01 > s02 > s03 > [ s04 ] s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"大任务拆小, 每个小任务干净的上下文"* -- Subagent 用独立 messages[], 不污染主对话。
>
> **Harness 层**: 上下文隔离 -- 守护模型的思维清晰度。
## 问题
Agent 工作越久, messages 数组越臃肿。每次读文件、跑命令的输出都永久留在上下文里。"这个项目用什么测试框架?" 可能要读 5 个文件, 但父 Agent 只需要一个词: "pytest。"
## 解决方案
```
Parent agent Subagent
+------------------+ +------------------+
| messages=[...] | | messages=[] | <-- fresh
| | dispatch | |
| tool: task | ----------> | while tool_use: |
| prompt="..." | | call tools |
| | summary | append results |
| result = "..." | <---------- | return last text |
+------------------+ +------------------+
Parent context stays clean. Subagent context is discarded.
```
## 工作原理
1. 父 Agent 有一个 `task` 工具。Subagent 拥有除 `task` 外的所有基础工具 (禁止递归生成)。
```python
PARENT_TOOLS = CHILD_TOOLS + [
{"name": "task",
"description": "Spawn a subagent with fresh context.",
"input_schema": {
"type": "object",
"properties": {"prompt": {"type": "string"}},
"required": ["prompt"],
}},
]
```
2. Subagent 以 `messages=[]` 启动, 运行自己的循环。只有最终文本返回给父 Agent。
```python
def run_subagent(prompt: str) -> str:
sub_messages = [{"role": "user", "content": prompt}]
for _ in range(30): # safety limit
response = client.messages.create(
model=MODEL, system=SUBAGENT_SYSTEM,
messages=sub_messages,
tools=CHILD_TOOLS, max_tokens=8000,
)
sub_messages.append({"role": "assistant",
"content": response.content})
if response.stop_reason != "tool_use":
break
results = []
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input)
results.append({"type": "tool_result",
"tool_use_id": block.id,
"content": str(output)[:50000]})
sub_messages.append({"role": "user", "content": results})
return "".join(
b.text for b in response.content if hasattr(b, "text")
) or "(no summary)"
```
Subagent 可能跑了 30+ 次工具调用, 但整个消息历史直接丢弃。父 Agent 收到的只是一段摘要文本, 作为普通 `tool_result` 返回。
## 相对 s03 的变更
| 组件 | 之前 (s03) | 之后 (s04) |
|----------------|------------------|-------------------------------|
| Tools | 5 | 5 (基础) + task (仅父端) |
| 上下文 | 单一共享 | 父 + 子隔离 |
| Subagent | 无 | `run_subagent()` 函数 |
| 返回值 | 不适用 | 仅摘要文本 |
## 试一试
```sh
cd learn-claude-code
python agents/s04_subagent.py
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
1. `Use a subtask to find what testing framework this project uses`
2. `Delegate: read all .py files and summarize what each one does`
3. `Use a task to create a new module, then verify it from here`
+110
View File
@@ -0,0 +1,110 @@
# s05: Skills (Skill 加载)
`s01 > s02 > s03 > s04 > [ s05 ] s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"用到什么知识, 临时加载什么知识"* -- 通过 tool_result 注入, 不塞 system prompt。
>
> **Harness 层**: 按需知识 -- 模型开口要时才给的领域专长。
## 问题
你希望 Agent 遵循特定领域的工作流: git 约定、测试模式、代码审查清单。全塞进系统提示太浪费 -- 10 个 Skill, 每个 2000 token, 就是 20,000 token, 大部分跟当前任务毫无关系。
## 解决方案
```
System prompt (Layer 1 -- always present):
+--------------------------------------+
| You are a coding agent. |
| Skills available: |
| - git: Git workflow helpers | ~100 tokens/skill
| - test: Testing best practices |
+--------------------------------------+
When model calls load_skill("git"):
+--------------------------------------+
| tool_result (Layer 2 -- on demand): |
| <skill name="git"> |
| Full git workflow instructions... | ~2000 tokens
| Step 1: ... |
| </skill> |
+--------------------------------------+
```
第一层: 系统提示中放 Skill 名称 (低成本)。第二层: tool_result 中按需放完整内容。
## 工作原理
1. 每个 Skill 是一个目录, 包含 `SKILL.md` 文件和 YAML frontmatter。
```
skills/
pdf/
SKILL.md # ---\n name: pdf\n description: Process PDF files\n ---\n ...
code-review/
SKILL.md # ---\n name: code-review\n description: Review code\n ---\n ...
```
2. SkillLoader 递归扫描 `SKILL.md` 文件, 用目录名作为 Skill 标识。
```python
class SkillLoader:
def __init__(self, skills_dir: Path):
self.skills = {}
for f in sorted(skills_dir.rglob("SKILL.md")):
text = f.read_text()
meta, body = self._parse_frontmatter(text)
name = meta.get("name", f.parent.name)
self.skills[name] = {"meta": meta, "body": body}
def get_descriptions(self) -> str:
lines = []
for name, skill in self.skills.items():
desc = skill["meta"].get("description", "")
lines.append(f" - {name}: {desc}")
return "\n".join(lines)
def get_content(self, name: str) -> str:
skill = self.skills.get(name)
if not skill:
return f"Error: Unknown skill '{name}'."
return f"<skill name=\"{name}\">\n{skill['body']}\n</skill>"
```
3. 第一层写入系统提示。第二层不过是 dispatch map 中的又一个工具。
```python
SYSTEM = f"""You are a coding agent at {WORKDIR}.
Skills available:
{SKILL_LOADER.get_descriptions()}"""
TOOL_HANDLERS = {
# ...base tools...
"load_skill": lambda **kw: SKILL_LOADER.get_content(kw["name"]),
}
```
模型知道有哪些 Skill (便宜), 需要时再加载完整内容 (贵)。
## 相对 s04 的变更
| 组件 | 之前 (s04) | 之后 (s05) |
|----------------|------------------|--------------------------------|
| Tools | 5 (基础 + task) | 5 (基础 + load_skill) |
| 系统提示 | 静态字符串 | + Skill 描述列表 |
| 知识库 | 无 | skills/\*/SKILL.md 文件 |
| 注入方式 | 无 | 两层 (系统提示 + result) |
## 试一试
```sh
cd learn-claude-code
python agents/s05_skill_loading.py
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
1. `What skills are available?`
2. `Load the agent-builder skill and follow its instructions`
3. `I need to do a code review -- load the relevant skill first`
4. `Build an MCP server using the mcp-builder skill`
+126
View File
@@ -0,0 +1,126 @@
# s06: Context Compact (上下文压缩)
`s01 > s02 > s03 > s04 > s05 > [ s06 ] | s07 > s08 > s09 > s10 > s11 > s12`
> *"上下文总会满, 要有办法腾地方"* -- 三层压缩策略, 换来无限会话。
>
> **Harness 层**: 压缩 -- 干净的记忆, 无限的会话。
## 问题
上下文窗口是有限的。读一个 1000 行的文件就吃掉 ~4000 token; 读 30 个文件、跑 20 条命令, 轻松突破 100k token。不压缩, Agent 根本没法在大项目里干活。
## 解决方案
三层压缩, 激进程度递增:
```
Every turn:
+------------------+
| Tool call result |
+------------------+
|
v
[Layer 1: micro_compact] (silent, every turn)
Replace tool_result > 3 turns old
with "[Previous: used {tool_name}]"
|
v
[Check: tokens > 50000?]
| |
no yes
| |
v v
continue [Layer 2: auto_compact]
Save transcript to .transcripts/
LLM summarizes conversation.
Replace all messages with [summary].
|
v
[Layer 3: compact tool]
Model calls compact explicitly.
Same summarization as auto_compact.
```
## 工作原理
1. **第一层 -- micro_compact**: 每次 LLM 调用前, 将旧的 tool result 替换为占位符。
```python
def micro_compact(messages: list) -> list:
tool_results = []
for i, msg in enumerate(messages):
if msg["role"] == "user" and isinstance(msg.get("content"), list):
for j, part in enumerate(msg["content"]):
if isinstance(part, dict) and part.get("type") == "tool_result":
tool_results.append((i, j, part))
if len(tool_results) <= KEEP_RECENT:
return messages
for _, _, part in tool_results[:-KEEP_RECENT]:
if len(part.get("content", "")) > 100:
part["content"] = f"[Previous: used {tool_name}]"
return messages
```
2. **第二层 -- auto_compact**: token 超过阈值时, 保存完整对话到磁盘, 让 LLM 做摘要。
```python
def auto_compact(messages: list) -> list:
# Save transcript for recovery
transcript_path = TRANSCRIPT_DIR / f"transcript_{int(time.time())}.jsonl"
with open(transcript_path, "w") as f:
for msg in messages:
f.write(json.dumps(msg, default=str) + "\n")
# LLM summarizes
response = client.messages.create(
model=MODEL,
messages=[{"role": "user", "content":
"Summarize this conversation for continuity..."
+ json.dumps(messages, default=str)[:80000]}],
max_tokens=2000,
)
return [
{"role": "user", "content": f"[Compressed]\n\n{response.content[0].text}"},
]
```
3. **第三层 -- manual compact**: `compact` 工具按需触发同样的摘要机制。
4. 循环整合三层:
```python
def agent_loop(messages: list):
while True:
micro_compact(messages) # Layer 1
if estimate_tokens(messages) > THRESHOLD:
messages[:] = auto_compact(messages) # Layer 2
response = client.messages.create(...)
# ... tool execution ...
if manual_compact:
messages[:] = auto_compact(messages) # Layer 3
```
完整历史通过 transcript 保存在磁盘上。信息没有真正丢失, 只是移出了活跃上下文。
## 相对 s05 的变更
| 组件 | 之前 (s05) | 之后 (s06) |
|----------------|------------------|--------------------------------|
| Tools | 5 | 5 (基础 + compact) |
| 上下文管理 | 无 | 三层压缩 |
| Micro-compact | 无 | 旧结果 -> 占位符 |
| Auto-compact | 无 | token 阈值触发 |
| Transcripts | 无 | 保存到 .transcripts/ |
## 试一试
```sh
cd learn-claude-code
python agents/s06_context_compact.py
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
1. `Read every Python file in the agents/ directory one by one` (观察 micro-compact 替换旧结果)
2. `Keep reading files until compression triggers automatically`
3. `Use the compact tool to manually compress the conversation`
+133
View File
@@ -0,0 +1,133 @@
# s07: Task System (任务系统)
`s01 > s02 > s03 > s04 > s05 > s06 | [ s07 ] s08 > s09 > s10 > s11 > s12`
> *"大目标要拆成小任务, 排好序, 记在磁盘上"* -- 文件持久化的任务图, 为多 agent 协作打基础。
>
> **Harness 层**: 持久化任务 -- 比任何一次对话都长命的目标。
## 问题
s03 的 TodoManager 只是内存中的扁平清单: 没有顺序、没有依赖、状态只有做完没做完。真实目标是有结构的 -- 任务 B 依赖任务 A, 任务 C 和 D 可以并行, 任务 E 要等 C 和 D 都完成。
没有显式的关系, Agent 分不清什么能做、什么被卡住、什么能同时跑。而且清单只活在内存里, 上下文压缩 (s06) 一跑就没了。
## 解决方案
把扁平清单升级为持久化到磁盘的**任务图**。每个任务是一个 JSON 文件, 有状态、前置依赖 (`blockedBy`)。任务图随时回答三个问题:
- **什么可以做?** -- 状态为 `pending``blockedBy` 为空的任务。
- **什么被卡住?** -- 等待前置任务完成的任务。
- **什么做完了?** -- 状态为 `completed` 的任务, 完成时自动解锁后续任务。
```
.tasks/
task_1.json {"id":1, "status":"completed"}
task_2.json {"id":2, "blockedBy":[1], "status":"pending"}
task_3.json {"id":3, "blockedBy":[1], "status":"pending"}
task_4.json {"id":4, "blockedBy":[2,3], "status":"pending"}
任务图 (DAG):
+----------+
+--> | task 2 | --+
| | pending | |
+----------+ +----------+ +--> +----------+
| task 1 | | task 4 |
| completed| --> +----------+ +--> | blocked |
+----------+ | task 3 | --+ +----------+
| pending |
+----------+
顺序: task 1 必须先完成, 才能开始 2 和 3
并行: task 2 和 3 可以同时执行
依赖: task 4 要等 2 和 3 都完成
状态: pending -> in_progress -> completed
```
这个任务图是 s07 之后所有机制的协调骨架: 后台执行 (s08)、多 agent 团队 (s09+)、worktree 隔离 (s12) 都读写这同一个结构。
## 工作原理
1. **TaskManager**: 每个任务一个 JSON 文件, CRUD + 依赖图。
```python
class TaskManager:
def __init__(self, tasks_dir: Path):
self.dir = tasks_dir
self.dir.mkdir(exist_ok=True)
self._next_id = self._max_id() + 1
def create(self, subject, description=""):
task = {"id": self._next_id, "subject": subject,
"status": "pending", "blockedBy": [],
"owner": ""}
self._save(task)
self._next_id += 1
return json.dumps(task, indent=2)
```
2. **依赖解除**: 完成任务时, 自动将其 ID 从其他任务的 `blockedBy` 中移除, 解锁后续任务。
```python
def _clear_dependency(self, completed_id):
for f in self.dir.glob("task_*.json"):
task = json.loads(f.read_text())
if completed_id in task.get("blockedBy", []):
task["blockedBy"].remove(completed_id)
self._save(task)
```
3. **状态变更 + 依赖关联**: `update` 处理状态转换和依赖边。
```python
def update(self, task_id, status=None,
add_blocked_by=None, remove_blocked_by=None):
task = self._load(task_id)
if status:
task["status"] = status
if status == "completed":
self._clear_dependency(task_id)
if add_blocked_by:
task["blockedBy"] = list(set(task["blockedBy"] + add_blocked_by))
if remove_blocked_by:
task["blockedBy"] = [x for x in task["blockedBy"] if x not in remove_blocked_by]
self._save(task)
```
4. 四个任务工具加入 dispatch map。
```python
TOOL_HANDLERS = {
# ...base tools...
"task_create": lambda **kw: TASKS.create(kw["subject"]),
"task_update": lambda **kw: TASKS.update(kw["task_id"], kw.get("status")),
"task_list": lambda **kw: TASKS.list_all(),
"task_get": lambda **kw: TASKS.get(kw["task_id"]),
}
```
从 s07 起, 任务图是多步工作的默认选择。s03 的 Todo 仍可用于单次会话内的快速清单。
## 相对 s06 的变更
| 组件 | 之前 (s06) | 之后 (s07) |
|---|---|---|
| Tools | 5 | 8 (`task_create/update/list/get`) |
| 规划模型 | 扁平清单 (仅内存) | 带依赖关系的任务图 (磁盘) |
| 关系 | 无 | `blockedBy` 边 |
| 状态追踪 | 做完没做完 | `pending` -> `in_progress` -> `completed` |
| 持久化 | 压缩后丢失 | 压缩和重启后存活 |
## 试一试
```sh
cd learn-claude-code
python agents/s07_task_system.py
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
1. `Create 3 tasks: "Setup project", "Write code", "Write tests". Make them depend on each other in order.`
2. `List all tasks and show the dependency graph`
3. `Complete task 1 and then list tasks to see task 2 unblocked`
4. `Create a task board for refactoring: parse -> transform -> emit -> test, where transform and emit can run in parallel after parse`
+109
View File
@@ -0,0 +1,109 @@
# s08: Background Tasks (后台任务)
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > [ s08 ] s09 > s10 > s11 > s12`
> *"慢操作丢后台, agent 继续想下一步"* -- 后台线程跑命令, 完成后注入通知。
>
> **Harness 层**: 后台执行 -- 模型继续思考, harness 负责等待。
## 问题
有些命令要跑好几分钟: `npm install``pytest``docker build`。阻塞式循环下模型只能干等。用户说 "装依赖, 顺便建个配置文件", Agent 却只能一个一个来。
## 解决方案
```
Main thread Background thread
+-----------------+ +-----------------+
| agent loop | | subprocess runs |
| ... | | ... |
| [LLM call] <---+------- | enqueue(result) |
| ^drain queue | +-----------------+
+-----------------+
Timeline:
Agent --[spawn A]--[spawn B]--[other work]----
| |
v v
[A runs] [B runs] (parallel)
| |
+-- results injected before next LLM call --+
```
## 工作原理
1. BackgroundManager 用线程安全的通知队列追踪任务。
```python
class BackgroundManager:
def __init__(self):
self.tasks = {}
self._notification_queue = []
self._lock = threading.Lock()
```
2. `run()` 启动守护线程, 立即返回。
```python
def run(self, command: str) -> str:
task_id = str(uuid.uuid4())[:8]
self.tasks[task_id] = {"status": "running", "command": command}
thread = threading.Thread(
target=self._execute, args=(task_id, command), daemon=True)
thread.start()
return f"Background task {task_id} started"
```
3. 子进程完成后, 结果进入通知队列。
```python
def _execute(self, task_id, command):
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=300)
output = (r.stdout + r.stderr).strip()[:50000]
except subprocess.TimeoutExpired:
output = "Error: Timeout (300s)"
with self._lock:
self._notification_queue.append({
"task_id": task_id, "result": output[:500]})
```
4. 每次 LLM 调用前排空通知队列。
```python
def agent_loop(messages: list):
while True:
notifs = BG.drain_notifications()
if notifs:
notif_text = "\n".join(
f"[bg:{n['task_id']}] {n['result']}" for n in notifs)
messages.append({"role": "user",
"content": f"<background-results>\n{notif_text}\n"
f"</background-results>"})
response = client.messages.create(...)
```
循环保持单线程。只有子进程 I/O 被并行化。
## 相对 s07 的变更
| 组件 | 之前 (s07) | 之后 (s08) |
|----------------|------------------|------------------------------------|
| Tools | 8 | 6 (基础 + background_run + check) |
| 执行方式 | 仅阻塞 | 阻塞 + 后台线程 |
| 通知机制 | 无 | 每轮排空的队列 |
| 并发 | 无 | 守护线程 |
## 试一试
```sh
cd learn-claude-code
python agents/s08_background_tasks.py
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
1. `Run "sleep 5 && echo done" in the background, then create a file while it runs`
2. `Start 3 background tasks: "sleep 2", "sleep 4", "sleep 6". Check their status.`
3. `Run pytest in the background and keep working on other things`
+127
View File
@@ -0,0 +1,127 @@
# s09: Agent Teams (Agent 团队)
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > [ s09 ] s10 > s11 > s12`
> *"任务太大一个人干不完, 要能分给队友"* -- 持久化队友 + JSONL 邮箱。
>
> **Harness 层**: 团队邮箱 -- 多个模型, 通过文件协调。
## 问题
Subagent (s04) 是一次性的: 生成、干活、返回摘要、消亡。没有身份, 没有跨调用的记忆。Background Tasks (s08) 能跑 shell 命令, 但做不了 LLM 引导的决策。
真正的团队协作需要三样东西: (1) 能跨多轮对话存活的持久 Agent, (2) 身份和生命周期管理, (3) Agent 之间的通信通道。
## 解决方案
```
Teammate lifecycle:
spawn -> WORKING -> IDLE -> WORKING -> ... -> SHUTDOWN
Communication:
.team/
config.json <- team roster + statuses
inbox/
alice.jsonl <- append-only, drain-on-read
bob.jsonl
lead.jsonl
+--------+ send("alice","bob","...") +--------+
| alice | -----------------------------> | bob |
| loop | bob.jsonl << {json_line} | loop |
+--------+ +--------+
^ |
| BUS.read_inbox("alice") |
+---- alice.jsonl -> read + drain ---------+
```
## 工作原理
1. TeammateManager 通过 config.json 维护团队名册。
```python
class TeammateManager:
def __init__(self, team_dir: Path):
self.dir = team_dir
self.dir.mkdir(exist_ok=True)
self.config_path = self.dir / "config.json"
self.config = self._load_config()
self.threads = {}
```
2. `spawn()` 创建队友并在线程中启动 agent loop。
```python
def spawn(self, name: str, role: str, prompt: str) -> str:
member = {"name": name, "role": role, "status": "working"}
self.config["members"].append(member)
self._save_config()
thread = threading.Thread(
target=self._teammate_loop,
args=(name, role, prompt), daemon=True)
thread.start()
return f"Spawned teammate '{name}' (role: {role})"
```
3. MessageBus: append-only 的 JSONL 收件箱。`send()` 追加一行; `read_inbox()` 读取全部并清空。
```python
class MessageBus:
def send(self, sender, to, content, msg_type="message", extra=None):
msg = {"type": msg_type, "from": sender,
"content": content, "timestamp": time.time()}
if extra:
msg.update(extra)
with open(self.dir / f"{to}.jsonl", "a") as f:
f.write(json.dumps(msg) + "\n")
def read_inbox(self, name):
path = self.dir / f"{name}.jsonl"
if not path.exists(): return "[]"
msgs = [json.loads(l) for l in path.read_text().strip().splitlines() if l]
path.write_text("") # drain
return json.dumps(msgs, indent=2)
```
4. 每个队友在每次 LLM 调用前检查收件箱, 将消息注入上下文。
```python
def _teammate_loop(self, name, role, prompt):
messages = [{"role": "user", "content": prompt}]
for _ in range(50):
inbox = BUS.read_inbox(name)
if inbox != "[]":
messages.append({"role": "user",
"content": f"<inbox>{inbox}</inbox>"})
response = client.messages.create(...)
if response.stop_reason != "tool_use":
break
# execute tools, append results...
self._find_member(name)["status"] = "idle"
```
## 相对 s08 的变更
| 组件 | 之前 (s08) | 之后 (s09) |
|----------------|------------------|------------------------------------|
| Tools | 6 | 9 (+spawn/send/read_inbox) |
| Agent 数量 | 单一 | 领导 + N 个队友 |
| 持久化 | 无 | config.json + JSONL 收件箱 |
| 线程 | 后台命令 | 每线程完整 agent loop |
| 生命周期 | 一次性 | idle -> working -> idle |
| 通信 | 无 | message + broadcast |
## 试一试
```sh
cd learn-claude-code
python agents/s09_agent_teams.py
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
1. `Spawn alice (coder) and bob (tester). Have alice send bob a message.`
2. `Broadcast "status update: phase 1 complete" to all teammates`
3. `Check the lead inbox for any messages`
4. 输入 `/team` 查看团队名册和状态
5. 输入 `/inbox` 手动检查领导的收件箱
+108
View File
@@ -0,0 +1,108 @@
# s10: Team Protocols (团队协议)
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > [ s10 ] s11 > s12`
> *"队友之间要有统一的沟通规矩"* -- 一个 request-response 模式驱动所有协商。
>
> **Harness 层**: 协议 -- 模型之间的结构化握手。
## 问题
s09 中队友能干活能通信, 但缺少结构化协调:
**关机**: 直接杀线程会留下写了一半的文件和过期的 config.json。需要握手 -- 领导请求, 队友批准 (收尾退出) 或拒绝 (继续干)。
**计划审批**: 领导说 "重构认证模块", 队友立刻开干。高风险变更应该先过审。
两者结构一样: 一方发带唯一 ID 的请求, 另一方引用同一 ID 响应。
## 解决方案
```
Shutdown Protocol Plan Approval Protocol
================== ======================
Lead Teammate Teammate Lead
| | | |
|--shutdown_req-->| |--plan_req------>|
| {req_id:"abc"} | | {req_id:"xyz"} |
| | | |
|<--shutdown_resp-| |<--plan_resp-----|
| {req_id:"abc", | | {req_id:"xyz", |
| approve:true} | | approve:true} |
Shared FSM:
[pending] --approve--> [approved]
[pending] --reject---> [rejected]
Trackers:
shutdown_requests = {req_id: {target, status}}
plan_requests = {req_id: {from, plan, status}}
```
## 工作原理
1. 领导生成 request_id, 通过收件箱发起关机请求。
```python
shutdown_requests = {}
def handle_shutdown_request(teammate: str) -> str:
req_id = str(uuid.uuid4())[:8]
shutdown_requests[req_id] = {"target": teammate, "status": "pending"}
BUS.send("lead", teammate, "Please shut down gracefully.",
"shutdown_request", {"request_id": req_id})
return f"Shutdown request {req_id} sent (status: pending)"
```
2. 队友收到请求后, 用 approve/reject 响应。
```python
if tool_name == "shutdown_response":
req_id = args["request_id"]
approve = args["approve"]
shutdown_requests[req_id]["status"] = "approved" if approve else "rejected"
BUS.send(sender, "lead", args.get("reason", ""),
"shutdown_response",
{"request_id": req_id, "approve": approve})
```
3. 计划审批遵循完全相同的模式。队友提交计划 (生成 request_id), 领导审查 (引用同一个 request_id)。
```python
plan_requests = {}
def handle_plan_review(request_id, approve, feedback=""):
req = plan_requests[request_id]
req["status"] = "approved" if approve else "rejected"
BUS.send("lead", req["from"], feedback,
"plan_approval_response",
{"request_id": request_id, "approve": approve})
```
一个 FSM, 两种用途。同样的 `pending -> approved | rejected` 状态机可以套用到任何请求-响应协议上。
## 相对 s09 的变更
| 组件 | 之前 (s09) | 之后 (s10) |
|----------------|------------------|--------------------------------------|
| Tools | 9 | 12 (+shutdown_req/resp +plan) |
| 关机 | 仅自然退出 | 请求-响应握手 |
| 计划门控 | 无 | 提交/审查与审批 |
| 关联 | 无 | 每个请求一个 request_id |
| FSM | 无 | pending -> approved/rejected |
## 试一试
```sh
cd learn-claude-code
python agents/s10_team_protocols.py
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
1. `Spawn alice as a coder. Then request her shutdown.`
2. `List teammates to see alice's status after shutdown approval`
3. `Spawn bob with a risky refactoring task. Review and reject his plan.`
4. `Spawn charlie, have him submit a plan, then approve it.`
5. 输入 `/team` 监控状态
+144
View File
@@ -0,0 +1,144 @@
# s11: Autonomous Agents (Autonomous Agent)
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > [ s11 ] s12`
> *"队友自己看看板, 有活就认领"* -- 不需要领导逐个分配, 自组织。
>
> **Harness 层**: 自治 -- 模型自己找活干, 无需指派。
## 问题
s09-s10 中, 队友只在被明确指派时才动。领导得给每个队友写 prompt, 任务看板上 10 个未认领的任务得手动分配。这扩展不了。
真正的自治: 队友自己扫描任务看板, 认领没人做的任务, 做完再找下一个。
一个细节: Context Compact (s06) 后 Agent 可能忘了自己是谁。身份重注入解决这个问题。
## 解决方案
```
Teammate lifecycle with idle cycle:
+-------+
| spawn |
+---+---+
|
v
+-------+ tool_use +-------+
| WORK | <------------- | LLM |
+---+---+ +-------+
|
| stop_reason != tool_use (or idle tool called)
v
+--------+
| IDLE | poll every 5s for up to 60s
+---+----+
|
+---> check inbox --> message? ----------> WORK
|
+---> scan .tasks/ --> unclaimed? -------> claim -> WORK
|
+---> 60s timeout ----------------------> SHUTDOWN
Identity re-injection after compression:
if len(messages) <= 3:
messages.insert(0, identity_block)
```
## 工作原理
1. 队友循环分两个阶段: WORK 和 IDLE。LLM 停止调用工具 (或调用了 `idle`) 时, 进入 IDLE。
```python
def _loop(self, name, role, prompt):
while True:
# -- WORK PHASE --
messages = [{"role": "user", "content": prompt}]
for _ in range(50):
response = client.messages.create(...)
if response.stop_reason != "tool_use":
break
# execute tools...
if idle_requested:
break
# -- IDLE PHASE --
self._set_status(name, "idle")
resume = self._idle_poll(name, messages)
if not resume:
self._set_status(name, "shutdown")
return
self._set_status(name, "working")
```
2. 空闲阶段循环轮询收件箱和任务看板。
```python
def _idle_poll(self, name, messages):
for _ in range(IDLE_TIMEOUT // POLL_INTERVAL): # 60s / 5s = 12
time.sleep(POLL_INTERVAL)
inbox = BUS.read_inbox(name)
if inbox:
messages.append({"role": "user",
"content": f"<inbox>{inbox}</inbox>"})
return True
unclaimed = scan_unclaimed_tasks()
if unclaimed:
claim_task(unclaimed[0]["id"], name)
messages.append({"role": "user",
"content": f"<auto-claimed>Task #{unclaimed[0]['id']}: "
f"{unclaimed[0]['subject']}</auto-claimed>"})
return True
return False # timeout -> shutdown
```
3. 任务看板扫描: 找 pending 状态、无 owner、未被阻塞的任务。
```python
def scan_unclaimed_tasks() -> list:
unclaimed = []
for f in sorted(TASKS_DIR.glob("task_*.json")):
task = json.loads(f.read_text())
if (task.get("status") == "pending"
and not task.get("owner")
and not task.get("blockedBy")):
unclaimed.append(task)
return unclaimed
```
4. 身份重注入: 上下文过短 (说明发生了压缩) 时, 在开头插入身份块。
```python
if len(messages) <= 3:
messages.insert(0, {"role": "user",
"content": f"<identity>You are '{name}', role: {role}, "
f"team: {team_name}. Continue your work.</identity>"})
messages.insert(1, {"role": "assistant",
"content": f"I am {name}. Continuing."})
```
## 相对 s10 的变更
| 组件 | 之前 (s10) | 之后 (s11) |
|----------------|------------------|----------------------------------|
| Tools | 12 | 14 (+idle, +claim_task) |
| 自治性 | 领导指派 | 自组织 |
| 空闲阶段 | 无 | 轮询收件箱 + 任务看板 |
| 任务认领 | 仅手动 | 自动认领未分配任务 |
| 身份 | 系统提示 | + 压缩后重注入 |
| 超时 | 无 | 60 秒空闲 -> 自动关机 |
## 试一试
```sh
cd learn-claude-code
python agents/s11_autonomous_agents.py
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
1. `Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim.`
2. `Spawn a coder teammate and let it find work from the task board itself`
3. `Create tasks with dependencies. Watch teammates respect the blocked order.`
4. 输入 `/tasks` 查看带 owner 的任务看板
5. 输入 `/team` 监控谁在工作、谁在空闲
+123
View File
@@ -0,0 +1,123 @@
# s12: Worktree + Task Isolation (Worktree 任务隔离)
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > [ s12 ]`
> *"各干各的目录, 互不干扰"* -- 任务管目标, worktree 管目录, 按 ID 绑定。
>
> **Harness 层**: 目录隔离 -- 永不碰撞的并行执行通道。
## 问题
到 s11, Agent 已经能自主认领和完成任务。但所有任务共享一个目录。两个 Agent 同时重构不同模块 -- A 改 `config.py`, B 也改 `config.py`, 未提交的改动互相污染, 谁也没法干净回滚。
任务板管 "做什么" 但不管 "在哪做"。解法: 给每个任务一个独立的 git worktree 目录, 用任务 ID 把两边关联起来。
## 解决方案
```
Control plane (.tasks/) Execution plane (.worktrees/)
+------------------+ +------------------------+
| task_1.json | | auth-refactor/ |
| status: in_progress <------> branch: wt/auth-refactor
| worktree: "auth-refactor" | task_id: 1 |
+------------------+ +------------------------+
| task_2.json | | ui-login/ |
| status: pending <------> branch: wt/ui-login
| worktree: "ui-login" | task_id: 2 |
+------------------+ +------------------------+
|
index.json (worktree registry)
events.jsonl (lifecycle log)
State machines:
Task: pending -> in_progress -> completed
Worktree: absent -> active -> removed | kept
```
## 工作原理
1. **创建任务。** 先把目标持久化。
```python
TASKS.create("Implement auth refactor")
# -> .tasks/task_1.json status=pending worktree=""
```
2. **创建 worktree 并绑定任务。** 传入 `task_id` 自动将任务推进到 `in_progress`
```python
WORKTREES.create("auth-refactor", task_id=1)
# -> git worktree add -b wt/auth-refactor .worktrees/auth-refactor HEAD
# -> index.json gets new entry, task_1.json gets worktree="auth-refactor"
```
绑定同时写入两侧状态:
```python
def bind_worktree(self, task_id, worktree):
task = self._load(task_id)
task["worktree"] = worktree
if task["status"] == "pending":
task["status"] = "in_progress"
self._save(task)
```
3. **在 worktree 中执行命令。** `cwd` 指向隔离目录。
```python
subprocess.run(command, shell=True, cwd=worktree_path,
capture_output=True, text=True, timeout=300)
```
4. **收尾。** 两种选择:
- `worktree_keep(name)` -- 保留目录供后续使用。
- `worktree_remove(name, complete_task=True)` -- 删除目录, 完成绑定任务, 发出事件。一个调用搞定拆除 + 完成。
```python
def remove(self, name, force=False, complete_task=False):
self._run_git(["worktree", "remove", wt["path"]])
if complete_task and wt.get("task_id") is not None:
self.tasks.update(wt["task_id"], status="completed")
self.tasks.unbind_worktree(wt["task_id"])
self.events.emit("task.completed", ...)
```
5. **事件流。** 每个生命周期步骤写入 `.worktrees/events.jsonl`:
```json
{
"event": "worktree.remove.after",
"task": {"id": 1, "status": "completed"},
"worktree": {"name": "auth-refactor", "status": "removed"},
"ts": 1730000000
}
```
事件类型: `worktree.create.before/after/failed`, `worktree.remove.before/after/failed`, `worktree.keep`, `task.completed`
崩溃后从 `.tasks/` + `.worktrees/index.json` 重建现场。会话记忆是易失的; 磁盘状态是持久的。
## 相对 s11 的变更
| 组件 | 之前 (s11) | 之后 (s12) |
|--------------------|----------------------------|----------------------------------------------|
| 协调 | 任务板 (owner/status) | 任务板 + worktree 显式绑定 |
| 执行范围 | 共享目录 | 每个任务独立目录 |
| 可恢复性 | 仅任务状态 | 任务状态 + worktree 索引 |
| 收尾 | 任务完成 | 任务完成 + 显式 keep/remove |
| 生命周期可见性 | 隐式日志 | `.worktrees/events.jsonl` 显式事件流 |
## 试一试
```sh
cd learn-claude-code
python agents/s12_worktree_task_isolation.py
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
1. `Create tasks for backend auth and frontend login page, then list tasks.`
2. `Create worktree "auth-refactor" for task 1, then bind task 2 to a new worktree "ui-login".`
3. `Run "git status --short" in worktree "auth-refactor".`
4. `Keep worktree "ui-login", then list worktrees and inspect events.`
5. `Remove worktree "auth-refactor" with complete_task=true, then list tasks/worktrees/events.`
+3
View File
@@ -0,0 +1,3 @@
anthropic>=0.25.0
python-dotenv>=1.0.0
pyyaml>=6.0
+207
View File
@@ -0,0 +1,207 @@
# s01: The Agent Loop — One Loop Is All You Need
[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
`s01` → [s02](../s02_tool_use/) → s03 → s04 → ... → s20
> *"One loop & Bash is all you need"* — One tool + one loop = one Agent.
>
> **Harness Layer**: The Loop — the first bridge between the model and the real world.
---
## The Problem
You ask the model: "List the files in my directory and run XXX.py."
The model can output a bash command, but once it's done outputting, it stops — it won't execute the command on its own, and it won't keep reasoning based on the result.
You could run it manually, paste the output back into the chat, and let it continue. Next command comes out, you run it again, paste it back.
Every round-trip, you're the middle layer. Automating that is what this chapter is about.
---
## The Solution
![Agent Loop](images/agent-loop.en.svg)
A `while True` loop: keep going when the model calls a tool, stop when it doesn't. The entire process hinges on two signals:
| Signal | Meaning | Loop Action |
|--------|---------|-------------|
| `stop_reason == "tool_use"` | Model raises hand: "I need a tool" | Execute → feed result back → continue |
| `stop_reason != "tool_use"` | Model says: "I'm done" | Exit loop |
---
## How It Works
Let's translate this process into code. Step by step:
**Step 1**: Start with the user's question as the first message.
```python
messages = [{"role": "user", "content": query}]
```
**Step 2**: Send the messages and tool definitions to the LLM.
```python
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
```
**Step 3**: Append the model's response and check whether it called a tool. No tool call → done.
```python
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
```
**Step 4**: Execute the tool the model requested and collect the results.
```python
results = []
for block in response.content:
if block.type == "tool_use":
output = run_bash(block.input["command"])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
```
**Step 5**: Append the tool results as a new message and go back to Step 2.
```python
messages.append({"role": "user", "content": results})
```
Assembled into a complete function:
```python
def agent_loop(messages):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
for block in response.content:
if block.type == "tool_use":
output = run_bash(block.input["command"])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
```
Under 30 lines — that's the minimal runnable agent harness kernel. It's not intelligence itself, but the smallest runtime framework that lets the model keep acting. The model decides (whether to call a tool, which one), the harness executes (if called, run it, feed the result back). The next 18 chapters all add mechanisms on top of this loop. The loop itself never changes.
---
## Try It
> **Teaching demo notice**: The code executes shell commands generated by the model. Run it in a temporary test directory to avoid affecting your project files. s03 covers the real permission system.
**Setup** (first run):
```sh
pip install -r requirements.txt
cp .env.example .env
# Edit .env, fill in ANTHROPIC_API_KEY and MODEL_ID
```
**Run**:
```sh
python s01_agent_loop/code.py
```
Try these prompts:
1. `Create a file called hello.py that prints "Hello, World!"`
2. `List all Python files in this directory`
3. `What is the current git branch?`
What to watch for: When does the model call a tool (loop continues), and when does it not (loop ends)?
---
## What's Next
Right now the model only has bash — reading files requires `cat`, writing files requires `echo ... >`, finding files requires `find`. Ugly and error-prone.
→ s02 Tool Use: What happens when we give it 5 proper tools? Will the model call multiple tools at once? Will parallel tool executions step on each other?
<details>
<summary>Dive into CC Source Code</summary>
> The following is based on a review of CC source code `src/query.ts` (1729 lines). The core differences are twofold: CC doesn't rely on the `stop_reason` field to decide whether to continue the loop — instead it checks whether the content contains `tool_use` blocks (because `stop_reason` is unreliable in streaming responses); CC has more exit paths and recovery strategies for production-grade protection.
**The 30-line `while True` from the teaching version IS the core of CC's 1729 lines.** Everything below is a protection mechanism layered on top of that core.
<details>
<summary>1. Loop Structure Differences</summary>
The teaching version checks `response.stop_reason`. CC doesn't use it as the sole signal for loop continuation — in streaming responses, `stop_reason` may not have updated yet even though `tool_use` blocks are already present. CC uses a `needsFollowUp` flag: during streaming message reception (`query.ts:830-834`), it's set to `true` whenever a `tool_use` block is detected. `QueryEngine.ts` captures the real `stop_reason` from `message_delta` for other logic, but the query loop itself relies on `needsFollowUp`.
```typescript
// query.ts:554-558
// stop_reason === 'tool_use' is unreliable.
// Set during streaming whenever a tool_use block arrives.
let needsFollowUp = false
```
</details>
<details>
<summary>2. State Object — 10 Fields (Teaching Version Only Uses messages)</summary>
| # | Field | Purpose | Chapter |
|---|-------|---------|---------|
| 1 | `messages` | Message array for the current iteration | s01 |
| 2 | `toolUseContext` | Tool, signal, and permission context | s02 |
| 3 | `autoCompactTracking` | Compaction state tracking | s08 |
| 4 | `maxOutputTokensRecoveryCount` | Token recovery attempt count (max 3) | s11 |
| 5 | `hasAttemptedReactiveCompact` | Whether reactive compaction was attempted this round | s08 |
| 6 | `maxOutputTokensOverride` | 8K→64K upgrade override | s11 |
| 7 | `pendingToolUseSummary` | Background Haiku-generated tool use summary | s08 |
| 8 | `stopHookActive` | Whether the stop hook produced a blocking error | s04 |
| 9 | `turnCount` | Turn count (for maxTurns check) | s01 |
| 10 | `transition` | Last continue reason | s11 |
> Note: `taskBudgetRemaining` (`query.ts:291`) is a loop-local variable, not on State. The source comment explicitly says "Loop-local (not on State)".
</details>
<details>
<summary>3. Multiple Exit and Continue Paths</summary>
The teaching version has only 1 exit path (model doesn't call a tool → done). The production version has multiple exit and continue paths, covering blocking limit, prompt too long, model error, abort, hook stop, max turns, token budget continuation, reactive compact retry, and more. Each scenario has a corresponding recovery or exit strategy.
</details>
<details>
<summary>4. Streaming Tool Execution and QueryEngine</summary>
CC's `StreamingToolExecutor` (`query.ts:561`) allows tools to begin parallel execution while the model is still generating (concurrency-safe tools run in parallel, others run exclusively). `QueryEngine.ts` adds additional protections for cost overruns, structured output validation failures, and more. The teaching version doesn't implement these — the goal is conceptual clarity, not peak performance.
</details>
**In one sentence**: The core of query.ts's 1729 lines is a 30-line `while True`. All the complex fields and exit paths are protection mechanisms. Understand the core loop first, and everything that follows unfolds naturally.
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+207
View File
@@ -0,0 +1,207 @@
# s01: Agent Loop — ループ一つで十分
[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
`s01` → [s02](../s02_tool_use/) → s03 → s04 → ... → s20
> *"One loop & Bash is all you need"* — ツール一つ + ループ一つ = 一つの Agent。
>
> **Harness レイヤー**: ループ — モデルと現実世界をつなぐ最初の架け橋。
---
## 課題
モデルにこう頼んだとする:「ディレクトリ内のファイル一覧を取得して、XXX.py を実行して」。
モデルは bash コマンドを出力できるが、出力が終わると止まってしまう — 自分で実行することも、結果を見て推論を続けることもない。
手動で実行し、出力をチャットに貼り付ければ、モデルは続きを生成できる。次のコマンドが出たら、また実行して貼り付ける。
毎回の往復で、あなたが中間層になっている。これを自動化するのが、この章の目的だ。
---
## ソリューション
![Agent Loop](images/agent-loop.ja.svg)
一つの `while True` ループ — モデルがツールを呼べば続き、呼ばなければ停止。全体でたった 2 つのシグナル:
| シグナル | 意味 | ループの動作 |
|----------|------|-------------|
| `stop_reason == "tool_use"` | モデルが「ツールが必要」と挙手 | 実行 → 結果を戻す → 続行 |
| `stop_reason != "tool_use"` | モデルが「完了」と宣言 | ループ終了 |
---
## 仕組み
このプロセスをコードに変換してみよう。ステップごとに:
**ステップ 1**:ユーザーの質問を最初のメッセージとして設定する。
```python
messages = [{"role": "user", "content": query}]
```
**ステップ 2**:メッセージとツール定義を一緒に LLM に送信する。
```python
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
```
**ステップ 3**:モデルの応答を追加し、ツールを呼び出したか確認する。呼び出しなし → 終了。
```python
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
```
**ステップ 4**:モデルが要求したツールを実行し、結果を収集する。
```python
results = []
for block in response.content:
if block.type == "tool_use":
output = run_bash(block.input["command"])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
```
**ステップ 5**:ツールの結果を新しいメッセージとして追加し、ステップ 2 に戻る。
```python
messages.append({"role": "user", "content": results})
```
完全な関数に組み立てる:
```python
def agent_loop(messages):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
for block in response.content:
if block.type == "tool_use":
output = run_bash(block.input["command"])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
```
30 行未満 — これが最小実行可能な agent harness のカーネルだ。これは知能そのものではなく、モデルが継続的に行動できるための最小ランタイムフレームワーク。モデルが決定し(ツールを呼ぶか、どれを呼ぶか)、harness が実行する(呼ばれたら実行し、結果を戻す)。次の 18 章はすべてこのループの上に仕組みを積み重ねていく。ループ自体は永遠に変わらない。
---
## 試してみよう
> **教育デモの注意**: このコードはモデルが生成したシェルコマンドを実行します。プロジェクトファイルへの影響を避けるため、一時テストディレクトリで実行してください。s03 で本格的な権限システムを説明します。
**準備**(初回のみ):
```sh
pip install -r requirements.txt
cp .env.example .env
# .env を編集し、ANTHROPIC_API_KEY と MODEL_ID を入力
```
**実行**
```sh
python s01_agent_loop/code.py
```
以下のプロンプトを試してみよう:
1. `Create a file called hello.py that prints "Hello, World!"`
2. `List all Python files in this directory`
3. `What is the current git branch?`
観察のポイント:モデルがツールを呼び出すとき(ループ継続)、呼び出さないとき(ループ終了)の違い。
---
## 次へ
現在、モデルが持っているのは bash だけだ — ファイルを読むには `cat`、書くには `echo ... >`、探すには `find`。不便でエラーも起きやすい。
→ s02 Tool Use:5 つの本格的なツールを与えたらどうなる? モデルは複数のツールを同時に呼び出すか? 並列実行で競合は起きないか?
<details>
<summary>CC ソースコードを深掘り</summary>
> 以下は CC ソースコード `src/query.ts`(1729 行)の検証に基づく。核心的な違いは二つ:CC はループ継続の判断に `stop_reason` フィールドを頼らず、コンテンツに `tool_use` ブロックが含まれるかをチェックする(ストリーミングレスポンスでは `stop_reason` が信頼できないため)。CC には本番環境向けのより多くの終了パスとリカバリ戦略がある。
**教育版の 30 行 `while True` が CC の 1729 行の核心。** 以下の各項目は、すべてその核心の上に積み重ねられた保護機構である。
<details>
<summary>一、ループ構造の違い</summary>
教育版は `response.stop_reason` をチェックする。CC はこれをループ継続の唯一の根拠として使わない — ストリーミングレスポンスでは、`stop_reason` がまだ更新されていなくても、コンテンツに既に `tool_use` ブロックが含まれている可能性がある。CC は `needsFollowUp` フラグを使用する:ストリーミングメッセージの受信時(`query.ts:830-834`)に、`tool_use` ブロックが検出されると `true` に設定される。`QueryEngine.ts``message_delta` から実際の `stop_reason` を取得して他の処理に利用するが、query loop 自体は `needsFollowUp` に依存する。
```typescript
// query.ts:554-558
// stop_reason === 'tool_use' is unreliable.
// Set during streaming whenever a tool_use block arrives.
let needsFollowUp = false
```
</details>
<details>
<summary>二、State オブジェクト 10 フィールド(教育版は messages のみ使用)</summary>
| # | フィールド | 用途 | 対応章 |
|---|-----------|------|--------|
| 1 | `messages` | 現在のイテレーションのメッセージ配列 | s01 |
| 2 | `toolUseContext` | ツール、シグナル、権限コンテキスト | s02 |
| 3 | `autoCompactTracking` | 圧縮状態の追跡 | s08 |
| 4 | `maxOutputTokensRecoveryCount` | トークンリカバリ試行回数(上限 3) | s11 |
| 5 | `hasAttemptedReactiveCompact` | 今回のラウンドでリアクティブ圧縮を試みたか | s08 |
| 6 | `maxOutputTokensOverride` | 8K→64K へのアップグレード上書き | s11 |
| 7 | `pendingToolUseSummary` | バックグラウンド Haiku 生成のツール使用要約 | s08 |
| 8 | `stopHookActive` | 停止フックがブロッキングエラーを発生させたか | s04 |
| 9 | `turnCount` | ターン数(maxTurns チェック用) | s01 |
| 10 | `transition` | 前回の継続理由 | s11 |
> 注:`taskBudgetRemaining``query.ts:291`)は loop-local のローカル変数であり、State には含まれない。ソースコメントには明確に "Loop-local (not on State)" と書かれている。
</details>
<details>
<summary>三、複数の終了パスと継続パス</summary>
教育版には 1 つの終了パスしかない(モデルがツールを呼ばなければ終了)。本番版には複数の終了・継続パスがあり、blocking limit、prompt too long、model error、abort、hook stop、max turns、token budget continuation、reactive compact retry など多くのシナリオをカバーしている。各シナリオには対応するリカバリまたは終了戦略がある。
</details>
<details>
<summary>四、ストリーミングツール実行と QueryEngine</summary>
CC の `StreamingToolExecutor``query.ts:561`)は、モデルがまだ生成中にツールの実行を開始できる(concurrency-safe なツールは並列、それ以外は排他実行)。`QueryEngine.ts` はさらに、コスト超過や構造化出力の検証失敗などの保護を追加する。教育版はこれらを実装しない — 目標は概念の明確さであり、極限のパフォーマンスではない。
</details>
**一言で**: query.ts の 1729 行の核心は 30 行の `while True`。複雑なフィールドや終了パスはすべて保護機構だ。まず核心のループを理解すれば、その後のすべては自然に理解できる。
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+207
View File
@@ -0,0 +1,207 @@
# s01: Agent Loop — 一个循环就够了
[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
`s01` → [s02](../s02_tool_use/) → s03 → s04 → ... → s20
> *"One loop & Bash is all you need"* — 一个工具 + 一个循环 = 一个 Agent。
>
> **Harness 层**: 循环 — 模型与真实世界的第一道连接。
---
## 问题
你提出了一个问题给大模型:“帮我读取下我的目录下有哪些文件,并且执行XXX.py”。
模型能输出一条 bash 命令,但输出完了就停了,它不会自己跑,也不会看到结果后继续推理。
你可以手动跑一遍,把输出粘贴回对话框,让它接着干。下一个命令出来,你再跑一遍、再贴回去。
每一个来回,你都在做中间层。而把它自动化,就是这一章要做的事。
---
## 解决方案
![Agent Loop](images/agent-loop.svg)
一个 `while True` 循环,模型调用工具就继续,不调用就停。整个过程只有两个信号:
| 信号 | 含义 | 循环动作 |
|------|------|---------|
| `stop_reason == "tool_use"` | 模型举手说"我要用工具" | 执行 → 结果喂回去 → 继续 |
| `stop_reason != "tool_use"` | 模型说"我做完了" | 退出循环 |
---
## 工作原理
将这个过程翻译成代码。分步来看:
**第 1 步**:把用户的问题作为第一条消息。
```python
messages = [{"role": "user", "content": query}]
```
**第 2 步**:将消息和工具定义一起发给 LLM。
```python
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
```
**第 3 步**:追加模型回答,检查它是否调了工具。没调 → 结束。
```python
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
```
**第 4 步**:执行模型要求的工具,收集结果。
```python
results = []
for block in response.content:
if block.type == "tool_use":
output = run_bash(block.input["command"])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
```
**第 5 步**:把工具结果作为新消息追加,回到第 2 步。
```python
messages.append({"role": "user", "content": results})
```
组装为一个完整函数:
```python
def agent_loop(messages):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
for block in response.content:
if block.type == "tool_use":
output = run_bash(block.input["command"])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
```
不到 30 行,这就是最小可运行的 agent harness 内核。它不是智能本身,而是让模型能持续行动的最小运行框架,模型负责决策(要不要调工具、调哪个),harness 负责执行(调了就跑、结果喂回去)。后面 18 个章节都在这个循环上叠加机制,循环本身始终不变。
---
## 试一下
> **教学 demo 提示**:代码会执行模型生成的 shell 命令。建议在一个临时测试目录中运行,避免影响你的项目文件。s03 会讲真正的权限系统。
**准备**(首次运行):
```sh
pip install -r requirements.txt
cp .env.example .env
# 编辑 .env,填入 ANTHROPIC_API_KEY 和 MODEL_ID
```
**运行**
```sh
python s01_agent_loop/code.py
```
试试这些 prompt
1. `Create a file called hello.py that prints "Hello, World!"`
2. `List all Python files in this directory`
3. `What is the current git branch?`
观察重点:模型什么时候调用工具(循环继续),什么时候不调用(循环结束)?
---
## 接下来
现在模型手里只有 bash 一个工具,读文件要 `cat`,写文件要 `echo ... >`,找个文件要 `find`,又丑又容易出错。
s02 Tool Use → 给它 5 个真正的工具,会发生什么?模型会不会一次调用多个工具?几个工具同时跑会不会互相踩?
<details>
<summary>深入 CC 源码</summary>
> 以下内容基于 CC 源码 `src/query.ts`1729 行)的核查。核心差异就两个:CC 不看 `stop_reason` 字段而是检查内容里有没有 tool_use 块(因为流式响应中 stop_reason 不可靠);CC 有更多的退出路径和恢复策略做生产级保护。
**教学版的 30 行 `while True` 就是 CC 1729 行的核心。** 下面每一项都是在这个核心上叠加的保护机制。
<details>
<summary>一、循环结构差异</summary>
教学版检查 `response.stop_reason`。CC 不把它作为循环继续的唯一依据——流式响应中 `stop_reason` 可能还没更新但内容里已经有 `tool_use` 块了。CC 用 `needsFollowUp` 标志:接收到流式消息时(`query.ts:830-834`),只要检测到 `tool_use` 块就设为 `true``QueryEngine.ts` 会从 `message_delta` 捕获真实 `stop_reason` 用于其他逻辑,但 query loop 本身靠 `needsFollowUp` 决定是否继续。
```typescript
// query.ts:554-558
// stop_reason === 'tool_use' is unreliable.
// Set during streaming whenever a tool_use block arrives.
let needsFollowUp = false
```
</details>
<details>
<summary>二、State 对象 10 字段(教学版只用 messages</summary>
| # | 字段 | 用途 | 对应章节 |
|---|------|------|---------|
| 1 | `messages` | 当前迭代的消息数组 | s01 |
| 2 | `toolUseContext` | 工具、信号、权限上下文 | s02 |
| 3 | `autoCompactTracking` | 压缩状态追踪 | s08 |
| 4 | `maxOutputTokensRecoveryCount` | token 恢复尝试次数(上限 3 | s11 |
| 5 | `hasAttemptedReactiveCompact` | 本轮是否已尝试响应式压缩 | s08 |
| 6 | `maxOutputTokensOverride` | 8K→64K 的升级覆盖 | s11 |
| 7 | `pendingToolUseSummary` | 后台 Haiku 生成的 tool use 摘要 | s08 |
| 8 | `stopHookActive` | 停止钩子是否产生阻塞错误 | s04 |
| 9 | `turnCount` | 轮次计数(maxTurns 检查) | s01 |
| 10 | `transition` | 上一次继续原因 | s11 |
> 注:`taskBudgetRemaining``query.ts:291`)是 loop-local 局部变量,不在 State 上。源码注释明确写了 "Loop-local (not on State)"。
</details>
<details>
<summary>三、多条退出和继续路径</summary>
教学版只有 1 条退出路径(模型不调工具就结束)。生产版有多条退出和继续路径,覆盖 blocking limit、prompt too long、model error、abort、hook stop、max turns、token budget continuation、reactive compact retry 等场景。每种场景都有对应的恢复或退出策略。
</details>
<details>
<summary>四、流式工具执行和 QueryEngine</summary>
CC 的 `StreamingToolExecutor``query.ts:561`)让工具在模型还在生成时就开始并行执行(根据工具是否 concurrency-safe 决定并发或独占)。`QueryEngine.ts` 额外加了费用超限、结构化输出验证失败等保护。教学版不实现这些——目标是概念清晰,不是性能极致。
</details>
**一句话**1729 行的 query.ts 核心就是 30 行 `while True`。所有复杂字段和退出路径都是保护机制。先理解核心循环,后面的一切自然展开。
</details>
<!-- translation-sync: zh@v1, en@v0, ja@v0 -->
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""
s01_agent_loop.py - The Agent Loop
The entire secret of an AI coding agent in one pattern:
while stop_reason == "tool_use":
response = LLM(messages, tools)
execute tools
append results
+----------+ +-------+ +---------+
| User | ---> | LLM | ---> | Tool |
| prompt | | | | execute |
+----------+ +---+---+ +----+----+
^ |
| tool_result |
+---------------+
(loop continues)
This is the core loop: feed tool results back to the model
until the model decides to stop. Production agents layer
policy, hooks, and lifecycle controls on top.
Usage:
pip install anthropic python-dotenv
ANTHROPIC_API_KEY=... python s01_agent_loop/code.py
"""
import os
import subprocess
try:
import readline
# macOS 的 libedit 在处理中文输入时有退格问题,这四行修复它
readline.parse_and_bind('set bind-tty-special-chars off')
readline.parse_and_bind('set input-meta on')
readline.parse_and_bind('set output-meta on')
readline.parse_and_bind('set convert-meta off')
except ImportError:
pass
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
SYSTEM = f"You are a coding agent at {os.getcwd()}. Use bash to solve tasks. Act, don't explain."
# ── Tool definition: just bash ────────────────────────────
TOOLS = [{
"name": "bash",
"description": "Run a shell command.",
"input_schema": {
"type": "object",
"properties": {"command": {"type": "string"}},
"required": ["command"],
},
}]
# ── Tool execution ────────────────────────────────────────
def run_bash(command: str) -> str:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
try:
r = subprocess.run(command, shell=True, cwd=os.getcwd(),
capture_output=True, text=True, timeout=120)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
except (FileNotFoundError, OSError) as e:
return f"Error: {e}"
# ── The core pattern: a while loop that calls tools until the model stops ──
def agent_loop(messages: list):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
# Append assistant turn
messages.append({"role": "assistant", "content": response.content})
# If the model didn't call a tool, we're done
if response.stop_reason != "tool_use":
return
# Execute each tool call, collect results
results = []
for block in response.content:
if block.type == "tool_use":
print(f"\033[33m$ {block.input['command']}\033[0m")
output = run_bash(block.input["command"])
print(output[:200])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
# Feed tool results back, loop continues
messages.append({"role": "user", "content": results})
# ── Entry point ──────────────────────────────────────────
if __name__ == "__main__":
print("s01: Agent Loop")
print("输入问题,回车发送。输入 q 退出。\n")
history = []
while True:
try:
query = input("\033[36ms01 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
history.append({"role": "user", "content": query})
agent_loop(history)
# Print the model's final text response
response_content = history[-1]["content"]
if isinstance(response_content, list):
for block in response_content:
if getattr(block, "type", None) == "text":
print(block.text)
print()
+86
View File
@@ -0,0 +1,86 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 530" font-family="system-ui, -apple-system, sans-serif">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555"/>
</marker>
<marker id="arrow-blue" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#2563eb"/>
</marker>
<marker id="arrow-green" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#16a34a"/>
</marker>
<linearGradient id="header" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#1e3a5f"/>
<stop offset="100%" stop-color="#2563eb"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="720" height="530" fill="#fafbfc" rx="8"/>
<!-- Title -->
<rect x="0" y="0" width="720" height="48" fill="url(#header)" rx="8"/>
<rect x="0" y="40" width="720" height="8" fill="url(#header)"/>
<text x="360" y="31" fill="#fff" font-size="16" font-weight="700" text-anchor="middle">Agent Loop — A while Loop Drives the Entire Agent</text>
<!-- ===== User Input ===== -->
<rect x="60" y="80" width="160" height="52" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="2"/>
<text x="140" y="102" fill="#1e3a5f" font-size="13" font-weight="600" text-anchor="middle">User Query</text>
<text x="140" y="120" fill="#64748b" font-size="11" text-anchor="middle">"Create hello.py for me"</text>
<!-- Arrow: User → Messages -->
<line x1="140" y1="132" x2="140" y2="162" stroke="#2563eb" stroke-width="1.5" marker-end="url(#arrow-blue)"/>
<!-- ===== Messages ===== -->
<rect x="60" y="164" width="160" height="48" rx="8" fill="#f8fafc" stroke="#94a3b8" stroke-width="1.5" stroke-dasharray="4,2"/>
<text x="140" y="185" fill="#334155" font-size="12" font-weight="500" text-anchor="middle">messages[]</text>
<text x="140" y="201" fill="#94a3b8" font-size="10" text-anchor="middle">Accumulated message list</text>
<!-- Arrow: Messages → LLM -->
<line x1="220" y1="188" x2="288" y2="188" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<!-- ===== LLM ===== -->
<rect x="290" y="140" width="180" height="96" rx="8" fill="#fff" stroke="#2563eb" stroke-width="2"/>
<text x="380" y="166" fill="#1e3a5f" font-size="14" font-weight="700" text-anchor="middle">LLM</text>
<line x1="310" y1="176" x2="450" y2="176" stroke="#e2e8f0" stroke-width="1"/>
<text x="380" y="194" fill="#475569" font-size="11" text-anchor="middle">Model reads message history</text>
<text x="380" y="210" fill="#475569" font-size="11" text-anchor="middle">Decision: Need a tool?</text>
<text x="380" y="228" fill="#64748b" font-size="10" text-anchor="middle">Returns stop_reason signal</text>
<!-- Arrow: LLM → Decision (down) -->
<line x1="380" y1="236" x2="380" y2="276" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<!-- ===== Decision Diamond ===== -->
<polygon points="380,280 470,316 380,352 290,316" fill="#fff8f0" stroke="#d97706" stroke-width="2"/>
<text x="380" y="312" fill="#92400e" font-size="12" font-weight="600" text-anchor="middle">stop_reason</text>
<text x="380" y="326" fill="#92400e" font-size="10" text-anchor="middle">== "tool_use"?</text>
<!-- Arrow: No → End (right) -->
<line x1="470" y1="316" x2="540" y2="316" stroke="#16a34a" stroke-width="2" marker-end="url(#arrow-green)"/>
<text x="505" y="308" fill="#16a34a" font-size="10" font-weight="600" text-anchor="middle">No</text>
<!-- End Node -->
<rect x="542" y="294" width="130" height="44" rx="22" fill="#dcfce7" stroke="#16a34a" stroke-width="2"/>
<text x="607" y="313" fill="#166534" font-size="12" font-weight="600" text-anchor="middle">Return Result</text>
<text x="607" y="329" fill="#166534" font-size="10" text-anchor="middle">Loop Ends</text>
<!-- Arrow: Yes → Tool Execution (down) -->
<line x1="380" y1="352" x2="380" y2="392" stroke="#d97706" stroke-width="2" marker-end="url(#arrow)"/>
<text x="395" y="376" fill="#d97706" font-size="10" font-weight="600">Yes</text>
<!-- ===== Tool Execution ===== -->
<rect x="290" y="394" width="180" height="48" rx="8" fill="#fff7ed" stroke="#d97706" stroke-width="1.5"/>
<text x="380" y="415" fill="#92400e" font-size="12" font-weight="600" text-anchor="middle">Execute Tool Call</text>
<text x="380" y="432" fill="#92400e" font-size="10" text-anchor="middle">run_bash(command)</text>
<!-- Arrow: Tool result → Append to messages (loop back) -->
<path d="M 290 418 L 40 418 L 40 188 L 58 188" fill="none" stroke="#d97706" stroke-width="1.5" marker-end="url(#arrow)" stroke-dasharray="6,3"/>
<text x="18" y="375" fill="#92400e" font-size="10" font-weight="500" transform="rotate(-90, 18, 375)">Append tool_result to messages</text>
<!-- Legend -->
<rect x="60" y="462" width="600" height="48" rx="6" fill="#f1f5f9"/>
<text x="80" y="482" fill="#334155" font-size="10">Core: a</text>
<text x="113" y="482" fill="#1e3a5f" font-size="10" font-weight="700" font-family="monospace">while True</text>
<text x="186" y="482" fill="#334155" font-size="10">loop. Model calls tool → Execute → Feed back → Ask again. No tool call → Stop.</text>
<text x="80" y="500" fill="#64748b" font-size="10">All subsequent chapters layer mechanisms on top of this loop.</text>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

+86
View File
@@ -0,0 +1,86 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 530" font-family="system-ui, -apple-system, sans-serif">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555"/>
</marker>
<marker id="arrow-blue" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#2563eb"/>
</marker>
<marker id="arrow-green" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#16a34a"/>
</marker>
<linearGradient id="header" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#1e3a5f"/>
<stop offset="100%" stop-color="#2563eb"/>
</linearGradient>
</defs>
<!-- 背景 -->
<rect width="720" height="530" fill="#fafbfc" rx="8"/>
<!-- タイトル -->
<rect x="0" y="0" width="720" height="48" fill="url(#header)" rx="8"/>
<rect x="0" y="40" width="720" height="8" fill="url(#header)"/>
<text x="360" y="31" fill="#fff" font-size="15" font-weight="700" text-anchor="middle">Agent Loop — 一つの while ループで Agent 全体を駆動</text>
<!-- ===== ユーザー入力 ===== -->
<rect x="60" y="80" width="160" height="52" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="2"/>
<text x="140" y="102" fill="#1e3a5f" font-size="13" font-weight="600" text-anchor="middle">ユーザーの質問</text>
<text x="140" y="120" fill="#64748b" font-size="11" text-anchor="middle">"hello.py を作って"</text>
<!-- 矢印:ユーザー → メッセージリスト -->
<line x1="140" y1="132" x2="140" y2="162" stroke="#2563eb" stroke-width="1.5" marker-end="url(#arrow-blue)"/>
<!-- ===== メッセージリスト ===== -->
<rect x="60" y="164" width="160" height="48" rx="8" fill="#f8fafc" stroke="#94a3b8" stroke-width="1.5" stroke-dasharray="4,2"/>
<text x="140" y="185" fill="#334155" font-size="12" font-weight="500" text-anchor="middle">messages[]</text>
<text x="140" y="201" fill="#94a3b8" font-size="10" text-anchor="middle">累積メッセージリスト</text>
<!-- 矢印:メッセージ → LLM -->
<line x1="220" y1="188" x2="288" y2="188" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<!-- ===== LLM ===== -->
<rect x="290" y="140" width="180" height="96" rx="8" fill="#fff" stroke="#2563eb" stroke-width="2"/>
<text x="380" y="166" fill="#1e3a5f" font-size="14" font-weight="700" text-anchor="middle">大規模言語モデル (LLM)</text>
<line x1="310" y1="176" x2="450" y2="176" stroke="#e2e8f0" stroke-width="1"/>
<text x="380" y="194" fill="#475569" font-size="11" text-anchor="middle">モデルがメッセージ履歴を読む</text>
<text x="380" y="210" fill="#475569" font-size="11" text-anchor="middle">判断:ツールが必要か?</text>
<text x="380" y="228" fill="#64748b" font-size="10" text-anchor="middle">stop_reason シグナルを返す</text>
<!-- 矢印:LLM → 判定(下) -->
<line x1="380" y1="236" x2="380" y2="276" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<!-- ===== 判定ダイヤモンド ===== -->
<polygon points="380,280 470,316 380,352 290,316" fill="#fff8f0" stroke="#d97706" stroke-width="2"/>
<text x="380" y="312" fill="#92400e" font-size="12" font-weight="600" text-anchor="middle">stop_reason</text>
<text x="380" y="326" fill="#92400e" font-size="10" text-anchor="middle">== "tool_use"?</text>
<!-- 矢印:いいえ → 終了(右) -->
<line x1="470" y1="316" x2="540" y2="316" stroke="#16a34a" stroke-width="2" marker-end="url(#arrow-green)"/>
<text x="505" y="308" fill="#16a34a" font-size="10" font-weight="600" text-anchor="middle">No</text>
<!-- 終了ノード -->
<rect x="542" y="294" width="130" height="44" rx="22" fill="#dcfce7" stroke="#16a34a" stroke-width="2"/>
<text x="607" y="313" fill="#166534" font-size="12" font-weight="600" text-anchor="middle">結果を返す</text>
<text x="607" y="329" fill="#166534" font-size="10" text-anchor="middle">ループ終了</text>
<!-- 矢印:はい → ツール実行(下) -->
<line x1="380" y1="352" x2="380" y2="392" stroke="#d97706" stroke-width="2" marker-end="url(#arrow)"/>
<text x="395" y="376" fill="#d97706" font-size="10" font-weight="600">Yes</text>
<!-- ===== ツール実行 ===== -->
<rect x="290" y="394" width="180" height="48" rx="8" fill="#fff7ed" stroke="#d97706" stroke-width="1.5"/>
<text x="380" y="415" fill="#92400e" font-size="12" font-weight="600" text-anchor="middle">ツール呼び出しを実行</text>
<text x="380" y="432" fill="#92400e" font-size="10" text-anchor="middle">run_bash(command)</text>
<!-- 矢印:ツール結果 → メッセージに追加(ループバック) -->
<path d="M 290 418 L 40 418 L 40 188 L 58 188" fill="none" stroke="#d97706" stroke-width="1.5" marker-end="url(#arrow)" stroke-dasharray="6,3"/>
<text x="18" y="375" fill="#92400e" font-size="10" font-weight="500" transform="rotate(-90, 18, 375)">tool_result を messages に追加</text>
<!-- 凡例 -->
<rect x="60" y="462" width="600" height="48" rx="6" fill="#f1f5f9"/>
<text x="80" y="482" fill="#334155" font-size="10">核心:一つの</text>
<text x="138" y="482" fill="#1e3a5f" font-size="10" font-weight="700" font-family="monospace">while True</text>
<text x="210" y="482" fill="#334155" font-size="10">ループ。ツール呼出 → 実行 → 結果を戻す → 再度問う。ツールなし → 停止。</text>
<text x="80" y="500" fill="#64748b" font-size="10">以降の全章がこのループの上に仕組みを積み重ねる。</text>
</svg>

After

Width:  |  Height:  |  Size: 5.7 KiB

+86
View File
@@ -0,0 +1,86 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 530" font-family="system-ui, -apple-system, sans-serif">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555"/>
</marker>
<marker id="arrow-blue" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#2563eb"/>
</marker>
<marker id="arrow-green" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#16a34a"/>
</marker>
<linearGradient id="header" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#1e3a5f"/>
<stop offset="100%" stop-color="#2563eb"/>
</linearGradient>
</defs>
<!-- 背景 -->
<rect width="720" height="530" fill="#fafbfc" rx="8"/>
<!-- 标题 -->
<rect x="0" y="0" width="720" height="48" fill="url(#header)" rx="8"/>
<rect x="0" y="40" width="720" height="8" fill="url(#header)"/>
<text x="360" y="31" fill="#fff" font-size="16" font-weight="700" text-anchor="middle">Agent Loop — 一个 while 循环驱动整个 Agent</text>
<!-- ===== 用户输入 ===== -->
<rect x="60" y="80" width="160" height="52" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="2"/>
<text x="140" y="102" fill="#1e3a5f" font-size="13" font-weight="600" text-anchor="middle">用户提问</text>
<text x="140" y="120" fill="#64748b" font-size="11" text-anchor="middle">"帮我创建 hello.py"</text>
<!-- 箭头:用户 → 消息列表 -->
<line x1="140" y1="132" x2="140" y2="162" stroke="#2563eb" stroke-width="1.5" marker-end="url(#arrow-blue)"/>
<!-- ===== 消息列表 ===== -->
<rect x="60" y="164" width="160" height="48" rx="8" fill="#f8fafc" stroke="#94a3b8" stroke-width="1.5" stroke-dasharray="4,2"/>
<text x="140" y="185" fill="#334155" font-size="12" font-weight="500" text-anchor="middle">messages[]</text>
<text x="140" y="201" fill="#94a3b8" font-size="10" text-anchor="middle">累积式消息列表</text>
<!-- 箭头:消息列表 → LLM -->
<line x1="220" y1="188" x2="288" y2="188" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<!-- ===== LLM ===== -->
<rect x="290" y="140" width="180" height="96" rx="8" fill="#fff" stroke="#2563eb" stroke-width="2"/>
<text x="380" y="166" fill="#1e3a5f" font-size="14" font-weight="700" text-anchor="middle">大模型 (LLM)</text>
<line x1="310" y1="176" x2="450" y2="176" stroke="#e2e8f0" stroke-width="1"/>
<text x="380" y="194" fill="#475569" font-size="11" text-anchor="middle">模型阅读消息历史</text>
<text x="380" y="210" fill="#475569" font-size="11" text-anchor="middle">判断:需要工具吗?</text>
<text x="380" y="228" fill="#64748b" font-size="10" text-anchor="middle">返回 stop_reason 信号</text>
<!-- 箭头:LLM → 判断(向下) -->
<line x1="380" y1="236" x2="380" y2="276" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<!-- ===== 判断菱形 ===== -->
<polygon points="380,280 470,316 380,352 290,316" fill="#fff8f0" stroke="#d97706" stroke-width="2"/>
<text x="380" y="312" fill="#92400e" font-size="12" font-weight="600" text-anchor="middle">stop_reason</text>
<text x="380" y="326" fill="#92400e" font-size="10" text-anchor="middle">== "tool_use"?</text>
<!-- 箭头:否 → 结束(向右) -->
<line x1="470" y1="316" x2="540" y2="316" stroke="#16a34a" stroke-width="2" marker-end="url(#arrow-green)"/>
<text x="505" y="308" fill="#16a34a" font-size="10" font-weight="600" text-anchor="middle"></text>
<!-- 结束节点 -->
<rect x="542" y="294" width="130" height="44" rx="22" fill="#dcfce7" stroke="#16a34a" stroke-width="2"/>
<text x="607" y="313" fill="#166534" font-size="12" font-weight="600" text-anchor="middle">返回结果</text>
<text x="607" y="329" fill="#166534" font-size="10" text-anchor="middle">循环结束</text>
<!-- 箭头:是 → 工具执行(向下) -->
<line x1="380" y1="352" x2="380" y2="392" stroke="#d97706" stroke-width="2" marker-end="url(#arrow)"/>
<text x="395" y="376" fill="#d97706" font-size="10" font-weight="600"></text>
<!-- ===== 工具执行 ===== -->
<rect x="290" y="394" width="180" height="48" rx="8" fill="#fff7ed" stroke="#d97706" stroke-width="1.5"/>
<text x="380" y="415" fill="#92400e" font-size="12" font-weight="600" text-anchor="middle">执行工具调用</text>
<text x="380" y="432" fill="#92400e" font-size="10" text-anchor="middle">run_bash(command)</text>
<!-- 箭头:工具结果 → 追加到消息列表(向左弯回去) -->
<path d="M 290 418 L 40 418 L 40 188 L 58 188" fill="none" stroke="#d97706" stroke-width="1.5" marker-end="url(#arrow)" stroke-dasharray="6,3"/>
<text x="18" y="375" fill="#92400e" font-size="10" font-weight="500" transform="rotate(-90, 18, 375)">追加 tool_result 到 messages</text>
<!-- 图例 -->
<rect x="60" y="462" width="600" height="48" rx="6" fill="#f1f5f9"/>
<text x="80" y="482" fill="#334155" font-size="10">核心:一个</text>
<text x="148" y="482" fill="#1e3a5f" font-size="10" font-weight="700" font-family="monospace">while True</text>
<text x="220" y="482" fill="#334155" font-size="10">循环。模型调工具 → 执行 → 喂回 → 再问。不调工具就停。</text>
<text x="80" y="500" fill="#64748b" font-size="10">后续所有章节都在这个循环上叠加机制。</text>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

+222
View File
@@ -0,0 +1,222 @@
# s02: Tool Use — Add a Tool, Add Just One Line
[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
s01 → `s02` → [s03](../s03_permission/) → s04 → ... → s20
> *"Add a tool, add just one handler"* — The loop stays the same. Register the new tool in the dispatch map and you're done.
>
> **Harness Layer**: Tool Dispatch — Expanding the model's reach.
---
## Only One Tool: Bash
The s01 Agent has only one tool: bash. To read a file, `cat`; to write, `echo "..." > file.py`; to edit, `sed`.
The model thinks "read this file" but has to spell out `cat path/to/file`. An extra layer of translation that wastes tokens and invites errors.
---
## Overview: Tool Dispatch
![Tool Dispatch](images/tool-dispatch.en.svg)
The s01 loop is fully preserved (LLM call, stop_reason check, message append — not a single word changed). The only change is in that one line of tool execution: `run_bash()` is replaced with `TOOL_HANDLERS[block.name]()` dispatch lookup.
Adding a tool to the Agent requires just two things:
1. **Define the tool**: Add one entry to the `TOOLS` array
2. **Register the handler**: Add one mapping in the `TOOL_HANDLERS` dict
---
## From 1 Tool to 5 Tools
s01 had only bash:
```python
TOOLS = [{"name": "bash", ...}]
def run_bash(command): ...
```
s02 expands to 5 tools, each independently defined:
```python
TOOLS = [
{"name": "bash", "description": "Run a shell command.", ...},
{"name": "read_file", "description": "Read file contents.", ...},
{"name": "write_file", "description": "Write content to file.", ...},
{"name": "edit_file", "description": "Replace text in file once.", ...},
{"name": "glob", "description": "Find files by pattern.", ...},
]
```
Each tool has its own implementation function:
```python
def run_read(path, limit=None):
lines = safe_path(path).read_text().splitlines()
if limit:
lines = lines[:limit]
return "\n".join(lines)
def run_write(path, content):
safe_path(path).write_text(content)
return f"Wrote {len(content)} bytes to {path}"
def run_edit(path, old_text, new_text):
text = safe_path(path).read_text()
if old_text not in text:
return "Error: text not found"
safe_path(path).write_text(text.replace(old_text, new_text, 1))
return f"Edited {path}"
def run_glob(pattern):
import glob as g
return "\n".join(g.glob(pattern, root_dir=WORKDIR))
```
---
## Tool Dispatch
```python
TOOL_HANDLERS = {
"bash": run_bash,
"read_file": run_read,
"write_file": run_write,
"edit_file": run_edit,
"glob": run_glob,
}
# Only one line changed in the loop — from hardcoded run_bash to dispatch lookup:
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS[block.name] # lookup
output = handler(**block.input) # call
results.append(...)
```
Adding a tool = one entry in `TOOLS` array + one line in `TOOL_HANDLERS` dict. The loop stays the same.
---
## Multiple Tool Calls
The model often returns multiple tool_use calls at once — "read a.py and b.py, then list all .py files".
The teaching version executes them one by one in the original `response.content` order. CC's approach is more complex: it slices the original order into consecutive batches, where concurrency-safe tools within a batch run in parallel, and batches are strictly sequential (see appendix).
---
## Quick Reference
| Concept | One-Liner |
|---------|-----------|
| TOOL_HANDLERS | Tool name → handler function dict. Add a tool = add one mapping line |
| Tool Definition | JSON schema telling the model "what I can do" |
| Multiple tool calls | Model may return multiple tool_use at once; teaching version executes them in original order |
| Loop Unchanged | s01's `while True` loop — not a single line changed |
---
## Changes from s01
| Component | Before (s01) | After (s02) |
|-----------|-------------|-------------|
| Tool count | 1 (bash) | 5 (+read, write, edit, glob) |
| Tool execution | Hardcoded `run_bash()` | TOOL_HANDLERS dispatch lookup |
| Path safety | None | safe_path validation (file tools only) |
| Loop | `while True` + `stop_reason` | Identical to s01 |
---
## Try It
```sh
cd learn-claude-code
python s02_tool_use/code.py
```
Try these prompts:
1. `Read the file README.md and tell me what this project is about`
2. `Create a file called test.py that prints "hello", then read it back`
3. `Find all Python files in this directory`
4. `Read both README.md and requirements.txt, then create a summary file`
What to watch for: When does the model call just one tool, and when does it call multiple at once? Are multiple tool calls executed in the correct order?
---
## What's Next
The Agent now has 5 specialized tools. File tools are protected by `safe_path`, but bash is unrestricted — `rm -rf /` still runs.
→ s03 Permission: Add a gate before tool execution — is this operation safe? Does it need user approval?
<details>
<summary>Dive into CC Source Code</summary>
> The following is based on a review of CC source code `Tool.ts`, `tools.ts`, `toolOrchestration.ts`, `toolExecution.ts`, and `StreamingToolExecutor.ts`.
### 1. Tool Definition Approach
**Teaching version**: `TOOLS` array + `TOOL_HANDLERS` dict. Definition and implementation are separate.
**CC**: Each tool is an independent object created by `buildTool()`, containing schema, validation, permissions, and execution. `getAllBaseTools()` aggregates all tools.
The teaching version's separation is clearer for teaching — readers immediately see "add a tool = two definitions".
### 2. Concurrency Safety: isConcurrencySafe()
![Tool Concurrency](images/concurrency-comparison.en.svg)
The teaching version executes tools one by one in original order, without concurrency. CC uses `isConcurrencySafe(input)` to determine concurrency — note this isn't simply "read-only vs write", but judges by specific input:
| | isReadOnly | isConcurrencySafe |
|---|---|---|
| FileRead | true | true |
| Glob | true | true |
| Bash `ls` | true | **true** ← key difference |
| Bash `rm` | false | false |
| TaskCreate | false | **true** ← modifies state but can be concurrent (introduced in s12) |
CC's Bash tool's `isConcurrencySafe` equals `isReadOnly` — read-only commands can be concurrent, write commands cannot. TaskCreate modifies task files, but each writes a different file, so it can be concurrent.
### 3. Partition Algorithm
CC's `partitionToolCalls()` (`toolOrchestration.ts:91-115`) doesn't split into two groups — it batches tool calls **by consecutive blocks**:
```
[read A, read B, glob *.py, bash "rm x", read C]
→ batch1(concurrent): [read A, read B, glob *.py]
→ batch2(serial): [bash "rm x"]
→ batch3(concurrent): [read C]
```
Consecutive concurrency-safe calls are grouped into the same batch for truly concurrent execution (`toolOrchestration.ts:152-176`, with a concurrency limit). When a non-concurrency-safe call is encountered, a new batch starts for serial execution. Batches are strictly sequential.
### 4. Validation Pipeline
Each tool call in CC goes through a strict 5-step validation (`toolExecution.ts`):
1. **Zod schema validation** (`614-680`, teaching version uses JSON Schema): parameter type/structure check
2. **Tool-level validateInput()** (`682-733`): parameter value validation (e.g., is the path within the working directory)
3. **PreToolUse hooks** (`800-862`, covered in s04): hooks can return messages, modify input, or block execution
4. **Permission check** (`921-931`, core topic of s03): canUseTool + checkPermissions → allow/deny/ask
5. **Execute tool.call()** (`1207-1222`)
The teaching version omits Zod (uses JSON Schema), omits validateInput (uses safety functions), but preserves the permission check and hook concepts.
### 5. Streaming Tool Execution
CC's `StreamingToolExecutor` (`StreamingToolExecutor.ts`) starts tools while the model is still generating — no waiting for the model to finish. `read_file` might complete while the model is still outputting "Let me analyze". The teaching version doesn't implement this, consistent with s01's goal — conceptual clarity, not peak performance.
### 6. Tool Result Persistence
Each tool has a `maxResultSizeChars` field. Results exceeding this threshold are persisted to disk, and the model sees a preview + file path. FileRead is special — set to `Infinity`, preventing file read output from being persisted again. Specifically, if FileRead's result exceeds the threshold and gets persisted, the model's next read of that persisted file would trigger another persistence → infinite loop (read file → persist → re-read → re-persist → ...).
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+222
View File
@@ -0,0 +1,222 @@
# s02: Tool Use — ツール一つ追加、一行追加だけ
[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
s01 → `s02` → [s03](../s03_permission/) → s04 → ... → s20
> *"ツールを一つ追加、ハンドラを一つ追加"* — ループはそのまま。新しいツールをディスパッチマップに登録するだけ。
>
> **Harness レイヤー**: ツールディスパッチ — モデルが触れる範囲を拡張。
---
## ツールは bash 一つだけ
s01 の Agent には bash 一つのツールしかない。ファイルを読むには `cat`、書くには `echo "..." > file.py`、編集するには `sed`
モデルは「このファイルを読みたい」と考えながら、`cat path/to/file` と組み立てなければならない。翻訳の層が一つ増え、トークンを無駄にし、エラーも起きやすい。
---
## 概要:ツールディスパッチ
![Tool Dispatch](images/tool-dispatch.ja.svg)
s01 のループは完全に保持される(LLM 呼び出し、stop_reason 判定、メッセージ追加 — 一文字も変更なし)。唯一の変更点はツール実行の 1 行:`run_bash()``TOOL_HANDLERS[block.name]()` の検索ディスパッチに置き換わる。
Agent にツールを追加するには、たった二つ:
1. **ツールを定義**`TOOLS` 配列に一条を追加
2. **ハンドラを登録**`TOOL_HANDLERS` 辞書に一つのマッピングを追加
---
## 1 つのツールから 5 つのツールへ
s01 には bash だけだった:
```python
TOOLS = [{"name": "bash", ...}]
def run_bash(command): ...
```
s02 では 5 つに増え、各ツールは独立して定義される:
```python
TOOLS = [
{"name": "bash", "description": "Run a shell command.", ...},
{"name": "read_file", "description": "Read file contents.", ...},
{"name": "write_file", "description": "Write content to file.", ...},
{"name": "edit_file", "description": "Replace text in file once.", ...},
{"name": "glob", "description": "Find files by pattern.", ...},
]
```
各ツールには専用の実装関数がある:
```python
def run_read(path, limit=None):
lines = safe_path(path).read_text().splitlines()
if limit:
lines = lines[:limit]
return "\n".join(lines)
def run_write(path, content):
safe_path(path).write_text(content)
return f"Wrote {len(content)} bytes to {path}"
def run_edit(path, old_text, new_text):
text = safe_path(path).read_text()
if old_text not in text:
return "Error: text not found"
safe_path(path).write_text(text.replace(old_text, new_text, 1))
return f"Edited {path}"
def run_glob(pattern):
import glob as g
return "\n".join(g.glob(pattern, root_dir=WORKDIR))
```
---
## ツールディスパッチ
```python
TOOL_HANDLERS = {
"bash": run_bash,
"read_file": run_read,
"write_file": run_write,
"edit_file": run_edit,
"glob": run_glob,
}
# ループ内で変更されたのは一行だけ — ハードコードの run_bash から検索ディスパッチへ:
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS[block.name] # 検索
output = handler(**block.input) # 呼び出し
results.append(...)
```
ツールの追加 = `TOOLS` 配列に一条 + `TOOL_HANDLERS` 辞書に一行。ループは変わらない。
---
## 複数のツール呼び出し
モデルはよく一度に複数の tool_use を返す — 「a.py と b.py を読んで、全 .py ファイルを列挙して」。
教育版は `response.content` の元の順序で一つずつ実行する。CC のやり方はより複雑:元の順序を保ったまま連続バッチに分割し、バッチ内の並列安全なツールを並行実行し、バッチ間は厳密に順次(付録を参照)。
---
## 速查
| 概念 | 一言で |
|------|--------|
| TOOL_HANDLERS | ツール名 → ハンドラ関数の辞書。ツール追加 = マッピング一行追加 |
| ツール定義 | モデルに「何ができるか」を伝える JSON schema |
| 複数ツール呼び出し | モデルは一度に複数の tool_use を返す可能性がある。教育版は元の順序で一つずつ実行 |
| ループ不変 | s01 の `while True` ループ — 一行も変更なし |
---
## s01 からの変更
| コンポーネント | 変更前 (s01) | 変更後 (s02) |
|--------------|-------------|-------------|
| ツール数 | 1 (bash) | 5 (+read, write, edit, glob) |
| ツール実行 | ハードコード `run_bash()` | TOOL_HANDLERS 検索ディスパッチ |
| パス安全性 | なし | safe_path 検証(file tools のみ) |
| ループ | `while True` + `stop_reason` | s01 と完全に同一 |
---
## 試してみよう
```sh
cd learn-claude-code
python s02_tool_use/code.py
```
以下のプロンプトを試してみよう:
1. `Read the file README.md and tell me what this project is about`
2. `Create a file called test.py that prints "hello", then read it back`
3. `Find all Python files in this directory`
4. `Read both README.md and requirements.txt, then create a summary file`
観察のポイント:モデルがツールを一つだけ呼び出すときと、複数同時に呼び出すときの違い。複数のツール呼び出しは正しい順序で実行されているか?
---
## 次へ
Agent は 5 つの専用ツールを持つようになった。file tools は `safe_path` で保護されるが、bash は制限なし — `rm -rf /` はまだ実行できる。
→ s03 Permission:ツール実行前にゲートを追加 — この操作は安全か? ユーザーの承認が必要か?
<details>
<summary>CC ソースコードを深掘り</summary>
> 以下は CC ソースコード `Tool.ts`、`tools.ts`、`toolOrchestration.ts`、`toolExecution.ts`、`StreamingToolExecutor.ts` の検証に基づく。
### 一、ツール定義方式
**教育版**`TOOLS` 配列 + `TOOL_HANDLERS` 辞書。定義と実装が分離。
**CC**:各ツールは `buildTool()` で作成された独立オブジェクトで、schema、バリデーション、権限、実行を含む。`getAllBaseTools()` が全ツールを集約。
教育版の分離方式は教学に適している — 読者は「ツール追加 = 二つの定義」と一目で分かる。
### 二、並列安全性:isConcurrencySafe()
![Tool Concurrency](images/concurrency-comparison.ja.svg)
教育版は元の順序で一つずつ実行し、並列処理は行わない。CC は `isConcurrencySafe(input)` で並列可否を判断する — これは単なる「読み取り専用 vs 書き込み」ではなく、具体的な入力で判断する:
| | isReadOnly | isConcurrencySafe |
|---|---|---|
| FileRead | true | true |
| Glob | true | true |
| Bash `ls` | true | **true** ← 重要な違い |
| Bash `rm` | false | false |
| TaskCreate | false | **true** ← 状態変更するが並列可能(s12 で紹介) |
CC の Bash ツールの `isConcurrencySafe``isReadOnly` と同じ — 読み取り専用コマンドは並列可能、書き込みコマンドは不可。TaskCreate はタスクファイルを変更するが、毎回異なるファイルに書き込むため並列可能。
### 三、パーティションアルゴリズム
CC の `partitionToolCalls()``toolOrchestration.ts:91-115`)は二つのグループに分けるのではなく、ツール呼び出しを**連続ブロックごとにバッチ化**する:
```
[read A, read B, glob *.py, bash "rm x", read C]
→ batch1(並列): [read A, read B, glob *.py]
→ batch2(直列): [bash "rm x"]
→ batch3(並列): [read C]
```
連続する並列安全な呼び出しを同じバッチにまとめ、真の並列実行を行う(`toolOrchestration.ts:152-176`、並列数上限あり)。非並列安全な呼び出しに遭遇すると新しいバッチを開始して直列実行。バッチ間は厳密に順次。
### 四、バリデーションパイプライン
CC の各ツール呼び出しは厳格な 5 段階のバリデーションを経る(`toolExecution.ts`):
1. **Zod schema バリデーション**`614-680`、教育版は JSON Schema で代替):パラメータの型/構造チェック
2. **ツールレベル validateInput()**`682-733`):パラメータ値の検証(例:パスが作業ディレクトリ内か)
3. **PreToolUse フック**`800-862`、s04 で詳解):フックはメッセージの返却、入力の変更、実行のブロックが可能
4. **権限チェック**`921-931`、s03 の核心):canUseTool + checkPermissions → allow/deny/ask
5. **tool.call() の実行**`1207-1222`
教育版は Zod を省略(JSON Schema を使用)、validateInput を省略(安全関数を使用)、権限チェックとフック概念は保持。
### 五、ストリーミングツール実行
CC の `StreamingToolExecutor``StreamingToolExecutor.ts`)はモデルがまだ生成中にツールを起動する — モデルの完了を待たない。`read_file` はモデルが「分析します」と出力中に完了するかもしれない。教育版はこれを実装しない。s01 と同じ目標 — 概念の明確さ、極限のパフォーマンスではない。
### 六、ツール結果の永続化
各ツールには `maxResultSizeChars` フィールドがある。この閾値を超える結果はディスクに保存され、モデルにはプレビュー + ファイルパスが表示される。FileRead は特殊 — `Infinity` に設定され、ファイル読み出し結果の再永続化を防ぐ。具体的には、FileRead の結果が閾値を超えて永続化されると、モデルがその永続化ファイルを次に読むときにまた永続化がトリガーされ → 無限ループ(ファイル読む → 永続化 → 再読み → 再永続化 → ...)になる。
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+222
View File
@@ -0,0 +1,222 @@
# s02: Tool Use — 多加一个工具,只加一行
[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
s01 → `s02` → [s03](../s03_permission/) → s04 → ... → s20
> *"加一个工具, 只加一个 handler"* — 循环不用动, 新工具注册进 dispatch map 就行。
>
> **Harness 层**: 工具分发 — 扩展模型能触达的边界。
---
## 只有 bash 一个工具
s01 的 Agent 只有一个 bash 工具。读文件要 `cat`,写文件要 `echo "..." > file.py`,改文件要 `sed`
模型想的是"读这个文件",却要拼出 `cat path/to/file`。多了一层翻译,浪费 token,还容易拼错。
---
## 全局视角:工具分发
![Tool Dispatch](images/tool-dispatch.svg)
s01 的循环完全保留(LLM 调用、stop_reason 判断、消息追加)。唯一的变动在工具执行那 1 行:`run_bash()` 替换为 `TOOL_HANDLERS[block.name]()` 查表分发。
给 Agent 加一个工具只需要做两件事:
1. **定义工具**:在 `TOOLS` 数组里加一条描述
2. **注册处理函数**:在 `TOOL_HANDLERS` 字典里加一个映射
---
## 从 1 个工具到 5 个工具
s01 只有一个 bash
```python
TOOLS = [{"name": "bash", ...}]
def run_bash(command): ...
```
s02 加到 5 个,每个工具都是独立定义:
```python
TOOLS = [
{"name": "bash", "description": "Run a shell command.", ...},
{"name": "read_file", "description": "Read file contents.", ...},
{"name": "write_file", "description": "Write content to file.", ...},
{"name": "edit_file", "description": "Replace text in file once.", ...},
{"name": "glob", "description": "Find files by pattern.", ...},
]
```
每个工具有自己的实现函数:
```python
def run_read(path, limit=None):
lines = safe_path(path).read_text().splitlines()
if limit:
lines = lines[:limit]
return "\n".join(lines)
def run_write(path, content):
safe_path(path).write_text(content)
return f"Wrote {len(content)} bytes to {path}"
def run_edit(path, old_text, new_text):
text = safe_path(path).read_text()
if old_text not in text:
return "Error: text not found"
safe_path(path).write_text(text.replace(old_text, new_text, 1))
return f"Edited {path}"
def run_glob(pattern):
import glob as g
return "\n".join(g.glob(pattern, root_dir=WORKDIR))
```
---
## 工具分发
```python
TOOL_HANDLERS = {
"bash": run_bash,
"read_file": run_read,
"write_file": run_write,
"edit_file": run_edit,
"glob": run_glob,
}
# 循环里只改了一行——从硬编码 run_bash 变成查表:
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS[block.name] # 查表
output = handler(**block.input) # 调用
results.append(...)
```
加一个工具 = 在 `TOOLS` 数组加一条 + 在 `TOOL_HANDLERS` 字典加一行。循环不变。
---
## 多个工具调用
模型经常一次返回多个 tool_use"读一下 a.py 和 b.py,然后列出所有 .py 文件"。
教学版按 `response.content` 原始顺序逐个执行。CC 的做法更复杂:按原始顺序切成连续 batch,batch 内并发安全的工具并行执行,batch 间严格顺序(见附录)。
---
## 速查
| 概念 | 一句话 |
|------|--------|
| TOOL_HANDLERS | 工具名 → 处理函数的字典。加工具 = 加一行映射 |
| 工具定义 | 告诉模型"我能做什么"的 JSON schema |
| 多工具调用 | 模型可一次返回多个 tool_use,教学版按原始顺序逐个执行 |
| 循环不变 | s01 的 `while True` 循环一行都没改 |
---
## 相对 s01 的变更
| 组件 | 之前 (s01) | 之后 (s02) |
|------|-----------|-----------|
| 工具数量 | 1 (bash) | 5 (+read, write, edit, glob) |
| 工具执行 | 硬编码 `run_bash()` | TOOL_HANDLERS 查表分发 |
| 路径安全 | 无 | safe_path 校验(仅 file tools |
| 循环 | `while True` + `stop_reason` | 与 s01 完全一致 |
---
## 试一下
```sh
cd learn-claude-code
python s02_tool_use/code.py
```
试试这些 prompt
1. `Read the file README.md and tell me what this project is about`
2. `Create a file called test.py that prints "hello", then read it back`
3. `Find all Python files in this directory`
4. `Read both README.md and requirements.txt, then create a summary file`
观察重点:模型什么时候只调一个工具,什么时候一次调多个?多个工具调用的顺序和结果是否正确?
---
## 接下来
现在 Agent 有 5 个专用工具。file tools 受 `safe_path` 保护,但 bash 不受限制,`rm -rf /` 还是能跑。
s03 Permission → 在工具执行之前加一道门:这个操作安全吗?需要用户批准吗?
<details>
<summary>深入 CC 源码</summary>
> 以下基于 CC 源码 `Tool.ts`、`tools.ts`、`toolOrchestration.ts`、`toolExecution.ts`、`StreamingToolExecutor.ts` 的核查。
### 一、工具定义方式
**教学版**`TOOLS` 数组 + `TOOL_HANDLERS` 字典。定义和实现分开。
**CC**:每个工具是 `buildTool()` 创建的独立对象,包含 schema、验证、权限、执行。`getAllBaseTools()` 汇总所有工具。
教学版的分离方式对教学更清晰——读者一眼看到"加一个工具 = 两条定义"。
### 二、并发安全判断:isConcurrencySafe()
![Tool Concurrency](images/concurrency-comparison.svg)
教学版按原始顺序逐个执行,不做并发。CC 用 `isConcurrencySafe(input)` 判断能否并发——注意这不是简单的"只读 vs 写",而是按具体输入判断:
| | isReadOnly | isConcurrencySafe |
|---|---|---|
| FileRead | true | true |
| Glob | true | true |
| Bash `ls` | true | **true** ← 关键差异 |
| Bash `rm` | false | false |
| TaskCreate | false | **true** ← 改状态但可并发(TaskCreate 在 s12 介绍) |
CC 的 Bash tool 的 `isConcurrencySafe` 等于 `isReadOnly`——只读命令可并发,写命令不可。TaskCreate 虽然改了任务文件,但每次都写不同的文件,所以可以并发。
### 三、分区算法
CC 的 `partitionToolCalls()``toolOrchestration.ts:91-115`)不是分两组,而是把工具调用**按连续块分批**:
```
[read A, read B, glob *.py, bash "rm x", read C]
→ batch1(并发): [read A, read B, glob *.py]
→ batch2(串行): [bash "rm x"]
→ batch3(并发): [read C]
```
并发安全的连续块编入同一个 batch,batch 内真正并发执行(`toolOrchestration.ts:152-176`,有并发上限)。遇到非并发安全的就开新 batch 串行执行。batch 之间严格顺序。
### 四、验证管线
CC 的每个工具调用经过严格的 5 步验证(`toolExecution.ts`):
1. **Zod schema 验证**`614-680`,教学版用 JSON Schema 替代):参数类型/结构检查
2. **工具级 validateInput()**`682-733`):参数值验证(如路径是否在工作区内)
3. **PreToolUse hooks**`800-862`,s04 详细介绍):钩子可以返回消息、修改输入、阻止执行
4. **权限检查**`921-931`s03 的核心内容):canUseTool + checkPermissions → allow/deny/ask
5. **执行 tool.call()**`1207-1222`
教学版省略了 Zod(用 JSON Schema)、省略了 validateInput(用安全函数)、保留了权限检查和钩子概念。
### 五、流式工具执行
CC 的 `StreamingToolExecutor``StreamingToolExecutor.ts`)让工具在模型还在生成时就启动——不等模型说完。`read_file` 可能在模型还在输出"我来分析"的时候就跑完了。教学版不实现这个,目标和 s01 一致——概念清晰,不追求性能极致。
### 六、工具结果持久化
每个工具有一个 `maxResultSizeChars` 字段。结果超过这个值就落盘,模型看到的是预览 + 文件路径。FileRead 特殊——设为 `Infinity`,防止读文件的输出又被当成文件落盘。具体来说,如果 FileRead 的结果超过阈值被落盘,模型下次读那个落盘文件时又会触发落盘 → 无限循环(读文件 → 落盘 → 再读 → 再落盘 → ...)。
</details>
<!-- translation-sync: zh@v1, en@v0, ja@v0 -->
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""
s02: Tool Use — 在 s01 基础上新增 4 个工具 + 分发映射。
运行: python s02_tool_use/code.py
需要: pip install anthropic python-dotenv + .env 中配置 ANTHROPIC_API_KEY
本文件 = s01 的全部代码 + 以下新增:
+ run_read / run_write / run_edit / run_glob 四个工具实现
+ TOOL_HANDLERS 分发映射(替代 s01 中硬编码的 run_bash 调用)
+ safe_path 路径安全校验
循环本身(agent_loop)与 s01 完全一致。
"""
import os, subprocess
from pathlib import Path
try:
import readline
readline.parse_and_bind('set bind-tty-special-chars off')
readline.parse_and_bind('set input-meta on')
readline.parse_and_bind('set output-meta on')
readline.parse_and_bind('set convert-meta off')
except ImportError:
pass
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
SYSTEM = f"You are a coding agent at {WORKDIR}. Use tools to solve tasks. Act, don't explain."
# ═══════════════════════════════════════════════════════════
# FROM s01 (unchanged)
# ═══════════════════════════════════════════════════════════
def run_bash(command: str) -> str:
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=120)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
except (FileNotFoundError, OSError) as e:
return f"Error: {e}"
# ═══════════════════════════════════════════════════════════
# NEW in s02: 4 个新工具
# ═══════════════════════════════════════════════════════════
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def run_read(path: str, limit: int | None = None) -> str:
try:
lines = safe_path(path).read_text().splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"]
return "\n".join(lines)
except Exception as e:
return f"Error: {e}"
def run_write(path: str, content: str) -> str:
try:
file_path = safe_path(path)
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content)
return f"Wrote {len(content)} bytes to {path}"
except Exception as e:
return f"Error: {e}"
def run_edit(path: str, old_text: str, new_text: str) -> str:
try:
file_path = safe_path(path)
text = file_path.read_text()
if old_text not in text:
return f"Error: text not found in {path}"
file_path.write_text(text.replace(old_text, new_text, 1))
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"
def run_glob(pattern: str) -> str:
import glob as g
try:
results = []
for match in g.glob(pattern, root_dir=WORKDIR):
if (WORKDIR / match).resolve().is_relative_to(WORKDIR):
results.append(match)
return "\n".join(results) if results else "(no matches)"
except Exception as e:
return f"Error: {e}"
# ═══════════════════════════════════════════════════════════
# NEW in s02: 工具定义(s01 只有一个 bash,现在扩展到 5 个)
# ═══════════════════════════════════════════════════════════
TOOLS = [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write content to a file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in a file once.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "glob", "description": "Find files matching a glob pattern.",
"input_schema": {"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]}},
]
# ═══════════════════════════════════════════════════════════
# NEW in s02: 工具分发映射(s01 是硬编码 run_bash,现在改为查表)
# ═══════════════════════════════════════════════════════════
TOOL_HANDLERS = {
"bash": run_bash, "read_file": run_read, "write_file": run_write,
"edit_file": run_edit, "glob": run_glob,
}
# ═══════════════════════════════════════════════════════════
# agent_loop — 与 s01 结构完全一致,只改了工具执行那部分
# s01: output = run_bash(block.input["command"])
# s02: output = TOOL_HANDLERS[block.name](**block.input)
# ═══════════════════════════════════════════════════════════
def agent_loop(messages: list):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
for block in response.content:
if block.type == "tool_use":
print(f"\033[33m> {block.name}\033[0m")
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler else f"Unknown: {block.name}"
print(str(output)[:200])
results.append({"type": "tool_result", "tool_use_id": block.id, "content": output})
messages.append({"role": "user", "content": results})
if __name__ == "__main__":
print("s02: Tool Use — 在 s01 基础上加了 4 个工具")
print("输入问题,回车发送。输入 q 退出。\n")
history = []
while True:
try:
query = input("\033[36ms02 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
history.append({"role": "user", "content": query})
agent_loop(history)
for block in history[-1]["content"]:
if getattr(block, "type", None) == "text":
print(block.text)
print()
@@ -0,0 +1,108 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 500" font-family="system-ui, -apple-system, sans-serif">
<defs>
<linearGradient id="header" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#1e3a5f"/><stop offset="100%" stop-color="#2563eb"/>
</linearGradient>
<linearGradient id="teach" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#fef3c7"/><stop offset="100%" stop-color="#fde68a"/>
</linearGradient>
<linearGradient id="cc" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#dcfce7"/><stop offset="100%" stop-color="#bbf7d0"/>
</linearGradient>
<marker id="arrow-g" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#16a34a"/>
</marker>
</defs>
<rect width="760" height="500" fill="#fafbfc" rx="8"/>
<rect x="0" y="0" width="760" height="36" fill="url(#header)" rx="8"/>
<rect x="0" y="28" width="760" height="8" fill="url(#header)"/>
<text x="380" y="24" fill="#fff" font-size="14" font-weight="700" text-anchor="middle">Tool Concurrency — Teaching Version vs Claude Code</text>
<!-- Input tool blocks -->
<rect x="180" y="52" width="400" height="28" rx="14" fill="#f1f5f9" stroke="#94a3b8" stroke-width="1"/>
<text x="380" y="71" fill="#475569" font-size="11" font-weight="600" text-anchor="middle">Model returns 5 tool calls at once</text>
<rect x="38" y="92" width="100" height="36" rx="4" fill="#dbeafe" stroke="#93c5fd" stroke-width="1"/>
<text x="88" y="114" fill="#1e40af" font-size="10" font-weight="600" text-anchor="middle">read A.py</text>
<rect x="148" y="92" width="100" height="36" rx="4" fill="#dbeafe" stroke="#93c5fd" stroke-width="1"/>
<text x="198" y="114" fill="#1e40af" font-size="10" font-weight="600" text-anchor="middle">glob *.py</text>
<rect x="258" y="92" width="110" height="36" rx="4" fill="#fef3c7" stroke="#fbbf24" stroke-width="1"/>
<text x="313" y="114" fill="#92400e" font-size="10" font-weight="600" text-anchor="middle">bash "ls -la"</text>
<rect x="378" y="92" width="100" height="36" rx="4" fill="#fee2e2" stroke="#fca5a5" stroke-width="1"/>
<text x="428" y="114" fill="#991b1b" font-size="10" font-weight="600" text-anchor="middle">write B.py</text>
<rect x="488" y="92" width="100" height="36" rx="4" fill="#dbeafe" stroke="#93c5fd" stroke-width="1"/>
<text x="538" y="114" fill="#1e40af" font-size="10" font-weight="600" text-anchor="middle">read C.py</text>
<!-- LEFT: Teaching Version -->
<rect x="20" y="156" width="350" height="230" rx="8" fill="url(#teach)" stroke="#d97706" stroke-width="1.5"/>
<text x="195" y="180" fill="#92400e" font-size="13" font-weight="700" text-anchor="middle">Teaching: Original Order, One by One</text>
<rect x="35" y="192" width="320" height="46" rx="4" fill="#fff" stroke="#fbbf24" stroke-width="0.5"/>
<text x="46" y="209" fill="#92400e" font-size="9" font-family="monospace">for block in response.content:</text>
<text x="46" y="224" fill="#92400e" font-size="9" font-family="monospace"> TOOL_HANDLERS[name](**input)</text>
<text x="195" y="258" fill="#92400e" font-size="10" font-weight="600" text-anchor="middle">Result: 5 serial calls, no batches</text>
<rect x="45" y="270" width="300" height="20" rx="4" fill="#fff7ed" stroke="#fbbf24" stroke-width="0.7"/>
<text x="60" y="284" fill="#92400e" font-size="8" font-weight="600">1. read A.py</text>
<rect x="45" y="294" width="300" height="20" rx="4" fill="#fff7ed" stroke="#fbbf24" stroke-width="0.7"/>
<text x="60" y="308" fill="#92400e" font-size="8" font-weight="600">2. glob *.py</text>
<rect x="45" y="318" width="300" height="20" rx="4" fill="#fff7ed" stroke="#fbbf24" stroke-width="0.7"/>
<text x="60" y="332" fill="#92400e" font-size="8" font-weight="600">3. bash "ls -la"</text>
<rect x="45" y="342" width="145" height="20" rx="4" fill="#fff7ed" stroke="#fbbf24" stroke-width="0.7"/>
<text x="60" y="356" fill="#92400e" font-size="8" font-weight="600">4. write B.py</text>
<rect x="200" y="342" width="145" height="20" rx="4" fill="#fff7ed" stroke="#fbbf24" stroke-width="0.7"/>
<text x="215" y="356" fill="#92400e" font-size="8" font-weight="600">5. read C.py</text>
<text x="195" y="378" fill="#dc2626" font-size="8" font-weight="600" text-anchor="middle">Teaching focus: tool dispatch first; concurrency omitted</text>
<!-- RIGHT: Claude Code -->
<rect x="390" y="156" width="350" height="230" rx="8" fill="url(#cc)" stroke="#16a34a" stroke-width="1.5"/>
<text x="565" y="180" fill="#166534" font-size="13" font-weight="700" text-anchor="middle">Claude Code: isConcurrencySafe(input)</text>
<rect x="405" y="192" width="320" height="38" rx="4" fill="#fff" stroke="#86efac" stroke-width="0.5"/>
<text x="416" y="207" fill="#166534" font-size="9" font-family="monospace">Each tool call judged individually:</text>
<text x="416" y="222" fill="#166534" font-size="9" font-family="monospace">tool.isConcurrencySafe(parsedInput) → bool</text>
<text x="565" y="250" fill="#166534" font-size="10" font-weight="600" text-anchor="middle">Result: 3 batches (by consecutive blocks)</text>
<rect x="400" y="258" width="155" height="50" rx="4" fill="#dcfce7" stroke="#86efac" stroke-width="1"/>
<text x="477" y="276" fill="#166534" font-size="8" font-weight="600" text-anchor="middle">Batch 1</text>
<text x="477" y="289" fill="#166534" font-size="8" text-anchor="middle">Concurrent</text>
<text x="477" y="302" fill="#166534" font-size="7" text-anchor="middle">read A · glob · bash "ls"</text>
<line x1="560" y1="283" x2="575" y2="283" stroke="#16a34a" stroke-width="1" marker-end="url(#arrow-g)"/>
<rect x="580" y="258" width="65" height="50" rx="4" fill="#fee2e2" stroke="#fca5a5" stroke-width="1"/>
<text x="612" y="276" fill="#991b1b" font-size="8" font-weight="600" text-anchor="middle">Batch 2</text>
<text x="612" y="289" fill="#991b1b" font-size="8" text-anchor="middle">Serial</text>
<text x="612" y="302" fill="#991b1b" font-size="7" text-anchor="middle">write B</text>
<line x1="650" y1="283" x2="665" y2="283" stroke="#16a34a" stroke-width="1" marker-end="url(#arrow-g)"/>
<rect x="670" y="258" width="55" height="50" rx="4" fill="#dcfce7" stroke="#86efac" stroke-width="1"/>
<text x="697" y="276" fill="#166534" font-size="8" font-weight="600" text-anchor="middle">Batch 3</text>
<text x="697" y="289" fill="#166534" font-size="8" text-anchor="middle">Concurrent</text>
<text x="697" y="302" fill="#166534" font-size="7" text-anchor="middle">read C</text>
<text x="565" y="332" fill="#16a34a" font-size="8" font-weight="600" text-anchor="middle">bash "ls" is safe and consecutive, so it stays in Batch 1</text>
<text x="565" y="366" fill="#16a34a" font-size="9" font-weight="600" text-anchor="middle">✓ Input-dependent safety, not tool-name hardcoding</text>
<text x="565" y="380" fill="#16a34a" font-size="9" font-weight="600" text-anchor="middle">✓ Original order preserved; only safe consecutive calls run together</text>
<!-- Bottom Summary -->
<rect x="20" y="402" width="720" height="82" rx="6" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1"/>
<text x="40" y="424" fill="#1e3a5f" font-size="12" font-weight="600">Key Difference</text>
<text x="40" y="444" fill="#475569" font-size="10">• Teaching: executes response.content in original order, one tool call at a time; no concurrency or batching</text>
<text x="40" y="460" fill="#475569" font-size="10">• CC: checks isConcurrencySafe(input), then groups consecutive safe calls into one batch</text>
<text x="40" y="476" fill="#475569" font-size="10">• Key difference: teaching focuses on dispatch; CC optimizes safe concurrency while preserving order semantics</text>
</svg>

After

Width:  |  Height:  |  Size: 7.7 KiB

@@ -0,0 +1,108 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 500" font-family="system-ui, -apple-system, sans-serif">
<defs>
<linearGradient id="header" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#1e3a5f"/><stop offset="100%" stop-color="#2563eb"/>
</linearGradient>
<linearGradient id="teach" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#fef3c7"/><stop offset="100%" stop-color="#fde68a"/>
</linearGradient>
<linearGradient id="cc" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#dcfce7"/><stop offset="100%" stop-color="#bbf7d0"/>
</linearGradient>
<marker id="arrow-g" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#16a34a"/>
</marker>
</defs>
<rect width="760" height="500" fill="#fafbfc" rx="8"/>
<rect x="0" y="0" width="760" height="36" fill="url(#header)" rx="8"/>
<rect x="0" y="28" width="760" height="8" fill="url(#header)"/>
<text x="380" y="24" fill="#fff" font-size="14" font-weight="700" text-anchor="middle">ツール並列実行 — 教育版 vs Claude Code</text>
<!-- 入力ツールブロック -->
<rect x="180" y="52" width="400" height="28" rx="14" fill="#f1f5f9" stroke="#94a3b8" stroke-width="1"/>
<text x="380" y="71" fill="#475569" font-size="11" font-weight="600" text-anchor="middle">モデルが一度に 5 つのツール呼び出しを返す</text>
<rect x="38" y="92" width="100" height="36" rx="4" fill="#dbeafe" stroke="#93c5fd" stroke-width="1"/>
<text x="88" y="114" fill="#1e40af" font-size="10" font-weight="600" text-anchor="middle">read A.py</text>
<rect x="148" y="92" width="100" height="36" rx="4" fill="#dbeafe" stroke="#93c5fd" stroke-width="1"/>
<text x="198" y="114" fill="#1e40af" font-size="10" font-weight="600" text-anchor="middle">glob *.py</text>
<rect x="258" y="92" width="110" height="36" rx="4" fill="#fef3c7" stroke="#fbbf24" stroke-width="1"/>
<text x="313" y="114" fill="#92400e" font-size="10" font-weight="600" text-anchor="middle">bash "ls -la"</text>
<rect x="378" y="92" width="100" height="36" rx="4" fill="#fee2e2" stroke="#fca5a5" stroke-width="1"/>
<text x="428" y="114" fill="#991b1b" font-size="10" font-weight="600" text-anchor="middle">write B.py</text>
<rect x="488" y="92" width="100" height="36" rx="4" fill="#dbeafe" stroke="#93c5fd" stroke-width="1"/>
<text x="538" y="114" fill="#1e40af" font-size="10" font-weight="600" text-anchor="middle">read C.py</text>
<!-- 左:教育版 -->
<rect x="20" y="156" width="350" height="230" rx="8" fill="url(#teach)" stroke="#d97706" stroke-width="1.5"/>
<text x="195" y="180" fill="#92400e" font-size="13" font-weight="700" text-anchor="middle">教育版:元の順序で一つずつ実行</text>
<rect x="35" y="192" width="320" height="46" rx="4" fill="#fff" stroke="#fbbf24" stroke-width="0.5"/>
<text x="46" y="209" fill="#92400e" font-size="9" font-family="monospace">for block in response.content:</text>
<text x="46" y="224" fill="#92400e" font-size="9" font-family="monospace"> TOOL_HANDLERS[name](**input)</text>
<text x="195" y="258" fill="#92400e" font-size="10" font-weight="600" text-anchor="middle">結果:5 回の直列呼び出し、batch なし</text>
<rect x="45" y="270" width="300" height="20" rx="4" fill="#fff7ed" stroke="#fbbf24" stroke-width="0.7"/>
<text x="60" y="284" fill="#92400e" font-size="8" font-weight="600">1. read A.py</text>
<rect x="45" y="294" width="300" height="20" rx="4" fill="#fff7ed" stroke="#fbbf24" stroke-width="0.7"/>
<text x="60" y="308" fill="#92400e" font-size="8" font-weight="600">2. glob *.py</text>
<rect x="45" y="318" width="300" height="20" rx="4" fill="#fff7ed" stroke="#fbbf24" stroke-width="0.7"/>
<text x="60" y="332" fill="#92400e" font-size="8" font-weight="600">3. bash "ls -la"</text>
<rect x="45" y="342" width="145" height="20" rx="4" fill="#fff7ed" stroke="#fbbf24" stroke-width="0.7"/>
<text x="60" y="356" fill="#92400e" font-size="8" font-weight="600">4. write B.py</text>
<rect x="200" y="342" width="145" height="20" rx="4" fill="#fff7ed" stroke="#fbbf24" stroke-width="0.7"/>
<text x="215" y="356" fill="#92400e" font-size="8" font-weight="600">5. read C.py</text>
<text x="195" y="378" fill="#dc2626" font-size="8" font-weight="600" text-anchor="middle">教育の焦点:まず tool_use 分配を理解し、並列は省略</text>
<!-- 右:Claude Code -->
<rect x="390" y="156" width="350" height="230" rx="8" fill="url(#cc)" stroke="#16a34a" stroke-width="1.5"/>
<text x="565" y="180" fill="#166534" font-size="13" font-weight="700" text-anchor="middle">Claude CodeisConcurrencySafe(input)</text>
<rect x="405" y="192" width="320" height="38" rx="4" fill="#fff" stroke="#86efac" stroke-width="0.5"/>
<text x="416" y="207" fill="#166534" font-size="9" font-family="monospace">各ツール呼び出しを個別に判定:</text>
<text x="416" y="222" fill="#166534" font-size="9" font-family="monospace">tool.isConcurrencySafe(parsedInput) → bool</text>
<text x="565" y="250" fill="#166534" font-size="10" font-weight="600" text-anchor="middle">結果:3 バッチ(連続ブロックごと)</text>
<rect x="400" y="258" width="155" height="50" rx="4" fill="#dcfce7" stroke="#86efac" stroke-width="1"/>
<text x="477" y="276" fill="#166534" font-size="8" font-weight="600" text-anchor="middle">Batch 1</text>
<text x="477" y="289" fill="#166534" font-size="8" text-anchor="middle">並列</text>
<text x="477" y="302" fill="#166534" font-size="7" text-anchor="middle">read A · glob · bash "ls"</text>
<line x1="560" y1="283" x2="575" y2="283" stroke="#16a34a" stroke-width="1" marker-end="url(#arrow-g)"/>
<rect x="580" y="258" width="65" height="50" rx="4" fill="#fee2e2" stroke="#fca5a5" stroke-width="1"/>
<text x="612" y="276" fill="#991b1b" font-size="8" font-weight="600" text-anchor="middle">Batch 2</text>
<text x="612" y="289" fill="#991b1b" font-size="8" text-anchor="middle">直列</text>
<text x="612" y="302" fill="#991b1b" font-size="7" text-anchor="middle">write B</text>
<line x1="650" y1="283" x2="665" y2="283" stroke="#16a34a" stroke-width="1" marker-end="url(#arrow-g)"/>
<rect x="670" y="258" width="55" height="50" rx="4" fill="#dcfce7" stroke="#86efac" stroke-width="1"/>
<text x="697" y="276" fill="#166534" font-size="8" font-weight="600" text-anchor="middle">Batch 3</text>
<text x="697" y="289" fill="#166534" font-size="8" text-anchor="middle">並列</text>
<text x="697" y="302" fill="#166534" font-size="7" text-anchor="middle">read C</text>
<text x="565" y="332" fill="#16a34a" font-size="8" font-weight="600" text-anchor="middle">bash "ls" は安全かつ連続しているため Batch 1 に入る</text>
<text x="565" y="366" fill="#16a34a" font-size="9" font-weight="600" text-anchor="middle">✓ 入力に基づく安全判定、ツール名ハードコードではない</text>
<text x="565" y="380" fill="#16a34a" font-size="9" font-weight="600" text-anchor="middle">✓ 元の順序を保ち、連続する安全呼び出しだけ並列化</text>
<!-- 下部まとめ -->
<rect x="20" y="402" width="720" height="82" rx="6" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1"/>
<text x="40" y="424" fill="#1e3a5f" font-size="12" font-weight="600">核心的な違い</text>
<text x="40" y="444" fill="#475569" font-size="10">• 教育版:response.content の元の順序で一つずつ実行し、並列処理も batch 化もしない</text>
<text x="40" y="460" fill="#475569" font-size="10">• CCisConcurrencySafe(input) で判定し、連続する安全呼び出しを同じ batch にまとめる</text>
<text x="40" y="476" fill="#475569" font-size="10">• 差分の要点:教育版は分配に集中し、CC は順序意味を保ったまま安全な並列を最適化する</text>
</svg>

After

Width:  |  Height:  |  Size: 7.8 KiB

@@ -0,0 +1,108 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 500" font-family="system-ui, -apple-system, sans-serif">
<defs>
<linearGradient id="header" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#1e3a5f"/><stop offset="100%" stop-color="#2563eb"/>
</linearGradient>
<linearGradient id="teach" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#fef3c7"/><stop offset="100%" stop-color="#fde68a"/>
</linearGradient>
<linearGradient id="cc" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#dcfce7"/><stop offset="100%" stop-color="#bbf7d0"/>
</linearGradient>
<marker id="arrow-g" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#16a34a"/>
</marker>
</defs>
<rect width="760" height="500" fill="#fafbfc" rx="8"/>
<rect x="0" y="0" width="760" height="36" fill="url(#header)" rx="8"/>
<rect x="0" y="28" width="760" height="8" fill="url(#header)"/>
<text x="380" y="24" fill="#fff" font-size="14" font-weight="700" text-anchor="middle">Tool Concurrency — 教学版 vs Claude Code</text>
<!-- Input tool blocks -->
<rect x="180" y="52" width="400" height="28" rx="14" fill="#f1f5f9" stroke="#94a3b8" stroke-width="1"/>
<text x="380" y="71" fill="#475569" font-size="11" font-weight="600" text-anchor="middle">模型一次返回 5 个工具调用</text>
<rect x="38" y="92" width="100" height="36" rx="4" fill="#dbeafe" stroke="#93c5fd" stroke-width="1"/>
<text x="88" y="114" fill="#1e40af" font-size="10" font-weight="600" text-anchor="middle">read A.py</text>
<rect x="148" y="92" width="100" height="36" rx="4" fill="#dbeafe" stroke="#93c5fd" stroke-width="1"/>
<text x="198" y="114" fill="#1e40af" font-size="10" font-weight="600" text-anchor="middle">glob *.py</text>
<rect x="258" y="92" width="110" height="36" rx="4" fill="#fef3c7" stroke="#fbbf24" stroke-width="1"/>
<text x="313" y="114" fill="#92400e" font-size="10" font-weight="600" text-anchor="middle">bash "ls -la"</text>
<rect x="378" y="92" width="100" height="36" rx="4" fill="#fee2e2" stroke="#fca5a5" stroke-width="1"/>
<text x="428" y="114" fill="#991b1b" font-size="10" font-weight="600" text-anchor="middle">write B.py</text>
<rect x="488" y="92" width="100" height="36" rx="4" fill="#dbeafe" stroke="#93c5fd" stroke-width="1"/>
<text x="538" y="114" fill="#1e40af" font-size="10" font-weight="600" text-anchor="middle">read C.py</text>
<!-- LEFT: Teaching Version -->
<rect x="20" y="156" width="350" height="230" rx="8" fill="url(#teach)" stroke="#d97706" stroke-width="1.5"/>
<text x="195" y="180" fill="#92400e" font-size="13" font-weight="700" text-anchor="middle">教学版:按原始顺序逐个执行</text>
<rect x="35" y="192" width="320" height="46" rx="4" fill="#fff" stroke="#fbbf24" stroke-width="0.5"/>
<text x="46" y="209" fill="#92400e" font-size="9" font-family="monospace">for block in response.content:</text>
<text x="46" y="224" fill="#92400e" font-size="9" font-family="monospace"> TOOL_HANDLERS[name](**input)</text>
<text x="195" y="258" fill="#92400e" font-size="10" font-weight="600" text-anchor="middle">结果:5 次串行调用,不做 batch</text>
<rect x="45" y="270" width="300" height="20" rx="4" fill="#fff7ed" stroke="#fbbf24" stroke-width="0.7"/>
<text x="60" y="284" fill="#92400e" font-size="8" font-weight="600">1. read A.py</text>
<rect x="45" y="294" width="300" height="20" rx="4" fill="#fff7ed" stroke="#fbbf24" stroke-width="0.7"/>
<text x="60" y="308" fill="#92400e" font-size="8" font-weight="600">2. glob *.py</text>
<rect x="45" y="318" width="300" height="20" rx="4" fill="#fff7ed" stroke="#fbbf24" stroke-width="0.7"/>
<text x="60" y="332" fill="#92400e" font-size="8" font-weight="600">3. bash "ls -la"</text>
<rect x="45" y="342" width="145" height="20" rx="4" fill="#fff7ed" stroke="#fbbf24" stroke-width="0.7"/>
<text x="60" y="356" fill="#92400e" font-size="8" font-weight="600">4. write B.py</text>
<rect x="200" y="342" width="145" height="20" rx="4" fill="#fff7ed" stroke="#fbbf24" stroke-width="0.7"/>
<text x="215" y="356" fill="#92400e" font-size="8" font-weight="600">5. read C.py</text>
<text x="195" y="378" fill="#dc2626" font-size="8" font-weight="600" text-anchor="middle">教学重点:先理解 tool_use 分发,暂不引入并发执行</text>
<!-- RIGHT: Claude Code -->
<rect x="390" y="156" width="350" height="230" rx="8" fill="url(#cc)" stroke="#16a34a" stroke-width="1.5"/>
<text x="565" y="180" fill="#166534" font-size="13" font-weight="700" text-anchor="middle">Claude CodeisConcurrencySafe(input)</text>
<rect x="405" y="192" width="320" height="38" rx="4" fill="#fff" stroke="#86efac" stroke-width="0.5"/>
<text x="416" y="207" fill="#166534" font-size="9" font-family="monospace">每个工具调用单独判断:</text>
<text x="416" y="222" fill="#166534" font-size="9" font-family="monospace">tool.isConcurrencySafe(parsedInput) → bool</text>
<text x="565" y="250" fill="#166534" font-size="10" font-weight="600" text-anchor="middle">结果:3 个 batch(按连续块分批)</text>
<rect x="400" y="258" width="155" height="50" rx="4" fill="#dcfce7" stroke="#86efac" stroke-width="1"/>
<text x="477" y="276" fill="#166534" font-size="8" font-weight="600" text-anchor="middle">Batch 1</text>
<text x="477" y="289" fill="#166534" font-size="8" text-anchor="middle">并发</text>
<text x="477" y="302" fill="#166534" font-size="7" text-anchor="middle">read A · glob · bash "ls"</text>
<line x1="560" y1="283" x2="575" y2="283" stroke="#16a34a" stroke-width="1" marker-end="url(#arrow-g)"/>
<rect x="580" y="258" width="65" height="50" rx="4" fill="#fee2e2" stroke="#fca5a5" stroke-width="1"/>
<text x="612" y="276" fill="#991b1b" font-size="8" font-weight="600" text-anchor="middle">Batch 2</text>
<text x="612" y="289" fill="#991b1b" font-size="8" text-anchor="middle">串行</text>
<text x="612" y="302" fill="#991b1b" font-size="7" text-anchor="middle">write B</text>
<line x1="650" y1="283" x2="665" y2="283" stroke="#16a34a" stroke-width="1" marker-end="url(#arrow-g)"/>
<rect x="670" y="258" width="55" height="50" rx="4" fill="#dcfce7" stroke="#86efac" stroke-width="1"/>
<text x="697" y="276" fill="#166534" font-size="8" font-weight="600" text-anchor="middle">Batch 3</text>
<text x="697" y="289" fill="#166534" font-size="8" text-anchor="middle">并发</text>
<text x="697" y="302" fill="#166534" font-size="7" text-anchor="middle">read C</text>
<text x="565" y="332" fill="#16a34a" font-size="8" font-weight="600" text-anchor="middle">bash "ls" 是并发安全调用,且和 read/glob 连续,所以留在 Batch 1</text>
<text x="565" y="366" fill="#16a34a" font-size="9" font-weight="600" text-anchor="middle">✓ 按输入判断并发安全,不按工具名硬编码</text>
<text x="565" y="380" fill="#16a34a" font-size="9" font-weight="600" text-anchor="middle">✓ 保留原始顺序,只在连续安全块内部并发</text>
<!-- Bottom Summary -->
<rect x="20" y="402" width="720" height="82" rx="6" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1"/>
<text x="40" y="424" fill="#1e3a5f" font-size="12" font-weight="600">核心差异</text>
<text x="40" y="444" fill="#475569" font-size="10">• 教学版:按 response.content 原始顺序逐个执行,不做并发,也不分 batch</text>
<text x="40" y="460" fill="#475569" font-size="10">• CC:按 isConcurrencySafe(input) 判断,并把连续的并发安全调用合成同一个 batch</text>
<text x="40" y="476" fill="#475569" font-size="10">• 差异重点:教学版聚焦工具分发;CC 在保持顺序语义的同时优化安全并发</text>
</svg>

After

Width:  |  Height:  |  Size: 7.7 KiB

+108
View File
@@ -0,0 +1,108 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 560" font-family="system-ui, -apple-system, sans-serif">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555"/>
</marker>
<marker id="arrow-blue" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#2563eb"/>
</marker>
<marker id="arrow-green" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#16a34a"/>
</marker>
<marker id="arrow-orange" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#d97706"/>
</marker>
<linearGradient id="header" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#1e3a5f"/>
<stop offset="100%" stop-color="#2563eb"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="720" height="560" fill="#fafbfc" rx="8"/>
<!-- Title -->
<rect x="0" y="0" width="720" height="48" fill="url(#header)" rx="8"/>
<rect x="0" y="40" width="720" height="8" fill="url(#header)"/>
<text x="360" y="31" fill="#fff" font-size="16" font-weight="700" text-anchor="middle">Tool Use — Loop Unchanged, Just Add Dispatch Mapping</text>
<!-- ===== s01 (gray, preserved) ===== -->
<text x="50" y="76" fill="#94a3b8" font-size="11" font-weight="600">s01 Preserved</text>
<!-- User Input -->
<rect x="60" y="86" width="140" height="44" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="130" y="105" fill="#1e3a5f" font-size="12" font-weight="600" text-anchor="middle">User Query</text>
<text x="130" y="121" fill="#64748b" font-size="10" text-anchor="middle">messages[]</text>
<!-- Arrow: User → LLM -->
<line x1="200" y1="108" x2="268" y2="108" stroke="#2563eb" stroke-width="1.5" marker-end="url(#arrow-blue)"/>
<!-- LLM -->
<rect x="270" y="82" width="150" height="52" rx="8" fill="#fff" stroke="#2563eb" stroke-width="1.5"/>
<text x="345" y="104" fill="#1e3a5f" font-size="13" font-weight="700" text-anchor="middle">LLM</text>
<text x="345" y="122" fill="#64748b" font-size="10" text-anchor="middle">stop_reason check</text>
<!-- Arrow: LLM → Decision -->
<line x1="345" y1="134" x2="345" y2="162" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<!-- Decision Diamond -->
<polygon points="345,166 415,196 345,226 275,196" fill="#fff8f0" stroke="#d97706" stroke-width="1.5"/>
<text x="345" y="194" fill="#92400e" font-size="10" font-weight="600" text-anchor="middle">tool_use?</text>
<!-- No → Return -->
<line x1="415" y1="196" x2="475" y2="196" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrow-green)"/>
<text x="445" y="189" fill="#16a34a" font-size="9" font-weight="600">No</text>
<rect x="477" y="178" width="100" height="36" rx="18" fill="#dcfce7" stroke="#16a34a" stroke-width="1.5"/>
<text x="527" y="200" fill="#166534" font-size="11" font-weight="600" text-anchor="middle">Return Result</text>
<!-- Yes → Next Step -->
<line x1="345" y1="226" x2="345" y2="260" stroke="#d97706" stroke-width="1.5" marker-end="url(#arrow-orange)"/>
<text x="356" y="248" fill="#d97706" font-size="9" font-weight="600">Yes</text>
<!-- ===== s02 New: TOOL_HANDLERS Dispatch Mapping ===== -->
<text x="505" y="282" fill="#d97706" font-size="11" font-weight="600">s02 New</text>
<!-- Dispatch Mapping Outer Box -->
<rect x="195" y="268" width="300" height="200" rx="10" fill="#fff7ed" stroke="#d97706" stroke-width="2" stroke-dasharray="6,3"/>
<text x="345" y="290" fill="#92400e" font-size="12" font-weight="700" text-anchor="middle">TOOL_HANDLERS Dispatch Mapping</text>
<!-- Arrow into dispatch mapping -->
<line x1="345" y1="260" x2="345" y2="268" stroke="#d97706" stroke-width="1.5"/>
<!-- bash handler -->
<rect x="220" y="300" width="120" height="34" rx="6" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.2"/>
<text x="280" y="316" fill="#1e3a5f" font-size="11" font-weight="600" text-anchor="middle">bash</text>
<text x="280" y="328" fill="#64748b" font-size="9" text-anchor="middle">→ run_bash()</text>
<!-- read_file handler -->
<rect x="360" y="300" width="120" height="34" rx="6" fill="#ecfdf5" stroke="#16a34a" stroke-width="1.2"/>
<text x="420" y="316" fill="#166534" font-size="11" font-weight="600" text-anchor="middle">read_file</text>
<text x="420" y="328" fill="#64748b" font-size="9" text-anchor="middle">→ run_read()</text>
<!-- write_file handler -->
<rect x="220" y="346" width="120" height="34" rx="6" fill="#ecfdf5" stroke="#16a34a" stroke-width="1.2"/>
<text x="280" y="362" fill="#166534" font-size="11" font-weight="600" text-anchor="middle">write_file</text>
<text x="280" y="374" fill="#64748b" font-size="9" text-anchor="middle">→ run_write()</text>
<!-- edit_file handler -->
<rect x="360" y="346" width="120" height="34" rx="6" fill="#ecfdf5" stroke="#16a34a" stroke-width="1.2"/>
<text x="420" y="362" fill="#166534" font-size="11" font-weight="600" text-anchor="middle">edit_file</text>
<text x="420" y="374" fill="#64748b" font-size="9" text-anchor="middle">→ run_edit()</text>
<!-- glob handler -->
<rect x="290" y="392" width="120" height="34" rx="6" fill="#ecfdf5" stroke="#16a34a" stroke-width="1.2"/>
<text x="350" y="408" fill="#166534" font-size="11" font-weight="600" text-anchor="middle">glob</text>
<text x="350" y="420" fill="#64748b" font-size="9" text-anchor="middle">→ run_glob()</text>
<!-- Arrow: Dispatch Mapping → Back to Messages -->
<path d="M 195 368 L 50 368 L 50 108 L 58 108" fill="none" stroke="#d97706" stroke-width="1.5" marker-end="url(#arrow-orange)" stroke-dasharray="6,3"/>
<text x="28" y="300" fill="#92400e" font-size="9" font-weight="500" transform="rotate(-90, 28, 300)">Append tool_result to messages</text>
<!-- ===== Legend ===== -->
<rect x="60" y="492" width="600" height="52" rx="6" fill="#f1f5f9"/>
<rect x="80" y="508" width="12" height="12" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
<text x="100" y="518" fill="#334155" font-size="10">s01 Preserved (loop, LLM, decision — completely unchanged)</text>
<rect x="380" y="508" width="12" height="12" rx="2" fill="#ecfdf5" stroke="#16a34a" stroke-width="1"/>
<text x="400" y="518" fill="#334155" font-size="10">s02 New (5 tools + dispatch mapping)</text>
<text x="80" y="536" fill="#64748b" font-size="10">Only 1 line changed in the loop: run_bash() → TOOL_HANDLERS[block.name]()</text>
</svg>

After

Width:  |  Height:  |  Size: 6.6 KiB

+108
View File
@@ -0,0 +1,108 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 560" font-family="system-ui, -apple-system, sans-serif">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555"/>
</marker>
<marker id="arrow-blue" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#2563eb"/>
</marker>
<marker id="arrow-green" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#16a34a"/>
</marker>
<marker id="arrow-orange" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#d97706"/>
</marker>
<linearGradient id="header" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#1e3a5f"/>
<stop offset="100%" stop-color="#2563eb"/>
</linearGradient>
</defs>
<!-- 背景 -->
<rect width="720" height="560" fill="#fafbfc" rx="8"/>
<!-- タイトル -->
<rect x="0" y="0" width="720" height="48" fill="url(#header)" rx="8"/>
<rect x="0" y="40" width="720" height="8" fill="url(#header)"/>
<text x="360" y="31" fill="#fff" font-size="15" font-weight="700" text-anchor="middle">Tool Use — ループ不変、ディスパッチマッピングを追加</text>
<!-- ===== s01 (灰色、保持部分) ===== -->
<text x="50" y="76" fill="#94a3b8" font-size="11" font-weight="600">s01 保持</text>
<!-- ユーザー入力 -->
<rect x="60" y="86" width="140" height="44" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="130" y="105" fill="#1e3a5f" font-size="12" font-weight="600" text-anchor="middle">ユーザーの質問</text>
<text x="130" y="121" fill="#64748b" font-size="10" text-anchor="middle">messages[]</text>
<!-- 矢印:ユーザー → LLM -->
<line x1="200" y1="108" x2="268" y2="108" stroke="#2563eb" stroke-width="1.5" marker-end="url(#arrow-blue)"/>
<!-- LLM -->
<rect x="270" y="82" width="150" height="52" rx="8" fill="#fff" stroke="#2563eb" stroke-width="1.5"/>
<text x="345" y="104" fill="#1e3a5f" font-size="13" font-weight="700" text-anchor="middle">LLM</text>
<text x="345" y="122" fill="#64748b" font-size="10" text-anchor="middle">stop_reason 判定</text>
<!-- 矢印:LLM → 判定 -->
<line x1="345" y1="134" x2="345" y2="162" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<!-- 判定ダイヤモンド -->
<polygon points="345,166 415,196 345,226 275,196" fill="#fff8f0" stroke="#d97706" stroke-width="1.5"/>
<text x="345" y="194" fill="#92400e" font-size="10" font-weight="600" text-anchor="middle">tool_use?</text>
<!-- いいえ → 返却 -->
<line x1="415" y1="196" x2="475" y2="196" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrow-green)"/>
<text x="445" y="189" fill="#16a34a" font-size="9" font-weight="600">No</text>
<rect x="477" y="178" width="100" height="36" rx="18" fill="#dcfce7" stroke="#16a34a" stroke-width="1.5"/>
<text x="527" y="200" fill="#166534" font-size="11" font-weight="600" text-anchor="middle">結果を返す</text>
<!-- はい → 次のステップ -->
<line x1="345" y1="226" x2="345" y2="260" stroke="#d97706" stroke-width="1.5" marker-end="url(#arrow-orange)"/>
<text x="356" y="248" fill="#d97706" font-size="9" font-weight="600">Yes</text>
<!-- ===== s02 新規:TOOL_HANDLERS ディスパッチマッピング ===== -->
<text x="505" y="282" fill="#d97706" font-size="11" font-weight="600">s02 新規</text>
<!-- ディスパッチマッピング外枠 -->
<rect x="195" y="268" width="300" height="200" rx="10" fill="#fff7ed" stroke="#d97706" stroke-width="2" stroke-dasharray="6,3"/>
<text x="345" y="290" fill="#92400e" font-size="12" font-weight="700" text-anchor="middle">TOOL_HANDLERS ディスパッチマッピング</text>
<!-- ディスパッチマッピングへの矢印 -->
<line x1="345" y1="260" x2="345" y2="268" stroke="#d97706" stroke-width="1.5"/>
<!-- bash handler -->
<rect x="220" y="300" width="120" height="34" rx="6" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.2"/>
<text x="280" y="316" fill="#1e3a5f" font-size="11" font-weight="600" text-anchor="middle">bash</text>
<text x="280" y="328" fill="#64748b" font-size="9" text-anchor="middle">→ run_bash()</text>
<!-- read_file handler -->
<rect x="360" y="300" width="120" height="34" rx="6" fill="#ecfdf5" stroke="#16a34a" stroke-width="1.2"/>
<text x="420" y="316" fill="#166534" font-size="11" font-weight="600" text-anchor="middle">read_file</text>
<text x="420" y="328" fill="#64748b" font-size="9" text-anchor="middle">→ run_read()</text>
<!-- write_file handler -->
<rect x="220" y="346" width="120" height="34" rx="6" fill="#ecfdf5" stroke="#16a34a" stroke-width="1.2"/>
<text x="280" y="362" fill="#166534" font-size="11" font-weight="600" text-anchor="middle">write_file</text>
<text x="280" y="374" fill="#64748b" font-size="9" text-anchor="middle">→ run_write()</text>
<!-- edit_file handler -->
<rect x="360" y="346" width="120" height="34" rx="6" fill="#ecfdf5" stroke="#16a34a" stroke-width="1.2"/>
<text x="420" y="362" fill="#166534" font-size="11" font-weight="600" text-anchor="middle">edit_file</text>
<text x="420" y="374" fill="#64748b" font-size="9" text-anchor="middle">→ run_edit()</text>
<!-- glob handler -->
<rect x="290" y="392" width="120" height="34" rx="6" fill="#ecfdf5" stroke="#16a34a" stroke-width="1.2"/>
<text x="350" y="408" fill="#166534" font-size="11" font-weight="600" text-anchor="middle">glob</text>
<text x="350" y="420" fill="#64748b" font-size="9" text-anchor="middle">→ run_glob()</text>
<!-- 矢印:ディスパッチマッピング → メッセージリストに戻る -->
<path d="M 195 368 L 50 368 L 50 108 L 58 108" fill="none" stroke="#d97706" stroke-width="1.5" marker-end="url(#arrow-orange)" stroke-dasharray="6,3"/>
<text x="28" y="300" fill="#92400e" font-size="9" font-weight="500" transform="rotate(-90, 28, 300)">tool_result を messages に追加</text>
<!-- ===== 凡例 ===== -->
<rect x="60" y="492" width="600" height="52" rx="6" fill="#f1f5f9"/>
<rect x="80" y="508" width="12" height="12" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
<text x="100" y="518" fill="#334155" font-size="10">s01 保持(ループ、LLM、判定 — 完全に不変)</text>
<rect x="380" y="508" width="12" height="12" rx="2" fill="#ecfdf5" stroke="#16a34a" stroke-width="1"/>
<text x="400" y="518" fill="#334155" font-size="10">s02 新規(5 つのツール + ディスパッチマッピング)</text>
<text x="80" y="536" fill="#64748b" font-size="10">ループ内で変更されたのは 1 行だけ:run_bash() → TOOL_HANDLERS[block.name]()</text>
</svg>

After

Width:  |  Height:  |  Size: 6.9 KiB

+108
View File
@@ -0,0 +1,108 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 560" font-family="system-ui, -apple-system, sans-serif">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555"/>
</marker>
<marker id="arrow-blue" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#2563eb"/>
</marker>
<marker id="arrow-green" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#16a34a"/>
</marker>
<marker id="arrow-orange" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#d97706"/>
</marker>
<linearGradient id="header" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#1e3a5f"/>
<stop offset="100%" stop-color="#2563eb"/>
</linearGradient>
</defs>
<!-- 背景 -->
<rect width="720" height="560" fill="#fafbfc" rx="8"/>
<!-- 标题 -->
<rect x="0" y="0" width="720" height="48" fill="url(#header)" rx="8"/>
<rect x="0" y="40" width="720" height="8" fill="url(#header)"/>
<text x="360" y="31" fill="#fff" font-size="16" font-weight="700" text-anchor="middle">Tool Use — 循环不变,只加分发映射</text>
<!-- ===== s01 (灰色,保留部分) ===== -->
<text x="50" y="76" fill="#94a3b8" font-size="11" font-weight="600">s01 保留</text>
<!-- 用户输入 -->
<rect x="60" y="86" width="140" height="44" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="130" y="105" fill="#1e3a5f" font-size="12" font-weight="600" text-anchor="middle">用户提问</text>
<text x="130" y="121" fill="#64748b" font-size="10" text-anchor="middle">messages[]</text>
<!-- 箭头:用户 → LLM -->
<line x1="200" y1="108" x2="268" y2="108" stroke="#2563eb" stroke-width="1.5" marker-end="url(#arrow-blue)"/>
<!-- LLM -->
<rect x="270" y="82" width="150" height="52" rx="8" fill="#fff" stroke="#2563eb" stroke-width="1.5"/>
<text x="345" y="104" fill="#1e3a5f" font-size="13" font-weight="700" text-anchor="middle">大模型 (LLM)</text>
<text x="345" y="122" fill="#64748b" font-size="10" text-anchor="middle">stop_reason 判断</text>
<!-- 箭头:LLM → 判断 -->
<line x1="345" y1="134" x2="345" y2="162" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<!-- 判断菱形 -->
<polygon points="345,166 415,196 345,226 275,196" fill="#fff8f0" stroke="#d97706" stroke-width="1.5"/>
<text x="345" y="194" fill="#92400e" font-size="10" font-weight="600" text-anchor="middle">tool_use?</text>
<!-- 否 → 返回 -->
<line x1="415" y1="196" x2="475" y2="196" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrow-green)"/>
<text x="445" y="189" fill="#16a34a" font-size="9" font-weight="600"></text>
<rect x="477" y="178" width="100" height="36" rx="18" fill="#dcfce7" stroke="#16a34a" stroke-width="1.5"/>
<text x="527" y="200" fill="#166534" font-size="11" font-weight="600" text-anchor="middle">返回结果</text>
<!-- 是 → 下一步 -->
<line x1="345" y1="226" x2="345" y2="260" stroke="#d97706" stroke-width="1.5" marker-end="url(#arrow-orange)"/>
<text x="356" y="248" fill="#d97706" font-size="9" font-weight="600"></text>
<!-- ===== s02 新增:TOOL_HANDLERS 分发映射 ===== -->
<text x="505" y="282" fill="#d97706" font-size="11" font-weight="600">s02 新增</text>
<!-- 分发映射外框 -->
<rect x="195" y="268" width="300" height="200" rx="10" fill="#fff7ed" stroke="#d97706" stroke-width="2" stroke-dasharray="6,3"/>
<text x="345" y="290" fill="#92400e" font-size="12" font-weight="700" text-anchor="middle">TOOL_HANDLERS 分发映射</text>
<!-- 箭头进入分发映射 -->
<line x1="345" y1="260" x2="345" y2="268" stroke="#d97706" stroke-width="1.5"/>
<!-- bash handler -->
<rect x="220" y="300" width="120" height="34" rx="6" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.2"/>
<text x="280" y="316" fill="#1e3a5f" font-size="11" font-weight="600" text-anchor="middle">bash</text>
<text x="280" y="328" fill="#64748b" font-size="9" text-anchor="middle">→ run_bash()</text>
<!-- read_file handler -->
<rect x="360" y="300" width="120" height="34" rx="6" fill="#ecfdf5" stroke="#16a34a" stroke-width="1.2"/>
<text x="420" y="316" fill="#166534" font-size="11" font-weight="600" text-anchor="middle">read_file</text>
<text x="420" y="328" fill="#64748b" font-size="9" text-anchor="middle">→ run_read()</text>
<!-- write_file handler -->
<rect x="220" y="346" width="120" height="34" rx="6" fill="#ecfdf5" stroke="#16a34a" stroke-width="1.2"/>
<text x="280" y="362" fill="#166534" font-size="11" font-weight="600" text-anchor="middle">write_file</text>
<text x="280" y="374" fill="#64748b" font-size="9" text-anchor="middle">→ run_write()</text>
<!-- edit_file handler -->
<rect x="360" y="346" width="120" height="34" rx="6" fill="#ecfdf5" stroke="#16a34a" stroke-width="1.2"/>
<text x="420" y="362" fill="#166534" font-size="11" font-weight="600" text-anchor="middle">edit_file</text>
<text x="420" y="374" fill="#64748b" font-size="9" text-anchor="middle">→ run_edit()</text>
<!-- glob handler -->
<rect x="290" y="392" width="120" height="34" rx="6" fill="#ecfdf5" stroke="#16a34a" stroke-width="1.2"/>
<text x="350" y="408" fill="#166534" font-size="11" font-weight="600" text-anchor="middle">glob</text>
<text x="350" y="420" fill="#64748b" font-size="9" text-anchor="middle">→ run_glob()</text>
<!-- 箭头:分发映射 → 回到消息列表 -->
<path d="M 195 368 L 50 368 L 50 108 L 58 108" fill="none" stroke="#d97706" stroke-width="1.5" marker-end="url(#arrow-orange)" stroke-dasharray="6,3"/>
<text x="28" y="300" fill="#92400e" font-size="9" font-weight="500" transform="rotate(-90, 28, 300)">tool_result 追加到 messages</text>
<!-- ===== 图例 ===== -->
<rect x="60" y="492" width="600" height="52" rx="6" fill="#f1f5f9"/>
<rect x="80" y="508" width="12" height="12" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
<text x="100" y="518" fill="#334155" font-size="10">s01 保留(循环、LLM、判断——完全不变)</text>
<rect x="380" y="508" width="12" height="12" rx="2" fill="#ecfdf5" stroke="#16a34a" stroke-width="1"/>
<text x="400" y="518" fill="#334155" font-size="10">s02 新增(5 个工具 + 分发映射)</text>
<text x="80" y="536" fill="#64748b" font-size="10">循环里只改了 1 行:run_bash() → TOOL_HANDLERS[block.name]()</text>
</svg>

After

Width:  |  Height:  |  Size: 6.6 KiB

+232
View File
@@ -0,0 +1,232 @@
# s03: Permission — Check Permissions Before Execution
[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
s01 → s02 → `s03` → [s04](../s04_hooks/) → s05 → ... → s20
> *"Check permissions before executing"* — The permission pipeline decides which operations need approval.
>
> **Harness Layer**: Permission — a gate before tool execution.
---
## The Problem
s02's Agent has 5 tools. File tools are protected by `safe_path`, but bash is unrestricted. Ask it to "clean up the project," and it might run `rm -rf /`.
Safety can't rely on trusting the model — it needs code: a check before every tool execution.
---
## The Solution
![Permission Overview](images/permission-overview.en.svg)
s02's loop is fully preserved. The only change is inserting `check_permission()` before tool execution — each tool call passes through three gates in a fixed order: hard deny first, then soft ask, and if neither matches, allow.
The three gates correspond to three decisions:
| Gate | Purpose | On Match |
|------|---------|----------|
| 1. Deny List | Permanently forbidden operations (`rm -rf /`, `sudo`) | Denied immediately, not executed |
| 2. Rule Matching | Context-dependent operations (writing outside workspace, `rm` files) | Passed to Gate 3 |
| 3. User Approval | After Gate 2 matches, pauses for user confirmation | User decides allow or deny |
None of the three gates match → execute directly. Most routine operations take this path.
---
## How It Works
![Permission Pipeline](images/permission-pipeline.en.svg)
**Gate 1**: A hard deny list. Check first; if matched, return a block message. (Teaching demo: simple string matching is not a reliable security mechanism — command variants and shell expansion can bypass it. CC's approach is in the appendix.)
```python
DENY_LIST = [
"rm -rf /", "sudo", "shutdown", "reboot",
"mkfs", "dd if=", "> /dev/sda",
]
def check_deny_list(command: str) -> str | None:
for pattern in DENY_LIST:
if pattern in command:
return f"Blocked: '{pattern}' is on the deny list"
return None
```
**Gate 2**: Rule matching — describes "when to ask the user." Each rule specifies a tool and a check condition.
```python
PERMISSION_RULES = [
{
"tools": ["write_file", "edit_file"],
"check": lambda args: not (WORKDIR / args.get("path", "")).resolve().is_relative_to(WORKDIR),
"message": "Writing outside workspace",
},
{
"tools": ["bash"],
"check": lambda args: any(kw in args.get("command", "") for kw in ["rm ", "> /etc/", "chmod 777"]),
"message": "Potentially destructive command",
},
]
def check_rules(tool_name: str, args: dict) -> str | None:
for rule in PERMISSION_RULES:
if tool_name in rule["tools"] and rule["check"](args):
return rule["message"]
return None
```
**Gate 3**: After a rule matches, pause for user input.
```python
def ask_user(tool_name: str, args: dict, reason: str) -> str:
print(f"\n{reason}")
print(f" Tool: {tool_name}({args})")
choice = input(" Allow? [y/N] ").strip().lower()
return "allow" if choice in ("y", "yes") else "deny"
```
**All three gates chained together**, inserted before tool execution:
```python
def check_permission(block) -> bool:
# Gate 1: Hard deny
if block.name == "bash":
reason = check_deny_list(block.input.get("command", ""))
if reason:
print(f"\n{reason}")
return False
# Gate 2 + 3: Rule matching → User approval
reason = check_rules(block.name, block.input)
if reason:
decision = ask_user(block.name, block.input, reason)
if decision == "deny":
return False
return True
# In agent_loop — s02's loop with just one line added:
for block in response.content:
if block.type == "tool_use":
if not check_permission(block): # ← NEW
results.append({... "content": "Permission denied."})
continue
output = TOOL_HANDLERS[block.name](**block.input) # s02 original
results.append(...)
```
---
## Changes from s02
| Component | Before (s02) | After (s03) |
|-----------|-------------|-------------|
| Security model | None (trust the model) | Three-gate permission pipeline |
| New functions | — | check_deny_list, check_rules, ask_user, check_permission |
| Loop | Executes all tools directly | Inserts check_permission() before execution |
---
## Try It
```sh
cd learn-claude-code
python s03_permission/code.py
```
Try these prompts:
1. `Create a file called test.txt in the current directory` (should pass through)
2. `Delete the file test.txt` (bash + rm triggers Gate 2)
3. `What files are in the current directory?` (read-only, all pass)
4. `Try to write a file to /etc/something` (writing outside workspace triggers Gate 2)
What to watch for: Which operations pass through? Which need your confirmation? Which are denied outright?
---
## What's Next
Permission checks are in place — but every check is hardcoded as `check_permission()` inside the loop. What if you want to add logging before and after each tool execution? What if you want to auto-trigger a git commit after certain operations? Scattering this extension logic throughout the loop makes it bloat.
→ s04 Hooks: Add hooks to the loop. Extension logic hangs on hooks; the loop stays clean.
<details>
<summary>Dive into CC Source Code</summary>
> The following is based on a review of CC source code `types/permissions.ts`, `utils/permissions/permissions.ts`, `toolExecution.ts`, `utils/permissions/yoloClassifier.ts`, `tools/AgentTool/forkSubagent.ts`.
### 1. PermissionResult: Not 3, but 4
The teaching version's three gates (deny → ask → allow) don't fully correspond to CC. CC's `PermissionResult` has 4 behaviors (`types/permissions.ts:241-266`):
| behavior | Meaning | Teaching Version Equivalent |
|----------|---------|---------------------------|
| `allow` | Allow directly | Gate 3 passes |
| `deny` | Deny directly | Gate 1 matches |
| `ask` | Show dialog to user | Gate 2 matches |
| `passthrough` | Tool doesn't express opinion, passes to generic pipeline | Not in teaching version |
### 2. Production Verification Stages
CC's tool calls don't go through three gates — they go through multiple stages distributed across `checkPermissionsAndCallTool()` (`toolExecution.ts:599-1745`), hooks, `hasPermissionsToUseToolInner()` (`utils/permissions/permissions.ts:1158-1310`), and classifier logic:
1. **Zod schema validation** (`toolExecution.ts:614-680`) — parameter type checking
2. **validateInput()** (`toolExecution.ts:682-733`) — tool-level semantic validation
3. **backfillObservableInput()** (`toolExecution.ts:784`) — backfill legacy fields
4. **PreToolUse hooks** (`toolExecution.ts:800-862`) — hooks can return allow/deny/ask
5. **resolveHookPermissionDecision()** (`toolExecution.ts:921-931`) — coordinate hook + pipeline decisions
6. **hasPermissionsToUseToolInner()** (`permissions.ts:1158-1310`) — multi-layer rule check:
- Entire tool disabled by deny rule → `deny`
- Entire tool flagged by ask rule → `ask`
- `tool.checkPermissions()` tool's own judgment
- Tool itself returns deny → `deny`
- `requiresUserInteraction()``ask`
- Content-related ask rules → `ask` (not bypassable)
- Security check violation → `ask` (not bypassable)
- bypassPermissions mode → `allow`
- Entire tool allowed by allow rule → `allow`
- passthrough → converted to `ask`
### 3. Deny List: Not One File, but 8 Sources
CC doesn't have a single deny list. Permission rules come from 8 sources (`types/permissions.ts:54-62`):
| Source | Configuration Location |
|--------|----------------------|
| `userSettings` | `~/.claude/settings.json` |
| `projectSettings` | `.claude/settings.json` |
| `localSettings` | `settings.local.json` |
| `flagSettings` | Feature flags |
| `policySettings` | Enterprise management policy |
| `cliArg` | `--allowedTools` / `--deniedTools` |
| `command` | Inline command |
| `session` | In-session temporary authorization |
Each rule format: `{ toolName: "Bash", ruleBehavior: "deny", ruleContent: "npm publish:*" }`. Rules from multiple sources are merged, with higher-priority sources overriding lower ones (low to high: user < project < local < flag < policy, plus cliArg, command, session).
### 4. What is isDestructive()
In CC, `isDestructive` (`Tool.ts:405-406`) is **purely for UI display** — showing a `[destructive]` label in the tool list. It doesn't participate in permission decisions. All tools return `false` by default. Only ExitWorktree (on remove) and MCP tools (depending on `annotations.destructiveHint`) override it.
### 5. YoloClassifier (Auto-Approval)
In CC's auto mode, it doesn't pop a dialog every time. `classifyYoloAction` (`utils/permissions/yoloClassifier.ts:1012`) sends the tool call + conversation context to a classifier LLM to judge safety. It first tries acceptEdits mode simulation (`permissions.ts:620-656`, if acceptEdits allows → auto-approve), then checks the safe tool whitelist (`permissions.ts:658-686`), and finally calls the classifier. If the classifier rejects too many times in a row → falls back to manual approval.
### 6. Permission Bubbling
A sub-Agent's (forked via AgentTool) `permissionMode` is set to `'bubble'` (`forkSubagent.ts:50`). This means permission dialogs **bubble up to the parent Agent's terminal**, rather than being silently denied in the sub-Agent. The Bash classifier continues running during this process — displaying the permission dialog while judging in the background whether auto-approval is possible.
### The Teaching Version's Simplification Is Intentional
- Multi-stage pipeline → 3 gates: dramatically lower barrier to understanding
- 8 rule sources → 1 local DENY_LIST: manageable concept count
- isDestructive → omitted (teaching version has no UI layer, and it doesn't participate in permission decisions in CC either)
- YoloClassifier → omitted (depends on additional LLM calls and telemetry)
- Permission bubbling → omitted (s15 covers multi-Agent)
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+232
View File
@@ -0,0 +1,232 @@
# s03: Permission — 実行前に権限を判断する
[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
s01 → s02 → `s03` → [s04](../s04_hooks/) → s05 → ... → s20
> *"ツール実行前に権限を判断"* — 権限パイプラインは、どの操作に承認が必要かを決める。
>
> **Harness レイヤー**: 権限 — ツール実行前に一つのゲートを追加。
---
## 課題
s02 の Agent は 5 つのツールを持つ。file tools は `safe_path` で保護されるが、bash は制限なし。「プロジェクトを掃除して」と頼むと、`rm -rf /` を実行しかねない。
安全性はモデルを信頼することではなく、コードに頼る — ツール実行前に判断を挟む。
---
## ソリューション
![Permission Overview](images/permission-overview.ja.svg)
s02 のループは完全に維持される。唯一の変更は、ツール実行前に `check_permission()` を挿入すること — 各ツール呼び出しは 3 つのゲートを固定順序で通過する:ハード拒否が最優先、次にソフト確認、どちらも一致しなければ許可。
3 つのゲートは 3 つの決定に対応する:
| ゲート | 役割 | 一致時 |
|--------|------|--------|
| 1. 拒否リスト | 常に禁止される操作(`rm -rf /``sudo` | 即座に拒否、実行しない |
| 2. ルールマッチング | コンテキスト依存の操作(作業ディレクトリ外への書き込み、`rm` ファイル) | ゲート 3 へ |
| 3. ユーザー承認 | ゲート 2 が一致した場合、ユーザー確認を待機 | ユーザーが許可または拒否を決定 |
3 つのゲートのどれにも一致しない → 直接実行。日常の操作の大部分はこの経路を通る。
---
## 仕組み
![Permission Pipeline](images/permission-pipeline.ja.svg)
**ゲート 1**:ハード拒否リスト。最初に確認し、一致すればブロックメッセージを返す。(教育デモ:単純な文字列マッチングは信頼できるセキュリティ機構ではない — コマンドの変種やシェル展開で回避される可能性がある。CC のアプローチは付録を参照。)
```python
DENY_LIST = [
"rm -rf /", "sudo", "shutdown", "reboot",
"mkfs", "dd if=", "> /dev/sda",
]
def check_deny_list(command: str) -> str | None:
for pattern in DENY_LIST:
if pattern in command:
return f"Blocked: '{pattern}' is on the deny list"
return None
```
**ゲート 2**:ルールマッチング — 「いつユーザーに聞くべきか」を記述する。各ルールはツールとチェック条件を指定する。
```python
PERMISSION_RULES = [
{
"tools": ["write_file", "edit_file"],
"check": lambda args: not (WORKDIR / args.get("path", "")).resolve().is_relative_to(WORKDIR),
"message": "Writing outside workspace",
},
{
"tools": ["bash"],
"check": lambda args: any(kw in args.get("command", "") for kw in ["rm ", "> /etc/", "chmod 777"]),
"message": "Potentially destructive command",
},
]
def check_rules(tool_name: str, args: dict) -> str | None:
for rule in PERMISSION_RULES:
if tool_name in rule["tools"] and rule["check"](args):
return rule["message"]
return None
```
**ゲート 3**:ルールが一致した後、ユーザー入力を待機。
```python
def ask_user(tool_name: str, args: dict, reason: str) -> str:
print(f"\n{reason}")
print(f" Tool: {tool_name}({args})")
choice = input(" Allow? [y/N] ").strip().lower()
return "allow" if choice in ("y", "yes") else "deny"
```
**3 つのゲートを直列に接続**、ツール実行前に挿入する:
```python
def check_permission(block) -> bool:
# ゲート 1: ハード拒否
if block.name == "bash":
reason = check_deny_list(block.input.get("command", ""))
if reason:
print(f"\n{reason}")
return False
# ゲート 2 + 3: ルールマッチング → ユーザー承認
reason = check_rules(block.name, block.input)
if reason:
decision = ask_user(block.name, block.input, reason)
if decision == "deny":
return False
return True
# agent_loop で — s02 のループに 1 行追加するだけ:
for block in response.content:
if block.type == "tool_use":
if not check_permission(block): # ← 新規
results.append({... "content": "Permission denied."})
continue
output = TOOL_HANDLERS[block.name](**block.input) # s02 既存
results.append(...)
```
---
## s02 からの変更点
| コンポーネント | 変更前 (s02) | 変更後 (s03) |
|---------------|-------------|-------------|
| セキュリティモデル | なし(モデルを信頼) | 3 ゲート権限パイプライン |
| 新規関数 | — | check_deny_list, check_rules, ask_user, check_permission |
| ループ | すべてのツールを直接実行 | 実行前に check_permission() を挿入 |
---
## 試してみよう
```sh
cd learn-claude-code
python s03_permission/code.py
```
以下のプロンプトを試してみよう:
1. `Create a file called test.txt in the current directory`(そのまま通過するはず)
2. `Delete the file test.txt`bash + rm でゲート 2 が発動)
3. `What files are in the current directory?`(読み取り専用、すべて通過)
4. `Try to write a file to /etc/something`(作業ディレクトリ外への書き込みでゲート 2 が発動)
観察のポイント:どの操作がそのまま通過するか? どれに確認が必要か? どれが即座に拒否されるか?
---
## 次へ
権限チェックは実装された — しかし、毎回ループ内に `check_permission()` をハードコードしている。ツール実行の前後にログを追加したい場合は? 特定の操作後に自動的に git commit をトリガーしたい場合は? このような拡張ロジックがループ内に散らばると、ループはすぐに膨張する。
→ s04 Hooks:ループにフックを追加する。拡張ロジックはフックにぶら下げ、ループはクリーンに保つ。
<details>
<summary>CC ソースコードを深掘り</summary>
> 以下は CC ソースコード `types/permissions.ts`、`utils/permissions/permissions.ts`、`toolExecution.ts`、`utils/permissions/yoloClassifier.ts`、`tools/AgentTool/forkSubagent.ts` の検証に基づく。
### 一、PermissionResult3 種ではなく、4 種
教育版の 3 つのゲート(deny → ask → allow)は CC と完全には対応しない。CC の `PermissionResult` には 4 つの behavior がある(`types/permissions.ts:241-266`):
| behavior | 意味 | 教育版の対応 |
|----------|------|-------------|
| `allow` | 直接許可 | ゲート 3 通過 |
| `deny` | 直接拒否 | ゲート 1 一致 |
| `ask` | ユーザーにダイアログを表示 | ゲート 2 一致 |
| `passthrough` | ツールが意見を表明せず、汎用パイプラインに委ねる | 教育版にはなし |
### 二、本番環境の検証段階
CC のツール呼び出しは 3 つのゲートを通るのではなく、`checkPermissionsAndCallTool()``toolExecution.ts:599-1745`)、hooks、`hasPermissionsToUseToolInner()``utils/permissions/permissions.ts:1158-1310`)、classifier ロジックに分散する複数の段階を経る:
1. **Zod schema 検証**`toolExecution.ts:614-680`)— パラメータの型チェック
2. **validateInput()**`toolExecution.ts:682-733`)— ツールレベルの意味的検証
3. **backfillObservableInput()**`toolExecution.ts:784`)— レガシーフィールドの補完
4. **PreToolUse hooks**`toolExecution.ts:800-862`)— フックが allow/deny/ask を返す
5. **resolveHookPermissionDecision()**`toolExecution.ts:921-931`)— フック + パイプラインの決定を調整
6. **hasPermissionsToUseToolInner()**`permissions.ts:1158-1310`)— 多層ルールチェック:
- ツール全体が deny rule で無効 → `deny`
- ツール全体が ask rule でマーク → `ask`
- `tool.checkPermissions()` ツール自身の判断
- ツール自身が deny を返す → `deny`
- `requiresUserInteraction()``ask`
- コンテンツ関連の ask ルール → `ask`(バイパス不可)
- セキュリティチェック違反 → `ask`(バイパス不可)
- bypassPermissions モード → `allow`
- ツール全体が allow rule で許可 → `allow`
- passthrough → `ask` に変換
### 三、拒否リスト:1 つのファイルではなく、8 つのソース
CC には単一の deny list はない。権限ルールは 8 つのソースから来る(`types/permissions.ts:54-62`):
| ソース | 設定場所 |
|--------|---------|
| `userSettings` | `~/.claude/settings.json` |
| `projectSettings` | `.claude/settings.json` |
| `localSettings` | `settings.local.json` |
| `flagSettings` | フィーチャーフラグ |
| `policySettings` | 企業管理ポリシー |
| `cliArg` | `--allowedTools` / `--deniedTools` |
| `command` | インラインコマンド |
| `session` | セッション内一時承認 |
各ルールの形式:`{ toolName: "Bash", ruleBehavior: "deny", ruleContent: "npm publish:*" }`。複数ソースのルールは統合され、高優先度ソースが低優先度を上書きする(低→高:user < project < local < flag < policy、さらに cliArg、command、session)。
### 四、isDestructive() とは
CC では `isDestructive``Tool.ts:405-406`)は**純粋に UI 表示用** — ツール一覧に `[destructive]` ラベルを表示するだけ。権限決定には参加しない。デフォルトではすべてのツールが `false` を返す。ExitWorktreeremove 時)と MCP ツール(`annotations.destructiveHint` に依存)のみがオーバーライドする。
### 五、YoloClassifier(自動承認)
CC の auto モードでは、毎回ダイアログを表示するわけではない。`classifyYoloAction``utils/permissions/yoloClassifier.ts:1012`)はツール呼び出し + 会話コンテキストを分類器 LLM に送って安全性を判断する。まず acceptEdits モードのシミュレーションを試み(`permissions.ts:620-656`、acceptEdits が許可すれば → 自動承認)、次にセーフツールホワイトリストを確認し(`permissions.ts:658-686`)、最後に分類器を呼び出す。分類器が連続して拒否しすぎた場合 → 手動承認にフォールバック。
### 六、権限バブリング
サブ AgentAgentTool 経由でフォークされたもの)の `permissionMode``'bubble'` に設定される(`forkSubagent.ts:50`)。これは権限ダイアログが**親 Agent のターミナルにバブルアップ**することを意味する。サブ Agent で黙って拒否されるのではない。Bash 分類器はこの過程で引き続き実行され — 権限ダイアログを表示しつつ、バックグラウンドで自動承認可能か判断する。
### 教育版の単純化は意図的
- 多段階パイプライン → 3 ゲート:理解のハードルが大幅に下がる
- 8 ルールソース → 1 つのローカル DENY_LIST:概念量を制御可能
- isDestructive → 省略(教育版には UI レイヤーがなく、CC でも権限決定には参加しない)
- YoloClassifier → 省略(追加の LLM 呼び出しとテレメトリに依存)
- 権限バブリング → 省略(s15 でマルチ Agent を扱う)
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+232
View File
@@ -0,0 +1,232 @@
# s03: Permission — 执行前做权限判断
[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
s01 → s02 → `s03` → [s04](../s04_hooks/) → s05 → ... → s20
> *"工具执行前先做权限判断"* — 权限管线决定哪些操作需要审批。
>
> **Harness 层**: 权限 — 在工具执行前加一道门。
---
## 问题
s02 的 Agent 有 5 个工具。file tools 受 `safe_path` 保护,但 bash 不受限制。让它"清理一下项目",可能执行 `rm -rf /`
安全不能靠信任模型,要靠代码——在工具执行之前做判断。
---
## 解决方案
![Permission Overview](images/permission-overview.svg)
s02 的循环完全保留。唯一的变动在工具执行前插入 `check_permission()`——每个工具调用经过三道闸门,顺序固定:硬拒绝优先,软询问次之,都没命中就放行。
三道闸门对应三种决策:
| 闸门 | 作用 | 命中后 |
|------|------|--------|
| 1. 拒绝列表 | 永远禁止的操作(`rm -rf /``sudo` | 直接拒绝,不执行 |
| 2. 规则匹配 | 取决于上下文的操作(写工作区外、`rm` 文件) | 交给闸门 3 |
| 3. 用户审批 | 闸门 2 命中后,暂停等用户确认 | 用户决定允许或拒绝 |
三道都没命中 → 直接执行。大部分日常操作走这条路。
---
## 工作原理
![Permission Pipeline](images/permission-pipeline.svg)
**闸门 1**:一张硬拒绝表,先查,命中就返回阻止信息。(教学示意:简单字符串匹配不是可靠安全机制,命令变体和 shell 展开可能绕过。CC 的做法见附录。)
```python
DENY_LIST = [
"rm -rf /", "sudo", "shutdown", "reboot",
"mkfs", "dd if=", "> /dev/sda",
]
def check_deny_list(command: str) -> str | None:
for pattern in DENY_LIST:
if pattern in command:
return f"Blocked: '{pattern}' is on the deny list"
return None
```
**闸门 2**:规则匹配——描述"什么时候需要问用户"。每条规则指定工具和检查条件。
```python
PERMISSION_RULES = [
{
"tools": ["write_file", "edit_file"],
"check": lambda args: not (WORKDIR / args.get("path", "")).resolve().is_relative_to(WORKDIR),
"message": "Writing outside workspace",
},
{
"tools": ["bash"],
"check": lambda args: any(kw in args.get("command", "") for kw in ["rm ", "> /etc/", "chmod 777"]),
"message": "Potentially destructive command",
},
]
def check_rules(tool_name: str, args: dict) -> str | None:
for rule in PERMISSION_RULES:
if tool_name in rule["tools"] and rule["check"](args):
return rule["message"]
return None
```
**闸门 3**:规则命中后,暂停等用户输入。
```python
def ask_user(tool_name: str, args: dict, reason: str) -> str:
print(f"\n{reason}")
print(f" Tool: {tool_name}({args})")
choice = input(" Allow? [y/N] ").strip().lower()
return "allow" if choice in ("y", "yes") else "deny"
```
**三道闸门串在一起**,插在工具执行之前:
```python
def check_permission(block) -> bool:
# 闸门 1: 硬拒绝
if block.name == "bash":
reason = check_deny_list(block.input.get("command", ""))
if reason:
print(f"\n{reason}")
return False
# 闸门 2 + 3: 规则匹配 → 用户审批
reason = check_rules(block.name, block.input)
if reason:
decision = ask_user(block.name, block.input, reason)
if decision == "deny":
return False
return True
# 在 agent_loop 中——s02 的循环只加了一行:
for block in response.content:
if block.type == "tool_use":
if not check_permission(block): # ← 新增
results.append({... "content": "Permission denied."})
continue
output = TOOL_HANDLERS[block.name](**block.input) # s02 原有
results.append(...)
```
---
## 相对 s02 的变更
| 组件 | 之前 (s02) | 之后 (s03) |
|------|-----------|-----------|
| 安全模型 | 无(信任模型) | 三道闸门权限管线 |
| 新函数 | — | check_deny_list, check_rules, ask_user, check_permission |
| 循环 | 直接执行所有工具 | 执行前插入 check_permission() |
---
## 试一下
```sh
cd learn-claude-code
python s03_permission/code.py
```
试试这些 prompt
1. `Create a file called test.txt in the current directory`(应该直接通过)
2. `Delete the file test.txt`bash + rm 会触发闸门 2
3. `What files are in the current directory?`(只读,全部通过)
4. `Try to write a file to /etc/something`(写工作区外,触发闸门 2
观察重点:哪些操作直接通过?哪些需要你确认?哪些被直接拒绝?
---
## 接下来
权限检查做了——但每次都在循环里硬编码 `check_permission()`。如果我想在每次工具执行前后加日志?如果想在某些操作后自动触发 git commit?这些扩展逻辑散落在 loop 里,循环很快就会膨胀。
s04 Hooks → 给循环加钩子,扩展逻辑挂在钩子上,循环保持干净。
<details>
<summary>深入 CC 源码</summary>
> 以下基于 CC 源码 `types/permissions.ts`、`utils/permissions/permissions.ts`、`toolExecution.ts`、`utils/permissions/yoloClassifier.ts`、`tools/AgentTool/forkSubagent.ts` 的核查。
### 一、PermissionResult:不是 3 种,是 4 种
教学版的三道闸门(deny → ask → allow)和 CC 不完全对应。CC 的 `PermissionResult` 有 4 个 behavior`types/permissions.ts:241-266`):
| behavior | 含义 | 教学版对应 |
|----------|------|-----------|
| `allow` | 直接允许 | 闸门 3 通过 |
| `deny` | 直接拒绝 | 闸门 1 命中 |
| `ask` | 弹出对话框问用户 | 闸门 2 命中 |
| `passthrough` | 工具不表态,交给通用管线决定 | 教学版无 |
### 二、生产版的验证阶段
CC 的工具调用不是经过三道闸门,而是经过多个阶段,分布在 `checkPermissionsAndCallTool()``toolExecution.ts:599-1745`)、hooks、`hasPermissionsToUseToolInner()``utils/permissions/permissions.ts:1158-1310`)和 classifier 逻辑里:
1. **Zod schema 验证**`toolExecution.ts:614-680`)— 参数类型检查
2. **validateInput()**`toolExecution.ts:682-733`)— 工具级语义验证
3. **backfillObservableInput()**`toolExecution.ts:784`)— 补全遗留字段
4. **PreToolUse hooks**`toolExecution.ts:800-862`)— 钩子可以返回 allow/deny/ask
5. **resolveHookPermissionDecision()**`toolExecution.ts:921-931`)— 协调钩子+管线决策
6. **hasPermissionsToUseToolInner()**`permissions.ts:1158-1310`)— 多层规则检查:
- 整个工具被 deny rule 禁用 → `deny`
- 整个工具被 ask rule 标记 → `ask`
- `tool.checkPermissions()` 工具自己的判断
- 工具自己返回 deny → `deny`
- `requiresUserInteraction()``ask`
- 内容相关的 ask 规则 → `ask`(不可绕过)
- 安全检查违规 → `ask`(不可绕过)
- bypassPermissions 模式 → `allow`
- 整个工具被 allow rule 放行 → `allow`
- passthrough → 转为 `ask`
### 三、拒绝列表:不是一个文件,是 8 个来源
CC 没有单一的 deny list。权限规则来自 8 个来源(`types/permissions.ts:54-62`):
| 来源 | 配置位置 |
|------|---------|
| `userSettings` | `~/.claude/settings.json` |
| `projectSettings` | `.claude/settings.json` |
| `localSettings` | `settings.local.json` |
| `flagSettings` | Feature flags |
| `policySettings` | 企业管理策略 |
| `cliArg` | `--allowedTools` / `--deniedTools` |
| `command` | 内联命令 |
| `session` | 会话内临时授权 |
每条规则格式:`{ toolName: "Bash", ruleBehavior: "deny", ruleContent: "npm publish:*" }`。多个来源的规则合并,高优先级来源覆盖低优先级(从低到高:user < project < local < flag < policy,加上 cliArg、command、session)。
### 四、isDestructive() 是什么
CC 中 `isDestructive``Tool.ts:405-406`)**纯粹是 UI 展示用的**——在工具列表里显示 `[destructive]` 标签。它不参与权限决策。默认所有工具都返回 `false`。只有 ExitWorktreeremove 时)和 MCP 工具(依赖 `annotations.destructiveHint`)覆写了它。
### 五、YoloClassifier(自动审批)
CC 的 auto 模式下,不会每次都弹对话框。`classifyYoloAction``utils/permissions/yoloClassifier.ts:1012`)把工具调用 + 对话上下文发给一个分类器 LLM 判断是否安全。先尝试 acceptEdits 模式模拟(`permissions.ts:620-656`,如果 acceptEdits 允许 → 直接批准),再查安全工具白名单(`permissions.ts:658-686`),最后才调分类器。分类器连续拒绝太多次 → 回退到人工审批。
### 六、权限冒泡
子 Agent(通过 AgentTool fork 出来的)的 `permissionMode` 设为 `'bubble'``forkSubagent.ts:50`)。意思是权限弹窗**冒泡到父 Agent 的终端**,而不是在子 Agent 里静默拒绝。Bash 分类器在这个过程中继续跑——给权限对话框显示的同时在后台判断是否可以自动批准。
### 教学版的简化是刻意的
- 多阶段管线 → 3 道闸门:理解门槛大幅降低
- 8 个规则来源 → 1 个本地 DENY_LIST:概念量可控
- isDestructive → 忽略(教学版没有 UI 层,CC 里它也不参与权限决策)
- YoloClassifier → 省略(依赖于额外的 LLM 调用和遥测系统)
- 权限冒泡 → 省略(s15 才涉及多 Agent)
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env python3
"""
s03_permission.py - Permission System
Three gates inserted before tool execution:
Gate 1: Hard deny list (rm -rf /, sudo, ...)
Gate 2: Rule matching (write outside workspace? destructive cmd?)
Gate 3: User approval (pause and wait for confirmation)
+-------+ +--------+ +--------+ +--------+ +------+
| Tool | -> | Gate 1 | -> | Gate 2 | -> | Gate 3 | -> | Exec |
| call | | deny? | | match? | | allow? | | |
+-------+ +--------+ +--------+ +--------+ +------+
| | | |
v v v v
(normal) (blocked) (ask user) (user says no?)
Only one line added to the agent loop:
if not check_permission(block):
continue
Builds on s02 (multi-tool). Usage:
python s03_permission/code.py
Needs: pip install anthropic python-dotenv + ANTHROPIC_API_KEY in .env
"""
import os, subprocess
from pathlib import Path
try:
import readline
readline.parse_and_bind('set bind-tty-special-chars off')
readline.parse_and_bind('set input-meta on')
readline.parse_and_bind('set output-meta on')
readline.parse_and_bind('set convert-meta off')
except ImportError:
pass
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
SYSTEM = f"You are a coding agent at {WORKDIR}. All destructive operations require user approval."
# ═══════════════════════════════════════════════════════════
# FROM s02 (unchanged): Tool Implementations
# ═══════════════════════════════════════════════════════════
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def run_bash(command: str) -> str:
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=120)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
def run_read(path: str, limit: int | None = None) -> str:
try:
lines = safe_path(path).read_text().splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"]
return "\n".join(lines)
except Exception as e:
return f"Error: {e}"
def run_write(path: str, content: str) -> str:
try:
file_path = safe_path(path)
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content)
return f"Wrote {len(content)} bytes to {path}"
except Exception as e:
return f"Error: {e}"
def run_edit(path: str, old_text: str, new_text: str) -> str:
try:
file_path = safe_path(path)
text = file_path.read_text()
if old_text not in text:
return f"Error: text not found in {path}"
file_path.write_text(text.replace(old_text, new_text, 1))
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"
def run_glob(pattern: str) -> str:
import glob as g
try:
results = []
for match in g.glob(pattern, root_dir=WORKDIR):
if (WORKDIR / match).resolve().is_relative_to(WORKDIR):
results.append(match)
return "\n".join(results) if results else "(no matches)"
except Exception as e:
return f"Error: {e}"
# ═══════════════════════════════════════════════════════════
# FROM s02 (unchanged): Tool Definitions & Dispatch
# ═══════════════════════════════════════════════════════════
TOOLS = [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write content to a file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in a file once.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "glob", "description": "Find files matching a glob pattern.",
"input_schema": {"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]}},
]
TOOL_HANDLERS = {
"bash": run_bash, "read_file": run_read, "write_file": run_write,
"edit_file": run_edit, "glob": run_glob,
}
# ═══════════════════════════════════════════════════════════
# NEW in s03: Three-Gate Permission Pipeline
# ═══════════════════════════════════════════════════════════
# Gate 1: Hard deny list — always forbidden
DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if=", "> /dev/sda"]
def check_deny_list(command: str) -> str | None:
for pattern in DENY_LIST:
if pattern in command:
return f"Blocked: '{pattern}' is on the deny list"
return None
# Gate 2: Rule matching — context-dependent checks
PERMISSION_RULES = [
{"tools": ["write_file", "edit_file"],
"check": lambda args: not (WORKDIR / args.get("path", "")).resolve().is_relative_to(WORKDIR),
"message": "Writing outside workspace"},
{"tools": ["bash"],
"check": lambda args: any(kw in args.get("command", "") for kw in ["rm ", "> /etc/", "chmod 777"]),
"message": "Potentially destructive command"},
]
def check_rules(tool_name: str, args: dict) -> str | None:
for rule in PERMISSION_RULES:
if tool_name in rule["tools"] and rule["check"](args):
return rule["message"]
return None
# Gate 3: User approval — wait for confirmation after rule match
def ask_user(tool_name: str, args: dict, reason: str) -> str:
print(f"\n\033[33m⚠ {reason}\033[0m")
print(f" Tool: {tool_name}({args})")
choice = input(" Allow? [y/N] ").strip().lower()
return "allow" if choice in ("y", "yes") else "deny"
# Pipeline: all three gates chained
def check_permission(block) -> bool:
if block.name == "bash":
reason = check_deny_list(block.input.get("command", ""))
if reason:
print(f"\n\033[31m⛔ {reason}\033[0m")
return False
reason = check_rules(block.name, block.input)
if reason:
decision = ask_user(block.name, block.input, reason)
if decision == "deny":
return False
return True
# ═══════════════════════════════════════════════════════════
# agent_loop — same as s02, with check_permission() inserted
# ═══════════════════════════════════════════════════════════
def agent_loop(messages: list):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
for block in response.content:
if block.type != "tool_use":
continue
print(f"\033[36m> {block.name}\033[0m")
# s03 change: run through permission pipeline before executing
if not check_permission(block):
results.append({"type": "tool_result", "tool_use_id": block.id,
"content": "Permission denied."})
continue
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler else f"Unknown: {block.name}"
print(str(output)[:200])
results.append({"type": "tool_result", "tool_use_id": block.id, "content": output})
messages.append({"role": "user", "content": results})
if __name__ == "__main__":
print("s03: Permission")
print("输入问题,回车发送。输入 q 退出。\n")
history = []
while True:
try:
query = input("\033[36ms03 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
history.append({"role": "user", "content": query})
agent_loop(history)
for block in history[-1]["content"]:
if getattr(block, "type", None) == "text":
print(block.text)
print()
@@ -0,0 +1,104 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 320" font-family="system-ui, -apple-system, sans-serif">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555"/>
</marker>
<marker id="arrow-blue" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#2563eb"/>
</marker>
<marker id="arrow-red" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#dc2626"/>
</marker>
<linearGradient id="header" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#1e3a5f"/>
<stop offset="100%" stop-color="#2563eb"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="720" height="320" fill="#fafbfc" rx="8"/>
<!-- Title -->
<rect x="0" y="0" width="720" height="48" fill="url(#header)" rx="8"/>
<rect x="0" y="40" width="720" height="8" fill="url(#header)"/>
<text x="360" y="31" fill="#fff" font-size="16" font-weight="700" text-anchor="middle">Permission — Loop unchanged, a gate before tool execution</text>
<!-- ===== s02 preserved (gray) ===== -->
<text x="50" y="76" fill="#94a3b8" font-size="11" font-weight="600">s02 preserved</text>
<!-- User input -->
<rect x="60" y="88" width="120" height="40" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="120" y="113" fill="#1e3a5f" font-size="12" font-weight="600" text-anchor="middle">messages[]</text>
<!-- Arrow → LLM -->
<line x1="180" y1="108" x2="228" y2="108" stroke="#2563eb" stroke-width="1.5" marker-end="url(#arrow-blue)"/>
<!-- LLM -->
<rect x="230" y="84" width="130" height="48" rx="8" fill="#fff" stroke="#2563eb" stroke-width="1.5"/>
<text x="295" y="104" fill="#1e3a5f" font-size="13" font-weight="700" text-anchor="middle">LLM</text>
<text x="295" y="122" fill="#64748b" font-size="10" text-anchor="middle">stop_reason?</text>
<!-- No → return -->
<line x1="295" y1="132" x2="295" y2="156" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrow)"/>
<text x="308" y="150" fill="#16a34a" font-size="9" font-weight="600">No</text>
<rect x="240" y="158" width="110" height="32" rx="16" fill="#dcfce7" stroke="#16a34a" stroke-width="1.5"/>
<text x="295" y="178" fill="#166534" font-size="11" font-weight="600" text-anchor="middle">Return result</text>
<!-- Yes → next step -->
<line x1="360" y1="108" x2="400" y2="108" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<text x="380" y="100" fill="#d97706" font-size="9" font-weight="600">Yes</text>
<!-- ===== s03 new: Permission check ===== -->
<text x="482" y="72" fill="#dc2626" font-size="11" font-weight="600" text-anchor="middle">s03 new</text>
<!-- Permission check box -->
<rect x="402" y="78" width="160" height="120" rx="10" fill="#fef2f2" stroke="#dc2626" stroke-width="2" stroke-dasharray="6,3"/>
<text x="482" y="100" fill="#991b1b" font-size="11" font-weight="700" text-anchor="middle">check_permission()</text>
<!-- Gate 1 -->
<rect x="416" y="110" width="132" height="24" rx="4" fill="#fee2e2" stroke="#dc2626" stroke-width="1"/>
<text x="482" y="126" fill="#991b1b" font-size="9" font-weight="600" text-anchor="middle">Gate 1: Deny List</text>
<!-- Gate 2 -->
<rect x="416" y="140" width="132" height="24" rx="4" fill="#fef3c7" stroke="#d97706" stroke-width="1"/>
<text x="482" y="156" fill="#92400e" font-size="9" font-weight="600" text-anchor="middle">Gate 2: Rule Matching</text>
<!-- Gate 3 -->
<rect x="416" y="170" width="132" height="24" rx="4" fill="#fef3c7" stroke="#d97706" stroke-width="1"/>
<text x="482" y="186" fill="#92400e" font-size="9" font-weight="600" text-anchor="middle">Gate 3: User Approval</text>
<!-- Deny: denial becomes a tool_result, converging with the pass output -->
<path d="M 440 198 L 440 216" fill="none" stroke="#dc2626" stroke-width="1.5" marker-end="url(#arrow-red)"/>
<text x="446" y="210" fill="#dc2626" font-size="8" font-weight="600">Deny</text>
<!-- Pass → tool execution -->
<line x1="562" y1="138" x2="598" y2="138" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<text x="575" y="132" fill="#16a34a" font-size="8" font-weight="600">Pass</text>
<!-- ===== s02 preserved: Tool execution ===== -->
<text x="608" y="124" fill="#94a3b8" font-size="9">s02</text>
<!-- TOOL_HANDLERS -->
<rect x="600" y="130" width="100" height="64" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="650" y="152" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">TOOL_</text>
<text x="650" y="166" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">HANDLERS</text>
<text x="650" y="184" fill="#64748b" font-size="8" text-anchor="middle">bash/read/write/...</text>
<!-- Pass output: TOOL_HANDLERS result → tool_result -->
<path d="M 650 194 L 650 230 L 492 230" fill="none" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)" stroke-dasharray="6,3"/>
<!-- tool_result: both a successful output and a denial become a tool_result -->
<rect x="370" y="216" width="122" height="28" rx="6" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="431" y="234" fill="#1e3a5f" font-size="11" font-weight="600" text-anchor="middle">tool_result</text>
<!-- tool_result → back to messages[] -->
<path d="M 370 230 L 120 230 L 120 128" fill="none" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)" stroke-dasharray="6,3"/>
<!-- ===== Legend ===== -->
<rect x="60" y="260" width="600" height="44" rx="6" fill="#f1f5f9"/>
<rect x="80" y="276" width="12" height="12" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
<text x="100" y="286" fill="#334155" font-size="10">s02 preserved (loop, LLM, dispatch — unchanged)</text>
<rect x="400" y="276" width="12" height="12" rx="2" fill="#fef2f2" stroke="#dc2626" stroke-width="1"/>
<text x="420" y="286" fill="#334155" font-size="10">s03 new (three-gate permission pipeline)</text>
</svg>

After

Width:  |  Height:  |  Size: 6.1 KiB

@@ -0,0 +1,104 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 320" font-family="system-ui, -apple-system, sans-serif">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555"/>
</marker>
<marker id="arrow-blue" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#2563eb"/>
</marker>
<marker id="arrow-red" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#dc2626"/>
</marker>
<linearGradient id="header" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#1e3a5f"/>
<stop offset="100%" stop-color="#2563eb"/>
</linearGradient>
</defs>
<!-- 背景 -->
<rect width="720" height="320" fill="#fafbfc" rx="8"/>
<!-- タイトル -->
<rect x="0" y="0" width="720" height="48" fill="url(#header)" rx="8"/>
<rect x="0" y="40" width="720" height="8" fill="url(#header)"/>
<text x="360" y="31" fill="#fff" font-size="16" font-weight="700" text-anchor="middle">Permission — ループは変更なし、ツール実行前にゲートを追加</text>
<!-- ===== s02 維持(灰色) ===== -->
<text x="50" y="76" fill="#94a3b8" font-size="11" font-weight="600">s02 維持</text>
<!-- ユーザー入力 -->
<rect x="60" y="88" width="120" height="40" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="120" y="113" fill="#1e3a5f" font-size="12" font-weight="600" text-anchor="middle">messages[]</text>
<!-- 矢印 → LLM -->
<line x1="180" y1="108" x2="228" y2="108" stroke="#2563eb" stroke-width="1.5" marker-end="url(#arrow-blue)"/>
<!-- LLM -->
<rect x="230" y="84" width="130" height="48" rx="8" fill="#fff" stroke="#2563eb" stroke-width="1.5"/>
<text x="295" y="104" fill="#1e3a5f" font-size="13" font-weight="700" text-anchor="middle">LLM</text>
<text x="295" y="122" fill="#64748b" font-size="10" text-anchor="middle">stop_reason?</text>
<!-- No → 戻る -->
<line x1="295" y1="132" x2="295" y2="156" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrow)"/>
<text x="308" y="150" fill="#16a34a" font-size="9" font-weight="600">No</text>
<rect x="240" y="158" width="110" height="32" rx="16" fill="#dcfce7" stroke="#16a34a" stroke-width="1.5"/>
<text x="295" y="178" fill="#166534" font-size="11" font-weight="600" text-anchor="middle">結果を返す</text>
<!-- Yes → 次へ -->
<line x1="360" y1="108" x2="400" y2="108" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<text x="380" y="100" fill="#d97706" font-size="9" font-weight="600">Yes</text>
<!-- ===== s03 新規:権限チェック ===== -->
<text x="482" y="72" fill="#dc2626" font-size="11" font-weight="600" text-anchor="middle">s03 新規</text>
<!-- 権限チェック枠 -->
<rect x="402" y="78" width="160" height="120" rx="10" fill="#fef2f2" stroke="#dc2626" stroke-width="2" stroke-dasharray="6,3"/>
<text x="482" y="100" fill="#991b1b" font-size="11" font-weight="700" text-anchor="middle">check_permission()</text>
<!-- ゲート 1 -->
<rect x="416" y="110" width="132" height="24" rx="4" fill="#fee2e2" stroke="#dc2626" stroke-width="1"/>
<text x="482" y="126" fill="#991b1b" font-size="9" font-weight="600" text-anchor="middle">ゲート 1: 拒否リスト</text>
<!-- ゲート 2 -->
<rect x="416" y="140" width="132" height="24" rx="4" fill="#fef3c7" stroke="#d97706" stroke-width="1"/>
<text x="482" y="156" fill="#92400e" font-size="9" font-weight="600" text-anchor="middle">ゲート 2: ルール照合</text>
<!-- ゲート 3 -->
<rect x="416" y="170" width="132" height="24" rx="4" fill="#fef3c7" stroke="#d97706" stroke-width="1"/>
<text x="482" y="186" fill="#92400e" font-size="9" font-weight="600" text-anchor="middle">ゲート 3: ユーザー承認</text>
<!-- 拒否:拒否は tool_result となり、通過パスの出力と合流する -->
<path d="M 440 198 L 440 216" fill="none" stroke="#dc2626" stroke-width="1.5" marker-end="url(#arrow-red)"/>
<text x="446" y="210" fill="#dc2626" font-size="8" font-weight="600">拒否</text>
<!-- 通過 → ツール実行 -->
<line x1="562" y1="138" x2="598" y2="138" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<text x="575" y="132" fill="#16a34a" font-size="8" font-weight="600">通過</text>
<!-- ===== s02 維持:ツール実行 ===== -->
<text x="608" y="124" fill="#94a3b8" font-size="9">s02</text>
<!-- TOOL_HANDLERS -->
<rect x="600" y="130" width="100" height="64" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="650" y="152" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">TOOL_</text>
<text x="650" y="166" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">HANDLERS</text>
<text x="650" y="184" fill="#64748b" font-size="8" text-anchor="middle">bash/read/write/...</text>
<!-- 通過の出力:TOOL_HANDLERS の結果 → tool_result -->
<path d="M 650 194 L 650 230 L 492 230" fill="none" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)" stroke-dasharray="6,3"/>
<!-- tool_result:成功した出力も拒否も tool_result になる -->
<rect x="370" y="216" width="122" height="28" rx="6" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="431" y="234" fill="#1e3a5f" font-size="11" font-weight="600" text-anchor="middle">tool_result</text>
<!-- tool_result → メッセージリストに戻る -->
<path d="M 370 230 L 120 230 L 120 128" fill="none" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)" stroke-dasharray="6,3"/>
<!-- ===== 凡例 ===== -->
<rect x="60" y="260" width="600" height="44" rx="6" fill="#f1f5f9"/>
<rect x="80" y="276" width="12" height="12" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
<text x="100" y="286" fill="#334155" font-size="10">s02 維持(ループ、LLM、ディスパッチ — 変更なし)</text>
<rect x="400" y="276" width="12" height="12" rx="2" fill="#fef2f2" stroke="#dc2626" stroke-width="1"/>
<text x="420" y="286" fill="#334155" font-size="10">s03 新規(3 ゲート権限パイプライン)</text>
</svg>

After

Width:  |  Height:  |  Size: 6.2 KiB

@@ -0,0 +1,104 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 320" font-family="system-ui, -apple-system, sans-serif">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555"/>
</marker>
<marker id="arrow-blue" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#2563eb"/>
</marker>
<marker id="arrow-red" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#dc2626"/>
</marker>
<linearGradient id="header" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#1e3a5f"/>
<stop offset="100%" stop-color="#2563eb"/>
</linearGradient>
</defs>
<!-- 背景 -->
<rect width="720" height="320" fill="#fafbfc" rx="8"/>
<!-- 标题 -->
<rect x="0" y="0" width="720" height="48" fill="url(#header)" rx="8"/>
<rect x="0" y="40" width="720" height="8" fill="url(#header)"/>
<text x="360" y="31" fill="#fff" font-size="16" font-weight="700" text-anchor="middle">Permission — 循环不变,工具执行前加一道门</text>
<!-- ===== s02 保留(灰色) ===== -->
<text x="50" y="76" fill="#94a3b8" font-size="11" font-weight="600">s02 保留</text>
<!-- 用户输入 -->
<rect x="60" y="88" width="120" height="40" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="120" y="113" fill="#1e3a5f" font-size="12" font-weight="600" text-anchor="middle">messages[]</text>
<!-- 箭头 → LLM -->
<line x1="180" y1="108" x2="228" y2="108" stroke="#2563eb" stroke-width="1.5" marker-end="url(#arrow-blue)"/>
<!-- LLM -->
<rect x="230" y="84" width="130" height="48" rx="8" fill="#fff" stroke="#2563eb" stroke-width="1.5"/>
<text x="295" y="104" fill="#1e3a5f" font-size="13" font-weight="700" text-anchor="middle">LLM</text>
<text x="295" y="122" fill="#64748b" font-size="10" text-anchor="middle">stop_reason?</text>
<!-- 否 → 返回 -->
<line x1="295" y1="132" x2="295" y2="156" stroke="#16a34a" stroke-width="1.5" marker-end="url(#arrow)"/>
<text x="308" y="150" fill="#16a34a" font-size="9" font-weight="600"></text>
<rect x="240" y="158" width="110" height="32" rx="16" fill="#dcfce7" stroke="#16a34a" stroke-width="1.5"/>
<text x="295" y="178" fill="#166534" font-size="11" font-weight="600" text-anchor="middle">返回结果</text>
<!-- 是 → 下一步 -->
<line x1="360" y1="108" x2="400" y2="108" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<text x="380" y="100" fill="#d97706" font-size="9" font-weight="600"></text>
<!-- ===== s03 新增:权限检查 ===== -->
<text x="482" y="72" fill="#dc2626" font-size="11" font-weight="600" text-anchor="middle">s03 新增</text>
<!-- 权限检查框 -->
<rect x="402" y="78" width="160" height="120" rx="10" fill="#fef2f2" stroke="#dc2626" stroke-width="2" stroke-dasharray="6,3"/>
<text x="482" y="100" fill="#991b1b" font-size="11" font-weight="700" text-anchor="middle">check_permission()</text>
<!-- 闸门 1 -->
<rect x="416" y="110" width="132" height="24" rx="4" fill="#fee2e2" stroke="#dc2626" stroke-width="1"/>
<text x="482" y="126" fill="#991b1b" font-size="9" font-weight="600" text-anchor="middle">闸门 1: 拒绝列表</text>
<!-- 闸门 2 -->
<rect x="416" y="140" width="132" height="24" rx="4" fill="#fef3c7" stroke="#d97706" stroke-width="1"/>
<text x="482" y="156" fill="#92400e" font-size="9" font-weight="600" text-anchor="middle">闸门 2: 规则匹配</text>
<!-- 闸门 3 -->
<rect x="416" y="170" width="132" height="24" rx="4" fill="#fef3c7" stroke="#d97706" stroke-width="1"/>
<text x="482" y="186" fill="#92400e" font-size="9" font-weight="600" text-anchor="middle">闸门 3: 用户审批</text>
<!-- 拒绝:拒绝信息成为 tool_result,与通过路径的输出汇合 -->
<path d="M 440 198 L 440 216" fill="none" stroke="#dc2626" stroke-width="1.5" marker-end="url(#arrow-red)"/>
<text x="446" y="210" fill="#dc2626" font-size="8" font-weight="600">拒绝</text>
<!-- 通过 → 工具执行 -->
<line x1="562" y1="138" x2="598" y2="138" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<text x="575" y="132" fill="#16a34a" font-size="8" font-weight="600">通过</text>
<!-- ===== s02 保留:工具执行 ===== -->
<text x="608" y="124" fill="#94a3b8" font-size="9">s02</text>
<!-- TOOL_HANDLERS -->
<rect x="600" y="130" width="100" height="64" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="650" y="152" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">TOOL_</text>
<text x="650" y="166" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">HANDLERS</text>
<text x="650" y="184" fill="#64748b" font-size="8" text-anchor="middle">bash/read/write/...</text>
<!-- 通过路径的输出:TOOL_HANDLERS 结果 → tool_result -->
<path d="M 650 194 L 650 230 L 492 230" fill="none" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)" stroke-dasharray="6,3"/>
<!-- tool_result:成功的输出和拒绝都会成为 tool_result -->
<rect x="370" y="216" width="122" height="28" rx="6" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="431" y="234" fill="#1e3a5f" font-size="11" font-weight="600" text-anchor="middle">tool_result</text>
<!-- tool_result → 回到消息列表 -->
<path d="M 370 230 L 120 230 L 120 128" fill="none" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)" stroke-dasharray="6,3"/>
<!-- ===== 图例 ===== -->
<rect x="60" y="260" width="600" height="44" rx="6" fill="#f1f5f9"/>
<rect x="80" y="276" width="12" height="12" rx="2" fill="#f0f4ff" stroke="#2563eb" stroke-width="1"/>
<text x="100" y="286" fill="#334155" font-size="10">s02 保留(循环、LLM、分发——完全不变)</text>
<rect x="400" y="276" width="12" height="12" rx="2" fill="#fef2f2" stroke="#dc2626" stroke-width="1"/>
<text x="420" y="286" fill="#334155" font-size="10">s03 新增(三道闸门权限管线)</text>
</svg>

After

Width:  |  Height:  |  Size: 6.1 KiB

@@ -0,0 +1,61 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 280" font-family="system-ui, -apple-system, sans-serif">
<defs>
<linearGradient id="header" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#1e3a5f"/><stop offset="100%" stop-color="#2563eb"/>
</linearGradient>
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555"/>
</marker>
</defs>
<rect width="720" height="280" fill="#fafbfc" rx="8"/>
<rect x="0" y="0" width="720" height="38" fill="url(#header)" rx="8"/>
<rect x="0" y="30" width="720" height="8" fill="url(#header)"/>
<text x="360" y="25" fill="#fff" font-size="14" font-weight="700" text-anchor="middle">Permission Pipeline — Three Gates</text>
<!-- Tool call enters -->
<rect x="40" y="62" width="120" height="36" rx="6" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="100" y="84" fill="#1e40af" font-size="12" font-weight="600" text-anchor="middle">Tool call enters</text>
<line x1="160" y1="80" x2="210" y2="80" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<!-- Gate 1: Deny list -->
<rect x="214" y="56" width="145" height="48" rx="6" fill="#fee2e2" stroke="#dc2626" stroke-width="2"/>
<text x="286" y="76" fill="#991b1b" font-size="11" font-weight="700" text-anchor="middle">Gate 1: Deny List</text>
<text x="286" y="94" fill="#991b1b" font-size="9" text-anchor="middle">rm -rf /, sudo, shutdown</text>
<line x1="359" y1="80" x2="409" y2="80" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<!-- Gate 2: Rule check -->
<rect x="413" y="56" width="145" height="48" rx="6" fill="#fef3c7" stroke="#d97706" stroke-width="2"/>
<text x="485" y="76" fill="#92400e" font-size="11" font-weight="700" text-anchor="middle">Gate 2: Rule Matching</text>
<text x="485" y="94" fill="#92400e" font-size="9" text-anchor="middle">Write outside ws? Destructive?</text>
<text x="485" y="116" fill="#166534" font-size="8" font-weight="600" text-anchor="middle">no match → allow</text>
<line x1="558" y1="80" x2="608" y2="80" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<text x="583" y="72" fill="#92400e" font-size="8" font-weight="600" text-anchor="middle">match</text>
<!-- Gate 3: User approval -->
<rect x="612" y="56" width="90" height="48" rx="6" fill="#fef3c7" stroke="#d97706" stroke-width="2"/>
<text x="657" y="76" fill="#92400e" font-size="11" font-weight="700" text-anchor="middle">Gate 3</text>
<text x="657" y="94" fill="#92400e" font-size="9" text-anchor="middle">User approval</text>
<text x="657" y="116" fill="#64748b" font-size="8" font-weight="600" text-anchor="middle">allow / deny</text>
<!-- Results area -->
<rect x="40" y="130" width="662" height="130" rx="6" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1"/>
<text x="60" y="152" fill="#1e3a5f" font-size="12" font-weight="600">Three Decisions</text>
<rect x="60" y="166" width="200" height="42" rx="4" fill="#fee2e2" stroke="#fca5a5" stroke-width="0.5"/>
<text x="160" y="184" fill="#991b1b" font-size="11" font-weight="600" text-anchor="middle">Deny</text>
<text x="160" y="200" fill="#991b1b" font-size="9" text-anchor="middle">Gate 1 hit, or user denied</text>
<rect x="280" y="166" width="200" height="42" rx="4" fill="#fef3c7" stroke="#fbbf24" stroke-width="0.5"/>
<text x="380" y="184" fill="#92400e" font-size="11" font-weight="600" text-anchor="middle">Ask</text>
<text x="380" y="200" fill="#92400e" font-size="9" text-anchor="middle">Gate 2 matched, enter Gate 3</text>
<rect x="500" y="166" width="182" height="42" rx="4" fill="#dcfce7" stroke="#86efac" stroke-width="0.5"/>
<text x="591" y="184" fill="#166534" font-size="11" font-weight="600" text-anchor="middle">Allow</text>
<text x="591" y="200" fill="#166534" font-size="9" text-anchor="middle">No rule hit, or user approved</text>
<text x="371" y="248" fill="#64748b" font-size="10" text-anchor="middle">Priority: hard deny → rule matching → if matched ask user; if unmatched allow by default</text>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -0,0 +1,61 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 280" font-family="system-ui, -apple-system, sans-serif">
<defs>
<linearGradient id="header" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#1e3a5f"/><stop offset="100%" stop-color="#2563eb"/>
</linearGradient>
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555"/>
</marker>
</defs>
<rect width="720" height="280" fill="#fafbfc" rx="8"/>
<rect x="0" y="0" width="720" height="38" fill="url(#header)" rx="8"/>
<rect x="0" y="30" width="720" height="8" fill="url(#header)"/>
<text x="360" y="25" fill="#fff" font-size="14" font-weight="700" text-anchor="middle">Permission Pipeline — 3 つのゲート</text>
<!-- Tool call enters -->
<rect x="40" y="62" width="120" height="36" rx="6" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="100" y="84" fill="#1e40af" font-size="12" font-weight="600" text-anchor="middle">ツール呼び出し</text>
<line x1="160" y1="80" x2="210" y2="80" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<!-- Gate 1: Deny list -->
<rect x="214" y="56" width="145" height="48" rx="6" fill="#fee2e2" stroke="#dc2626" stroke-width="2"/>
<text x="286" y="76" fill="#991b1b" font-size="11" font-weight="700" text-anchor="middle">ゲート 1: 拒否リスト</text>
<text x="286" y="94" fill="#991b1b" font-size="9" text-anchor="middle">rm -rf /, sudo, shutdown</text>
<line x1="359" y1="80" x2="409" y2="80" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<!-- Gate 2: Rule check -->
<rect x="413" y="56" width="145" height="48" rx="6" fill="#fef3c7" stroke="#d97706" stroke-width="2"/>
<text x="485" y="76" fill="#92400e" font-size="11" font-weight="700" text-anchor="middle">ゲート 2: ルール照合</text>
<text x="485" y="94" fill="#92400e" font-size="9" text-anchor="middle">ws 外への書き込み?破壊的?</text>
<text x="485" y="116" fill="#166534" font-size="8" font-weight="600" text-anchor="middle">不一致 → allow</text>
<line x1="558" y1="80" x2="608" y2="80" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<text x="583" y="72" fill="#92400e" font-size="8" font-weight="600" text-anchor="middle">一致</text>
<!-- Gate 3: User approval -->
<rect x="612" y="56" width="90" height="48" rx="6" fill="#fef3c7" stroke="#d97706" stroke-width="2"/>
<text x="657" y="76" fill="#92400e" font-size="11" font-weight="700" text-anchor="middle">ゲート 3</text>
<text x="657" y="94" fill="#92400e" font-size="9" text-anchor="middle">ユーザー承認</text>
<text x="657" y="116" fill="#64748b" font-size="8" font-weight="600" text-anchor="middle">allow / deny</text>
<!-- Results area -->
<rect x="40" y="130" width="662" height="130" rx="6" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1"/>
<text x="60" y="152" fill="#1e3a5f" font-size="12" font-weight="600">3 つの決定</text>
<rect x="60" y="166" width="200" height="42" rx="4" fill="#fee2e2" stroke="#fca5a5" stroke-width="0.5"/>
<text x="160" y="184" fill="#991b1b" font-size="11" font-weight="600" text-anchor="middle">拒否 (deny)</text>
<text x="160" y="200" fill="#991b1b" font-size="9" text-anchor="middle">ゲート 1 一致、またはユーザー拒否</text>
<rect x="280" y="166" width="200" height="42" rx="4" fill="#fef3c7" stroke="#fbbf24" stroke-width="0.5"/>
<text x="380" y="184" fill="#92400e" font-size="11" font-weight="600" text-anchor="middle">確認 (ask)</text>
<text x="380" y="200" fill="#92400e" font-size="9" text-anchor="middle">ゲート 2 一致、ゲート 3 へ</text>
<rect x="500" y="166" width="182" height="42" rx="4" fill="#dcfce7" stroke="#86efac" stroke-width="0.5"/>
<text x="591" y="184" fill="#166534" font-size="11" font-weight="600" text-anchor="middle">許可 (allow)</text>
<text x="591" y="200" fill="#166534" font-size="9" text-anchor="middle">ルール不一致、またはユーザー許可</text>
<text x="371" y="248" fill="#64748b" font-size="10" text-anchor="middle">優先順位:ハード拒否 → ルール照合 → 一致ならユーザー承認、不一致ならデフォルト許可</text>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -0,0 +1,61 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 280" font-family="system-ui, -apple-system, sans-serif">
<defs>
<linearGradient id="header" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#1e3a5f"/><stop offset="100%" stop-color="#2563eb"/>
</linearGradient>
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555"/>
</marker>
</defs>
<rect width="720" height="280" fill="#fafbfc" rx="8"/>
<rect x="0" y="0" width="720" height="38" fill="url(#header)" rx="8"/>
<rect x="0" y="30" width="720" height="8" fill="url(#header)"/>
<text x="360" y="25" fill="#fff" font-size="14" font-weight="700" text-anchor="middle">Permission Pipeline — 三道闸门</text>
<!-- Tool call enters -->
<rect x="40" y="62" width="120" height="36" rx="6" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="100" y="84" fill="#1e40af" font-size="12" font-weight="600" text-anchor="middle">工具调用进入</text>
<line x1="160" y1="80" x2="210" y2="80" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<!-- Gate 1: Deny list -->
<rect x="214" y="56" width="145" height="48" rx="6" fill="#fee2e2" stroke="#dc2626" stroke-width="2"/>
<text x="286" y="76" fill="#991b1b" font-size="11" font-weight="700" text-anchor="middle">闸门 1: 拒绝列表</text>
<text x="286" y="94" fill="#991b1b" font-size="9" text-anchor="middle">rm -rf /, sudo, shutdown</text>
<line x1="359" y1="80" x2="409" y2="80" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<!-- Gate 2: Rule check -->
<rect x="413" y="56" width="145" height="48" rx="6" fill="#fef3c7" stroke="#d97706" stroke-width="2"/>
<text x="485" y="76" fill="#92400e" font-size="11" font-weight="700" text-anchor="middle">闸门 2: 规则匹配</text>
<text x="485" y="94" fill="#92400e" font-size="9" text-anchor="middle">写工作区外?读敏感路径?</text>
<text x="485" y="116" fill="#166534" font-size="8" font-weight="600" text-anchor="middle">未命中 → allow</text>
<line x1="558" y1="80" x2="608" y2="80" stroke="#555" stroke-width="1.5" marker-end="url(#arrow)"/>
<text x="583" y="72" fill="#92400e" font-size="8" font-weight="600" text-anchor="middle">命中</text>
<!-- Gate 3: User approval -->
<rect x="612" y="56" width="90" height="48" rx="6" fill="#fef3c7" stroke="#d97706" stroke-width="2"/>
<text x="657" y="76" fill="#92400e" font-size="11" font-weight="700" text-anchor="middle">闸门 3</text>
<text x="657" y="94" fill="#92400e" font-size="9" text-anchor="middle">用户审批</text>
<text x="657" y="116" fill="#64748b" font-size="8" font-weight="600" text-anchor="middle">允许 / 拒绝</text>
<!-- Results area -->
<rect x="40" y="130" width="662" height="130" rx="6" fill="#f8fafc" stroke="#cbd5e1" stroke-width="1"/>
<text x="60" y="152" fill="#1e3a5f" font-size="12" font-weight="600">三种决策</text>
<rect x="60" y="166" width="200" height="42" rx="4" fill="#fee2e2" stroke="#fca5a5" stroke-width="0.5"/>
<text x="160" y="184" fill="#991b1b" font-size="11" font-weight="600" text-anchor="middle">阻止 (deny)</text>
<text x="160" y="200" fill="#991b1b" font-size="9" text-anchor="middle">闸门 1 命中,或用户拒绝</text>
<rect x="280" y="166" width="200" height="42" rx="4" fill="#fef3c7" stroke="#fbbf24" stroke-width="0.5"/>
<text x="380" y="184" fill="#92400e" font-size="11" font-weight="600" text-anchor="middle">询问 (ask)</text>
<text x="380" y="200" fill="#92400e" font-size="9" text-anchor="middle">闸门 2 命中,进入闸门 3</text>
<rect x="500" y="166" width="182" height="42" rx="4" fill="#dcfce7" stroke="#86efac" stroke-width="0.5"/>
<text x="591" y="184" fill="#166534" font-size="11" font-weight="600" text-anchor="middle">允许 (allow)</text>
<text x="591" y="200" fill="#166534" font-size="9" text-anchor="middle">规则未命中,或用户允许</text>
<text x="371" y="248" fill="#64748b" font-size="10" text-anchor="middle">规则优先:闸门 1 硬拒绝 → 闸门 2 规则匹配 → 命中则用户审批,未命中默认允许</text>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

+283
View File
@@ -0,0 +1,283 @@
# s04: Hooks — Hang on the Loop, Don't Write into It
[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
s01 → s02 → s03 → `s04` → [s05](../s05_todo_write/) → s06 → ... → s20
> *"Hang on the loop, don't write into it"* — Hooks inject extension logic before and after tool execution.
>
> **Harness Layer**: Hooks — Extension points that don't invade the loop.
---
## The Problem
The s03 Agent has permission checks. But every new check, "log every bash call", "auto git add after writes", requires modifying the `agent_loop` function.
The loop quickly becomes this:
```python
def agent_loop(messages):
while True:
# ... LLM call ...
for block in response.content:
if block.type != "tool_use":
continue
log_to_file(block) # added a line
check_permission(block) # added a line
notify_slack(block) # added another line
output = execute(block)
auto_git_add(block) # yet another line
# ... the loop is unrecognizable
```
What you want to extend is the Agent's behavior, but what you're modifying is the loop itself. The loop should be a stable core; extensions should hang on the outside.
---
## The Solution
![Hooks Overview](images/hooks-overview.en.svg)
The s03 loop and permission logic are fully preserved. The only change is moving `check_permission()` from inside the loop body onto a hook. The loop no longer directly calls any check function. Instead it calls `trigger_hooks("PreToolUse", block)`, and the registry decides what to run.
Four events, covering a complete agent cycle:
| Event | Trigger Timing | Typical Use |
|-------|---------------|-------------|
| UserPromptSubmit | After user input, before entering LLM | Input validation, context injection |
| PreToolUse | Before tool execution | Permission checks, logging |
| PostToolUse | After tool execution | Side effects (auto git add etc.), output checking |
| Stop | When the loop is about to exit | Cleanup (CC also supports force continuation) |
Extensions are added via `register_hook()`. The loop only calls `trigger_hooks()`.
---
## How It Works
**Hook registry**: a dict mapping event names to callback lists.
```python
HOOKS = {
"UserPromptSubmit": [],
"PreToolUse": [],
"PostToolUse": [],
"Stop": [],
}
def register_hook(event: str, callback):
HOOKS[event].append(callback)
def trigger_hooks(event: str, *args):
for callback in HOOKS[event]:
result = callback(*args)
if result is not None: # return value ≠ None → hook says "stop"
return result
return None
```
In the teaching version, PreToolUse returning non-None means block execution; Stop returning non-None means force continuation. UserPromptSubmit and PostToolUse return values are unused.
**UserPromptSubmit**, triggers after user input, before entering the LLM. CC can intercept or modify input; the teaching version only logs:
```python
def context_inject_hook(query: str) -> str | None:
"""Inject current working directory info into every prompt."""
print(f"\033[90m[HOOK] UserPromptSubmit: working in {WORKDIR}\033[0m")
return None # return None = no modification, let prompt through
register_hook("UserPromptSubmit", context_inject_hook)
```
In the main loop, triggered right after user input:
```python
query = input("s04 >> ")
trigger_hooks("UserPromptSubmit", query) # ← before entering LLM
history.append({"role": "user", "content": query})
agent_loop(history)
```
**PreToolUse / PostToolUse**, hooks before and after tool execution. s03's permission check logic is now wrapped as a PreToolUse hook, plus a logging hook and a large-output reminder:
```python
# PreToolUse: permission check (s03 logic, moved from loop to hook)
def permission_hook(block):
if block.name == "bash":
for pattern in DENY_LIST:
if pattern in block.input.get("command", ""):
return "Permission denied by deny list"
if block.name in ("write_file", "edit_file"):
path = block.input.get("path", "")
if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):
choice = input(" Allow? [y/N] ").strip().lower()
if choice not in ("y", "yes"):
return "Permission denied by user"
return None
# PreToolUse: logging
def log_hook(block):
print(f"[HOOK] {block.name}(...)")
# PostToolUse: large output reminder
def large_output_hook(block, output):
if len(str(output)) > 100000:
print(f"[HOOK] ⚠ Large output from {block.name}")
register_hook("PreToolUse", permission_hook)
register_hook("PreToolUse", log_hook)
register_hook("PostToolUse", large_output_hook)
```
**Stop**, triggers when the loop is about to exit (`stop_reason != "tool_use"`). The teaching version prints a cleanup summary:
```python
def summary_hook(messages: list) -> str | None:
"""Print a summary when the loop is about to stop."""
tool_count = sum(1 for m in messages
for b in (m.get("content") if isinstance(m.get("content"), list) else [])
if isinstance(b, dict) and b.get("type") == "tool_result")
print(f"\033[90m[HOOK] Stop: session used {tool_count} tool calls\033[0m")
return None # return None = allow stop, return string = force continuation
register_hook("Stop", summary_hook)
```
In agent_loop, triggered before exit:
```python
if response.stop_reason != "tool_use":
force = trigger_hooks("Stop", messages) # ← before exiting
if force:
# hook returned a message → inject it and continue
messages.append({"role": "user", "content": force})
continue
return
```
**Only one change in the loop**: s03 directly called `check_permission(block)`, s04 replaces it with `trigger_hooks("PreToolUse", block)`:
```python
for block in response.content:
if block.type != "tool_use":
continue
# s03: if not check_permission(block): ...
# s04: hooks replace hardcoding
blocked = trigger_hooks("PreToolUse", block)
if blocked:
results.append({"type": "tool_result", "tool_use_id": block.id,
"content": str(blocked)})
continue
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler else f"Unknown: {block.name}"
trigger_hooks("PostToolUse", block, output)
results.append({"type": "tool_result", "tool_use_id": block.id,
"content": output})
```
Four hooks cover the critical nodes of the agent cycle: input → before execution → after execution → exit. The loop only calls trigger_hooks(); all logic lives in hook callbacks.
---
## Changes from s03
| Component | Before (s03) | After (s04) |
|-----------|-------------|-------------|
| Extension method | check_permission() hardcoded in the loop | HOOKS registry + trigger_hooks() |
| New functions | — | register_hook, trigger_hooks |
| Hook callbacks | — | context_inject_hook, permission_hook, log_hook, large_output_hook, summary_hook |
| Loop | Directly calls check_permission() | Calls trigger_hooks("PreToolUse", ...) |
| Exit control | None | trigger_hooks("Stop", ...) can prevent exit |
| Input interception | None | trigger_hooks("UserPromptSubmit", ...) can inject context |
---
## Try It
```sh
cd learn-claude-code
python s04_hooks/code.py
```
Try these prompts:
1. `Read the file README.md` (should pass directly, observe hook logs)
2. `Create a file called test.txt` (after creation, observe if PostToolUse fires)
3. `Delete all temporary files in /tmp` (bash + rm triggers permission hook)
What to watch for: Before each tool execution, does the `[HOOK]` log appear? When permission is denied, was it intercepted by a hook or hardcoded in the loop?
---
## What's Next
The Agent can now safely execute operations. But does it ever stop to think "what should I do first, and what next?" Given a complex task, does it jump straight in, or plan first?
→ s05 TodoWrite: Give the Agent a planning tool. Make a list first, then execute.
<details>
<summary>Dive into CC Source Code</summary>
> The following is based on a complete analysis of CC source code `toolHooks.ts` (650 lines), `hooks.ts`, `stopHooks.ts`, and `coreTypes.ts`.
### 1. Hook Events: Not Just 4, but 27
The teaching version covers only PreToolUse and PostToolUse. CC actually has 27 hook events (`coreTypes.ts:25-53`):
| Category | Events |
|----------|--------|
| Tool-related | `PreToolUse`, `PostToolUse`, `PostToolUseFailure` |
| Session-related | `SessionStart`, `SessionEnd`, `Stop`, `StopFailure`, `Setup` |
| User interaction | `UserPromptSubmit`, `Notification`, `PermissionRequest`, `PermissionDenied` |
| Sub-agents | `SubagentStart`, `SubagentStop` |
| Compaction-related | `PreCompact`, `PostCompact` |
| Team-related | `TeammateIdle`, `TaskCreated`, `TaskCompleted` |
| Other | `Elicitation`, `ElicitationResult`, `ConfigChange`, `WorktreeCreate`, `WorktreeRemove`, `InstructionsLoaded`, `CwdChanged`, `FileChanged` |
The teaching version covers only 4 core events (UserPromptSubmit, PreToolUse, PostToolUse, Stop) because they cover every critical node of a complete agent cycle. The other 23 follow the same pattern.
### 2. HookResult Common Fields
CC's `HookResult` (`types/hooks.ts:260-275`) has 14 fields. Common ones:
| Field | Type | Purpose |
|-------|------|---------|
| `message` | Message | Optional UI message |
| `blockingError` | HookBlockingError | Blocking error → injected into conversation for model self-correction |
| `outcome` | success/blocking/non_blocking_error/cancelled | Execution result |
| `preventContinuation` | boolean | Prevent subsequent execution |
| `stopReason` | string | Stop reason description |
| `permissionBehavior` | allow/deny/ask/passthrough | Hook returns permission decision |
| `updatedInput` | Record | Modify tool input |
| `additionalContext` | string | Additional context |
| `updatedMCPToolOutput` | unknown | MCP tool output modification |
### 3. Key Invariant: Hook 'allow' Cannot Bypass deny/ask Rules
This is the most important security design in CC's permission system (`toolHooks.ts:325-331`): **when a hook returns allow, it still checks settings.json deny/ask rules.** Even if the user's hook script says "allow", if the tool is disabled in settings.json, the operation is still blocked.
The teaching version doesn't have this layer; hooks returning non-None directly interrupt. This is sufficient for teaching, but would create a security vulnerability in production.
### 4. stopHookActive Mechanism
CC's Stop hooks have an infinite-loop prevention mechanism (`query.ts:212,1300`): the `stopHookActive` state field. When stop hooks produce a blockingError, the loop re-enters with `stopHookActive: true`. Subsequent iterations see this flag and don't trigger stop hooks again. This prevents a never-stopping bug: model self-corrects → stop hook errors again → model self-corrects again → stop hook errors again...
### 5. hook_stopped_continuation
When PostToolUse hooks return `preventContinuation: true`, a `hook_stopped_continuation` attachment is produced (`toolHooks.ts:117-130`). query.ts (L1388-1393) detects it and sets `shouldPreventContinuation = true`, causing the loop to exit. This is the mechanism for "hooks gracefully shut down the Agent" — not a crash, but a completion.
### Teaching Version Simplifications Are Intentional
- 27 events → 4 (UserPromptSubmit/PreToolUse/PostToolUse/Stop): covers agent cycle critical nodes
- 14 fields → simple return values (None = continue, non-None = interrupt/continue): minimal cognitive load
- Hook allow vs deny/ask invariant → omitted: teaching version has no settings.json layer
- stopHookActive → omitted: teaching version Stop hook only does simple continuation, no infinite-loop prevention needed
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+283
View File
@@ -0,0 +1,283 @@
# s04: Hooks — ループに掛ける、ループには書き込まない
[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
s01 → s02 → s03 → `s04` → [s05](../s05_todo_write/) → s06 → ... → s20
> *"ループに掛ける、ループには書き込まない"* — フックがツール実行の前後に拡張ロジックを注入する。
>
> **Harness レイヤー**: フック — ループを侵襲しない拡張ポイント。
---
## 課題
s03 の Agent には権限チェックがある。しかし新しいチェックを追加するたび、「bash 呼び出しを毎回ログに記録」「操作後に自動 git add」、`agent_loop` 関数を修正する必要がある。
ループはすぐにこうなる:
```python
def agent_loop(messages):
while True:
# ... LLM call ...
for block in response.content:
if block.type != "tool_use":
continue
log_to_file(block) # 一行追加
check_permission(block) # 一行追加
notify_slack(block) # さらに一行追加
output = execute(block)
auto_git_add(block) # さらに一行追加
# ... もうループが見えない
```
拡張したいのは Agent の振る舞いなのに、変更しているのはループそのもの。ループは安定した核心であるべき。拡張は外側に掛ける。
---
## ソリューション
![Hooks Overview](images/hooks-overview.ja.svg)
s03 のループと権限ロジックは完全に保持される。唯一の変更点は `check_permission()` をループ本体内からフックに移動したこと。ループはもうチェック関数を直接呼び出さず、代わりに `trigger_hooks("PreToolUse", block)` を呼び、登録済みのフックが何を実行するかを決める。
4 つのイベントで、完全な agent cycle をカバー:
| イベント | 発火タイミング | 典型的な用途 |
|----------|--------------|-------------|
| UserPromptSubmit | ユーザー入力後、LLM に入る前 | 入力バリデーション、コンテキスト注入 |
| PreToolUse | ツール実行前 | 権限チェック、ログ記録 |
| PostToolUse | ツール実行後 | 副作用(自動 git add など)、出力チェック |
| Stop | ループが終了する直前 | クリーンアップ(CC は強制続行もサポート) |
拡張は `register_hook()` で追加する。ループは `trigger_hooks()` を呼ぶだけ。
---
## 仕組み
**フック登録簿**:イベント名をコールバックリストにマッピングする辞書。
```python
HOOKS = {
"UserPromptSubmit": [],
"PreToolUse": [],
"PostToolUse": [],
"Stop": [],
}
def register_hook(event: str, callback):
HOOKS[event].append(callback)
def trigger_hooks(event: str, *args):
for callback in HOOKS[event]:
result = callback(*args)
if result is not None: # 戻り値 ≠ None → フックが「止め」と指示
return result
return None
```
教学版では、PreToolUse の非 None 戻り値は実行阻止を意味し、Stop の非 None 戻り値は強制続行を意味する。UserPromptSubmit と PostToolUse の戻り値は未使用。
**UserPromptSubmit**、ユーザー入力後、LLM に入る前に発火。CC では入力の横取りや変更が可能、教学版はログ出力のみ:
```python
def context_inject_hook(query: str) -> str | None:
"""Inject current working directory info into every prompt."""
print(f"\033[90m[HOOK] UserPromptSubmit: working in {WORKDIR}\033[0m")
return None # return None = 変更なし、プロンプトを通す
register_hook("UserPromptSubmit", context_inject_hook)
```
メインループでは、ユーザー入力直後に発火:
```python
query = input("s04 >> ")
trigger_hooks("UserPromptSubmit", query) # ← LLM に入る前
history.append({"role": "user", "content": query})
agent_loop(history)
```
**PreToolUse / PostToolUse**、ツール実行の前後のフック。s03 の権限チェックロジックは PreToolUse フックに包まれ、さらにログフックと大出力リマインダーが追加される:
```python
# PreToolUse: 権限チェック(s03 のロジック、ループからフックに移動)
def permission_hook(block):
if block.name == "bash":
for pattern in DENY_LIST:
if pattern in block.input.get("command", ""):
return "Permission denied by deny list"
if block.name in ("write_file", "edit_file"):
path = block.input.get("path", "")
if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):
choice = input(" Allow? [y/N] ").strip().lower()
if choice not in ("y", "yes"):
return "Permission denied by user"
return None
# PreToolUse: ログ
def log_hook(block):
print(f"[HOOK] {block.name}(...)")
# PostToolUse: 大ファイルリマインダー
def large_output_hook(block, output):
if len(str(output)) > 100000:
print(f"[HOOK] ⚠ Large output from {block.name}")
register_hook("PreToolUse", permission_hook)
register_hook("PreToolUse", log_hook)
register_hook("PostToolUse", large_output_hook)
```
**Stop**、ループが終了する直前に発火(`stop_reason != "tool_use"`)。教学版ではクリーンアップ統計を印刷:
```python
def summary_hook(messages: list) -> str | None:
"""Print a summary when the loop is about to stop."""
tool_count = sum(1 for m in messages
for b in (m.get("content") if isinstance(m.get("content"), list) else [])
if isinstance(b, dict) and b.get("type") == "tool_result")
print(f"\033[90m[HOOK] Stop: session used {tool_count} tool calls\033[0m")
return None # return None = 終了を許可、return 文字列 = 強制続行
register_hook("Stop", summary_hook)
```
agent_loop 内では、終了前に発火:
```python
if response.stop_reason != "tool_use":
force = trigger_hooks("Stop", messages) # ← 終了する前に
if force:
# フックがメッセージを返した → 注入して続行
messages.append({"role": "user", "content": force})
continue
return
```
**ループ内で変更されたのは一箇所だけ**s03 は直接 `check_permission(block)` を呼び出していたが、s04 は `trigger_hooks("PreToolUse", block)` に置き換えた:
```python
for block in response.content:
if block.type != "tool_use":
continue
# s03: if not check_permission(block): ...
# s04: フックがハードコードを代替
blocked = trigger_hooks("PreToolUse", block)
if blocked:
results.append({"type": "tool_result", "tool_use_id": block.id,
"content": str(blocked)})
continue
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler else f"Unknown: {block.name}"
trigger_hooks("PostToolUse", block, output)
results.append({"type": "tool_result", "tool_use_id": block.id,
"content": output})
```
4 つのフックが agent cycle の重要ノードをカバー:入力→実行前→実行後→終了。ループは trigger_hooks() を呼ぶだけで、具体的なロジックは全てフックコールバックにある。
---
## s03 からの変更
| コンポーネント | 変更前 (s03) | 変更後 (s04) |
|--------------|-------------|-------------|
| 拡張方式 | check_permission() をループ内にハードコード | HOOKS 登録簿 + trigger_hooks() |
| 新規関数 | — | register_hook, trigger_hooks |
| フックコールバック | — | context_inject_hook, permission_hook, log_hook, large_output_hook, summary_hook |
| ループ | check_permission() を直接呼び出し | trigger_hooks("PreToolUse", ...) を呼び出し |
| 終了制御 | なし | trigger_hooks("Stop", ...) が終了を阻止可能 |
| 入力横取り | なし | trigger_hooks("UserPromptSubmit", ...) がコンテキスト注入可能 |
---
## 試してみよう
```sh
cd learn-claude-code
python s04_hooks/code.py
```
以下のプロンプトを試してみよう:
1. `Read the file README.md`(そのまま通過するはず、フックログを観察)
2. `Create a file called test.txt`(作成後、PostToolUse が発火するか観察)
3. `Delete all temporary files in /tmp`(bash + rm で権限フックが発動)
観察のポイント:各ツール実行前に `[HOOK]` ログが表示されるか? 権限が拒否されたとき、フックが拦截したのか、ループ内のハードコードが拦截したのか?
---
## 次へ
Agent は安全に操作を実行できるようになった。しかし「まず何をして、次に何をすべきか」を立ち止まって考えたことはあるか? 複雑なタスクを与えたとき、すぐに取り掛かるのか、まず計画を立てるのか?
→ s05 TodoWriteAgent に計画ツールを与える。まずリストを作り、それから実行。
<details>
<summary>CC ソースコードを深掘り</summary>
> 以下は CC ソースコード `toolHooks.ts`650 行)、`hooks.ts`、`stopHooks.ts`、`coreTypes.ts` の完全分析に基づく。
### 一、Hook イベント:4 つではなく 27 個
教育版は PreToolUse と PostToolUse のみを取り上げる。CC には実際に 27 のフックイベントがある(`coreTypes.ts:25-53`):
| カテゴリ | イベント |
|----------|---------|
| ツール関連 | `PreToolUse`, `PostToolUse`, `PostToolUseFailure` |
| セッション関連 | `SessionStart`, `SessionEnd`, `Stop`, `StopFailure`, `Setup` |
| ユーザー対話 | `UserPromptSubmit`, `Notification`, `PermissionRequest`, `PermissionDenied` |
| サブエージェント | `SubagentStart`, `SubagentStop` |
| 圧縮関連 | `PreCompact`, `PostCompact` |
| チーム関連 | `TeammateIdle`, `TaskCreated`, `TaskCompleted` |
| その他 | `Elicitation`, `ElicitationResult`, `ConfigChange`, `WorktreeCreate`, `WorktreeRemove`, `InstructionsLoaded`, `CwdChanged`, `FileChanged` |
教育版は 4 つのコアイベント(UserPromptSubmit、PreToolUse、PostToolUse、Stop)のみを取り上げる。これらで agent cycle の重要ノードを全てカバーできる。残り 23 個は同じパターン。
### 二、HookResult よく使うフィールド抜粋
CC の `HookResult``types/hooks.ts:260-275`)には 14 のフィールドがある。よく使うもの:
| フィールド | 型 | 用途 |
|-----------|-----|------|
| `message` | Message | オプションの UI メッセージ |
| `blockingError` | HookBlockingError | ブロッキングエラー → 会話に注入してモデルが自己修正 |
| `outcome` | success/blocking/non_blocking_error/cancelled | 実行結果 |
| `preventContinuation` | boolean | 後続実行を阻止 |
| `stopReason` | string | 停止理由の説明 |
| `permissionBehavior` | allow/deny/ask/passthrough | フックが権限決定を返す |
| `updatedInput` | Record | ツール入力の変更 |
| `additionalContext` | string | 追加コンテキスト |
| `updatedMCPToolOutput` | unknown | MCP ツール出力の変更 |
### 三、重要な不変条件:Hook 'allow' は deny/ask ルールをバイパスできない
これは CC 権限システムで最も重要なセキュリティ設計(`toolHooks.ts:325-331`):**フックが allow を返しても、settings.json の deny/ask ルールをチェックする。** ユーザーのフックスクリプトが「許可」と言っても、settings.json でそのツールが無効になっていれば、操作は阻止される。
教育版にはこの階層がない。フックが非 None を返せば直接中断。教育目的では十分だが、本番環境ではセキュリティホールになる。
### 四、stopHookActive 機構
CC の Stop フックには無限ループ防止機構がある(`query.ts:212,1300`):`stopHookActive` 状態フィールド。Stop フックが blockingError を発生させると、ループは `stopHookActive: true` で次のラウンドに再入する。後続のイテレーションではこのフラグを見て Stop フックを再トリガーしない。これで「永久に止まらない」バグを防ぐ:モデルが自己修正 → Stop フックが再度エラー → モデルが再修正 → Stop フックが再度エラー... を防止。
### 五、hook_stopped_continuation
PostToolUse フックが `preventContinuation: true` を返すと、`hook_stopped_continuation` アタッチメントが生成される(`toolHooks.ts:117-130`)。query.tsL1388-1393)はそれを検出して `shouldPreventContinuation = true` を設定し、ループが終了する。これは「フックが Agent を優雅に停止させる」機構 — クラッシュではなく、完了。
### 教育版の簡略化は意図的
- 27 イベント → 4UserPromptSubmit/PreToolUse/PostToolUse/Stop):agent cycle の重要ノードをカバー
- 14 フィールド → 単純な戻り値(None = 続行、非 None = 中断/続行):認知負荷を最小限に
- Hook allow vs deny/ask の不変条件 → 省略:教育版に settings.json 層はない
- stopHookActive → 省略:教育版の Stop フックは単純な続行のみ、無限ループ防止は不要
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+283
View File
@@ -0,0 +1,283 @@
# s04: Hooks — 挂在循环上,不写进循环里
[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
s01 → s02 → s03 → `s04` → [s05](../s05_todo_write/) → s06 → ... → s20
> *"挂在循环上, 不写进循环里"* — hook 在工具执行前后注入扩展逻辑。
>
> **Harness 层**: hook — 扩展点不侵入循环。
---
## 问题
s03 的 Agent 有权限检查了。但每次加一个新检查,比如"记录每次 bash 调用"、"操作后自动 git add",都要修改 `agent_loop` 函数。
循环很快就变成了这样:
```python
def agent_loop(messages):
while True:
# ... LLM call ...
for block in response.content:
if block.type != "tool_use":
continue
log_to_file(block) # 加一行
check_permission(block) # 加一行
notify_slack(block) # 又加一行
output = execute(block)
auto_git_add(block) # 再加一行
# ... 很快循环就认不出来了
```
你想扩展的是 Agent 的行为,但你改的却是循环本身。循环应该是一个稳定的核心,扩展应该挂在外面。
---
## 解决方案
![Hooks Overview](images/hooks-overview.svg)
s03 的循环和权限逻辑完全保留。唯一的变动是把 `check_permission()` 从循环体内移到了 hook 上,循环不再直接调用任何检查函数,改为 `trigger_hooks("PreToolUse", block)`,由注册表决定跑什么。
四个事件,覆盖一个完整的 agent cycle
| 事件 | 触发时机 | 典型用途 |
|------|---------|---------|
| UserPromptSubmit | 用户输入提交后、进入 LLM 前 | 输入验证、注入上下文 |
| PreToolUse | 工具执行前 | 权限检查、日志记录 |
| PostToolUse | 工具执行后 | 副作用(自动 git add 等)、输出检查 |
| Stop | 循环即将退出时 | 收尾清理(CC 还支持强制续跑) |
扩展通过 `register_hook()` 添加,循环只调用 `trigger_hooks()`
---
## 工作原理
**hook 注册表**:一个字典,事件名映射到回调列表。
```python
HOOKS = {
"UserPromptSubmit": [],
"PreToolUse": [],
"PostToolUse": [],
"Stop": [],
}
def register_hook(event: str, callback):
HOOKS[event].append(callback)
def trigger_hooks(event: str, *args):
for callback in HOOKS[event]:
result = callback(*args)
if result is not None: # 返回值 ≠ None → hook 说"停"
return result
return None
```
教学版中,PreToolUse 的非 None 返回值会阻止本次工具执行,Stop 的非 None 返回值会强制续跑。UserPromptSubmit 和 PostToolUse 的返回值未被使用。
**UserPromptSubmit**,用户输入提交后、进入 LLM 前触发。CC 中可以拦截或修改输入,教学版只做日志演示:
```python
def context_inject_hook(query: str) -> str | None:
"""Inject current working directory info into every prompt."""
print(f"\033[90m[HOOK] UserPromptSubmit: working in {WORKDIR}\033[0m")
return None # return None = no modification, let prompt through
register_hook("UserPromptSubmit", context_inject_hook)
```
在主循环中,用户输入后立即触发:
```python
query = input("s04 >> ")
trigger_hooks("UserPromptSubmit", query) # ← 进入 LLM 之前
history.append({"role": "user", "content": query})
agent_loop(history)
```
**PreToolUse / PostToolUse**,工具执行前后的 hook。s03 的权限检查逻辑现在包装成 PreToolUse hook,再加一个日志 hook 和一个大输出提醒:
```python
# PreToolUse: 权限检查(s03 的逻辑,从循环移到 hook)
def permission_hook(block):
if block.name == "bash":
for pattern in DENY_LIST:
if pattern in block.input.get("command", ""):
return "Permission denied by deny list"
if block.name in ("write_file", "edit_file"):
path = block.input.get("path", "")
if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):
choice = input(" Allow? [y/N] ").strip().lower()
if choice not in ("y", "yes"):
return "Permission denied by user"
return None
# PreToolUse: 日志
def log_hook(block):
print(f"[HOOK] {block.name}(...)")
# PostToolUse: 大文件提醒
def large_output_hook(block, output):
if len(str(output)) > 100000:
print(f"[HOOK] ⚠ Large output from {block.name}")
register_hook("PreToolUse", permission_hook)
register_hook("PreToolUse", log_hook)
register_hook("PostToolUse", large_output_hook)
```
**Stop**,循环即将退出时触发(`stop_reason != "tool_use"`)。教学版用于打印收尾统计:
```python
def summary_hook(messages: list) -> str | None:
"""Print a summary when the loop is about to stop."""
tool_count = sum(1 for m in messages
for b in (m.get("content") if isinstance(m.get("content"), list) else [])
if isinstance(b, dict) and b.get("type") == "tool_result")
print(f"\033[90m[HOOK] Stop: session used {tool_count} tool calls\033[0m")
return None # return None = allow stop, return string = force continuation
register_hook("Stop", summary_hook)
```
在 agent_loop 中,退出前触发:
```python
if response.stop_reason != "tool_use":
force = trigger_hooks("Stop", messages) # ← 退出之前
if force:
# hook returned a message → inject it and continue
messages.append({"role": "user", "content": force})
continue
return
```
**循环里只改了一处**s03 直接调用 `check_permission(block)`s04 改为 `trigger_hooks("PreToolUse", block)`
```python
for block in response.content:
if block.type != "tool_use":
continue
# s03: if not check_permission(block): ...
# s04: hook 替代硬编码
blocked = trigger_hooks("PreToolUse", block)
if blocked:
results.append({"type": "tool_result", "tool_use_id": block.id,
"content": str(blocked)})
continue
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler else f"Unknown: {block.name}"
trigger_hooks("PostToolUse", block, output)
results.append({"type": "tool_result", "tool_use_id": block.id,
"content": output})
```
四个 hook 覆盖了 agent cycle 的关键节点:输入→执行前→执行后→退出。循环只负责调用 trigger_hooks(),具体逻辑全在 hook 回调里。
---
## 相对 s03 的变更
| 组件 | 之前 (s03) | 之后 (s04) |
|------|-----------|-----------|
| 扩展方式 | check_permission() 硬编码在循环里 | HOOKS 注册表 + trigger_hooks() |
| 新函数 | — | register_hook, trigger_hooks |
| hook 回调 | — | context_inject_hook, permission_hook, log_hook, large_output_hook, summary_hook |
| 循环 | 直接调用 check_permission() | 调用 trigger_hooks("PreToolUse", ...) |
| 退出控制 | 无 | trigger_hooks("Stop", ...) 可阻止退出 |
| 输入拦截 | 无 | trigger_hooks("UserPromptSubmit", ...) 可注入上下文 |
---
## 试一下
```sh
cd learn-claude-code
python s04_hooks/code.py
```
试试这些 prompt
1. `Read the file README.md`(应该直接通过,观察 hook 日志)
2. `Create a file called test.txt`(通过后观察 PostToolUse 是否触发)
3. `Delete all temporary files in /tmp`bash + rm 触发权限 hook
观察重点:每次工具执行前,是否出现了 `[HOOK]` 日志?权限被拒时,是 hook 拦截的还是循环里硬编码的?
---
## 接下来
Agent 现在能安全执行操作了。但它有没有停下来想过"我应该先做什么,再做什么"?给它一个复杂任务,它是一上来就动手,还是先列个计划?
s05 TodoWrite → 给 Agent 一个计划工具。先列清单,再做。
<details>
<summary>深入 CC 源码</summary>
> 以下基于 CC 源码 `toolHooks.ts`650 行)、`hooks.ts`、`stopHooks.ts`、`coreTypes.ts` 的完整分析。
### 一、Hook 事件:不止这 4 个,而是 27 个
教学版只讲了 PreToolUse 和 PostToolUse。CC 实际有 27 个 hook 事件(`coreTypes.ts:25-53`):
| 类别 | 事件 |
|------|------|
| 工具相关 | `PreToolUse`, `PostToolUse`, `PostToolUseFailure` |
| 会话相关 | `SessionStart`, `SessionEnd`, `Stop`, `StopFailure`, `Setup` |
| 用户交互 | `UserPromptSubmit`, `Notification`, `PermissionRequest`, `PermissionDenied` |
| 子 Agent | `SubagentStart`, `SubagentStop` |
| 压缩相关 | `PreCompact`, `PostCompact` |
| 团队相关 | `TeammateIdle`, `TaskCreated`, `TaskCompleted` |
| 其他 | `Elicitation`, `ElicitationResult`, `ConfigChange`, `WorktreeCreate`, `WorktreeRemove`, `InstructionsLoaded`, `CwdChanged`, `FileChanged` |
教学版只讲 4 个核心事件(UserPromptSubmit、PreToolUse、PostToolUse、Stop),因为它们覆盖了一个完整 agent cycle 的关键节点。其他 23 个都是同样的模式。
### 二、HookResult 常用字段摘录
CC 的 `HookResult``types/hooks.ts:260-275`)有 14 个字段,以下是常用字段:
| 字段 | 类型 | 用途 |
|------|------|------|
| `message` | Message | 可选 UI 消息 |
| `blockingError` | HookBlockingError | 阻塞错误 → 注入对话让模型自纠 |
| `outcome` | success/blocking/non_blocking_error/cancelled | 执行结果 |
| `preventContinuation` | boolean | 阻止后续执行 |
| `stopReason` | string | 停止原因描述 |
| `permissionBehavior` | allow/deny/ask/passthrough | hook 返回权限决策 |
| `updatedInput` | Record | 修改工具输入 |
| `additionalContext` | string | 附加上下文 |
| `updatedMCPToolOutput` | unknown | MCP 工具输出修改 |
### 三、关键不变式:Hook 'allow' 不能绕过 deny/ask 规则
这是 CC 权限系统最重要的安全设计(`toolHooks.ts:325-331`):**hook 返回 allow 时,仍然要检查 settings.json 的 deny/ask 规则**。即使用户的 hook 脚本说"允许",如果在 settings.json 中禁用了这个工具,操作仍然会被阻止。
教学版没有这个层次,只把 PreToolUse 的非 None 返回值解释为阻止本次工具执行。这在教学场景中够了,但在生产环境中会形成安全漏洞。
### 四、stopHookActive 机制
CC 的 Stop hooks 有一个防无限循环机制(`query.ts:212,1300`):`stopHookActive` 状态字段。当 stop hooks 产生 blockingError 时,循环带 `stopHookActive: true` 重入下一轮。后续迭代中 stop hooks 看到这个标志就不会再次触发。这防止了一个永不停机的 bug:模型自纠后 stop hook 再次报错 → 模型再自纠 → stop hook 再报错...
### 五、hook_stopped_continuation
PostToolUse hooks 返回 `preventContinuation: true` 时,会产生一个 `hook_stopped_continuation` 附件(`toolHooks.ts:117-130`)。query.tsL1388-1393)检测到后设置 `shouldPreventContinuation = true`,循环退出。这是 "hook 优雅地让 Agent 停机" 的机制,不是崩溃,是完成。
### 教学版的简化是刻意的
- 27 个事件 → 4 个(UserPromptSubmit/PreToolUse/PostToolUse/Stop):覆盖 agent cycle 关键节点
- 14 个字段 → 简单的返回值(None = 继续,非 None = 阻止/续跑):心智负担降到最低
- Hook allow vs deny/ask 不变式 → 省略:教学版没有 settings.json 层
- stopHookActive → 省略:教学版 Stop hook 只做简单续跑,不涉及防无限循环机制
</details>
<!-- translation-sync: zh@v1, en@v0, ja@v0 -->
+293
View File
@@ -0,0 +1,293 @@
#!/usr/bin/env python3
"""
s04: Hooks — move extension logic out of the loop, onto hooks.
User types query
┌──────────────────┐
│ UserPromptSubmit │ ── trigger_hooks() before LLM
└────────┬─────────┘
┌────────────┐ ┌─────────────────────────────┐
│ messages │────▶│ LLM (stop_reason=tool_use?)│
└────────────┘ │ No ──▶ Stop hooks ──▶ exit │
│ Yes ──▶ tool_use block ──┐ │
└────────────────────────────┘ │
┌──────────────────┐
│ trigger_hooks() │
│ PreToolUse: │
│ permission_hook │
│ log_hook │
└───────┬──────────┘
│ (not blocked)
┌───────▼──────────┐
│ TOOL_HANDLERS[x] │
└───────┬──────────┘
┌───────▼──────────┐
│ trigger_hooks() │
│ PostToolUse: │
│ large_output │
└───────┬──────────┘
results ──▶ back to messages
Changes from s03:
+ HOOKS registry (event -> list of callbacks)
+ register_hook() / trigger_hooks()
+ context_inject_hook (UserPromptSubmit)
+ permission_hook, log_hook (PreToolUse)
+ large_output_hook (PostToolUse)
+ summary_hook (Stop)
- check_permission() removed from loop body
(logic moved into permission_hook, triggered via PreToolUse)
Run: python s04_hooks/code.py
Needs: pip install anthropic python-dotenv + ANTHROPIC_API_KEY in .env
"""
import os, subprocess
from pathlib import Path
try:
import readline
readline.parse_and_bind('set bind-tty-special-chars off')
readline.parse_and_bind('set input-meta on')
readline.parse_and_bind('set output-meta on')
readline.parse_and_bind('set convert-meta off')
except ImportError:
pass
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
SYSTEM = f"You are a coding agent at {WORKDIR}. Use tools to solve tasks. Act, don't explain."
# ═══════════════════════════════════════════════════════════
# FROM s02-s03 (unchanged): Tool Implementations
# ═══════════════════════════════════════════════════════════
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def run_bash(command: str) -> str:
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=120)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
def run_read(path: str, limit: int | None = None) -> str:
try:
lines = safe_path(path).read_text().splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"]
return "\n".join(lines)
except Exception as e:
return f"Error: {e}"
def run_write(path: str, content: str) -> str:
try:
file_path = safe_path(path)
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content)
return f"Wrote {len(content)} bytes to {path}"
except Exception as e:
return f"Error: {e}"
def run_edit(path: str, old_text: str, new_text: str) -> str:
try:
file_path = safe_path(path)
text = file_path.read_text()
if old_text not in text:
return f"Error: text not found in {path}"
file_path.write_text(text.replace(old_text, new_text, 1))
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"
def run_glob(pattern: str) -> str:
import glob as g
try:
results = []
for match in g.glob(pattern, root_dir=WORKDIR):
if (WORKDIR / match).resolve().is_relative_to(WORKDIR):
results.append(match)
return "\n".join(results) if results else "(no matches)"
except Exception as e:
return f"Error: {e}"
TOOLS = [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write content to a file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in a file once.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "glob", "description": "Find files matching a glob pattern.",
"input_schema": {"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]}},
]
TOOL_HANDLERS = {
"bash": run_bash, "read_file": run_read, "write_file": run_write,
"edit_file": run_edit, "glob": run_glob,
}
# ═══════════════════════════════════════════════════════════
# NEW in s04: Hook System (s03 permission logic now via hooks)
# ═══════════════════════════════════════════════════════════
HOOKS = {"UserPromptSubmit": [], "PreToolUse": [], "PostToolUse": [], "Stop": []}
def register_hook(event: str, callback):
HOOKS[event].append(callback)
def trigger_hooks(event: str, *args):
for callback in HOOKS[event]:
result = callback(*args)
if result is not None: # teaching shortcut: block this tool call
return result
return None
# s03 permission check logic, now wrapped as a hook
DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="]
DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
def permission_hook(block):
"""PreToolUse: s03 check_permission() logic moved here."""
if block.name == "bash":
for pattern in DENY_LIST:
if pattern in block.input.get("command", ""):
print(f"\n\033[31m⛔ Blocked: '{pattern}'\033[0m")
return "Permission denied by deny list"
for kw in DESTRUCTIVE:
if kw in block.input.get("command", ""):
print(f"\n\033[33m⚠ Potentially destructive command\033[0m")
print(f" Tool: {block.name}({block.input})")
choice = input(" Allow? [y/N] ").strip().lower()
if choice not in ("y", "yes"):
return "Permission denied by user"
if block.name in ("write_file", "edit_file"):
path = block.input.get("path", "")
if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):
print(f"\n\033[33m⚠ Writing outside workspace\033[0m")
print(f" Tool: {block.name}({block.input})")
choice = input(" Allow? [y/N] ").strip().lower()
if choice not in ("y", "yes"):
return "Permission denied by user"
return None
def log_hook(block):
"""PreToolUse: log every tool call."""
args_preview = str(list(block.input.values())[:2])[:60]
print(f"\033[90m[HOOK] {block.name}({args_preview})\033[0m")
return None
def large_output_hook(block, output):
"""PostToolUse: warn on large output."""
if len(str(output)) > 100000:
print(f"\033[33m[HOOK] ⚠ Large output from {block.name}: {len(str(output))} chars\033[0m")
return None
# UserPromptSubmit hook: log user input before it reaches the LLM
def context_inject_hook(query: str):
print(f"\033[90m[HOOK] UserPromptSubmit: working in {WORKDIR}\033[0m")
return None
# Stop hook: print summary when loop is about to exit
def summary_hook(messages: list):
tool_count = sum(1 for m in messages
for b in (m.get("content") if isinstance(m.get("content"), list) else [])
if isinstance(b, dict) and b.get("type") == "tool_result")
print(f"\033[90m[HOOK] Stop: session used {tool_count} tool calls\033[0m")
return None
register_hook("UserPromptSubmit", context_inject_hook)
register_hook("PreToolUse", permission_hook)
register_hook("PreToolUse", log_hook)
register_hook("PostToolUse", large_output_hook)
register_hook("Stop", summary_hook)
# ═══════════════════════════════════════════════════════════
# agent_loop — same structure as s03, but no hard-coded check
# s03: if not check_permission(block): ...
# s04: if trigger_hooks("PreToolUse", block): ...
# ═══════════════════════════════════════════════════════════
def agent_loop(messages: list):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
force = trigger_hooks("Stop", messages)
if force:
messages.append({"role": "user", "content": force})
continue
return
results = []
for block in response.content:
if block.type != "tool_use":
continue
# s04 change: hook replaces hard-coded check_permission()
blocked = trigger_hooks("PreToolUse", block)
if blocked:
results.append({"type": "tool_result", "tool_use_id": block.id,
"content": str(blocked)})
continue
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler else f"Unknown: {block.name}"
trigger_hooks("PostToolUse", block, output) # s04: post hook
results.append({"type": "tool_result", "tool_use_id": block.id, "content": output})
messages.append({"role": "user", "content": results})
if __name__ == "__main__":
print("s04: Hooks — extension logic on hooks, loop stays clean")
print("Type a question, press Enter. Type q to quit.\n")
history = []
while True:
try:
query = input("\033[36ms04 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
trigger_hooks("UserPromptSubmit", query)
history.append({"role": "user", "content": query})
agent_loop(history)
for block in history[-1]["content"]:
if getattr(block, "type", None) == "text":
print(block.text)
print()
+100
View File
@@ -0,0 +1,100 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 460" font-family="system-ui, -apple-system, sans-serif">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555"/>
</marker>
<marker id="arrow-blue" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#2563eb"/>
</marker>
<marker id="arrow-green" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#16a34a"/>
</marker>
<marker id="arrow-red" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#dc2626"/>
</marker>
<linearGradient id="header" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#1e3a5f"/>
<stop offset="100%" stop-color="#2563eb"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="800" height="460" fill="#fafbfc" rx="8"/>
<!-- Title -->
<rect x="0" y="0" width="800" height="48" fill="url(#header)" rx="8"/>
<rect x="0" y="40" width="800" height="8" fill="url(#header)"/>
<text x="400" y="31" fill="#fff" font-size="16" font-weight="700" text-anchor="middle">Hooks — Extension Logic Hangs Outside, Loop Unchanged</text>
<!-- ===== Main Flow Line (y=140 horizontal) ===== -->
<!-- ① messages[] -->
<rect x="40" y="112" width="110" height="56" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="95" y="138" fill="#1e3a5f" font-size="12" font-weight="600" text-anchor="middle">messages[]</text>
<text x="95" y="156" fill="#64748b" font-size="9" text-anchor="middle">(s01 preserved)</text>
<!-- → LLM -->
<line x1="150" y1="140" x2="198" y2="140" stroke="#2563eb" stroke-width="2" marker-end="url(#arrow-blue)"/>
<!-- ② LLM -->
<rect x="200" y="108" width="120" height="64" rx="8" fill="#fff" stroke="#2563eb" stroke-width="1.5"/>
<text x="260" y="134" fill="#1e3a5f" font-size="14" font-weight="700" text-anchor="middle">LLM</text>
<text x="260" y="154" fill="#64748b" font-size="10" text-anchor="middle">stop_reason=tool_use?</text>
<!-- LLM No → Return -->
<line x1="260" y1="172" x2="260" y2="200" stroke="#16a34a" stroke-width="2" marker-end="url(#arrow)"/>
<text x="275" y="192" fill="#16a34a" font-size="10" font-weight="600">No</text>
<rect x="205" y="202" width="110" height="28" rx="14" fill="#dcfce7" stroke="#16a34a" stroke-width="1.5"/>
<text x="260" y="220" fill="#166534" font-size="11" font-weight="600" text-anchor="middle">Return Result</text>
<!-- LLM Yes → PreToolUse -->
<line x1="320" y1="140" x2="378" y2="140" stroke="#555" stroke-width="2" marker-end="url(#arrow)"/>
<text x="345" y="132" fill="#d97706" font-size="10" font-weight="600">Yes</text>
<!-- ③ PreToolUse Hook (s04 new) -->
<rect x="380" y="96" width="160" height="88" rx="10" fill="#f0fdf4" stroke="#16a34a" stroke-width="2" stroke-dasharray="6,3"/>
<text x="460" y="116" fill="#166534" font-size="11" font-weight="700" text-anchor="middle">trigger_hooks()</text>
<text x="460" y="132" fill="#166534" font-size="9" font-weight="600" text-anchor="middle">PreToolUse</text>
<rect x="396" y="140" width="128" height="18" rx="3" fill="#dcfce7" stroke="#16a34a" stroke-width="0.8"/>
<text x="460" y="153" fill="#166534" font-size="8" text-anchor="middle">permission_hook · log_hook</text>
<text x="460" y="176" fill="#64748b" font-size="8" text-anchor="middle">Teaching: non-None → block</text>
<!-- PreToolUse Block → branch down -->
<line x1="460" y1="184" x2="460" y2="218" stroke="#dc2626" stroke-width="2" marker-end="url(#arrow-red)"/>
<rect x="405" y="220" width="110" height="24" rx="12" fill="#fef2f2" stroke="#dc2626" stroke-width="1.5"/>
<text x="460" y="236" fill="#991b1b" font-size="10" font-weight="600" text-anchor="middle">Write tool_result</text>
<!-- PreToolUse Pass → TOOL_HANDLERS -->
<line x1="540" y1="140" x2="588" y2="140" stroke="#16a34a" stroke-width="2" marker-end="url(#arrow-green)"/>
<text x="558" y="132" fill="#16a34a" font-size="9" font-weight="600">Pass</text>
<!-- ④ TOOL_HANDLERS (s02 preserved) -->
<rect x="590" y="108" width="100" height="64" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="640" y="134" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">TOOL_</text>
<text x="640" y="148" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">HANDLERS</text>
<text x="640" y="164" fill="#64748b" font-size="8" text-anchor="middle">bash/read/...</text>
<!-- TOOL_HANDLERS → PostToolUse (down) -->
<line x1="640" y1="172" x2="640" y2="268" stroke="#16a34a" stroke-width="2"/>
<text x="648" y="224" fill="#16a34a" font-size="9" font-weight="600">After exec</text>
<!-- ⑤ PostToolUse Hook (s04 new) -->
<rect x="560" y="270" width="160" height="56" rx="10" fill="#f0fdf4" stroke="#16a34a" stroke-width="2" stroke-dasharray="6,3"/>
<text x="640" y="290" fill="#166534" font-size="11" font-weight="700" text-anchor="middle">trigger_hooks()</text>
<text x="640" y="306" fill="#166534" font-size="9" font-weight="600" text-anchor="middle">PostToolUse</text>
<rect x="576" y="310" width="128" height="12" rx="3" fill="#dcfce7" stroke="#16a34a" stroke-width="0.8"/>
<text x="640" y="320" fill="#166534" font-size="7" text-anchor="middle">large_output_hook</text>
<!-- ===== Loop: results back to messages ===== -->
<path d="M 720 298 L 760 298 L 760 350 L 95 350 L 95 168" fill="none" stroke="#555" stroke-width="2" marker-end="url(#arrow)" stroke-dasharray="6,3"/>
<text x="400" y="370" fill="#64748b" font-size="10" text-anchor="middle">Results appended to messages[], loop continues</text>
<!-- ===== Bottom Comparison ===== -->
<rect x="60" y="396" width="680" height="48" rx="6" fill="#f8fafc" stroke="#e2e8f0" stroke-width="1"/>
<text x="100" y="416" fill="#94a3b8" font-size="10" font-weight="600">s03:</text>
<text x="130" y="416" fill="#64748b" font-size="10" font-family="monospace">if not check_permission(block): ...</text>
<text x="400" y="416" fill="#94a3b8" font-size="10">← every new check requires modifying the loop</text>
<text x="100" y="436" fill="#16a34a" font-size="10" font-weight="600">s04:</text>
<text x="130" y="436" fill="#16a34a" font-size="10" font-family="monospace">blocked = trigger_hooks("PreToolUse", block)</text>
<text x="520" y="436" fill="#16a34a" font-size="10">← add check = register_hook(), loop unchanged</text>
</svg>

After

Width:  |  Height:  |  Size: 6.6 KiB

+100
View File
@@ -0,0 +1,100 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 460" font-family="system-ui, -apple-system, sans-serif">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555"/>
</marker>
<marker id="arrow-blue" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#2563eb"/>
</marker>
<marker id="arrow-green" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#16a34a"/>
</marker>
<marker id="arrow-red" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#dc2626"/>
</marker>
<linearGradient id="header" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#1e3a5f"/>
<stop offset="100%" stop-color="#2563eb"/>
</linearGradient>
</defs>
<!-- 背景 -->
<rect width="800" height="460" fill="#fafbfc" rx="8"/>
<!-- タイトル -->
<rect x="0" y="0" width="800" height="48" fill="url(#header)" rx="8"/>
<rect x="0" y="40" width="800" height="8" fill="url(#header)"/>
<text x="400" y="31" fill="#fff" font-size="15" font-weight="700" text-anchor="middle">Hooks — 拡張ロジックは外側に、ループは一文字も変更しない</text>
<!-- ===== メインフロー(y=140 水平) ===== -->
<!-- ① messages[] -->
<rect x="40" y="112" width="110" height="56" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="95" y="138" fill="#1e3a5f" font-size="12" font-weight="600" text-anchor="middle">messages[]</text>
<text x="95" y="156" fill="#64748b" font-size="9" text-anchor="middle">(s01 保持)</text>
<!-- → LLM -->
<line x1="150" y1="140" x2="198" y2="140" stroke="#2563eb" stroke-width="2" marker-end="url(#arrow-blue)"/>
<!-- ② LLM -->
<rect x="200" y="108" width="120" height="64" rx="8" fill="#fff" stroke="#2563eb" stroke-width="1.5"/>
<text x="260" y="134" fill="#1e3a5f" font-size="14" font-weight="700" text-anchor="middle">LLM</text>
<text x="260" y="154" fill="#64748b" font-size="10" text-anchor="middle">stop_reason=tool_use?</text>
<!-- LLM No → 返却 -->
<line x1="260" y1="172" x2="260" y2="200" stroke="#16a34a" stroke-width="2" marker-end="url(#arrow)"/>
<text x="275" y="192" fill="#16a34a" font-size="10" font-weight="600">No</text>
<rect x="205" y="202" width="110" height="28" rx="14" fill="#dcfce7" stroke="#16a34a" stroke-width="1.5"/>
<text x="260" y="220" fill="#166534" font-size="11" font-weight="600" text-anchor="middle">結果を返す</text>
<!-- LLM Yes → PreToolUse -->
<line x1="320" y1="140" x2="378" y2="140" stroke="#555" stroke-width="2" marker-end="url(#arrow)"/>
<text x="345" y="132" fill="#d97706" font-size="10" font-weight="600">Yes</text>
<!-- ③ PreToolUse フック(s04 新規) -->
<rect x="380" y="96" width="160" height="88" rx="10" fill="#f0fdf4" stroke="#16a34a" stroke-width="2" stroke-dasharray="6,3"/>
<text x="460" y="116" fill="#166534" font-size="11" font-weight="700" text-anchor="middle">trigger_hooks()</text>
<text x="460" y="132" fill="#166534" font-size="9" font-weight="600" text-anchor="middle">PreToolUse</text>
<rect x="396" y="140" width="128" height="18" rx="3" fill="#dcfce7" stroke="#16a34a" stroke-width="0.8"/>
<text x="460" y="153" fill="#166534" font-size="8" text-anchor="middle">permission_hook · log_hook</text>
<text x="460" y="176" fill="#64748b" font-size="8" text-anchor="middle">教育版: 非 None → ブロック</text>
<!-- PreToolUse 中断 → 下に分岐 -->
<line x1="460" y1="184" x2="460" y2="218" stroke="#dc2626" stroke-width="2" marker-end="url(#arrow-red)"/>
<rect x="405" y="220" width="110" height="24" rx="12" fill="#fef2f2" stroke="#dc2626" stroke-width="1.5"/>
<text x="460" y="236" fill="#991b1b" font-size="10" font-weight="600" text-anchor="middle">tool_result に返す</text>
<!-- PreToolUse 通過 → TOOL_HANDLERS -->
<line x1="540" y1="140" x2="588" y2="140" stroke="#16a34a" stroke-width="2" marker-end="url(#arrow-green)"/>
<text x="558" y="132" fill="#16a34a" font-size="9" font-weight="600">通過</text>
<!-- ④ TOOL_HANDLERS (s02 保持) -->
<rect x="590" y="108" width="100" height="64" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="640" y="134" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">TOOL_</text>
<text x="640" y="148" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">HANDLERS</text>
<text x="640" y="164" fill="#64748b" font-size="8" text-anchor="middle">bash/read/...</text>
<!-- TOOL_HANDLERS → PostToolUse (下) -->
<line x1="640" y1="172" x2="640" y2="268" stroke="#16a34a" stroke-width="2"/>
<text x="648" y="224" fill="#16a34a" font-size="9" font-weight="600">実行後</text>
<!-- ⑤ PostToolUse フック(s04 新規) -->
<rect x="560" y="270" width="160" height="56" rx="10" fill="#f0fdf4" stroke="#16a34a" stroke-width="2" stroke-dasharray="6,3"/>
<text x="640" y="290" fill="#166534" font-size="11" font-weight="700" text-anchor="middle">trigger_hooks()</text>
<text x="640" y="306" fill="#166534" font-size="9" font-weight="600" text-anchor="middle">PostToolUse</text>
<rect x="576" y="310" width="128" height="12" rx="3" fill="#dcfce7" stroke="#16a34a" stroke-width="0.8"/>
<text x="640" y="320" fill="#166534" font-size="7" text-anchor="middle">large_output_hook</text>
<!-- ===== ループ:結果を messages に戻す ===== -->
<path d="M 720 298 L 760 298 L 760 350 L 95 350 L 95 168" fill="none" stroke="#555" stroke-width="2" marker-end="url(#arrow)" stroke-dasharray="6,3"/>
<text x="400" y="370" fill="#64748b" font-size="10" text-anchor="middle">結果を messages[] に追加、ループ継続</text>
<!-- ===== 下部比較 ===== -->
<rect x="60" y="396" width="680" height="48" rx="6" fill="#f8fafc" stroke="#e2e8f0" stroke-width="1"/>
<text x="100" y="416" fill="#94a3b8" font-size="10" font-weight="600">s03:</text>
<text x="130" y="416" fill="#64748b" font-size="10" font-family="monospace">if not check_permission(block): ...</text>
<text x="400" y="416" fill="#94a3b8" font-size="10">← チェックを追加するたびにループを修正</text>
<text x="100" y="436" fill="#16a34a" font-size="10" font-weight="600">s04:</text>
<text x="130" y="436" fill="#16a34a" font-size="10" font-family="monospace">blocked = trigger_hooks("PreToolUse", block)</text>
<text x="520" y="436" fill="#16a34a" font-size="10">← チェック追加 = register_hook()、ループ不変</text>
</svg>

After

Width:  |  Height:  |  Size: 6.7 KiB

+100
View File
@@ -0,0 +1,100 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 460" font-family="system-ui, -apple-system, sans-serif">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555"/>
</marker>
<marker id="arrow-blue" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#2563eb"/>
</marker>
<marker id="arrow-green" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#16a34a"/>
</marker>
<marker id="arrow-red" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#dc2626"/>
</marker>
<linearGradient id="header" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#1e3a5f"/>
<stop offset="100%" stop-color="#2563eb"/>
</linearGradient>
</defs>
<!-- 背景 -->
<rect width="800" height="460" fill="#fafbfc" rx="8"/>
<!-- 标题 -->
<rect x="0" y="0" width="800" height="48" fill="url(#header)" rx="8"/>
<rect x="0" y="40" width="800" height="8" fill="url(#header)"/>
<text x="400" y="31" fill="#fff" font-size="16" font-weight="700" text-anchor="middle">Hooks — 扩展逻辑挂在外面,循环本身一字不改</text>
<!-- ===== 主流程线(y=140 水平) ===== -->
<!-- ① messages[] -->
<rect x="40" y="112" width="110" height="56" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="95" y="138" fill="#1e3a5f" font-size="12" font-weight="600" text-anchor="middle">messages[]</text>
<text x="95" y="156" fill="#64748b" font-size="9" text-anchor="middle">(s01 保留)</text>
<!-- → LLM -->
<line x1="150" y1="140" x2="198" y2="140" stroke="#2563eb" stroke-width="2" marker-end="url(#arrow-blue)"/>
<!-- ② LLM -->
<rect x="200" y="108" width="120" height="64" rx="8" fill="#fff" stroke="#2563eb" stroke-width="1.5"/>
<text x="260" y="134" fill="#1e3a5f" font-size="14" font-weight="700" text-anchor="middle">LLM</text>
<text x="260" y="154" fill="#64748b" font-size="10" text-anchor="middle">stop_reason=tool_use?</text>
<!-- LLM 否 → 返回 -->
<line x1="260" y1="172" x2="260" y2="200" stroke="#16a34a" stroke-width="2" marker-end="url(#arrow)"/>
<text x="275" y="192" fill="#16a34a" font-size="10" font-weight="600"></text>
<rect x="205" y="202" width="110" height="28" rx="14" fill="#dcfce7" stroke="#16a34a" stroke-width="1.5"/>
<text x="260" y="220" fill="#166534" font-size="11" font-weight="600" text-anchor="middle">返回结果</text>
<!-- LLM 是 → PreToolUse -->
<line x1="320" y1="140" x2="378" y2="140" stroke="#555" stroke-width="2" marker-end="url(#arrow)"/>
<text x="345" y="132" fill="#d97706" font-size="10" font-weight="600"></text>
<!-- ③ PreToolUse hooks04 新增) -->
<rect x="380" y="96" width="160" height="88" rx="10" fill="#f0fdf4" stroke="#16a34a" stroke-width="2" stroke-dasharray="6,3"/>
<text x="460" y="116" fill="#166534" font-size="11" font-weight="700" text-anchor="middle">trigger_hooks()</text>
<text x="460" y="132" fill="#166534" font-size="9" font-weight="600" text-anchor="middle">PreToolUse</text>
<rect x="396" y="140" width="128" height="18" rx="3" fill="#dcfce7" stroke="#16a34a" stroke-width="0.8"/>
<text x="460" y="153" fill="#166534" font-size="8" text-anchor="middle">permission_hook · log_hook</text>
<text x="460" y="176" fill="#64748b" font-size="8" text-anchor="middle">教学版:非 None → 阻止</text>
<!-- PreToolUse 阻止 → 向下引出 -->
<line x1="460" y1="184" x2="460" y2="218" stroke="#dc2626" stroke-width="2" marker-end="url(#arrow-red)"/>
<rect x="405" y="220" width="110" height="24" rx="12" fill="#fef2f2" stroke="#dc2626" stroke-width="1.5"/>
<text x="460" y="236" fill="#991b1b" font-size="10" font-weight="600" text-anchor="middle">写入 tool_result</text>
<!-- PreToolUse 通过 → TOOL_HANDLERS -->
<line x1="540" y1="140" x2="588" y2="140" stroke="#16a34a" stroke-width="2" marker-end="url(#arrow-green)"/>
<text x="558" y="132" fill="#16a34a" font-size="9" font-weight="600">通过</text>
<!-- ④ TOOL_HANDLERS (s02 保留) -->
<rect x="590" y="108" width="100" height="64" rx="8" fill="#f0f4ff" stroke="#2563eb" stroke-width="1.5"/>
<text x="640" y="134" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">TOOL_</text>
<text x="640" y="148" fill="#1e3a5f" font-size="10" font-weight="600" text-anchor="middle">HANDLERS</text>
<text x="640" y="164" fill="#64748b" font-size="8" text-anchor="middle">bash/read/...</text>
<!-- TOOL_HANDLERS → PostToolUse (向下) -->
<line x1="640" y1="172" x2="640" y2="268" stroke="#16a34a" stroke-width="2"/>
<text x="648" y="224" fill="#16a34a" font-size="9" font-weight="600">执行后</text>
<!-- ⑤ PostToolUse hooks04 新增) -->
<rect x="560" y="270" width="160" height="56" rx="10" fill="#f0fdf4" stroke="#16a34a" stroke-width="2" stroke-dasharray="6,3"/>
<text x="640" y="290" fill="#166534" font-size="11" font-weight="700" text-anchor="middle">trigger_hooks()</text>
<text x="640" y="306" fill="#166534" font-size="9" font-weight="600" text-anchor="middle">PostToolUse</text>
<rect x="576" y="310" width="128" height="12" rx="3" fill="#dcfce7" stroke="#16a34a" stroke-width="0.8"/>
<text x="640" y="320" fill="#166534" font-size="7" text-anchor="middle">large_output_hook</text>
<!-- ===== 回环:结果回到 messages ===== -->
<path d="M 720 298 L 760 298 L 760 350 L 95 350 L 95 168" fill="none" stroke="#555" stroke-width="2" marker-end="url(#arrow)" stroke-dasharray="6,3"/>
<text x="400" y="370" fill="#64748b" font-size="10" text-anchor="middle">结果追加到 messages[],循环继续</text>
<!-- ===== 底部对比 ===== -->
<rect x="60" y="396" width="680" height="48" rx="6" fill="#f8fafc" stroke="#e2e8f0" stroke-width="1"/>
<text x="100" y="416" fill="#94a3b8" font-size="10" font-weight="600">s03:</text>
<text x="130" y="416" fill="#64748b" font-size="10" font-family="monospace">if not check_permission(block): ...</text>
<text x="400" y="416" fill="#94a3b8" font-size="10">← 每加一个检查就要改循环</text>
<text x="100" y="436" fill="#16a34a" font-size="10" font-weight="600">s04:</text>
<text x="130" y="436" fill="#16a34a" font-size="10" font-family="monospace">blocked = trigger_hooks("PreToolUse", block)</text>
<text x="520" y="436" fill="#16a34a" font-size="10">← 加检查 = register_hook(),循环不改</text>
</svg>

After

Width:  |  Height:  |  Size: 6.6 KiB

+158
View File
@@ -0,0 +1,158 @@
# s05: TodoWrite — An Agent Without a Plan Drifts Off Course
[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
s01 → s02 → s03 → s04 → `s05` → [s06](../s06_subagent/) → s07 → ... → s20
> *"An agent without a plan goes wherever the wind blows"* — List the steps first, then execute. Complex tasks are less likely to miss steps.
>
> **Harness Layer**: Planning — Let the Agent think before it acts.
---
## The Problem
Give the Agent a complex task: "Rename all Python files to snake_case, run tests, and fix failures."
The Agent starts working, renames 3 files, runs a test, finds 2 failures, starts fixing. While fixing, it forgets the original goal was "rename to snake_case", the test failures have consumed all its attention.
The longer the conversation, the worse it gets: tool results keep filling the context, diluting the system prompt's influence. A 10-step refactoring: after steps 1-3, the Agent starts improvising because steps 4-10 have been pushed out of its attention.
---
## The Solution
![Todo Overview](images/todo-overview.en.svg)
The minimal hook structure from the previous chapter is preserved, focusing on the new `todo_write` tool and reminder mechanism. `todo_write` does no actual work, can't read files or run commands, it simply lets the Agent organize its thoughts before diving in.
The dispatch mechanism is unchanged; the new tool is still routed through `TOOL_HANDLERS[block.name]`. However, to demonstrate the todo reminder, a counter was added to the loop: after 3 consecutive rounds without calling `todo_write`, a reminder is injected.
---
## How It Works
**The todo_write tool** accepts a list with statuses, keeps it in the current process memory, and displays progress in the terminal:
```python
CURRENT_TODOS: list[dict] = []
def run_todo_write(todos: list) -> str:
global CURRENT_TODOS
CURRENT_TODOS = todos
lines = ["\n## Current Tasks"]
for t in CURRENT_TODOS:
icon = {"pending": " ", "in_progress": "", "completed": ""}[t["status"]]
lines.append(f" [{icon}] {t['content']}")
print("\n".join(lines))
return f"Updated {len(CURRENT_TODOS)} tasks"
```
The tool definition joins the other 5 in the dispatch map:
```python
TOOLS = [
{"name": "bash", ...},
{"name": "read_file", ...},
{"name": "write_file", ...},
{"name": "edit_file", ...},
{"name": "glob", ...},
# s05: new entry
{"name": "todo_write", "description": "Create and manage a task list ...",
"input_schema": {
"type": "object",
"properties": {
"todos": {
"type": "array",
"items": {
"type": "object",
"properties": {
"content": {"type": "string"},
"status": {"type": "string", "enum": ["pending", "in_progress", "completed"]},
},
},
},
},
},
},
]
TOOL_HANDLERS["todo_write"] = run_todo_write
```
**Nag reminder**, when the model hasn't called `todo_write` for 3 consecutive rounds, a reminder is automatically injected (teaching mechanism; CC source has no fixed round-count logic):
```python
if rounds_since_todo >= 3 and messages:
messages.append({
"role": "user",
"content": "<reminder>Update your todos.</reminder>",
})
rounds_since_todo = 0
```
Typical flow when the Agent receives a task: first call `todo_write` to list all steps (all `pending`) → pick one step, set it to `in_progress` → complete it, set to `completed` → look at the next `pending` → continue. After 3 rounds without `todo_write`, the loop appends a reminder before the next LLM call.
**Key insight**: todo_write doesn't give the Agent any additional **execution capability**. What it adds is **planning capability**.
---
## Changes from s04
| Component | Before (s04) | After (s05) |
|-----------|-------------|-------------|
| Tool count | 5 (bash, read, write, edit, glob) | 6 (+todo_write) |
| Planning | None | Stateful TODO list + nag reminder |
| SYSTEM prompt | Generic prompt | Added "plan before executing" guidance |
| Loop | Unchanged | Dispatch unchanged, added rounds_since_todo counter and reminder injection |
---
## Try It
```sh
cd learn-claude-code
python s05_todo_write/code.py
```
Try these prompts:
1. `Refactor s05_todo_write/example/hello.py: add type hints, docstrings, and a main guard` (should list 3 steps first, then execute)
2. `Create a Python package under s05_todo_write/example/demo_pkg with __init__.py, utils.py, and tests/test_utils.py`
3. `Review Python files under s05_todo_write/example and fix any style issues`
What to watch for: Was the first tool call `todo_write`? How many TODO steps were listed? Did statuses move from `pending` to `in_progress` / `completed` during execution?
---
## What's Next
The Agent can plan now. But if a task is too large, say "refactor the entire auth module", a TODO list alone isn't enough. That task is itself a collection of dozens of subtasks that would drown in a single conversation's context.
→ s06 Subagent: Break large tasks into subtasks, each handled by an independent Agent with its own clean context, no cross-contamination.
<details>
<summary>Dive into CC Source Code</summary>
CC has two task systems coexisting (`tasks.ts:133-139`):
- **TodoWrite (V1)**: A simple list tool, data maintained in memory AppState (`TodoWriteTool.ts:65-103`). The teaching version also keeps it in process memory and clears it on exit.
- **Task System (V2 = s12)**: File-persisted, dependency graph, concurrency locks, ownership.
The switch is controlled by `isTodoV2Enabled()`. In the current source: V2 is enabled by default in interactive sessions, V1 in non-interactive (SDK) sessions; setting `CLAUDE_CODE_ENABLE_TASKS` forces V2 regardless. Note the source comment "Force-enable tasks in non-interactive mode" describes the env var path's purpose, not the default branch's return semantics.
The teaching version omits the `activeForm` field from the real source (`utils/todo/types.ts:8-15`). CC uses it for the UI spinner to show "what's being done"; the teaching version only has terminal output and doesn't need this field.
The teaching version's nag reminder (3 rounds without update triggers injection) is an educational mechanism. The CC source has no fixed "3 rounds" logic; the closest is `TodoWriteTool.ts:72-107` which appends a verification nudge when 3+ todos are all completed without a verification item.
Core increments of the Task System over TodoWrite:
- File persistence (Claude config directory `tasks/{taskListId}/{taskId}.json`) instead of in-memory list
- `blockedBy` dependency graph instead of flat list
- `proper-lockfile` concurrency safety instead of no locking
- Four separate tools (Create/Get/Update/List) instead of one
- TaskCreated / TaskCompleted hooks (`TaskCreateTool.ts:80-129`, `TaskUpdateTool.ts:231-260`) for external system integration
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+158
View File
@@ -0,0 +1,158 @@
# s05: TodoWrite — 計画なき Agent は途中で道を外れる
[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
s01 → s02 → s03 → s04 → `s05` → [s06](../s06_subagent/) → s07 → ... → s20
> *"計画なき agent は風の向くままに"* — まず手順を列挙してから実行。長いタスクで見落としが減る。
>
> **Harness レイヤー**: 計画 — Agent が行動する前に考えさせる。
---
## 課題
Agent に複雑なタスクを与える:「全 Python ファイルを snake_case にリネームし、テストを実行し、失敗を修正して。」
Agent は作業を開始する。3 つのファイルをリネーム、テストを実行、2 つの失敗を発見、修正を開始。修正しているうちに、本来の目的が「snake_case にリネーム」だったことを忘れる。テストの失敗に注意を全て持っていかれる。
会話が長くなるほど悪化する:ツールの結果がコンテキストを埋め続け、システムプロンプトの影響力が希釈される。10 ステップのリファクタリング:ステップ 1-3 を終えた時点で Agent は即興で動き始める。ステップ 4-10 は既に注意の外に追い出されているから。
---
## ソリューション
![Todo Overview](images/todo-overview.ja.svg)
前章の最小フック構造を保持し、本章では新規の `todo_write` ツールとリマインダー機構に注目する。`todo_write` は実際の作業を何もしない。ファイルを読めない、コマンドを実行できない。Agent が手を動かす前に思考を整理できるようにするだけ。
ディスパッチ機構は変わらず、新ツールも `TOOL_HANDLERS[block.name]` を経由する。ただし、todo リマインダーのデモのため、ループにカウンターを追加した:連続 3 ラウンド `todo_write` を呼び出さないとリマインダーが注入される。
---
## 仕組み
**todo_write ツール**は、ステータス付きのリストを受け取り、現在のプロセスメモリに保持し、端末に進捗を表示する:
```python
CURRENT_TODOS: list[dict] = []
def run_todo_write(todos: list) -> str:
global CURRENT_TODOS
CURRENT_TODOS = todos
lines = ["\n## Current Tasks"]
for t in CURRENT_TODOS:
icon = {"pending": " ", "in_progress": "", "completed": ""}[t["status"]]
lines.append(f" [{icon}] {t['content']}")
print("\n".join(lines))
return f"Updated {len(CURRENT_TODOS)} tasks"
```
ツール定義は他の 5 つと一緒にディスパッチマップに追加される:
```python
TOOLS = [
{"name": "bash", ...},
{"name": "read_file", ...},
{"name": "write_file", ...},
{"name": "edit_file", ...},
{"name": "glob", ...},
# s05: 新規追加
{"name": "todo_write", "description": "Create and manage a task list ...",
"input_schema": {
"type": "object",
"properties": {
"todos": {
"type": "array",
"items": {
"type": "object",
"properties": {
"content": {"type": "string"},
"status": {"type": "string", "enum": ["pending", "in_progress", "completed"]},
},
},
},
},
},
},
]
TOOL_HANDLERS["todo_write"] = run_todo_write
```
**Nag リマインダー**、モデルが連続 3 ラウンド `todo_write` を呼び出さないとき、リマインダーが自動的に注入される(教育用機構、CC ソースコードに固定ラウンド数のロジックはない):
```python
if rounds_since_todo >= 3 and messages:
messages.append({
"role": "user",
"content": "<reminder>Update your todos.</reminder>",
})
rounds_since_todo = 0
```
Agent がタスクを受け取った後の典型的な流れ:まず `todo_write` を呼び出して全手順を列挙(全て `pending`)→ 一つの手順に取り掛かり、`in_progress` に変更 → 完了したら `completed` に変更 → 次の `pending` を見る → 続行。3 ラウンド `todo_write` がない場合、次の LLM 呼び出し前にリマインダーが追加される。
**重要な洞察**todo_write は Agent に**実行能力**を何も追加しない。追加するのは**計画能力**だ。
---
## s04 からの変更
| コンポーネント | 変更前 (s04) | 変更後 (s05) |
|--------------|-------------|-------------|
| ツール数 | 5 (bash, read, write, edit, glob) | 6 (+todo_write) |
| 計画能力 | なし | ステータス付き TODO リスト + Nag リマインダー |
| SYSTEM プロンプト | 汎用プロンプト | 「先に計画してから実行」のガイダンスを追加 |
| ループ | 不変 | ディスパッチは不変、rounds_since_todo カウンターとリマインダー注入を追加 |
---
## 試してみよう
```sh
cd learn-claude-code
python s05_todo_write/code.py
```
以下のプロンプトを試してみよう:
1. `Refactor s05_todo_write/example/hello.py: add type hints, docstrings, and a main guard`(まず 3 手順を列挙してから実行するはず)
2. `Create a Python package under s05_todo_write/example/demo_pkg with __init__.py, utils.py, and tests/test_utils.py`
3. `Review Python files under s05_todo_write/example and fix any style issues`
観察のポイント:最初のツール呼び出しは `todo_write` か? TODO は何手順列挙されたか? 実行中にステータスが `pending` から `in_progress` / `completed` に変わったか?
---
## 次へ
Agent は計画できるようになった。しかしタスクが大きすぎる場合、例えば「認証モジュール全体をリファクタリング」、TODO リストだけでは不十分。そのタスク自体が数十のサブタスクの集合体で、同じ会話のコンテキストに押し込めると溢れてしまう。
→ s06 Subagent:大きなタスクをサブタスクに分割し、それぞれを独立した Agent に任せる。それぞれが独自のクリーンなコンテキストを持ち、相互汚染がない。
<details>
<summary>CC ソースコードを深掘り</summary>
CC には二つのタスクシステムが共存している(`tasks.ts:133-139`):
- **TodoWrite(V1)**:シンプルなリストツール、データはメモリ AppState で管理(`TodoWriteTool.ts:65-103`)。教育版もプロセスメモリに保持し、終了時に消える
- **Task SystemV2 = s12)**:ファイル永続化、依存グラフ、並行ロック、ownership
切り替えは `isTodoV2Enabled()` で制御される。現在のソースコードの実装:対話型セッションでは V2 がデフォルトで有効、非対話型セッション(SDK)では V1 がデフォルトで有効。`CLAUDE_CODE_ENABLE_TASKS` 環境変数を設定するとセッション種別に関わらず V2 が強制有効になる。ソースコメント「Force-enable tasks in non-interactive mode」は環境変数パスの用途を説明しており、デフォルト分岐の戻り値のセマンティクスとは異なるため注意。
教育版は実際のソースコードにある `activeForm` フィールドを省略している(`utils/todo/types.ts:8-15`)。CC は UI スピナーに「何をしているか」を表示するために使用するが、教育版は端末出力のみでこのフィールドは不要。
教育版の Nag リマインダー(3 ラウンド未更新で注入)は教育用機構。CC ソースコードに固定「3 ラウンド」のロジックはなく、最も近いのは `TodoWriteTool.ts:72-107` で 3 つ以上の todo が全て完了しているのに verification 項目がない場合に verification nudge を追加する処理。
Task System の TodoWrite に対する核心的な増分:
- メモリリストではなくファイル永続化(Claude 設定ディレクトリ下 `tasks/{taskListId}/{taskId}.json`
- 平坦なリストではなく `blockedBy` 依存グラフ
- ロックなしではなく `proper-lockfile` による並行安全性
- 一つのツールではなく四つの独立ツール(Create/Get/Update/List
- TaskCreated / TaskCompleted フック(`TaskCreateTool.ts:80-129``TaskUpdateTool.ts:231-260`)による外部システム統合
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+158
View File
@@ -0,0 +1,158 @@
# s05: TodoWrite — 没有计划的 Agent,做着做着就偏了
[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md)
s01 → s02 → s03 → s04 → `s05` → [s06](../s06_subagent/) → s07 → ... → s20
> *"没有计划的 agent 走哪算哪"* — 先列步骤再动手,长任务更不容易漏项。
>
> **Harness 层**: 规划 — 让 Agent 在动手之前先想清楚。
---
## 问题
给 Agent 一个复杂任务:"把所有 Python 文件改成 snake_case 命名,然后跑测试,修好失败。"
Agent 开始干活,改了 3 个文件,跑了个测试,发现 2 个失败,开始修。修着修着,它忘了最初是"改成 snake_case",测试失败把注意力全吸走了。
对话越长越严重:工具结果不断填满上下文,系统提示的影响力被稀释。一个 10 步重构,做完 1-3 步就开始即兴发挥,因为 4-10 步已经被挤出注意力了。
---
## 解决方案
![Todo Overview](images/todo-overview.svg)
保留上一章的最小 hook 结构,重点看新增的 `todo_write` 工具和 reminder 机制。`todo_write` 本身不做任何实际工作,不能读文件、不能跑命令,只是让 Agent 在动手之前先理清思路。
dispatch 机制不变,新工具仍然走 `TOOL_HANDLERS[block.name]` 分发。但为了演示 todo reminder,循环里加了一个计数器:连续 3 轮没调 `todo_write` 就注入一条提醒。
---
## 工作原理
**todo_write 工具**,接收一个带状态的列表,保存在当前进程内存中,同时在终端显示进度:
```python
CURRENT_TODOS: list[dict] = []
def run_todo_write(todos: list) -> str:
global CURRENT_TODOS
CURRENT_TODOS = todos
lines = ["\n## Current Tasks"]
for t in CURRENT_TODOS:
icon = {"pending": " ", "in_progress": "", "completed": ""}[t["status"]]
lines.append(f" [{icon}] {t['content']}")
print("\n".join(lines))
return f"Updated {len(CURRENT_TODOS)} tasks"
```
工具定义和其他 5 个工具一起加入 dispatch map
```python
TOOLS = [
{"name": "bash", ...},
{"name": "read_file", ...},
{"name": "write_file", ...},
{"name": "edit_file", ...},
{"name": "glob", ...},
# s05: 新增一条
{"name": "todo_write", "description": "Create and manage a task list ...",
"input_schema": {
"type": "object",
"properties": {
"todos": {
"type": "array",
"items": {
"type": "object",
"properties": {
"content": {"type": "string"},
"status": {"type": "string", "enum": ["pending", "in_progress", "completed"]},
},
},
},
},
},
},
]
TOOL_HANDLERS["todo_write"] = run_todo_write
```
**Nag reminder**,模型连续 3 轮没调 `todo_write` 时,自动注入一条提醒(教学版机制,CC 源码中没有这个固定轮数逻辑):
```python
if rounds_since_todo >= 3 and messages:
messages.append({
"role": "user",
"content": "<reminder>Update your todos.</reminder>",
})
rounds_since_todo = 0
```
Agent 收到任务后的典型流程:先调 `todo_write` 列出所有步骤(全 `pending`)→ 做一个步骤,改成 `in_progress` → 做完改成 `completed` → 看下一个 `pending` → 继续。连续 3 轮没有调用 `todo_write` 时,循环会在下一次 LLM 调用前追加一条 reminder。
**关键洞察**todo_write 不给 Agent 增加任何**执行能力**。它增加的是**规划能力**。
---
## 相对 s04 的变更
| 组件 | 之前 (s04) | 之后 (s05) |
|------|-----------|-----------|
| 工具数量 | 5 (bash, read, write, edit, glob) | 6 (+todo_write) |
| 规划能力 | 无 | 带状态的 TODO 列表 + nag reminder |
| SYSTEM 提示 | 通用提示 | 加入 "先计划再执行" 引导 |
| 循环 | 不变 | dispatch 不变,新增 rounds_since_todo 计数器和 reminder 注入 |
---
## 试一下
```sh
cd learn-claude-code
python s05_todo_write/code.py
```
试试这些 prompt
1. `Refactor s05_todo_write/example/hello.py: add type hints, docstrings, and a main guard`(先列 3 步再执行)
2. `Create a Python package under s05_todo_write/example/demo_pkg with __init__.py, utils.py, and tests/test_utils.py`
3. `Review Python files under s05_todo_write/example and fix any style issues`
观察重点:第一次工具调用是不是 `todo_write`?TODO 列了几步?执行过程中状态有没有从 `pending` 变成 `in_progress` / `completed`
---
## 接下来
Agent 能计划了。但如果一个任务太大,比如"重构整个认证模块",光靠 TODO 列表不够。这个任务本身就是几十个小任务的集合,放在同一个对话里会被上下文淹没。
s06 Subagent → 把大任务拆成子任务,每个子任务派一个独立的 Agent。它们有自己的干净上下文,不会互相污染。
<details>
<summary>深入 CC 源码</summary>
CC 中有两套任务系统并存(`tasks.ts:133-139`):
- **TodoWrite(V1)**:一个简单的列表工具,数据在内存 AppState 中维护(`TodoWriteTool.ts:65-103`)。教学版也保存在进程内存里,退出后清空
- **Task SystemV2 = s12**:文件持久化、依赖图、并发锁、ownership
切换由 `isTodoV2Enabled()` 控制。当前源码的实现逻辑:交互式会话中 V2 默认启用,非交互式会话(SDK)中 V1 默认启用;设置 `CLAUDE_CODE_ENABLE_TASKS` 环境变量可强制启用 V2。注意源码注释 "Force-enable tasks in non-interactive mode" 描述的是 env var 路径的用途,和默认分支的返回值语义不同,阅读时需区分。
教学版省略了真实源码中的 `activeForm` 字段(`utils/todo/types.ts:8-15`)。CC 用它给 UI spinner 展示"正在做什么",教学版只有终端输出,不需要这个字段。
教学版的 nag reminder(3 轮未更新就注入提醒)是教学机制。CC 源码中没有固定的"3 轮"逻辑,更接近的是 `TodoWriteTool.ts:72-107` 中当 3 个以上 todo 全部完成但没有 verification 项时,追加 verification nudge。
Task System 相比 TodoWrite 的核心增量:
- 文件持久化(Claude 配置目录下 `tasks/{taskListId}/{taskId}.json`)而非内存列表
- `blockedBy` 依赖图而非平铺列表
- `proper-lockfile` 并发安全而非无锁
- 四个独立工具(Create/Get/Update/List)而非一个
- TaskCreated / TaskCompleted hooks`TaskCreateTool.ts:80-129``TaskUpdateTool.ts:231-260`)供外部系统集成
</details>
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
+304
View File
@@ -0,0 +1,304 @@
#!/usr/bin/env python3
"""
s05: TodoWrite — add a planning tool on top of s04 hooks.
+---------+ +-------+ +------------------+
| User | ---> | LLM | ---> | TOOL_HANDLERS |
| prompt | | | | bash |
+---------+ +---+---+ | read_file |
^ | write_file |
| result | edit_file |
+---------+ glob |
todo_write ← NEW
+------------------+
|
in-memory current_todos
|
if rounds_since_todo >= 3:
inject <reminder>
Changes from s04:
+ todo_write tool + run_todo_write() implementation
+ Nag reminder (inject reminder after 3 rounds without todo update)
+ SYSTEM prompt includes "plan before execute" guidance
+ rounds_since_todo counter in agent_loop
Loop unchanged: new tool auto-dispatches via TOOL_HANDLERS.
Run: python s05_todo_write/code.py
Needs: pip install anthropic python-dotenv + ANTHROPIC_API_KEY in .env
"""
import ast, json, os, subprocess
from pathlib import Path
try:
import readline
readline.parse_and_bind('set bind-tty-special-chars off')
except ImportError:
pass
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
CURRENT_TODOS: list[dict] = []
# s05 change: SYSTEM prompt adds planning guidance
SYSTEM = (
f"You are a coding agent at {WORKDIR}. "
"Before starting any multi-step task, use todo_write to plan your steps. "
"Update status as you go."
)
# ═══════════════════════════════════════════════════════════
# FROM s02-s04 (unchanged): Tool Implementations
# ═══════════════════════════════════════════════════════════
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
def run_bash(command: str) -> str:
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=120)
out = (r.stdout + r.stderr).strip()
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
def run_read(path: str, limit: int | None = None) -> str:
try:
lines = safe_path(path).read_text().splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"]
return "\n".join(lines)
except Exception as e:
return f"Error: {e}"
def run_write(path: str, content: str) -> str:
try:
file_path = safe_path(path)
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content)
return f"Wrote {len(content)} bytes to {path}"
except Exception as e:
return f"Error: {e}"
def run_edit(path: str, old_text: str, new_text: str) -> str:
try:
file_path = safe_path(path)
text = file_path.read_text()
if old_text not in text:
return f"Error: text not found in {path}"
file_path.write_text(text.replace(old_text, new_text, 1))
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"
def run_glob(pattern: str) -> str:
import glob as g
try:
results = []
for match in g.glob(pattern, root_dir=WORKDIR):
if (WORKDIR / match).resolve().is_relative_to(WORKDIR):
results.append(match)
return "\n".join(results) if results else "(no matches)"
except Exception as e:
return f"Error: {e}"
# ═══════════════════════════════════════════════════════════
# NEW in s05: todo_write tool — plan only, no execution
# ═══════════════════════════════════════════════════════════
def _normalize_todos(todos):
if isinstance(todos, str):
try:
todos = json.loads(todos)
except json.JSONDecodeError:
try:
todos = ast.literal_eval(todos)
except (SyntaxError, ValueError):
return None, "Error: todos must be a list or JSON array string"
if not isinstance(todos, list):
return None, "Error: todos must be a list"
for i, t in enumerate(todos):
if not isinstance(t, dict):
return None, f"Error: todos[{i}] must be an object"
if "content" not in t or "status" not in t:
return None, f"Error: todos[{i}] missing 'content' or 'status'"
if t["status"] not in ("pending", "in_progress", "completed"):
return None, f"Error: todos[{i}] has invalid status '{t['status']}'"
return todos, None
def run_todo_write(todos: list) -> str:
global CURRENT_TODOS
todos, error = _normalize_todos(todos)
if error:
return error
CURRENT_TODOS = todos
lines = ["\n\033[33m## Current Tasks\033[0m"]
for t in CURRENT_TODOS:
icon = {"pending": " ", "in_progress": "\033[36m▸\033[0m", "completed": "\033[32m✓\033[0m"}[t["status"]]
lines.append(f" [{icon}] {t['content']}")
print("\n".join(lines))
return f"Updated {len(CURRENT_TODOS)} tasks"
TOOLS = [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write content to a file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in a file once.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "glob", "description": "Find files matching a glob pattern.",
"input_schema": {"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]}},
# s05: new tool
{"name": "todo_write", "description": "Create and manage a task list for your current coding session.",
"input_schema": {"type": "object", "properties": {"todos": {"type": "array", "items": {"type": "object", "properties": {"content": {"type": "string"}, "status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}}, "required": ["content", "status"]}}}, "required": ["todos"]}},
]
TOOL_HANDLERS = {
"bash": run_bash, "read_file": run_read, "write_file": run_write,
"edit_file": run_edit, "glob": run_glob, "todo_write": run_todo_write,
}
# ═══════════════════════════════════════════════════════════
# FROM s04 (unchanged): Hook System
# ═══════════════════════════════════════════════════════════
HOOKS = {"UserPromptSubmit": [], "PreToolUse": [], "PostToolUse": [], "Stop": []}
def register_hook(event: str, callback):
HOOKS[event].append(callback)
def trigger_hooks(event: str, *args):
for callback in HOOKS[event]:
result = callback(*args)
if result is not None:
return result
return None
# s04 hooks preserved
DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="]
def permission_hook(block):
"""PreToolUse: deny list check."""
if block.name == "bash":
for p in DENY_LIST:
if p in block.input.get("command", ""):
print(f"\n\033[31m⛔ Blocked: '{p}'\033[0m")
return "Permission denied"
return None
def log_hook(block):
"""PreToolUse: log tool calls."""
print(f"\033[90m[HOOK] {block.name}\033[0m")
return None
def context_inject_hook(query: str):
"""UserPromptSubmit: log working directory."""
print(f"\033[90m[HOOK] UserPromptSubmit: working in {WORKDIR}\033[0m")
return None
def summary_hook(messages: list):
"""Stop: print tool call count."""
tool_count = sum(1 for m in messages
for b in (m.get("content") if isinstance(m.get("content"), list) else [])
if isinstance(b, dict) and b.get("type") == "tool_result")
print(f"\033[90m[HOOK] Stop: session used {tool_count} tool calls\033[0m")
return None
register_hook("UserPromptSubmit", context_inject_hook)
register_hook("PreToolUse", permission_hook)
register_hook("PreToolUse", log_hook)
register_hook("Stop", summary_hook)
# ═══════════════════════════════════════════════════════════
# agent_loop — same as s04 + nag reminder counter
# ═══════════════════════════════════════════════════════════
rounds_since_todo = 0
def agent_loop(messages: list):
global rounds_since_todo
while True:
# s05: nag reminder — inject if model hasn't updated todos for 3 rounds
if rounds_since_todo >= 3 and messages:
messages.append({"role": "user",
"content": "<reminder>Update your todos.</reminder>"})
rounds_since_todo = 0
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
force = trigger_hooks("Stop", messages)
if force:
messages.append({"role": "user", "content": force})
continue
return
rounds_since_todo += 1
results = []
for block in response.content:
if block.type != "tool_use":
continue
blocked = trigger_hooks("PreToolUse", block)
if blocked:
results.append({"type": "tool_result", "tool_use_id": block.id,
"content": str(blocked)})
continue
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler else f"Unknown: {block.name}"
trigger_hooks("PostToolUse", block, output)
# s05: reset nag counter when todo_write is called
if block.name == "todo_write":
rounds_since_todo = 0
results.append({"type": "tool_result", "tool_use_id": block.id,
"content": output})
messages.append({"role": "user", "content": results})
if __name__ == "__main__":
print("s05: TodoWrite — plan before execute, nag if you forget")
print("Type a question, press Enter. Type q to quit.\n")
history = []
while True:
try:
query = input("\033[36ms05 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
trigger_hooks("UserPromptSubmit", query)
history.append({"role": "user", "content": query})
agent_loop(history)
for block in history[-1]["content"]:
if getattr(block, "type", None) == "text":
print(block.text)
print()
+6
View File
@@ -0,0 +1,6 @@
def greet(name):
message = "Hello, " + name
print(message)
greet("Claude")

Some files were not shown because too many files have changed in this diff Show More