Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b60a212ad0 | ||
|
|
4be570a4f2 | ||
|
|
9f4023bdf0 | ||
|
|
12a443c9a9 | ||
|
|
80f43480c1 | ||
|
|
5929f50b3a | ||
|
|
bd375e6b15 | ||
|
|
dd47566cb8 | ||
|
|
d09809e924 | ||
|
|
d65d63038f | ||
|
|
12557f23f0 | ||
|
|
76191f5035 | ||
|
|
293dd5657c | ||
|
|
dcba6c86ae | ||
|
|
b98675f643 | ||
|
|
a926b31e34 | ||
|
|
5a6798540a | ||
|
|
758d0d2980 | ||
|
|
6e49c4fdc7 | ||
|
|
6088678c4f | ||
|
|
cb76b267f1 | ||
|
|
bd98dea6b3 | ||
|
|
95b45940fd | ||
|
|
05e6e019b2 | ||
|
|
f5da3499a4 | ||
|
|
d9b8c795b6 | ||
|
|
08ebc72416 | ||
|
|
c5440a2b4c | ||
|
|
3b47c5e818 | ||
|
|
8946ca93f2 | ||
|
|
d63a10c221 | ||
|
|
f05732a60c | ||
|
|
ecb88b25a0 | ||
|
|
db3de65c3c | ||
|
|
4523e7e4d3 | ||
|
|
43e62fb8fc | ||
|
|
54c3cb41a9 | ||
|
|
012d5dfae6 | ||
|
|
6c5580ccd7 | ||
|
|
f995a34d66 | ||
|
|
99a4ccf059 | ||
|
|
5ad2628b1a | ||
|
|
a20dffa7a0 | ||
|
|
a30dfa9ad0 | ||
|
|
e47a3445df | ||
|
|
d4f9c8104f | ||
|
|
1b53357ded | ||
|
|
ffb94bb2f7 | ||
|
|
91190e180b | ||
|
|
f28d2328ef | ||
|
|
7199b4f2dd | ||
|
|
65bd5b5d9a | ||
|
|
2f645b9d85 | ||
|
|
9a437889b7 | ||
|
|
b72fcfea79 | ||
|
|
75ecaeca58 | ||
|
|
02ab8c2777 | ||
|
|
45283c87e4 | ||
|
|
199ad6259b | ||
|
|
601a1c9ea9 | ||
|
|
f9072534ef | ||
|
|
2d704cc014 | ||
|
|
fa565d8c07 | ||
|
|
730551cb3f | ||
|
|
c989d3b451 | ||
|
|
d17beb8a85 | ||
|
|
b2066c0cc7 | ||
|
|
2e8e1eb819 | ||
|
|
86a47282cf | ||
|
|
3c852d4446 | ||
|
|
e3f60a5933 | ||
|
|
1e35cbe222 | ||
|
|
ff734d6b0d | ||
|
|
1172ab6b08 | ||
|
|
1e84fb79a9 | ||
|
|
831461aff0 | ||
|
|
031a868286 | ||
|
|
d8a7171325 | ||
|
|
e7eb5e33b1 | ||
|
|
c52d5313a2 | ||
|
|
1fc7a6a495 | ||
|
|
79736cf16f | ||
|
|
da26f02498 | ||
|
|
749062fba7 | ||
|
|
82a28b7aa7 | ||
|
|
7dc4d142b3 | ||
|
|
094057f0d2 | ||
|
|
399cea4478 | ||
|
|
fd262bf98d | ||
|
|
de8349079f | ||
|
|
640822de19 | ||
|
|
2adbfa56bc | ||
|
|
7a6c2fcc02 | ||
|
|
e09c3de91e | ||
|
|
549cc79dc6 | ||
|
|
cc0d3f8b00 | ||
|
|
eeceaaaea9 | ||
|
|
b7ad7956c0 | ||
|
|
f3ded93fec | ||
|
|
1c74bbdcdb | ||
|
|
9048cc8b74 | ||
|
|
b29d61581a | ||
|
|
70c9ebed2b | ||
|
|
6d3f24aad6 | ||
|
|
d67f41358d | ||
|
|
2a565d314d |
@@ -1,61 +0,0 @@
|
|||||||
# 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 SWE-bench TB2 Base URL
|
|
||||||
# --------------- -------------------- --------- ------ -------------------
|
|
||||||
# Anthropic claude-sonnet-4-6 79.6% 59.1% (default)
|
|
||||||
# MiniMax MiniMax-M2.5 80.2% - see below
|
|
||||||
# GLM (Zhipu) glm-5 77.8% - see below
|
|
||||||
# Kimi (Moonshot) kimi-k2.5 76.8% - see below
|
|
||||||
# DeepSeek deepseek-chat 73.0% - see below
|
|
||||||
# (V3.2)
|
|
||||||
#
|
|
||||||
# SWE-bench = SWE-bench Verified (Feb 2026)
|
|
||||||
# TB2 = Terminal-Bench 2.0 (Feb 2026)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# ---- International ----
|
|
||||||
|
|
||||||
# MiniMax https://www.minimax.io
|
|
||||||
# ANTHROPIC_BASE_URL=https://api.minimax.io/anthropic
|
|
||||||
# MODEL_ID=MiniMax-M2.5
|
|
||||||
|
|
||||||
# GLM (Zhipu) https://z.ai
|
|
||||||
# ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic
|
|
||||||
# MODEL_ID=glm-5
|
|
||||||
|
|
||||||
# Kimi (Moonshot) https://platform.moonshot.ai
|
|
||||||
# ANTHROPIC_BASE_URL=https://api.moonshot.ai/anthropic
|
|
||||||
# MODEL_ID=kimi-k2.5
|
|
||||||
|
|
||||||
# DeepSeek https://platform.deepseek.com
|
|
||||||
# ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic
|
|
||||||
# MODEL_ID=deepseek-chat
|
|
||||||
|
|
||||||
# ---- China mainland ----
|
|
||||||
|
|
||||||
# MiniMax https://platform.minimax.io
|
|
||||||
# ANTHROPIC_BASE_URL=https://api.minimaxi.com/anthropic
|
|
||||||
# MODEL_ID=MiniMax-M2.5
|
|
||||||
|
|
||||||
# GLM (Zhipu) https://open.bigmodel.cn
|
|
||||||
# ANTHROPIC_BASE_URL=https://open.bigmodel.cn/api/anthropic
|
|
||||||
# MODEL_ID=glm-5
|
|
||||||
|
|
||||||
# Kimi (Moonshot) https://platform.moonshot.cn
|
|
||||||
# ANTHROPIC_BASE_URL=https://api.moonshot.cn/anthropic
|
|
||||||
# MODEL_ID=kimi-k2.5
|
|
||||||
|
|
||||||
# DeepSeek (no regional split, same endpoint globally)
|
|
||||||
# ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic
|
|
||||||
# MODEL_ID=deepseek-chat
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: web
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v6
|
|
||||||
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
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
name: Test
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
unit-test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v6
|
|
||||||
with:
|
|
||||||
python-version: "3.11"
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pip install anthropic python-dotenv
|
|
||||||
|
|
||||||
- name: Run unit tests
|
|
||||||
run: python tests/test_unit.py
|
|
||||||
|
|
||||||
session-test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
session: [v0, v1, v2, v3, v4, v5, v6, v7, v8a, v8b, v8c, v9]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v6
|
|
||||||
with:
|
|
||||||
python-version: "3.11"
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pip install anthropic python-dotenv
|
|
||||||
|
|
||||||
- name: Run ${{ matrix.session }} tests
|
|
||||||
env:
|
|
||||||
TEST_API_KEY: ${{ secrets.TEST_API_KEY }}
|
|
||||||
TEST_BASE_URL: ${{ secrets.TEST_BASE_URL }}
|
|
||||||
TEST_MODEL: ${{ secrets.TEST_MODEL }}
|
|
||||||
run: python tests/test_${{ matrix.session }}.py
|
|
||||||
|
|
||||||
web-build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: web
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Set up Node.js
|
|
||||||
uses: actions/setup-node@v6
|
|
||||||
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
|
|
||||||
+17
-227
@@ -1,233 +1,23 @@
|
|||||||
# Byte-compiled / optimized / DLL files
|
# Maven
|
||||||
__pycache__/
|
|
||||||
*.py[codz]
|
|
||||||
*$py.class
|
|
||||||
|
|
||||||
# C extensions
|
|
||||||
*.so
|
|
||||||
|
|
||||||
# Distribution / packaging
|
|
||||||
.idea
|
|
||||||
*.iml
|
|
||||||
.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/
|
target/
|
||||||
|
*.class
|
||||||
|
|
||||||
# Jupyter Notebook
|
# Distribution
|
||||||
.ipynb_checkpoints
|
dist/
|
||||||
|
dist2/
|
||||||
|
|
||||||
# IPython
|
# IDE
|
||||||
profile_default/
|
.idea/
|
||||||
ipython_config.py
|
*.iml
|
||||||
|
.vscode/
|
||||||
|
.settings/
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
|
||||||
# pyenv
|
# OS
|
||||||
# For a library or package, you might want to ignore these files since the code is
|
.DS_Store
|
||||||
# intended to run in multiple environments; otherwise, check them in:
|
Thumbs.db
|
||||||
# .python-version
|
|
||||||
|
|
||||||
# pipenv
|
# Logs
|
||||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
*.log
|
||||||
# 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)
|
|
||||||
.task_outputs/
|
|
||||||
.tasks/
|
|
||||||
.teams/
|
|
||||||
.team/
|
|
||||||
.worktrees/
|
|
||||||
|
|
||||||
# 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
|
|
||||||
TODO.md
|
TODO.md
|
||||||
.test-workspace/
|
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
# Claude Code Java — 构建与安装指南
|
||||||
|
|
||||||
|
## 前置要求
|
||||||
|
|
||||||
|
| 依赖 | 版本 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| **JDK** | 25+ | 必须,推荐 [Oracle JDK 25](https://www.oracle.com/java/technologies/downloads/) 或 [OpenJDK 25](https://jdk.java.net/25/) |
|
||||||
|
| **Maven** | 3.9+ | 必须,[下载地址](https://maven.apache.org/download.cgi) |
|
||||||
|
| **API Key** | - | OpenAI / Anthropic / 兼容 API 的密钥 |
|
||||||
|
|
||||||
|
> ⚠️ JDK 25 是必须的,项目使用了 preview 特性(如 pattern matching、string templates)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速开始(开发模式)
|
||||||
|
|
||||||
|
适合开发和调试,通过 Maven 直接运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 设置 JDK
|
||||||
|
export JAVA_HOME=/path/to/jdk-25 # Linux/macOS
|
||||||
|
set JAVA_HOME=C:\Dev\jdk-25.0.2 # Windows
|
||||||
|
|
||||||
|
# 2. 设置 API Key
|
||||||
|
export AI_API_KEY=your-api-key # Linux/macOS
|
||||||
|
set AI_API_KEY=your-api-key # Windows
|
||||||
|
|
||||||
|
# 3. 运行
|
||||||
|
mvn spring-boot:run
|
||||||
|
```
|
||||||
|
|
||||||
|
> 📌 开发模式下,工作目录(AI 操作的目录)为执行 `mvn` 命令的目录。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 构建发行版(推荐用于日常使用)
|
||||||
|
|
||||||
|
发行版使用 **jlink** 创建精简 JRE,打包为独立可执行程序,无需目标机器安装 JDK。
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 构建
|
||||||
|
.\packaging\build-dist.ps1 -JavaHome "C:\Dev\jdk-25.0.2"
|
||||||
|
|
||||||
|
# 或跳过 Maven 构建(已有 jar 的情况)
|
||||||
|
.\packaging\build-dist.ps1 -JavaHome "C:\Dev\jdk-25.0.2" -SkipBuild
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux / macOS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建
|
||||||
|
JAVA_HOME=/path/to/jdk-25 ./packaging/build-dist.sh
|
||||||
|
|
||||||
|
# 或跳过 Maven 构建
|
||||||
|
JAVA_HOME=/path/to/jdk-25 ./packaging/build-dist.sh --skip-build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 构建输出
|
||||||
|
|
||||||
|
```
|
||||||
|
dist/
|
||||||
|
├── bin/
|
||||||
|
│ ├── claude-code # Unix 启动脚本 (Linux/macOS)
|
||||||
|
│ └── claude-code.cmd # Windows 启动脚本
|
||||||
|
├── lib/
|
||||||
|
│ └── claude-code-java.jar # Spring Boot fat jar (~71 MB)
|
||||||
|
└── runtime/ # jlink 精简 JRE (~49 MB)
|
||||||
|
├── bin/
|
||||||
|
├── conf/
|
||||||
|
├── lib/
|
||||||
|
└── release
|
||||||
|
```
|
||||||
|
|
||||||
|
总大小约 **120 MB**(对比完整 JDK ~350 MB)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 安装到系统 PATH
|
||||||
|
|
||||||
|
将 `dist/bin` 加入系统 PATH 后,可在任意目录直接使用 `claude-code` 命令。
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
**方法 1:CMD 临时生效(当前终端)**
|
||||||
|
```cmd
|
||||||
|
set PATH=C:\path\to\claude-code-java\dist\bin;%PATH%
|
||||||
|
```
|
||||||
|
|
||||||
|
**方法 2:PowerShell 临时生效(当前终端)**
|
||||||
|
```powershell
|
||||||
|
$env:PATH = "C:\path\to\claude-code-java\dist\bin;$env:PATH"
|
||||||
|
```
|
||||||
|
|
||||||
|
**方法 3:CMD 永久生效(用户级)**
|
||||||
|
```cmd
|
||||||
|
setx PATH "%PATH%;C:\path\to\claude-code-java\dist\bin"
|
||||||
|
```
|
||||||
|
> 需要重开终端窗口生效。
|
||||||
|
|
||||||
|
**方法 4:PowerShell 永久生效**
|
||||||
|
```powershell
|
||||||
|
$binPath = "C:\path\to\claude-code-java\dist\bin"
|
||||||
|
$currentPath = [Environment]::GetEnvironmentVariable("PATH", "User")
|
||||||
|
if ($currentPath -notmatch [regex]::Escape($binPath)) {
|
||||||
|
[Environment]::SetEnvironmentVariable("PATH", "$currentPath;$binPath", "User")
|
||||||
|
Write-Host "Added to PATH. Restart terminal to take effect."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 临时生效
|
||||||
|
export PATH="/path/to/claude-code-java/dist/bin:$PATH"
|
||||||
|
|
||||||
|
# 永久生效 — 添加到 ~/.bashrc 或 ~/.zshrc
|
||||||
|
echo 'export PATH="/path/to/claude-code-java/dist/bin:$PATH"' >> ~/.bashrc
|
||||||
|
source ~/.bashrc
|
||||||
|
```
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 临时生效
|
||||||
|
export PATH="/path/to/claude-code-java/dist/bin:$PATH"
|
||||||
|
|
||||||
|
# 永久生效 — 添加到 ~/.zshrc (macOS 默认 shell 是 zsh)
|
||||||
|
echo 'export PATH="/path/to/claude-code-java/dist/bin:$PATH"' >> ~/.zshrc
|
||||||
|
source ~/.zshrc
|
||||||
|
```
|
||||||
|
|
||||||
|
### 验证安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在任意目录执行
|
||||||
|
claude-code # Linux/macOS
|
||||||
|
claude-code.cmd # Windows CMD
|
||||||
|
claude-code # Windows PowerShell (自动找 .cmd)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 在其他目录使用
|
||||||
|
|
||||||
|
安装到 PATH 后,`claude-code` 可以在任何目录启动。**AI 的工作目录就是你启动命令时的当前目录**。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 示例:在项目目录启动,AI 会自动读取该目录的上下文
|
||||||
|
cd /path/to/my-project
|
||||||
|
claude-code
|
||||||
|
|
||||||
|
# AI 将:
|
||||||
|
# - 自动加载 ./CLAUDE.md(如果存在)
|
||||||
|
# - 读取 .git 信息获取项目上下文
|
||||||
|
# - 所有文件操作基于当前目录
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 示例:在不同项目之间切换
|
||||||
|
cd ~/projects/web-app && claude-code # 操作 web-app 项目
|
||||||
|
cd ~/projects/api-server && claude-code # 操作 api-server 项目
|
||||||
|
```
|
||||||
|
|
||||||
|
### 工作目录说明
|
||||||
|
|
||||||
|
| 场景 | 工作目录 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `cd /my-project && claude-code` | `/my-project` | AI 在此目录下操作文件 |
|
||||||
|
| 启动后使用工具 | 当前目录 | `bash`, `file_read` 等都基于启动目录 |
|
||||||
|
| `CLAUDE.md` 加载 | 当前目录 + `~/.claude/` | 项目级 + 全局级自动合并 |
|
||||||
|
| Git 上下文 | 当前目录的 `.git` | 自动检测分支、状态、最近提交 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
| 变量 | 必须 | 说明 | 默认值 |
|
||||||
|
|------|------|------|--------|
|
||||||
|
| `AI_API_KEY` | ✅ | API 密钥 | - |
|
||||||
|
| `CLAUDE_CODE_PROVIDER` | ❌ | 提供者 (`openai` / `anthropic`) | `openai` |
|
||||||
|
| `AI_BASE_URL` | ❌ | API 基础 URL | 按提供者不同 |
|
||||||
|
| `AI_MODEL` | ❌ | 模型名称 | 按提供者不同 |
|
||||||
|
| `AI_MAX_TOKENS` | ❌ | 最大生成 Token 数 | `8096` |
|
||||||
|
| `CLAUDE_CODE_VIM` | ❌ | 启用 Vim 编辑模式 | `0` |
|
||||||
|
| `CLAUDE_CODE_CONTEXT_WINDOW` | ❌ | 上下文窗口大小 | `200000` |
|
||||||
|
|
||||||
|
建议将常用变量配置到 shell profile 中:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ~/.bashrc 或 ~/.zshrc
|
||||||
|
export AI_API_KEY="sk-your-key-here"
|
||||||
|
export CLAUDE_CODE_PROVIDER="openai"
|
||||||
|
export AI_BASE_URL="https://api.deepseek.com"
|
||||||
|
export AI_MODEL="deepseek-chat"
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows 可用 `setx` 持久化:
|
||||||
|
```cmd
|
||||||
|
setx AI_API_KEY "sk-your-key-here"
|
||||||
|
setx CLAUDE_CODE_PROVIDER "openai"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 跨平台构建注意事项
|
||||||
|
|
||||||
|
jlink 创建的 JRE 是 **平台相关** 的。在 Windows 上构建的 `dist/` 只能在 Windows 上运行。
|
||||||
|
|
||||||
|
如果需要为多个平台构建:
|
||||||
|
|
||||||
|
| 构建平台 | 产出 | 可运行平台 |
|
||||||
|
|----------|------|------------|
|
||||||
|
| Windows x64 | `dist/runtime/` (Windows JRE) | Windows x64 |
|
||||||
|
| Linux x64 | `dist/runtime/` (Linux JRE) | Linux x64 |
|
||||||
|
| macOS ARM | `dist/runtime/` (macOS JRE) | macOS ARM (M1/M2/M3) |
|
||||||
|
| macOS x64 | `dist/runtime/` (macOS JRE) | macOS Intel |
|
||||||
|
|
||||||
|
> 💡 `lib/claude-code-java.jar` 是跨平台的,只有 `runtime/` 需要针对每个平台构建。
|
||||||
|
>
|
||||||
|
> 如果在 CI/CD 中构建,可以在 GitHub Actions 中使用 matrix strategy 分别在 `ubuntu-latest`, `windows-latest`, `macos-latest` 上构建。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: 启动时报 "OpenAI API key must be set"
|
||||||
|
|
||||||
|
设置 `AI_API_KEY` 环境变量:
|
||||||
|
```bash
|
||||||
|
export AI_API_KEY="your-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: Windows 终端中文乱码
|
||||||
|
|
||||||
|
发行版启动脚本已自动执行 `chcp 65001`。如果仍有问题,手动运行:
|
||||||
|
```cmd
|
||||||
|
chcp 65001
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: 如何使用 Anthropic API 而不是 OpenAI?
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export CLAUDE_CODE_PROVIDER="anthropic"
|
||||||
|
export AI_API_KEY="sk-ant-your-key"
|
||||||
|
export AI_MODEL="claude-sonnet-4-20250514"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: 如何使用 DeepSeek / Azure OpenAI 等兼容 API?
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export CLAUDE_CODE_PROVIDER="openai"
|
||||||
|
export AI_BASE_URL="https://api.deepseek.com"
|
||||||
|
export AI_MODEL="deepseek-chat"
|
||||||
|
export AI_API_KEY="your-deepseek-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: dist/ 目录可以复制到其他机器吗?
|
||||||
|
|
||||||
|
可以,只要目标机器的操作系统和 CPU 架构与构建机器一致。`dist/` 是完全自包含的,不需要安装 JDK。
|
||||||
|
|
||||||
|
### Q: 如何更新?
|
||||||
|
|
||||||
|
重新执行构建脚本覆盖 `dist/` 目录即可:
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
./packaging/build-dist.sh # 或 .ps1
|
||||||
|
```
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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.
|
|
||||||
-438
@@ -1,438 +0,0 @@
|
|||||||
# Learn Claude Code -- Harness Engineering for Real Agents
|
|
||||||
|
|
||||||
[English](./README-en.md) | [中文](./README.md) | [日本語](./README-ja.md)
|
|
||||||
|
|
||||||
## The Model IS the Agent
|
|
||||||
|
|
||||||
Before we talk about code, let's get one thing absolutely straight.
|
|
||||||
|
|
||||||
**An agent is a model. Not a framework. Not a prompt chain. Not a drag-and-drop workflow.**
|
|
||||||
|
|
||||||
### What an Agent IS
|
|
||||||
|
|
||||||
An agent is a neural network -- a Transformer, an RNN, a learned function -- that has been trained, through billions of gradient updates on action-sequence data, to perceive an environment, reason about goals, and take actions to achieve them. The word "agent" in AI has always meant this. Always.
|
|
||||||
|
|
||||||
A human is an agent. A biological neural network, shaped by millions of years of evolutionary training, perceiving the world through senses, reasoning through a brain, acting through a body. When DeepMind, OpenAI, or Anthropic say "agent," they mean the same thing the field has meant since its inception: **a model that has learned to act.**
|
|
||||||
|
|
||||||
The proof is written in history:
|
|
||||||
|
|
||||||
- **2013 -- DeepMind DQN plays Atari.** A single neural network, receiving only raw pixels and game scores, learned to play 7 Atari 2600 games -- surpassing all prior algorithms and beating human experts on 3 of them. By 2015, the same architecture scaled to [49 games and matched professional human testers](https://www.nature.com/articles/nature14236), published in *Nature*. No game-specific rules. No decision trees. One model, learning from experience. That model was the agent.
|
|
||||||
|
|
||||||
- **2019 -- OpenAI Five conquers Dota 2.** Five neural networks, having played [45,000 years of Dota 2](https://openai.com/index/openai-five-defeats-dota-2-world-champions/) against themselves in 10 months, defeated **OG** -- the reigning TI8 world champions -- 2-0 on a San Francisco livestream. In a subsequent public arena, the AI won 99.4% of 42,729 games against all comers. No scripted strategies. No meta-programmed team coordination. The models learned teamwork, tactics, and real-time adaptation entirely through self-play.
|
|
||||||
|
|
||||||
- **2019 -- DeepMind AlphaStar masters StarCraft II.** AlphaStar [beat professional players 10-1](https://deepmind.google/blog/alphastar-mastering-the-real-time-strategy-game-starcraft-ii/) in a closed-door match, and later achieved [Grandmaster status](https://www.nature.com/articles/d41586-019-03298-6) on European servers -- top 0.15% of 90,000 players. A game with imperfect information, real-time decisions, and a combinatorial action space that dwarfs chess and Go. The agent? A model. Trained. Not scripted.
|
|
||||||
|
|
||||||
- **2019 -- Tencent Jueyu dominates Honor of Kings.** Tencent AI Lab's "Jueyu" [defeated KPL professional players](https://www.jiemian.com/article/3371171.html) in a full 5v5 match at the World Champion Cup. In 1v1 mode, pros won only [1 out of 15 games and never survived past 8 minutes](https://developer.aliyun.com/article/851058). Training intensity: one day equaled 440 human years. By 2021, Jueyu surpassed KPL pros across the full hero pool. No handcrafted matchup tables. No scripted compositions. 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 entirety of human code and reasoning -- are deployed as coding agents. They read codebases, write implementations, debug failures, coordinate in teams. The architecture is identical to every agent before them: a trained model, placed in an environment, given tools to perceive and act. The only difference is the scale of what they've learned and the generality of the tasks they solve.
|
|
||||||
|
|
||||||
Every one of these milestones shares the same truth: **the "agent" is never the surrounding code. The agent is always the model.**
|
|
||||||
|
|
||||||
### What an Agent Is NOT
|
|
||||||
|
|
||||||
The word "agent" has been hijacked by an entire cottage industry of prompt plumbing.
|
|
||||||
|
|
||||||
Drag-and-drop workflow builders. No-code "AI agent" platforms. Prompt-chain orchestration libraries. They all share the same delusion: that wiring together LLM API calls with if-else branches, node graphs, and hardcoded routing logic constitutes "building an agent."
|
|
||||||
|
|
||||||
It doesn't. What they build is a Rube Goldberg machine -- an over-engineered, brittle pipeline of procedural rules, with an LLM wedged in as a glorified text-completion node. That is not an agent. That is a shell script with delusions of grandeur.
|
|
||||||
|
|
||||||
**Prompt plumbing "agents" are the fantasy of programmers who don't train models.** They attempt to brute-force intelligence by stacking procedural logic -- massive rule trees, node graphs, chain-of-prompt waterfalls -- and praying that enough glue code will somehow emergently produce autonomous behavior. It won't. You cannot engineer your way to agency. Agency is learned, not programmed.
|
|
||||||
|
|
||||||
Those systems are dead on arrival: fragile, unscalable, fundamentally incapable of generalization. They are the modern resurrection of GOFAI (Good Old-Fashioned AI) -- the symbolic rule systems the field abandoned decades ago, now spray-painted with an LLM veneer. Different packaging, same dead end.
|
|
||||||
|
|
||||||
### The Mind Shift: From "Developing Agents" to Developing Harness
|
|
||||||
|
|
||||||
When someone says "I'm developing an agent," they can only mean one of two things:
|
|
||||||
|
|
||||||
**1. Training the model.** Adjusting weights through reinforcement learning, fine-tuning, RLHF, or other gradient-based methods. Collecting task-process data -- the actual sequences of perception, reasoning, and action in real domains -- and using it to shape the model's behavior. This is what DeepMind, OpenAI, Tencent AI Lab, and Anthropic do. This is agent development in the truest sense.
|
|
||||||
|
|
||||||
**2. Building the harness.** Writing the code that gives the model an environment to operate in. This is what most of us do, and it is the focus of this repository.
|
|
||||||
|
|
||||||
A harness is everything the agent needs to function 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: sandboxing, 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.
|
|
||||||
|
|
||||||
**A coding agent's harness is its IDE, terminal, and filesystem access.** A farm agent's harness is its sensor array, irrigation controls, and weather data feeds. A hotel agent's harness is its booking system, guest communication channels, and facility management APIs. The agent -- the intelligence, the decision-maker -- is always the model. The harness changes per domain. The agent generalizes across them.
|
|
||||||
|
|
||||||
This repo teaches you to build vehicles. Vehicles for coding. But the design patterns generalize to any domain: farm management, hotel operations, manufacturing, logistics, healthcare, education, scientific research. Anywhere a task needs to be perceived, reasoned about, and acted upon -- an agent needs a harness.
|
|
||||||
|
|
||||||
### What Harness Engineers Actually Do
|
|
||||||
|
|
||||||
If you are reading this repository, you are likely a harness engineer -- and that is a powerful thing to be. Here is your real job:
|
|
||||||
|
|
||||||
- **Implement tools.** Give the agent hands. File read/write, shell execution, API calls, browser control, database queries. Each tool is an action the agent can take in its environment. Design them to be atomic, composable, and well-described.
|
|
||||||
|
|
||||||
- **Curate knowledge.** Give the agent domain expertise. Product documentation, architectural decision records, style guides, regulatory requirements. Load them on-demand (s05), not upfront. The agent should know what's available and pull what it needs.
|
|
||||||
|
|
||||||
- **Manage context.** Give the agent clean memory. Subagent isolation (s04) prevents noise from leaking. Context compression (s06) prevents history from overwhelming. Task systems (s07) persist goals beyond any 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. This is where safety engineering meets harness engineering.
|
|
||||||
|
|
||||||
- **Collect task-process data.** Every action sequence the agent executes in your harness is training signal. The perception-reasoning-action traces from real deployments are the raw material for fine-tuning the next generation of agent models. Your harness doesn't just serve the agent -- it can help improve the agent.
|
|
||||||
|
|
||||||
You are not writing the intelligence. You are building the world the intelligence inhabits. The quality of that world -- how clearly the agent can perceive, how precisely it can act, how rich its available knowledge is -- directly determines how effectively the intelligence can express itself.
|
|
||||||
|
|
||||||
**Build great harnesses. The agent will do the rest.**
|
|
||||||
|
|
||||||
### Why Claude Code -- A Masterclass in Harness Engineering
|
|
||||||
|
|
||||||
Why does this repository dissect Claude Code specifically?
|
|
||||||
|
|
||||||
Because Claude Code is the most elegant and fully-realized agent harness we have seen. Not because of any single clever trick, but because of what it *doesn't* do: it doesn't try to be the agent. It doesn't impose rigid workflows. It doesn't second-guess the model with elaborate decision trees. It provides the model with tools, knowledge, context management, and permission boundaries -- then gets out of the way.
|
|
||||||
|
|
||||||
Look at what Claude Code actually is, stripped to its essence:
|
|
||||||
|
|
||||||
```
|
|
||||||
Claude Code = one agent loop
|
|
||||||
+ tools (bash, read, write, edit, glob, grep, browser...)
|
|
||||||
+ on-demand skill loading
|
|
||||||
+ context compression
|
|
||||||
+ subagent spawning
|
|
||||||
+ task system with dependency graph
|
|
||||||
+ team coordination with async mailboxes
|
|
||||||
+ worktree isolation for parallel execution
|
|
||||||
+ permission governance
|
|
||||||
```
|
|
||||||
|
|
||||||
That's it. That's the entire architecture. Every component is a harness mechanism -- a piece of the world built for the agent to inhabit. The agent itself? It's Claude. A model. Trained by Anthropic on the full breadth of human reasoning and code. The harness doesn't make Claude smart. Claude is already smart. The harness gives Claude hands, eyes, and a workspace.
|
|
||||||
|
|
||||||
This is why Claude Code is the ideal teaching subject: **it demonstrates what happens when you trust the model and focus your engineering on the harness.** Every session in this repository (s01-s12) reverse-engineers one harness mechanism from Claude Code's architecture. By the end, you understand not just how Claude Code works, but the universal principles of harness engineering that apply to any agent in any domain.
|
|
||||||
|
|
||||||
The lesson is not "copy Claude Code." The lesson is: **the best agent products are built by engineers who understand that their job is harness, not intelligence.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The Vision: Fill the Universe with Real Agents
|
|
||||||
|
|
||||||
This is not just about coding agents.
|
|
||||||
|
|
||||||
Every domain where humans perform complex, multi-step, judgment-intensive work is a domain where agents can operate -- given the right harness. The patterns in this repository are universal:
|
|
||||||
|
|
||||||
```
|
|
||||||
Estate management agent = model + property sensors + maintenance tools + tenant comms
|
|
||||||
Agricultural agent = model + soil/weather data + irrigation controls + crop knowledge
|
|
||||||
Hotel operations agent = model + booking system + guest channels + facility APIs
|
|
||||||
Medical research agent = model + literature search + lab instruments + protocol docs
|
|
||||||
Manufacturing agent = model + production line sensors + quality controls + logistics
|
|
||||||
Education agent = model + curriculum knowledge + student progress + assessment tools
|
|
||||||
```
|
|
||||||
|
|
||||||
The loop is always the same. The tools change. The knowledge changes. The permissions change. The agent -- the model -- generalizes.
|
|
||||||
|
|
||||||
Every harness engineer reading this repository is learning patterns that apply far beyond software engineering. You are learning to build the infrastructure for an intelligent, automated future. Every well-designed harness deployed in a real domain is one more place where an agent can perceive, reason, and act.
|
|
||||||
|
|
||||||
First we fill the workshops. Then the farms, the hospitals, the factories. Then the cities. Then the planet.
|
|
||||||
|
|
||||||
**Bash is all you need. Real agents are all the universe needs.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
```
|
|
||||||
THE AGENT PATTERN
|
|
||||||
=================
|
|
||||||
|
|
||||||
User --> messages[] --> LLM --> response
|
|
||||||
|
|
|
||||||
More tool call requests?
|
|
||||||
/ \
|
|
||||||
yes no
|
|
||||||
| |
|
|
||||||
execute @Tool methods return text
|
|
||||||
return results
|
|
||||||
continue loop -----------------> messages[]
|
|
||||||
|
|
||||||
|
|
||||||
That's the minimal loop. Every AI agent needs this loop.
|
|
||||||
The model decides when to call tools and when to stop.
|
|
||||||
Spring AI's ChatClient.call() automatically drives this loop.
|
|
||||||
This repo teaches you to build everything around this loop --
|
|
||||||
the harness that makes the agent effective in a specific domain.
|
|
||||||
```
|
|
||||||
|
|
||||||
**12 progressive sessions, from a simple loop to isolated autonomous execution.**
|
|
||||||
**Each session adds one harness mechanism. Each mechanism has one motto.**
|
|
||||||
|
|
||||||
> **s01** *"One loop & Bash is all you need"* — one tool + one loop = an agent
|
|
||||||
>
|
|
||||||
> **s02** *"Adding a tool means adding one handler"* — the loop stays the same; new tools register with `@Tool` annotation + `defaultTools()`
|
|
||||||
>
|
|
||||||
> **s03** *"An agent without a plan drifts"* — list the steps first, then execute; completion doubles
|
|
||||||
>
|
|
||||||
> **s04** *"Break big tasks down; each subtask gets a clean context"* — subagents use independent messages[], keeping the main conversation clean
|
|
||||||
>
|
|
||||||
> **s05** *"Load knowledge when you need it, not upfront"* — inject via tool_result, not the system prompt
|
|
||||||
>
|
|
||||||
> **s06** *"Context will fill up; you need a way to make room"* — three-layer compression strategy for infinite sessions
|
|
||||||
>
|
|
||||||
> **s07** *"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
|
|
||||||
>
|
|
||||||
> **s08** *"Run slow operations in the background; the agent keeps thinking"* — daemon threads run commands, inject notifications on completion
|
|
||||||
>
|
|
||||||
> **s09** *"When the task is too big for one, delegate to teammates"* — persistent teammates + async mailboxes
|
|
||||||
>
|
|
||||||
> **s10** *"Teammates need shared communication rules"* — one request-response pattern drives all negotiation
|
|
||||||
>
|
|
||||||
> **s11** *"Teammates scan the board and claim tasks themselves"* — no need for the lead to assign each one
|
|
||||||
>
|
|
||||||
> **s12** *"Each works in its own directory, no interference"* — tasks manage goals, worktrees manage directories, bound by ID
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The Core Pattern
|
|
||||||
|
|
||||||
```java
|
|
||||||
// Spring AI's ChatClient + @Tool annotation implement the Agent loop
|
|
||||||
// The model automatically decides when to call tools and when to return text -- the loop is driven by the framework
|
|
||||||
|
|
||||||
@SpringBootApplication
|
|
||||||
public class S01AgentLoop implements CommandLineRunner {
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
public CommandLineRunner agentLoop(ChatClient.Builder builder) {
|
|
||||||
ChatClient chatClient = builder
|
|
||||||
.defaultSystem("You are a helpful assistant with access to tools.")
|
|
||||||
.defaultTools(new BashTool()) // 注册工具
|
|
||||||
.build();
|
|
||||||
|
|
||||||
return args -> {
|
|
||||||
// 一次 call() 内部自动完成: 调用模型 → 检测工具请求 → 执行工具 → 回传结果 → 再次调用模型...
|
|
||||||
String result = chatClient.prompt()
|
|
||||||
.user(userInput)
|
|
||||||
.call()
|
|
||||||
.content();
|
|
||||||
System.out.println(result);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// @Tool 注解让方法自动成为模型可调用的工具
|
|
||||||
public class BashTool {
|
|
||||||
@Tool(description = "Execute a shell command and return stdout/stderr")
|
|
||||||
public String executeBash(String command) {
|
|
||||||
// 执行命令并返回结果
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Spring AI's `ChatClient.call()` encapsulates the complete agent loop internally: call the LLM → detect tool call requests → execute `@Tool` methods → return results to the model → repeat until the model returns text. Each session layers one harness mechanism on top of this loop -- without changing the loop itself. The loop belongs to the agent. The mechanisms belong to the harness.
|
|
||||||
|
|
||||||
## Scope (Important)
|
|
||||||
|
|
||||||
This repository is a 0->1 learning project for harness engineering -- building the environment that surrounds an agent model.
|
|
||||||
It intentionally simplifies or omits several production mechanisms:
|
|
||||||
|
|
||||||
- Full event/hook buses (for example PreToolUse, SessionStart/End, ConfigChange).
|
|
||||||
s12 includes only a minimal append-only lifecycle event stream for teaching.
|
|
||||||
- Rule-based permission governance and trust workflows
|
|
||||||
- Session lifecycle controls (resume/fork) and advanced worktree lifecycle controls
|
|
||||||
- Full MCP runtime details (transport/OAuth/resource subscribe/polling)
|
|
||||||
|
|
||||||
Treat the team JSONL mailbox protocol in this repo as a teaching implementation, not a claim about any specific production internals.
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### Requirements
|
|
||||||
|
|
||||||
- **JDK 21+** (recommended: [Eclipse Temurin](https://adoptium.net/) or GraalVM)
|
|
||||||
- **Maven 3.9+**
|
|
||||||
- An OpenAI-compatible LLM API key (DeepSeek, GLM, Qwen, OpenAI, etc.)
|
|
||||||
|
|
||||||
### Clone & Build
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git clone https://github.com/abel533/claude-code
|
|
||||||
cd learn-claude-code
|
|
||||||
mvn compile # 编译项目
|
|
||||||
```
|
|
||||||
|
|
||||||
### Set Environment Variables
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# Linux / macOS
|
|
||||||
export AI_API_KEY=your-api-key
|
|
||||||
export AI_BASE_URL=https://api.deepseek.com # 替换为你的模型服务商地址
|
|
||||||
export AI_MODEL=deepseek-chat # 替换为你使用的模型名称
|
|
||||||
|
|
||||||
# Windows PowerShell
|
|
||||||
$env:AI_API_KEY="your-api-key"
|
|
||||||
$env:AI_BASE_URL="https://api.deepseek.com"
|
|
||||||
$env:AI_MODEL="deepseek-chat"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Run Sessions
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# 从第一课开始
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s01.S01AgentLoop
|
|
||||||
|
|
||||||
# 完整递进终点
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s12.S12WorktreeIsolation
|
|
||||||
|
|
||||||
# 总纲: 全部机制合一
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.full.SFullAgent
|
|
||||||
```
|
|
||||||
|
|
||||||
### Web Platform
|
|
||||||
|
|
||||||
Interactive visualizations, step-through animations, source viewer, and documentation for each session.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd web && npm install && npm run dev # http://localhost:3000
|
|
||||||
```
|
|
||||||
|
|
||||||
### Java Version Features
|
|
||||||
|
|
||||||
This project uses the **Java 21 + Spring Boot 3.5.7 + Spring AI 1.0.3** stack. Compared to the original Python version:
|
|
||||||
|
|
||||||
- **Compatible with multiple LLM providers** -- adapts to DeepSeek, GLM, Qwen, Moonshot and other models via the OpenAI protocol, no vendor lock-in
|
|
||||||
- **`@Tool` annotation handles the tool call loop automatically** -- Spring AI completes the full "model call → tool execution → result return" cycle, no hand-written while loop needed
|
|
||||||
- **Java 21 Virtual Threads** -- lightweight concurrency for background tasks and multi-agent collaboration, without thread pool management overhead
|
|
||||||
- **Each session is independently runnable** -- every session is a `@SpringBootApplication` + `CommandLineRunner`, launchable with a single `mvn exec:java` command
|
|
||||||
- **Type safety** -- Java's strong type system catches errors at compile time, with IDE-friendly auto-completion
|
|
||||||
|
|
||||||
## Learning Path
|
|
||||||
|
|
||||||
```
|
|
||||||
Phase 1: THE LOOP Phase 2: PLANNING & KNOWLEDGE
|
|
||||||
================== ==============================
|
|
||||||
s01 The Agent Loop [1] s03 TodoWrite [5]
|
|
||||||
ChatClient + @Tool TodoManager + nag reminder
|
|
||||||
| |
|
|
||||||
+-> s02 Tool Use [4] s04 Subagents [5]
|
|
||||||
@Tool registers tools independent ChatClient per child
|
|
||||||
|
|
|
||||||
s05 Skills [5]
|
|
||||||
SKILL.md via tool_result
|
|
||||||
|
|
|
||||||
s06 Context Compact [5]
|
|
||||||
3-layer compression
|
|
||||||
|
|
||||||
Phase 3: PERSISTENCE Phase 4: TEAMS
|
|
||||||
================== =====================
|
|
||||||
s07 Tasks [8] s09 Agent Teams [9]
|
|
||||||
file-based CRUD + deps graph teammates + JSONL mailboxes
|
|
||||||
| |
|
|
||||||
s08 Background Tasks [6] s10 Team Protocols [12]
|
|
||||||
virtual threads + notify queue shutdown + plan approval FSM
|
|
||||||
|
|
|
||||||
s11 Autonomous Agents [14]
|
|
||||||
idle cycle + auto-claim
|
|
||||||
|
|
|
||||||
s12 Worktree Isolation [16]
|
|
||||||
task coordination + on-demand isolated execution lanes
|
|
||||||
|
|
||||||
[N] = number of tools
|
|
||||||
```
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
learn-claude-code/
|
|
||||||
|
|
|
||||||
|-- src/main/java/io/mybatis/learn/ # Java implementation (Spring AI + Spring Boot)
|
|
||||||
| |-- core/ # shared utilities (AgentRunner, BashTool, EditFileTool, etc.)
|
|
||||||
| |-- s01/ S01AgentLoop.java # session 01: Agent Loop
|
|
||||||
| |-- s02/ S02ToolUse.java # session 02: Multi-Tool Registration
|
|
||||||
| |-- s03/ S03TodoWrite.java # session 03: Plan-Driven Execution
|
|
||||||
| |-- s04/ S04Subagent.java # session 04: Subagents
|
|
||||||
| |-- s05/ S05SkillLoading.java # session 05: Skill Loading
|
|
||||||
| |-- s06/ S06ContextCompact.java # session 06: Context Compression
|
|
||||||
| |-- s07/ S07TaskSystem.java # session 07: Task System
|
|
||||||
| |-- s08/ S08BackgroundTasks.java # session 08: Background Tasks
|
|
||||||
| |-- s09/ S09AgentTeams.java # session 09: Agent Teams
|
|
||||||
| |-- s10/ S10TeamProtocols.java # session 10: Team Protocols
|
|
||||||
| |-- s11/ S11AutonomousAgents.java# session 11: Autonomous Agents
|
|
||||||
| |-- s12/ S12WorktreeIsolation.java# session 12: Worktree Isolation
|
|
||||||
| +-- full/ SFullAgent.java # capstone: all mechanisms combined
|
|
||||||
|
|
|
||||||
|-- agents/ # Python reference implementations (original version, kept for comparison)
|
|
||||||
|-- docs/{en,zh,ja}/ # Mental-model-first documentation (3 languages)
|
|
||||||
|-- web/ # Interactive learning platform (Next.js)
|
|
||||||
|-- skills/ # Skill files for s05
|
|
||||||
|-- pom.xml # Maven build config (Spring Boot 3.5.7 + Spring AI 1.0.3)
|
|
||||||
+-- .github/workflows/ci.yml # CI: typecheck + build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
Mental-model-first: problem, solution, ASCII diagram, minimal code.
|
|
||||||
Available in [English](./docs/en/) | [中文](./docs/zh/) | [日本語](./docs/ja/).
|
|
||||||
|
|
||||||
| Session | Topic | Motto |
|
|
||||||
|---------|-------|-------|
|
|
||||||
| [s01](./docs/en/s01-the-agent-loop.md) | Agent Loop | *One loop & Bash is all you need* |
|
|
||||||
| [s02](./docs/en/s02-tool-use.md) | Tool Use | *Adding a tool means adding one handler* |
|
|
||||||
| [s03](./docs/en/s03-todo-write.md) | TodoWrite | *An agent without a plan drifts* |
|
|
||||||
| [s04](./docs/en/s04-subagent.md) | Subagents | *Break big tasks down; each subtask gets a clean context* |
|
|
||||||
| [s05](./docs/en/s05-skill-loading.md) | Skills | *Load knowledge when you need it, not upfront* |
|
|
||||||
| [s06](./docs/en/s06-context-compact.md) | Context Compact | *Context will fill up; you need a way to make room* |
|
|
||||||
| [s07](./docs/en/s07-task-system.md) | Task System | *Break big goals into small tasks, order them, persist to disk* |
|
|
||||||
| [s08](./docs/en/s08-background-tasks.md) | Background Tasks | *Run slow operations in the background; the agent keeps thinking* |
|
|
||||||
| [s09](./docs/en/s09-agent-teams.md) | Agent Teams | *When the task is too big for one, delegate to teammates* |
|
|
||||||
| [s10](./docs/en/s10-team-protocols.md) | Team Protocols | *Teammates need shared communication rules* |
|
|
||||||
| [s11](./docs/en/s11-autonomous-agents.md) | Autonomous Agents | *Teammates scan the board and claim tasks themselves* |
|
|
||||||
| [s12](./docs/en/s12-worktree-task-isolation.md) | Worktree + Task Isolation | *Each works in its own directory, no interference* |
|
|
||||||
|
|
||||||
## What's Next -- from understanding to shipping
|
|
||||||
|
|
||||||
After the 12 sessions you understand how harness engineering works inside out. Two ways to put that knowledge to work:
|
|
||||||
|
|
||||||
### Kode Agent CLI -- Open-Source Coding Agent CLI
|
|
||||||
|
|
||||||
> `npm i -g @shareai-lab/kode`
|
|
||||||
|
|
||||||
Skill & LSP support, Windows-ready, pluggable 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 App
|
|
||||||
|
|
||||||
The official Claude Code Agent SDK communicates with a full CLI process under the hood -- each concurrent user means a separate terminal process. Kode SDK is a standalone library with no per-user process overhead, embeddable in backends, browser extensions, embedded devices, or any runtime.
|
|
||||||
|
|
||||||
GitHub: **[shareAI-lab/Kode-agent-sdk](https://github.com/shareAI-lab/Kode-agent-sdk)**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Sister Repo: from *on-demand sessions* to *always-on assistant*
|
|
||||||
|
|
||||||
The harness this repo teaches is **use-and-discard** -- open a terminal, give the agent a task, close when done, next session starts blank. That is the Claude Code model.
|
|
||||||
|
|
||||||
[OpenClaw](https://github.com/openclaw/openclaw) proved another possibility: on top of the same agent core, two harness mechanisms turn the agent from "poke it to make it move" into "it wakes up every 30 seconds to look for work":
|
|
||||||
|
|
||||||
- **Heartbeat** -- every 30s the harness sends the agent a message to check if there is anything to do. Nothing? Go back to sleep. Something? Act immediately.
|
|
||||||
- **Cron** -- the agent can schedule its own future tasks, executed automatically when the time comes.
|
|
||||||
|
|
||||||
Add multi-channel IM routing (WhatsApp / Telegram / Slack / Discord, 13+ platforms), persistent context memory, and a Soul personality system, and the agent goes from a disposable tool to an always-on personal AI assistant.
|
|
||||||
|
|
||||||
**[claw0](https://github.com/shareAI-lab/claw0)** is our companion teaching repo that deconstructs these harness mechanisms from scratch:
|
|
||||||
|
|
||||||
```
|
|
||||||
claw agent = agent core + heartbeat + cron + IM chat + memory + soul
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
learn-claude-code claw0
|
|
||||||
(agent harness core: (proactive always-on harness:
|
|
||||||
loop, tools, planning, heartbeat, cron, IM channels,
|
|
||||||
teams, worktree isolation) memory, soul personality)
|
|
||||||
```
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**The model is the agent. The code is the harness. Build great harnesses. The agent will do the rest.**
|
|
||||||
|
|
||||||
**Bash is all you need. Real agents are all the universe needs.**
|
|
||||||
-438
@@ -1,438 +0,0 @@
|
|||||||
# Learn Claude Code -- 真の Agent のための Harness Engineering
|
|
||||||
|
|
||||||
[English](./README-en.md) | [中文](./README.md) | [日本語](./README-ja.md)
|
|
||||||
|
|
||||||
## モデルこそが Agent である
|
|
||||||
|
|
||||||
コードの話をする前に、一つだけ明確にしておく。
|
|
||||||
|
|
||||||
**Agent とはモデルのことだ。フレームワークではない。プロンプトチェーンではない。ドラッグ&ドロップのワークフローではない。**
|
|
||||||
|
|
||||||
### Agent とは何か
|
|
||||||
|
|
||||||
Agent とはニューラルネットワークである -- Transformer、RNN、学習された関数 -- 数十億回の勾配更新を経て、行動系列データの上で環境を知覚し、目標を推論し、行動を起こすことを学んだもの。AI における "Agent" という言葉は、始まりからずっとこの意味だった。常に。
|
|
||||||
|
|
||||||
人間も Agent だ。数百万年の進化的訓練によって形作られた生物的ニューラルネットワーク。感覚で世界を知覚し、脳で推論し、身体で行動する。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 と同一:訓練されたモデルが環境に配置され、知覚と行動のツールを与えられる。唯一の違いは、学んだものの規模と解くタスクの汎用性。
|
|
||||||
|
|
||||||
すべてのマイルストーンが同じ真理を共有している:**"Agent" は決して周囲のコードではない。Agent は常にモデルそのものだ。**
|
|
||||||
|
|
||||||
### Agent ではないもの
|
|
||||||
|
|
||||||
"Agent" という言葉は、プロンプト配管工の産業全体に乗っ取られてしまった。
|
|
||||||
|
|
||||||
ドラッグ&ドロップのワークフロービルダー。ノーコード "AI Agent" プラットフォーム。プロンプトチェーン・オーケストレーションライブラリ。すべて同じ幻想を共有している:LLM API 呼び出しを if-else 分岐、ノードグラフ、ハードコードされたルーティングロジックで繋ぎ合わせることが "Agent の構築" だと。
|
|
||||||
|
|
||||||
違う。彼らが作ったものはルーブ・ゴールドバーグ・マシンだ -- 過剰に設計された脆い手続き的ルールのパイプライン。LLM は美化されたテキスト補完ノードとして押し込まれているだけ。それは Agent ではない。壮大な妄想を持つシェルスクリプトだ。
|
|
||||||
|
|
||||||
**プロンプト配管工式 "Agent" は、モデルを訓練しないプログラマーの妄想だ。** 手続き的ロジックを積み重ねて知能を力技で再現しようとする -- 巨大なルールツリー、ノードグラフ、チェーン・プロンプトの滝 -- そして十分なグルーコードがいつか自律的振る舞いを創発すると祈る。しない。工学的手段で Agency をコーディングすることはできない。Agency は学習されるものであって、プログラムされるものではない。
|
|
||||||
|
|
||||||
あのシステムたちは生まれた瞬間から死んでいる:脆弱で、スケールせず、汎化が根本的に不可能。GOFAI(Good 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 にドメイン専門性を与える。製品ドキュメント、アーキテクチャ決定記録、スタイルガイド、規制要件。オンデマンドで読み込み(s05)、前もって詰め込まない。Agent は何が利用可能か知った上で、必要なものを自ら取得すべき。
|
|
||||||
|
|
||||||
- **コンテキストの管理。** Agent にクリーンな記憶を与える。サブ Agent 隔離(s04)がノイズの漏洩を防ぐ。コンテキスト圧縮(s06)が履歴の氾濫を防ぐ。タスクシステム(s07)が目標を単一の会話を超えて永続化する。
|
|
||||||
|
|
||||||
- **権限の制御。** 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-s12)は Claude Code アーキテクチャから一つの Harness メカニズムをリバースエンジニアリングする。終了時には、Claude Code の仕組みだけでなく、あらゆるドメインのあらゆる Agent に適用される Harness 工学の普遍的原則を理解している。
|
|
||||||
|
|
||||||
教訓は「Claude Code をコピーせよ」ではない。教訓は:**最高の Agent プロダクトは、自分の仕事が Harness であって Intelligence ではないと理解しているエンジニアが作る。**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ビジョン:宇宙を本物の Agent で満たす
|
|
||||||
|
|
||||||
これはコーディング Agent だけの話ではない。
|
|
||||||
|
|
||||||
人間が複雑で多段階の判断集約的な仕事をしているすべてのドメインは、Agent が稼働できるドメインだ -- 正しい Harness さえあれば。このリポジトリのパターンは普遍的だ:
|
|
||||||
|
|
||||||
```
|
|
||||||
不動産管理 Agent = モデル + 物件センサー + メンテナンスツール + テナント通信
|
|
||||||
農業 Agent = モデル + 土壌/気象データ + 灌漑制御 + 作物知識
|
|
||||||
ホテル運営 Agent = モデル + 予約システム + ゲストチャネル + 施設 API
|
|
||||||
医学研究 Agent = モデル + 文献検索 + 実験機器 + プロトコル文書
|
|
||||||
製造 Agent = モデル + 生産ラインセンサー + 品質管理 + 物流
|
|
||||||
教育 Agent = モデル + カリキュラム知識 + 学生進捗 + 評価ツール
|
|
||||||
```
|
|
||||||
|
|
||||||
ループは常に同じ。ツールが変わる。知識が変わる。権限が変わる。Agent -- モデル -- がすべてを汎化する。
|
|
||||||
|
|
||||||
このリポジトリを読むすべての Harness エンジニアは、ソフトウェアエンジニアリングを遥かに超えたパターンを学んでいる。知的で自動化された未来のためのインフラストラクチャを構築することを学んでいる。実ドメインにデプロイされた優れた Harness の一つ一つが、Agent が知覚し、推論し、行動できる新たな拠点。
|
|
||||||
|
|
||||||
まずワークショップを満たす。次に農場、病院、工場。次に都市。次に惑星。
|
|
||||||
|
|
||||||
**Bash is all you need. Real agents are all the universe needs.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
```
|
|
||||||
THE AGENT PATTERN
|
|
||||||
=================
|
|
||||||
|
|
||||||
User --> messages[] --> LLM --> response
|
|
||||||
|
|
|
||||||
ツール呼び出しリクエストあり?
|
|
||||||
/ \
|
|
||||||
あり なし
|
|
||||||
| |
|
|
||||||
@Tool メソッドを実行 テキストを返す
|
|
||||||
結果を返却
|
|
||||||
ループ継続 -----------------> messages[]
|
|
||||||
|
|
||||||
|
|
||||||
最小ループ。すべての AI エージェントにこのループが必要。
|
|
||||||
モデルがツール呼び出しと停止を決める。
|
|
||||||
Spring AI の ChatClient.call() がこのループを自動駆動する。
|
|
||||||
本リポジトリはこのループを囲むすべて --
|
|
||||||
エージェントを特定ドメインで効果的にする Harness -- の作り方を教える。
|
|
||||||
```
|
|
||||||
|
|
||||||
**12 の段階的セッション、シンプルなループから分離された自律実行まで。**
|
|
||||||
**各セッションは 1 つの Harness メカニズムを追加する。各メカニズムには 1 つのモットーがある。**
|
|
||||||
|
|
||||||
> **s01** *"One loop & Bash is all you need"* — 1つのツール + 1つのループ = エージェント
|
|
||||||
>
|
|
||||||
> **s02** *"ツールを足すなら、ハンドラーを1つ足すだけ"* — ループは変わらない。新ツールは `@Tool` アノテーション + `defaultTools()` で登録するだけ
|
|
||||||
>
|
|
||||||
> **s03** *"計画のないエージェントは行き当たりばったり"* — まずステップを書き出し、それから実行
|
|
||||||
>
|
|
||||||
> **s04** *"大きなタスクを分割し、各サブタスクにクリーンなコンテキストを"* — サブエージェントは独立した messages[] を使い、メイン会話を汚さない
|
|
||||||
>
|
|
||||||
> **s05** *"必要な知識を、必要な時に読み込む"* — system prompt ではなく tool_result で注入
|
|
||||||
>
|
|
||||||
> **s06** *"コンテキストはいつか溢れる、空ける手段が要る"* — 3層圧縮で無限セッションを実現
|
|
||||||
>
|
|
||||||
> **s07** *"大きな目標を小タスクに分解し、順序付けし、ディスクに記録する"* — ファイルベースのタスクグラフ、マルチエージェント協調の基盤
|
|
||||||
>
|
|
||||||
> **s08** *"遅い操作はバックグラウンドへ、エージェントは次を考え続ける"* — デーモンスレッドがコマンド実行、完了後に通知を注入
|
|
||||||
>
|
|
||||||
> **s09** *"一人で終わらないなら、チームメイトに任せる"* — 永続チームメイト + 非同期メールボックス
|
|
||||||
>
|
|
||||||
> **s10** *"チームメイト間には統一の通信ルールが必要"* — 1つの request-response パターンが全交渉を駆動
|
|
||||||
>
|
|
||||||
> **s11** *"チームメイトが自らボードを見て、仕事を取る"* — リーダーが逐一割り振る必要はない
|
|
||||||
>
|
|
||||||
> **s12** *"各自のディレクトリで作業し、互いに干渉しない"* — タスクは目標を管理、worktree はディレクトリを管理、IDで紐付け
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## コアパターン
|
|
||||||
|
|
||||||
```java
|
|
||||||
// Spring AI の ChatClient + @Tool 注解实现 Agent 循环
|
|
||||||
// 模型自动决定何时调用工具、何时返回文本 -- 循环由框架驱动
|
|
||||||
|
|
||||||
@SpringBootApplication
|
|
||||||
public class S01AgentLoop implements CommandLineRunner {
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
public CommandLineRunner agentLoop(ChatClient.Builder builder) {
|
|
||||||
ChatClient chatClient = builder
|
|
||||||
.defaultSystem("You are a helpful assistant with access to tools.")
|
|
||||||
.defaultTools(new BashTool()) // 注册工具
|
|
||||||
.build();
|
|
||||||
|
|
||||||
return args -> {
|
|
||||||
// 一次 call() 内部自动完成: 调用模型 → 检测工具请求 → 执行工具 → 回传结果 → 再次调用模型...
|
|
||||||
String result = chatClient.prompt()
|
|
||||||
.user(userInput)
|
|
||||||
.call()
|
|
||||||
.content();
|
|
||||||
System.out.println(result);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// @Tool 注解让方法自动成为模型可调用的工具
|
|
||||||
public class BashTool {
|
|
||||||
@Tool(description = "Execute a shell command and return stdout/stderr")
|
|
||||||
public String executeBash(String command) {
|
|
||||||
// 执行命令并返回结果
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Spring AI の `ChatClient.call()` は完全なエージェントループを内部にカプセル化:モデル呼び出し → ツール呼び出しリクエスト検出 → `@Tool` メソッド実行 → 結果をモデルに返却 → モデルがテキストを返すまで繰り返し。各セッションはこのループの上に 1 つの Harness メカニズムを重ねる -- ループ自体は変わらない。ループはエージェントのもの。メカニズムは Harness のもの。
|
|
||||||
|
|
||||||
## 範囲説明 (重要)
|
|
||||||
|
|
||||||
このリポジトリは Harness 工学の 0->1 学習プロジェクト -- Agent モデルを囲む環境の構築を学ぶ。
|
|
||||||
学習を優先するため、以下の本番メカニズムは意図的に簡略化または省略している:
|
|
||||||
|
|
||||||
- 完全なイベント / Hook バス (例: PreToolUse, SessionStart/End, ConfigChange)。
|
|
||||||
s12 では教材用に最小の追記型ライフサイクルイベントのみ実装。
|
|
||||||
- ルールベースの権限ガバナンスと信頼フロー
|
|
||||||
- セッションライフサイクル制御 (resume/fork) と高度な worktree ライフサイクル制御
|
|
||||||
- MCP ランタイムの詳細 (transport/OAuth/リソース購読/ポーリング)
|
|
||||||
|
|
||||||
このリポジトリの JSONL メールボックス方式は教材用の実装であり、特定の本番内部実装を主張するものではない。
|
|
||||||
|
|
||||||
## クイックスタート
|
|
||||||
|
|
||||||
### 環境要件
|
|
||||||
|
|
||||||
- **JDK 21+** (推奨 [Eclipse Temurin](https://adoptium.net/) または GraalVM)
|
|
||||||
- **Maven 3.9+**
|
|
||||||
- OpenAI プロトコル互換の LLM API Key (DeepSeek、智谱 GLM、通義千問、OpenAI 等)
|
|
||||||
|
|
||||||
### クローンとビルド
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git clone https://github.com/abel533/claude-code
|
|
||||||
cd learn-claude-code
|
|
||||||
mvn compile # 编译项目
|
|
||||||
```
|
|
||||||
|
|
||||||
### 環境変数の設定
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# Linux / macOS
|
|
||||||
export AI_API_KEY=your-api-key
|
|
||||||
export AI_BASE_URL=https://api.deepseek.com # 替换为你的模型服务商地址
|
|
||||||
export AI_MODEL=deepseek-chat # 替换为你使用的模型名称
|
|
||||||
|
|
||||||
# Windows PowerShell
|
|
||||||
$env:AI_API_KEY="your-api-key"
|
|
||||||
$env:AI_BASE_URL="https://api.deepseek.com"
|
|
||||||
$env:AI_MODEL="deepseek-chat"
|
|
||||||
```
|
|
||||||
|
|
||||||
### セッションの実行
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# 从第一课开始
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s01.S01AgentLoop
|
|
||||||
|
|
||||||
# 完整递进终点
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s12.S12WorktreeIsolation
|
|
||||||
|
|
||||||
# 总纲: 全部机制合一
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.full.SFullAgent
|
|
||||||
```
|
|
||||||
|
|
||||||
### Web プラットフォーム
|
|
||||||
|
|
||||||
インタラクティブな可視化、ステップスルーアニメーション、ソースビューア、各セッションのドキュメント。
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd web && npm install && npm run dev # http://localhost:3000
|
|
||||||
```
|
|
||||||
|
|
||||||
### Java 版の特色
|
|
||||||
|
|
||||||
本プロジェクトは **Java 21 + Spring Boot 3.5.7 + Spring AI 1.0.3** 技術スタックを採用しており、オリジナルの Python 版に比べ以下の特色がある:
|
|
||||||
|
|
||||||
- **複数のLLMプロバイダーに対応** -- OpenAI プロトコルを通じて DeepSeek、智谱 GLM、通義千問、Moonshot 等の国産モデルに対応、特定ベンダーに縛られない
|
|
||||||
- **`@Tool` アノテーションによるツール呼び出しループの自動処理** -- Spring AI フレームワークが「モデル呼び出し → ツール実行 → 結果返却」の完全なループを自動処理、while ループの手書き不要
|
|
||||||
- **Java 21 仮想スレッド** -- 軽量な並行処理でバックグラウンドタスクとマルチエージェント協調を実現、スレッドプール管理のオーバーヘッド不要
|
|
||||||
- **各セッション独立実行可能** -- 各セッションは `@SpringBootApplication` + `CommandLineRunner` であり、`mvn exec:java` 一行で起動可能
|
|
||||||
- **型安全** -- Java の強い型システムがコンパイル時にエラーを検出、IDE の自動補完にも優しい
|
|
||||||
|
|
||||||
## 学習パス
|
|
||||||
|
|
||||||
```
|
|
||||||
フェーズ1: ループ フェーズ2: 計画と知識
|
|
||||||
================== ==============================
|
|
||||||
s01 エージェントループ [1] s03 TodoWrite [5]
|
|
||||||
ChatClient + @Tool TodoManager + nag リマインダー
|
|
||||||
| |
|
|
||||||
+-> s02 Tool Use [4] s04 サブエージェント [5]
|
|
||||||
@Tool で複数ツール登録 各サブエージェントに独立 ChatClient
|
|
||||||
|
|
|
||||||
s05 Skills [5]
|
|
||||||
SKILL.md を tool_result で注入
|
|
||||||
|
|
|
||||||
s06 Context Compact [5]
|
|
||||||
3層コンテキスト圧縮
|
|
||||||
|
|
||||||
フェーズ3: 永続化 フェーズ4: チーム
|
|
||||||
================== =====================
|
|
||||||
s07 タスクシステム [8] s09 エージェントチーム [9]
|
|
||||||
ファイルベース CRUD + 依存グラフ チームメイト + JSONL メールボックス
|
|
||||||
| |
|
|
||||||
s08 バックグラウンドタスク [6] s10 チームプロトコル [12]
|
|
||||||
仮想スレッド + 通知キュー シャットダウン + プラン承認 FSM
|
|
||||||
|
|
|
||||||
s11 自律エージェント [14]
|
|
||||||
アイドルサイクル + 自動クレーム
|
|
||||||
|
|
|
||||||
s12 Worktree 分離 [16]
|
|
||||||
タスク調整 + 必要時の分離実行レーン
|
|
||||||
|
|
||||||
[N] = ツール数
|
|
||||||
```
|
|
||||||
|
|
||||||
## プロジェクト構成
|
|
||||||
|
|
||||||
```
|
|
||||||
learn-claude-code/
|
|
||||||
|
|
|
||||||
|-- src/main/java/io/mybatis/learn/ # Java 実装 (Spring AI + Spring Boot)
|
|
||||||
| |-- core/ # 共通ツールクラス (AgentRunner, BashTool, EditFileTool 等)
|
|
||||||
| |-- s01/ S01AgentLoop.java # セッション 01: エージェントループ
|
|
||||||
| |-- s02/ S02ToolUse.java # セッション 02: マルチツール登録
|
|
||||||
| |-- s03/ S03TodoWrite.java # セッション 03: 計画駆動
|
|
||||||
| |-- s04/ S04Subagent.java # セッション 04: サブエージェント
|
|
||||||
| |-- s05/ S05SkillLoading.java # セッション 05: Skill ロード
|
|
||||||
| |-- s06/ S06ContextCompact.java # セッション 06: コンテキスト圧縮
|
|
||||||
| |-- s07/ S07TaskSystem.java # セッション 07: タスクシステム
|
|
||||||
| |-- s08/ S08BackgroundTasks.java # セッション 08: バックグラウンドタスク
|
|
||||||
| |-- s09/ S09AgentTeams.java # セッション 09: エージェントチーム
|
|
||||||
| |-- s10/ S10TeamProtocols.java # セッション 10: チームプロトコル
|
|
||||||
| |-- s11/ S11AutonomousAgents.java# セッション 11: 自律エージェント
|
|
||||||
| |-- s12/ S12WorktreeIsolation.java# セッション 12: Worktree 分離
|
|
||||||
| +-- full/ SFullAgent.java # 総括: 全メカニズム統合
|
|
||||||
|
|
|
||||||
|-- agents/ # Python リファレンス実装 (オリジナル版、対照用に保存)
|
|
||||||
|-- docs/{en,zh,ja}/ # メンタルモデル優先のドキュメント (3言語)
|
|
||||||
|-- web/ # インタラクティブ学習プラットフォーム (Next.js)
|
|
||||||
|-- skills/ # s05 の Skill ファイル
|
|
||||||
|-- pom.xml # Maven ビルド設定 (Spring Boot 3.5.7 + Spring AI 1.0.3)
|
|
||||||
+-- .github/workflows/ci.yml # CI: 型チェック + ビルド
|
|
||||||
```
|
|
||||||
|
|
||||||
## ドキュメント
|
|
||||||
|
|
||||||
メンタルモデル優先: 問題、解決策、ASCII図、最小限のコード。
|
|
||||||
[English](./docs/en/) | [中文](./docs/zh/) | [日本語](./docs/ja/)
|
|
||||||
|
|
||||||
| セッション | トピック | モットー |
|
|
||||||
|-----------|---------|---------|
|
|
||||||
| [s01](./docs/ja/s01-the-agent-loop.md) | エージェントループ | *One loop & Bash is all you need* |
|
|
||||||
| [s02](./docs/ja/s02-tool-use.md) | Tool Use | *ツールを足すなら、ハンドラーを1つ足すだけ* |
|
|
||||||
| [s03](./docs/ja/s03-todo-write.md) | TodoWrite | *計画のないエージェントは行き当たりばったり* |
|
|
||||||
| [s04](./docs/ja/s04-subagent.md) | サブエージェント | *大きなタスクを分割し、各サブタスクにクリーンなコンテキストを* |
|
|
||||||
| [s05](./docs/ja/s05-skill-loading.md) | Skills | *必要な知識を、必要な時に読み込む* |
|
|
||||||
| [s06](./docs/ja/s06-context-compact.md) | Context Compact | *コンテキストはいつか溢れる、空ける手段が要る* |
|
|
||||||
| [s07](./docs/ja/s07-task-system.md) | タスクシステム | *大きな目標を小タスクに分解し、順序付けし、ディスクに記録する* |
|
|
||||||
| [s08](./docs/ja/s08-background-tasks.md) | バックグラウンドタスク | *遅い操作はバックグラウンドへ、エージェントは次を考え続ける* |
|
|
||||||
| [s09](./docs/ja/s09-agent-teams.md) | エージェントチーム | *一人で終わらないなら、チームメイトに任せる* |
|
|
||||||
| [s10](./docs/ja/s10-team-protocols.md) | チームプロトコル | *チームメイト間には統一の通信ルールが必要* |
|
|
||||||
| [s11](./docs/ja/s11-autonomous-agents.md) | 自律エージェント | *チームメイトが自らボードを見て、仕事を取る* |
|
|
||||||
| [s12](./docs/ja/s12-worktree-task-isolation.md) | Worktree + タスク分離 | *各自のディレクトリで作業し、互いに干渉しない* |
|
|
||||||
|
|
||||||
## 次のステップ -- 理解から出荷へ
|
|
||||||
|
|
||||||
12 セッションを終えれば、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
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**モデルが Agent だ。コードは Harness だ。優れた Harness を作れ。Agent が残りをやる。**
|
|
||||||
|
|
||||||
**Bash is all you need. Real agents are all the universe needs.**
|
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
# Release Notes
|
||||||
|
|
||||||
|
## v0.1.0 — 首个正式版本 🎉
|
||||||
|
|
||||||
|
> **发布日期**: 2025-07
|
||||||
|
|
||||||
|
### 概述
|
||||||
|
|
||||||
|
Claude Code Java 是使用 Java + Spring AI 重写的 [Claude Code](https://github.com/anthropics/claude-code) CLI AI 编码助手。本次发布是首个正式版本,涵盖了原版约 **90%** 的本地可实现功能。
|
||||||
|
|
||||||
|
### 技术栈
|
||||||
|
|
||||||
|
| 组件 | 版本 |
|
||||||
|
|------|------|
|
||||||
|
| JDK | 25 |
|
||||||
|
| Spring Boot | 4.1.0-M2 |
|
||||||
|
| Spring AI | 2.0.0-M4 |
|
||||||
|
| Jink (TUI) | 0.5.0 |
|
||||||
|
| JLine 3 | 3.28.0 |
|
||||||
|
|
||||||
|
### 核心特性
|
||||||
|
|
||||||
|
#### 🤖 AI 对话
|
||||||
|
- 流式输出,逐 token 实时显示
|
||||||
|
- 双 API 提供者:OpenAI 兼容 API + Anthropic 原生 API
|
||||||
|
- Thinking 模式支持(展示 AI 思考过程)
|
||||||
|
- 三层上下文自动压缩(微压缩 → Session Memory → 全量压缩)
|
||||||
|
|
||||||
|
#### 🔧 34 个内置工具
|
||||||
|
- **文件操作**: FileRead(含图片支持)、FileWrite、FileEdit(带 diff 输出)
|
||||||
|
- **搜索**: Glob、Grep、ListFiles
|
||||||
|
- **执行**: Bash(沙箱模式)、WebFetch、WebSearch
|
||||||
|
- **任务管理**: TaskCreate/Get/List/Update、TaskStop、TaskOutput
|
||||||
|
- **Agent**: Agent(子 Agent)、AskUser(结构化提问)
|
||||||
|
- **MCP**: McpToolBridge(远程工具桥接)
|
||||||
|
- **扩展**: Config(持久化配置)、NotebookEdit、Sleep
|
||||||
|
- **诊断**: LSPTool、BriefTool、NotificationTool、ToolSearch
|
||||||
|
|
||||||
|
#### ⌨️ 54 个斜杠命令
|
||||||
|
- **基础**: /help(带搜索)、/clear、/compact(统计+激进模式)、/cost、/model、/status
|
||||||
|
- **文件**: /diff、/files、/commit(push/PR 支持)
|
||||||
|
- **记忆**: /memory、/skills、/init
|
||||||
|
- **对话**: /resume、/export、/history、/branch、/rewind、/tag
|
||||||
|
- **审查**: /review、/security-review
|
||||||
|
- **MCP/插件**: /mcp、/plugin、/permissions、/tasks
|
||||||
|
- **UX**: /brief、/vim、/theme、/usage、/tips、/output-style、/keybindings
|
||||||
|
- **隐私**: /privacy、/feedback
|
||||||
|
- **调试**: /debug、/trace、/heapdump、/ctx-viz、/sandbox、/reset-limits、/env、/performance
|
||||||
|
|
||||||
|
#### 🖥️ 全屏 TUI(Jink 框架)
|
||||||
|
- 基于 [Jink](https://github.com/abel533/jink) 的 React-like 终端 UI
|
||||||
|
- 全屏渲染(alternate screen buffer)
|
||||||
|
- 消息列表、状态栏、输入区分离布局
|
||||||
|
- 鼠标追踪支持
|
||||||
|
|
||||||
|
#### 🔒 多级权限管理
|
||||||
|
- 5 种权限模式:DEFAULT / ACCEPT_EDITS / BYPASS / DONT_ASK / PLAN
|
||||||
|
- 规则引擎自动评估(7 步评估链)
|
||||||
|
- 30+ 危险命令模式检测
|
||||||
|
- Y/A/N/D 四选项确认 UI
|
||||||
|
- 拒绝追踪(连续 3 次 / 累计 20 次自动降级)
|
||||||
|
|
||||||
|
#### 📋 CLAUDE.md 记忆系统
|
||||||
|
- 三级加载:全局 → 项目 → 目录
|
||||||
|
- Session Memory 持久化
|
||||||
|
- Skills 技能系统(全局 + 项目)
|
||||||
|
|
||||||
|
#### 🔌 扩展能力
|
||||||
|
- **MCP 协议**: StdIO 传输、多服务器管理、工具发现与桥接
|
||||||
|
- **插件系统**: JAR 插件(ClassLoader 隔离)、Marketplace、自动更新
|
||||||
|
- **Hook 系统**: PreToolUse / PostToolUse / PrePrompt / PostResponse
|
||||||
|
- **Plan 模式**: 结构化规划,只读操作允许
|
||||||
|
- **Coordinator**: 多 Agent 协调模式
|
||||||
|
|
||||||
|
#### 🏗️ 基础设施
|
||||||
|
- WebSocket Server Mode(远程连接)
|
||||||
|
- Git Worktree 隔离
|
||||||
|
- LSP 集成(语言服务器协议客户端)
|
||||||
|
- 基础遥测(功能开关 + 指标收集)
|
||||||
|
- RateLimiter、TokenEstimation、通知服务
|
||||||
|
- 内部日志系统(会话日志 + 调试日志 + 导出)
|
||||||
|
|
||||||
|
### 代码质量
|
||||||
|
|
||||||
|
- **184 源文件**(176 main + 8 test)
|
||||||
|
- **~26.7K 行** Java 代码
|
||||||
|
- **87 单元测试**(全部通过)
|
||||||
|
- 代码结构重构:提取 7 个共享组件,消除 ~560 行重复代码
|
||||||
|
|
||||||
|
### 构建与运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建
|
||||||
|
mvn clean package -DskipTests
|
||||||
|
|
||||||
|
# 运行
|
||||||
|
java -jar target/claude-code-java-0.1.0.jar
|
||||||
|
|
||||||
|
# 或构建发行版(含 jlink 精简 JRE)
|
||||||
|
.\packaging\build-dist.ps1 # Windows
|
||||||
|
./packaging/build-dist.sh # Linux/macOS
|
||||||
|
```
|
||||||
|
|
||||||
|
### 已知限制
|
||||||
|
|
||||||
|
- 不支持 Anthropic OAuth 登录(login/logout)
|
||||||
|
- 不支持 Bridge 远程环境
|
||||||
|
- 不支持 Voice 语音输入
|
||||||
|
- Windows 终端需 `chcp 65001` 切换 UTF-8 编码(发行版启动脚本已自动处理)
|
||||||
|
- IDE 内置终端(dumb 模式)TUI 功能受限
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*完整文档请参阅 [README.md](README.md) 和 [BUILD.md](BUILD.md)*
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
#!/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
|
|
||||||
|
|
||||||
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)"
|
|
||||||
|
|
||||||
|
|
||||||
# -- 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()
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
#!/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, 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}: {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()
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
#!/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}: {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.insert(0, {"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()
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
#!/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)"
|
|
||||||
|
|
||||||
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")
|
|
||||||
print(f"> task ({desc}): {block.input['prompt'][:80]}")
|
|
||||||
output = run_subagent(block.input["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()
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
#!/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
|
|
||||||
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
|
|
||||||
meta = {}
|
|
||||||
for line in match.group(1).strip().splitlines():
|
|
||||||
if ":" in line:
|
|
||||||
key, val = line.split(":", 1)
|
|
||||||
meta[key.strip()] = val.strip()
|
|
||||||
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}: {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()
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
#!/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 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
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
to_clear = tool_results[:-KEEP_RECENT]
|
|
||||||
for _, _, result in to_clear:
|
|
||||||
if isinstance(result.get("content"), str) and len(result["content"]) > 100:
|
|
||||||
tool_id = result.get("tool_use_id", "")
|
|
||||||
tool_name = tool_name_map.get(tool_id, "unknown")
|
|
||||||
result["content"] = f"[Previous: used {tool_name}]"
|
|
||||||
return messages
|
|
||||||
|
|
||||||
|
|
||||||
# -- Layer 2: auto_compact - save transcript, summarize, replace messages --
|
|
||||||
def auto_compact(messages: list) -> 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]
|
|
||||||
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.\n\n" + conversation_text}],
|
|
||||||
max_tokens=2000,
|
|
||||||
)
|
|
||||||
summary = response.content[0].text
|
|
||||||
# Replace all messages with compressed summary
|
|
||||||
return [
|
|
||||||
{"role": "user", "content": f"[Conversation compressed. Transcript: {transcript_path}]\n\n{summary}"},
|
|
||||||
{"role": "assistant", "content": "Understood. I have the context from the summary. Continuing."},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# -- 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
|
|
||||||
for block in response.content:
|
|
||||||
if block.type == "tool_use":
|
|
||||||
if block.name == "compact":
|
|
||||||
manual_compact = True
|
|
||||||
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}: {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)
|
|
||||||
|
|
||||||
|
|
||||||
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()
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
#!/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/blocks).
|
|
||||||
|
|
||||||
.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], "blocks":[], ...}
|
|
||||||
|
|
||||||
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))
|
|
||||||
|
|
||||||
def create(self, subject: str, description: str = "") -> str:
|
|
||||||
task = {
|
|
||||||
"id": self._next_id, "subject": subject, "description": description,
|
|
||||||
"status": "pending", "blockedBy": [], "blocks": [], "owner": "",
|
|
||||||
}
|
|
||||||
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 update(self, task_id: int, status: str = None,
|
|
||||||
add_blocked_by: list = None, add_blocks: 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
|
|
||||||
# When a task is completed, remove it from all other tasks' blockedBy
|
|
||||||
if status == "completed":
|
|
||||||
self._clear_dependency(task_id)
|
|
||||||
if add_blocked_by:
|
|
||||||
task["blockedBy"] = list(set(task["blockedBy"] + add_blocked_by))
|
|
||||||
if add_blocks:
|
|
||||||
task["blocks"] = list(set(task["blocks"] + add_blocks))
|
|
||||||
# Bidirectional: also update the blocked tasks' blockedBy lists
|
|
||||||
for blocked_id in add_blocks:
|
|
||||||
try:
|
|
||||||
blocked = self._load(blocked_id)
|
|
||||||
if task_id not in blocked["blockedBy"]:
|
|
||||||
blocked["blockedBy"].append(task_id)
|
|
||||||
self._save(blocked)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
self._save(task)
|
|
||||||
return json.dumps(task, indent=2)
|
|
||||||
|
|
||||||
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 = []
|
|
||||||
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"], "[?]")
|
|
||||||
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("addBlocks")),
|
|
||||||
"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"}}, "addBlocks": {"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}: {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()
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
#!/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>"})
|
|
||||||
messages.append({"role": "assistant", "content": "Noted 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}: {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()
|
|
||||||
@@ -1,406 +0,0 @@
|
|||||||
#!/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"]
|
|
||||||
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>",
|
|
||||||
})
|
|
||||||
messages.append({
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "Noted inbox 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 = []
|
|
||||||
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}: {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()
|
|
||||||
@@ -1,487 +0,0 @@
|
|||||||
#!/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"]
|
|
||||||
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>",
|
|
||||||
})
|
|
||||||
messages.append({
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "Noted inbox 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 = []
|
|
||||||
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}: {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()
|
|
||||||
@@ -1,579 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
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]
|
|
||||||
claim_task(task["id"], name)
|
|
||||||
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"]
|
|
||||||
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>",
|
|
||||||
})
|
|
||||||
messages.append({
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "Noted inbox 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 = []
|
|
||||||
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}: {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()
|
|
||||||
@@ -1,781 +0,0 @@
|
|||||||
#!/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}: {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()
|
|
||||||
@@ -1,736 +0,0 @@
|
|||||||
#!/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}"},
|
|
||||||
{"role": "assistant", "content": "Understood. Continuing with summary context."},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# === 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": [], "blocks": []}
|
|
||||||
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, add_blocks: 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 add_blocks:
|
|
||||||
task["blocks"] = list(set(task["blocks"] + add_blocks))
|
|
||||||
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', '(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("add_blocks")),
|
|
||||||
"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"}}, "add_blocks": {"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>"})
|
|
||||||
messages.append({"role": "assistant", "content": "Noted 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>"})
|
|
||||||
messages.append({"role": "assistant", "content": "Noted inbox messages."})
|
|
||||||
# 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}: {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.insert(0, {"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)
|
|
||||||
|
|
||||||
|
|
||||||
# === 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)
|
|
||||||
print()
|
|
||||||
@@ -1,318 +0,0 @@
|
|||||||
# 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 |
|
|
||||||
+----------------+
|
|
||||||
(ChatClient.call() auto-loops until no tool calls)
|
|
||||||
```
|
|
||||||
|
|
||||||
A single `call()` invocation controls the entire flow. Spring AI loops automatically until the model stops calling tools.
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
### 1. Build ChatClient: Inject Model + Register Tools
|
|
||||||
|
|
||||||
Inject `ChatModel` via Spring Boot auto-configuration, build the client with `ChatClient.builder()`, set the system prompt and tools.
|
|
||||||
|
|
||||||
```java
|
|
||||||
// TIP: The Python version creates client = Anthropic() and MODEL at module level.
|
|
||||||
// Spring AI injects ChatModel via auto-configuration, then builds ChatClient with builder.
|
|
||||||
public S01AgentLoop(ChatModel chatModel) {
|
|
||||||
this.chatClient = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem("You are a coding agent at " + System.getProperty("user.dir")
|
|
||||||
+ ". Use bash to solve tasks. Act, don't explain.")
|
|
||||||
.defaultTools(new BashTool()) // Tool object with @Tool annotation
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. `@Tool` Annotation: Declarative Tool Registration
|
|
||||||
|
|
||||||
Spring AI automatically discovers and registers tools via the `@Tool` annotation. At startup, the framework scans objects passed to `defaultTools()`, extracts all `@Tool` method signatures and descriptions, generates the tool schema the LLM needs (name, parameters, description), and automatically includes it in every `call()` request.
|
|
||||||
|
|
||||||
```java
|
|
||||||
// BashTool -- corresponds to the Python version's run_bash() function
|
|
||||||
public class BashTool {
|
|
||||||
@Tool(description = "Run a shell command and return stdout + stderr")
|
|
||||||
public String bash(@ToolParam(description = "The shell command to execute")
|
|
||||||
String command) {
|
|
||||||
// Dangerous command check + ProcessBuilder execution + timeout control + output truncation
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> Comparison with Python's manual registration:
|
|
||||||
> - Python: `TOOLS = [{"name": "bash", "input_schema": {...}}]` + `TOOL_HANDLERS = {"bash": run_bash}`
|
|
||||||
> - Java: Just `@Tool` + `@ToolParam` annotations; the framework auto-generates schemas and dispatches methods
|
|
||||||
|
|
||||||
### 3. Spring AI Internal Auto-Loop: How `call()` Works Under the Hood
|
|
||||||
|
|
||||||
**This is the most critical difference between the Java and Python versions.** The Python version requires a hand-written while loop to drive tool calls:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Python version -- manual loop
|
|
||||||
def agent_loop(messages):
|
|
||||||
while True:
|
|
||||||
response = client.messages.create(model=MODEL, messages=messages, tools=TOOLS)
|
|
||||||
# Collect assistant message
|
|
||||||
messages.append({"role": "assistant", "content": response.content})
|
|
||||||
if response.stop_reason != "tool_use":
|
|
||||||
return response # Model no longer calling tools, exit loop
|
|
||||||
# Execute tools and feed back results
|
|
||||||
for block in response.content:
|
|
||||||
if block.type == "tool_use":
|
|
||||||
result = TOOL_HANDLERS[block.name](block.input)
|
|
||||||
messages.append({"role": "user", "content": [{"type": "tool_result", ...}]})
|
|
||||||
```
|
|
||||||
|
|
||||||
Spring AI's `ChatClient.call()` **encapsulates fully equivalent logic internally**:
|
|
||||||
|
|
||||||
```
|
|
||||||
call() internal flow:
|
|
||||||
┌─────────────────────────────────────────────────────┐
|
|
||||||
│ 1. Assemble request: system prompt + user msg + tools │
|
|
||||||
│ 2. Send to LLM │
|
|
||||||
│ 3. Parse response │
|
|
||||||
│ ├── Has tool_use? ──→ Yes: │
|
|
||||||
│ │ a. Extract tool name and arguments │
|
|
||||||
│ │ b. Invoke corresponding @Tool method via reflection │
|
|
||||||
│ │ c. Append tool_result to message list │
|
|
||||||
│ │ d. Go back to step 2 (auto-loop) │
|
|
||||||
│ └── No ──→ Return final text │
|
|
||||||
└─────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
Key points:
|
|
||||||
- **Tool detection**: Spring AI checks if the response contains `tool_use` content blocks (equivalent to Python's `stop_reason == "tool_use"`)
|
|
||||||
- **Reflection dispatch**: The framework uses Java reflection to find and invoke the `@Tool` method matching the tool name returned by the LLM (equivalent to Python's `TOOL_HANDLERS[block.name]`)
|
|
||||||
- **Result feedback**: Tool execution results are automatically wrapped as `tool_result` messages and appended to the conversation (equivalent to Python's manual `tool_result` content block construction)
|
|
||||||
- **Loop termination**: When the model returns pure text (no tool calls), `call()` returns the final result
|
|
||||||
|
|
||||||
Thus, Python's ~15-line while loop is condensed into a single `.call()` in Java.
|
|
||||||
|
|
||||||
### 4. `AgentRunner.interactive()`: The REPL Interaction Loop
|
|
||||||
|
|
||||||
`AgentRunner` is a shared REPL (Read-Eval-Print Loop) utility class used across all lessons, corresponding to the `input()` loop in Python's `if __name__ == "__main__"` block.
|
|
||||||
|
|
||||||
```java
|
|
||||||
public class AgentRunner {
|
|
||||||
/**
|
|
||||||
* Start an interactive REPL loop.
|
|
||||||
* @param prefix Prompt prefix (e.g., "s01")
|
|
||||||
* @param handler Function that processes user input and returns Agent response
|
|
||||||
*/
|
|
||||||
public static void interactive(String prefix, Function<String, String> handler) {
|
|
||||||
Scanner scanner = new Scanner(System.in);
|
|
||||||
System.out.println("Type 'q' or 'exit' to quit");
|
|
||||||
while (true) {
|
|
||||||
System.out.print("\033[36m" + prefix + " >> \033[0m"); // Colored prompt
|
|
||||||
String input;
|
|
||||||
try {
|
|
||||||
if (!scanner.hasNextLine()) break;
|
|
||||||
input = scanner.nextLine().trim();
|
|
||||||
} catch (Exception e) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (input.isEmpty() || "exit".equalsIgnoreCase(input) || "q".equalsIgnoreCase(input)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
String response = handler.apply(input); // Call Agent handler
|
|
||||||
if (response != null && !response.isBlank()) {
|
|
||||||
System.out.println(response);
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.err.println("Error: " + e.getMessage());
|
|
||||||
}
|
|
||||||
System.out.println();
|
|
||||||
}
|
|
||||||
System.out.println("Bye!");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Workflow: `Scanner` reads input → `handler.apply()` sends to Agent → print response → loop. The `handler` is a functional interface; each lesson passes in its own Agent invocation logic.
|
|
||||||
|
|
||||||
### 5. Assembled into a Complete Agent Class
|
|
||||||
|
|
||||||
```java
|
|
||||||
@SpringBootApplication(scanBasePackages = "io.mybatis.learn.core")
|
|
||||||
public class S01AgentLoop implements CommandLineRunner {
|
|
||||||
|
|
||||||
private final ChatClient chatClient;
|
|
||||||
|
|
||||||
public S01AgentLoop(ChatModel chatModel) {
|
|
||||||
this.chatClient = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem("You are a coding agent at ...")
|
|
||||||
.defaultTools(new BashTool())
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void run(String... args) {
|
|
||||||
AgentRunner.interactive("s01", userMessage ->
|
|
||||||
chatClient.prompt()
|
|
||||||
.user(userMessage)
|
|
||||||
.call() // ← This single call = Python's entire while loop
|
|
||||||
.content()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> **TIPS — Key Python → Java Adaptations:**
|
|
||||||
> - Python's `while True` + `stop_reason` manual loop → Spring AI `ChatClient.call()` built-in auto-loop
|
|
||||||
> - Python's `TOOLS` array + `TOOL_HANDLERS` dict → `@Tool` annotation + `defaultTools()` auto-registration with reflection dispatch
|
|
||||||
> - Python's `client = Anthropic()` → Spring Boot auto-configured `ChatModel` injection
|
|
||||||
> - Python's `input()` interaction → `AgentRunner.interactive()` wrapping Scanner REPL + functional interface
|
|
||||||
|
|
||||||
Under 40 lines of core code, and that's the entire agent. The next 11 chapters all layer mechanisms on top of this loop -- the loop itself never changes.
|
|
||||||
|
|
||||||
## What Changed
|
|
||||||
|
|
||||||
| Component | Before | After |
|
|
||||||
|---------------|------------|-------------------------------------------------|
|
|
||||||
| Agent loop | (none) | `ChatClient.call()` built-in tool loop |
|
|
||||||
| Tools | (none) | `BashTool` (single `@Tool` tool) |
|
|
||||||
| Messages | (none) | Managed internally by Spring AI |
|
|
||||||
| Control flow | (none) | Framework auto-detects: returns final text when no tool calls |
|
|
||||||
|
|
||||||
```java
|
|
||||||
// Core code -- build + call
|
|
||||||
ChatClient chatClient = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem("You are a coding agent ...")
|
|
||||||
.defaultTools(new BashTool())
|
|
||||||
.build();
|
|
||||||
|
|
||||||
AgentRunner.interactive("s01", userMessage ->
|
|
||||||
chatClient.prompt().user(userMessage).call().content()
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Try It
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd learn-claude-code
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s01.S01AgentLoop
|
|
||||||
```
|
|
||||||
|
|
||||||
> Set environment variables before running: `AI_API_KEY`, `AI_BASE_URL`, `AI_MODEL`
|
|
||||||
>
|
|
||||||
> **The default protocol is OpenAI** (compatible with all OpenAI API-format services, including OpenAI official, Azure OpenAI, and any third-party model services offering an OpenAI-compatible interface).
|
|
||||||
> To use the Anthropic protocol (Claude native API), expand the section below.
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>Switching AI Protocols (OpenAI ↔ Anthropic)</strong></summary>
|
|
||||||
|
|
||||||
This project switches the underlying protocol via **Spring AI Starter dependency + configuration file**. Java business code (`ChatModel`, `ChatClient`) **requires no changes**.
|
|
||||||
|
|
||||||
#### Option 1: OpenAI Protocol (Default)
|
|
||||||
|
|
||||||
`pom.xml` dependency:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.ai</groupId>
|
|
||||||
<artifactId>spring-ai-starter-model-openai</artifactId>
|
|
||||||
</dependency>
|
|
||||||
```
|
|
||||||
|
|
||||||
`application.yml` configuration:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
spring:
|
|
||||||
ai:
|
|
||||||
openai:
|
|
||||||
api-key: ${AI_API_KEY:sk-xxx}
|
|
||||||
base-url: ${AI_BASE_URL:https://api.openai.com}
|
|
||||||
chat:
|
|
||||||
options:
|
|
||||||
model: ${AI_MODEL:gpt-4o}
|
|
||||||
```
|
|
||||||
|
|
||||||
Environment variable example:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
export AI_API_KEY=sk-proj-xxxxxxxx
|
|
||||||
export AI_BASE_URL=https://api.openai.com # Replace with any OpenAI-compatible endpoint
|
|
||||||
export AI_MODEL=gpt-4o
|
|
||||||
```
|
|
||||||
|
|
||||||
> **TIP**: Many third-party model services (e.g., DeepSeek, Mistral, Qwen) provide OpenAI-compatible APIs. Simply change `AI_BASE_URL` and `AI_MODEL` to connect — no protocol switch needed.
|
|
||||||
|
|
||||||
#### Option 2: Anthropic Protocol (Claude Native API)
|
|
||||||
|
|
||||||
**Step 1**: Edit `pom.xml` — replace the OpenAI starter with the Anthropic starter:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<!-- Comment out or remove the OpenAI starter -->
|
|
||||||
<!-- <dependency>
|
|
||||||
<groupId>org.springframework.ai</groupId>
|
|
||||||
<artifactId>spring-ai-starter-model-openai</artifactId>
|
|
||||||
</dependency> -->
|
|
||||||
|
|
||||||
<!-- Add the Anthropic starter -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.ai</groupId>
|
|
||||||
<artifactId>spring-ai-starter-model-anthropic</artifactId>
|
|
||||||
</dependency>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2**: Edit `application.yml` — replace `spring.ai.openai` with `spring.ai.anthropic`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
spring:
|
|
||||||
ai:
|
|
||||||
anthropic:
|
|
||||||
api-key: ${AI_API_KEY}
|
|
||||||
base-url: ${AI_BASE_URL:https://api.anthropic.com}
|
|
||||||
chat:
|
|
||||||
options:
|
|
||||||
model: ${AI_MODEL:claude-sonnet-4-20250514}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 3**: Set environment variables:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
export AI_API_KEY=sk-ant-xxxxxxxx
|
|
||||||
export AI_BASE_URL=https://api.anthropic.com
|
|
||||||
export AI_MODEL=claude-sonnet-4-20250514
|
|
||||||
```
|
|
||||||
|
|
||||||
#### How Switching Works
|
|
||||||
|
|
||||||
Spring AI's `ChatModel` is a unified abstraction interface. Different Starters provide different implementations:
|
|
||||||
|
|
||||||
| Starter Dependency | Auto-injected ChatModel | Config Prefix |
|
|
||||||
|---|---|---|
|
|
||||||
| `spring-ai-starter-model-openai` | `OpenAiChatModel` | `spring.ai.openai.*` |
|
|
||||||
| `spring-ai-starter-model-anthropic` | `AnthropicChatModel` | `spring.ai.anthropic.*` |
|
|
||||||
|
|
||||||
Business code always programs against the `ChatModel` interface. Switching protocols only requires changing the dependency and configuration — no Java code changes needed.
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
Try these prompts(English prompts work better with LLMs, but Chinese also works):
|
|
||||||
|
|
||||||
1. `Create a file called Hello.java that prints "Hello, World!"`
|
|
||||||
2. `List all Java files in this directory`
|
|
||||||
3. `What is the current git branch?`
|
|
||||||
4. `Create a directory called test_output and write 3 files in it`
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
# s02: Tool Use
|
|
||||||
|
|
||||||
`s01 > [ s02 ] s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
|
|
||||||
|
|
||||||
> *"Adding a tool means adding one @Tool method"* -- the loop stays the same; new tools are passed into `defaultTools()`.
|
|
||||||
>
|
|
||||||
> **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 (`read_file`, `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 | ---> | defaultTools() |
|
|
||||||
| prompt | | | | { |
|
|
||||||
+--------+ +---+---+ | BashTool |
|
|
||||||
^ | ReadFileTool |
|
|
||||||
| | WriteFileTool |
|
|
||||||
+-----------+ EditFileTool |
|
|
||||||
tool_result | } |
|
|
||||||
+--------------------+
|
|
||||||
|
|
||||||
Spring AI auto-registers and dispatches via @Tool annotations.
|
|
||||||
No hand-written dispatch map needed -- the framework scans annotated methods on tool objects.
|
|
||||||
```
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
1. Each tool is a standalone class declared with `@Tool` annotation. `PathValidator` provides path sandboxing to prevent workspace escape.
|
|
||||||
|
|
||||||
```java
|
|
||||||
// PathValidator -- corresponds to the Python version's safe_path() function
|
|
||||||
public class PathValidator {
|
|
||||||
private final Path workDir;
|
|
||||||
|
|
||||||
public Path resolve(String relativePath) {
|
|
||||||
Path resolved = workDir.resolve(relativePath).toAbsolutePath().normalize();
|
|
||||||
if (!resolved.startsWith(workDir)) {
|
|
||||||
throw new IllegalArgumentException("Path escapes workspace: " + relativePath);
|
|
||||||
}
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadFileTool -- corresponds to the Python version's run_read() function
|
|
||||||
public class ReadFileTool {
|
|
||||||
private final PathValidator pathValidator;
|
|
||||||
|
|
||||||
@Tool(description = "Read file contents. Optionally limit the number of lines returned.")
|
|
||||||
public String readFile(
|
|
||||||
@ToolParam(description = "Relative path to the file") String path,
|
|
||||||
@ToolParam(description = "Maximum number of lines to read", required = false) Integer limit) {
|
|
||||||
Path filePath = pathValidator.resolve(path);
|
|
||||||
List<String> lines = Files.readAllLines(filePath);
|
|
||||||
if (limit != null && limit > 0 && limit < lines.size()) {
|
|
||||||
lines = lines.subList(0, limit);
|
|
||||||
}
|
|
||||||
return String.join("\n", lines);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Tool registration simply passes objects to `defaultTools()`. Spring AI scans `@Tool` annotated methods and automatically handles name mapping and parameter binding.
|
|
||||||
|
|
||||||
```java
|
|
||||||
// Corresponds to the Python version's TOOL_HANDLERS dict
|
|
||||||
// Python: TOOL_HANDLERS = {"bash": fn, "read_file": fn, "write_file": fn, "edit_file": fn}
|
|
||||||
// Java: Just pass tool objects; @Tool annotations handle auto-registration
|
|
||||||
this.chatClient = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem("You are a coding agent ...")
|
|
||||||
.defaultTools(
|
|
||||||
new BashTool(), // bash command execution
|
|
||||||
new ReadFileTool(), // file reading
|
|
||||||
new WriteFileTool(), // file writing
|
|
||||||
new EditFileTool() // file editing (find & replace)
|
|
||||||
)
|
|
||||||
.build();
|
|
||||||
```
|
|
||||||
|
|
||||||
3. The calling code is identical to s01. The loop is managed by the framework; developers only focus on tool implementation.
|
|
||||||
|
|
||||||
```java
|
|
||||||
// Compared to s01, the only change is that defaultTools() receives 3 more tool objects
|
|
||||||
// The loop code is exactly the same -- this is the core insight of s02
|
|
||||||
AgentRunner.interactive("s02", userMessage ->
|
|
||||||
chatClient.prompt()
|
|
||||||
.user(userMessage)
|
|
||||||
.call()
|
|
||||||
.content()
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
Add a tool = add a `@Tool` class + pass it to `defaultTools()`. The loop never changes.
|
|
||||||
|
|
||||||
> **TIPS — Key Python → Java Adaptations:**
|
|
||||||
> - Python's `TOOL_HANDLERS` dict → Spring AI `@Tool` annotation + `defaultTools()` auto-registration and dispatch
|
|
||||||
> - Python's `safe_path()` function → `PathValidator` class (same path escape check logic)
|
|
||||||
> - Python's `lambda **kw` parameter unpacking → `@ToolParam` annotation auto-binds parameters
|
|
||||||
> - Python's `block.type == "tool_use"` check → Spring AI handles detection and dispatch internally
|
|
||||||
|
|
||||||
## What Changed From s01
|
|
||||||
|
|
||||||
| Component | Before (s01) | After (s02) |
|
|
||||||
|----------------|-----------------------|------------------------------------------------|
|
|
||||||
| Tools | 1 (`BashTool`) | 4 (`Bash`, `ReadFile`, `WriteFile`, `EditFile`) |
|
|
||||||
| Dispatch | `defaultTools(bash)` | `defaultTools(bash, read, write, edit)` |
|
|
||||||
| Path safety | None | `PathValidator` sandbox |
|
|
||||||
| Agent loop | Unchanged | Unchanged |
|
|
||||||
|
|
||||||
```java
|
|
||||||
// s01 → s02 only change: defaultTools() receives 3 more tool objects
|
|
||||||
.defaultTools(
|
|
||||||
new BashTool(),
|
|
||||||
new ReadFileTool(), // +new
|
|
||||||
new WriteFileTool(), // +new
|
|
||||||
new EditFileTool() // +new
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Try It
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd learn-claude-code
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s02.S02ToolUse
|
|
||||||
```
|
|
||||||
|
|
||||||
> Set environment variables before running: `AI_API_KEY`, `AI_BASE_URL`, `AI_MODEL`
|
|
||||||
|
|
||||||
Try these prompts (English prompts work better with LLMs, but Chinese also works):
|
|
||||||
|
|
||||||
1. `Read the file pom.xml`
|
|
||||||
2. `Create a file called Greet.java with a greet(name) method`
|
|
||||||
3. `Edit Greet.java to add a Javadoc comment to the method`
|
|
||||||
4. `Read Greet.java to verify the edit worked`
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
# 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. Doubles the completion rate.
|
|
||||||
>
|
|
||||||
> **Harness layer**: Planning -- keeping the model on course without scripting the route.
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
On multi-step tasks, the model loses track -- repeats work, skips steps, or wanders off. Long conversations make this worse: tool results keep filling the context, gradually diluting the system prompt's influence. A 10-step refactoring might complete steps 1-3, then the model starts improvising because steps 4-10 have been pushed out of attention.
|
|
||||||
|
|
||||||
## Solution
|
|
||||||
|
|
||||||
```
|
|
||||||
+--------+ +-------+ +---------+
|
|
||||||
| User | ---> | LLM | ---> | Tools |
|
|
||||||
| prompt | | | | + todo |
|
|
||||||
+--------+ +---+---+ +----+----+
|
|
||||||
^ |
|
|
||||||
| tool_result |
|
|
||||||
+----------------+
|
|
||||||
|
|
|
||||||
+-----------+-----------+
|
|
||||||
| TodoManager state |
|
|
||||||
| [ ] task A |
|
|
||||||
| [>] task B <- doing |
|
|
||||||
| [x] task C |
|
|
||||||
+-----------------------+
|
|
||||||
|
|
|
||||||
Inject latest todo state into
|
|
||||||
system prompt via defaultSystem()
|
|
||||||
on each request
|
|
||||||
```
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
1. TodoManager stores items with statuses. Only one item can be `in_progress` at a time.
|
|
||||||
|
|
||||||
```java
|
|
||||||
public class TodoManager {
|
|
||||||
|
|
||||||
public record TodoItem(String id, String text, String status) {}
|
|
||||||
|
|
||||||
private List<TodoItem> items = new ArrayList<>();
|
|
||||||
|
|
||||||
@Tool(description = "Update the full task list to track progress. "
|
|
||||||
+ "Each item must have id, text, status (pending/in_progress/completed). "
|
|
||||||
+ "Only one task can be in_progress at a time. Max 20 items.")
|
|
||||||
public String updateTodos(
|
|
||||||
@ToolParam(description = "The complete list of todo items")
|
|
||||||
List<TodoItem> items) {
|
|
||||||
if (items.size() > 20) return "Error: Max 20 todos allowed";
|
|
||||||
List<TodoItem> validated = new ArrayList<>();
|
|
||||||
int inProgressCount = 0;
|
|
||||||
for (TodoItem item : items) {
|
|
||||||
String status = (item.status() != null)
|
|
||||||
? item.status().toLowerCase() : "pending";
|
|
||||||
if ("in_progress".equals(status)) inProgressCount++;
|
|
||||||
validated.add(new TodoItem(item.id(), item.text().trim(), status));
|
|
||||||
}
|
|
||||||
if (inProgressCount > 1)
|
|
||||||
return "Error: Only one task can be in_progress at a time";
|
|
||||||
this.items = validated;
|
|
||||||
return render();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. `TodoManager` is registered via `defaultTools()`; the `@Tool` annotated method is automatically exposed as a tool.
|
|
||||||
|
|
||||||
```java
|
|
||||||
ChatClient chatClient = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem(system)
|
|
||||||
.defaultTools(
|
|
||||||
new BashTool(),
|
|
||||||
new ReadFileTool(),
|
|
||||||
new WriteFileTool(),
|
|
||||||
new EditFileTool(),
|
|
||||||
todoManager // @Tool annotated method auto-registered
|
|
||||||
)
|
|
||||||
.build();
|
|
||||||
```
|
|
||||||
|
|
||||||
3. System prompt injection: on each user input, inject the latest todo state into the system prompt with emphasis on update instructions.
|
|
||||||
|
|
||||||
```java
|
|
||||||
// Dynamic system prompt: includes current todo state
|
|
||||||
String system = "You are a coding agent at " + workDir + ".\n"
|
|
||||||
+ "Use the todo tool to plan multi-step tasks. "
|
|
||||||
+ "Mark in_progress before starting, completed when done.\n"
|
|
||||||
+ "IMPORTANT: You MUST call updateTodos regularly.\n\n"
|
|
||||||
+ "<current-todos>\n" + todoManager.render() + "\n</current-todos>";
|
|
||||||
```
|
|
||||||
|
|
||||||
The "only one in_progress at a time" constraint forces sequential focus. Continuously injecting todo state into the system prompt creates accountability pressure -- the model sees its own plan every turn and won't forget to update it.
|
|
||||||
|
|
||||||
> **TIP**: The Python version tracks `rounds_since_todo` inside the tool loop and injects `<reminder>` text after 3 consecutive rounds without a todo call. Spring AI's ChatClient manages the tool loop automatically and doesn't allow mid-loop injection, so system prompt injection is used instead to achieve the same effect.
|
|
||||||
|
|
||||||
## What Changed From s02
|
|
||||||
|
|
||||||
| Component | Before (s02) | After (s03) |
|
|
||||||
|----------------|------------------|--------------------------------------|
|
|
||||||
| Tools | 4 | 5 (+TodoManager `@Tool`) |
|
|
||||||
| Planning | None | TodoManager with statuses |
|
|
||||||
| State injection| None | System prompt injection `<current-todos>` |
|
|
||||||
| ChatClient | Fixed system prompt | Rebuilt each turn, dynamic todo state injection |
|
|
||||||
|
|
||||||
## Try It
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd learn-claude-code
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s03.S03TodoWrite
|
|
||||||
```
|
|
||||||
|
|
||||||
Try these prompts (English prompts work better with LLMs, but Chinese also works):
|
|
||||||
|
|
||||||
1. `Refactor the file Hello.java: add JavaDoc, improve naming, and keep main method behavior unchanged`
|
|
||||||
2. `Create a Java package with utils and tests`
|
|
||||||
3. `Review all Java files and fix any style issues`
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
# 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 one word: "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 agent has a `task` tool. The subagent gets all base tools except `task` (no recursive spawning).
|
|
||||||
|
|
||||||
```java
|
|
||||||
// Parent Agent: has base tools + SubagentTool
|
|
||||||
this.chatClient = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem("You are a coding agent. "
|
|
||||||
+ "Use the task tool to delegate subtasks.")
|
|
||||||
.defaultTools(
|
|
||||||
new BashTool(),
|
|
||||||
new ReadFileTool(),
|
|
||||||
new WriteFileTool(),
|
|
||||||
new EditFileTool(),
|
|
||||||
new SubagentTool(chatModel) // Parent Agent exclusive
|
|
||||||
)
|
|
||||||
.build();
|
|
||||||
```
|
|
||||||
|
|
||||||
2. The subagent starts with a brand new `ChatClient` and an independent context. Only the final text returns to the parent.
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Tool(description = "Spawn a subagent with fresh context. "
|
|
||||||
+ "Use for exploration or subtasks that might pollute the main context.")
|
|
||||||
public String task(
|
|
||||||
@ToolParam(description = "The task prompt") String prompt,
|
|
||||||
@ToolParam(description = "Short description", required = false)
|
|
||||||
String description) {
|
|
||||||
|
|
||||||
// Create a brand new ChatClient -- this IS "context isolation"
|
|
||||||
ChatClient subClient = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem("You are a coding subagent. "
|
|
||||||
+ "Complete the task, then summarize findings.")
|
|
||||||
.defaultTools( // Base tools, no task (prevents recursion)
|
|
||||||
new BashTool(),
|
|
||||||
new ReadFileTool(),
|
|
||||||
new WriteFileTool(),
|
|
||||||
new EditFileTool()
|
|
||||||
)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
String result = subClient.prompt()
|
|
||||||
.user(prompt)
|
|
||||||
.call()
|
|
||||||
.content();
|
|
||||||
|
|
||||||
// Only the final text is returned; subagent context is discarded
|
|
||||||
return (result != null) ? result : "(no summary)";
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The subagent may have run multiple tool calls, but its entire message history is discarded. The parent receives only a summary text, returned as a normal `tool_result`. Spring AI's `ChatClient.call()` manages the tool loop internally -- no need to manually limit iteration count.
|
|
||||||
|
|
||||||
## What Changed From s03
|
|
||||||
|
|
||||||
| Component | Before (s03) | After (s04) |
|
|
||||||
|----------------|------------------|---------------------------------------|
|
|
||||||
| Tools | 5 | 5 (base) + SubagentTool (parent only) |
|
|
||||||
| Context | Single shared | Parent + child isolation (independent ChatClient) |
|
|
||||||
| Subagent | None | `SubagentTool.task()` method |
|
|
||||||
| Return value | N/A | Summary text only |
|
|
||||||
|
|
||||||
## Try It
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd learn-claude-code
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s04.S04Subagent
|
|
||||||
```
|
|
||||||
|
|
||||||
Try these prompts (English prompts work better with LLMs, but Chinese also works):
|
|
||||||
|
|
||||||
1. `Use a subtask to find what testing framework this project uses`
|
|
||||||
2. `Delegate: read all .java files and summarize what each one does`
|
|
||||||
3. `Use a task to create a new module, then verify it from here`
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
# 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 -- 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` file 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 recursively scans for `SKILL.md` files, using the directory name as the skill identifier.
|
|
||||||
|
|
||||||
```java
|
|
||||||
public class SkillLoader {
|
|
||||||
|
|
||||||
private static final Pattern FRONTMATTER_PATTERN =
|
|
||||||
Pattern.compile("^---\\n(.*?)\\n---\\n(.*)", Pattern.DOTALL);
|
|
||||||
|
|
||||||
private final Map<String, SkillInfo> skills = new LinkedHashMap<>();
|
|
||||||
|
|
||||||
record SkillInfo(Map<String, String> meta, String body, String path) {}
|
|
||||||
|
|
||||||
public SkillLoader(Path skillsDir) {
|
|
||||||
loadAll(skillsDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Recursively scan all SKILL.md files under the skills directory */
|
|
||||||
private void loadAll(Path skillsDir) {
|
|
||||||
if (!Files.exists(skillsDir)) return;
|
|
||||||
try (Stream<Path> paths = Files.walk(skillsDir)) {
|
|
||||||
paths.filter(p -> p.getFileName().toString().equals("SKILL.md"))
|
|
||||||
.sorted()
|
|
||||||
.forEach(p -> {
|
|
||||||
String text = Files.readString(p);
|
|
||||||
var parsed = parseFrontmatter(text);
|
|
||||||
String name = parsed.meta().getOrDefault("name",
|
|
||||||
p.getParent().getFileName().toString());
|
|
||||||
skills.put(name, new SkillInfo(
|
|
||||||
parsed.meta(), parsed.body(), p.toString()));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Layer 1: Get short descriptions of all skills (for system prompt injection) */
|
|
||||||
public String getDescriptions() {
|
|
||||||
if (skills.isEmpty()) return "(no skills available)";
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
for (var entry : skills.entrySet()) {
|
|
||||||
String desc = entry.getValue().meta()
|
|
||||||
.getOrDefault("description", "No description");
|
|
||||||
sb.append(" - ").append(entry.getKey())
|
|
||||||
.append(": ").append(desc).append("\n");
|
|
||||||
}
|
|
||||||
return sb.toString().stripTrailing();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Layer 2: Load full content of a specified skill (as @Tool method) */
|
|
||||||
@Tool(description = "Load specialized knowledge by name.")
|
|
||||||
public String loadSkill(
|
|
||||||
@ToolParam(description = "Skill name to load") String name) {
|
|
||||||
SkillInfo skill = skills.get(name);
|
|
||||||
if (skill == null)
|
|
||||||
return "Error: Unknown skill '" + name + "'. Available: "
|
|
||||||
+ String.join(", ", skills.keySet());
|
|
||||||
return "<skill name=\"" + name + "\">\n"
|
|
||||||
+ skill.body() + "\n</skill>";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Layer 1 goes into the system prompt. Layer 2 is loaded on demand via the `@Tool` annotated method on SkillLoader.
|
|
||||||
|
|
||||||
```java
|
|
||||||
public S05SkillLoading(ChatModel chatModel) {
|
|
||||||
Path skillsDir = Path.of(System.getProperty("user.dir"), "skills");
|
|
||||||
SkillLoader skillLoader = new SkillLoader(skillsDir);
|
|
||||||
|
|
||||||
// Layer 1: Skill metadata injected into system prompt
|
|
||||||
String system = "You are a coding agent at " + System.getProperty("user.dir") + ".\n"
|
|
||||||
+ "Use loadSkill to access specialized knowledge.\n\n"
|
|
||||||
+ "Skills available:\n"
|
|
||||||
+ skillLoader.getDescriptions();
|
|
||||||
|
|
||||||
this.chatClient = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem(system)
|
|
||||||
.defaultTools(
|
|
||||||
new BashTool(), new ReadFileTool(),
|
|
||||||
new WriteFileTool(), new EditFileTool(),
|
|
||||||
skillLoader // Layer 2: loadSkill @Tool method
|
|
||||||
)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s05.S05SkillLoading
|
|
||||||
```
|
|
||||||
|
|
||||||
Try these prompts (English prompts work better with LLMs, but Chinese also works):
|
|
||||||
|
|
||||||
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`
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
# 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 commands, you easily blow past 100k tokens. Without compression, the agent simply cannot work on large codebases.
|
|
||||||
|
|
||||||
## 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 -- Context window management**: Spring AI's ChatClient manages the tool loop automatically and doesn't allow mid-loop compression injection. The Java version achieves an equivalent effect by limiting the number of conversation turns injected into the system prompt (keeping only the most recent N turns) and truncating content.
|
|
||||||
|
|
||||||
```java
|
|
||||||
/** Estimate token count: rough estimate of 4 chars ≈ 1 token */
|
|
||||||
public int estimateTokens() {
|
|
||||||
int chars = history.stream().mapToInt(t -> t.content().length()).sum();
|
|
||||||
return chars / 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get conversation history summary (for system prompt injection, keeping only recent turns) */
|
|
||||||
public String getContextSummary() {
|
|
||||||
if (history.isEmpty()) return "";
|
|
||||||
StringBuilder sb = new StringBuilder("\n<conversation-context>\n");
|
|
||||||
int start = Math.max(0, history.size() - KEEP_RECENT * 2);
|
|
||||||
for (int i = start; i < history.size(); i++) {
|
|
||||||
ConversationTurn turn = history.get(i);
|
|
||||||
sb.append("[").append(turn.role()).append("]: ")
|
|
||||||
.append(turn.content(), 0, Math.min(500, turn.content().length()))
|
|
||||||
.append("\n");
|
|
||||||
}
|
|
||||||
sb.append("</conversation-context>");
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Layer 2 -- auto_compact**: When tokens exceed the threshold, save the full conversation to disk and have the LLM summarize it.
|
|
||||||
|
|
||||||
```java
|
|
||||||
public String compact() {
|
|
||||||
// Save transcript to disk (full history is not lost)
|
|
||||||
Files.createDirectories(transcriptDir);
|
|
||||||
Path transcriptPath = transcriptDir.resolve(
|
|
||||||
"transcript_" + System.currentTimeMillis() + ".jsonl");
|
|
||||||
try (BufferedWriter writer = Files.newBufferedWriter(transcriptPath)) {
|
|
||||||
for (ConversationTurn turn : history) {
|
|
||||||
writer.write(objectMapper.writeValueAsString(turn));
|
|
||||||
writer.newLine();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LLM generates summary
|
|
||||||
String conversationText = history.stream()
|
|
||||||
.map(t -> t.role() + ": " + t.content())
|
|
||||||
.reduce("", (a, b) -> a + "\n" + b);
|
|
||||||
if (conversationText.length() > 80000) {
|
|
||||||
conversationText = conversationText.substring(0, 80000);
|
|
||||||
}
|
|
||||||
|
|
||||||
ChatClient summaryClient = ChatClient.builder(chatModel).build();
|
|
||||||
String summary = summaryClient.prompt()
|
|
||||||
.user("Summarize this conversation for continuity. Include: "
|
|
||||||
+ "1) What was accomplished, 2) Current state, "
|
|
||||||
+ "3) Key decisions.\n\n" + conversationText)
|
|
||||||
.call().content();
|
|
||||||
|
|
||||||
// Replace history with summary
|
|
||||||
history.clear();
|
|
||||||
history.add(new ConversationTurn("system",
|
|
||||||
"[Conversation compressed. Transcript: " + transcriptPath
|
|
||||||
+ "]\n\n" + summary));
|
|
||||||
return summary;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Layer 3 -- manual compact**: The `CompactTool` triggers the same summarization mechanism on demand.
|
|
||||||
|
|
||||||
```java
|
|
||||||
public class CompactTool {
|
|
||||||
private final ContextCompactor compactor;
|
|
||||||
|
|
||||||
public CompactTool(ContextCompactor compactor) {
|
|
||||||
this.compactor = compactor;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Tool(description = "Trigger manual conversation compression to free up context space.")
|
|
||||||
public String compact(
|
|
||||||
@ToolParam(description = "What to preserve in summary",
|
|
||||||
required = false) String focus) {
|
|
||||||
compactor.requestCompact();
|
|
||||||
return "Compression triggered. Context will be summarized.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
4. The REPL layer integrates all three layers (Spring AI's ChatClient manages the tool loop automatically; compression is triggered at the user message level):
|
|
||||||
|
|
||||||
```java
|
|
||||||
AgentRunner.interactive("s06", userMessage -> {
|
|
||||||
// Layer 2: Auto-compact check (before each user input)
|
|
||||||
if (compactor.needsAutoCompact()) {
|
|
||||||
System.out.println("[auto_compact triggered]");
|
|
||||||
compactor.compact();
|
|
||||||
}
|
|
||||||
compactor.addTurn("user", userMessage);
|
|
||||||
|
|
||||||
// Dynamic system prompt: includes conversation context summary
|
|
||||||
String system = baseSystem + compactor.getContextSummary();
|
|
||||||
ChatClient chatClient = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem(system)
|
|
||||||
.defaultTools(new BashTool(), new ReadFileTool(),
|
|
||||||
new WriteFileTool(), new EditFileTool(), compactTool)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
String response = chatClient.prompt()
|
|
||||||
.user(userMessage).call().content();
|
|
||||||
compactor.addTurn("assistant", response != null ? response : "");
|
|
||||||
|
|
||||||
// Layer 3: Manual compact (if the agent called the compact tool)
|
|
||||||
if (compactor.isCompactRequested()) {
|
|
||||||
compactor.compact();
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Full history is preserved on disk via transcripts. 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 |
|
|
||||||
| Context window mgmt | None | Limited turn injection + content truncation |
|
|
||||||
| Auto-compact | None | Token threshold trigger |
|
|
||||||
| Transcripts | None | Saved to .transcripts/ |
|
|
||||||
|
|
||||||
## Try It
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd learn-claude-code
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s06.S06ContextCompact
|
|
||||||
```
|
|
||||||
|
|
||||||
Try these prompts (English prompts work better with LLMs, but Chinese also works):
|
|
||||||
|
|
||||||
1. `Read every Java file in the src/ directory one by one` (observe context window management)
|
|
||||||
2. `Keep reading files until compression triggers automatically`
|
|
||||||
3. `Use the compact tool to manually compress the conversation`
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
# 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 compaction (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`), and dependents (`blocks`). 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. Uses Jackson `ObjectMapper` for JSON serialization.
|
|
||||||
|
|
||||||
```java
|
|
||||||
public class TaskManager {
|
|
||||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
|
||||||
private final Path dir;
|
|
||||||
private int nextId;
|
|
||||||
|
|
||||||
public TaskManager(Path tasksDir) {
|
|
||||||
this.dir = tasksDir;
|
|
||||||
Files.createDirectories(dir);
|
|
||||||
this.nextId = maxId() + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Tool(description = "Create a new task with subject and optional description")
|
|
||||||
public String taskCreate(
|
|
||||||
@ToolParam(description = "Short subject of the task") String subject,
|
|
||||||
@ToolParam(description = "Detailed description", required = false) String description) {
|
|
||||||
Map<String, Object> task = new LinkedHashMap<>();
|
|
||||||
task.put("id", nextId);
|
|
||||||
task.put("subject", subject);
|
|
||||||
task.put("status", "pending");
|
|
||||||
task.put("blockedBy", new ArrayList<>());
|
|
||||||
task.put("blocks", new ArrayList<>());
|
|
||||||
save(task);
|
|
||||||
nextId++;
|
|
||||||
return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(task);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Dependency resolution**: completing a task clears its ID from every other task's `blockedBy` list, automatically unblocking dependents.
|
|
||||||
|
|
||||||
```java
|
|
||||||
private void clearDependency(int completedId) {
|
|
||||||
try (Stream<Path> files = Files.list(dir)) {
|
|
||||||
files.filter(f -> f.getFileName().toString().matches("task_\\d+\\.json"))
|
|
||||||
.forEach(f -> {
|
|
||||||
Map<String, Object> task = MAPPER.readValue(
|
|
||||||
Files.readString(f), new TypeReference<>() {});
|
|
||||||
List<Integer> blockedBy = (List<Integer>) task.get("blockedBy");
|
|
||||||
if (blockedBy != null && blockedBy.remove(Integer.valueOf(completedId))) {
|
|
||||||
save(task);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Status transitions + dependency wiring**: `taskUpdate` handles status transitions and dependency edges. When status changes to `completed`, it automatically calls `clearDependency`; `blockedBy`/`blocks` are bidirectional relationships.
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Tool(description = "Update a task's status or dependencies.")
|
|
||||||
public String taskUpdate(
|
|
||||||
@ToolParam(description = "Task ID") int taskId,
|
|
||||||
@ToolParam(description = "New status", required = false) String status,
|
|
||||||
@ToolParam(description = "Task IDs that block this task", required = false) List<Integer> addBlockedBy,
|
|
||||||
@ToolParam(description = "Task IDs that this task blocks", required = false) List<Integer> addBlocks) {
|
|
||||||
Map<String, Object> task = load(taskId);
|
|
||||||
if (status != null) {
|
|
||||||
task.put("status", status);
|
|
||||||
if ("completed".equals(status)) {
|
|
||||||
clearDependency(taskId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Handle addBlockedBy / addBlocks bidirectional dependencies ...
|
|
||||||
save(task);
|
|
||||||
return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(task);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Spring AI auto-registers tools**: Pass `TaskManager` as a `defaultTools` argument to `ChatClient`. Spring AI automatically recognizes `@Tool` annotated methods -- no manual dispatch map needed.
|
|
||||||
|
|
||||||
```java
|
|
||||||
@SpringBootApplication(scanBasePackages = "io.mybatis.learn.core")
|
|
||||||
public class S07TaskSystem implements CommandLineRunner {
|
|
||||||
|
|
||||||
private final ChatClient chatClient;
|
|
||||||
|
|
||||||
public S07TaskSystem(ChatModel chatModel) {
|
|
||||||
Path tasksDir = Path.of(System.getProperty("user.dir"), ".tasks");
|
|
||||||
TaskManager taskManager = new TaskManager(tasksDir);
|
|
||||||
|
|
||||||
this.chatClient = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem("You are a coding agent. Use task tools to plan and track work.")
|
|
||||||
.defaultTools(
|
|
||||||
new BashTool(), new ReadFileTool(),
|
|
||||||
new WriteFileTool(), new EditFileTool(),
|
|
||||||
taskManager // @Tool methods in TaskManager are auto-registered
|
|
||||||
)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
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` + `blocks` 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
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s07.S07TaskSystem
|
|
||||||
```
|
|
||||||
|
|
||||||
Try these prompts (English prompts work better with LLMs, but Chinese also works):
|
|
||||||
|
|
||||||
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`
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
# 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"* -- background 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 thread-safe concurrent containers. Java uses `ConcurrentHashMap` and `CopyOnWriteArrayList` instead of Python's manual locking.
|
|
||||||
|
|
||||||
```java
|
|
||||||
public class BackgroundManager {
|
|
||||||
private static final int TIMEOUT_SECONDS = 300;
|
|
||||||
|
|
||||||
private final Map<String, TaskInfo> tasks = new ConcurrentHashMap<>();
|
|
||||||
private final List<Notification> notificationQueue = new CopyOnWriteArrayList<>();
|
|
||||||
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
|
||||||
|
|
||||||
record TaskInfo(String status, String result, String command) {}
|
|
||||||
public record Notification(String taskId, String status, String command, String result) {}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. `backgroundRun()` submits a virtual thread (Java 21) and returns immediately. Compared to Python's `daemon=True` threads, virtual threads are lighter and scheduled by the JVM.
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Tool(description = "Run a command in a background thread. Returns task_id immediately without waiting.")
|
|
||||||
public String backgroundRun(
|
|
||||||
@ToolParam(description = "The shell command to run in background") String command) {
|
|
||||||
String taskId = UUID.randomUUID().toString().substring(0, 8);
|
|
||||||
tasks.put(taskId, new TaskInfo("running", null, command));
|
|
||||||
|
|
||||||
executor.submit(() -> execute(taskId, command));
|
|
||||||
|
|
||||||
return "Background task " + taskId + " started: "
|
|
||||||
+ command.substring(0, Math.min(80, command.length()));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. When the subprocess finishes, the result goes into the notification queue. Uses `ProcessBuilder` for command execution with timeout control.
|
|
||||||
|
|
||||||
```java
|
|
||||||
private void execute(String taskId, String command) {
|
|
||||||
String status, output;
|
|
||||||
try {
|
|
||||||
ProcessBuilder pb = new ProcessBuilder("sh", "-c", command);
|
|
||||||
pb.redirectErrorStream(true);
|
|
||||||
Process process = pb.start();
|
|
||||||
try (BufferedReader reader = new BufferedReader(
|
|
||||||
new InputStreamReader(process.getInputStream()))) {
|
|
||||||
output = reader.lines().collect(Collectors.joining("\n"));
|
|
||||||
}
|
|
||||||
boolean finished = process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
|
||||||
if (!finished) { process.destroyForcibly(); status = "timeout"; }
|
|
||||||
else { status = "completed"; }
|
|
||||||
} catch (Exception e) { output = "Error: " + e.getMessage(); status = "error"; }
|
|
||||||
|
|
||||||
tasks.put(taskId, new TaskInfo(status, output, command));
|
|
||||||
notificationQueue.add(new Notification(taskId, status, command, output));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Drain the notification queue on each user input and inject into the system prompt. Spring AI's `ChatClient` manages the internal tool loop, so notifications are drained and built into the system prompt on each user input instead -- the core concept remains the same: fire and forget.
|
|
||||||
|
|
||||||
```java
|
|
||||||
AgentRunner.interactive("s08", userMessage -> {
|
|
||||||
// Drain background task notifications (corresponds to Python's pre-loop drain_notifications)
|
|
||||||
var notifs = bgManager.drainNotifications();
|
|
||||||
String bgContext = "";
|
|
||||||
if (!notifs.isEmpty()) {
|
|
||||||
String notifText = notifs.stream()
|
|
||||||
.map(n -> "[bg:" + n.taskId() + "] " + n.status() + ": " + n.result())
|
|
||||||
.collect(Collectors.joining("\n"));
|
|
||||||
bgContext = "\n\n<background-results>\n" + notifText + "\n</background-results>";
|
|
||||||
}
|
|
||||||
|
|
||||||
String system = "You are a coding agent. Use backgroundRun for long-running commands."
|
|
||||||
+ bgContext;
|
|
||||||
|
|
||||||
ChatClient chatClient = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem(system)
|
|
||||||
.defaultTools(new BashTool(), new ReadFileTool(),
|
|
||||||
new WriteFileTool(), new EditFileTool(), bgManager)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
return chatClient.prompt().user(userMessage).call().content();
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
The loop stays single-threaded. Only subprocess I/O is parallelized.
|
|
||||||
|
|
||||||
## What Changed From s07
|
|
||||||
|
|
||||||
| Component | Before (s07) | After (s08) |
|
|
||||||
|----------------|------------------|------------------------------------|
|
|
||||||
| Tools | 8 | 6 (base + backgroundRun + check) |
|
|
||||||
| Execution | Blocking only | Blocking + virtual threads (Java 21)|
|
|
||||||
| Notification | None | ConcurrentLinkedQueue drained per turn |
|
|
||||||
| Concurrency | None | Virtual threads (lighter, JVM-scheduled) |
|
|
||||||
|
|
||||||
## Try It
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd learn-claude-code
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s08.S08BackgroundTasks
|
|
||||||
```
|
|
||||||
|
|
||||||
Try these prompts (English prompts work better with LLMs, but Chinese also works):
|
|
||||||
|
|
||||||
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`
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
# 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 + JSONL 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 three things: (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 the team roster via config.json.
|
|
||||||
|
|
||||||
```java
|
|
||||||
// src/main/java/io/mybatis/learn/s09/TeammateManager.java
|
|
||||||
public class TeammateManager {
|
|
||||||
private final ChatModel chatModel;
|
|
||||||
private final MessageBus bus;
|
|
||||||
private final Path configPath;
|
|
||||||
private final ObjectMapper mapper = new ObjectMapper();
|
|
||||||
private Map<String, Object> config;
|
|
||||||
// Python uses threading.Thread + dict; Java uses ConcurrentHashMap for natural thread safety
|
|
||||||
private final Map<String, Thread> threads = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
public TeammateManager(ChatModel chatModel, MessageBus bus, Path teamDir) {
|
|
||||||
this.chatModel = chatModel;
|
|
||||||
this.bus = bus;
|
|
||||||
this.configPath = teamDir.resolve("config.json");
|
|
||||||
Files.createDirectories(teamDir);
|
|
||||||
this.config = loadConfig();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. `spawn()` creates a teammate and starts its agent loop in a thread.
|
|
||||||
|
|
||||||
```java
|
|
||||||
// Python uses threading.Thread; Java uses Thread.startVirtualThread() for virtual threads
|
|
||||||
public synchronized String spawn(String name, String role, String prompt) {
|
|
||||||
Map<String, Object> member = new LinkedHashMap<>();
|
|
||||||
member.put("name", name);
|
|
||||||
member.put("role", role);
|
|
||||||
member.put("status", "working");
|
|
||||||
((List<Map<String, Object>>) config.get("members")).add(member);
|
|
||||||
saveConfig();
|
|
||||||
|
|
||||||
// Virtual thread: lightweight, JVM-scheduled, doesn't occupy OS threads
|
|
||||||
Thread thread = Thread.startVirtualThread(
|
|
||||||
() -> teammateLoop(name, role, prompt));
|
|
||||||
threads.put(name, thread);
|
|
||||||
return "Spawned '" + name + "' (role: " + role + ")";
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. MessageBus: append-only JSONL inboxes. `send()` appends a JSON line; `read_inbox()` reads all and drains.
|
|
||||||
|
|
||||||
```java
|
|
||||||
// src/main/java/io/mybatis/learn/core/team/MessageBus.java
|
|
||||||
// Python relies on GIL for implicit thread safety; Java uses synchronized for explicit safety
|
|
||||||
public class MessageBus {
|
|
||||||
private final Path inboxDir;
|
|
||||||
private final ObjectMapper mapper = new ObjectMapper();
|
|
||||||
|
|
||||||
public synchronized String send(String sender, String to, String content,
|
|
||||||
String msgType, Map<String, Object> extra) {
|
|
||||||
Map<String, Object> msg = new LinkedHashMap<>();
|
|
||||||
msg.put("type", msgType);
|
|
||||||
msg.put("from", sender);
|
|
||||||
msg.put("content", content);
|
|
||||||
msg.put("timestamp", System.currentTimeMillis() / 1000.0);
|
|
||||||
if (extra != null) msg.putAll(extra);
|
|
||||||
|
|
||||||
Path inbox = inboxDir.resolve(to + ".jsonl");
|
|
||||||
Files.writeString(inbox, mapper.writeValueAsString(msg) + "\n",
|
|
||||||
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
|
||||||
return "Sent " + msgType + " to " + to;
|
|
||||||
}
|
|
||||||
|
|
||||||
public synchronized List<Map<String, Object>> readInbox(String name) {
|
|
||||||
Path inbox = inboxDir.resolve(name + ".jsonl");
|
|
||||||
if (!Files.exists(inbox)) return List.of();
|
|
||||||
List<Map<String, Object>> messages = new ArrayList<>();
|
|
||||||
for (String line : Files.readAllLines(inbox)) {
|
|
||||||
if (!line.isBlank())
|
|
||||||
messages.add(mapper.readValue(line, new TypeReference<>() {}));
|
|
||||||
}
|
|
||||||
Files.writeString(inbox, ""); // drain
|
|
||||||
return messages;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Each teammate checks its inbox between `call()` invocations, injecting messages into context. ChatClient's `call()` is equivalent to Python's full tool loop (looping until `stop_reason != "tool_use"`).
|
|
||||||
|
|
||||||
```java
|
|
||||||
// Python teammates check inbox before each LLM call; Java checks between each call()
|
|
||||||
protected void teammateLoop(String name, String role, String initialPrompt) {
|
|
||||||
String sysPrompt = String.format(
|
|
||||||
"You are '%s', role: %s. Use send_message to communicate.",
|
|
||||||
name, role);
|
|
||||||
|
|
||||||
var messageTool = new TeammateMessageTool(bus, name);
|
|
||||||
ChatClient client = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem(sysPrompt)
|
|
||||||
.defaultTools(new BashTool(), new ReadFileTool(),
|
|
||||||
new WriteFileTool(), new EditFileTool(), messageTool)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
// Initial work (call() = full tool chain, equivalent to Python loop until stop_reason != "tool_use")
|
|
||||||
String response = client.prompt(initialPrompt).call().content();
|
|
||||||
|
|
||||||
// Check inbox between each call() (vs. Python's between each LLM call)
|
|
||||||
for (int round = 0; round < 50; round++) {
|
|
||||||
Thread.sleep(2000);
|
|
||||||
var inbox = bus.readInbox(name);
|
|
||||||
if (inbox.isEmpty()) break;
|
|
||||||
String inboxJson = mapper.writeValueAsString(inbox);
|
|
||||||
response = client.prompt("<inbox>" + inboxJson + "</inbox>").call().content();
|
|
||||||
}
|
|
||||||
setStatus(name, "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
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s09.S09AgentTeams
|
|
||||||
```
|
|
||||||
|
|
||||||
Try these prompts (English prompts work better with LLMs, but Chinese also works):
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
# 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.
|
|
||||||
|
|
||||||
```java
|
|
||||||
// src/main/java/io/mybatis/learn/s10/ProtocolTracker.java
|
|
||||||
// Python uses dict + threading.Lock; Java uses ConcurrentHashMap for natural thread safety
|
|
||||||
private final ConcurrentHashMap<String, Map<String, String>> shutdownRequests
|
|
||||||
= new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
public String handleShutdownRequest(String teammate) {
|
|
||||||
String reqId = UUID.randomUUID().toString().substring(0, 8);
|
|
||||||
shutdownRequests.put(reqId, new ConcurrentHashMap<>(Map.of(
|
|
||||||
"target", teammate, "status", "pending")));
|
|
||||||
bus.send("lead", teammate, "Please shut down gracefully.",
|
|
||||||
"shutdown_request", Map.of("request_id", reqId));
|
|
||||||
return "Shutdown request " + reqId + " sent to '" + teammate
|
|
||||||
+ "' (status: pending)";
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. The teammate receives the request and responds with approve/reject.
|
|
||||||
|
|
||||||
```java
|
|
||||||
// TeammateProtocolTool - teammates respond to shutdown requests via @Tool annotation
|
|
||||||
@Tool(description = "Respond to a shutdown request")
|
|
||||||
public String shutdownResponse(
|
|
||||||
@ToolParam(description = "The request_id") String requestId,
|
|
||||||
@ToolParam(description = "true to approve") boolean approve,
|
|
||||||
@ToolParam(description = "Reason for decision") String reason) {
|
|
||||||
return tracker.respondToShutdown(name, requestId, approve, reason);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProtocolTracker - updates tracker + sends response message
|
|
||||||
public String respondToShutdown(String sender, String requestId,
|
|
||||||
boolean approve, String reason) {
|
|
||||||
var req = shutdownRequests.get(requestId);
|
|
||||||
if (req != null) {
|
|
||||||
req.put("status", approve ? "approved" : "rejected");
|
|
||||||
}
|
|
||||||
bus.send(sender, "lead", reason != null ? reason : "",
|
|
||||||
"shutdown_response",
|
|
||||||
Map.of("request_id", requestId, "approve", approve));
|
|
||||||
return "Shutdown " + (approve ? "approved" : "rejected");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Plan approval follows the identical pattern. The teammate submits a plan (generating a request_id), the lead reviews (referencing the same request_id).
|
|
||||||
|
|
||||||
```java
|
|
||||||
// ProtocolTracker - same request_id correlation pattern, two use cases
|
|
||||||
private final ConcurrentHashMap<String, Map<String, String>> planRequests
|
|
||||||
= new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
public String reviewPlan(String requestId, boolean approve, String feedback) {
|
|
||||||
var req = planRequests.get(requestId);
|
|
||||||
if (req == null) return "Error: Unknown plan request_id '" + requestId + "'";
|
|
||||||
req.put("status", approve ? "approved" : "rejected");
|
|
||||||
bus.send("lead", req.get("from"), feedback != null ? feedback : "",
|
|
||||||
"plan_approval_response",
|
|
||||||
Map.of("request_id", requestId, "approve", approve,
|
|
||||||
"feedback", feedback != null ? feedback : ""));
|
|
||||||
return "Plan " + req.get("status") + " for '" + req.get("from") + "'";
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s10.S10TeamProtocols
|
|
||||||
```
|
|
||||||
|
|
||||||
Try these prompts (English prompts work better with LLMs, but Chinese also works):
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
# 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. Self-organizing.
|
|
||||||
>
|
|
||||||
> **Harness layer**: Autonomy -- models that find work without being told.
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
In s09-s10, teammates only work when explicitly told to. The lead must write a prompt for each teammate. 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 compaction (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 via system prompt (always present):
|
|
||||||
ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem(identityPrompt) // automatically included in every call
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
```java
|
|
||||||
// src/main/java/io/mybatis/learn/s11/S11AutonomousAgents.java
|
|
||||||
// AutonomousTeammateManager.autonomousLoop()
|
|
||||||
|
|
||||||
private void autonomousLoop(String name, String role, String initialPrompt) {
|
|
||||||
// idle flag: set by tool call, detected by outer loop
|
|
||||||
AtomicBoolean idleRequested = new AtomicBoolean(false);
|
|
||||||
var idleTool = new IdleTool(idleRequested);
|
|
||||||
|
|
||||||
ChatClient client = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem(sysPrompt)
|
|
||||||
.defaultTools(new BashTool(), new ReadFileTool(),
|
|
||||||
new WriteFileTool(), new EditFileTool(),
|
|
||||||
messageTool, protocolTool, idleTool, claimTool)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
// -- WORK PHASE --
|
|
||||||
String nextMsg = initialPrompt;
|
|
||||||
for (int round = 0; round < 50 && nextMsg != null; round++) {
|
|
||||||
var inbox = bus.readInbox(name);
|
|
||||||
// ... merge inbox messages into nextMsg ...
|
|
||||||
idleRequested.set(false);
|
|
||||||
String response = client.prompt(sb.toString()).call().content();
|
|
||||||
if (idleRequested.get()) break; // idle tool was called
|
|
||||||
nextMsg = null; // subsequent rounds are inbox-driven
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- IDLE PHASE --
|
|
||||||
setStatus(name, "idle");
|
|
||||||
// ... poll inbox + task board (see below) ...
|
|
||||||
if (!resume) { setStatus(name, "shutdown"); return; }
|
|
||||||
setStatus(name, "working");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. The idle phase polls inbox and task board in a loop.
|
|
||||||
|
|
||||||
```java
|
|
||||||
// IDLE PHASE: poll inbox + task board
|
|
||||||
setStatus(name, "idle");
|
|
||||||
boolean resume = false;
|
|
||||||
int polls = IDLE_TIMEOUT / Math.max(POLL_INTERVAL, 1); // 60/5 = 12
|
|
||||||
|
|
||||||
for (int p = 0; p < polls; p++) {
|
|
||||||
Thread.sleep(POLL_INTERVAL * 1000L);
|
|
||||||
|
|
||||||
// Check inbox
|
|
||||||
var inbox = bus.readInbox(name);
|
|
||||||
if (!inbox.isEmpty()) {
|
|
||||||
initialPrompt = "<inbox>" + mapper.writeValueAsString(inbox) + "</inbox>";
|
|
||||||
resume = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scan task board
|
|
||||||
var unclaimed = scanUnclaimedTasks(tasksDir);
|
|
||||||
if (!unclaimed.isEmpty()) {
|
|
||||||
var task = unclaimed.get(0);
|
|
||||||
int taskId = ((Number) task.get("id")).intValue();
|
|
||||||
claimTask(tasksDir, taskId, name);
|
|
||||||
initialPrompt = String.format(
|
|
||||||
"<auto-claimed>Task #%d: %s\n%s</auto-claimed>",
|
|
||||||
taskId, task.get("subject"),
|
|
||||||
task.getOrDefault("description", ""));
|
|
||||||
resume = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!resume) { setStatus(name, "shutdown"); return; }
|
|
||||||
setStatus(name, "working");
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Task board scanning: find pending, unowned, unblocked tasks.
|
|
||||||
|
|
||||||
```java
|
|
||||||
static List<Map<String, Object>> scanUnclaimedTasks(Path tasksDir) {
|
|
||||||
if (!Files.exists(tasksDir)) return List.of();
|
|
||||||
List<Map<String, Object>> unclaimed = new ArrayList<>();
|
|
||||||
ObjectMapper mapper = new ObjectMapper();
|
|
||||||
try (var files = Files.list(tasksDir)) {
|
|
||||||
files.filter(f -> f.getFileName().toString().startsWith("task_")
|
|
||||||
&& f.getFileName().toString().endsWith(".json"))
|
|
||||||
.sorted()
|
|
||||||
.forEach(f -> {
|
|
||||||
Map<String, Object> task = mapper.readValue(f.toFile(), Map.class);
|
|
||||||
if ("pending".equals(task.get("status"))
|
|
||||||
&& (task.get("owner") == null || "".equals(task.get("owner")))
|
|
||||||
&& (task.get("blockedBy") == null
|
|
||||||
|| ((List<?>) task.get("blockedBy")).isEmpty())) {
|
|
||||||
unclaimed.add(task);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return unclaimed;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Identity persistence: Java/Spring AI's `ChatClient.defaultSystem()` automatically includes the system prompt in every call, so identity is always present -- no need to manually re-inject after compaction as in the Python version.
|
|
||||||
|
|
||||||
```java
|
|
||||||
// Identity is injected via defaultSystem at build time, automatically included in every prompt
|
|
||||||
String sysPrompt = String.format(
|
|
||||||
"You are '%s', role: %s, team: %s, at %s. "
|
|
||||||
+ "Use idle tool when you have no more work. You will auto-claim new tasks.",
|
|
||||||
name, role, teamName, workDir);
|
|
||||||
|
|
||||||
ChatClient client = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem(sysPrompt) // Identity always present in system prompt
|
|
||||||
.defaultTools(new BashTool(), new ReadFileTool(),
|
|
||||||
new WriteFileTool(), new EditFileTool(),
|
|
||||||
messageTool, protocolTool, idleTool, claimTool)
|
|
||||||
.build();
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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 compaction |
|
|
||||||
| Timeout | None | 60s idle -> auto shutdown |
|
|
||||||
|
|
||||||
## Try It
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd learn-claude-code
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s11.S11AutonomousAgents
|
|
||||||
```
|
|
||||||
|
|
||||||
Try these prompts (English prompts work better with LLMs, but Chinese also works):
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
# 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.java`, agent B also edits `Config.java`, 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.
|
|
||||||
|
|
||||||
```java
|
|
||||||
// src/main/java/io/mybatis/learn/s12/WorktreeTaskManager.java
|
|
||||||
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`.
|
|
||||||
|
|
||||||
```java
|
|
||||||
// src/main/java/io/mybatis/learn/s12/WorktreeManager.java
|
|
||||||
worktrees.create("auth-refactor", 1, "HEAD");
|
|
||||||
// -> 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:
|
|
||||||
|
|
||||||
```java
|
|
||||||
// src/main/java/io/mybatis/learn/s12/WorktreeTaskManager.java
|
|
||||||
public String bindWorktree(int taskId, String worktree, String owner) {
|
|
||||||
var task = load(taskId);
|
|
||||||
task.put("worktree", worktree);
|
|
||||||
if (owner != null && !owner.isEmpty()) task.put("owner", owner);
|
|
||||||
if ("pending".equals(task.get("status"))) task.put("status", "in_progress");
|
|
||||||
task.put("updated_at", System.currentTimeMillis() / 1000.0);
|
|
||||||
save(task);
|
|
||||||
return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(task);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Run commands in the worktree.** `cwd` points to the isolated directory.
|
|
||||||
|
|
||||||
```java
|
|
||||||
// src/main/java/io/mybatis/learn/s12/WorktreeManager.java - run()
|
|
||||||
boolean isWindows = System.getProperty("os.name").toLowerCase().contains("win");
|
|
||||||
ProcessBuilder pb = isWindows
|
|
||||||
? new ProcessBuilder("cmd", "/c", command)
|
|
||||||
: new ProcessBuilder("sh", "-c", command);
|
|
||||||
pb.directory(path.toFile());
|
|
||||||
pb.redirectErrorStream(true);
|
|
||||||
Process p = pb.start();
|
|
||||||
String out = new String(p.getInputStream().readAllBytes()).trim();
|
|
||||||
boolean finished = p.waitFor(300, java.util.concurrent.TimeUnit.SECONDS);
|
|
||||||
```
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
```java
|
|
||||||
// src/main/java/io/mybatis/learn/s12/WorktreeManager.java
|
|
||||||
public String remove(String name, boolean force, boolean completeTask) {
|
|
||||||
var wt = findWorktree(name);
|
|
||||||
events.emit("worktree.remove.before", ...);
|
|
||||||
runGit("worktree", "remove", wt.get("path").toString());
|
|
||||||
if (completeTask && wt.get("task_id") != null) {
|
|
||||||
int taskId = ((Number) wt.get("task_id")).intValue();
|
|
||||||
tasks.update(taskId, "completed", null);
|
|
||||||
tasks.unbindWorktree(taskId);
|
|
||||||
events.emit("task.completed",
|
|
||||||
Map.of("id", taskId, "status", "completed"),
|
|
||||||
Map.of("name", name), null);
|
|
||||||
}
|
|
||||||
// Update index.json: status -> "removed"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s12.S12WorktreeIsolation
|
|
||||||
```
|
|
||||||
|
|
||||||
Try these prompts (English prompts work better with LLMs, but Chinese also works):
|
|
||||||
|
|
||||||
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.`
|
|
||||||
@@ -1,318 +0,0 @@
|
|||||||
# 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 |
|
|
||||||
+----------------+
|
|
||||||
(ChatClient.call() がツール呼び出しがなくなるまで自動ループ)
|
|
||||||
```
|
|
||||||
|
|
||||||
1つの `call()` 呼び出しがフロー全体を制御する。Spring AI が自動的にループし、モデルがツール呼び出しを止めるまで続ける。
|
|
||||||
|
|
||||||
## 仕組み
|
|
||||||
|
|
||||||
### 1. ChatClient の構築:モデル注入 + ツール登録
|
|
||||||
|
|
||||||
Spring Boot の自動設定で `ChatModel` を注入し、`ChatClient.builder()` でクライアントを構築、システムプロンプトとツールを設定する。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// TIP: Python 版ではモジュールレベルで client = Anthropic() と MODEL を作成。
|
|
||||||
// Spring AI は自動設定で ChatModel を注入し、builder で ChatClient を構築する。
|
|
||||||
public S01AgentLoop(ChatModel chatModel) {
|
|
||||||
this.chatClient = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem("You are a coding agent at " + System.getProperty("user.dir")
|
|
||||||
+ ". Use bash to solve tasks. Act, don't explain.")
|
|
||||||
.defaultTools(new BashTool()) // @Tool アノテーション付きツールオブジェクト
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. `@Tool` アノテーション:宣言的ツール登録
|
|
||||||
|
|
||||||
Spring AI は `@Tool` アノテーションでツールを自動的に検出・登録する。起動時にフレームワークが `defaultTools()` に渡されたオブジェクトをスキャンし、すべての `@Tool` メソッドのシグネチャと説明を抽出し、LLM が必要とするツールスキーマ(名前、パラメータ、説明)を生成して、毎回の `call()` リクエストに自動的に含める。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// BashTool -- Python 版の run_bash() 関数に相当
|
|
||||||
public class BashTool {
|
|
||||||
@Tool(description = "Run a shell command and return stdout + stderr")
|
|
||||||
public String bash(@ToolParam(description = "The shell command to execute")
|
|
||||||
String command) {
|
|
||||||
// 危険コマンドチェック + ProcessBuilder 実行 + タイムアウト制御 + 出力切り詰め
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> Python の手動登録方式との比較:
|
|
||||||
> - Python: `TOOLS = [{"name": "bash", "input_schema": {...}}]` + `TOOL_HANDLERS = {"bash": run_bash}`
|
|
||||||
> - Java: `@Tool` + `@ToolParam` アノテーションだけで、フレームワークがスキーマ生成とメソッドディスパッチを自動化
|
|
||||||
|
|
||||||
### 3. Spring AI 内部自動ループ:`call()` の内部実装
|
|
||||||
|
|
||||||
**これが Java 版と Python 版の最も重要な違いだ。** Python 版ではツール呼び出しを駆動するために手書きの while ループが必要:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Python 版 -- 手動ループ
|
|
||||||
def agent_loop(messages):
|
|
||||||
while True:
|
|
||||||
response = client.messages.create(model=MODEL, messages=messages, tools=TOOLS)
|
|
||||||
# assistant メッセージを収集
|
|
||||||
messages.append({"role": "assistant", "content": response.content})
|
|
||||||
if response.stop_reason != "tool_use":
|
|
||||||
return response # モデルがツールを呼ばなくなった、ループ終了
|
|
||||||
# ツールを実行して結果を返送
|
|
||||||
for block in response.content:
|
|
||||||
if block.type == "tool_use":
|
|
||||||
result = TOOL_HANDLERS[block.name](block.input)
|
|
||||||
messages.append({"role": "user", "content": [{"type": "tool_result", ...}]})
|
|
||||||
```
|
|
||||||
|
|
||||||
Spring AI の `ChatClient.call()` は**完全に等価なロジックを内部にカプセル化**している:
|
|
||||||
|
|
||||||
```
|
|
||||||
call() 内部フロー:
|
|
||||||
┌─────────────────────────────────────────────────────┐
|
|
||||||
│ 1. リクエスト組み立て: system prompt + user msg + tools │
|
|
||||||
│ 2. LLM に送信 │
|
|
||||||
│ 3. レスポンス解析 │
|
|
||||||
│ ├── tool_use あり? ──→ はい: │
|
|
||||||
│ │ a. ツール名と引数を抽出 │
|
|
||||||
│ │ b. リフレクションで対応する @Tool メソッドを呼出 │
|
|
||||||
│ │ c. tool_result をメッセージリストに追加 │
|
|
||||||
│ │ d. ステップ 2 に戻る(自動ループ) │
|
|
||||||
│ └── いいえ ──→ 最終テキストを返す │
|
|
||||||
└─────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
キーポイント:
|
|
||||||
- **ツール検出**: Spring AI はレスポンスに `tool_use` タイプのコンテンツブロックがあるかチェック(Python の `stop_reason == "tool_use"` に相当)
|
|
||||||
- **リフレクションディスパッチ**: フレームワークが Java リフレクションで、LLM が返したツール名に対応する `@Tool` メソッドを見つけて呼び出す(Python の `TOOL_HANDLERS[block.name]` に相当)
|
|
||||||
- **結果返送**: ツール実行結果は自動的に `tool_result` メッセージとして会話に追加(Python が手動で `tool_result` コンテンツブロックを構築するのに相当)
|
|
||||||
- **ループ終了**: モデルが純粋なテキスト(ツール呼び出しなし)を返すと、`call()` が最終結果を返す
|
|
||||||
|
|
||||||
従って、Python 版の約15行の while ループは、Java 版では1行の `.call()` に凝縮される。
|
|
||||||
|
|
||||||
### 4. `AgentRunner.interactive()`:REPL インタラクションループ
|
|
||||||
|
|
||||||
`AgentRunner` は全レッスン共通の REPL(Read-Eval-Print Loop)ユーティリティクラスで、Python の `if __name__ == "__main__"` 内の `input()` ループに相当する。
|
|
||||||
|
|
||||||
```java
|
|
||||||
public class AgentRunner {
|
|
||||||
/**
|
|
||||||
* インタラクティブ REPL ループを開始。
|
|
||||||
* @param prefix プロンプトプレフィックス(例: "s01")
|
|
||||||
* @param handler ユーザー入力を処理し Agent レスポンスを返す関数
|
|
||||||
*/
|
|
||||||
public static void interactive(String prefix, Function<String, String> handler) {
|
|
||||||
Scanner scanner = new Scanner(System.in);
|
|
||||||
System.out.println("'q' または 'exit' で終了");
|
|
||||||
while (true) {
|
|
||||||
System.out.print("\033[36m" + prefix + " >> \033[0m"); // カラープロンプト
|
|
||||||
String input;
|
|
||||||
try {
|
|
||||||
if (!scanner.hasNextLine()) break;
|
|
||||||
input = scanner.nextLine().trim();
|
|
||||||
} catch (Exception e) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (input.isEmpty() || "exit".equalsIgnoreCase(input) || "q".equalsIgnoreCase(input)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
String response = handler.apply(input); // Agent ハンドラーを呼び出し
|
|
||||||
if (response != null && !response.isBlank()) {
|
|
||||||
System.out.println(response);
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.err.println("Error: " + e.getMessage());
|
|
||||||
}
|
|
||||||
System.out.println();
|
|
||||||
}
|
|
||||||
System.out.println("Bye!");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
ワークフロー:`Scanner` で入力読み取り → `handler.apply()` で Agent に送信 → レスポンス出力 → ループ。`handler` は関数型インターフェースで、各レッスンが自分の Agent 呼び出しロジックを渡す。
|
|
||||||
|
|
||||||
### 5. 完全な Agent クラスとして組み立て
|
|
||||||
|
|
||||||
```java
|
|
||||||
@SpringBootApplication(scanBasePackages = "io.mybatis.learn.core")
|
|
||||||
public class S01AgentLoop implements CommandLineRunner {
|
|
||||||
|
|
||||||
private final ChatClient chatClient;
|
|
||||||
|
|
||||||
public S01AgentLoop(ChatModel chatModel) {
|
|
||||||
this.chatClient = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem("You are a coding agent at ...")
|
|
||||||
.defaultTools(new BashTool())
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void run(String... args) {
|
|
||||||
AgentRunner.interactive("s01", userMessage ->
|
|
||||||
chatClient.prompt()
|
|
||||||
.user(userMessage)
|
|
||||||
.call() // ← この1つの呼び出し = Python の while ループ全体
|
|
||||||
.content()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> **TIPS — Python → Java 主要な適応ポイント:**
|
|
||||||
> - Python の `while True` + `stop_reason` 手動ループ → Spring AI `ChatClient.call()` 内蔵自動ループ
|
|
||||||
> - Python の `TOOLS` 配列 + `TOOL_HANDLERS` 辞書 → `@Tool` アノテーション + `defaultTools()` 自動登録とリフレクションディスパッチ
|
|
||||||
> - Python の `client = Anthropic()` → Spring Boot 自動設定で `ChatModel` を注入
|
|
||||||
> - Python の `input()` インタラクション → `AgentRunner.interactive()` が Scanner REPL + 関数型インターフェースをカプセル化
|
|
||||||
|
|
||||||
コアコード40行未満、これがエージェント全体だ。残り11章はすべてこのループの上にメカニズムを積み重ねる -- ループ自体は決して変わらない。
|
|
||||||
|
|
||||||
## 変更点
|
|
||||||
|
|
||||||
| コンポーネント | 変更前 | 変更後 |
|
|
||||||
|---------------|------------|--------------------------------------------------|
|
|
||||||
| Agent loop | (なし) | `ChatClient.call()` 内蔵ツールループ |
|
|
||||||
| Tools | (なし) | `BashTool` (単一の `@Tool` ツール) |
|
|
||||||
| Messages | (なし) | Spring AI が内部でメッセージリストを管理 |
|
|
||||||
| Control flow | (なし) | フレームワークが自動判定: ツール呼び出しなしで最終テキストを返す |
|
|
||||||
|
|
||||||
```java
|
|
||||||
// コアコード -- 構築 + 呼び出し
|
|
||||||
ChatClient chatClient = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem("You are a coding agent ...")
|
|
||||||
.defaultTools(new BashTool())
|
|
||||||
.build();
|
|
||||||
|
|
||||||
AgentRunner.interactive("s01", userMessage ->
|
|
||||||
chatClient.prompt().user(userMessage).call().content()
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
## 試してみる
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd learn-claude-code
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s01.S01AgentLoop
|
|
||||||
```
|
|
||||||
|
|
||||||
> 実行前に環境変数の設定が必要: `AI_API_KEY`, `AI_BASE_URL`, `AI_MODEL`
|
|
||||||
>
|
|
||||||
> **デフォルトプロトコルは OpenAI**(OpenAI 公式、Azure OpenAI、OpenAI 互換インターフェースを提供するサードパーティモデルサービスなど、すべての OpenAI API 形式のサービスに対応)。
|
|
||||||
> Anthropic プロトコル(Claude ネイティブ API)を使用する場合は、以下のセクションを展開してください。
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>AI プロトコルの切り替え(OpenAI ↔ Anthropic)</strong></summary>
|
|
||||||
|
|
||||||
このプロジェクトは **Spring AI の Starter 依存 + 設定ファイル** で基盤プロトコルを切り替える。Java ビジネスコード(`ChatModel`、`ChatClient`)は**変更不要**。
|
|
||||||
|
|
||||||
#### 方式 1:OpenAI プロトコル(デフォルト)
|
|
||||||
|
|
||||||
`pom.xml` の依存:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.ai</groupId>
|
|
||||||
<artifactId>spring-ai-starter-model-openai</artifactId>
|
|
||||||
</dependency>
|
|
||||||
```
|
|
||||||
|
|
||||||
`application.yml` の設定:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
spring:
|
|
||||||
ai:
|
|
||||||
openai:
|
|
||||||
api-key: ${AI_API_KEY:sk-xxx}
|
|
||||||
base-url: ${AI_BASE_URL:https://api.openai.com}
|
|
||||||
chat:
|
|
||||||
options:
|
|
||||||
model: ${AI_MODEL:gpt-4o}
|
|
||||||
```
|
|
||||||
|
|
||||||
環境変数の例:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
export AI_API_KEY=sk-proj-xxxxxxxx
|
|
||||||
export AI_BASE_URL=https://api.openai.com # 任意の OpenAI 互換エンドポイントに変更可
|
|
||||||
export AI_MODEL=gpt-4o
|
|
||||||
```
|
|
||||||
|
|
||||||
> **TIP**: 多くのサードパーティモデルサービス(DeepSeek、Mistral、Qwen など)が OpenAI 互換 API を提供している。`AI_BASE_URL` と `AI_MODEL` を変更するだけで接続でき、プロトコル切り替えは不要。
|
|
||||||
|
|
||||||
#### 方式 2:Anthropic プロトコル(Claude ネイティブ API)
|
|
||||||
|
|
||||||
**ステップ 1**:`pom.xml` を編集 — OpenAI starter を Anthropic starter に置き換え:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<!-- OpenAI starter をコメントアウトまたは削除 -->
|
|
||||||
<!-- <dependency>
|
|
||||||
<groupId>org.springframework.ai</groupId>
|
|
||||||
<artifactId>spring-ai-starter-model-openai</artifactId>
|
|
||||||
</dependency> -->
|
|
||||||
|
|
||||||
<!-- Anthropic starter を追加 -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.ai</groupId>
|
|
||||||
<artifactId>spring-ai-starter-model-anthropic</artifactId>
|
|
||||||
</dependency>
|
|
||||||
```
|
|
||||||
|
|
||||||
**ステップ 2**:`application.yml` を編集 — `spring.ai.openai` を `spring.ai.anthropic` に置き換え:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
spring:
|
|
||||||
ai:
|
|
||||||
anthropic:
|
|
||||||
api-key: ${AI_API_KEY}
|
|
||||||
base-url: ${AI_BASE_URL:https://api.anthropic.com}
|
|
||||||
chat:
|
|
||||||
options:
|
|
||||||
model: ${AI_MODEL:claude-sonnet-4-20250514}
|
|
||||||
```
|
|
||||||
|
|
||||||
**ステップ 3**:環境変数を設定:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
export AI_API_KEY=sk-ant-xxxxxxxx
|
|
||||||
export AI_BASE_URL=https://api.anthropic.com
|
|
||||||
export AI_MODEL=claude-sonnet-4-20250514
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 切り替えの仕組み
|
|
||||||
|
|
||||||
Spring AI の `ChatModel` は統一された抽象インターフェース。異なる Starter が異なる実装を提供する:
|
|
||||||
|
|
||||||
| Starter 依存 | 自動注入される ChatModel 実装 | 設定プレフィックス |
|
|
||||||
|---|---|---|
|
|
||||||
| `spring-ai-starter-model-openai` | `OpenAiChatModel` | `spring.ai.openai.*` |
|
|
||||||
| `spring-ai-starter-model-anthropic` | `AnthropicChatModel` | `spring.ai.anthropic.*` |
|
|
||||||
|
|
||||||
ビジネスコードは常に `ChatModel` インターフェースに対してプログラムする。プロトコル切り替えには依存と設定の変更だけが必要で、Java コードの変更は不要。
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
以下のプロンプトを試してみよう(英語プロンプトの方が LLM に効果的だが、日本語でも可):
|
|
||||||
|
|
||||||
1. `Create a file called Hello.java that prints "Hello, World!"`
|
|
||||||
2. `List all Java files in this directory`
|
|
||||||
3. `What is the current git branch?`
|
|
||||||
4. `Create a directory called test_output and write 3 files in it`
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
# s02: Tool Use (ツール使用)
|
|
||||||
|
|
||||||
`s01 > [ s02 ] s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
|
|
||||||
|
|
||||||
> *"ツールを足すなら、@Tool メソッドを1つ足すだけ"* -- ループは変わらない。新ツールは `defaultTools()` に渡すだけ。
|
|
||||||
>
|
|
||||||
> **Harness 層**: ツール分配 -- モデルが届く範囲を広げる。
|
|
||||||
|
|
||||||
## 問題
|
|
||||||
|
|
||||||
`bash` だけでは、すべての操作がシェル経由になる。`cat` は予測不能に切り詰め、`sed` は特殊文字で壊れ、すべての bash 呼び出しが制約のないセキュリティ面になる。専用ツール (`read_file`, `write_file`) ならツールレベルでパスのサンドボックス化を強制できる。
|
|
||||||
|
|
||||||
重要な洞察: ツールを追加してもループの変更は不要。
|
|
||||||
|
|
||||||
## 解決策
|
|
||||||
|
|
||||||
```
|
|
||||||
+--------+ +-------+ +--------------------+
|
|
||||||
| User | ---> | LLM | ---> | defaultTools() |
|
|
||||||
| prompt | | | | { |
|
|
||||||
+--------+ +---+---+ | BashTool |
|
|
||||||
^ | ReadFileTool |
|
|
||||||
| | WriteFileTool |
|
|
||||||
+-----------+ EditFileTool |
|
|
||||||
tool_result | } |
|
|
||||||
+--------------------+
|
|
||||||
|
|
||||||
Spring AI が @Tool アノテーションで自動的に登録・分配する。
|
|
||||||
手書きの dispatch map は不要、フレームワークがツールオブジェクトのアノテーションメソッドをスキャンする。
|
|
||||||
```
|
|
||||||
|
|
||||||
## 仕組み
|
|
||||||
|
|
||||||
1. 各ツールは独立したクラスで、`@Tool` アノテーションで宣言する。`PathValidator` がパスサンドボックスでワークスペース外への脱出を防ぐ。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// PathValidator -- Python 版の safe_path() 関数に相当
|
|
||||||
public class PathValidator {
|
|
||||||
private final Path workDir;
|
|
||||||
|
|
||||||
public Path resolve(String relativePath) {
|
|
||||||
Path resolved = workDir.resolve(relativePath).toAbsolutePath().normalize();
|
|
||||||
if (!resolved.startsWith(workDir)) {
|
|
||||||
throw new IllegalArgumentException("Path escapes workspace: " + relativePath);
|
|
||||||
}
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadFileTool -- Python 版の run_read() 関数に相当
|
|
||||||
public class ReadFileTool {
|
|
||||||
private final PathValidator pathValidator;
|
|
||||||
|
|
||||||
@Tool(description = "Read file contents. Optionally limit the number of lines returned.")
|
|
||||||
public String readFile(
|
|
||||||
@ToolParam(description = "Relative path to the file") String path,
|
|
||||||
@ToolParam(description = "Maximum number of lines to read", required = false) Integer limit) {
|
|
||||||
Path filePath = pathValidator.resolve(path);
|
|
||||||
List<String> lines = Files.readAllLines(filePath);
|
|
||||||
if (limit != null && limit > 0 && limit < lines.size()) {
|
|
||||||
lines = lines.subList(0, limit);
|
|
||||||
}
|
|
||||||
return String.join("\n", lines);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. ツール登録は `defaultTools()` に渡すだけ。Spring AI が `@Tool` アノテーションメソッドをスキャンし、名前マッピングとパラメータバインディングを自動的に行う。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// Python 版の TOOL_HANDLERS 辞書に相当
|
|
||||||
// Python: TOOL_HANDLERS = {"bash": fn, "read_file": fn, "write_file": fn, "edit_file": fn}
|
|
||||||
// Java: ツールオブジェクトを渡すだけ、@Tool アノテーションで自動登録
|
|
||||||
this.chatClient = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem("You are a coding agent ...")
|
|
||||||
.defaultTools(
|
|
||||||
new BashTool(), // bash コマンド実行
|
|
||||||
new ReadFileTool(), // ファイル読み取り
|
|
||||||
new WriteFileTool(), // ファイル書き込み
|
|
||||||
new EditFileTool() // ファイル編集(検索置換)
|
|
||||||
)
|
|
||||||
.build();
|
|
||||||
```
|
|
||||||
|
|
||||||
3. 呼び出しコードは s01 と完全に同一。ループはフレームワークが管理し、開発者はツール実装だけに集中する。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// s01 との違いは defaultTools() に3つのツールオブジェクトが追加されたこと
|
|
||||||
// ループコードは完全に同一 -- これが s02 の核心的な洞察
|
|
||||||
AgentRunner.interactive("s02", userMessage ->
|
|
||||||
chatClient.prompt()
|
|
||||||
.user(userMessage)
|
|
||||||
.call()
|
|
||||||
.content()
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
ツール追加 = `@Tool` クラスを1つ追加 + `defaultTools()` に渡す。ループは決して変わらない。
|
|
||||||
|
|
||||||
> **TIPS — Python → Java 主要な適応ポイント:**
|
|
||||||
> - Python の `TOOL_HANDLERS` 辞書 → Spring AI `@Tool` アノテーション + `defaultTools()` 自動登録・分配
|
|
||||||
> - Python の `safe_path()` 関数 → `PathValidator` クラス(同じパス脱出チェックロジック)
|
|
||||||
> - Python の `lambda **kw` パラメータ展開 → `@ToolParam` アノテーションで自動バインディング
|
|
||||||
> - Python の `block.type == "tool_use"` 判定 → Spring AI が内部で自動検出・分配
|
|
||||||
|
|
||||||
## s01 からの変更点
|
|
||||||
|
|
||||||
| コンポーネント | 変更前 (s01) | 変更後 (s02) |
|
|
||||||
|----------------|-----------------------|----------------------------------------|
|
|
||||||
| Tools | 1 (`BashTool`) | 4 (`Bash`, `ReadFile`, `WriteFile`, `EditFile`) |
|
|
||||||
| Dispatch | `defaultTools(bash)` | `defaultTools(bash, read, write, edit)` |
|
|
||||||
| パス安全性 | なし | `PathValidator` サンドボックス |
|
|
||||||
| Agent loop | 不変 | 不変 |
|
|
||||||
|
|
||||||
```java
|
|
||||||
// s01 → s02 唯一の変更: defaultTools() に3つのツールオブジェクトを追加
|
|
||||||
.defaultTools(
|
|
||||||
new BashTool(),
|
|
||||||
new ReadFileTool(), // +新規追加
|
|
||||||
new WriteFileTool(), // +新規追加
|
|
||||||
new EditFileTool() // +新規追加
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 試してみる
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd learn-claude-code
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s02.S02ToolUse
|
|
||||||
```
|
|
||||||
|
|
||||||
> 実行前に環境変数の設定が必要: `AI_API_KEY`, `AI_BASE_URL`, `AI_MODEL`
|
|
||||||
|
|
||||||
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
|
|
||||||
|
|
||||||
1. `Read the file pom.xml`
|
|
||||||
2. `Create a file called Greet.java with a greet(name) method`
|
|
||||||
3. `Edit Greet.java to add a Javadoc comment to the method`
|
|
||||||
4. `Read Greet.java to verify the edit worked`
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
# s03: TodoWrite (Todo書き込み)
|
|
||||||
|
|
||||||
`s01 > s02 > [ s03 ] s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
|
|
||||||
|
|
||||||
> *"計画のないエージェントは行き当たりばったり"* -- まずステップを書き出し、それから実行。完了率は倍増する。
|
|
||||||
>
|
|
||||||
> **Harness 層**: 計画 -- 航路を描かずにモデルを軌道に乗せる。
|
|
||||||
|
|
||||||
## 問題
|
|
||||||
|
|
||||||
マルチステップのタスクで、モデルは進捗を見失う -- 既にやったことを繰り返したり、ステップを飛ばしたり、脱線したりする。会話が長くなるほど悪化する: ツール結果がコンテキストを埋め尽くし、システムプロンプトの影響力が徐々に薄れる。10ステップのリファクタリングでステップ1-3を完了した後、即興を始めてしまう。ステップ4-10はもう注意の外だ。
|
|
||||||
|
|
||||||
## 解決策
|
|
||||||
|
|
||||||
```
|
|
||||||
+--------+ +-------+ +---------+
|
|
||||||
| User | ---> | LLM | ---> | Tools |
|
|
||||||
| prompt | | | | + todo |
|
|
||||||
+--------+ +---+---+ +----+----+
|
|
||||||
^ |
|
|
||||||
| tool_result |
|
|
||||||
+----------------+
|
|
||||||
|
|
|
||||||
+-----------+-----------+
|
|
||||||
| TodoManager state |
|
|
||||||
| [ ] task A |
|
|
||||||
| [>] task B <- doing |
|
|
||||||
| [x] task C |
|
|
||||||
+-----------------------+
|
|
||||||
|
|
|
||||||
毎回のリクエスト時に defaultSystem() で
|
|
||||||
最新の todo 状態をシステムプロンプトに注入
|
|
||||||
```
|
|
||||||
|
|
||||||
## 仕組み
|
|
||||||
|
|
||||||
1. TodoManager はステータス付きアイテムを保持する。同時に `in_progress` にできるのは1つだけ。
|
|
||||||
|
|
||||||
```java
|
|
||||||
public class TodoManager {
|
|
||||||
|
|
||||||
public record TodoItem(String id, String text, String status) {}
|
|
||||||
|
|
||||||
private List<TodoItem> items = new ArrayList<>();
|
|
||||||
|
|
||||||
@Tool(description = "Update the full task list to track progress. "
|
|
||||||
+ "Each item must have id, text, status (pending/in_progress/completed). "
|
|
||||||
+ "Only one task can be in_progress at a time. Max 20 items.")
|
|
||||||
public String updateTodos(
|
|
||||||
@ToolParam(description = "The complete list of todo items")
|
|
||||||
List<TodoItem> items) {
|
|
||||||
if (items.size() > 20) return "Error: Max 20 todos allowed";
|
|
||||||
List<TodoItem> validated = new ArrayList<>();
|
|
||||||
int inProgressCount = 0;
|
|
||||||
for (TodoItem item : items) {
|
|
||||||
String status = (item.status() != null)
|
|
||||||
? item.status().toLowerCase() : "pending";
|
|
||||||
if ("in_progress".equals(status)) inProgressCount++;
|
|
||||||
validated.add(new TodoItem(item.id(), item.text().trim(), status));
|
|
||||||
}
|
|
||||||
if (inProgressCount > 1)
|
|
||||||
return "Error: Only one task can be in_progress at a time";
|
|
||||||
this.items = validated;
|
|
||||||
return render();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. `TodoManager` は `defaultTools()` で登録し、`@Tool` アノテーションメソッドが自動的にツールとして公開される。
|
|
||||||
|
|
||||||
```java
|
|
||||||
ChatClient chatClient = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem(system)
|
|
||||||
.defaultTools(
|
|
||||||
new BashTool(),
|
|
||||||
new ReadFileTool(),
|
|
||||||
new WriteFileTool(),
|
|
||||||
new EditFileTool(),
|
|
||||||
todoManager // @Tool アノテーションメソッドが自動登録
|
|
||||||
)
|
|
||||||
.build();
|
|
||||||
```
|
|
||||||
|
|
||||||
3. システムプロンプト注入: ユーザー入力のたびに、最新の todo 状態をシステムプロンプトに注入し、更新指示を強調する。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// 動的システムプロンプト: 現在の todo 状態を含む
|
|
||||||
String system = "You are a coding agent at " + workDir + ".\n"
|
|
||||||
+ "Use the todo tool to plan multi-step tasks. "
|
|
||||||
+ "Mark in_progress before starting, completed when done.\n"
|
|
||||||
+ "IMPORTANT: You MUST call updateTodos regularly.\n\n"
|
|
||||||
+ "<current-todos>\n" + todoManager.render() + "\n</current-todos>";
|
|
||||||
```
|
|
||||||
|
|
||||||
「同時に in_progress は1つだけ」の制約が逐次的な集中を強制する。システムプロンプトへの todo 状態の継続的な注入が説明責任を生む -- モデルは毎回自分の計画を見るため、更新を忘れない。
|
|
||||||
|
|
||||||
> **TIP**: Python 版ではツールループ内で `rounds_since_todo` を追跡し、3ラウンド連続で todo を呼ばなかった場合に `<reminder>` テキストを注入する。Spring AI の ChatClient は内部でツールループを自動管理するため、ループ内での注入はできない。そのため、システムプロンプト注入方式で同等の効果を実現している。
|
|
||||||
|
|
||||||
## s02 からの変更点
|
|
||||||
|
|
||||||
| コンポーネント | 変更前 (s02) | 変更後 (s03) |
|
|
||||||
|----------------|------------------|--------------------------------------|
|
|
||||||
| Tools | 4 | 5 (+TodoManager `@Tool`) |
|
|
||||||
| 計画 | なし | ステータス付き TodoManager |
|
|
||||||
| 状態注入 | なし | システムプロンプトに `<current-todos>` を注入 |
|
|
||||||
| ChatClient | 固定システムプロンプト | 毎ターン再構築、動的に todo 状態を注入 |
|
|
||||||
|
|
||||||
## 試してみる
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd learn-claude-code
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s03.S03TodoWrite
|
|
||||||
```
|
|
||||||
|
|
||||||
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
|
|
||||||
|
|
||||||
1. `Refactor the file Hello.java: add JavaDoc, improve naming, and keep main method behavior unchanged`
|
|
||||||
2. `Create a Java package with utils and tests`
|
|
||||||
3. `Review all Java files and fix any style issues`
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
# s04: Subagents (サブエージェント)
|
|
||||||
|
|
||||||
`s01 > s02 > s03 > [ s04 ] s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
|
|
||||||
|
|
||||||
> *"大きなタスクを分割し、各サブタスクにクリーンなコンテキストを"* -- サブエージェントは独立した messages[] を使い、メイン会話を汚さない。
|
|
||||||
>
|
|
||||||
> **Harness 層**: コンテキスト隔離 -- モデルの思考の明晰さを守る。
|
|
||||||
|
|
||||||
## 問題
|
|
||||||
|
|
||||||
エージェントが作業するにつれ、messages 配列は膨張し続ける。すべてのファイル読み取り、すべてのコマンド出力がコンテキストに永久に残る。「このプロジェクトはどのテストフレームワークを使っているか」という質問は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` を除くすべての基本ツールを持つ(再帰的な生成は不可)。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// 親 Agent: 基本ツール + SubagentTool を持つ
|
|
||||||
this.chatClient = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem("You are a coding agent. "
|
|
||||||
+ "Use the task tool to delegate subtasks.")
|
|
||||||
.defaultTools(
|
|
||||||
new BashTool(),
|
|
||||||
new ReadFileTool(),
|
|
||||||
new WriteFileTool(),
|
|
||||||
new EditFileTool(),
|
|
||||||
new SubagentTool(chatModel) // 親 Agent 専用
|
|
||||||
)
|
|
||||||
.build();
|
|
||||||
```
|
|
||||||
|
|
||||||
2. サブエージェントは新しい `ChatClient` で起動し、独立したコンテキストを持つ。最終テキストだけが親に返る。
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Tool(description = "Spawn a subagent with fresh context. "
|
|
||||||
+ "Use for exploration or subtasks that might pollute the main context.")
|
|
||||||
public String task(
|
|
||||||
@ToolParam(description = "The task prompt") String prompt,
|
|
||||||
@ToolParam(description = "Short description", required = false)
|
|
||||||
String description) {
|
|
||||||
|
|
||||||
// 新しい ChatClient を作成 -- これが「コンテキスト隔離」のすべて
|
|
||||||
ChatClient subClient = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem("You are a coding subagent. "
|
|
||||||
+ "Complete the task, then summarize findings.")
|
|
||||||
.defaultTools( // 基本ツール、task なし(再帰防止)
|
|
||||||
new BashTool(),
|
|
||||||
new ReadFileTool(),
|
|
||||||
new WriteFileTool(),
|
|
||||||
new EditFileTool()
|
|
||||||
)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
String result = subClient.prompt()
|
|
||||||
.user(prompt)
|
|
||||||
.call()
|
|
||||||
.content();
|
|
||||||
|
|
||||||
// 最終テキストだけを返し、子 Agent のコンテキストは破棄
|
|
||||||
return (result != null) ? result : "(no summary)";
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
サブエージェントは複数回のツール呼び出しを実行するかもしれないが、メッセージ履歴全体は破棄される。親が受け取るのは要約テキストだけで、通常の `tool_result` として返される。Spring AI の `ChatClient.call()` が内部でツールループを管理するため、手動でイテレーション回数を制限する必要はない。
|
|
||||||
|
|
||||||
## s03 からの変更点
|
|
||||||
|
|
||||||
| コンポーネント | 変更前 (s03) | 変更後 (s04) |
|
|
||||||
|----------------|------------------|---------------------------------------|
|
|
||||||
| Tools | 5 | 5 (基本) + SubagentTool (親側のみ) |
|
|
||||||
| コンテキスト | 単一共有 | 親 + 子隔離 (独立した ChatClient) |
|
|
||||||
| Subagent | なし | `SubagentTool.task()` メソッド |
|
|
||||||
| 戻り値 | 該当なし | 要約テキストのみ |
|
|
||||||
|
|
||||||
## 試してみる
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd learn-claude-code
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s04.S04Subagent
|
|
||||||
```
|
|
||||||
|
|
||||||
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
|
|
||||||
|
|
||||||
1. `Use a subtask to find what testing framework this project uses`
|
|
||||||
2. `Delegate: read all .java files and summarize what each one does`
|
|
||||||
3. `Use a task to create a new module, then verify it from here`
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
# 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` ファイルを含むディレクトリで、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` を再帰的にスキャンし、ディレクトリ名をスキル識別子として使用する。
|
|
||||||
|
|
||||||
```java
|
|
||||||
public class SkillLoader {
|
|
||||||
|
|
||||||
private static final Pattern FRONTMATTER_PATTERN =
|
|
||||||
Pattern.compile("^---\\n(.*?)\\n---\\n(.*)", Pattern.DOTALL);
|
|
||||||
|
|
||||||
private final Map<String, SkillInfo> skills = new LinkedHashMap<>();
|
|
||||||
|
|
||||||
record SkillInfo(Map<String, String> meta, String body, String path) {}
|
|
||||||
|
|
||||||
public SkillLoader(Path skillsDir) {
|
|
||||||
loadAll(skillsDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** skills ディレクトリ配下のすべての SKILL.md ファイルを再帰スキャン */
|
|
||||||
private void loadAll(Path skillsDir) {
|
|
||||||
if (!Files.exists(skillsDir)) return;
|
|
||||||
try (Stream<Path> paths = Files.walk(skillsDir)) {
|
|
||||||
paths.filter(p -> p.getFileName().toString().equals("SKILL.md"))
|
|
||||||
.sorted()
|
|
||||||
.forEach(p -> {
|
|
||||||
String text = Files.readString(p);
|
|
||||||
var parsed = parseFrontmatter(text);
|
|
||||||
String name = parsed.meta().getOrDefault("name",
|
|
||||||
p.getParent().getFileName().toString());
|
|
||||||
skills.put(name, new SkillInfo(
|
|
||||||
parsed.meta(), parsed.body(), p.toString()));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Layer 1: 全スキルの短い説明を取得(システムプロンプト注入用) */
|
|
||||||
public String getDescriptions() {
|
|
||||||
if (skills.isEmpty()) return "(no skills available)";
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
for (var entry : skills.entrySet()) {
|
|
||||||
String desc = entry.getValue().meta()
|
|
||||||
.getOrDefault("description", "No description");
|
|
||||||
sb.append(" - ").append(entry.getKey())
|
|
||||||
.append(": ").append(desc).append("\n");
|
|
||||||
}
|
|
||||||
return sb.toString().stripTrailing();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Layer 2: 指定スキルの完全なコンテンツを読み込む(@Tool メソッドとして) */
|
|
||||||
@Tool(description = "Load specialized knowledge by name.")
|
|
||||||
public String loadSkill(
|
|
||||||
@ToolParam(description = "Skill name to load") String name) {
|
|
||||||
SkillInfo skill = skills.get(name);
|
|
||||||
if (skill == null)
|
|
||||||
return "Error: Unknown skill '" + name + "'. Available: "
|
|
||||||
+ String.join(", ", skills.keySet());
|
|
||||||
return "<skill name=\"" + name + "\">\n"
|
|
||||||
+ skill.body() + "\n</skill>";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. 第1層はシステムプロンプトに配置。第2層は SkillLoader 上の `@Tool` アノテーションメソッドでオンデマンド読み込み。
|
|
||||||
|
|
||||||
```java
|
|
||||||
public S05SkillLoading(ChatModel chatModel) {
|
|
||||||
Path skillsDir = Path.of(System.getProperty("user.dir"), "skills");
|
|
||||||
SkillLoader skillLoader = new SkillLoader(skillsDir);
|
|
||||||
|
|
||||||
// Layer 1: スキルメタデータをシステムプロンプトに注入
|
|
||||||
String system = "You are a coding agent at " + System.getProperty("user.dir") + ".\n"
|
|
||||||
+ "Use loadSkill to access specialized knowledge.\n\n"
|
|
||||||
+ "Skills available:\n"
|
|
||||||
+ skillLoader.getDescriptions();
|
|
||||||
|
|
||||||
this.chatClient = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem(system)
|
|
||||||
.defaultTools(
|
|
||||||
new BashTool(), new ReadFileTool(),
|
|
||||||
new WriteFileTool(), new EditFileTool(),
|
|
||||||
skillLoader // Layer 2: loadSkill @Tool メソッド
|
|
||||||
)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
モデルはどのスキルが存在するかを知り(低コスト)、必要な時にだけ完全なコンテンツを読み込む(高コスト)。
|
|
||||||
|
|
||||||
## s04 からの変更点
|
|
||||||
|
|
||||||
| コンポーネント | 変更前 (s04) | 変更後 (s05) |
|
|
||||||
|----------------|------------------|--------------------------------|
|
|
||||||
| Tools | 5 (基本 + task) | 5 (基本 + load_skill) |
|
|
||||||
| システムプロンプト | 静的文字列 | + スキル説明リスト |
|
|
||||||
| 知識ベース | なし | skills/\*/SKILL.md ファイル |
|
|
||||||
| 注入方式 | なし | 二層構造 (システムプロンプト + result) |
|
|
||||||
|
|
||||||
## 試してみる
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd learn-claude-code
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s05.S05SkillLoading
|
|
||||||
```
|
|
||||||
|
|
||||||
以下のプロンプトを試してみよう (英語プロンプトの方が 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`
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
# s06: Context Compact (コンテキスト圧縮)
|
|
||||||
|
|
||||||
`s01 > s02 > s03 > s04 > s05 > [ s06 ] | s07 > s08 > s09 > s10 > s11 > s12`
|
|
||||||
|
|
||||||
> *"コンテキストはいつか溢れる、空ける手段が要る"* -- 3層圧縮で無限セッションを実現。
|
|
||||||
>
|
|
||||||
> **Harness 層**: 圧縮 -- クリーンな記憶、無限のセッション。
|
|
||||||
|
|
||||||
## 問題
|
|
||||||
|
|
||||||
コンテキストウィンドウは有限だ。1000行のファイルを読むだけで約4000トークンを消費する。30ファイルを読み20回のコマンドを実行すると、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層 -- コンテキストウィンドウ管理**: Spring AI の ChatClient は内部でツールループを自動管理するため、ループ内に圧縮を挿入できない。Java 版では、システムプロンプトに注入する会話ターン数を制限し(最近の N ターンのみ保持)、コンテンツを切り詰めることで同等の効果を実現する。
|
|
||||||
|
|
||||||
```java
|
|
||||||
/** トークン数の推定: 粗い見積もりで 4文字 ≈ 1トークン */
|
|
||||||
public int estimateTokens() {
|
|
||||||
int chars = history.stream().mapToInt(t -> t.content().length()).sum();
|
|
||||||
return chars / 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 会話履歴のサマリーを取得(システムプロンプト注入用、最近数ターンのみ保持) */
|
|
||||||
public String getContextSummary() {
|
|
||||||
if (history.isEmpty()) return "";
|
|
||||||
StringBuilder sb = new StringBuilder("\n<conversation-context>\n");
|
|
||||||
int start = Math.max(0, history.size() - KEEP_RECENT * 2);
|
|
||||||
for (int i = start; i < history.size(); i++) {
|
|
||||||
ConversationTurn turn = history.get(i);
|
|
||||||
sb.append("[").append(turn.role()).append("]: ")
|
|
||||||
.append(turn.content(), 0, Math.min(500, turn.content().length()))
|
|
||||||
.append("\n");
|
|
||||||
}
|
|
||||||
sb.append("</conversation-context>");
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **第2層 -- auto_compact**: トークンが閾値を超えたら、完全な会話をディスクに保存し、LLM に要約を依頼する。
|
|
||||||
|
|
||||||
```java
|
|
||||||
public String compact() {
|
|
||||||
// トランスクリプトをディスクに保存(完全な履歴は失われない)
|
|
||||||
Files.createDirectories(transcriptDir);
|
|
||||||
Path transcriptPath = transcriptDir.resolve(
|
|
||||||
"transcript_" + System.currentTimeMillis() + ".jsonl");
|
|
||||||
try (BufferedWriter writer = Files.newBufferedWriter(transcriptPath)) {
|
|
||||||
for (ConversationTurn turn : history) {
|
|
||||||
writer.write(objectMapper.writeValueAsString(turn));
|
|
||||||
writer.newLine();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LLM が要約を生成
|
|
||||||
String conversationText = history.stream()
|
|
||||||
.map(t -> t.role() + ": " + t.content())
|
|
||||||
.reduce("", (a, b) -> a + "\n" + b);
|
|
||||||
if (conversationText.length() > 80000) {
|
|
||||||
conversationText = conversationText.substring(0, 80000);
|
|
||||||
}
|
|
||||||
|
|
||||||
ChatClient summaryClient = ChatClient.builder(chatModel).build();
|
|
||||||
String summary = summaryClient.prompt()
|
|
||||||
.user("Summarize this conversation for continuity. Include: "
|
|
||||||
+ "1) What was accomplished, 2) Current state, "
|
|
||||||
+ "3) Key decisions.\n\n" + conversationText)
|
|
||||||
.call().content();
|
|
||||||
|
|
||||||
// 要約で履歴を置換
|
|
||||||
history.clear();
|
|
||||||
history.add(new ConversationTurn("system",
|
|
||||||
"[Conversation compressed. Transcript: " + transcriptPath
|
|
||||||
+ "]\n\n" + summary));
|
|
||||||
return summary;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **第3層 -- manual compact**: `CompactTool` ツールが同じ要約メカニズムをオンデマンドでトリガーする。
|
|
||||||
|
|
||||||
```java
|
|
||||||
public class CompactTool {
|
|
||||||
private final ContextCompactor compactor;
|
|
||||||
|
|
||||||
public CompactTool(ContextCompactor compactor) {
|
|
||||||
this.compactor = compactor;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Tool(description = "Trigger manual conversation compression to free up context space.")
|
|
||||||
public String compact(
|
|
||||||
@ToolParam(description = "What to preserve in summary",
|
|
||||||
required = false) String focus) {
|
|
||||||
compactor.requestCompact();
|
|
||||||
return "Compression triggered. Context will be summarized.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
4. REPL 層が3層すべてを統合する(Spring AI の ChatClient が内部でツールループを自動管理するため、圧縮はユーザーメッセージレベルでトリガーされる):
|
|
||||||
|
|
||||||
```java
|
|
||||||
AgentRunner.interactive("s06", userMessage -> {
|
|
||||||
// Layer 2: 自動圧縮チェック(毎回のユーザー入力前)
|
|
||||||
if (compactor.needsAutoCompact()) {
|
|
||||||
System.out.println("[auto_compact triggered]");
|
|
||||||
compactor.compact();
|
|
||||||
}
|
|
||||||
compactor.addTurn("user", userMessage);
|
|
||||||
|
|
||||||
// 動的システムプロンプト: 会話コンテキストサマリーを含む
|
|
||||||
String system = baseSystem + compactor.getContextSummary();
|
|
||||||
ChatClient chatClient = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem(system)
|
|
||||||
.defaultTools(new BashTool(), new ReadFileTool(),
|
|
||||||
new WriteFileTool(), new EditFileTool(), compactTool)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
String response = chatClient.prompt()
|
|
||||||
.user(userMessage).call().content();
|
|
||||||
compactor.addTurn("assistant", response != null ? response : "");
|
|
||||||
|
|
||||||
// Layer 3: 手動圧縮(Agent が compact ツールを呼び出した場合)
|
|
||||||
if (compactor.isCompactRequested()) {
|
|
||||||
compactor.compact();
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
完全な履歴はトランスクリプトとしてディスク上に保存される。情報は真に失われるのではなく、アクティブなコンテキストの外に移動されるだけだ。
|
|
||||||
|
|
||||||
## s05 からの変更点
|
|
||||||
|
|
||||||
| コンポーネント | 変更前 (s05) | 変更後 (s06) |
|
|
||||||
|----------------|------------------|--------------------------------|
|
|
||||||
| Tools | 5 | 5 (基本 + compact) |
|
|
||||||
| コンテキスト管理 | なし | 三層圧縮 |
|
|
||||||
| コンテキストウィンドウ管理 | なし | 注入ターン数制限 + コンテンツ切り詰め |
|
|
||||||
| Auto-compact | なし | トークン閾値トリガー |
|
|
||||||
| Transcripts | なし | .transcripts/ に保存 |
|
|
||||||
|
|
||||||
## 試してみる
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd learn-claude-code
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s06.S06ContextCompact
|
|
||||||
```
|
|
||||||
|
|
||||||
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
|
|
||||||
|
|
||||||
1. `Read every Java file in the src/ directory one by one` (コンテキストウィンドウ管理の効果を観察する)
|
|
||||||
2. `Keep reading files until compression triggers automatically`
|
|
||||||
3. `Use the compact tool to manually compress the conversation`
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
# 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`)・後方依存 (`blocks`) を持つ。タスクグラフは常に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。Jackson `ObjectMapper` で JSON シリアライゼーションを行う。
|
|
||||||
|
|
||||||
```java
|
|
||||||
public class TaskManager {
|
|
||||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
|
||||||
private final Path dir;
|
|
||||||
private int nextId;
|
|
||||||
|
|
||||||
public TaskManager(Path tasksDir) {
|
|
||||||
this.dir = tasksDir;
|
|
||||||
Files.createDirectories(dir);
|
|
||||||
this.nextId = maxId() + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Tool(description = "Create a new task with subject and optional description")
|
|
||||||
public String taskCreate(
|
|
||||||
@ToolParam(description = "Short subject of the task") String subject,
|
|
||||||
@ToolParam(description = "Detailed description", required = false) String description) {
|
|
||||||
Map<String, Object> task = new LinkedHashMap<>();
|
|
||||||
task.put("id", nextId);
|
|
||||||
task.put("subject", subject);
|
|
||||||
task.put("status", "pending");
|
|
||||||
task.put("blockedBy", new ArrayList<>());
|
|
||||||
task.put("blocks", new ArrayList<>());
|
|
||||||
save(task);
|
|
||||||
nextId++;
|
|
||||||
return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(task);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **依存解除**: タスク完了時に、他タスクの `blockedBy` リストから完了 ID を除去し、後続タスクをアンブロックする。
|
|
||||||
|
|
||||||
```java
|
|
||||||
private void clearDependency(int completedId) {
|
|
||||||
try (Stream<Path> files = Files.list(dir)) {
|
|
||||||
files.filter(f -> f.getFileName().toString().matches("task_\\d+\\.json"))
|
|
||||||
.forEach(f -> {
|
|
||||||
Map<String, Object> task = MAPPER.readValue(
|
|
||||||
Files.readString(f), new TypeReference<>() {});
|
|
||||||
List<Integer> blockedBy = (List<Integer>) task.get("blockedBy");
|
|
||||||
if (blockedBy != null && blockedBy.remove(Integer.valueOf(completedId))) {
|
|
||||||
save(task);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **ステータス遷移 + 依存配線**: `taskUpdate` がステータス変更と依存エッジを担う。status が `completed` になると自動的に `clearDependency` を呼び出す。`blockedBy`/`blocks` は双方向の関係。
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Tool(description = "Update a task's status or dependencies.")
|
|
||||||
public String taskUpdate(
|
|
||||||
@ToolParam(description = "Task ID") int taskId,
|
|
||||||
@ToolParam(description = "New status", required = false) String status,
|
|
||||||
@ToolParam(description = "Task IDs that block this task", required = false) List<Integer> addBlockedBy,
|
|
||||||
@ToolParam(description = "Task IDs that this task blocks", required = false) List<Integer> addBlocks) {
|
|
||||||
Map<String, Object> task = load(taskId);
|
|
||||||
if (status != null) {
|
|
||||||
task.put("status", status);
|
|
||||||
if ("completed".equals(status)) {
|
|
||||||
clearDependency(taskId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// addBlockedBy / addBlocks の双方向依存を処理 ...
|
|
||||||
save(task);
|
|
||||||
return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(task);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Spring AI 自動ツール登録**: `TaskManager` を `defaultTools` として `ChatClient` に渡すと、Spring AI が `@Tool` アノテーションメソッドを自動認識する。手動 dispatch map は不要。
|
|
||||||
|
|
||||||
```java
|
|
||||||
@SpringBootApplication(scanBasePackages = "io.mybatis.learn.core")
|
|
||||||
public class S07TaskSystem implements CommandLineRunner {
|
|
||||||
|
|
||||||
private final ChatClient chatClient;
|
|
||||||
|
|
||||||
public S07TaskSystem(ChatModel chatModel) {
|
|
||||||
Path tasksDir = Path.of(System.getProperty("user.dir"), ".tasks");
|
|
||||||
TaskManager taskManager = new TaskManager(tasksDir);
|
|
||||||
|
|
||||||
this.chatClient = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem("You are a coding agent. Use task tools to plan and track work.")
|
|
||||||
.defaultTools(
|
|
||||||
new BashTool(), new ReadFileTool(),
|
|
||||||
new WriteFileTool(), new EditFileTool(),
|
|
||||||
taskManager // TaskManager 内の @Tool メソッドが自動登録
|
|
||||||
)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
s07 以降、タスクグラフがマルチステップ作業のデフォルト。s03 の Todo は軽量な単一セッション用チェックリストとして残る。
|
|
||||||
|
|
||||||
## s06 からの変更点
|
|
||||||
|
|
||||||
| コンポーネント | 変更前 (s06) | 変更後 (s07) |
|
|
||||||
|---|---|---|
|
|
||||||
| Tools | 5 | 8 (`task_create/update/list/get`) |
|
|
||||||
| 計画モデル | フラットチェックリスト (メモリのみ) | 依存関係付きタスクグラフ (ディスク) |
|
|
||||||
| 関係 | なし | `blockedBy` + `blocks` エッジ |
|
|
||||||
| ステータス追跡 | 完了か未完了 | `pending` -> `in_progress` -> `completed` |
|
|
||||||
| 永続性 | 圧縮で消失 | 圧縮・再起動後も存続 |
|
|
||||||
|
|
||||||
## 試してみる
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd learn-claude-code
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s07.S07TaskSystem
|
|
||||||
```
|
|
||||||
|
|
||||||
以下のプロンプトを試してみよう (英語プロンプトの方が 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`
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
# s08: Background Tasks (バックグラウンドタスク)
|
|
||||||
|
|
||||||
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > [ s08 ] s09 > s10 > s11 > s12`
|
|
||||||
|
|
||||||
> *"遅い操作はバックグラウンドへ、エージェントは次を考え続ける"* -- バックグラウンドスレッドがコマンド実行、完了後に通知を注入。
|
|
||||||
>
|
|
||||||
> **Harness 層**: バックグラウンド実行 -- モデルが考え続ける間、Harness が待つ。
|
|
||||||
|
|
||||||
## 問題
|
|
||||||
|
|
||||||
一部のコマンドは数分かかる: `npm install`、`pytest`、`docker build`。ブロッキングループでは、モデルは待つしかない。ユーザーが「依存関係をインストールして、その間に config ファイルを作って」と言っても、エージェントは1つずつしか処理できない。
|
|
||||||
|
|
||||||
## 解決策
|
|
||||||
|
|
||||||
```
|
|
||||||
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 がスレッドセーフな並行コンテナでタスクを追跡する。Java では `ConcurrentHashMap` と `CopyOnWriteArrayList` を使用し、Python の手動ロックを置き換える。
|
|
||||||
|
|
||||||
```java
|
|
||||||
public class BackgroundManager {
|
|
||||||
private static final int TIMEOUT_SECONDS = 300;
|
|
||||||
|
|
||||||
private final Map<String, TaskInfo> tasks = new ConcurrentHashMap<>();
|
|
||||||
private final List<Notification> notificationQueue = new CopyOnWriteArrayList<>();
|
|
||||||
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
|
||||||
|
|
||||||
record TaskInfo(String status, String result, String command) {}
|
|
||||||
public record Notification(String taskId, String status, String command, String result) {}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. `backgroundRun()` が仮想スレッド (Java 21) に投入し、即座にリターンする。Python の `daemon=True` スレッドに比べ、仮想スレッドはより軽量で JVM がスケジュールする。
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Tool(description = "Run a command in a background thread. Returns task_id immediately without waiting.")
|
|
||||||
public String backgroundRun(
|
|
||||||
@ToolParam(description = "The shell command to run in background") String command) {
|
|
||||||
String taskId = UUID.randomUUID().toString().substring(0, 8);
|
|
||||||
tasks.put(taskId, new TaskInfo("running", null, command));
|
|
||||||
|
|
||||||
executor.submit(() -> execute(taskId, command));
|
|
||||||
|
|
||||||
return "Background task " + taskId + " started: "
|
|
||||||
+ command.substring(0, Math.min(80, command.length()));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. サブプロセス完了時に、結果が通知キューに入る。`ProcessBuilder` でコマンドを実行し、タイムアウト制御をサポート。
|
|
||||||
|
|
||||||
```java
|
|
||||||
private void execute(String taskId, String command) {
|
|
||||||
String status, output;
|
|
||||||
try {
|
|
||||||
ProcessBuilder pb = new ProcessBuilder("sh", "-c", command);
|
|
||||||
pb.redirectErrorStream(true);
|
|
||||||
Process process = pb.start();
|
|
||||||
try (BufferedReader reader = new BufferedReader(
|
|
||||||
new InputStreamReader(process.getInputStream()))) {
|
|
||||||
output = reader.lines().collect(Collectors.joining("\n"));
|
|
||||||
}
|
|
||||||
boolean finished = process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
|
||||||
if (!finished) { process.destroyForcibly(); status = "timeout"; }
|
|
||||||
else { status = "completed"; }
|
|
||||||
} catch (Exception e) { output = "Error: " + e.getMessage(); status = "error"; }
|
|
||||||
|
|
||||||
tasks.put(taskId, new TaskInfo(status, output, command));
|
|
||||||
notificationQueue.add(new Notification(taskId, status, command, output));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
4. 毎回のユーザー入力時に通知キューをドレインし、システムプロンプトに注入する。Spring AI の `ChatClient` が内部ツールループを管理するため、毎回のユーザー入力時にドレイン+システムプロンプト構築に変更。核心的なコンセプトは同じ: fire and forget。
|
|
||||||
|
|
||||||
```java
|
|
||||||
AgentRunner.interactive("s08", userMessage -> {
|
|
||||||
// バックグラウンドタスク通知をドレイン(Python のループ前 drain_notifications に相当)
|
|
||||||
var notifs = bgManager.drainNotifications();
|
|
||||||
String bgContext = "";
|
|
||||||
if (!notifs.isEmpty()) {
|
|
||||||
String notifText = notifs.stream()
|
|
||||||
.map(n -> "[bg:" + n.taskId() + "] " + n.status() + ": " + n.result())
|
|
||||||
.collect(Collectors.joining("\n"));
|
|
||||||
bgContext = "\n\n<background-results>\n" + notifText + "\n</background-results>";
|
|
||||||
}
|
|
||||||
|
|
||||||
String system = "You are a coding agent. Use backgroundRun for long-running commands."
|
|
||||||
+ bgContext;
|
|
||||||
|
|
||||||
ChatClient chatClient = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem(system)
|
|
||||||
.defaultTools(new BashTool(), new ReadFileTool(),
|
|
||||||
new WriteFileTool(), new EditFileTool(), bgManager)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
return chatClient.prompt().user(userMessage).call().content();
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
ループはシングルスレッドのまま。サブプロセス I/O だけが並列化される。
|
|
||||||
|
|
||||||
## s07 からの変更点
|
|
||||||
|
|
||||||
| コンポーネント | 変更前 (s07) | 変更後 (s08) |
|
|
||||||
|----------------|------------------|------------------------------------|
|
|
||||||
| Tools | 8 | 6 (基本 + backgroundRun + check) |
|
|
||||||
| 実行方式 | ブロッキングのみ | ブロッキング + 仮想スレッド (Java 21) |
|
|
||||||
| 通知メカニズム | なし | 毎ターンドレインの ConcurrentLinkedQueue |
|
|
||||||
| 並行性 | なし | 仮想スレッド (より軽量、JVM スケジュール) |
|
|
||||||
|
|
||||||
## 試してみる
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd learn-claude-code
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s08.S08BackgroundTasks
|
|
||||||
```
|
|
||||||
|
|
||||||
以下のプロンプトを試してみよう (英語プロンプトの方が 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`
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
# s09: Agent Teams (エージェントチーム)
|
|
||||||
|
|
||||||
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > [ s09 ] s10 > s11 > s12`
|
|
||||||
|
|
||||||
> *"一人で終わらないなら、チームメイトに任せる"* -- 永続チームメイト + JSONL メールボックス。
|
|
||||||
>
|
|
||||||
> **Harness 層**: チームメールボックス -- 複数モデルをファイルで協調。
|
|
||||||
|
|
||||||
## 問題
|
|
||||||
|
|
||||||
サブエージェント (s04) は使い捨てだ: 生成し、作業し、要約を返し、消滅する。アイデンティティもなく、呼び出し間の記憶もない。バックグラウンドタスク (s08) はシェルコマンドを実行するが、LLM 誘導の意思決定はできない。
|
|
||||||
|
|
||||||
本物のチームワークには3つが必要: (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 でチーム名簿を管理する。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// src/main/java/io/mybatis/learn/s09/TeammateManager.java
|
|
||||||
public class TeammateManager {
|
|
||||||
private final ChatModel chatModel;
|
|
||||||
private final MessageBus bus;
|
|
||||||
private final Path configPath;
|
|
||||||
private final ObjectMapper mapper = new ObjectMapper();
|
|
||||||
private Map<String, Object> config;
|
|
||||||
// Python は threading.Thread + dict を使用、Java は ConcurrentHashMap で天然スレッドセーフ
|
|
||||||
private final Map<String, Thread> threads = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
public TeammateManager(ChatModel chatModel, MessageBus bus, Path teamDir) {
|
|
||||||
this.chatModel = chatModel;
|
|
||||||
this.bus = bus;
|
|
||||||
this.configPath = teamDir.resolve("config.json");
|
|
||||||
Files.createDirectories(teamDir);
|
|
||||||
this.config = loadConfig();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. `spawn()` がチームメイトを作成し、スレッド内でエージェントループを開始する。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// Python は threading.Thread を使用、Java は Thread.startVirtualThread() 仮想スレッドを使用
|
|
||||||
public synchronized String spawn(String name, String role, String prompt) {
|
|
||||||
Map<String, Object> member = new LinkedHashMap<>();
|
|
||||||
member.put("name", name);
|
|
||||||
member.put("role", role);
|
|
||||||
member.put("status", "working");
|
|
||||||
((List<Map<String, Object>>) config.get("members")).add(member);
|
|
||||||
saveConfig();
|
|
||||||
|
|
||||||
// 仮想スレッド: 軽量、JVM スケジュール、OS スレッドを占有しない
|
|
||||||
Thread thread = Thread.startVirtualThread(
|
|
||||||
() -> teammateLoop(name, role, prompt));
|
|
||||||
threads.put(name, thread);
|
|
||||||
return "Spawned '" + name + "' (role: " + role + ")";
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. MessageBus: 追記専用の JSONL インボックス。`send()` が1行を追記し、`read_inbox()` がすべて読み取ってドレインする。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// src/main/java/io/mybatis/learn/core/team/MessageBus.java
|
|
||||||
// Python は GIL で暗黙的にスレッドセーフ、Java は synchronized で明示的に保証
|
|
||||||
public class MessageBus {
|
|
||||||
private final Path inboxDir;
|
|
||||||
private final ObjectMapper mapper = new ObjectMapper();
|
|
||||||
|
|
||||||
public synchronized String send(String sender, String to, String content,
|
|
||||||
String msgType, Map<String, Object> extra) {
|
|
||||||
Map<String, Object> msg = new LinkedHashMap<>();
|
|
||||||
msg.put("type", msgType);
|
|
||||||
msg.put("from", sender);
|
|
||||||
msg.put("content", content);
|
|
||||||
msg.put("timestamp", System.currentTimeMillis() / 1000.0);
|
|
||||||
if (extra != null) msg.putAll(extra);
|
|
||||||
|
|
||||||
Path inbox = inboxDir.resolve(to + ".jsonl");
|
|
||||||
Files.writeString(inbox, mapper.writeValueAsString(msg) + "\n",
|
|
||||||
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
|
||||||
return "Sent " + msgType + " to " + to;
|
|
||||||
}
|
|
||||||
|
|
||||||
public synchronized List<Map<String, Object>> readInbox(String name) {
|
|
||||||
Path inbox = inboxDir.resolve(name + ".jsonl");
|
|
||||||
if (!Files.exists(inbox)) return List.of();
|
|
||||||
List<Map<String, Object>> messages = new ArrayList<>();
|
|
||||||
for (String line : Files.readAllLines(inbox)) {
|
|
||||||
if (!line.isBlank())
|
|
||||||
messages.add(mapper.readValue(line, new TypeReference<>() {}));
|
|
||||||
}
|
|
||||||
Files.writeString(inbox, ""); // drain
|
|
||||||
return messages;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
4. 各チームメイトは `call()` 呼び出し間でインボックスをチェックし、メッセージをコンテキストに注入する。ChatClient の `call()` は Python の完全なツールループ(`stop_reason != "tool_use"` まで繰り返す)に相当する。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// Python のチームメイトは毎回の LLM 呼び出し前にインボックスをチェック、Java は毎回の call() 呼び出し間でチェック
|
|
||||||
protected void teammateLoop(String name, String role, String initialPrompt) {
|
|
||||||
String sysPrompt = String.format(
|
|
||||||
"You are '%s', role: %s. Use send_message to communicate.",
|
|
||||||
name, role);
|
|
||||||
|
|
||||||
var messageTool = new TeammateMessageTool(bus, name);
|
|
||||||
ChatClient client = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem(sysPrompt)
|
|
||||||
.defaultTools(new BashTool(), new ReadFileTool(),
|
|
||||||
new WriteFileTool(), new EditFileTool(), messageTool)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
// 初期作業(call() = 完全なツールチェーン、Python の stop_reason != "tool_use" までのループに相当)
|
|
||||||
String response = client.prompt(initialPrompt).call().content();
|
|
||||||
|
|
||||||
// 毎回の call() 間でインボックスをチェック(Python の毎回の LLM 呼び出し間ではなく)
|
|
||||||
for (int round = 0; round < 50; round++) {
|
|
||||||
Thread.sleep(2000);
|
|
||||||
var inbox = bus.readInbox(name);
|
|
||||||
if (inbox.isEmpty()) break;
|
|
||||||
String inboxJson = mapper.writeValueAsString(inbox);
|
|
||||||
response = client.prompt("<inbox>" + inboxJson + "</inbox>").call().content();
|
|
||||||
}
|
|
||||||
setStatus(name, "idle");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## s08 からの変更点
|
|
||||||
|
|
||||||
| コンポーネント | 変更前 (s08) | 変更後 (s09) |
|
|
||||||
|----------------|------------------|------------------------------------|
|
|
||||||
| Tools | 6 | 9 (+spawn/send/read_inbox) |
|
|
||||||
| エージェント数 | 単一 | リーダー + N チームメイト |
|
|
||||||
| 永続化 | なし | config.json + JSONL インボックス |
|
|
||||||
| スレッド | バックグラウンドコマンド | 各スレッドで完全なエージェントループ |
|
|
||||||
| ライフサイクル | 使い捨て | idle -> working -> idle |
|
|
||||||
| 通信 | なし | message + broadcast |
|
|
||||||
|
|
||||||
## 試してみる
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd learn-claude-code
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s09.S09AgentTeams
|
|
||||||
```
|
|
||||||
|
|
||||||
以下のプロンプトを試してみよう (英語プロンプトの方が 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` と入力してリーダーのインボックスを手動確認する
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
# 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 を生成し、インボックス経由でシャットダウンを開始する。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// src/main/java/io/mybatis/learn/s10/ProtocolTracker.java
|
|
||||||
// Python は辞書 + threading.Lock を使用、Java は ConcurrentHashMap で天然スレッドセーフ
|
|
||||||
private final ConcurrentHashMap<String, Map<String, String>> shutdownRequests
|
|
||||||
= new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
public String handleShutdownRequest(String teammate) {
|
|
||||||
String reqId = UUID.randomUUID().toString().substring(0, 8);
|
|
||||||
shutdownRequests.put(reqId, new ConcurrentHashMap<>(Map.of(
|
|
||||||
"target", teammate, "status", "pending")));
|
|
||||||
bus.send("lead", teammate, "Please shut down gracefully.",
|
|
||||||
"shutdown_request", Map.of("request_id", reqId));
|
|
||||||
return "Shutdown request " + reqId + " sent to '" + teammate
|
|
||||||
+ "' (status: pending)";
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. チームメイトがリクエストを受信し、承認または拒否で応答する。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// TeammateProtocolTool - チームメイトが @Tool アノテーションでシャットダウン要求に応答
|
|
||||||
@Tool(description = "Respond to a shutdown request")
|
|
||||||
public String shutdownResponse(
|
|
||||||
@ToolParam(description = "The request_id") String requestId,
|
|
||||||
@ToolParam(description = "true to approve") boolean approve,
|
|
||||||
@ToolParam(description = "Reason for decision") String reason) {
|
|
||||||
return tracker.respondToShutdown(name, requestId, approve, reason);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProtocolTracker - トラッカー更新 + レスポンスメッセージ送信
|
|
||||||
public String respondToShutdown(String sender, String requestId,
|
|
||||||
boolean approve, String reason) {
|
|
||||||
var req = shutdownRequests.get(requestId);
|
|
||||||
if (req != null) {
|
|
||||||
req.put("status", approve ? "approved" : "rejected");
|
|
||||||
}
|
|
||||||
bus.send(sender, "lead", reason != null ? reason : "",
|
|
||||||
"shutdown_response",
|
|
||||||
Map.of("request_id", requestId, "approve", approve));
|
|
||||||
return "Shutdown " + (approve ? "approved" : "rejected");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. プラン承認もまったく同じパターン。チームメイトがプランを提出(request_id を生成)、リーダーがレビュー(同じ request_id を参照)。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// ProtocolTracker - 同じ request_id 関連パターン、2つの用途
|
|
||||||
private final ConcurrentHashMap<String, Map<String, String>> planRequests
|
|
||||||
= new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
public String reviewPlan(String requestId, boolean approve, String feedback) {
|
|
||||||
var req = planRequests.get(requestId);
|
|
||||||
if (req == null) return "Error: Unknown plan request_id '" + requestId + "'";
|
|
||||||
req.put("status", approve ? "approved" : "rejected");
|
|
||||||
bus.send("lead", req.get("from"), feedback != null ? feedback : "",
|
|
||||||
"plan_approval_response",
|
|
||||||
Map.of("request_id", requestId, "approve", approve,
|
|
||||||
"feedback", feedback != null ? feedback : ""));
|
|
||||||
return "Plan " + req.get("status") + " for '" + req.get("from") + "'";
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
1つの FSM、2つの用途。同じ `pending -> approved | rejected` 状態機械が、あらゆるリクエスト-レスポンスプロトコルに適用できる。
|
|
||||||
|
|
||||||
## s09 からの変更点
|
|
||||||
|
|
||||||
| コンポーネント | 変更前 (s09) | 変更後 (s10) |
|
|
||||||
|----------------|------------------|--------------------------------------|
|
|
||||||
| Tools | 9 | 12 (+shutdown_req/resp +plan) |
|
|
||||||
| シャットダウン | 自然終了のみ | リクエスト-レスポンスハンドシェイク |
|
|
||||||
| プランゲーティング | なし | 提出/レビューと承認 |
|
|
||||||
| 関連付け | なし | リクエストごとに request_id |
|
|
||||||
| FSM | なし | pending -> approved/rejected |
|
|
||||||
|
|
||||||
## 試してみる
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd learn-claude-code
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s10.S10TeamProtocols
|
|
||||||
```
|
|
||||||
|
|
||||||
以下のプロンプトを試してみよう (英語プロンプトの方が 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` と入力してステータスを監視する
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
# s11: Autonomous Agents (自律エージェント)
|
|
||||||
|
|
||||||
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > [ s11 ] s12`
|
|
||||||
|
|
||||||
> *"チームメイトが自らボードを見て、仕事を取る"* -- リーダーが逐一割り振る必要はない、自己組織化。
|
|
||||||
>
|
|
||||||
> **Harness 層**: 自律 -- 指示なしで仕事を見つけるモデル。
|
|
||||||
|
|
||||||
## 問題
|
|
||||||
|
|
||||||
s09-s10 では、チームメイトは明示的に指示された時のみ作業する。リーダーは各チームメイトにプロンプトを書き、タスクボード上の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 via system prompt (always present):
|
|
||||||
ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem(identityPrompt) // 毎回の呼び出しで自動付与
|
|
||||||
```
|
|
||||||
|
|
||||||
## 仕組み
|
|
||||||
|
|
||||||
1. チームメイトのループは WORK と IDLE の2フェーズ。LLM がツール呼び出しを止めた時(または `idle` ツールを呼んだ時)、IDLE フェーズに入る。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// src/main/java/io/mybatis/learn/s11/S11AutonomousAgents.java
|
|
||||||
// AutonomousTeammateManager.autonomousLoop()
|
|
||||||
|
|
||||||
private void autonomousLoop(String name, String role, String initialPrompt) {
|
|
||||||
// idle フラグ: ツール呼び出し時に設定、外部ループが検出
|
|
||||||
AtomicBoolean idleRequested = new AtomicBoolean(false);
|
|
||||||
var idleTool = new IdleTool(idleRequested);
|
|
||||||
|
|
||||||
ChatClient client = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem(sysPrompt)
|
|
||||||
.defaultTools(new BashTool(), new ReadFileTool(),
|
|
||||||
new WriteFileTool(), new EditFileTool(),
|
|
||||||
messageTool, protocolTool, idleTool, claimTool)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
// -- WORK PHASE --
|
|
||||||
String nextMsg = initialPrompt;
|
|
||||||
for (int round = 0; round < 50 && nextMsg != null; round++) {
|
|
||||||
var inbox = bus.readInbox(name);
|
|
||||||
// ... インボックスメッセージを nextMsg にマージ ...
|
|
||||||
idleRequested.set(false);
|
|
||||||
String response = client.prompt(sb.toString()).call().content();
|
|
||||||
if (idleRequested.get()) break; // idle ツールが呼ばれた
|
|
||||||
nextMsg = null; // 以降のラウンドは inbox 駆動
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- IDLE PHASE --
|
|
||||||
setStatus(name, "idle");
|
|
||||||
// ... インボックス + タスクボードをポーリング(下記参照) ...
|
|
||||||
if (!resume) { setStatus(name, "shutdown"); return; }
|
|
||||||
setStatus(name, "working");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. IDLE フェーズがインボックスとタスクボードをポーリングする。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// IDLE PHASE: インボックス + タスクボードをポーリング
|
|
||||||
setStatus(name, "idle");
|
|
||||||
boolean resume = false;
|
|
||||||
int polls = IDLE_TIMEOUT / Math.max(POLL_INTERVAL, 1); // 60/5 = 12
|
|
||||||
|
|
||||||
for (int p = 0; p < polls; p++) {
|
|
||||||
Thread.sleep(POLL_INTERVAL * 1000L);
|
|
||||||
|
|
||||||
// インボックスをチェック
|
|
||||||
var inbox = bus.readInbox(name);
|
|
||||||
if (!inbox.isEmpty()) {
|
|
||||||
initialPrompt = "<inbox>" + mapper.writeValueAsString(inbox) + "</inbox>";
|
|
||||||
resume = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// タスクボードをスキャン
|
|
||||||
var unclaimed = scanUnclaimedTasks(tasksDir);
|
|
||||||
if (!unclaimed.isEmpty()) {
|
|
||||||
var task = unclaimed.get(0);
|
|
||||||
int taskId = ((Number) task.get("id")).intValue();
|
|
||||||
claimTask(tasksDir, taskId, name);
|
|
||||||
initialPrompt = String.format(
|
|
||||||
"<auto-claimed>Task #%d: %s\n%s</auto-claimed>",
|
|
||||||
taskId, task.get("subject"),
|
|
||||||
task.getOrDefault("description", ""));
|
|
||||||
resume = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!resume) { setStatus(name, "shutdown"); return; }
|
|
||||||
setStatus(name, "working");
|
|
||||||
```
|
|
||||||
|
|
||||||
3. タスクボードスキャン: pending ステータスかつ owner なしかつブロックされていないタスクを探す。
|
|
||||||
|
|
||||||
```java
|
|
||||||
static List<Map<String, Object>> scanUnclaimedTasks(Path tasksDir) {
|
|
||||||
if (!Files.exists(tasksDir)) return List.of();
|
|
||||||
List<Map<String, Object>> unclaimed = new ArrayList<>();
|
|
||||||
ObjectMapper mapper = new ObjectMapper();
|
|
||||||
try (var files = Files.list(tasksDir)) {
|
|
||||||
files.filter(f -> f.getFileName().toString().startsWith("task_")
|
|
||||||
&& f.getFileName().toString().endsWith(".json"))
|
|
||||||
.sorted()
|
|
||||||
.forEach(f -> {
|
|
||||||
Map<String, Object> task = mapper.readValue(f.toFile(), Map.class);
|
|
||||||
if ("pending".equals(task.get("status"))
|
|
||||||
&& (task.get("owner") == null || "".equals(task.get("owner")))
|
|
||||||
&& (task.get("blockedBy") == null
|
|
||||||
|| ((List<?>) task.get("blockedBy")).isEmpty())) {
|
|
||||||
unclaimed.add(task);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return unclaimed;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
4. アイデンティティ保持: Java/Spring AI の `ChatClient.defaultSystem()` は毎回の呼び出しで自動的にシステムプロンプトを付与するため、アイデンティティ情報は常に存在する。Python 版のように圧縮後に手動で再注入する必要はない。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// アイデンティティ情報は defaultSystem で構築時に注入、毎回の prompt で自動付与
|
|
||||||
String sysPrompt = String.format(
|
|
||||||
"You are '%s', role: %s, team: %s, at %s. "
|
|
||||||
+ "Use idle tool when you have no more work. You will auto-claim new tasks.",
|
|
||||||
name, role, teamName, workDir);
|
|
||||||
|
|
||||||
ChatClient client = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem(sysPrompt) // アイデンティティは常にシステムプロンプトに存在
|
|
||||||
.defaultTools(new BashTool(), new ReadFileTool(),
|
|
||||||
new WriteFileTool(), new EditFileTool(),
|
|
||||||
messageTool, protocolTool, idleTool, claimTool)
|
|
||||||
.build();
|
|
||||||
```
|
|
||||||
|
|
||||||
## s10 からの変更点
|
|
||||||
|
|
||||||
| コンポーネント | 変更前 (s10) | 変更後 (s11) |
|
|
||||||
|----------------|------------------|----------------------------------|
|
|
||||||
| Tools | 12 | 14 (+idle, +claim_task) |
|
|
||||||
| 自律性 | リーダー指示 | 自己組織化 |
|
|
||||||
| IDLE フェーズ | なし | インボックス + タスクボードをポーリング |
|
|
||||||
| タスク確保 | 手動のみ | 未割り当てタスクの自動確保 |
|
|
||||||
| アイデンティティ | システムプロンプト | + 圧縮後の再注入 |
|
|
||||||
| タイムアウト | なし | 60秒 IDLE → 自動シャットダウン |
|
|
||||||
|
|
||||||
## 試してみる
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd learn-claude-code
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s11.S11AutonomousAgents
|
|
||||||
```
|
|
||||||
|
|
||||||
以下のプロンプトを試してみよう (英語プロンプトの方が 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` と入力して誰が作業中でアイドルかを監視する
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
# s12: Worktree + Task Isolation (Worktree タスク隔離)
|
|
||||||
|
|
||||||
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > [ s12 ]`
|
|
||||||
|
|
||||||
> *"各自のディレクトリで作業し、互いに干渉しない"* -- タスクは目標を管理、worktree はディレクトリを管理、ID で紐付け。
|
|
||||||
>
|
|
||||||
> **Harness 層**: ディレクトリ隔離 -- 決して衝突しない並列実行レーン。
|
|
||||||
|
|
||||||
## 問題
|
|
||||||
|
|
||||||
s11 までにエージェントはタスクを自律的に確保して完了できるようになった。しかし全タスクが1つの共有ディレクトリで走る。2つのエージェントが同時に異なるモジュールをリファクタリングすると -- A が `Config.java` を編集し、B も `Config.java` を編集し、未コミットの変更が互いに汚染し、どちらもクリーンにロールバックできない。
|
|
||||||
|
|
||||||
タスクボードは「何をやるか」を追跡するが「どこでやるか」には関知しない。解決策: 各タスクに独立した 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. **タスクを作成する。** まず目標を永続化する。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// src/main/java/io/mybatis/learn/s12/WorktreeTaskManager.java
|
|
||||||
tasks.create("Implement auth refactor", "");
|
|
||||||
// -> .tasks/task_1.json status=pending worktree=""
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **worktree を作成してタスクに紐付ける。** `task_id` を渡すと、タスクが自動的に `in_progress` に遷移する。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// src/main/java/io/mybatis/learn/s12/WorktreeManager.java
|
|
||||||
worktrees.create("auth-refactor", 1, "HEAD");
|
|
||||||
// -> git worktree add -b wt/auth-refactor .worktrees/auth-refactor HEAD
|
|
||||||
// -> index.json gets new entry, task_1.json gets worktree="auth-refactor"
|
|
||||||
```
|
|
||||||
|
|
||||||
紐付けは両側に状態を書き込む:
|
|
||||||
|
|
||||||
```java
|
|
||||||
// src/main/java/io/mybatis/learn/s12/WorktreeTaskManager.java
|
|
||||||
public String bindWorktree(int taskId, String worktree, String owner) {
|
|
||||||
var task = load(taskId);
|
|
||||||
task.put("worktree", worktree);
|
|
||||||
if (owner != null && !owner.isEmpty()) task.put("owner", owner);
|
|
||||||
if ("pending".equals(task.get("status"))) task.put("status", "in_progress");
|
|
||||||
task.put("updated_at", System.currentTimeMillis() / 1000.0);
|
|
||||||
save(task);
|
|
||||||
return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(task);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **worktree 内でコマンドを実行する。** `cwd` が隔離ディレクトリを指す。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// src/main/java/io/mybatis/learn/s12/WorktreeManager.java - run()
|
|
||||||
boolean isWindows = System.getProperty("os.name").toLowerCase().contains("win");
|
|
||||||
ProcessBuilder pb = isWindows
|
|
||||||
? new ProcessBuilder("cmd", "/c", command)
|
|
||||||
: new ProcessBuilder("sh", "-c", command);
|
|
||||||
pb.directory(path.toFile());
|
|
||||||
pb.redirectErrorStream(true);
|
|
||||||
Process p = pb.start();
|
|
||||||
String out = new String(p.getInputStream().readAllBytes()).trim();
|
|
||||||
boolean finished = p.waitFor(300, java.util.concurrent.TimeUnit.SECONDS);
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **終了処理。** 2つの選択肢:
|
|
||||||
- `worktree_keep(name)` -- ディレクトリを保持する。
|
|
||||||
- `worktree_remove(name, complete_task=True)` -- ディレクトリを削除し、紐付けられたタスクを完了し、イベントを発行する。1回の呼び出しで後片付けと完了を処理する。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// src/main/java/io/mybatis/learn/s12/WorktreeManager.java
|
|
||||||
public String remove(String name, boolean force, boolean completeTask) {
|
|
||||||
var wt = findWorktree(name);
|
|
||||||
events.emit("worktree.remove.before", ...);
|
|
||||||
runGit("worktree", "remove", wt.get("path").toString());
|
|
||||||
if (completeTask && wt.get("task_id") != null) {
|
|
||||||
int taskId = ((Number) wt.get("task_id")).intValue();
|
|
||||||
tasks.update(taskId, "completed", null);
|
|
||||||
tasks.unbindWorktree(taskId);
|
|
||||||
events.emit("task.completed",
|
|
||||||
Map.of("id", taskId, "status", "completed"),
|
|
||||||
Map.of("name", name), null);
|
|
||||||
}
|
|
||||||
// index.json を更新: status -> "removed"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s12.S12WorktreeIsolation
|
|
||||||
```
|
|
||||||
|
|
||||||
以下のプロンプトを試してみよう (英語プロンプトの方が 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.`
|
|
||||||
@@ -1,318 +0,0 @@
|
|||||||
# s01: The Agent Loop (智能体循环)
|
|
||||||
|
|
||||||
`[ s01 ] s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
|
|
||||||
|
|
||||||
> *"One loop & Bash is all you need"* -- 一个工具 + 一个循环 = 一个智能体。
|
|
||||||
>
|
|
||||||
> **Harness 层**: 循环 -- 模型与真实世界的第一道连接。
|
|
||||||
|
|
||||||
## 问题
|
|
||||||
|
|
||||||
语言模型能推理代码, 但碰不到真实世界 -- 不能读文件、跑测试、看报错。没有循环, 每次工具调用你都得手动把结果粘回去。你自己就是那个循环。
|
|
||||||
|
|
||||||
## 解决方案
|
|
||||||
|
|
||||||
```
|
|
||||||
+--------+ +-------+ +---------+
|
|
||||||
| User | ---> | LLM | ---> | Tool |
|
|
||||||
| prompt | | | | execute |
|
|
||||||
+--------+ +---+---+ +----+----+
|
|
||||||
^ |
|
|
||||||
| tool_result |
|
|
||||||
+----------------+
|
|
||||||
(ChatClient.call() 自动循环直到无工具调用)
|
|
||||||
```
|
|
||||||
|
|
||||||
一个 `call()` 调用控制整个流程。Spring AI 自动循环, 直到模型不再调用工具。
|
|
||||||
|
|
||||||
## 工作原理
|
|
||||||
|
|
||||||
### 1. 构建 ChatClient:注入模型 + 注册工具
|
|
||||||
|
|
||||||
通过 Spring Boot 自动配置注入 `ChatModel`,用 `ChatClient.builder()` 构建客户端,设置系统提示和工具。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// TIP: Python 版在模块级创建 client = Anthropic() 和 MODEL。
|
|
||||||
// Spring AI 通过自动配置注入 ChatModel,再用 builder 构建 ChatClient。
|
|
||||||
public S01AgentLoop(ChatModel chatModel) {
|
|
||||||
this.chatClient = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem("You are a coding agent at " + System.getProperty("user.dir")
|
|
||||||
+ ". Use bash to solve tasks. Act, don't explain.")
|
|
||||||
.defaultTools(new BashTool()) // @Tool 注解的工具对象
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. `@Tool` 注解:声明式工具注册
|
|
||||||
|
|
||||||
Spring AI 通过 `@Tool` 注解自动发现和注册工具。框架在启动时扫描 `defaultTools()` 传入的对象,提取所有 `@Tool` 方法的签名和描述,生成 LLM 需要的 tool schema(名称、参数、描述),然后在每次 `call()` 请求中自动携带。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// BashTool —— 对应 Python 版的 run_bash() 函数
|
|
||||||
public class BashTool {
|
|
||||||
@Tool(description = "Run a shell command and return stdout + stderr")
|
|
||||||
public String bash(@ToolParam(description = "The shell command to execute")
|
|
||||||
String command) {
|
|
||||||
// 危险命令检查 + ProcessBuilder 执行 + 超时控制 + 输出截断
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> 对比 Python 版的手动注册方式:
|
|
||||||
> - Python: `TOOLS = [{"name": "bash", "input_schema": {...}}]` + `TOOL_HANDLERS = {"bash": run_bash}`
|
|
||||||
> - Java: 只需 `@Tool` + `@ToolParam` 注解,框架自动完成 schema 生成和方法分派
|
|
||||||
|
|
||||||
### 3. Spring AI 内部自动循环:`call()` 的底层实现
|
|
||||||
|
|
||||||
**这是理解 Java 版与 Python 版最关键的区别。** Python 版本需要手写 while 循环来驱动工具调用:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Python 版 —— 手动循环
|
|
||||||
def agent_loop(messages):
|
|
||||||
while True:
|
|
||||||
response = client.messages.create(model=MODEL, messages=messages, tools=TOOLS)
|
|
||||||
# 收集 assistant 消息
|
|
||||||
messages.append({"role": "assistant", "content": response.content})
|
|
||||||
if response.stop_reason != "tool_use":
|
|
||||||
return response # 模型不再调用工具,退出循环
|
|
||||||
# 执行工具并回传结果
|
|
||||||
for block in response.content:
|
|
||||||
if block.type == "tool_use":
|
|
||||||
result = TOOL_HANDLERS[block.name](block.input)
|
|
||||||
messages.append({"role": "user", "content": [{"type": "tool_result", ...}]})
|
|
||||||
```
|
|
||||||
|
|
||||||
Spring AI 的 `ChatClient.call()` **内部封装了完全等价的逻辑**:
|
|
||||||
|
|
||||||
```
|
|
||||||
call() 内部流程:
|
|
||||||
┌─────────────────────────────────────────────────────┐
|
|
||||||
│ 1. 组装请求: system prompt + user message + tools │
|
|
||||||
│ 2. 发送给 LLM │
|
|
||||||
│ 3. 解析响应 │
|
|
||||||
│ ├── 有 tool_use? ──→ 是: │
|
|
||||||
│ │ a. 提取工具名和参数 │
|
|
||||||
│ │ b. 通过反射调用对应的 @Tool 方法 │
|
|
||||||
│ │ c. 将 tool_result 追加到消息列表 │
|
|
||||||
│ │ d. 回到步骤 2(自动循环) │
|
|
||||||
│ └── 否 ──→ 返回最终文本 │
|
|
||||||
└─────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
关键点:
|
|
||||||
- **工具检测**: Spring AI 检查响应中是否有 `tool_use` 类型的 content block(对应 Python 的 `stop_reason == "tool_use"`)
|
|
||||||
- **反射分派**: 框架通过 Java 反射机制,根据 LLM 返回的工具名称找到对应的 `@Tool` 方法并调用(对应 Python 的 `TOOL_HANDLERS[block.name]`)
|
|
||||||
- **结果回传**: 工具执行结果自动包装为 `tool_result` 消息追加到对话(对应 Python 手动构造 `tool_result` content block)
|
|
||||||
- **循环终止**: 当模型返回纯文本(无工具调用)时,`call()` 返回最终结果
|
|
||||||
|
|
||||||
因此,Python 版约 15 行的 while 循环,在 Java 版中浓缩为一行 `.call()`。
|
|
||||||
|
|
||||||
### 4. `AgentRunner.interactive()`:REPL 交互循环
|
|
||||||
|
|
||||||
`AgentRunner` 是所有课程共用的交互式 REPL(Read-Eval-Print Loop)工具类,对应 Python 版 `if __name__ == "__main__"` 中的 `input()` 循环。
|
|
||||||
|
|
||||||
```java
|
|
||||||
public class AgentRunner {
|
|
||||||
/**
|
|
||||||
* 启动交互式 REPL 循环。
|
|
||||||
* @param prefix 提示符前缀(如 "s01")
|
|
||||||
* @param handler 处理用户输入并返回 Agent 响应的函数
|
|
||||||
*/
|
|
||||||
public static void interactive(String prefix, Function<String, String> handler) {
|
|
||||||
Scanner scanner = new Scanner(System.in);
|
|
||||||
System.out.println("输入 'q' 或 'exit' 退出");
|
|
||||||
while (true) {
|
|
||||||
System.out.print("\033[36m" + prefix + " >> \033[0m"); // 彩色提示符
|
|
||||||
String input;
|
|
||||||
try {
|
|
||||||
if (!scanner.hasNextLine()) break;
|
|
||||||
input = scanner.nextLine().trim();
|
|
||||||
} catch (Exception e) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (input.isEmpty() || "exit".equalsIgnoreCase(input) || "q".equalsIgnoreCase(input)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
String response = handler.apply(input); // 调用 Agent 处理
|
|
||||||
if (response != null && !response.isBlank()) {
|
|
||||||
System.out.println(response);
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.err.println("Error: " + e.getMessage());
|
|
||||||
}
|
|
||||||
System.out.println();
|
|
||||||
}
|
|
||||||
System.out.println("Bye!");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
工作流程:`Scanner` 读取输入 → `handler.apply()` 发给 Agent → 打印响应 → 循环。`handler` 是一个函数式接口,每个课程传入自己的 Agent 调用逻辑。
|
|
||||||
|
|
||||||
### 5. 组装为完整的 Agent 类
|
|
||||||
|
|
||||||
```java
|
|
||||||
@SpringBootApplication(scanBasePackages = "io.mybatis.learn.core")
|
|
||||||
public class S01AgentLoop implements CommandLineRunner {
|
|
||||||
|
|
||||||
private final ChatClient chatClient;
|
|
||||||
|
|
||||||
public S01AgentLoop(ChatModel chatModel) {
|
|
||||||
this.chatClient = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem("You are a coding agent at ...")
|
|
||||||
.defaultTools(new BashTool())
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void run(String... args) {
|
|
||||||
AgentRunner.interactive("s01", userMessage ->
|
|
||||||
chatClient.prompt()
|
|
||||||
.user(userMessage)
|
|
||||||
.call() // ← 这一个调用 = Python 的整个 while 循环
|
|
||||||
.content()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> **TIPS — Python → Java 关键适配点:**
|
|
||||||
> - Python 的 `while True` + `stop_reason` 手动循环 → Spring AI `ChatClient.call()` 内置自动循环
|
|
||||||
> - Python 的 `TOOLS` 数组 + `TOOL_HANDLERS` 字典 → `@Tool` 注解 + `defaultTools()` 自动注册与反射分派
|
|
||||||
> - Python 的 `client = Anthropic()` → Spring Boot 自动配置注入 `ChatModel`
|
|
||||||
> - Python 的 `input()` 交互 → `AgentRunner.interactive()` 封装 Scanner REPL + 函数式接口
|
|
||||||
|
|
||||||
不到 40 行核心代码, 这就是整个智能体。后面 11 个章节都在这个循环上叠加机制 -- 循环本身始终不变。
|
|
||||||
|
|
||||||
## 变更内容
|
|
||||||
|
|
||||||
| 组件 | 之前 | 之后 |
|
|
||||||
|---------------|------------|--------------------------------------------------|
|
|
||||||
| Agent loop | (无) | `ChatClient.call()` 内置工具循环 |
|
|
||||||
| Tools | (无) | `BashTool` (单一 `@Tool` 工具) |
|
|
||||||
| Messages | (无) | Spring AI 内部管理消息列表 |
|
|
||||||
| Control flow | (无) | 框架自动判断: 无工具调用时返回最终文本 |
|
|
||||||
|
|
||||||
```java
|
|
||||||
// 核心代码 —— 构建 + 调用
|
|
||||||
ChatClient chatClient = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem("You are a coding agent ...")
|
|
||||||
.defaultTools(new BashTool())
|
|
||||||
.build();
|
|
||||||
|
|
||||||
AgentRunner.interactive("s01", userMessage ->
|
|
||||||
chatClient.prompt().user(userMessage).call().content()
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
## 试一试
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd learn-claude-code
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s01.S01AgentLoop
|
|
||||||
```
|
|
||||||
|
|
||||||
> 运行前需设置环境变量: `AI_API_KEY`, `AI_BASE_URL`, `AI_MODEL`
|
|
||||||
>
|
|
||||||
> **当前默认使用 OpenAI 协议**(兼容所有 OpenAI API 格式的服务,包括 OpenAI 官方、Azure OpenAI、各类第三方大模型服务的 OpenAI 兼容接口等)。
|
|
||||||
> 如需使用 Anthropic 协议(Claude 系列模型原生接口),请展开下方「切换 AI 协议」。
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>切换 AI 协议(OpenAI ↔ Anthropic)</strong></summary>
|
|
||||||
|
|
||||||
本项目通过 Spring AI 的 **Starter 依赖 + 配置文件** 来切换底层协议,Java 业务代码(`ChatModel`、`ChatClient`)**无需任何修改**。
|
|
||||||
|
|
||||||
#### 方式一:OpenAI 协议(默认)
|
|
||||||
|
|
||||||
`pom.xml` 依赖:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.ai</groupId>
|
|
||||||
<artifactId>spring-ai-starter-model-openai</artifactId>
|
|
||||||
</dependency>
|
|
||||||
```
|
|
||||||
|
|
||||||
`application.yml` 配置:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
spring:
|
|
||||||
ai:
|
|
||||||
openai:
|
|
||||||
api-key: ${AI_API_KEY:sk-xxx}
|
|
||||||
base-url: ${AI_BASE_URL:https://api.openai.com}
|
|
||||||
chat:
|
|
||||||
options:
|
|
||||||
model: ${AI_MODEL:gpt-4o}
|
|
||||||
```
|
|
||||||
|
|
||||||
环境变量示例(以 OpenAI 官方为例):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
export AI_API_KEY=sk-proj-xxxxxxxx
|
|
||||||
export AI_BASE_URL=https://api.openai.com # 可替换为任何 OpenAI 兼容接口
|
|
||||||
export AI_MODEL=gpt-4o
|
|
||||||
```
|
|
||||||
|
|
||||||
> **TIP**: 许多第三方大模型服务(如 DeepSeek、Mistral、通义千问等)提供了 OpenAI 兼容接口,只需修改 `AI_BASE_URL` 和 `AI_MODEL` 即可接入,无需切换协议。
|
|
||||||
|
|
||||||
#### 方式二:Anthropic 协议(Claude 原生接口)
|
|
||||||
|
|
||||||
**第 1 步**:修改 `pom.xml`,将 OpenAI starter 替换为 Anthropic starter:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<!-- 注释或删除 OpenAI starter -->
|
|
||||||
<!-- <dependency>
|
|
||||||
<groupId>org.springframework.ai</groupId>
|
|
||||||
<artifactId>spring-ai-starter-model-openai</artifactId>
|
|
||||||
</dependency> -->
|
|
||||||
|
|
||||||
<!-- 添加 Anthropic starter -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.ai</groupId>
|
|
||||||
<artifactId>spring-ai-starter-model-anthropic</artifactId>
|
|
||||||
</dependency>
|
|
||||||
```
|
|
||||||
|
|
||||||
**第 2 步**:修改 `application.yml`,将 `spring.ai.openai` 替换为 `spring.ai.anthropic`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
spring:
|
|
||||||
ai:
|
|
||||||
anthropic:
|
|
||||||
api-key: ${AI_API_KEY}
|
|
||||||
base-url: ${AI_BASE_URL:https://api.anthropic.com}
|
|
||||||
chat:
|
|
||||||
options:
|
|
||||||
model: ${AI_MODEL:claude-sonnet-4-20250514}
|
|
||||||
```
|
|
||||||
|
|
||||||
**第 3 步**:设置环境变量:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
export AI_API_KEY=sk-ant-xxxxxxxx
|
|
||||||
export AI_BASE_URL=https://api.anthropic.com
|
|
||||||
export AI_MODEL=claude-sonnet-4-20250514
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 切换原理
|
|
||||||
|
|
||||||
Spring AI 的设计使得 `ChatModel` 是一个统一的抽象接口。不同的 Starter 提供不同的实现:
|
|
||||||
|
|
||||||
| Starter 依赖 | 自动注入的 ChatModel 实现 | 配置前缀 |
|
|
||||||
|---|---|---|
|
|
||||||
| `spring-ai-starter-model-openai` | `OpenAiChatModel` | `spring.ai.openai.*` |
|
|
||||||
| `spring-ai-starter-model-anthropic` | `AnthropicChatModel` | `spring.ai.anthropic.*` |
|
|
||||||
|
|
||||||
业务代码始终面向 `ChatModel` 接口编程,切换协议只需替换依赖和配置,无需改动任何 Java 代码。
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
试试这些 prompt(英文 prompt 对 LLM 效果更好, 也可以用中文):
|
|
||||||
|
|
||||||
1. `Create a file called Hello.java that prints "Hello, World!"`
|
|
||||||
2. `List all Java files in this directory`
|
|
||||||
3. `What is the current git branch?`
|
|
||||||
4. `Create a directory called test_output and write 3 files in it`
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
# s02: Tool Use (工具使用)
|
|
||||||
|
|
||||||
`s01 > [ s02 ] s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
|
|
||||||
|
|
||||||
> *"加一个工具, 只加一个 @Tool 方法"* -- 循环不用动, 新工具传入 `defaultTools()` 就行。
|
|
||||||
>
|
|
||||||
> **Harness 层**: 工具分发 -- 扩展模型能触达的边界。
|
|
||||||
|
|
||||||
## 问题
|
|
||||||
|
|
||||||
只有 `bash` 时, 所有操作都走 shell。`cat` 截断不可预测, `sed` 遇到特殊字符就崩, 每次 bash 调用都是不受约束的安全面。专用工具 (`read_file`, `write_file`) 可以在工具层面做路径沙箱。
|
|
||||||
|
|
||||||
关键洞察: 加工具不需要改循环。
|
|
||||||
|
|
||||||
## 解决方案
|
|
||||||
|
|
||||||
```
|
|
||||||
+--------+ +-------+ +--------------------+
|
|
||||||
| User | ---> | LLM | ---> | defaultTools() |
|
|
||||||
| prompt | | | | { |
|
|
||||||
+--------+ +---+---+ | BashTool |
|
|
||||||
^ | ReadFileTool |
|
|
||||||
| | WriteFileTool |
|
|
||||||
+-----------+ EditFileTool |
|
|
||||||
tool_result | } |
|
|
||||||
+--------------------+
|
|
||||||
|
|
||||||
Spring AI 通过 @Tool 注解自动注册和分派。
|
|
||||||
无需手写 dispatch map,框架扫描工具对象的注解方法即可。
|
|
||||||
```
|
|
||||||
|
|
||||||
## 工作原理
|
|
||||||
|
|
||||||
1. 每个工具是一个独立的类,用 `@Tool` 注解声明。`PathValidator` 做路径沙箱防止逃逸工作区。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// PathValidator —— 对应 Python 版的 safe_path() 函数
|
|
||||||
public class PathValidator {
|
|
||||||
private final Path workDir;
|
|
||||||
|
|
||||||
public Path resolve(String relativePath) {
|
|
||||||
Path resolved = workDir.resolve(relativePath).toAbsolutePath().normalize();
|
|
||||||
if (!resolved.startsWith(workDir)) {
|
|
||||||
throw new IllegalArgumentException("Path escapes workspace: " + relativePath);
|
|
||||||
}
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadFileTool —— 对应 Python 版的 run_read() 函数
|
|
||||||
public class ReadFileTool {
|
|
||||||
private final PathValidator pathValidator;
|
|
||||||
|
|
||||||
@Tool(description = "Read file contents. Optionally limit the number of lines returned.")
|
|
||||||
public String readFile(
|
|
||||||
@ToolParam(description = "Relative path to the file") String path,
|
|
||||||
@ToolParam(description = "Maximum number of lines to read", required = false) Integer limit) {
|
|
||||||
Path filePath = pathValidator.resolve(path);
|
|
||||||
List<String> lines = Files.readAllLines(filePath);
|
|
||||||
if (limit != null && limit > 0 && limit < lines.size()) {
|
|
||||||
lines = lines.subList(0, limit);
|
|
||||||
}
|
|
||||||
return String.join("\n", lines);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. 工具注册只需传入 `defaultTools()`。Spring AI 扫描 `@Tool` 注解方法,自动完成名称映射和参数绑定。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// 对应 Python 版的 TOOL_HANDLERS 字典
|
|
||||||
// Python: TOOL_HANDLERS = {"bash": fn, "read_file": fn, "write_file": fn, "edit_file": fn}
|
|
||||||
// Java: 只需传入工具对象,@Tool 注解自动注册
|
|
||||||
this.chatClient = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem("You are a coding agent ...")
|
|
||||||
.defaultTools(
|
|
||||||
new BashTool(), // bash 命令执行
|
|
||||||
new ReadFileTool(), // 文件读取
|
|
||||||
new WriteFileTool(), // 文件写入
|
|
||||||
new EditFileTool() // 文件编辑(查找替换)
|
|
||||||
)
|
|
||||||
.build();
|
|
||||||
```
|
|
||||||
|
|
||||||
3. 调用代码与 s01 完全一致。循环由框架管理,开发者只需关注工具实现。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// 对比 s01,唯一变化是 defaultTools() 多传了 3 个工具对象
|
|
||||||
// 循环代码完全相同 —— 这正是 s02 的核心洞察
|
|
||||||
AgentRunner.interactive("s02", userMessage ->
|
|
||||||
chatClient.prompt()
|
|
||||||
.user(userMessage)
|
|
||||||
.call()
|
|
||||||
.content()
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
加工具 = 加一个 `@Tool` 类 + 传入 `defaultTools()`。循环永远不变。
|
|
||||||
|
|
||||||
> **TIPS — Python → Java 关键适配点:**
|
|
||||||
> - Python 的 `TOOL_HANDLERS` 字典 → Spring AI `@Tool` 注解 + `defaultTools()` 自动注册分派
|
|
||||||
> - Python 的 `safe_path()` 函数 → `PathValidator` 类(相同的路径逃逸检查逻辑)
|
|
||||||
> - Python 的 `lambda **kw` 参数解包 → `@ToolParam` 注解自动绑定参数
|
|
||||||
> - Python 的 `block.type == "tool_use"` 判断 → Spring AI 内部自动检测和分派
|
|
||||||
|
|
||||||
## 相对 s01 的变更
|
|
||||||
|
|
||||||
| 组件 | 之前 (s01) | 之后 (s02) |
|
|
||||||
|----------------|-----------------------|----------------------------------------|
|
|
||||||
| Tools | 1 (`BashTool`) | 4 (`Bash`, `ReadFile`, `WriteFile`, `EditFile`) |
|
|
||||||
| Dispatch | `defaultTools(bash)` | `defaultTools(bash, read, write, edit)` |
|
|
||||||
| 路径安全 | 无 | `PathValidator` 沙箱 |
|
|
||||||
| Agent loop | 不变 | 不变 |
|
|
||||||
|
|
||||||
```java
|
|
||||||
// s01 → s02 唯一变化: defaultTools() 多传了 3 个工具对象
|
|
||||||
.defaultTools(
|
|
||||||
new BashTool(),
|
|
||||||
new ReadFileTool(), // +新增
|
|
||||||
new WriteFileTool(), // +新增
|
|
||||||
new EditFileTool() // +新增
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 试一试
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd learn-claude-code
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s02.S02ToolUse
|
|
||||||
```
|
|
||||||
|
|
||||||
> 运行前需设置环境变量: `AI_API_KEY`, `AI_BASE_URL`, `AI_MODEL`
|
|
||||||
|
|
||||||
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
|
|
||||||
|
|
||||||
1. `Read the file pom.xml`
|
|
||||||
2. `Create a file called Greet.java with a greet(name) method`
|
|
||||||
3. `Edit Greet.java to add a Javadoc comment to the method`
|
|
||||||
4. `Read Greet.java to verify the edit worked`
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
# 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 |
|
|
||||||
+-----------------------+
|
|
||||||
|
|
|
||||||
每次请求时通过 defaultSystem()
|
|
||||||
注入最新 todo 状态到系统提示
|
|
||||||
```
|
|
||||||
|
|
||||||
## 工作原理
|
|
||||||
|
|
||||||
1. TodoManager 存储带状态的项目。同一时间只允许一个 `in_progress`。
|
|
||||||
|
|
||||||
```java
|
|
||||||
public class TodoManager {
|
|
||||||
|
|
||||||
public record TodoItem(String id, String text, String status) {}
|
|
||||||
|
|
||||||
private List<TodoItem> items = new ArrayList<>();
|
|
||||||
|
|
||||||
@Tool(description = "Update the full task list to track progress. "
|
|
||||||
+ "Each item must have id, text, status (pending/in_progress/completed). "
|
|
||||||
+ "Only one task can be in_progress at a time. Max 20 items.")
|
|
||||||
public String updateTodos(
|
|
||||||
@ToolParam(description = "The complete list of todo items")
|
|
||||||
List<TodoItem> items) {
|
|
||||||
if (items.size() > 20) return "Error: Max 20 todos allowed";
|
|
||||||
List<TodoItem> validated = new ArrayList<>();
|
|
||||||
int inProgressCount = 0;
|
|
||||||
for (TodoItem item : items) {
|
|
||||||
String status = (item.status() != null)
|
|
||||||
? item.status().toLowerCase() : "pending";
|
|
||||||
if ("in_progress".equals(status)) inProgressCount++;
|
|
||||||
validated.add(new TodoItem(item.id(), item.text().trim(), status));
|
|
||||||
}
|
|
||||||
if (inProgressCount > 1)
|
|
||||||
return "Error: Only one task can be in_progress at a time";
|
|
||||||
this.items = validated;
|
|
||||||
return render();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. `TodoManager` 通过 `defaultTools()` 注册, `@Tool` 注解方法自动暴露为工具。
|
|
||||||
|
|
||||||
```java
|
|
||||||
ChatClient chatClient = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem(system)
|
|
||||||
.defaultTools(
|
|
||||||
new BashTool(),
|
|
||||||
new ReadFileTool(),
|
|
||||||
new WriteFileTool(),
|
|
||||||
new EditFileTool(),
|
|
||||||
todoManager // @Tool 注解方法自动注册
|
|
||||||
)
|
|
||||||
.build();
|
|
||||||
```
|
|
||||||
|
|
||||||
3. 系统提示注入: 每次用户输入时, 将最新 todo 状态注入系统提示, 并强调更新指令。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// 动态系统提示:包含当前 todo 状态
|
|
||||||
String system = "You are a coding agent at " + workDir + ".\n"
|
|
||||||
+ "Use the todo tool to plan multi-step tasks. "
|
|
||||||
+ "Mark in_progress before starting, completed when done.\n"
|
|
||||||
+ "IMPORTANT: You MUST call updateTodos regularly.\n\n"
|
|
||||||
+ "<current-todos>\n" + todoManager.render() + "\n</current-todos>";
|
|
||||||
```
|
|
||||||
|
|
||||||
"同时只能有一个 in_progress" 强制顺序聚焦。系统提示中持续注入 todo 状态制造问责压力 -- 模型每次都能看到自己的计划, 不会忘记更新。
|
|
||||||
|
|
||||||
> **TIP**: Python 版在工具循环内追踪 `rounds_since_todo`, 连续 3 轮未调用 todo 时注入 `<reminder>` 文本。Spring AI 的 ChatClient 自动管理工具循环, 无法在循环内注入, 因此改用系统提示注入的方式实现同等效果。
|
|
||||||
|
|
||||||
## 相对 s02 的变更
|
|
||||||
|
|
||||||
| 组件 | 之前 (s02) | 之后 (s03) |
|
|
||||||
|----------------|------------------|--------------------------------------|
|
|
||||||
| Tools | 4 | 5 (+TodoManager `@Tool`) |
|
|
||||||
| 规划 | 无 | 带状态的 TodoManager |
|
|
||||||
| 状态注入 | 无 | 系统提示注入 `<current-todos>` |
|
|
||||||
| ChatClient | 固定系统提示 | 每轮重建, 动态注入 todo 状态 |
|
|
||||||
|
|
||||||
## 试一试
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd learn-claude-code
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s03.S03TodoWrite
|
|
||||||
```
|
|
||||||
|
|
||||||
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
|
|
||||||
|
|
||||||
1. `Refactor the file Hello.java: add JavaDoc, improve naming, and keep main method behavior unchanged`
|
|
||||||
2. `Create a Java package with utils and tests`
|
|
||||||
3. `Review all Java files and fix any style issues`
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
# s04: Subagents (子智能体)
|
|
||||||
|
|
||||||
`s01 > s02 > s03 > [ s04 ] s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
|
|
||||||
|
|
||||||
> *"大任务拆小, 每个小任务干净的上下文"* -- 子智能体用独立 messages[], 不污染主对话。
|
|
||||||
>
|
|
||||||
> **Harness 层**: 上下文隔离 -- 守护模型的思维清晰度。
|
|
||||||
|
|
||||||
## 问题
|
|
||||||
|
|
||||||
智能体工作越久, messages 数组越胖。每次读文件、跑命令的输出都永久留在上下文里。"这个项目用什么测试框架?" 可能要读 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` 外的所有基础工具 (禁止递归生成)。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// 父 Agent:拥有基础工具 + SubagentTool
|
|
||||||
this.chatClient = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem("You are a coding agent. "
|
|
||||||
+ "Use the task tool to delegate subtasks.")
|
|
||||||
.defaultTools(
|
|
||||||
new BashTool(),
|
|
||||||
new ReadFileTool(),
|
|
||||||
new WriteFileTool(),
|
|
||||||
new EditFileTool(),
|
|
||||||
new SubagentTool(chatModel) // 父 Agent 独有
|
|
||||||
)
|
|
||||||
.build();
|
|
||||||
```
|
|
||||||
|
|
||||||
2. 子智能体以全新的 `ChatClient` 启动, 拥有独立上下文。只有最终文本返回给父智能体。
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Tool(description = "Spawn a subagent with fresh context. "
|
|
||||||
+ "Use for exploration or subtasks that might pollute the main context.")
|
|
||||||
public String task(
|
|
||||||
@ToolParam(description = "The task prompt") String prompt,
|
|
||||||
@ToolParam(description = "Short description", required = false)
|
|
||||||
String description) {
|
|
||||||
|
|
||||||
// 创建全新的 ChatClient —— 这就是"上下文隔离"的全部
|
|
||||||
ChatClient subClient = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem("You are a coding subagent. "
|
|
||||||
+ "Complete the task, then summarize findings.")
|
|
||||||
.defaultTools( // 基础工具, 没有 task (防止递归)
|
|
||||||
new BashTool(),
|
|
||||||
new ReadFileTool(),
|
|
||||||
new WriteFileTool(),
|
|
||||||
new EditFileTool()
|
|
||||||
)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
String result = subClient.prompt()
|
|
||||||
.user(prompt)
|
|
||||||
.call()
|
|
||||||
.content();
|
|
||||||
|
|
||||||
// 只返回最终文本, 子 Agent 上下文被丢弃
|
|
||||||
return (result != null) ? result : "(no summary)";
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
子智能体可能跑了多次工具调用, 但整个消息历史直接丢弃。父智能体收到的只是一段摘要文本, 作为普通 `tool_result` 返回。Spring AI 的 `ChatClient.call()` 内部管理工具循环, 无需手动限制迭代次数。
|
|
||||||
|
|
||||||
## 相对 s03 的变更
|
|
||||||
|
|
||||||
| 组件 | 之前 (s03) | 之后 (s04) |
|
|
||||||
|----------------|------------------|---------------------------------------|
|
|
||||||
| Tools | 5 | 5 (基础) + SubagentTool (仅父端) |
|
|
||||||
| 上下文 | 单一共享 | 父 + 子隔离 (独立 ChatClient) |
|
|
||||||
| Subagent | 无 | `SubagentTool.task()` 方法 |
|
|
||||||
| 返回值 | 不适用 | 仅摘要文本 |
|
|
||||||
|
|
||||||
## 试一试
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd learn-claude-code
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s04.S04Subagent
|
|
||||||
```
|
|
||||||
|
|
||||||
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
|
|
||||||
|
|
||||||
1. `Use a subtask to find what testing framework this project uses`
|
|
||||||
2. `Delegate: read all .java files and summarize what each one does`
|
|
||||||
3. `Use a task to create a new module, then verify it from here`
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
# s05: Skills (技能加载)
|
|
||||||
|
|
||||||
`s01 > s02 > s03 > s04 > [ s05 ] s06 | s07 > s08 > s09 > s10 > s11 > s12`
|
|
||||||
|
|
||||||
> *"用到什么知识, 临时加载什么知识"* -- 通过 tool_result 注入, 不塞 system prompt。
|
|
||||||
>
|
|
||||||
> **Harness 层**: 按需知识 -- 模型开口要时才给的领域专长。
|
|
||||||
|
|
||||||
## 问题
|
|
||||||
|
|
||||||
你希望智能体遵循特定领域的工作流: git 约定、测试模式、代码审查清单。全塞进系统提示太浪费 -- 10 个技能, 每个 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> |
|
|
||||||
+--------------------------------------+
|
|
||||||
```
|
|
||||||
|
|
||||||
第一层: 系统提示中放技能名称 (低成本)。第二层: tool_result 中按需放完整内容。
|
|
||||||
|
|
||||||
## 工作原理
|
|
||||||
|
|
||||||
1. 每个技能是一个目录, 包含 `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` 文件, 用目录名作为技能标识。
|
|
||||||
|
|
||||||
```java
|
|
||||||
public class SkillLoader {
|
|
||||||
|
|
||||||
private static final Pattern FRONTMATTER_PATTERN =
|
|
||||||
Pattern.compile("^---\\n(.*?)\\n---\\n(.*)", Pattern.DOTALL);
|
|
||||||
|
|
||||||
private final Map<String, SkillInfo> skills = new LinkedHashMap<>();
|
|
||||||
|
|
||||||
record SkillInfo(Map<String, String> meta, String body, String path) {}
|
|
||||||
|
|
||||||
public SkillLoader(Path skillsDir) {
|
|
||||||
loadAll(skillsDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 递归扫描 skills 目录下所有 SKILL.md 文件 */
|
|
||||||
private void loadAll(Path skillsDir) {
|
|
||||||
if (!Files.exists(skillsDir)) return;
|
|
||||||
try (Stream<Path> paths = Files.walk(skillsDir)) {
|
|
||||||
paths.filter(p -> p.getFileName().toString().equals("SKILL.md"))
|
|
||||||
.sorted()
|
|
||||||
.forEach(p -> {
|
|
||||||
String text = Files.readString(p);
|
|
||||||
var parsed = parseFrontmatter(text);
|
|
||||||
String name = parsed.meta().getOrDefault("name",
|
|
||||||
p.getParent().getFileName().toString());
|
|
||||||
skills.put(name, new SkillInfo(
|
|
||||||
parsed.meta(), parsed.body(), p.toString()));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Layer 1: 获取所有技能的简短描述(用于系统提示注入) */
|
|
||||||
public String getDescriptions() {
|
|
||||||
if (skills.isEmpty()) return "(no skills available)";
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
for (var entry : skills.entrySet()) {
|
|
||||||
String desc = entry.getValue().meta()
|
|
||||||
.getOrDefault("description", "No description");
|
|
||||||
sb.append(" - ").append(entry.getKey())
|
|
||||||
.append(": ").append(desc).append("\n");
|
|
||||||
}
|
|
||||||
return sb.toString().stripTrailing();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Layer 2: 加载指定技能的完整内容(作为 @Tool 方法) */
|
|
||||||
@Tool(description = "Load specialized knowledge by name.")
|
|
||||||
public String loadSkill(
|
|
||||||
@ToolParam(description = "Skill name to load") String name) {
|
|
||||||
SkillInfo skill = skills.get(name);
|
|
||||||
if (skill == null)
|
|
||||||
return "Error: Unknown skill '" + name + "'. Available: "
|
|
||||||
+ String.join(", ", skills.keySet());
|
|
||||||
return "<skill name=\"" + name + "\">\n"
|
|
||||||
+ skill.body() + "\n</skill>";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. 第一层写入系统提示。第二层通过 SkillLoader 上的 `@Tool` 注解方法按需加载。
|
|
||||||
|
|
||||||
```java
|
|
||||||
public S05SkillLoading(ChatModel chatModel) {
|
|
||||||
Path skillsDir = Path.of(System.getProperty("user.dir"), "skills");
|
|
||||||
SkillLoader skillLoader = new SkillLoader(skillsDir);
|
|
||||||
|
|
||||||
// Layer 1: 技能元数据注入系统提示
|
|
||||||
String system = "You are a coding agent at " + System.getProperty("user.dir") + ".\n"
|
|
||||||
+ "Use loadSkill to access specialized knowledge.\n\n"
|
|
||||||
+ "Skills available:\n"
|
|
||||||
+ skillLoader.getDescriptions();
|
|
||||||
|
|
||||||
this.chatClient = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem(system)
|
|
||||||
.defaultTools(
|
|
||||||
new BashTool(), new ReadFileTool(),
|
|
||||||
new WriteFileTool(), new EditFileTool(),
|
|
||||||
skillLoader // Layer 2: loadSkill @Tool 方法
|
|
||||||
)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
模型知道有哪些技能 (便宜), 需要时再加载完整内容 (贵)。
|
|
||||||
|
|
||||||
## 相对 s04 的变更
|
|
||||||
|
|
||||||
| 组件 | 之前 (s04) | 之后 (s05) |
|
|
||||||
|----------------|------------------|--------------------------------|
|
|
||||||
| Tools | 5 (基础 + task) | 5 (基础 + load_skill) |
|
|
||||||
| 系统提示 | 静态字符串 | + 技能描述列表 |
|
|
||||||
| 知识库 | 无 | skills/\*/SKILL.md 文件 |
|
|
||||||
| 注入方式 | 无 | 两层 (系统提示 + result) |
|
|
||||||
|
|
||||||
## 试一试
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd learn-claude-code
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s05.S05SkillLoading
|
|
||||||
```
|
|
||||||
|
|
||||||
试试这些 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`
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
# s06: Context Compact (上下文压缩)
|
|
||||||
|
|
||||||
`s01 > s02 > s03 > s04 > s05 > [ s06 ] | s07 > s08 > s09 > s10 > s11 > s12`
|
|
||||||
|
|
||||||
> *"上下文总会满, 要有办法腾地方"* -- 三层压缩策略, 换来无限会话。
|
|
||||||
>
|
|
||||||
> **Harness 层**: 压缩 -- 干净的记忆, 无限的会话。
|
|
||||||
|
|
||||||
## 问题
|
|
||||||
|
|
||||||
上下文窗口是有限的。读一个 1000 行的文件就吃掉 ~4000 token; 读 30 个文件、跑 20 条命令, 轻松突破 100k token。不压缩, 智能体根本没法在大项目里干活。
|
|
||||||
|
|
||||||
## 解决方案
|
|
||||||
|
|
||||||
三层压缩, 激进程度递增:
|
|
||||||
|
|
||||||
```
|
|
||||||
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. **第一层 -- 上下文窗口管理**: Spring AI 的 ChatClient 自动管理工具循环, 无法在循环内插入压缩。Java 版通过限制注入系统提示的对话轮数(仅保留最近 N 轮)并截断内容来实现等价效果。
|
|
||||||
|
|
||||||
```java
|
|
||||||
/** 估算 token 数量: 粗略估计 4 字符 ≈ 1 token */
|
|
||||||
public int estimateTokens() {
|
|
||||||
int chars = history.stream().mapToInt(t -> t.content().length()).sum();
|
|
||||||
return chars / 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取对话历史的摘要(用于注入系统提示, 仅保留最近几轮) */
|
|
||||||
public String getContextSummary() {
|
|
||||||
if (history.isEmpty()) return "";
|
|
||||||
StringBuilder sb = new StringBuilder("\n<conversation-context>\n");
|
|
||||||
int start = Math.max(0, history.size() - KEEP_RECENT * 2);
|
|
||||||
for (int i = start; i < history.size(); i++) {
|
|
||||||
ConversationTurn turn = history.get(i);
|
|
||||||
sb.append("[").append(turn.role()).append("]: ")
|
|
||||||
.append(turn.content(), 0, Math.min(500, turn.content().length()))
|
|
||||||
.append("\n");
|
|
||||||
}
|
|
||||||
sb.append("</conversation-context>");
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **第二层 -- auto_compact**: token 超过阈值时, 保存完整对话到磁盘, 让 LLM 做摘要。
|
|
||||||
|
|
||||||
```java
|
|
||||||
public String compact() {
|
|
||||||
// 保存 transcript 到磁盘(完整历史不丢失)
|
|
||||||
Files.createDirectories(transcriptDir);
|
|
||||||
Path transcriptPath = transcriptDir.resolve(
|
|
||||||
"transcript_" + System.currentTimeMillis() + ".jsonl");
|
|
||||||
try (BufferedWriter writer = Files.newBufferedWriter(transcriptPath)) {
|
|
||||||
for (ConversationTurn turn : history) {
|
|
||||||
writer.write(objectMapper.writeValueAsString(turn));
|
|
||||||
writer.newLine();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LLM 生成摘要
|
|
||||||
String conversationText = history.stream()
|
|
||||||
.map(t -> t.role() + ": " + t.content())
|
|
||||||
.reduce("", (a, b) -> a + "\n" + b);
|
|
||||||
if (conversationText.length() > 80000) {
|
|
||||||
conversationText = conversationText.substring(0, 80000);
|
|
||||||
}
|
|
||||||
|
|
||||||
ChatClient summaryClient = ChatClient.builder(chatModel).build();
|
|
||||||
String summary = summaryClient.prompt()
|
|
||||||
.user("Summarize this conversation for continuity. Include: "
|
|
||||||
+ "1) What was accomplished, 2) Current state, "
|
|
||||||
+ "3) Key decisions.\n\n" + conversationText)
|
|
||||||
.call().content();
|
|
||||||
|
|
||||||
// 用摘要替换历史
|
|
||||||
history.clear();
|
|
||||||
history.add(new ConversationTurn("system",
|
|
||||||
"[Conversation compressed. Transcript: " + transcriptPath
|
|
||||||
+ "]\n\n" + summary));
|
|
||||||
return summary;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **第三层 -- manual compact**: `CompactTool` 工具按需触发同样的摘要机制。
|
|
||||||
|
|
||||||
```java
|
|
||||||
public class CompactTool {
|
|
||||||
private final ContextCompactor compactor;
|
|
||||||
|
|
||||||
public CompactTool(ContextCompactor compactor) {
|
|
||||||
this.compactor = compactor;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Tool(description = "Trigger manual conversation compression to free up context space.")
|
|
||||||
public String compact(
|
|
||||||
@ToolParam(description = "What to preserve in summary",
|
|
||||||
required = false) String focus) {
|
|
||||||
compactor.requestCompact();
|
|
||||||
return "Compression triggered. Context will be summarized.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
4. REPL 层整合三层 (Spring AI 的 ChatClient 自动管理工具循环, 压缩在用户消息级别触发):
|
|
||||||
|
|
||||||
```java
|
|
||||||
AgentRunner.interactive("s06", userMessage -> {
|
|
||||||
// Layer 2: 自动压缩检查(每次用户输入前)
|
|
||||||
if (compactor.needsAutoCompact()) {
|
|
||||||
System.out.println("[auto_compact triggered]");
|
|
||||||
compactor.compact();
|
|
||||||
}
|
|
||||||
compactor.addTurn("user", userMessage);
|
|
||||||
|
|
||||||
// 动态系统提示:包含对话上下文摘要
|
|
||||||
String system = baseSystem + compactor.getContextSummary();
|
|
||||||
ChatClient chatClient = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem(system)
|
|
||||||
.defaultTools(new BashTool(), new ReadFileTool(),
|
|
||||||
new WriteFileTool(), new EditFileTool(), compactTool)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
String response = chatClient.prompt()
|
|
||||||
.user(userMessage).call().content();
|
|
||||||
compactor.addTurn("assistant", response != null ? response : "");
|
|
||||||
|
|
||||||
// Layer 3: 手动压缩(如果 Agent 调用了 compact 工具)
|
|
||||||
if (compactor.isCompactRequested()) {
|
|
||||||
compactor.compact();
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
完整历史通过 transcript 保存在磁盘上。信息没有真正丢失, 只是移出了活跃上下文。
|
|
||||||
|
|
||||||
## 相对 s05 的变更
|
|
||||||
|
|
||||||
| 组件 | 之前 (s05) | 之后 (s06) |
|
|
||||||
|----------------|------------------|--------------------------------|
|
|
||||||
| Tools | 5 | 5 (基础 + compact) |
|
|
||||||
| 上下文管理 | 无 | 三层压缩 |
|
|
||||||
| 上下文窗口管理 | 无 | 限制注入轮数 + 内容截断 |
|
|
||||||
| Auto-compact | 无 | token 阈值触发 |
|
|
||||||
| Transcripts | 无 | 保存到 .transcripts/ |
|
|
||||||
|
|
||||||
## 试一试
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd learn-claude-code
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s06.S06ContextCompact
|
|
||||||
```
|
|
||||||
|
|
||||||
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
|
|
||||||
|
|
||||||
1. `Read every Java file in the src/ directory one by one` (观察上下文窗口管理效果)
|
|
||||||
2. `Keep reading files until compression triggers automatically`
|
|
||||||
3. `Use the compact tool to manually compress the conversation`
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
# 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 都完成。
|
|
||||||
|
|
||||||
没有显式的关系, 智能体分不清什么能做、什么被卡住、什么能同时跑。而且清单只活在内存里, 上下文压缩 (s06) 一跑就没了。
|
|
||||||
|
|
||||||
## 解决方案
|
|
||||||
|
|
||||||
把扁平清单升级为持久化到磁盘的**任务图**。每个任务是一个 JSON 文件, 有状态、前置依赖 (`blockedBy`) 和后置依赖 (`blocks`)。任务图随时回答三个问题:
|
|
||||||
|
|
||||||
- **什么可以做?** -- 状态为 `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 + 依赖图。使用 Jackson `ObjectMapper` 做 JSON 序列化。
|
|
||||||
|
|
||||||
```java
|
|
||||||
public class TaskManager {
|
|
||||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
|
||||||
private final Path dir;
|
|
||||||
private int nextId;
|
|
||||||
|
|
||||||
public TaskManager(Path tasksDir) {
|
|
||||||
this.dir = tasksDir;
|
|
||||||
Files.createDirectories(dir);
|
|
||||||
this.nextId = maxId() + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Tool(description = "Create a new task with subject and optional description")
|
|
||||||
public String taskCreate(
|
|
||||||
@ToolParam(description = "Short subject of the task") String subject,
|
|
||||||
@ToolParam(description = "Detailed description", required = false) String description) {
|
|
||||||
Map<String, Object> task = new LinkedHashMap<>();
|
|
||||||
task.put("id", nextId);
|
|
||||||
task.put("subject", subject);
|
|
||||||
task.put("status", "pending");
|
|
||||||
task.put("blockedBy", new ArrayList<>());
|
|
||||||
task.put("blocks", new ArrayList<>());
|
|
||||||
save(task);
|
|
||||||
nextId++;
|
|
||||||
return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(task);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **依赖解除**: 完成任务时, 自动将其 ID 从其他任务的 `blockedBy` 中移除, 解锁后续任务。
|
|
||||||
|
|
||||||
```java
|
|
||||||
private void clearDependency(int completedId) {
|
|
||||||
try (Stream<Path> files = Files.list(dir)) {
|
|
||||||
files.filter(f -> f.getFileName().toString().matches("task_\\d+\\.json"))
|
|
||||||
.forEach(f -> {
|
|
||||||
Map<String, Object> task = MAPPER.readValue(
|
|
||||||
Files.readString(f), new TypeReference<>() {});
|
|
||||||
List<Integer> blockedBy = (List<Integer>) task.get("blockedBy");
|
|
||||||
if (blockedBy != null && blockedBy.remove(Integer.valueOf(completedId))) {
|
|
||||||
save(task);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **状态变更 + 依赖关联**: `taskUpdate` 处理状态转换和依赖边。当 status 变为 `completed` 时自动调用 `clearDependency`;`blockedBy`/`blocks` 是双向关系。
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Tool(description = "Update a task's status or dependencies.")
|
|
||||||
public String taskUpdate(
|
|
||||||
@ToolParam(description = "Task ID") int taskId,
|
|
||||||
@ToolParam(description = "New status", required = false) String status,
|
|
||||||
@ToolParam(description = "Task IDs that block this task", required = false) List<Integer> addBlockedBy,
|
|
||||||
@ToolParam(description = "Task IDs that this task blocks", required = false) List<Integer> addBlocks) {
|
|
||||||
Map<String, Object> task = load(taskId);
|
|
||||||
if (status != null) {
|
|
||||||
task.put("status", status);
|
|
||||||
if ("completed".equals(status)) {
|
|
||||||
clearDependency(taskId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 处理 addBlockedBy / addBlocks 双向依赖 ...
|
|
||||||
save(task);
|
|
||||||
return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(task);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Spring AI 自动注册工具**: 将 `TaskManager` 作为 `defaultTools` 传入 `ChatClient`,Spring AI 自动识别 `@Tool` 注解方法,无需手动 dispatch map。
|
|
||||||
|
|
||||||
```java
|
|
||||||
@SpringBootApplication(scanBasePackages = "io.mybatis.learn.core")
|
|
||||||
public class S07TaskSystem implements CommandLineRunner {
|
|
||||||
|
|
||||||
private final ChatClient chatClient;
|
|
||||||
|
|
||||||
public S07TaskSystem(ChatModel chatModel) {
|
|
||||||
Path tasksDir = Path.of(System.getProperty("user.dir"), ".tasks");
|
|
||||||
TaskManager taskManager = new TaskManager(tasksDir);
|
|
||||||
|
|
||||||
this.chatClient = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem("You are a coding agent. Use task tools to plan and track work.")
|
|
||||||
.defaultTools(
|
|
||||||
new BashTool(), new ReadFileTool(),
|
|
||||||
new WriteFileTool(), new EditFileTool(),
|
|
||||||
taskManager // TaskManager 中的 @Tool 方法自动注册
|
|
||||||
)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
从 s07 起, 任务图是多步工作的默认选择。s03 的 Todo 仍可用于单次会话内的快速清单。
|
|
||||||
|
|
||||||
## 相对 s06 的变更
|
|
||||||
|
|
||||||
| 组件 | 之前 (s06) | 之后 (s07) |
|
|
||||||
|---|---|---|
|
|
||||||
| Tools | 5 | 8 (`task_create/update/list/get`) |
|
|
||||||
| 规划模型 | 扁平清单 (仅内存) | 带依赖关系的任务图 (磁盘) |
|
|
||||||
| 关系 | 无 | `blockedBy` + `blocks` 边 |
|
|
||||||
| 状态追踪 | 做完没做完 | `pending` -> `in_progress` -> `completed` |
|
|
||||||
| 持久化 | 压缩后丢失 | 压缩和重启后存活 |
|
|
||||||
|
|
||||||
## 试一试
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd learn-claude-code
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s07.S07TaskSystem
|
|
||||||
```
|
|
||||||
|
|
||||||
试试这些 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`
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
# s08: Background Tasks (后台任务)
|
|
||||||
|
|
||||||
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > [ s08 ] s09 > s10 > s11 > s12`
|
|
||||||
|
|
||||||
> *"慢操作丢后台, agent 继续想下一步"* -- 后台线程跑命令, 完成后注入通知。
|
|
||||||
>
|
|
||||||
> **Harness 层**: 后台执行 -- 模型继续思考, harness 负责等待。
|
|
||||||
|
|
||||||
## 问题
|
|
||||||
|
|
||||||
有些命令要跑好几分钟: `npm install`、`pytest`、`docker build`。阻塞式循环下模型只能干等。用户说 "装依赖, 顺便建个配置文件", 智能体却只能一个一个来。
|
|
||||||
|
|
||||||
## 解决方案
|
|
||||||
|
|
||||||
```
|
|
||||||
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 用线程安全的并发容器追踪任务。Java 使用 `ConcurrentHashMap` 和 `CopyOnWriteArrayList` 代替 Python 的手动加锁。
|
|
||||||
|
|
||||||
```java
|
|
||||||
public class BackgroundManager {
|
|
||||||
private static final int TIMEOUT_SECONDS = 300;
|
|
||||||
|
|
||||||
private final Map<String, TaskInfo> tasks = new ConcurrentHashMap<>();
|
|
||||||
private final List<Notification> notificationQueue = new CopyOnWriteArrayList<>();
|
|
||||||
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
|
||||||
|
|
||||||
record TaskInfo(String status, String result, String command) {}
|
|
||||||
public record Notification(String taskId, String status, String command, String result) {}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. `backgroundRun()` 提交虚拟线程 (Java 21), 立即返回。相比 Python 的 `daemon=True` 线程,虚拟线程更轻量、由 JVM 调度。
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Tool(description = "Run a command in a background thread. Returns task_id immediately without waiting.")
|
|
||||||
public String backgroundRun(
|
|
||||||
@ToolParam(description = "The shell command to run in background") String command) {
|
|
||||||
String taskId = UUID.randomUUID().toString().substring(0, 8);
|
|
||||||
tasks.put(taskId, new TaskInfo("running", null, command));
|
|
||||||
|
|
||||||
executor.submit(() -> execute(taskId, command));
|
|
||||||
|
|
||||||
return "Background task " + taskId + " started: "
|
|
||||||
+ command.substring(0, Math.min(80, command.length()));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. 子进程完成后, 结果进入通知队列。使用 `ProcessBuilder` 执行命令,支持超时控制。
|
|
||||||
|
|
||||||
```java
|
|
||||||
private void execute(String taskId, String command) {
|
|
||||||
String status, output;
|
|
||||||
try {
|
|
||||||
ProcessBuilder pb = new ProcessBuilder("sh", "-c", command);
|
|
||||||
pb.redirectErrorStream(true);
|
|
||||||
Process process = pb.start();
|
|
||||||
try (BufferedReader reader = new BufferedReader(
|
|
||||||
new InputStreamReader(process.getInputStream()))) {
|
|
||||||
output = reader.lines().collect(Collectors.joining("\n"));
|
|
||||||
}
|
|
||||||
boolean finished = process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
|
||||||
if (!finished) { process.destroyForcibly(); status = "timeout"; }
|
|
||||||
else { status = "completed"; }
|
|
||||||
} catch (Exception e) { output = "Error: " + e.getMessage(); status = "error"; }
|
|
||||||
|
|
||||||
tasks.put(taskId, new TaskInfo(status, output, command));
|
|
||||||
notificationQueue.add(new Notification(taskId, status, command, output));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
4. 每次用户输入时排空通知队列, 注入系统提示。Spring AI 的 `ChatClient` 管理内部工具循环, 因此改为在每次用户输入时 drain 通知并构建系统提示, 核心概念不变: fire and forget。
|
|
||||||
|
|
||||||
```java
|
|
||||||
AgentRunner.interactive("s08", userMessage -> {
|
|
||||||
// Drain 后台任务通知(对应 Python 中循环前的 drain_notifications)
|
|
||||||
var notifs = bgManager.drainNotifications();
|
|
||||||
String bgContext = "";
|
|
||||||
if (!notifs.isEmpty()) {
|
|
||||||
String notifText = notifs.stream()
|
|
||||||
.map(n -> "[bg:" + n.taskId() + "] " + n.status() + ": " + n.result())
|
|
||||||
.collect(Collectors.joining("\n"));
|
|
||||||
bgContext = "\n\n<background-results>\n" + notifText + "\n</background-results>";
|
|
||||||
}
|
|
||||||
|
|
||||||
String system = "You are a coding agent. Use backgroundRun for long-running commands."
|
|
||||||
+ bgContext;
|
|
||||||
|
|
||||||
ChatClient chatClient = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem(system)
|
|
||||||
.defaultTools(new BashTool(), new ReadFileTool(),
|
|
||||||
new WriteFileTool(), new EditFileTool(), bgManager)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
return chatClient.prompt().user(userMessage).call().content();
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
循环保持单线程。只有子进程 I/O 被并行化。
|
|
||||||
|
|
||||||
## 相对 s07 的变更
|
|
||||||
|
|
||||||
| 组件 | 之前 (s07) | 之后 (s08) |
|
|
||||||
|----------------|------------------|------------------------------------|
|
|
||||||
| Tools | 8 | 6 (基础 + backgroundRun + check) |
|
|
||||||
| 执行方式 | 仅阻塞 | 阻塞 + 虚拟线程 (Java 21) |
|
|
||||||
| 通知机制 | 无 | 每轮排空的 ConcurrentLinkedQueue |
|
|
||||||
| 并发 | 无 | 虚拟线程 (更轻量, JVM 调度) |
|
|
||||||
|
|
||||||
## 试一试
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd learn-claude-code
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s08.S08BackgroundTasks
|
|
||||||
```
|
|
||||||
|
|
||||||
试试这些 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`
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
# s09: Agent Teams (智能体团队)
|
|
||||||
|
|
||||||
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > [ s09 ] s10 > s11 > s12`
|
|
||||||
|
|
||||||
> *"任务太大一个人干不完, 要能分给队友"* -- 持久化队友 + JSONL 邮箱。
|
|
||||||
>
|
|
||||||
> **Harness 层**: 团队邮箱 -- 多个模型, 通过文件协调。
|
|
||||||
|
|
||||||
## 问题
|
|
||||||
|
|
||||||
子智能体 (s04) 是一次性的: 生成、干活、返回摘要、消亡。没有身份, 没有跨调用的记忆。后台任务 (s08) 能跑 shell 命令, 但做不了 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 维护团队名册。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// src/main/java/io/mybatis/learn/s09/TeammateManager.java
|
|
||||||
public class TeammateManager {
|
|
||||||
private final ChatModel chatModel;
|
|
||||||
private final MessageBus bus;
|
|
||||||
private final Path configPath;
|
|
||||||
private final ObjectMapper mapper = new ObjectMapper();
|
|
||||||
private Map<String, Object> config;
|
|
||||||
// Python用threading.Thread + dict; Java用ConcurrentHashMap天然线程安全
|
|
||||||
private final Map<String, Thread> threads = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
public TeammateManager(ChatModel chatModel, MessageBus bus, Path teamDir) {
|
|
||||||
this.chatModel = chatModel;
|
|
||||||
this.bus = bus;
|
|
||||||
this.configPath = teamDir.resolve("config.json");
|
|
||||||
Files.createDirectories(teamDir);
|
|
||||||
this.config = loadConfig();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. `spawn()` 创建队友并在线程中启动 agent loop。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// Python用threading.Thread; Java用Thread.startVirtualThread()虚拟线程
|
|
||||||
public synchronized String spawn(String name, String role, String prompt) {
|
|
||||||
Map<String, Object> member = new LinkedHashMap<>();
|
|
||||||
member.put("name", name);
|
|
||||||
member.put("role", role);
|
|
||||||
member.put("status", "working");
|
|
||||||
((List<Map<String, Object>>) config.get("members")).add(member);
|
|
||||||
saveConfig();
|
|
||||||
|
|
||||||
// 虚拟线程:轻量级,由JVM调度,不占用OS线程
|
|
||||||
Thread thread = Thread.startVirtualThread(
|
|
||||||
() -> teammateLoop(name, role, prompt));
|
|
||||||
threads.put(name, thread);
|
|
||||||
return "Spawned '" + name + "' (role: " + role + ")";
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. MessageBus: append-only 的 JSONL 收件箱。`send()` 追加一行; `read_inbox()` 读取全部并清空。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// src/main/java/io/mybatis/learn/core/team/MessageBus.java
|
|
||||||
// Python靠GIL隐式保证线程安全; Java用synchronized显式保证
|
|
||||||
public class MessageBus {
|
|
||||||
private final Path inboxDir;
|
|
||||||
private final ObjectMapper mapper = new ObjectMapper();
|
|
||||||
|
|
||||||
public synchronized String send(String sender, String to, String content,
|
|
||||||
String msgType, Map<String, Object> extra) {
|
|
||||||
Map<String, Object> msg = new LinkedHashMap<>();
|
|
||||||
msg.put("type", msgType);
|
|
||||||
msg.put("from", sender);
|
|
||||||
msg.put("content", content);
|
|
||||||
msg.put("timestamp", System.currentTimeMillis() / 1000.0);
|
|
||||||
if (extra != null) msg.putAll(extra);
|
|
||||||
|
|
||||||
Path inbox = inboxDir.resolve(to + ".jsonl");
|
|
||||||
Files.writeString(inbox, mapper.writeValueAsString(msg) + "\n",
|
|
||||||
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
|
||||||
return "Sent " + msgType + " to " + to;
|
|
||||||
}
|
|
||||||
|
|
||||||
public synchronized List<Map<String, Object>> readInbox(String name) {
|
|
||||||
Path inbox = inboxDir.resolve(name + ".jsonl");
|
|
||||||
if (!Files.exists(inbox)) return List.of();
|
|
||||||
List<Map<String, Object>> messages = new ArrayList<>();
|
|
||||||
for (String line : Files.readAllLines(inbox)) {
|
|
||||||
if (!line.isBlank())
|
|
||||||
messages.add(mapper.readValue(line, new TypeReference<>() {}));
|
|
||||||
}
|
|
||||||
Files.writeString(inbox, ""); // drain
|
|
||||||
return messages;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
4. 每个队友在每次 `call()` 调用间检查收件箱, 将消息注入上下文。ChatClient 的 `call()` 等价于 Python 的完整工具循环(循环到 `stop_reason != "tool_use"` 为止)。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// Python队友在每次LLM调用前检查收件箱; Java在每次call()调用间检查
|
|
||||||
protected void teammateLoop(String name, String role, String initialPrompt) {
|
|
||||||
String sysPrompt = String.format(
|
|
||||||
"You are '%s', role: %s. Use send_message to communicate.",
|
|
||||||
name, role);
|
|
||||||
|
|
||||||
var messageTool = new TeammateMessageTool(bus, name);
|
|
||||||
ChatClient client = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem(sysPrompt)
|
|
||||||
.defaultTools(new BashTool(), new ReadFileTool(),
|
|
||||||
new WriteFileTool(), new EditFileTool(), messageTool)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
// 初始工作(call() = 完整工具链,等价于Python循环到stop_reason != "tool_use")
|
|
||||||
String response = client.prompt(initialPrompt).call().content();
|
|
||||||
|
|
||||||
// 每次call()之间检查收件箱(而非Python的每次LLM调用之间)
|
|
||||||
for (int round = 0; round < 50; round++) {
|
|
||||||
Thread.sleep(2000);
|
|
||||||
var inbox = bus.readInbox(name);
|
|
||||||
if (inbox.isEmpty()) break;
|
|
||||||
String inboxJson = mapper.writeValueAsString(inbox);
|
|
||||||
response = client.prompt("<inbox>" + inboxJson + "</inbox>").call().content();
|
|
||||||
}
|
|
||||||
setStatus(name, "idle");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 相对 s08 的变更
|
|
||||||
|
|
||||||
| 组件 | 之前 (s08) | 之后 (s09) |
|
|
||||||
|----------------|------------------|------------------------------------|
|
|
||||||
| Tools | 6 | 9 (+spawn/send/read_inbox) |
|
|
||||||
| 智能体数量 | 单一 | 领导 + N 个队友 |
|
|
||||||
| 持久化 | 无 | config.json + JSONL 收件箱 |
|
|
||||||
| 线程 | 后台命令 | 每线程完整 agent loop |
|
|
||||||
| 生命周期 | 一次性 | idle -> working -> idle |
|
|
||||||
| 通信 | 无 | message + broadcast |
|
|
||||||
|
|
||||||
## 试一试
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd learn-claude-code
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s09.S09AgentTeams
|
|
||||||
```
|
|
||||||
|
|
||||||
试试这些 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` 手动检查领导的收件箱
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
# 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, 通过收件箱发起关机请求。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// src/main/java/io/mybatis/learn/s10/ProtocolTracker.java
|
|
||||||
// Python用字典 + threading.Lock; Java用ConcurrentHashMap天然线程安全
|
|
||||||
private final ConcurrentHashMap<String, Map<String, String>> shutdownRequests
|
|
||||||
= new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
public String handleShutdownRequest(String teammate) {
|
|
||||||
String reqId = UUID.randomUUID().toString().substring(0, 8);
|
|
||||||
shutdownRequests.put(reqId, new ConcurrentHashMap<>(Map.of(
|
|
||||||
"target", teammate, "status", "pending")));
|
|
||||||
bus.send("lead", teammate, "Please shut down gracefully.",
|
|
||||||
"shutdown_request", Map.of("request_id", reqId));
|
|
||||||
return "Shutdown request " + reqId + " sent to '" + teammate
|
|
||||||
+ "' (status: pending)";
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. 队友收到请求后, 用 approve/reject 响应。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// TeammateProtocolTool - 队友用@Tool注解响应关闭请求
|
|
||||||
@Tool(description = "Respond to a shutdown request")
|
|
||||||
public String shutdownResponse(
|
|
||||||
@ToolParam(description = "The request_id") String requestId,
|
|
||||||
@ToolParam(description = "true to approve") boolean approve,
|
|
||||||
@ToolParam(description = "Reason for decision") String reason) {
|
|
||||||
return tracker.respondToShutdown(name, requestId, approve, reason);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProtocolTracker - 更新追踪器 + 发送响应消息
|
|
||||||
public String respondToShutdown(String sender, String requestId,
|
|
||||||
boolean approve, String reason) {
|
|
||||||
var req = shutdownRequests.get(requestId);
|
|
||||||
if (req != null) {
|
|
||||||
req.put("status", approve ? "approved" : "rejected");
|
|
||||||
}
|
|
||||||
bus.send(sender, "lead", reason != null ? reason : "",
|
|
||||||
"shutdown_response",
|
|
||||||
Map.of("request_id", requestId, "approve", approve));
|
|
||||||
return "Shutdown " + (approve ? "approved" : "rejected");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. 计划审批遵循完全相同的模式。队友提交计划 (生成 request_id), 领导审查 (引用同一个 request_id)。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// ProtocolTracker - 同样的request_id关联模式,两种用途
|
|
||||||
private final ConcurrentHashMap<String, Map<String, String>> planRequests
|
|
||||||
= new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
public String reviewPlan(String requestId, boolean approve, String feedback) {
|
|
||||||
var req = planRequests.get(requestId);
|
|
||||||
if (req == null) return "Error: Unknown plan request_id '" + requestId + "'";
|
|
||||||
req.put("status", approve ? "approved" : "rejected");
|
|
||||||
bus.send("lead", req.get("from"), feedback != null ? feedback : "",
|
|
||||||
"plan_approval_response",
|
|
||||||
Map.of("request_id", requestId, "approve", approve,
|
|
||||||
"feedback", feedback != null ? feedback : ""));
|
|
||||||
return "Plan " + req.get("status") + " for '" + req.get("from") + "'";
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
一个 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
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s10.S10TeamProtocols
|
|
||||||
```
|
|
||||||
|
|
||||||
试试这些 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` 监控状态
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
# s11: Autonomous Agents (自治智能体)
|
|
||||||
|
|
||||||
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > [ s11 ] s12`
|
|
||||||
|
|
||||||
> *"队友自己看看板, 有活就认领"* -- 不需要领导逐个分配, 自组织。
|
|
||||||
>
|
|
||||||
> **Harness 层**: 自治 -- 模型自己找活干, 无需指派。
|
|
||||||
|
|
||||||
## 问题
|
|
||||||
|
|
||||||
s09-s10 中, 队友只在被明确指派时才动。领导得给每个队友写 prompt, 任务看板上 10 个未认领的任务得手动分配。这扩展不了。
|
|
||||||
|
|
||||||
真正的自治: 队友自己扫描任务看板, 认领没人做的任务, 做完再找下一个。
|
|
||||||
|
|
||||||
一个细节: 上下文压缩 (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 via system prompt (always present):
|
|
||||||
ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem(identityPrompt) // 每次调用自动携带
|
|
||||||
```
|
|
||||||
|
|
||||||
## 工作原理
|
|
||||||
|
|
||||||
1. 队友循环分两个阶段: WORK 和 IDLE。LLM 停止调用工具 (或调用了 `idle`) 时, 进入 IDLE。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// src/main/java/io/mybatis/learn/s11/S11AutonomousAgents.java
|
|
||||||
// AutonomousTeammateManager.autonomousLoop()
|
|
||||||
|
|
||||||
private void autonomousLoop(String name, String role, String initialPrompt) {
|
|
||||||
// idle标志:工具调用时设置,外部循环检测
|
|
||||||
AtomicBoolean idleRequested = new AtomicBoolean(false);
|
|
||||||
var idleTool = new IdleTool(idleRequested);
|
|
||||||
|
|
||||||
ChatClient client = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem(sysPrompt)
|
|
||||||
.defaultTools(new BashTool(), new ReadFileTool(),
|
|
||||||
new WriteFileTool(), new EditFileTool(),
|
|
||||||
messageTool, protocolTool, idleTool, claimTool)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
// -- WORK PHASE --
|
|
||||||
String nextMsg = initialPrompt;
|
|
||||||
for (int round = 0; round < 50 && nextMsg != null; round++) {
|
|
||||||
var inbox = bus.readInbox(name);
|
|
||||||
// ... 合并收件箱消息到 nextMsg ...
|
|
||||||
idleRequested.set(false);
|
|
||||||
String response = client.prompt(sb.toString()).call().content();
|
|
||||||
if (idleRequested.get()) break; // idle工具被调用
|
|
||||||
nextMsg = null; // 后续轮次靠inbox驱动
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- IDLE PHASE --
|
|
||||||
setStatus(name, "idle");
|
|
||||||
// ... 轮询收件箱 + 任务板(见下文) ...
|
|
||||||
if (!resume) { setStatus(name, "shutdown"); return; }
|
|
||||||
setStatus(name, "working");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. 空闲阶段循环轮询收件箱和任务看板。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// IDLE PHASE: 轮询收件箱 + 任务板
|
|
||||||
setStatus(name, "idle");
|
|
||||||
boolean resume = false;
|
|
||||||
int polls = IDLE_TIMEOUT / Math.max(POLL_INTERVAL, 1); // 60/5 = 12
|
|
||||||
|
|
||||||
for (int p = 0; p < polls; p++) {
|
|
||||||
Thread.sleep(POLL_INTERVAL * 1000L);
|
|
||||||
|
|
||||||
// 检查收件箱
|
|
||||||
var inbox = bus.readInbox(name);
|
|
||||||
if (!inbox.isEmpty()) {
|
|
||||||
initialPrompt = "<inbox>" + mapper.writeValueAsString(inbox) + "</inbox>";
|
|
||||||
resume = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 扫描任务板
|
|
||||||
var unclaimed = scanUnclaimedTasks(tasksDir);
|
|
||||||
if (!unclaimed.isEmpty()) {
|
|
||||||
var task = unclaimed.get(0);
|
|
||||||
int taskId = ((Number) task.get("id")).intValue();
|
|
||||||
claimTask(tasksDir, taskId, name);
|
|
||||||
initialPrompt = String.format(
|
|
||||||
"<auto-claimed>Task #%d: %s\n%s</auto-claimed>",
|
|
||||||
taskId, task.get("subject"),
|
|
||||||
task.getOrDefault("description", ""));
|
|
||||||
resume = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!resume) { setStatus(name, "shutdown"); return; }
|
|
||||||
setStatus(name, "working");
|
|
||||||
```
|
|
||||||
|
|
||||||
3. 任务看板扫描: 找 pending 状态、无 owner、未被阻塞的任务。
|
|
||||||
|
|
||||||
```java
|
|
||||||
static List<Map<String, Object>> scanUnclaimedTasks(Path tasksDir) {
|
|
||||||
if (!Files.exists(tasksDir)) return List.of();
|
|
||||||
List<Map<String, Object>> unclaimed = new ArrayList<>();
|
|
||||||
ObjectMapper mapper = new ObjectMapper();
|
|
||||||
try (var files = Files.list(tasksDir)) {
|
|
||||||
files.filter(f -> f.getFileName().toString().startsWith("task_")
|
|
||||||
&& f.getFileName().toString().endsWith(".json"))
|
|
||||||
.sorted()
|
|
||||||
.forEach(f -> {
|
|
||||||
Map<String, Object> task = mapper.readValue(f.toFile(), Map.class);
|
|
||||||
if ("pending".equals(task.get("status"))
|
|
||||||
&& (task.get("owner") == null || "".equals(task.get("owner")))
|
|
||||||
&& (task.get("blockedBy") == null
|
|
||||||
|| ((List<?>) task.get("blockedBy")).isEmpty())) {
|
|
||||||
unclaimed.add(task);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return unclaimed;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
4. 身份保持: Java/Spring AI 的 `ChatClient.defaultSystem()` 在每次调用时自动携带系统提示, 身份信息始终存在, 无需像 Python 版本那样在压缩后手动重注入。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// 身份信息通过 defaultSystem 在构建时注入, 每次 prompt 自动携带
|
|
||||||
String sysPrompt = String.format(
|
|
||||||
"You are '%s', role: %s, team: %s, at %s. "
|
|
||||||
+ "Use idle tool when you have no more work. You will auto-claim new tasks.",
|
|
||||||
name, role, teamName, workDir);
|
|
||||||
|
|
||||||
ChatClient client = ChatClient.builder(chatModel)
|
|
||||||
.defaultSystem(sysPrompt) // 身份始终存在于系统提示中
|
|
||||||
.defaultTools(new BashTool(), new ReadFileTool(),
|
|
||||||
new WriteFileTool(), new EditFileTool(),
|
|
||||||
messageTool, protocolTool, idleTool, claimTool)
|
|
||||||
.build();
|
|
||||||
```
|
|
||||||
|
|
||||||
## 相对 s10 的变更
|
|
||||||
|
|
||||||
| 组件 | 之前 (s10) | 之后 (s11) |
|
|
||||||
|----------------|------------------|----------------------------------|
|
|
||||||
| Tools | 12 | 14 (+idle, +claim_task) |
|
|
||||||
| 自治性 | 领导指派 | 自组织 |
|
|
||||||
| 空闲阶段 | 无 | 轮询收件箱 + 任务看板 |
|
|
||||||
| 任务认领 | 仅手动 | 自动认领未分配任务 |
|
|
||||||
| 身份 | 系统提示 | + 压缩后重注入 |
|
|
||||||
| 超时 | 无 | 60 秒空闲 -> 自动关机 |
|
|
||||||
|
|
||||||
## 试一试
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd learn-claude-code
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s11.S11AutonomousAgents
|
|
||||||
```
|
|
||||||
|
|
||||||
试试这些 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` 监控谁在工作、谁在空闲
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
# s12: Worktree + Task Isolation (Worktree 任务隔离)
|
|
||||||
|
|
||||||
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > [ s12 ]`
|
|
||||||
|
|
||||||
> *"各干各的目录, 互不干扰"* -- 任务管目标, worktree 管目录, 按 ID 绑定。
|
|
||||||
>
|
|
||||||
> **Harness 层**: 目录隔离 -- 永不碰撞的并行执行通道。
|
|
||||||
|
|
||||||
## 问题
|
|
||||||
|
|
||||||
到 s11, 智能体已经能自主认领和完成任务。但所有任务共享一个目录。两个智能体同时重构不同模块 -- A 改 `Config.java`, B 也改 `Config.java`, 未提交的改动互相污染, 谁也没法干净回滚。
|
|
||||||
|
|
||||||
任务板管 "做什么" 但不管 "在哪做"。解法: 给每个任务一个独立的 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. **创建任务。** 先把目标持久化。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// src/main/java/io/mybatis/learn/s12/WorktreeTaskManager.java
|
|
||||||
tasks.create("Implement auth refactor", "");
|
|
||||||
// -> .tasks/task_1.json status=pending worktree=""
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **创建 worktree 并绑定任务。** 传入 `task_id` 自动将任务推进到 `in_progress`。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// src/main/java/io/mybatis/learn/s12/WorktreeManager.java
|
|
||||||
worktrees.create("auth-refactor", 1, "HEAD");
|
|
||||||
// -> git worktree add -b wt/auth-refactor .worktrees/auth-refactor HEAD
|
|
||||||
// -> index.json gets new entry, task_1.json gets worktree="auth-refactor"
|
|
||||||
```
|
|
||||||
|
|
||||||
绑定同时写入两侧状态:
|
|
||||||
|
|
||||||
```java
|
|
||||||
// src/main/java/io/mybatis/learn/s12/WorktreeTaskManager.java
|
|
||||||
public String bindWorktree(int taskId, String worktree, String owner) {
|
|
||||||
var task = load(taskId);
|
|
||||||
task.put("worktree", worktree);
|
|
||||||
if (owner != null && !owner.isEmpty()) task.put("owner", owner);
|
|
||||||
if ("pending".equals(task.get("status"))) task.put("status", "in_progress");
|
|
||||||
task.put("updated_at", System.currentTimeMillis() / 1000.0);
|
|
||||||
save(task);
|
|
||||||
return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(task);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **在 worktree 中执行命令。** `cwd` 指向隔离目录。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// src/main/java/io/mybatis/learn/s12/WorktreeManager.java - run()
|
|
||||||
boolean isWindows = System.getProperty("os.name").toLowerCase().contains("win");
|
|
||||||
ProcessBuilder pb = isWindows
|
|
||||||
? new ProcessBuilder("cmd", "/c", command)
|
|
||||||
: new ProcessBuilder("sh", "-c", command);
|
|
||||||
pb.directory(path.toFile());
|
|
||||||
pb.redirectErrorStream(true);
|
|
||||||
Process p = pb.start();
|
|
||||||
String out = new String(p.getInputStream().readAllBytes()).trim();
|
|
||||||
boolean finished = p.waitFor(300, java.util.concurrent.TimeUnit.SECONDS);
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **收尾。** 两种选择:
|
|
||||||
- `worktree_keep(name)` -- 保留目录供后续使用。
|
|
||||||
- `worktree_remove(name, complete_task=True)` -- 删除目录, 完成绑定任务, 发出事件。一个调用搞定拆除 + 完成。
|
|
||||||
|
|
||||||
```java
|
|
||||||
// src/main/java/io/mybatis/learn/s12/WorktreeManager.java
|
|
||||||
public String remove(String name, boolean force, boolean completeTask) {
|
|
||||||
var wt = findWorktree(name);
|
|
||||||
events.emit("worktree.remove.before", ...);
|
|
||||||
runGit("worktree", "remove", wt.get("path").toString());
|
|
||||||
if (completeTask && wt.get("task_id") != null) {
|
|
||||||
int taskId = ((Number) wt.get("task_id")).intValue();
|
|
||||||
tasks.update(taskId, "completed", null);
|
|
||||||
tasks.unbindWorktree(taskId);
|
|
||||||
events.emit("task.completed",
|
|
||||||
Map.of("id", taskId, "status", "completed"),
|
|
||||||
Map.of("name", name), null);
|
|
||||||
}
|
|
||||||
// 更新 index.json: status -> "removed"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
|
||||||
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s12.S12WorktreeIsolation
|
|
||||||
```
|
|
||||||
|
|
||||||
试试这些 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.`
|
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
构建 Claude Code Java 发行版(jlink 最小 JRE + fat jar)
|
||||||
|
.DESCRIPTION
|
||||||
|
生成可分发包:bin/ 启动脚本 + lib/ fat jar + runtime/ 精简 JRE
|
||||||
|
用户只需将 bin/ 目录加入 PATH 即可使用 claude-code 命令
|
||||||
|
.PARAMETER JavaHome
|
||||||
|
JDK 25 安装路径(默认使用 JAVA_HOME 环境变量)
|
||||||
|
.PARAMETER OutputDir
|
||||||
|
输出目录(默认 dist/)
|
||||||
|
.PARAMETER SkipBuild
|
||||||
|
跳过 Maven 构建(使用已存在的 jar)
|
||||||
|
.EXAMPLE
|
||||||
|
.\packaging\build-dist.ps1
|
||||||
|
.\packaging\build-dist.ps1 -JavaHome "C:\Dev\jdk-25.0.2"
|
||||||
|
.\packaging\build-dist.ps1 -SkipBuild
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[string]$JavaHome = $env:JAVA_HOME,
|
||||||
|
[string]$OutputDir = "dist",
|
||||||
|
[switch]$SkipBuild
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$ProjectRoot = Split-Path -Parent $PSScriptRoot
|
||||||
|
|
||||||
|
# ─── 验证环境 ───
|
||||||
|
$javaExe = if ($IsWindows -or $env:OS -eq "Windows_NT") { "java.exe" } else { "java" }
|
||||||
|
$jlinkExe = if ($IsWindows -or $env:OS -eq "Windows_NT") { "jlink.exe" } else { "jlink" }
|
||||||
|
$jdepsExe = if ($IsWindows -or $env:OS -eq "Windows_NT") { "jdeps.exe" } else { "jdeps" }
|
||||||
|
|
||||||
|
if (-not $JavaHome) {
|
||||||
|
Write-Error "JAVA_HOME not set. Use -JavaHome parameter or set JAVA_HOME environment variable."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$javaBinPath = Join-Path $JavaHome "bin"
|
||||||
|
if (-not (Test-Path (Join-Path $javaBinPath $javaExe))) {
|
||||||
|
Write-Error "JDK not found at $JavaHome. Ensure JDK 25 is installed."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$env:JAVA_HOME = $JavaHome
|
||||||
|
$env:PATH = "$javaBinPath$([IO.Path]::PathSeparator)$env:PATH"
|
||||||
|
|
||||||
|
$javaVersion = & (Join-Path $javaBinPath $javaExe) --version 2>&1 | Select-Object -First 1
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " Claude Code Java - Distribution Builder" -ForegroundColor Cyan
|
||||||
|
Write-Host " ========================================" -ForegroundColor Cyan
|
||||||
|
Write-Host " JDK: $javaVersion" -ForegroundColor Gray
|
||||||
|
Write-Host " Output: $OutputDir" -ForegroundColor Gray
|
||||||
|
|
||||||
|
# ─── 检测平台 ───
|
||||||
|
$isWin = $IsWindows -or ($env:OS -eq "Windows_NT")
|
||||||
|
$isMac = $IsMacOS
|
||||||
|
if ($isWin) { $platform = "windows-x64" }
|
||||||
|
elseif ($isMac) {
|
||||||
|
$arch = & uname -m 2>&1
|
||||||
|
$platform = if ($arch -match "arm64") { "macos-aarch64" } else { "macos-x64" }
|
||||||
|
} else {
|
||||||
|
$arch = & uname -m 2>&1
|
||||||
|
$platform = if ($arch -match "aarch64") { "linux-aarch64" } else { "linux-x64" }
|
||||||
|
}
|
||||||
|
Write-Host " Platform: $platform" -ForegroundColor Gray
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# ─── Step 1: Maven 构建 ───
|
||||||
|
Push-Location $ProjectRoot
|
||||||
|
try {
|
||||||
|
if (-not $SkipBuild) {
|
||||||
|
Write-Host "[1/4] Building fat jar with Maven..." -ForegroundColor Yellow
|
||||||
|
& mvn package -q -DskipTests 2>&1
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Error "Maven build failed"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host " OK Build succeeded" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host "[1/4] Skipping build (using existing jar)" -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
|
||||||
|
# 查找 fat jar
|
||||||
|
$jarFile = Get-ChildItem "target/*.jar" -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.Name -notmatch "original" } |
|
||||||
|
Select-Object -First 1
|
||||||
|
if (-not $jarFile) {
|
||||||
|
Write-Error "No jar found in target/. Run without -SkipBuild."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$jarSize = [math]::Round($jarFile.Length / 1MB, 1)
|
||||||
|
Write-Host " Jar: $($jarFile.Name) ($jarSize MB)" -ForegroundColor Gray
|
||||||
|
|
||||||
|
# ─── Step 2: jdeps 分析模块 ───
|
||||||
|
Write-Host "[2/4] Analyzing module dependencies with jdeps..." -ForegroundColor Yellow
|
||||||
|
$detected = & (Join-Path $javaBinPath $jdepsExe) --print-module-deps --ignore-missing-deps --multi-release 25 $jarFile.FullName 2>&1
|
||||||
|
if ($LASTEXITCODE -ne 0 -or -not $detected) {
|
||||||
|
Write-Host " Warning: jdeps failed, using default modules" -ForegroundColor DarkYellow
|
||||||
|
$detected = "java.base,java.desktop,java.management,java.net.http"
|
||||||
|
}
|
||||||
|
Write-Host " Detected: $detected" -ForegroundColor Gray
|
||||||
|
|
||||||
|
# Spring Boot 运行时必要模块
|
||||||
|
$required = @(
|
||||||
|
"java.naming", # JNDI (Spring)
|
||||||
|
"java.xml", # XML processing (Spring)
|
||||||
|
"java.sql", # JDBC
|
||||||
|
"java.instrument", # Java agent / instrumentation
|
||||||
|
"java.compiler", # Annotation processing
|
||||||
|
"java.scripting", # Script engines
|
||||||
|
"jdk.unsupported", # sun.misc.Unsafe
|
||||||
|
"jdk.crypto.ec", # HTTPS/TLS ECDH
|
||||||
|
"jdk.zipfs", # ZIP filesystem provider
|
||||||
|
"jdk.jfr", # Flight Recorder
|
||||||
|
"jdk.net" # Extended socket options (RestClient)
|
||||||
|
)
|
||||||
|
$allSet = [System.Collections.Generic.HashSet[string]]::new()
|
||||||
|
foreach ($m in ($detected -split ",")) { [void]$allSet.Add($m.Trim()) }
|
||||||
|
foreach ($m in $required) { [void]$allSet.Add($m) }
|
||||||
|
$modules = ($allSet | Sort-Object) -join ","
|
||||||
|
Write-Host " Final modules: $modules" -ForegroundColor Gray
|
||||||
|
|
||||||
|
# ─── Step 3: jlink ───
|
||||||
|
Write-Host "[3/4] Creating minimal JRE with jlink..." -ForegroundColor Yellow
|
||||||
|
$distDir = Join-Path $ProjectRoot $OutputDir
|
||||||
|
$runtimeDir = Join-Path $distDir "runtime"
|
||||||
|
|
||||||
|
if (Test-Path $distDir) { Remove-Item $distDir -Recurse -Force }
|
||||||
|
New-Item -ItemType Directory -Path $distDir -Force | Out-Null
|
||||||
|
|
||||||
|
& (Join-Path $javaBinPath $jlinkExe) `
|
||||||
|
--add-modules $modules `
|
||||||
|
--output $runtimeDir `
|
||||||
|
--strip-debug `
|
||||||
|
--compress zip-6 `
|
||||||
|
--no-header-files `
|
||||||
|
--no-man-pages 2>&1
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Error "jlink failed"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$runtimeSize = [math]::Round(
|
||||||
|
(Get-ChildItem -Recurse $runtimeDir | Measure-Object -Property Length -Sum).Sum / 1MB, 1
|
||||||
|
)
|
||||||
|
Write-Host " OK Runtime: $runtimeSize MB" -ForegroundColor Green
|
||||||
|
|
||||||
|
# ─── Step 4: 组装 ───
|
||||||
|
Write-Host "[4/4] Assembling distribution..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
# lib/
|
||||||
|
$libDir = Join-Path $distDir "lib"
|
||||||
|
New-Item -ItemType Directory -Path $libDir -Force | Out-Null
|
||||||
|
Copy-Item $jarFile.FullName (Join-Path $libDir "claude-code-java.jar")
|
||||||
|
|
||||||
|
# bin/
|
||||||
|
$binDir = Join-Path $distDir "bin"
|
||||||
|
New-Item -ItemType Directory -Path $binDir -Force | Out-Null
|
||||||
|
|
||||||
|
# ── Windows launcher (claude-code.cmd) ──
|
||||||
|
$cmdContent = @'
|
||||||
|
@echo off
|
||||||
|
setlocal enabledelayedexpansion
|
||||||
|
chcp 65001 >nul 2>&1
|
||||||
|
|
||||||
|
set "SCRIPT_DIR=%~dp0"
|
||||||
|
set "APP_HOME=%SCRIPT_DIR%.."
|
||||||
|
set "RUNTIME=%APP_HOME%\runtime"
|
||||||
|
set "JAR=%APP_HOME%\lib\claude-code-java.jar"
|
||||||
|
|
||||||
|
if not exist "%RUNTIME%\bin\java.exe" (
|
||||||
|
echo Error: Runtime not found at %RUNTIME%
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
"%RUNTIME%\bin\java.exe" ^
|
||||||
|
--enable-preview ^
|
||||||
|
--enable-native-access=ALL-UNNAMED ^
|
||||||
|
--sun-misc-unsafe-memory-access=allow ^
|
||||||
|
-Xmx512m ^
|
||||||
|
-Djava.net.preferIPv4Stack=true ^
|
||||||
|
-Dstdout.encoding=UTF-8 ^
|
||||||
|
-Dstderr.encoding=UTF-8 ^
|
||||||
|
-Dfile.encoding=UTF-8 ^
|
||||||
|
-jar "%JAR%" ^
|
||||||
|
%*
|
||||||
|
'@
|
||||||
|
Set-Content -Path (Join-Path $binDir "claude-code.cmd") -Value $cmdContent -Encoding ASCII
|
||||||
|
|
||||||
|
# ── Unix launcher (claude-code) ──
|
||||||
|
$shContent = @'
|
||||||
|
#!/bin/sh
|
||||||
|
# Claude Code Java launcher
|
||||||
|
SCRIPT="$0"
|
||||||
|
while [ -L "$SCRIPT" ]; do
|
||||||
|
DIR="$(cd "$(dirname "$SCRIPT")" && pwd)"
|
||||||
|
SCRIPT="$(readlink "$SCRIPT")"
|
||||||
|
[ "${SCRIPT#/}" = "$SCRIPT" ] && SCRIPT="$DIR/$SCRIPT"
|
||||||
|
done
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$SCRIPT")" && pwd)"
|
||||||
|
APP_HOME="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
exec "$APP_HOME/runtime/bin/java" \
|
||||||
|
--enable-preview \
|
||||||
|
--enable-native-access=ALL-UNNAMED \
|
||||||
|
--sun-misc-unsafe-memory-access=allow \
|
||||||
|
-Xmx512m \
|
||||||
|
-Djava.net.preferIPv4Stack=true \
|
||||||
|
-Dstdout.encoding=UTF-8 \
|
||||||
|
-Dstderr.encoding=UTF-8 \
|
||||||
|
-Dfile.encoding=UTF-8 \
|
||||||
|
-jar "$APP_HOME/lib/claude-code-java.jar" \
|
||||||
|
"$@"
|
||||||
|
'@
|
||||||
|
$shContent = $shContent -replace "`r`n", "`n"
|
||||||
|
[IO.File]::WriteAllText(
|
||||||
|
(Join-Path $binDir "claude-code"),
|
||||||
|
$shContent,
|
||||||
|
[Text.UTF8Encoding]::new($false)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 总计 ──
|
||||||
|
$totalSize = [math]::Round(
|
||||||
|
(Get-ChildItem -Recurse $distDir | Measure-Object -Property Length -Sum).Sum / 1MB, 1
|
||||||
|
)
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " Distribution built successfully!" -ForegroundColor Green
|
||||||
|
Write-Host " ================================" -ForegroundColor Green
|
||||||
|
Write-Host " Location : $distDir" -ForegroundColor White
|
||||||
|
Write-Host " Size : $totalSize MB (Runtime $runtimeSize MB + Jar $jarSize MB)" -ForegroundColor White
|
||||||
|
Write-Host " Platform : $platform" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " Quick start:" -ForegroundColor Cyan
|
||||||
|
if ($isWin) {
|
||||||
|
Write-Host " set AI_API_KEY=your-key" -ForegroundColor Gray
|
||||||
|
Write-Host " $binDir\claude-code.cmd" -ForegroundColor Gray
|
||||||
|
} else {
|
||||||
|
Write-Host " chmod +x $binDir/claude-code" -ForegroundColor Gray
|
||||||
|
Write-Host " export AI_API_KEY=your-key" -ForegroundColor Gray
|
||||||
|
Write-Host " $binDir/claude-code" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " Or add bin/ to PATH for global access:" -ForegroundColor Cyan
|
||||||
|
if ($isWin) {
|
||||||
|
Write-Host " setx PATH `"%PATH%;$(Resolve-Path $binDir)`"" -ForegroundColor Gray
|
||||||
|
} else {
|
||||||
|
Write-Host " export PATH=`"${binDir}:`$PATH`"" -ForegroundColor Gray
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# 构建 Claude Code Java 发行版(jlink 最小 JRE + fat jar)
|
||||||
|
#
|
||||||
|
# 用法:
|
||||||
|
# ./packaging/build-dist.sh # 使用 JAVA_HOME
|
||||||
|
# JAVA_HOME=/path/to/jdk25 ./packaging/build-dist.sh # 指定 JDK
|
||||||
|
# ./packaging/build-dist.sh --skip-build # 跳过 Maven 构建
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
OUTPUT_DIR="${OUTPUT_DIR:-dist}"
|
||||||
|
SKIP_BUILD=false
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--skip-build) SKIP_BUILD=true ;;
|
||||||
|
--help|-h)
|
||||||
|
echo "Usage: $0 [--skip-build]"
|
||||||
|
echo " Environment: JAVA_HOME (required), OUTPUT_DIR (default: dist)"
|
||||||
|
exit 0 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ─── 验证环境 ───
|
||||||
|
if [ -z "${JAVA_HOME:-}" ] || [ ! -x "$JAVA_HOME/bin/java" ]; then
|
||||||
|
echo "Error: JAVA_HOME not set or JDK not found."
|
||||||
|
echo " export JAVA_HOME=/path/to/jdk-25"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
JAVA_VERSION=$("$JAVA_HOME/bin/java" --version 2>&1 | head -1)
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo " Claude Code Java - Distribution Builder"
|
||||||
|
echo " ========================================"
|
||||||
|
echo " JDK: $JAVA_VERSION"
|
||||||
|
echo " Output: $OUTPUT_DIR"
|
||||||
|
|
||||||
|
# 检测平台
|
||||||
|
OS="$(uname -s)"
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
case "$OS" in
|
||||||
|
Darwin)
|
||||||
|
PLATFORM="macos-$([ "$ARCH" = "arm64" ] && echo "aarch64" || echo "x64")" ;;
|
||||||
|
Linux)
|
||||||
|
PLATFORM="linux-$([ "$ARCH" = "aarch64" ] && echo "aarch64" || echo "x64")" ;;
|
||||||
|
*)
|
||||||
|
PLATFORM="unknown-$OS-$ARCH" ;;
|
||||||
|
esac
|
||||||
|
echo " Platform: $PLATFORM"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
|
# ─── Step 1: Maven 构建 ───
|
||||||
|
if [ "$SKIP_BUILD" = false ]; then
|
||||||
|
echo "[1/4] Building fat jar with Maven..."
|
||||||
|
mvn package -q -DskipTests
|
||||||
|
echo " OK Build succeeded"
|
||||||
|
else
|
||||||
|
echo "[1/4] Skipping build (using existing jar)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
JAR_FILE=$(find target -maxdepth 1 -name "*.jar" ! -name "*original*" | head -1)
|
||||||
|
if [ -z "$JAR_FILE" ]; then
|
||||||
|
echo "Error: No jar found in target/. Run without --skip-build."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
JAR_SIZE=$(du -m "$JAR_FILE" | cut -f1)
|
||||||
|
echo " Jar: $(basename "$JAR_FILE") (${JAR_SIZE} MB)"
|
||||||
|
|
||||||
|
# ─── Step 2: jdeps 分析 ───
|
||||||
|
echo "[2/4] Analyzing module dependencies with jdeps..."
|
||||||
|
DETECTED=$("$JAVA_HOME/bin/jdeps" --print-module-deps --ignore-missing-deps --multi-release 25 "$JAR_FILE" 2>/dev/null || echo "")
|
||||||
|
if [ -z "$DETECTED" ]; then
|
||||||
|
echo " Warning: jdeps failed, using defaults"
|
||||||
|
DETECTED="java.base,java.desktop,java.management,java.net.http"
|
||||||
|
fi
|
||||||
|
echo " Detected: $DETECTED"
|
||||||
|
|
||||||
|
# 追加 Spring Boot 运行时模块
|
||||||
|
EXTRA="java.naming,java.xml,java.sql,java.instrument,java.compiler,java.scripting,jdk.unsupported,jdk.crypto.ec,jdk.zipfs,jdk.jfr,jdk.net"
|
||||||
|
ALL_MODULES=$(echo "$DETECTED,$EXTRA" | tr ',' '\n' | sort -u | tr '\n' ',' | sed 's/,$//')
|
||||||
|
echo " Final: $ALL_MODULES"
|
||||||
|
|
||||||
|
# ─── Step 3: jlink ───
|
||||||
|
echo "[3/4] Creating minimal JRE with jlink..."
|
||||||
|
DIST_DIR="$PROJECT_ROOT/$OUTPUT_DIR"
|
||||||
|
RUNTIME_DIR="$DIST_DIR/runtime"
|
||||||
|
|
||||||
|
rm -rf "$DIST_DIR"
|
||||||
|
mkdir -p "$DIST_DIR"
|
||||||
|
|
||||||
|
"$JAVA_HOME/bin/jlink" \
|
||||||
|
--add-modules "$ALL_MODULES" \
|
||||||
|
--output "$RUNTIME_DIR" \
|
||||||
|
--strip-debug \
|
||||||
|
--compress zip-6 \
|
||||||
|
--no-header-files \
|
||||||
|
--no-man-pages
|
||||||
|
|
||||||
|
RUNTIME_SIZE=$(du -sm "$RUNTIME_DIR" | cut -f1)
|
||||||
|
echo " OK Runtime: ${RUNTIME_SIZE} MB"
|
||||||
|
|
||||||
|
# ─── Step 4: 组装 ───
|
||||||
|
echo "[4/4] Assembling distribution..."
|
||||||
|
|
||||||
|
# lib/
|
||||||
|
mkdir -p "$DIST_DIR/lib"
|
||||||
|
cp "$JAR_FILE" "$DIST_DIR/lib/claude-code-java.jar"
|
||||||
|
|
||||||
|
# bin/
|
||||||
|
mkdir -p "$DIST_DIR/bin"
|
||||||
|
|
||||||
|
# Unix launcher
|
||||||
|
cat > "$DIST_DIR/bin/claude-code" << 'LAUNCHER'
|
||||||
|
#!/bin/sh
|
||||||
|
SCRIPT="$0"
|
||||||
|
while [ -L "$SCRIPT" ]; do
|
||||||
|
DIR="$(cd "$(dirname "$SCRIPT")" && pwd)"
|
||||||
|
SCRIPT="$(readlink "$SCRIPT")"
|
||||||
|
[ "${SCRIPT#/}" = "$SCRIPT" ] && SCRIPT="$DIR/$SCRIPT"
|
||||||
|
done
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$SCRIPT")" && pwd)"
|
||||||
|
APP_HOME="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
exec "$APP_HOME/runtime/bin/java" \
|
||||||
|
--enable-preview \
|
||||||
|
--enable-native-access=ALL-UNNAMED \
|
||||||
|
--sun-misc-unsafe-memory-access=allow \
|
||||||
|
-Xmx512m \
|
||||||
|
-Djava.net.preferIPv4Stack=true \
|
||||||
|
-Dstdout.encoding=UTF-8 \
|
||||||
|
-Dstderr.encoding=UTF-8 \
|
||||||
|
-Dfile.encoding=UTF-8 \
|
||||||
|
-jar "$APP_HOME/lib/claude-code-java.jar" \
|
||||||
|
"$@"
|
||||||
|
LAUNCHER
|
||||||
|
chmod +x "$DIST_DIR/bin/claude-code"
|
||||||
|
|
||||||
|
# Windows launcher
|
||||||
|
cat > "$DIST_DIR/bin/claude-code.cmd" << 'LAUNCHER'
|
||||||
|
@echo off
|
||||||
|
setlocal enabledelayedexpansion
|
||||||
|
chcp 65001 >nul 2>&1
|
||||||
|
set "SCRIPT_DIR=%~dp0"
|
||||||
|
set "APP_HOME=%SCRIPT_DIR%.."
|
||||||
|
"%APP_HOME%\runtime\bin\java.exe" ^
|
||||||
|
--enable-preview ^
|
||||||
|
--enable-native-access=ALL-UNNAMED ^
|
||||||
|
--sun-misc-unsafe-memory-access=allow ^
|
||||||
|
-Xmx512m ^
|
||||||
|
-Djava.net.preferIPv4Stack=true ^
|
||||||
|
-Dstdout.encoding=UTF-8 ^
|
||||||
|
-Dstderr.encoding=UTF-8 ^
|
||||||
|
-Dfile.encoding=UTF-8 ^
|
||||||
|
-jar "%APP_HOME%\lib\claude-code-java.jar" ^
|
||||||
|
%*
|
||||||
|
LAUNCHER
|
||||||
|
|
||||||
|
# 统计
|
||||||
|
TOTAL_SIZE=$(du -sm "$DIST_DIR" | cut -f1)
|
||||||
|
echo ""
|
||||||
|
echo " Distribution built successfully!"
|
||||||
|
echo " ================================"
|
||||||
|
echo " Location : $DIST_DIR"
|
||||||
|
echo " Size : ${TOTAL_SIZE} MB (Runtime ${RUNTIME_SIZE} MB + Jar ${JAR_SIZE} MB)"
|
||||||
|
echo " Platform : $PLATFORM"
|
||||||
|
echo ""
|
||||||
|
echo " Quick start:"
|
||||||
|
echo " export AI_API_KEY=your-key"
|
||||||
|
echo " $DIST_DIR/bin/claude-code"
|
||||||
|
echo ""
|
||||||
|
echo " Or add to PATH:"
|
||||||
|
echo " export PATH=\"$DIST_DIR/bin:\$PATH\""
|
||||||
|
echo ""
|
||||||
@@ -7,20 +7,22 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-parent</artifactId>
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
<version>3.5.7</version>
|
<version>4.1.0-M2</version>
|
||||||
<relativePath/>
|
<relativePath/>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
<groupId>io.mybatis</groupId>
|
<groupId>com.claudecode</groupId>
|
||||||
<artifactId>learn-claude-code</artifactId>
|
<artifactId>claude-code-java</artifactId>
|
||||||
<version>0.0.1-SNAPSHOT</version>
|
<version>0.1.0</version>
|
||||||
<name>spring-ai-learn-claude-code</name>
|
<name>claude-code-java</name>
|
||||||
<description>Spring AI 实现的 learn-claude-code</description>
|
<description>Claude Code 的 Java Spring AI 重写实现</description>
|
||||||
<packaging>jar</packaging>
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<java.version>21</java.version>
|
<java.version>25</java.version>
|
||||||
<spring-ai.version>1.0.3</spring-ai.version>
|
<spring-ai.version>2.0.0-M4</spring-ai.version>
|
||||||
|
<jline.version>3.28.0</jline.version>
|
||||||
|
<picocli.version>4.7.6</picocli.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<dependencyManagement>
|
<dependencyManagement>
|
||||||
@@ -36,21 +38,64 @@
|
|||||||
</dependencyManagement>
|
</dependencyManagement>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<!-- Web (Servlet 同步调用) -->
|
<!-- Spring Boot Web(用于 HTTP 功能) -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-web</artifactId>
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
<!-- WebFlux (流式输出 Streaming 必须) -->
|
|
||||||
|
<!-- Spring Boot WebSocket(Server Mode 使用) -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
<!-- Spring AI OpenAI Starter (Spring AI 1.0.0 新命名规范) -->
|
|
||||||
|
<!-- Spring AI Anthropic(Claude 模型调用) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.ai</groupId>
|
||||||
|
<artifactId>spring-ai-starter-model-anthropic</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring AI OpenAI(兼容 OpenAI 格式的 API) -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.ai</groupId>
|
<groupId>org.springframework.ai</groupId>
|
||||||
<artifactId>spring-ai-starter-model-openai</artifactId>
|
<artifactId>spring-ai-starter-model-openai</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Jink: React-like Terminal UI 框架(基于 JLine,提供组件化全屏渲染) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.mybatis.jink</groupId>
|
||||||
|
<artifactId>jink</artifactId>
|
||||||
|
<version>0.5.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- JLine 3: 终端交互(行编辑、历史、Tab补全、ANSI样式) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.jline</groupId>
|
||||||
|
<artifactId>jline</artifactId>
|
||||||
|
<version>${jline.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- JLine FFM 终端提供者(JDK 22+,Windows/Linux/Mac 原生终端支持) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.jline</groupId>
|
||||||
|
<artifactId>jline-terminal-ffm</artifactId>
|
||||||
|
<version>${jline.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Picocli: CLI 命令解析 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>info.picocli</groupId>
|
||||||
|
<artifactId>picocli</artifactId>
|
||||||
|
<version>${picocli.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Jackson(JSON 处理) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
|
<artifactId>jackson-databind</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- Test -->
|
<!-- Test -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
@@ -64,7 +109,31 @@
|
|||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<jvmArguments>-Xmx512m -Xms256m --enable-native-access=ALL-UNNAMED -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 -Dfile.encoding=UTF-8</jvmArguments>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<release>${java.version}</release>
|
||||||
|
<compilerArgs>
|
||||||
|
<arg>--enable-preview</arg>
|
||||||
|
</compilerArgs>
|
||||||
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
|
|
||||||
|
<repositories>
|
||||||
|
<repository>
|
||||||
|
<id>spring-milestones</id>
|
||||||
|
<name>Spring Milestones</name>
|
||||||
|
<url>https://repo.spring.io/milestone</url>
|
||||||
|
<snapshots>
|
||||||
|
<enabled>false</enabled>
|
||||||
|
</snapshots>
|
||||||
|
</repository>
|
||||||
|
</repositories>
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
anthropic>=0.25.0
|
|
||||||
python-dotenv>=1.0.0
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
简单的天气查询脚本
|
||||||
|
使用OpenWeatherMap API
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import sys
|
||||||
|
|
||||||
|
def get_weather(api_key, city="Beijing", units="metric"):
|
||||||
|
"""
|
||||||
|
获取指定城市的天气
|
||||||
|
"""
|
||||||
|
url = "http://api.openweathermap.org/data/2.5/weather"
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"q": city,
|
||||||
|
"appid": api_key,
|
||||||
|
"units": units,
|
||||||
|
"lang": "zh_cn"
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(url, params=params, timeout=10)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get("cod") != 200:
|
||||||
|
print(f"错误: {data.get('message', '未知错误')}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return data
|
||||||
|
except Exception as e:
|
||||||
|
print(f"请求失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# 请在这里输入您的API密钥
|
||||||
|
API_KEY = "YOUR_API_KEY_HERE"
|
||||||
|
|
||||||
|
if API_KEY == "YOUR_API_KEY_HERE":
|
||||||
|
print("请先获取OpenWeatherMap API密钥:")
|
||||||
|
print("1. 访问 https://openweathermap.org/api")
|
||||||
|
print("2. 注册免费账户")
|
||||||
|
print("3. 获取API密钥并替换代码中的 'YOUR_API_KEY_HERE'")
|
||||||
|
print("\n免费套餐:每天60次调用")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 查询城市(可以修改为您所在的城市)
|
||||||
|
city = input("请输入城市名称(英文,如 Beijing, Shanghai, New York): ").strip()
|
||||||
|
if not city:
|
||||||
|
city = "Beijing"
|
||||||
|
|
||||||
|
print(f"\n正在查询 {city} 的天气...")
|
||||||
|
|
||||||
|
weather_data = get_weather(API_KEY, city)
|
||||||
|
|
||||||
|
if weather_data:
|
||||||
|
print(f"\n=== {weather_data['name']} 天气 ===")
|
||||||
|
print(f"温度: {weather_data['main']['temp']}°C")
|
||||||
|
print(f"体感温度: {weather_data['main']['feels_like']}°C")
|
||||||
|
print(f"天气: {weather_data['weather'][0]['description']}")
|
||||||
|
print(f"湿度: {weather_data['main']['humidity']}%")
|
||||||
|
print(f"风速: {weather_data['wind']['speed']} m/s")
|
||||||
|
print(f"气压: {weather_data['main']['pressure']} hPa")
|
||||||
|
else:
|
||||||
|
print("无法获取天气数据")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
---
|
|
||||||
name: agent-builder
|
|
||||||
description: |
|
|
||||||
Design and build AI agents for any domain. Use when users:
|
|
||||||
(1) ask to "create an agent", "build an assistant", or "design an AI system"
|
|
||||||
(2) want to understand agent architecture, agentic patterns, or autonomous AI
|
|
||||||
(3) need help with capabilities, subagents, planning, or skill mechanisms
|
|
||||||
(4) ask about Claude Code, Cursor, or similar agent internals
|
|
||||||
(5) want to build agents for business, research, creative, or operational tasks
|
|
||||||
Keywords: agent, assistant, autonomous, workflow, tool use, multi-step, orchestration
|
|
||||||
---
|
|
||||||
|
|
||||||
# Agent Builder
|
|
||||||
|
|
||||||
Build AI agents for any domain - customer service, research, operations, creative work, or specialized business processes.
|
|
||||||
|
|
||||||
## The Core Philosophy
|
|
||||||
|
|
||||||
> **The model already knows how to be an agent. Your job is to get out of the way.**
|
|
||||||
|
|
||||||
An agent is not complex engineering. It's a simple loop that invites the model to act:
|
|
||||||
|
|
||||||
```
|
|
||||||
LOOP:
|
|
||||||
Model sees: context + available capabilities
|
|
||||||
Model decides: act or respond
|
|
||||||
If act: execute capability, add result, continue
|
|
||||||
If respond: return to user
|
|
||||||
```
|
|
||||||
|
|
||||||
**That's it.** The magic isn't in the code - it's in the model. Your code just provides the opportunity.
|
|
||||||
|
|
||||||
## The Three Elements
|
|
||||||
|
|
||||||
### 1. Capabilities (What can it DO?)
|
|
||||||
|
|
||||||
Atomic actions the agent can perform: search, read, create, send, query, modify.
|
|
||||||
|
|
||||||
**Design principle**: Start with 3-5 capabilities. Add more only when the agent consistently fails because a capability is missing.
|
|
||||||
|
|
||||||
### 2. Knowledge (What does it KNOW?)
|
|
||||||
|
|
||||||
Domain expertise injected on-demand: policies, workflows, best practices, schemas.
|
|
||||||
|
|
||||||
**Design principle**: Make knowledge available, not mandatory. Load it when relevant, not upfront.
|
|
||||||
|
|
||||||
### 3. Context (What has happened?)
|
|
||||||
|
|
||||||
The conversation history - the thread connecting actions into coherent behavior.
|
|
||||||
|
|
||||||
**Design principle**: Context is precious. Isolate noisy subtasks. Truncate verbose outputs. Protect clarity.
|
|
||||||
|
|
||||||
## Agent Design Thinking
|
|
||||||
|
|
||||||
Before building, understand:
|
|
||||||
|
|
||||||
- **Purpose**: What should this agent accomplish?
|
|
||||||
- **Domain**: What world does it operate in? (customer service, research, operations, creative...)
|
|
||||||
- **Capabilities**: What 3-5 actions are essential?
|
|
||||||
- **Knowledge**: What expertise does it need access to?
|
|
||||||
- **Trust**: What decisions can you delegate to the model?
|
|
||||||
|
|
||||||
**CRITICAL**: Trust the model. Don't over-engineer. Don't pre-specify workflows. Give it capabilities and let it reason.
|
|
||||||
|
|
||||||
## Progressive Complexity
|
|
||||||
|
|
||||||
Start simple. Add complexity only when real usage reveals the need:
|
|
||||||
|
|
||||||
| Level | What to add | When to add it |
|
|
||||||
|-------|-------------|----------------|
|
|
||||||
| Basic | 3-5 capabilities | Always start here |
|
|
||||||
| Planning | Progress tracking | Multi-step tasks lose coherence |
|
|
||||||
| Subagents | Isolated child agents | Exploration pollutes context |
|
|
||||||
| Skills | On-demand knowledge | Domain expertise needed |
|
|
||||||
|
|
||||||
**Most agents never need to go beyond Level 2.**
|
|
||||||
|
|
||||||
## Domain Examples
|
|
||||||
|
|
||||||
**Business**: CRM queries, email, calendar, approvals
|
|
||||||
**Research**: Database search, document analysis, citations
|
|
||||||
**Operations**: Monitoring, tickets, notifications, escalation
|
|
||||||
**Creative**: Asset generation, editing, collaboration, review
|
|
||||||
|
|
||||||
The pattern is universal. Only the capabilities change.
|
|
||||||
|
|
||||||
## Key Principles
|
|
||||||
|
|
||||||
1. **The model IS the agent** - Code just runs the loop
|
|
||||||
2. **Capabilities enable** - What it CAN do
|
|
||||||
3. **Knowledge informs** - What it KNOWS how to do
|
|
||||||
4. **Constraints focus** - Limits create clarity
|
|
||||||
5. **Trust liberates** - Let the model reason
|
|
||||||
6. **Iteration reveals** - Start minimal, evolve from usage
|
|
||||||
|
|
||||||
## Anti-Patterns
|
|
||||||
|
|
||||||
| Pattern | Problem | Solution |
|
|
||||||
|---------|---------|----------|
|
|
||||||
| Over-engineering | Complexity before need | Start simple |
|
|
||||||
| Too many capabilities | Model confusion | 3-5 to start |
|
|
||||||
| Rigid workflows | Can't adapt | Let model decide |
|
|
||||||
| Front-loaded knowledge | Context bloat | Load on-demand |
|
|
||||||
| Micromanagement | Undercuts intelligence | Trust the model |
|
|
||||||
|
|
||||||
## Resources
|
|
||||||
|
|
||||||
**Philosophy & Theory**:
|
|
||||||
- `references/agent-philosophy.md` - Deep dive into why agents work
|
|
||||||
|
|
||||||
**Implementation**:
|
|
||||||
- `references/MinimalAgent.java` - Complete working agent (基于 Spring AI ChatClient)
|
|
||||||
- `references/ToolTemplates.java` - Capability definitions (使用 `@Tool` 注解)
|
|
||||||
- `references/SubagentPattern.java` - Context isolation
|
|
||||||
|
|
||||||
**Scaffolding**:
|
|
||||||
- `scripts/init-agent.sh` - Generate new Spring Boot agent project via `mvn archetype:generate`
|
|
||||||
|
|
||||||
## The Agent Mindset
|
|
||||||
|
|
||||||
**From**: "How do I make the system do X?"
|
|
||||||
**To**: "How do I enable the model to do X?"
|
|
||||||
|
|
||||||
**From**: "What's the workflow for this task?"
|
|
||||||
**To**: "What capabilities would help accomplish this?"
|
|
||||||
|
|
||||||
The best agent code is almost boring. Simple loops. Clear capabilities. Clean context. The magic isn't in the code.
|
|
||||||
|
|
||||||
**Give the model capabilities and knowledge. Trust it to figure out the rest.**
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
# The Philosophy of Agent Harness Engineering
|
|
||||||
|
|
||||||
> **The model already knows how to be an agent. Your job is to build it a world worth acting in.**
|
|
||||||
|
|
||||||
## The Fundamental Truth
|
|
||||||
|
|
||||||
Strip away every framework, every library, every architectural pattern. What remains?
|
|
||||||
|
|
||||||
A loop. A model. An invitation to act.
|
|
||||||
|
|
||||||
The agent is not the code. The agent is the model itself -- a vast neural network trained on humanity's collective problem-solving, reasoning, and tool use. The code merely provides the opportunity for the model to express its agency.
|
|
||||||
|
|
||||||
The code is the harness. The model is the agent. These are not interchangeable. Confuse them, and you will build the wrong thing.
|
|
||||||
|
|
||||||
## What an Agent IS
|
|
||||||
|
|
||||||
An agent is a neural network -- a Transformer, an RNN, a learned function -- that has been trained, through billions of gradient updates on action-sequence data, to perceive an environment, reason about goals, and take actions to achieve them.
|
|
||||||
|
|
||||||
A human is an agent: a biological neural network shaped by evolution. DeepMind's DQN is an agent: a convolutional network that learned to play Atari from raw pixels. OpenAI Five is an agent: five networks that learned Dota 2 teamwork through self-play. Claude is an agent: a language model that learned to reason and act from the breadth of human knowledge.
|
|
||||||
|
|
||||||
In every case, the agent is the trained model. Not the game engine. Not the Dota 2 client. Not the terminal. The model.
|
|
||||||
|
|
||||||
## What an Agent Is NOT
|
|
||||||
|
|
||||||
Prompt plumbing is not agency. Wiring together LLM API calls with if-else branches, node graphs, and hardcoded routing logic does not produce an agent. It produces a brittle pipeline -- a Rube Goldberg machine with an LLM wedged in as a text-completion node.
|
|
||||||
|
|
||||||
You cannot engineer your way to agency. Agency is learned, not programmed. No amount of glue code will emergently produce autonomous behavior. Those systems are the modern resurrection of GOFAI -- symbolic rule systems the field abandoned decades ago, now spray-painted with an LLM veneer.
|
|
||||||
|
|
||||||
## The Harness: What We Actually Build
|
|
||||||
|
|
||||||
If the model is the agent, then what is the code? It is the **harness** -- the environment that gives the agent the ability to perceive and act in a specific domain.
|
|
||||||
|
|
||||||
```
|
|
||||||
Harness = Tools + Knowledge + Observation + Action Interfaces + Permissions
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tools: The Agent's Hands
|
|
||||||
|
|
||||||
Tools answer: **What can the agent DO?**
|
|
||||||
|
|
||||||
Each tool is an atomic action the agent can take in its environment. File read/write, shell execution, API calls, browser control, database queries. The model needs to understand what each tool does, but not how to sequence them -- it will figure that out.
|
|
||||||
|
|
||||||
**Design principle**: Atomic, composable, well-described. Start with 3-5. Add more only when the model consistently fails to accomplish tasks because a tool is missing.
|
|
||||||
|
|
||||||
### Knowledge: The Agent's Expertise
|
|
||||||
|
|
||||||
Knowledge answers: **What does the agent KNOW?**
|
|
||||||
|
|
||||||
Domain expertise that turns a general agent into a domain specialist. Product documentation, architectural decisions, regulatory requirements, style guides. Inject on-demand (via tool_result), not upfront (via system prompt). Progressive disclosure preserves context for what matters.
|
|
||||||
|
|
||||||
**Design principle**: Available but not mandatory. The agent should know what knowledge exists and pull what it needs.
|
|
||||||
|
|
||||||
### Context: The Agent's Memory
|
|
||||||
|
|
||||||
Context is the thread connecting individual actions into coherent behavior. What has been said, tried, learned, and decided.
|
|
||||||
|
|
||||||
**Design principle**: Context is precious. Protect it. Isolate subtasks that generate noise (s04). Compress when history grows long (s06). Persist goals beyond single conversations (s07).
|
|
||||||
|
|
||||||
### Permissions: The Agent's Boundaries
|
|
||||||
|
|
||||||
Permissions answer: **What is the agent ALLOWED to do?**
|
|
||||||
|
|
||||||
Sandbox file access. Require approval for destructive operations. Enforce trust boundaries between the agent and external systems. This is where safety engineering meets harness engineering.
|
|
||||||
|
|
||||||
**Design principle**: Constraints focus behavior, not limit it. "One task in_progress at a time" forces sequential focus. "Read-only subagent" prevents accidental modifications.
|
|
||||||
|
|
||||||
### Task-Process Data: The Agent's Training Signal
|
|
||||||
|
|
||||||
Every action sequence the agent executes in your harness is training signal. The perception-reasoning-action traces from real deployments are the raw material for fine-tuning the next generation of agent models. Your harness doesn't just serve the agent -- it can help evolve the agent.
|
|
||||||
|
|
||||||
## The Universal Loop
|
|
||||||
|
|
||||||
Every effective agent -- regardless of domain -- follows the same pattern:
|
|
||||||
|
|
||||||
```
|
|
||||||
LOOP:
|
|
||||||
Model sees: conversation history + available tools
|
|
||||||
Model decides: act or respond
|
|
||||||
If act: tool executed, result added to context, loop continues
|
|
||||||
If respond: answer returned, loop ends
|
|
||||||
```
|
|
||||||
|
|
||||||
This is not a simplification. This is the actual architecture. Everything else is harness engineering -- mechanisms layered on top of this loop to make the agent more effective. The loop belongs to the agent. The mechanisms belong to the harness.
|
|
||||||
|
|
||||||
## Principles of Harness Engineering
|
|
||||||
|
|
||||||
### Trust the Model
|
|
||||||
|
|
||||||
The most important principle: **trust the model**.
|
|
||||||
|
|
||||||
Don't anticipate every edge case. Don't build elaborate decision trees. Don't pre-specify the workflow.
|
|
||||||
|
|
||||||
The model is better at reasoning than any rule system you could write. Your conditional logic will fail on edge cases. The model will reason through them.
|
|
||||||
|
|
||||||
**Give the model tools and knowledge. Let it figure out how to use them.**
|
|
||||||
|
|
||||||
### Constraints Enable
|
|
||||||
|
|
||||||
This seems paradoxical, but constraints don't limit agents -- they focus them.
|
|
||||||
|
|
||||||
A todo list with "only one task in progress" forces sequential focus. A subagent with read-only access prevents accidental modifications. A context compression threshold keeps history from overwhelming.
|
|
||||||
|
|
||||||
The best constraints prevent the model from getting lost, not micromanage its approach.
|
|
||||||
|
|
||||||
### Progressive Complexity
|
|
||||||
|
|
||||||
Never build everything upfront.
|
|
||||||
|
|
||||||
```
|
|
||||||
Level 0: Model + one tool (bash) -- s01
|
|
||||||
Level 1: Model + tool dispatch map -- s02
|
|
||||||
Level 2: Model + planning -- s03
|
|
||||||
Level 3: Model + subagents + skills -- s04, s05
|
|
||||||
Level 4: Model + context management + persistence -- s06, s07, s08
|
|
||||||
Level 5: Model + teams + autonomy + isolation -- s09-s12
|
|
||||||
```
|
|
||||||
|
|
||||||
Start at the lowest level that might work. Move up only when real usage reveals the need.
|
|
||||||
|
|
||||||
## The Mind Shift
|
|
||||||
|
|
||||||
Building harnesses requires a fundamental shift in thinking:
|
|
||||||
|
|
||||||
**From**: "How do I make the system do X?"
|
|
||||||
**To**: "How do I enable the model to do X?"
|
|
||||||
|
|
||||||
**From**: "What should happen when the user says Y?"
|
|
||||||
**To**: "What tools would help address Y?"
|
|
||||||
|
|
||||||
**From**: "What's the workflow for this task?"
|
|
||||||
**To**: "What does the model need to figure out the workflow?"
|
|
||||||
|
|
||||||
**From**: "I'm building an agent."
|
|
||||||
**To**: "I'm building a harness for the agent."
|
|
||||||
|
|
||||||
The best harness code is almost boring. Simple loops. Clear tool definitions. Clean context management. The magic isn't in the code -- it's in the model.
|
|
||||||
|
|
||||||
## The Vehicle Metaphor
|
|
||||||
|
|
||||||
The model is the driver. The harness is the vehicle.
|
|
||||||
|
|
||||||
A coding agent's vehicle is its IDE, terminal, and filesystem. A farm agent's vehicle is its sensor array, irrigation controls, and weather data. A hotel agent's vehicle is its booking system, guest channels, and facility APIs.
|
|
||||||
|
|
||||||
The driver generalizes. The vehicle specializes. Your job as a harness engineer is to build the best vehicle for your domain -- one that gives the driver maximum visibility, precise controls, and clear boundaries.
|
|
||||||
|
|
||||||
Build the cockpit. Build the dashboard. Build the controls. The pilot is already trained.
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
The model is the agent. The code is the harness. Know which one you're building.
|
|
||||||
|
|
||||||
You are not writing intelligence. You are building the world intelligence inhabits. The quality of that world -- how clearly the agent can perceive, how precisely it can act, how rich its knowledge -- directly determines how effectively the intelligence can express itself.
|
|
||||||
|
|
||||||
Build great harnesses. The agent will do the rest.
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Minimal Agent Template - Copy and customize this.
|
|
||||||
|
|
||||||
This is the simplest possible working agent (~80 lines).
|
|
||||||
It has everything you need: 3 tools + loop.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
1. Set ANTHROPIC_API_KEY environment variable
|
|
||||||
2. python minimal-agent.py
|
|
||||||
3. Type commands, 'q' to quit
|
|
||||||
"""
|
|
||||||
|
|
||||||
from anthropic import Anthropic
|
|
||||||
from pathlib import Path
|
|
||||||
import subprocess
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
client = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
|
|
||||||
MODEL = os.getenv("MODEL_NAME", "claude-sonnet-4-20250514")
|
|
||||||
WORKDIR = Path.cwd()
|
|
||||||
|
|
||||||
# System prompt - keep it simple
|
|
||||||
SYSTEM = f"""You are a coding agent at {WORKDIR}.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- Use tools to complete tasks
|
|
||||||
- Prefer action over explanation
|
|
||||||
- Summarize what you did when done"""
|
|
||||||
|
|
||||||
# Minimal tool set - add more as needed
|
|
||||||
TOOLS = [
|
|
||||||
{
|
|
||||||
"name": "bash",
|
|
||||||
"description": "Run 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"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def execute_tool(name: str, args: dict) -> str:
|
|
||||||
"""Execute a tool and return result."""
|
|
||||||
if name == "bash":
|
|
||||||
try:
|
|
||||||
r = subprocess.run(
|
|
||||||
args["command"], shell=True, cwd=WORKDIR,
|
|
||||||
capture_output=True, text=True, timeout=60
|
|
||||||
)
|
|
||||||
return (r.stdout + r.stderr).strip() or "(empty)"
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
return "Error: Timeout"
|
|
||||||
|
|
||||||
if name == "read_file":
|
|
||||||
try:
|
|
||||||
return (WORKDIR / args["path"]).read_text()[:50000]
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|
||||||
if name == "write_file":
|
|
||||||
try:
|
|
||||||
p = WORKDIR / args["path"]
|
|
||||||
p.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
p.write_text(args["content"])
|
|
||||||
return f"Wrote {len(args['content'])} bytes to {args['path']}"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|
||||||
return f"Unknown tool: {name}"
|
|
||||||
|
|
||||||
|
|
||||||
def agent(prompt: str, history: list = None) -> str:
|
|
||||||
"""Run the agent loop."""
|
|
||||||
if history is None:
|
|
||||||
history = []
|
|
||||||
|
|
||||||
history.append({"role": "user", "content": prompt})
|
|
||||||
|
|
||||||
while True:
|
|
||||||
response = client.messages.create(
|
|
||||||
model=MODEL,
|
|
||||||
system=SYSTEM,
|
|
||||||
messages=history,
|
|
||||||
tools=TOOLS,
|
|
||||||
max_tokens=8000,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build assistant message
|
|
||||||
history.append({"role": "assistant", "content": response.content})
|
|
||||||
|
|
||||||
# If no tool calls, return text
|
|
||||||
if response.stop_reason != "tool_use":
|
|
||||||
return "".join(b.text for b in response.content if hasattr(b, "text"))
|
|
||||||
|
|
||||||
# Execute tools
|
|
||||||
results = []
|
|
||||||
for block in response.content:
|
|
||||||
if block.type == "tool_use":
|
|
||||||
print(f"> {block.name}: {block.input}")
|
|
||||||
output = execute_tool(block.name, block.input)
|
|
||||||
print(f" {output[:100]}...")
|
|
||||||
results.append({
|
|
||||||
"type": "tool_result",
|
|
||||||
"tool_use_id": block.id,
|
|
||||||
"content": output
|
|
||||||
})
|
|
||||||
|
|
||||||
history.append({"role": "user", "content": results})
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
print(f"Minimal Agent - {WORKDIR}")
|
|
||||||
print("Type 'q' to quit.\n")
|
|
||||||
|
|
||||||
history = []
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
query = input(">> ").strip()
|
|
||||||
except (EOFError, KeyboardInterrupt):
|
|
||||||
break
|
|
||||||
if query in ("q", "quit", "exit", ""):
|
|
||||||
break
|
|
||||||
print(agent(query, history))
|
|
||||||
print()
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
"""
|
|
||||||
Subagent Pattern - How to implement Task tool for context isolation.
|
|
||||||
|
|
||||||
The key insight: spawn child agents with ISOLATED context to prevent
|
|
||||||
"context pollution" where exploration details fill up the main conversation.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import time
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# Assuming client, MODEL, execute_tool are defined elsewhere
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# AGENT TYPE REGISTRY
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
AGENT_TYPES = {
|
|
||||||
# Explore: Read-only, for searching and analyzing
|
|
||||||
"explore": {
|
|
||||||
"description": "Read-only agent for exploring code, finding files, searching",
|
|
||||||
"tools": ["bash", "read_file"], # No write access!
|
|
||||||
"prompt": "You are an exploration agent. Search and analyze, but NEVER modify files. Return a concise summary of what you found.",
|
|
||||||
},
|
|
||||||
|
|
||||||
# Code: Full-powered, for implementation
|
|
||||||
"code": {
|
|
||||||
"description": "Full agent for implementing features and fixing bugs",
|
|
||||||
"tools": "*", # All tools
|
|
||||||
"prompt": "You are a coding agent. Implement the requested changes efficiently. Return a summary of what you changed.",
|
|
||||||
},
|
|
||||||
|
|
||||||
# Plan: Read-only, for design work
|
|
||||||
"plan": {
|
|
||||||
"description": "Planning agent for designing implementation strategies",
|
|
||||||
"tools": ["bash", "read_file"], # Read-only
|
|
||||||
"prompt": "You are a planning agent. Analyze the codebase and output a numbered implementation plan. Do NOT make any changes.",
|
|
||||||
},
|
|
||||||
|
|
||||||
# Add your own types here...
|
|
||||||
# "test": {
|
|
||||||
# "description": "Testing agent for running and analyzing tests",
|
|
||||||
# "tools": ["bash", "read_file"],
|
|
||||||
# "prompt": "Run tests and report results. Don't modify code.",
|
|
||||||
# },
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_agent_descriptions() -> str:
|
|
||||||
"""Generate descriptions for Task tool schema."""
|
|
||||||
return "\n".join(
|
|
||||||
f"- {name}: {cfg['description']}"
|
|
||||||
for name, cfg in AGENT_TYPES.items()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_tools_for_agent(agent_type: str, base_tools: list) -> list:
|
|
||||||
"""
|
|
||||||
Filter tools based on agent type.
|
|
||||||
|
|
||||||
'*' means all base tools.
|
|
||||||
Otherwise, whitelist specific tool names.
|
|
||||||
|
|
||||||
Note: Subagents don't get Task tool to prevent infinite recursion.
|
|
||||||
"""
|
|
||||||
allowed = AGENT_TYPES.get(agent_type, {}).get("tools", "*")
|
|
||||||
|
|
||||||
if allowed == "*":
|
|
||||||
return base_tools # All base tools, but NOT Task
|
|
||||||
|
|
||||||
return [t for t in base_tools if t["name"] in allowed]
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# TASK TOOL DEFINITION
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
TASK_TOOL = {
|
|
||||||
"name": "Task",
|
|
||||||
"description": f"""Spawn a subagent for a focused subtask.
|
|
||||||
|
|
||||||
Subagents run in ISOLATED context - they don't see parent's history.
|
|
||||||
Use this to keep the main conversation clean.
|
|
||||||
|
|
||||||
Agent types:
|
|
||||||
{get_agent_descriptions()}
|
|
||||||
|
|
||||||
Example uses:
|
|
||||||
- Task(explore): "Find all files using the auth module"
|
|
||||||
- Task(plan): "Design a migration strategy for the database"
|
|
||||||
- Task(code): "Implement the user registration form"
|
|
||||||
""",
|
|
||||||
"input_schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"description": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Short task name (3-5 words) for progress display"
|
|
||||||
},
|
|
||||||
"prompt": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Detailed instructions for the subagent"
|
|
||||||
},
|
|
||||||
"agent_type": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": list(AGENT_TYPES.keys()),
|
|
||||||
"description": "Type of agent to spawn"
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["description", "prompt", "agent_type"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# SUBAGENT EXECUTION
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def run_task(description: str, prompt: str, agent_type: str,
|
|
||||||
client, model: str, workdir, base_tools: list, execute_tool) -> str:
|
|
||||||
"""
|
|
||||||
Execute a subagent task with isolated context.
|
|
||||||
|
|
||||||
Key concepts:
|
|
||||||
1. ISOLATED HISTORY - subagent starts fresh, no parent context
|
|
||||||
2. FILTERED TOOLS - based on agent type permissions
|
|
||||||
3. AGENT-SPECIFIC PROMPT - specialized behavior
|
|
||||||
4. RETURNS SUMMARY ONLY - parent sees just the final result
|
|
||||||
|
|
||||||
Args:
|
|
||||||
description: Short name for progress display
|
|
||||||
prompt: Detailed instructions for subagent
|
|
||||||
agent_type: Key from AGENT_TYPES
|
|
||||||
client: Anthropic client
|
|
||||||
model: Model to use
|
|
||||||
workdir: Working directory
|
|
||||||
base_tools: List of tool definitions
|
|
||||||
execute_tool: Function to execute tools
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Final text output from subagent
|
|
||||||
"""
|
|
||||||
if agent_type not in AGENT_TYPES:
|
|
||||||
return f"Error: Unknown agent type '{agent_type}'"
|
|
||||||
|
|
||||||
config = AGENT_TYPES[agent_type]
|
|
||||||
|
|
||||||
# Agent-specific system prompt
|
|
||||||
sub_system = f"""You are a {agent_type} subagent at {workdir}.
|
|
||||||
|
|
||||||
{config["prompt"]}
|
|
||||||
|
|
||||||
Complete the task and return a clear, concise summary."""
|
|
||||||
|
|
||||||
# Filtered tools for this agent type
|
|
||||||
sub_tools = get_tools_for_agent(agent_type, base_tools)
|
|
||||||
|
|
||||||
# KEY: ISOLATED message history!
|
|
||||||
# The subagent starts fresh, doesn't see parent's conversation
|
|
||||||
sub_messages = [{"role": "user", "content": prompt}]
|
|
||||||
|
|
||||||
# Progress display
|
|
||||||
print(f" [{agent_type}] {description}")
|
|
||||||
start = time.time()
|
|
||||||
tool_count = 0
|
|
||||||
|
|
||||||
# Run the same agent loop (but silently)
|
|
||||||
while True:
|
|
||||||
response = client.messages.create(
|
|
||||||
model=model,
|
|
||||||
system=sub_system,
|
|
||||||
messages=sub_messages,
|
|
||||||
tools=sub_tools,
|
|
||||||
max_tokens=8000,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if done
|
|
||||||
if response.stop_reason != "tool_use":
|
|
||||||
break
|
|
||||||
|
|
||||||
# Execute tools
|
|
||||||
tool_calls = [b for b in response.content if b.type == "tool_use"]
|
|
||||||
results = []
|
|
||||||
|
|
||||||
for tc in tool_calls:
|
|
||||||
tool_count += 1
|
|
||||||
output = execute_tool(tc.name, tc.input)
|
|
||||||
results.append({
|
|
||||||
"type": "tool_result",
|
|
||||||
"tool_use_id": tc.id,
|
|
||||||
"content": output
|
|
||||||
})
|
|
||||||
|
|
||||||
# Update progress (in-place on same line)
|
|
||||||
elapsed = time.time() - start
|
|
||||||
sys.stdout.write(
|
|
||||||
f"\r [{agent_type}] {description} ... {tool_count} tools, {elapsed:.1f}s"
|
|
||||||
)
|
|
||||||
sys.stdout.flush()
|
|
||||||
|
|
||||||
sub_messages.append({"role": "assistant", "content": response.content})
|
|
||||||
sub_messages.append({"role": "user", "content": results})
|
|
||||||
|
|
||||||
# Final progress update
|
|
||||||
elapsed = time.time() - start
|
|
||||||
sys.stdout.write(
|
|
||||||
f"\r [{agent_type}] {description} - done ({tool_count} tools, {elapsed:.1f}s)\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Extract and return ONLY the final text
|
|
||||||
# This is what the parent agent sees - a clean summary
|
|
||||||
for block in response.content:
|
|
||||||
if hasattr(block, "text"):
|
|
||||||
return block.text
|
|
||||||
|
|
||||||
return "(subagent returned no text)"
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# USAGE EXAMPLE
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
"""
|
|
||||||
# In your main agent's execute_tool function:
|
|
||||||
|
|
||||||
def execute_tool(name: str, args: dict) -> str:
|
|
||||||
if name == "Task":
|
|
||||||
return run_task(
|
|
||||||
description=args["description"],
|
|
||||||
prompt=args["prompt"],
|
|
||||||
agent_type=args["agent_type"],
|
|
||||||
client=client,
|
|
||||||
model=MODEL,
|
|
||||||
workdir=WORKDIR,
|
|
||||||
base_tools=BASE_TOOLS,
|
|
||||||
execute_tool=execute_tool # Pass self for recursion
|
|
||||||
)
|
|
||||||
# ... other tools ...
|
|
||||||
|
|
||||||
|
|
||||||
# In your TOOLS list:
|
|
||||||
TOOLS = BASE_TOOLS + [TASK_TOOL]
|
|
||||||
"""
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
"""
|
|
||||||
Tool Templates - Copy and customize these for your agent.
|
|
||||||
|
|
||||||
Each tool needs:
|
|
||||||
1. Definition (JSON schema for the model)
|
|
||||||
2. Implementation (Python function)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
WORKDIR = Path.cwd()
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# TOOL DEFINITIONS (for TOOLS list)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
BASH_TOOL = {
|
|
||||||
"name": "bash",
|
|
||||||
"description": "Run a shell command. Use for: ls, find, grep, git, npm, python, etc.",
|
|
||||||
"input_schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"command": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The shell command to execute"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["command"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
READ_FILE_TOOL = {
|
|
||||||
"name": "read_file",
|
|
||||||
"description": "Read file contents. Returns UTF-8 text.",
|
|
||||||
"input_schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"path": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Relative path to the file"
|
|
||||||
},
|
|
||||||
"limit": {
|
|
||||||
"type": "integer",
|
|
||||||
"description": "Max lines to read (default: all)"
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["path"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
WRITE_FILE_TOOL = {
|
|
||||||
"name": "write_file",
|
|
||||||
"description": "Write content to a file. Creates parent directories if needed.",
|
|
||||||
"input_schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"path": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Relative path for the file"
|
|
||||||
},
|
|
||||||
"content": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Content to write"
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["path", "content"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
EDIT_FILE_TOOL = {
|
|
||||||
"name": "edit_file",
|
|
||||||
"description": "Replace exact text in a file. Use for surgical edits.",
|
|
||||||
"input_schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"path": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Relative path to the file"
|
|
||||||
},
|
|
||||||
"old_text": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Exact text to find (must match precisely)"
|
|
||||||
},
|
|
||||||
"new_text": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Replacement text"
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["path", "old_text", "new_text"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
TODO_WRITE_TOOL = {
|
|
||||||
"name": "TodoWrite",
|
|
||||||
"description": "Update the task list. Use to plan and track progress.",
|
|
||||||
"input_schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"items": {
|
|
||||||
"type": "array",
|
|
||||||
"description": "Complete list of tasks",
|
|
||||||
"items": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"content": {"type": "string", "description": "Task description"},
|
|
||||||
"status": {"type": "string", "enum": ["pending", "in_progress", "completed"]},
|
|
||||||
"activeForm": {"type": "string", "description": "Present tense, e.g. 'Reading files'"},
|
|
||||||
},
|
|
||||||
"required": ["content", "status", "activeForm"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["items"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
TASK_TOOL_TEMPLATE = """
|
|
||||||
# Generate dynamically with agent types
|
|
||||||
TASK_TOOL = {
|
|
||||||
"name": "Task",
|
|
||||||
"description": f"Spawn a subagent for a focused subtask.\\n\\nAgent types:\\n{get_agent_descriptions()}",
|
|
||||||
"input_schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"description": {"type": "string", "description": "Short task name (3-5 words)"},
|
|
||||||
"prompt": {"type": "string", "description": "Detailed instructions"},
|
|
||||||
"agent_type": {"type": "string", "enum": list(AGENT_TYPES.keys())},
|
|
||||||
},
|
|
||||||
"required": ["description", "prompt", "agent_type"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# TOOL IMPLEMENTATIONS
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def safe_path(p: str) -> Path:
|
|
||||||
"""
|
|
||||||
Security: Ensure path stays within workspace.
|
|
||||||
Prevents ../../../etc/passwd attacks.
|
|
||||||
"""
|
|
||||||
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:
|
|
||||||
"""
|
|
||||||
Execute shell command with safety checks.
|
|
||||||
|
|
||||||
Safety features:
|
|
||||||
- Blocks obviously dangerous commands
|
|
||||||
- 60 second timeout
|
|
||||||
- Output truncated to 50KB
|
|
||||||
"""
|
|
||||||
dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
|
|
||||||
if any(d in command for d in dangerous):
|
|
||||||
return "Error: Dangerous command blocked"
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
command,
|
|
||||||
shell=True,
|
|
||||||
cwd=WORKDIR,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=60
|
|
||||||
)
|
|
||||||
output = (result.stdout + result.stderr).strip()
|
|
||||||
return output[:50000] if output else "(no output)"
|
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
return "Error: Command timed out (60s)"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
def run_read_file(path: str, limit: int = None) -> str:
|
|
||||||
"""
|
|
||||||
Read file contents with optional line limit.
|
|
||||||
|
|
||||||
Features:
|
|
||||||
- Safe path resolution
|
|
||||||
- Optional line limit for large files
|
|
||||||
- Output truncated to 50KB
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
text = safe_path(path).read_text()
|
|
||||||
lines = text.splitlines()
|
|
||||||
|
|
||||||
if limit and limit < len(lines):
|
|
||||||
lines = lines[:limit]
|
|
||||||
lines.append(f"... ({len(text.splitlines()) - limit} more lines)")
|
|
||||||
|
|
||||||
return "\n".join(lines)[:50000]
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
def run_write_file(path: str, content: str) -> str:
|
|
||||||
"""
|
|
||||||
Write content to file, creating parent directories if needed.
|
|
||||||
|
|
||||||
Features:
|
|
||||||
- Safe path resolution
|
|
||||||
- Auto-creates parent directories
|
|
||||||
- Returns byte count for confirmation
|
|
||||||
"""
|
|
||||||
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_file(path: str, old_text: str, new_text: str) -> str:
|
|
||||||
"""
|
|
||||||
Replace exact text in a file (surgical edit).
|
|
||||||
|
|
||||||
Features:
|
|
||||||
- Exact string matching (not regex)
|
|
||||||
- Only replaces first occurrence (safety)
|
|
||||||
- Clear error if text not found
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
fp = safe_path(path)
|
|
||||||
content = fp.read_text()
|
|
||||||
|
|
||||||
if old_text not in content:
|
|
||||||
return f"Error: Text not found in {path}"
|
|
||||||
|
|
||||||
new_content = content.replace(old_text, new_text, 1)
|
|
||||||
fp.write_text(new_content)
|
|
||||||
return f"Edited {path}"
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# DISPATCHER PATTERN
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def execute_tool(name: str, args: dict) -> str:
|
|
||||||
"""
|
|
||||||
Dispatch tool call to implementation.
|
|
||||||
|
|
||||||
This pattern makes it easy to add new tools:
|
|
||||||
1. Add definition to TOOLS list
|
|
||||||
2. Add implementation function
|
|
||||||
3. Add case to this dispatcher
|
|
||||||
"""
|
|
||||||
if name == "bash":
|
|
||||||
return run_bash(args["command"])
|
|
||||||
if name == "read_file":
|
|
||||||
return run_read_file(args["path"], args.get("limit"))
|
|
||||||
if name == "write_file":
|
|
||||||
return run_write_file(args["path"], args["content"])
|
|
||||||
if name == "edit_file":
|
|
||||||
return run_edit_file(args["path"], args["old_text"], args["new_text"])
|
|
||||||
# Add more tools here...
|
|
||||||
return f"Unknown tool: {name}"
|
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Agent Scaffold Script - Create a new agent project with best practices.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python init_agent.py <agent-name> [--level 0-4] [--path <output-dir>]
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
python init_agent.py my-agent # Level 1 (4 tools)
|
|
||||||
python init_agent.py my-agent --level 0 # Minimal (bash only)
|
|
||||||
python init_agent.py my-agent --level 2 # With TodoWrite
|
|
||||||
python init_agent.py my-agent --path ./bots # Custom output directory
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Agent templates for each level
|
|
||||||
TEMPLATES = {
|
|
||||||
0: '''#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Level 0 Agent - Bash is All You Need (~50 lines)
|
|
||||||
|
|
||||||
Core insight: One tool (bash) can do everything.
|
|
||||||
Subagents via self-recursion: python {name}.py "subtask"
|
|
||||||
"""
|
|
||||||
|
|
||||||
from anthropic import Anthropic
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
import subprocess
|
|
||||||
import os
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
client = Anthropic(
|
|
||||||
api_key=os.getenv("ANTHROPIC_API_KEY"),
|
|
||||||
base_url=os.getenv("ANTHROPIC_BASE_URL")
|
|
||||||
)
|
|
||||||
MODEL = os.getenv("MODEL_NAME", "claude-sonnet-4-20250514")
|
|
||||||
|
|
||||||
SYSTEM = """You are a coding agent. Use bash for everything:
|
|
||||||
- Read: cat, grep, find, ls
|
|
||||||
- Write: echo 'content' > file
|
|
||||||
- Subagent: python {name}.py "subtask"
|
|
||||||
"""
|
|
||||||
|
|
||||||
TOOL = [{{
|
|
||||||
"name": "bash",
|
|
||||||
"description": "Execute shell command",
|
|
||||||
"input_schema": {{"type": "object", "properties": {{"command": {{"type": "string"}}}}, "required": ["command"]}}
|
|
||||||
}}]
|
|
||||||
|
|
||||||
def run(prompt, history=[]):
|
|
||||||
history.append({{"role": "user", "content": prompt}})
|
|
||||||
while True:
|
|
||||||
r = client.messages.create(model=MODEL, system=SYSTEM, messages=history, tools=TOOL, max_tokens=8000)
|
|
||||||
history.append({{"role": "assistant", "content": r.content}})
|
|
||||||
if r.stop_reason != "tool_use":
|
|
||||||
return "".join(b.text for b in r.content if hasattr(b, "text"))
|
|
||||||
results = []
|
|
||||||
for b in r.content:
|
|
||||||
if b.type == "tool_use":
|
|
||||||
print(f"> {{b.input['command']}}")
|
|
||||||
try:
|
|
||||||
out = subprocess.run(b.input["command"], shell=True, capture_output=True, text=True, timeout=60)
|
|
||||||
output = (out.stdout + out.stderr).strip() or "(empty)"
|
|
||||||
except Exception as e:
|
|
||||||
output = f"Error: {{e}}"
|
|
||||||
results.append({{"type": "tool_result", "tool_use_id": b.id, "content": output[:50000]}})
|
|
||||||
history.append({{"role": "user", "content": results}})
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
h = []
|
|
||||||
print("{name} - Level 0 Agent\\nType 'q' to quit.\\n")
|
|
||||||
while (q := input(">> ").strip()) not in ("q", "quit", ""):
|
|
||||||
print(run(q, h), "\\n")
|
|
||||||
''',
|
|
||||||
|
|
||||||
1: '''#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Level 1 Agent - Model as Agent (~200 lines)
|
|
||||||
|
|
||||||
Core insight: 4 tools cover 90% of coding tasks.
|
|
||||||
The model IS the agent. Code just runs the loop.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from anthropic import Anthropic
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
from pathlib import Path
|
|
||||||
import subprocess
|
|
||||||
import os
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
client = Anthropic(
|
|
||||||
api_key=os.getenv("ANTHROPIC_API_KEY"),
|
|
||||||
base_url=os.getenv("ANTHROPIC_BASE_URL")
|
|
||||||
)
|
|
||||||
MODEL = os.getenv("MODEL_NAME", "claude-sonnet-4-20250514")
|
|
||||||
WORKDIR = Path.cwd()
|
|
||||||
|
|
||||||
SYSTEM = f"""You are a coding agent at {{WORKDIR}}.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- Prefer tools over prose. Act, don't just explain.
|
|
||||||
- Never invent file paths. Use ls/find first if unsure.
|
|
||||||
- Make minimal changes. Don't over-engineer.
|
|
||||||
- After finishing, summarize what changed."""
|
|
||||||
|
|
||||||
TOOLS = [
|
|
||||||
{{"name": "bash", "description": "Run 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"]}}}},
|
|
||||||
]
|
|
||||||
|
|
||||||
def safe_path(p: str) -> Path:
|
|
||||||
"""Prevent path escape attacks."""
|
|
||||||
path = (WORKDIR / p).resolve()
|
|
||||||
if not path.is_relative_to(WORKDIR):
|
|
||||||
raise ValueError(f"Path escapes workspace: {{p}}")
|
|
||||||
return path
|
|
||||||
|
|
||||||
def execute(name: str, args: dict) -> str:
|
|
||||||
"""Execute a tool and return result."""
|
|
||||||
if name == "bash":
|
|
||||||
dangerous = ["rm -rf /", "sudo", "shutdown", "> /dev/"]
|
|
||||||
if any(d in args["command"] for d in dangerous):
|
|
||||||
return "Error: Dangerous command blocked"
|
|
||||||
try:
|
|
||||||
r = subprocess.run(args["command"], shell=True, cwd=WORKDIR, capture_output=True, text=True, timeout=60)
|
|
||||||
return (r.stdout + r.stderr).strip()[:50000] or "(empty)"
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
return "Error: Timeout (60s)"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {{e}}"
|
|
||||||
|
|
||||||
if name == "read_file":
|
|
||||||
try:
|
|
||||||
return safe_path(args["path"]).read_text()[:50000]
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {{e}}"
|
|
||||||
|
|
||||||
if name == "write_file":
|
|
||||||
try:
|
|
||||||
p = safe_path(args["path"])
|
|
||||||
p.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
p.write_text(args["content"])
|
|
||||||
return f"Wrote {{len(args['content'])}} bytes to {{args['path']}}"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {{e}}"
|
|
||||||
|
|
||||||
if name == "edit_file":
|
|
||||||
try:
|
|
||||||
p = safe_path(args["path"])
|
|
||||||
content = p.read_text()
|
|
||||||
if args["old_text"] not in content:
|
|
||||||
return f"Error: Text not found in {{args['path']}}"
|
|
||||||
p.write_text(content.replace(args["old_text"], args["new_text"], 1))
|
|
||||||
return f"Edited {{args['path']}}"
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {{e}}"
|
|
||||||
|
|
||||||
return f"Unknown tool: {{name}}"
|
|
||||||
|
|
||||||
def agent(prompt: str, history: list = None) -> str:
|
|
||||||
"""Run the agent loop."""
|
|
||||||
if history is None:
|
|
||||||
history = []
|
|
||||||
history.append({{"role": "user", "content": prompt}})
|
|
||||||
|
|
||||||
while True:
|
|
||||||
response = client.messages.create(
|
|
||||||
model=MODEL, system=SYSTEM, messages=history, tools=TOOLS, max_tokens=8000
|
|
||||||
)
|
|
||||||
history.append({{"role": "assistant", "content": response.content}})
|
|
||||||
|
|
||||||
if response.stop_reason != "tool_use":
|
|
||||||
return "".join(b.text for b in response.content if hasattr(b, "text"))
|
|
||||||
|
|
||||||
results = []
|
|
||||||
for block in response.content:
|
|
||||||
if block.type == "tool_use":
|
|
||||||
print(f"> {{block.name}}: {{str(block.input)[:100]}}")
|
|
||||||
output = execute(block.name, block.input)
|
|
||||||
print(f" {{output[:100]}}...")
|
|
||||||
results.append({{"type": "tool_result", "tool_use_id": block.id, "content": output}})
|
|
||||||
history.append({{"role": "user", "content": results}})
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
print(f"{name} - Level 1 Agent at {{WORKDIR}}")
|
|
||||||
print("Type 'q' to quit.\\n")
|
|
||||||
h = []
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
query = input(">> ").strip()
|
|
||||||
except (EOFError, KeyboardInterrupt):
|
|
||||||
break
|
|
||||||
if query in ("q", "quit", "exit", ""):
|
|
||||||
break
|
|
||||||
print(agent(query, h), "\\n")
|
|
||||||
''',
|
|
||||||
}
|
|
||||||
|
|
||||||
ENV_TEMPLATE = '''# API Configuration
|
|
||||||
ANTHROPIC_API_KEY=sk-xxx
|
|
||||||
ANTHROPIC_BASE_URL=https://api.anthropic.com
|
|
||||||
MODEL_NAME=claude-sonnet-4-20250514
|
|
||||||
'''
|
|
||||||
|
|
||||||
|
|
||||||
def create_agent(name: str, level: int, output_dir: Path):
|
|
||||||
"""Create a new agent project."""
|
|
||||||
# Validate level
|
|
||||||
if level not in TEMPLATES and level not in (2, 3, 4):
|
|
||||||
print(f"Error: Level {level} not yet implemented in scaffold.")
|
|
||||||
print("Available levels: 0 (minimal), 1 (4 tools)")
|
|
||||||
print("For levels 2-4, copy from mini-claude-code repository.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Create output directory
|
|
||||||
agent_dir = output_dir / name
|
|
||||||
agent_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Write agent file
|
|
||||||
agent_file = agent_dir / f"{name}.py"
|
|
||||||
template = TEMPLATES.get(level, TEMPLATES[1])
|
|
||||||
agent_file.write_text(template.format(name=name))
|
|
||||||
print(f"Created: {agent_file}")
|
|
||||||
|
|
||||||
# Write .env.example
|
|
||||||
env_file = agent_dir / ".env.example"
|
|
||||||
env_file.write_text(ENV_TEMPLATE)
|
|
||||||
print(f"Created: {env_file}")
|
|
||||||
|
|
||||||
# Write .gitignore
|
|
||||||
gitignore = agent_dir / ".gitignore"
|
|
||||||
gitignore.write_text(".env\n__pycache__/\n*.pyc\n")
|
|
||||||
print(f"Created: {gitignore}")
|
|
||||||
|
|
||||||
print(f"\nAgent '{name}' created at {agent_dir}")
|
|
||||||
print(f"\nNext steps:")
|
|
||||||
print(f" 1. cd {agent_dir}")
|
|
||||||
print(f" 2. cp .env.example .env")
|
|
||||||
print(f" 3. Edit .env with your API key")
|
|
||||||
print(f" 4. pip install anthropic python-dotenv")
|
|
||||||
print(f" 5. python {name}.py")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Scaffold a new AI coding agent project",
|
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
||||||
epilog="""
|
|
||||||
Levels:
|
|
||||||
0 Minimal (~50 lines) - Single bash tool, self-recursion for subagents
|
|
||||||
1 Basic (~200 lines) - 4 core tools: bash, read, write, edit
|
|
||||||
2 Todo (~300 lines) - + TodoWrite for structured planning
|
|
||||||
3 Subagent (~450) - + Task tool for context isolation
|
|
||||||
4 Skills (~550) - + Skill tool for domain expertise
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
parser.add_argument("name", help="Name of the agent to create")
|
|
||||||
parser.add_argument("--level", type=int, default=1, choices=[0, 1, 2, 3, 4],
|
|
||||||
help="Complexity level (default: 1)")
|
|
||||||
parser.add_argument("--path", type=Path, default=Path.cwd(),
|
|
||||||
help="Output directory (default: current directory)")
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
create_agent(args.name, args.level, args.path)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
---
|
|
||||||
name: code-review
|
|
||||||
description: Perform thorough code reviews with security, performance, and maintainability analysis. Use when user asks to review code, check for bugs, or audit a codebase.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Code Review Skill
|
|
||||||
|
|
||||||
You now have expertise in conducting comprehensive code reviews. Follow this structured approach:
|
|
||||||
|
|
||||||
## Review Checklist
|
|
||||||
|
|
||||||
### 1. Security (Critical)
|
|
||||||
|
|
||||||
Check for:
|
|
||||||
- [ ] **Injection vulnerabilities**: SQL, command, XSS, template injection
|
|
||||||
- [ ] **Authentication issues**: Hardcoded credentials, weak auth
|
|
||||||
- [ ] **Authorization flaws**: Missing access controls, IDOR
|
|
||||||
- [ ] **Data exposure**: Sensitive data in logs, error messages
|
|
||||||
- [ ] **Cryptography**: Weak algorithms, improper key management
|
|
||||||
- [ ] **Dependencies**: Known vulnerabilities (check with `mvn dependency-check:check`, `npm audit`)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Quick security scans
|
|
||||||
mvn dependency-check:check # Java (OWASP Dependency-Check)
|
|
||||||
mvn versions:display-dependency-updates # Check outdated deps
|
|
||||||
npm audit # Node.js
|
|
||||||
grep -r "password\|secret\|api_key" --include="*.java" --include="*.properties" --include="*.yml"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Correctness
|
|
||||||
|
|
||||||
Check for:
|
|
||||||
- [ ] **Logic errors**: Off-by-one, null handling, edge cases
|
|
||||||
- [ ] **Race conditions**: Concurrent access without synchronization
|
|
||||||
- [ ] **Resource leaks**: Unclosed files, connections, memory
|
|
||||||
- [ ] **Error handling**: Swallowed exceptions, missing error paths
|
|
||||||
- [ ] **Type safety**: Implicit conversions, any types
|
|
||||||
|
|
||||||
### 3. Performance
|
|
||||||
|
|
||||||
Check for:
|
|
||||||
- [ ] **N+1 queries**: Database calls in loops
|
|
||||||
- [ ] **Memory issues**: Large allocations, retained references
|
|
||||||
- [ ] **Blocking operations**: Sync I/O in async code
|
|
||||||
- [ ] **Inefficient algorithms**: O(n^2) when O(n) possible
|
|
||||||
- [ ] **Missing caching**: Repeated expensive computations
|
|
||||||
|
|
||||||
### 4. Maintainability
|
|
||||||
|
|
||||||
Check for:
|
|
||||||
- [ ] **Naming**: Clear, consistent, descriptive
|
|
||||||
- [ ] **Complexity**: Functions > 50 lines, deep nesting > 3 levels
|
|
||||||
- [ ] **Duplication**: Copy-pasted code blocks
|
|
||||||
- [ ] **Dead code**: Unused imports, unreachable branches
|
|
||||||
- [ ] **Comments**: Outdated, redundant, or missing where needed
|
|
||||||
|
|
||||||
### 5. Testing
|
|
||||||
|
|
||||||
Check for:
|
|
||||||
- [ ] **Coverage**: Critical paths tested
|
|
||||||
- [ ] **Edge cases**: Null, empty, boundary values
|
|
||||||
- [ ] **Mocking**: External dependencies isolated
|
|
||||||
- [ ] **Assertions**: Meaningful, specific checks
|
|
||||||
|
|
||||||
## Review Output Format
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## Code Review: [file/component name]
|
|
||||||
|
|
||||||
### Summary
|
|
||||||
[1-2 sentence overview]
|
|
||||||
|
|
||||||
### Critical Issues
|
|
||||||
1. **[Issue]** (line X): [Description]
|
|
||||||
- Impact: [What could go wrong]
|
|
||||||
- Fix: [Suggested solution]
|
|
||||||
|
|
||||||
### Improvements
|
|
||||||
1. **[Suggestion]** (line X): [Description]
|
|
||||||
|
|
||||||
### Positive Notes
|
|
||||||
- [What was done well]
|
|
||||||
|
|
||||||
### Verdict
|
|
||||||
[ ] Ready to merge
|
|
||||||
[ ] Needs minor changes
|
|
||||||
[ ] Needs major revision
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Patterns to Flag
|
|
||||||
|
|
||||||
### Java
|
|
||||||
```java
|
|
||||||
// Bad: SQL injection
|
|
||||||
Statement stmt = conn.createStatement();
|
|
||||||
stmt.executeQuery("SELECT * FROM users WHERE id = " + userId);
|
|
||||||
// Good: 使用 PreparedStatement 参数化查询
|
|
||||||
PreparedStatement ps = conn.prepareStatement("SELECT * FROM users WHERE id = ?");
|
|
||||||
ps.setLong(1, userId);
|
|
||||||
ResultSet rs = ps.executeQuery();
|
|
||||||
|
|
||||||
// Bad: Command injection
|
|
||||||
Runtime.getRuntime().exec("ls " + userInput);
|
|
||||||
// Good: 使用 ProcessBuilder 分离命令和参数
|
|
||||||
new ProcessBuilder("ls", userInput).start();
|
|
||||||
|
|
||||||
// Bad: 未关闭资源
|
|
||||||
InputStream is = new FileInputStream("data.txt");
|
|
||||||
// Good: 使用 try-with-resources 自动关闭
|
|
||||||
try (InputStream is = new FileInputStream("data.txt")) {
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### JavaScript/TypeScript
|
|
||||||
```javascript
|
|
||||||
// Bad: Prototype pollution
|
|
||||||
Object.assign(target, userInput)
|
|
||||||
// Good:
|
|
||||||
Object.assign(target, sanitize(userInput))
|
|
||||||
|
|
||||||
// Bad: eval usage
|
|
||||||
eval(userCode)
|
|
||||||
// Good: Never use eval with user input
|
|
||||||
|
|
||||||
// Bad: Callback hell
|
|
||||||
getData(x => process(x, y => save(y, z => done(z))))
|
|
||||||
// Good:
|
|
||||||
const data = await getData();
|
|
||||||
const processed = await process(data);
|
|
||||||
await save(processed);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Review Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Show recent changes
|
|
||||||
git diff HEAD~5 --stat
|
|
||||||
git log --oneline -10
|
|
||||||
|
|
||||||
# Find potential issues
|
|
||||||
grep -rn "TODO\|FIXME\|HACK\|XXX" .
|
|
||||||
grep -rn "password\|secret\|token" . --include="*.java" --include="*.properties"
|
|
||||||
|
|
||||||
# Check complexity (Java)
|
|
||||||
mvn pmd:pmd # 静态代码分析
|
|
||||||
mvn spotbugs:check # Bug 检测
|
|
||||||
|
|
||||||
# Check dependencies
|
|
||||||
mvn versions:display-dependency-updates # 检查过期依赖
|
|
||||||
mvn dependency-check:check # OWASP 安全扫描
|
|
||||||
```
|
|
||||||
|
|
||||||
## Review Workflow
|
|
||||||
|
|
||||||
1. **Understand context**: Read PR description, linked issues
|
|
||||||
2. **Run the code**: Build, test, run locally if possible
|
|
||||||
3. **Read top-down**: Start with main entry points
|
|
||||||
4. **Check tests**: Are changes tested? Do tests pass?
|
|
||||||
5. **Security scan**: Run automated tools
|
|
||||||
6. **Manual review**: Use checklist above
|
|
||||||
7. **Write feedback**: Be specific, suggest fixes, be kind
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
---
|
|
||||||
name: mcp-builder
|
|
||||||
description: Build MCP (Model Context Protocol) servers that give Claude new capabilities. Use when user wants to create an MCP server, add tools to Claude, or integrate external services.
|
|
||||||
---
|
|
||||||
|
|
||||||
# MCP Server Building Skill
|
|
||||||
|
|
||||||
You now have expertise in building MCP (Model Context Protocol) servers. MCP enables Claude to interact with external services through a standardized protocol.
|
|
||||||
|
|
||||||
## What is MCP?
|
|
||||||
|
|
||||||
MCP servers expose:
|
|
||||||
- **Tools**: Functions Claude can call (like API endpoints)
|
|
||||||
- **Resources**: Data Claude can read (like files or database records)
|
|
||||||
- **Prompts**: Pre-built prompt templates
|
|
||||||
|
|
||||||
## Quick Start: Java/Spring AI MCP Server
|
|
||||||
|
|
||||||
### 1. Project Setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 使用 Spring Initializr 创建项目
|
|
||||||
# 或通过 Maven 手动创建
|
|
||||||
mkdir my-mcp-server && cd my-mcp-server
|
|
||||||
mvn archetype:generate -DgroupId=com.example -DartifactId=my-mcp-server \
|
|
||||||
-DarchetypeArtifactId=maven-archetype-quickstart -DinteractiveMode=false
|
|
||||||
```
|
|
||||||
|
|
||||||
在 `pom.xml` 中添加依赖:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<dependencies>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.ai</groupId>
|
|
||||||
<artifactId>spring-ai-mcp-server-spring-boot-starter</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter</artifactId>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Basic Server Template
|
|
||||||
|
|
||||||
```java
|
|
||||||
// src/main/java/com/example/McpServerApplication.java
|
|
||||||
package com.example;
|
|
||||||
|
|
||||||
import org.springframework.ai.tool.annotation.Tool;
|
|
||||||
import org.springframework.ai.tool.annotation.ToolParam;
|
|
||||||
import org.springframework.boot.SpringApplication;
|
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
|
||||||
import org.springframework.context.annotation.Bean;
|
|
||||||
|
|
||||||
@SpringBootApplication
|
|
||||||
public class McpServerApplication {
|
|
||||||
|
|
||||||
public static void main(String[] args) {
|
|
||||||
SpringApplication.run(McpServerApplication.class, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
public MyTools myTools() {
|
|
||||||
return new MyTools();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 定义工具类
|
|
||||||
class MyTools {
|
|
||||||
|
|
||||||
@Tool(description = "Say hello to someone")
|
|
||||||
public String hello(@ToolParam(description = "The name to greet") String name) {
|
|
||||||
return "Hello, " + name + "!";
|
|
||||||
}
|
|
||||||
|
|
||||||
@Tool(description = "Add two numbers together")
|
|
||||||
public String addNumbers(
|
|
||||||
@ToolParam(description = "First number") int a,
|
|
||||||
@ToolParam(description = "Second number") int b) {
|
|
||||||
return String.valueOf(a + b);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
配置 `application.properties`:
|
|
||||||
```properties
|
|
||||||
spring.ai.mcp.server.name=my-server
|
|
||||||
spring.ai.mcp.server.version=1.0.0
|
|
||||||
spring.ai.mcp.server.type=SYNC
|
|
||||||
spring.main.web-application-type=none
|
|
||||||
spring.main.banner-mode=off
|
|
||||||
spring.ai.mcp.server.stdio=true
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Register with Claude
|
|
||||||
|
|
||||||
Add to `~/.claude/mcp.json`:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"my-server": {
|
|
||||||
"command": "java",
|
|
||||||
"args": ["-jar", "/path/to/my-mcp-server/target/my-mcp-server.jar"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## TypeScript MCP Server
|
|
||||||
|
|
||||||
### 1. Setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir my-mcp-server && cd my-mcp-server
|
|
||||||
npm init -y
|
|
||||||
npm install @modelcontextprotocol/sdk
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Template
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/index.ts
|
|
||||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
||||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
||||||
|
|
||||||
const server = new Server({
|
|
||||||
name: "my-server",
|
|
||||||
version: "1.0.0",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Define tools
|
|
||||||
server.setRequestHandler("tools/list", async () => ({
|
|
||||||
tools: [
|
|
||||||
{
|
|
||||||
name: "hello",
|
|
||||||
description: "Say hello to someone",
|
|
||||||
inputSchema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
name: { type: "string", description: "Name to greet" },
|
|
||||||
},
|
|
||||||
required: ["name"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
|
|
||||||
server.setRequestHandler("tools/call", async (request) => {
|
|
||||||
if (request.params.name === "hello") {
|
|
||||||
const name = request.params.arguments.name;
|
|
||||||
return { content: [{ type: "text", text: `Hello, ${name}!` }] };
|
|
||||||
}
|
|
||||||
throw new Error("Unknown tool");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start server
|
|
||||||
const transport = new StdioServerTransport();
|
|
||||||
server.connect(transport);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Advanced Patterns
|
|
||||||
|
|
||||||
### External API Integration
|
|
||||||
|
|
||||||
```java
|
|
||||||
import org.springframework.ai.tool.annotation.Tool;
|
|
||||||
import org.springframework.ai.tool.annotation.ToolParam;
|
|
||||||
import org.springframework.web.client.RestTemplate;
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
|
|
||||||
public class WeatherTools {
|
|
||||||
|
|
||||||
private final RestTemplate restTemplate = new RestTemplate();
|
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
|
||||||
|
|
||||||
@Tool(description = "Get current weather for a city")
|
|
||||||
public String getWeather(@ToolParam(description = "City name") String city) {
|
|
||||||
String url = "https://api.weatherapi.com/v1/current.json?key=YOUR_API_KEY&q=" + city;
|
|
||||||
String response = restTemplate.getForObject(url, String.class);
|
|
||||||
JsonNode data = objectMapper.readTree(response);
|
|
||||||
JsonNode current = data.get("current");
|
|
||||||
return String.format("%s: %s°C, %s",
|
|
||||||
city, current.get("temp_c"), current.get("condition").get("text").asText());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database Access
|
|
||||||
|
|
||||||
```java
|
|
||||||
import org.springframework.ai.tool.annotation.Tool;
|
|
||||||
import org.springframework.ai.tool.annotation.ToolParam;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
|
|
||||||
public class DatabaseTools {
|
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
|
||||||
|
|
||||||
public DatabaseTools(JdbcTemplate jdbcTemplate) {
|
|
||||||
this.jdbcTemplate = jdbcTemplate;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Tool(description = "Execute a read-only SQL query")
|
|
||||||
public String queryDb(@ToolParam(description = "SQL query to execute") String sql) {
|
|
||||||
if (!sql.trim().toUpperCase().startsWith("SELECT")) {
|
|
||||||
return "Error: Only SELECT queries allowed";
|
|
||||||
}
|
|
||||||
var rows = jdbcTemplate.queryForList(sql);
|
|
||||||
return rows.toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Resources (Read-only Data)
|
|
||||||
|
|
||||||
```java
|
|
||||||
import org.springframework.ai.tool.annotation.Tool;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
|
|
||||||
public class ResourceTools {
|
|
||||||
|
|
||||||
@Tool(description = "Read application settings")
|
|
||||||
public String getSettings() throws Exception {
|
|
||||||
return Files.readString(Path.of("settings.json"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Tool(description = "Read a file from the workspace")
|
|
||||||
public String readFile(@ToolParam(description = "Path to the file") String path) throws Exception {
|
|
||||||
return Files.readString(Path.of(path));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build the project
|
|
||||||
mvn clean package -DskipTests
|
|
||||||
|
|
||||||
# Test with MCP Inspector
|
|
||||||
npx @anthropics/mcp-inspector java -jar target/my-mcp-server.jar
|
|
||||||
|
|
||||||
# Or send test messages directly
|
|
||||||
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | java -jar target/my-mcp-server.jar
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
1. **Clear tool descriptions**: Claude uses `@Tool(description=...)` to decide when to call tools
|
|
||||||
2. **Input validation**: Always validate and sanitize inputs in tool methods
|
|
||||||
3. **Error handling**: Return meaningful error messages, use proper exception handling
|
|
||||||
4. **Use Spring DI**: Leverage Spring's dependency injection for service wiring
|
|
||||||
5. **Security**: Never expose sensitive operations without auth
|
|
||||||
6. **Idempotency**: Tools should be safe to retry
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
---
|
|
||||||
name: pdf
|
|
||||||
description: Process PDF files - extract text, create PDFs, merge documents. Use when user asks to read PDF, create PDF, or work with PDF files.
|
|
||||||
---
|
|
||||||
|
|
||||||
# PDF Processing Skill
|
|
||||||
|
|
||||||
You now have expertise in PDF manipulation. Follow these workflows:
|
|
||||||
|
|
||||||
## Reading PDFs
|
|
||||||
|
|
||||||
**Option 1: Quick text extraction (preferred)**
|
|
||||||
```bash
|
|
||||||
# Using pdftotext (poppler-utils)
|
|
||||||
pdftotext input.pdf - # Output to stdout
|
|
||||||
pdftotext input.pdf output.txt # Output to file
|
|
||||||
```
|
|
||||||
|
|
||||||
**Option 2: Page-by-page with metadata (Apache PDFBox)**
|
|
||||||
```java
|
|
||||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
|
||||||
import org.apache.pdfbox.text.PDFTextStripper;
|
|
||||||
import java.io.File;
|
|
||||||
|
|
||||||
PDDocument doc = PDDocument.load(new File("input.pdf"));
|
|
||||||
System.out.println("Pages: " + doc.getNumberOfPages());
|
|
||||||
System.out.println("Metadata: " + doc.getDocumentInformation().getTitle());
|
|
||||||
|
|
||||||
PDFTextStripper stripper = new PDFTextStripper();
|
|
||||||
for (int i = 1; i <= doc.getNumberOfPages(); i++) {
|
|
||||||
stripper.setStartPage(i);
|
|
||||||
stripper.setEndPage(i);
|
|
||||||
String text = stripper.getText(doc);
|
|
||||||
System.out.println("--- Page " + i + " ---");
|
|
||||||
System.out.println(text);
|
|
||||||
}
|
|
||||||
doc.close();
|
|
||||||
```
|
|
||||||
|
|
||||||
## Creating PDFs
|
|
||||||
|
|
||||||
**Option 1: From Markdown (recommended)**
|
|
||||||
```bash
|
|
||||||
# Using pandoc
|
|
||||||
pandoc input.md -o output.pdf
|
|
||||||
|
|
||||||
# With custom styling
|
|
||||||
pandoc input.md -o output.pdf --pdf-engine=xelatex -V geometry:margin=1in
|
|
||||||
```
|
|
||||||
|
|
||||||
**Option 2: Programmatically (Apache PDFBox)**
|
|
||||||
```java
|
|
||||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
|
||||||
import org.apache.pdfbox.pdmodel.PDPage;
|
|
||||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
|
||||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
|
||||||
|
|
||||||
PDDocument doc = new PDDocument();
|
|
||||||
PDPage page = new PDPage();
|
|
||||||
doc.addPage(page);
|
|
||||||
|
|
||||||
try (PDPageContentStream content = new PDPageContentStream(doc, page)) {
|
|
||||||
content.beginText();
|
|
||||||
content.setFont(PDType1Font.HELVETICA, 12);
|
|
||||||
content.newLineAtOffset(100, 750);
|
|
||||||
content.showText("Hello, PDF!");
|
|
||||||
content.endText();
|
|
||||||
}
|
|
||||||
doc.save("output.pdf");
|
|
||||||
doc.close();
|
|
||||||
```
|
|
||||||
|
|
||||||
**Option 3: From HTML (OpenHTMLToPDF)**
|
|
||||||
```java
|
|
||||||
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
|
|
||||||
import java.io.FileOutputStream;
|
|
||||||
import java.io.OutputStream;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
|
|
||||||
String html = Files.readString(Path.of("input.html"));
|
|
||||||
try (OutputStream os = new FileOutputStream("output.pdf")) {
|
|
||||||
PdfRendererBuilder builder = new PdfRendererBuilder();
|
|
||||||
builder.withHtmlContent(html, new File("input.html").toURI().toString());
|
|
||||||
builder.toStream(os);
|
|
||||||
builder.run();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Merging PDFs
|
|
||||||
|
|
||||||
```java
|
|
||||||
import org.apache.pdfbox.multipdf.PDFMergerUtility;
|
|
||||||
import java.io.File;
|
|
||||||
|
|
||||||
PDFMergerUtility merger = new PDFMergerUtility();
|
|
||||||
merger.addSource(new File("file1.pdf"));
|
|
||||||
merger.addSource(new File("file2.pdf"));
|
|
||||||
merger.addSource(new File("file3.pdf"));
|
|
||||||
merger.setDestinationFileName("merged.pdf");
|
|
||||||
merger.mergeDocuments(null);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Splitting PDFs
|
|
||||||
|
|
||||||
```java
|
|
||||||
import org.apache.pdfbox.multipdf.Splitter;
|
|
||||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
|
||||||
import java.io.File;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
PDDocument doc = PDDocument.load(new File("input.pdf"));
|
|
||||||
Splitter splitter = new Splitter();
|
|
||||||
splitter.setSplitAtPage(1); // 每页拆分为一个文件
|
|
||||||
List<PDDocument> pages = splitter.split(doc);
|
|
||||||
|
|
||||||
for (int i = 0; i < pages.size(); i++) {
|
|
||||||
pages.get(i).save("page_" + (i + 1) + ".pdf");
|
|
||||||
pages.get(i).close();
|
|
||||||
}
|
|
||||||
doc.close();
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Libraries
|
|
||||||
|
|
||||||
| Task | Library | Maven Dependency |
|
|
||||||
|------|---------|-----------------|
|
|
||||||
| Read/Write/Merge/Split | Apache PDFBox | `org.apache.pdfbox:pdfbox:3.0.x` |
|
|
||||||
| Create from HTML | OpenHTMLToPDF | `com.openhtmltopdf:openhtmltopdf-pdfbox:1.0.x` |
|
|
||||||
| Advanced layout | iText | `com.itextpdf:itext7-core:8.0.x` |
|
|
||||||
| Text extraction | pdftotext | `brew install poppler` / `apt install poppler-utils` |
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
1. **Always check if tools are installed** before using them
|
|
||||||
2. **Handle encoding issues** - PDFs may contain various character encodings
|
|
||||||
3. **Large PDFs**: Process page by page to avoid memory issues; use try-with-resources 确保资源释放
|
|
||||||
4. **OCR for scanned PDFs**: Use Tesseract4J (`net.sourceforge.tess4j:tess4j`) if text extraction returns empty
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.claudecode;
|
||||||
|
|
||||||
|
import com.claudecode.server.DirectConnectServer;
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claude Code Java 版主入口。
|
||||||
|
* <p>
|
||||||
|
* 对应 claude-code/src/entrypoints/cli.tsx
|
||||||
|
* <p>
|
||||||
|
* 支持两种启动模式:
|
||||||
|
* <ul>
|
||||||
|
* <li>CLI 模式(默认)—— 关闭 Web 服务器,启动 TUI 交互</li>
|
||||||
|
* <li>Server 模式(--server)—— 启动 WebSocket 服务器,无 TUI</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@SpringBootApplication
|
||||||
|
public class ClaudeCodeApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication app = new SpringApplication(ClaudeCodeApplication.class);
|
||||||
|
|
||||||
|
if (DirectConnectServer.isServerMode(args)) {
|
||||||
|
// Server Mode: 启用 Web 服务器 + WebSocket
|
||||||
|
int port = DirectConnectServer.parsePort(args);
|
||||||
|
|
||||||
|
Map<String, Object> serverProps = new HashMap<>();
|
||||||
|
serverProps.put("spring.main.web-application-type", "servlet");
|
||||||
|
serverProps.put("server.port", port);
|
||||||
|
serverProps.put("claude-code.server-mode", "true");
|
||||||
|
app.setDefaultProperties(serverProps);
|
||||||
|
|
||||||
|
// 将原始参数保存到系统属性,供 ServerModeAutoConfiguration 解析
|
||||||
|
System.setProperty("claude-code.server-args", String.join(" ", args));
|
||||||
|
|
||||||
|
System.out.println("Starting in Server Mode on port " + port + "...");
|
||||||
|
}
|
||||||
|
// CLI 模式使用 application.yml 中的 web-application-type: none
|
||||||
|
|
||||||
|
app.run(args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package com.claudecode.cli;
|
||||||
|
|
||||||
|
import com.claudecode.repl.ReplSession;
|
||||||
|
import com.claudecode.server.DirectConnectServer;
|
||||||
|
import com.claudecode.tui.JinkReplSession;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.boot.CommandLineRunner;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动编排器 —— 对应 claude-code/src/main.tsx 的初始化逻辑。
|
||||||
|
* <p>
|
||||||
|
* 支持三种模式:
|
||||||
|
* <ul>
|
||||||
|
* <li>Server 模式 (--server) —— 不启动 TUI,WebSocket 服务器由 Spring 自动配置</li>
|
||||||
|
* <li>Jink TUI 模式(默认)—— 全屏终端 UI</li>
|
||||||
|
* <li>Legacy REPL 模式(降级或 CLAUDE_CODE_TUI=legacy)</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class ClaudeCodeRunner implements CommandLineRunner {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ClaudeCodeRunner.class);
|
||||||
|
|
||||||
|
private final JinkReplSession jinkReplSession;
|
||||||
|
private final ReplSession replSession;
|
||||||
|
|
||||||
|
public ClaudeCodeRunner(JinkReplSession jinkReplSession, ReplSession replSession) {
|
||||||
|
this.jinkReplSession = jinkReplSession;
|
||||||
|
this.replSession = replSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run(String... args) {
|
||||||
|
// Server Mode: 不启动 TUI,WebSocket 服务器已由 ServerModeAutoConfiguration 启动
|
||||||
|
if (DirectConnectServer.isServerMode(args)) {
|
||||||
|
log.info("Server Mode active — TUI disabled, WebSocket server running");
|
||||||
|
// 阻塞主线程,等待 Ctrl+C 或 SIGTERM
|
||||||
|
waitForShutdown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Claude Code (Java) starting...");
|
||||||
|
|
||||||
|
// 检查是否强制使用旧模式
|
||||||
|
String tuiMode = System.getenv("CLAUDE_CODE_TUI");
|
||||||
|
if ("legacy".equalsIgnoreCase(tuiMode)) {
|
||||||
|
log.info("Legacy TUI mode requested via CLAUDE_CODE_TUI=legacy");
|
||||||
|
replSession.start();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先使用 jink TUI
|
||||||
|
try {
|
||||||
|
jinkReplSession.start();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Jink TUI failed, falling back to legacy mode: {}", e.getMessage());
|
||||||
|
replSession.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 Server Mode 下阻塞主线程直到收到 shutdown 信号。
|
||||||
|
*/
|
||||||
|
private void waitForShutdown() {
|
||||||
|
Thread shutdownHook = new Thread(() -> {
|
||||||
|
log.info("Shutdown signal received");
|
||||||
|
});
|
||||||
|
Runtime.getRuntime().addShutdownHook(shutdownHook);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 阻塞直到中断
|
||||||
|
Thread.currentThread().join();
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
log.info("Server mode interrupted");
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package com.claudecode.command;
|
||||||
|
|
||||||
|
import com.claudecode.core.AgentLoop;
|
||||||
|
import com.claudecode.tool.ToolContext;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 命令抽象基类 —— 消除 54 个命令实现中的重复模式。
|
||||||
|
* <p>
|
||||||
|
* 提供:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link #args(String)} — 安全解析参数</li>
|
||||||
|
* <li>{@link #requireAgentLoop(CommandContext)} — null 检查并返回错误</li>
|
||||||
|
* <li>{@link #toolCtx(CommandContext)} — 快速获取 ToolContext</li>
|
||||||
|
* <li>{@link #success(String)}, {@link #error(String)}, {@link #warning(String)} — 格式化消息</li>
|
||||||
|
* <li>默认 {@link #aliases()} 返回空列表</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* 子类只需实现 {@link #name()}, {@link #description()}, {@link #execute(String, CommandContext)}。
|
||||||
|
* 可选覆盖 {@link #aliases()}。
|
||||||
|
*/
|
||||||
|
public abstract class BaseSlashCommand implements SlashCommand {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> aliases() {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 参数解析 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全解析命令参数(null → 空字符串,去首尾空白)。
|
||||||
|
*/
|
||||||
|
protected String args(String raw) {
|
||||||
|
return CommandUtils.parseArgs(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 上下文访问 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查 AgentLoop 是否可用。如果为 null,返回错误消息;否则返回 null。
|
||||||
|
* 典型用法:
|
||||||
|
* <pre>
|
||||||
|
* AgentLoop loop = requireAgentLoop(context);
|
||||||
|
* if (loop == null) return noSession();
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
protected AgentLoop requireAgentLoop(CommandContext context) {
|
||||||
|
return context.agentLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标准 "无会话" 错误消息。
|
||||||
|
*/
|
||||||
|
protected String noSession() {
|
||||||
|
return CommandUtils.error("No active session");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快速获取 ToolContext。如果 agentLoop 为 null 则返回 null。
|
||||||
|
*/
|
||||||
|
protected ToolContext toolCtx(CommandContext context) {
|
||||||
|
return context.agentLoop() != null ? context.agentLoop().getToolContext() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 格式化输出 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 成功消息(绿色 ✓)。
|
||||||
|
*/
|
||||||
|
protected String success(String message) {
|
||||||
|
return CommandUtils.success(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 错误消息(红色 ✗)。
|
||||||
|
*/
|
||||||
|
protected String error(String message) {
|
||||||
|
return CommandUtils.error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 警告消息(黄色 ⚠)。
|
||||||
|
*/
|
||||||
|
protected String warning(String message) {
|
||||||
|
return CommandUtils.warning(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 信息消息(蓝色 ℹ)。
|
||||||
|
*/
|
||||||
|
protected String info(String message) {
|
||||||
|
return CommandUtils.info(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化标题行。
|
||||||
|
*/
|
||||||
|
protected String header(String emoji, String title) {
|
||||||
|
return CommandUtils.header(emoji, title);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化子标题。
|
||||||
|
*/
|
||||||
|
protected String subtitle(String title) {
|
||||||
|
return CommandUtils.subtitle(title);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.claudecode.command;
|
||||||
|
|
||||||
|
import com.claudecode.core.AgentLoop;
|
||||||
|
import com.claudecode.tool.ToolRegistry;
|
||||||
|
|
||||||
|
import java.io.PrintStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 命令执行上下文。
|
||||||
|
*/
|
||||||
|
public record CommandContext(
|
||||||
|
AgentLoop agentLoop,
|
||||||
|
ToolRegistry toolRegistry,
|
||||||
|
CommandRegistry commandRegistry,
|
||||||
|
PrintStream out,
|
||||||
|
Runnable exitCallback
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.claudecode.command;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 命令注册中心 —— 对应 claude-code/src/commands.ts 中的命令集合管理。
|
||||||
|
*/
|
||||||
|
public class CommandRegistry {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(CommandRegistry.class);
|
||||||
|
|
||||||
|
private final Map<String, SlashCommand> commands = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
/** 注册命令(包括别名) */
|
||||||
|
public void register(SlashCommand command) {
|
||||||
|
commands.put(command.name().toLowerCase(), command);
|
||||||
|
for (String alias : command.aliases()) {
|
||||||
|
commands.put(alias.toLowerCase(), command);
|
||||||
|
}
|
||||||
|
log.debug("Registered command: /{}", command.name());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量注册 */
|
||||||
|
public void registerAll(SlashCommand... cmds) {
|
||||||
|
for (SlashCommand cmd : cmds) {
|
||||||
|
register(cmd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析并执行命令 */
|
||||||
|
public Optional<String> dispatch(String input, CommandContext context) {
|
||||||
|
if (!input.startsWith("/")) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
String stripped = input.substring(1).strip();
|
||||||
|
String[] parts = stripped.split("\\s+", 2);
|
||||||
|
String cmdName = parts[0].toLowerCase();
|
||||||
|
String args = parts.length > 1 ? parts[1] : "";
|
||||||
|
|
||||||
|
SlashCommand cmd = commands.get(cmdName);
|
||||||
|
if (cmd == null) {
|
||||||
|
return Optional.of("Unknown command: /" + cmdName + ". Type /help for available commands.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Optional.of(cmd.execute(args, context));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断输入是否是斜杠命令 */
|
||||||
|
public boolean isCommand(String input) {
|
||||||
|
return input != null && input.startsWith("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取所有唯一命令(用于 /help) */
|
||||||
|
public List<SlashCommand> getCommands() {
|
||||||
|
return commands.values().stream().distinct().toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取命令名称(用于 Tab 补全) */
|
||||||
|
public Set<String> getCommandNames() {
|
||||||
|
return Set.copyOf(commands.keySet());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package com.claudecode.command;
|
||||||
|
|
||||||
|
import com.claudecode.console.AnsiStyle;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 命令输出格式化工具 —— 消除命令实现中的重复格式化代码。
|
||||||
|
* <p>
|
||||||
|
* 提供标准化的命令输出格式:标题、分隔线、成功/失败消息、
|
||||||
|
* 字节/时间格式化、进度条等。
|
||||||
|
*/
|
||||||
|
public final class CommandUtils {
|
||||||
|
|
||||||
|
private CommandUtils() {}
|
||||||
|
|
||||||
|
// ==================== 标题和分隔线 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化命令标题行。
|
||||||
|
* 输出: "\n ⚡ Title\n ──────────\n\n"
|
||||||
|
*/
|
||||||
|
public static String header(String emoji, String title) {
|
||||||
|
return "\n" + AnsiStyle.bold(" " + emoji + " " + title + "\n")
|
||||||
|
+ separator(40) + "\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化命令标题行(无 emoji)。
|
||||||
|
*/
|
||||||
|
public static String header(String title) {
|
||||||
|
return "\n" + AnsiStyle.bold(" " + title + "\n")
|
||||||
|
+ separator(40) + "\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成分隔线。
|
||||||
|
*/
|
||||||
|
public static String separator(int width) {
|
||||||
|
return " " + "─".repeat(width);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 状态消息 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 成功消息(绿色 ✓)。
|
||||||
|
*/
|
||||||
|
public static String success(String message) {
|
||||||
|
return " " + AnsiStyle.green("✓") + " " + message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 错误消息(红色 ✗)。
|
||||||
|
*/
|
||||||
|
public static String error(String message) {
|
||||||
|
return " " + AnsiStyle.red("✗") + " " + message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 警告消息(黄色 ⚠)。
|
||||||
|
*/
|
||||||
|
public static String warning(String message) {
|
||||||
|
return " " + AnsiStyle.yellow("⚠") + " " + message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 信息消息(蓝色 ℹ)。
|
||||||
|
*/
|
||||||
|
public static String info(String message) {
|
||||||
|
return " " + AnsiStyle.cyan("ℹ") + " " + message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 子标题(粗体缩进)。
|
||||||
|
*/
|
||||||
|
public static String subtitle(String title) {
|
||||||
|
return AnsiStyle.bold(" " + title);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 键值对行。
|
||||||
|
*/
|
||||||
|
public static String keyValue(String key, Object value) {
|
||||||
|
return String.format(" %-12s %s", key + ":", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 键值对行(自定义宽度)。
|
||||||
|
*/
|
||||||
|
public static String keyValue(String key, Object value, int keyWidth) {
|
||||||
|
return String.format(" %-" + keyWidth + "s %s", key + ":", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 格式化工具 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化字节数为人类可读形式。
|
||||||
|
*/
|
||||||
|
public static String formatBytes(long bytes) {
|
||||||
|
if (bytes < 0) return "N/A";
|
||||||
|
if (bytes >= 1_073_741_824) return String.format("%.1fGB", bytes / 1_073_741_824.0);
|
||||||
|
if (bytes >= 1_048_576) return String.format("%.1fMB", bytes / 1_048_576.0);
|
||||||
|
if (bytes >= 1_024) return String.format("%.0fKB", bytes / 1_024.0);
|
||||||
|
return bytes + "B";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化秒数为人类可读时长。
|
||||||
|
*/
|
||||||
|
public static String formatDuration(long seconds) {
|
||||||
|
if (seconds < 0) return "N/A";
|
||||||
|
if (seconds < 60) return seconds + "s";
|
||||||
|
if (seconds < 3600) return (seconds / 60) + "m " + (seconds % 60) + "s";
|
||||||
|
return (seconds / 3600) + "h " + ((seconds % 3600) / 60) + "m";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化毫秒为人类可读时长。
|
||||||
|
*/
|
||||||
|
public static String formatMillis(long ms) {
|
||||||
|
if (ms < 1000) return ms + "ms";
|
||||||
|
return formatDuration(ms / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 截断字符串到最大长度。
|
||||||
|
*/
|
||||||
|
public static String truncate(String text, int maxLen) {
|
||||||
|
if (text == null) return "";
|
||||||
|
if (text.length() <= maxLen) return text;
|
||||||
|
return text.substring(0, maxLen - 3) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 进度条 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成带颜色的进度条。
|
||||||
|
*/
|
||||||
|
public static String progressBar(double ratio, int width) {
|
||||||
|
ratio = Math.max(0, Math.min(1.0, ratio));
|
||||||
|
int filled = (int) (ratio * width);
|
||||||
|
String bar = "█".repeat(filled);
|
||||||
|
String empty = "░".repeat(width - filled);
|
||||||
|
|
||||||
|
String colored;
|
||||||
|
if (ratio > 0.8) colored = AnsiStyle.red(bar);
|
||||||
|
else if (ratio > 0.5) colored = AnsiStyle.yellow(bar);
|
||||||
|
else colored = AnsiStyle.green(bar);
|
||||||
|
|
||||||
|
return "[" + colored + empty + "] " + String.format("%.0f%%", ratio * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 参数解析 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全解析命令参数(去空、null→空字符串)。
|
||||||
|
*/
|
||||||
|
public static String parseArgs(String args) {
|
||||||
|
return (args == null) ? "" : args.strip();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.claudecode.command;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 斜杠命令接口 —— 对应 claude-code/src/commands.ts 中的 Command 类型。
|
||||||
|
* <p>
|
||||||
|
* 用于处理以 / 开头的用户输入命令。
|
||||||
|
*/
|
||||||
|
public interface SlashCommand {
|
||||||
|
|
||||||
|
/** 命令名称(不含 / 前缀) */
|
||||||
|
String name();
|
||||||
|
|
||||||
|
/** 命令描述 */
|
||||||
|
String description();
|
||||||
|
|
||||||
|
/** 命令别名列表 */
|
||||||
|
default List<String> aliases() {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行命令。
|
||||||
|
*
|
||||||
|
* @param args 命令参数(/ 后的文本去掉命令名后的部分)
|
||||||
|
* @param context 命令执行上下文
|
||||||
|
* @return 命令输出文本
|
||||||
|
*/
|
||||||
|
String execute(String args, CommandContext context);
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package com.claudecode.command.impl;
|
||||||
|
|
||||||
|
import com.claudecode.command.CommandContext;
|
||||||
|
import com.claudecode.command.BaseSlashCommand;
|
||||||
|
import com.claudecode.console.AnsiStyle;
|
||||||
|
import com.claudecode.core.TaskManager;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /agent 命令 —— Agent 状态管理。
|
||||||
|
* <p>
|
||||||
|
* 对应 claude-code 的 agent 管理功能,显示运行中的子 agent/worker 信息。
|
||||||
|
* <ul>
|
||||||
|
* <li>/agent —— 列出所有活跃的 agent(任务)</li>
|
||||||
|
* <li>/agent [id] —— 查看特定 agent 详情</li>
|
||||||
|
* <li>/agent stop [id] —— 停止指定 agent</li>
|
||||||
|
* <li>/agent stop-all —— 停止所有 agent</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public class AgentCommand extends BaseSlashCommand {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return "agent";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() {
|
||||||
|
return "View and manage sub-agents/workers";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> aliases() {
|
||||||
|
return List.of("agents", "workers");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String execute(String args, CommandContext context) {
|
||||||
|
args = args(args);
|
||||||
|
|
||||||
|
// Get TaskManager from AgentLoop's tool context
|
||||||
|
TaskManager taskManager = getTaskManager(context);
|
||||||
|
if (taskManager == null) {
|
||||||
|
return AnsiStyle.dim(" No task manager available. Agent management requires an active session.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.equals("stop-all")) {
|
||||||
|
return handleStopAll(taskManager);
|
||||||
|
} else if (args.startsWith("stop ")) {
|
||||||
|
return handleStop(args.substring(5).strip(), taskManager);
|
||||||
|
} else if (!args.isBlank()) {
|
||||||
|
return handleDetail(args, taskManager);
|
||||||
|
} else {
|
||||||
|
return handleList(taskManager);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String handleList(TaskManager taskManager) {
|
||||||
|
var allTasks = taskManager.listTasks();
|
||||||
|
if (allTasks.isEmpty()) {
|
||||||
|
return AnsiStyle.dim(" No agents/workers running.");
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("\n");
|
||||||
|
sb.append(AnsiStyle.bold(" 🤖 Agents/Workers\n"));
|
||||||
|
sb.append(" ").append("─".repeat(60)).append("\n");
|
||||||
|
|
||||||
|
for (var task : allTasks) {
|
||||||
|
String statusIcon = switch (task.status()) {
|
||||||
|
case RUNNING -> "🟢";
|
||||||
|
case PENDING -> "🟡";
|
||||||
|
case COMPLETED -> "✅";
|
||||||
|
case FAILED -> "❌";
|
||||||
|
case CANCELLED -> "⛔";
|
||||||
|
};
|
||||||
|
|
||||||
|
String age = formatAge(task.createdAt());
|
||||||
|
|
||||||
|
sb.append(" ").append(statusIcon).append(" ")
|
||||||
|
.append(AnsiStyle.bold(task.id()))
|
||||||
|
.append(" ").append(task.status().name())
|
||||||
|
.append(" ").append(AnsiStyle.dim(age))
|
||||||
|
.append("\n");
|
||||||
|
sb.append(" ").append(truncate(task.description(), 60)).append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
long running = allTasks.stream()
|
||||||
|
.filter(t -> t.status() == TaskManager.TaskStatus.RUNNING).count();
|
||||||
|
long completed = allTasks.stream()
|
||||||
|
.filter(t -> t.status() == TaskManager.TaskStatus.COMPLETED).count();
|
||||||
|
|
||||||
|
sb.append("\n ").append(AnsiStyle.dim(
|
||||||
|
"Total: " + allTasks.size() + " | Running: " + running + " | Completed: " + completed));
|
||||||
|
sb.append("\n");
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String handleDetail(String taskId, TaskManager taskManager) {
|
||||||
|
var taskOpt = taskManager.getTask(taskId);
|
||||||
|
if (taskOpt.isEmpty()) {
|
||||||
|
// Try partial match
|
||||||
|
var allTasks = taskManager.listTasks();
|
||||||
|
var matched = allTasks.stream()
|
||||||
|
.filter(t -> t.id().startsWith(taskId) || t.description().toLowerCase().contains(taskId.toLowerCase()))
|
||||||
|
.findFirst();
|
||||||
|
if (matched.isEmpty()) {
|
||||||
|
return AnsiStyle.red(" ✗ No agent found with ID: " + taskId);
|
||||||
|
}
|
||||||
|
taskOpt = matched;
|
||||||
|
}
|
||||||
|
|
||||||
|
var task = taskOpt.get();
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("\n");
|
||||||
|
sb.append(AnsiStyle.bold(" 🤖 Agent Detail: " + task.id() + "\n"));
|
||||||
|
sb.append(" ").append("─".repeat(50)).append("\n");
|
||||||
|
sb.append(" ").append(AnsiStyle.dim("Status: ")).append(task.status().name()).append("\n");
|
||||||
|
sb.append(" ").append(AnsiStyle.dim("Task: ")).append(task.description()).append("\n");
|
||||||
|
sb.append(" ").append(AnsiStyle.dim("Created: ")).append(task.createdAt()).append("\n");
|
||||||
|
sb.append(" ").append(AnsiStyle.dim("Updated: ")).append(task.updatedAt()).append("\n");
|
||||||
|
|
||||||
|
if (task.result() != null && !task.result().isBlank()) {
|
||||||
|
sb.append(" ").append(AnsiStyle.dim("Result:")).append("\n");
|
||||||
|
task.result().lines().limit(20).forEach(line ->
|
||||||
|
sb.append(" ").append(line).append("\n"));
|
||||||
|
long totalLines = task.result().lines().count();
|
||||||
|
if (totalLines > 20) {
|
||||||
|
sb.append(" ").append(AnsiStyle.dim("... (" + (totalLines - 20) + " more lines)")).append("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!task.metadata().isEmpty()) {
|
||||||
|
sb.append(" ").append(AnsiStyle.dim("Metadata:")).append("\n");
|
||||||
|
task.metadata().forEach((k, v) ->
|
||||||
|
sb.append(" ").append(k).append(": ").append(v).append("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String handleStop(String taskId, TaskManager taskManager) {
|
||||||
|
boolean cancelled = taskManager.cancelTask(taskId);
|
||||||
|
if (cancelled) {
|
||||||
|
return AnsiStyle.green(" ✓ Agent " + taskId + " stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try partial match
|
||||||
|
var allTasks = taskManager.listTasks();
|
||||||
|
var matched = allTasks.stream()
|
||||||
|
.filter(t -> t.id().startsWith(taskId))
|
||||||
|
.findFirst();
|
||||||
|
if (matched.isPresent()) {
|
||||||
|
cancelled = taskManager.cancelTask(matched.get().id());
|
||||||
|
if (cancelled) {
|
||||||
|
return AnsiStyle.green(" ✓ Agent " + matched.get().id() + " stopped");
|
||||||
|
}
|
||||||
|
return AnsiStyle.yellow(" ⚠ Agent " + matched.get().id()
|
||||||
|
+ " is already " + matched.get().status().name());
|
||||||
|
}
|
||||||
|
|
||||||
|
return AnsiStyle.red(" ✗ No running agent found with ID: " + taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String handleStopAll(TaskManager taskManager) {
|
||||||
|
var running = taskManager.listTasks(TaskManager.TaskStatus.RUNNING);
|
||||||
|
if (running.isEmpty()) {
|
||||||
|
return AnsiStyle.dim(" No running agents to stop.");
|
||||||
|
}
|
||||||
|
|
||||||
|
int stopped = 0;
|
||||||
|
for (var task : running) {
|
||||||
|
if (taskManager.cancelTask(task.id())) stopped++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return AnsiStyle.green(" ✓ Stopped " + stopped + " agent(s)");
|
||||||
|
}
|
||||||
|
|
||||||
|
private TaskManager getTaskManager(CommandContext context) {
|
||||||
|
// The TaskManager is typically accessible from the tool context
|
||||||
|
// via AgentLoop's tool context. Try to access it.
|
||||||
|
if (requireAgentLoop(context) == null) return null;
|
||||||
|
var toolContext = toolCtx(context);
|
||||||
|
if (toolContext == null) return null;
|
||||||
|
return toolContext.getOrDefault("TASK_MANAGER", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatAge(Instant created) {
|
||||||
|
long seconds = Duration.between(created, Instant.now()).getSeconds();
|
||||||
|
if (seconds < 60) return seconds + "s ago";
|
||||||
|
if (seconds < 3600) return (seconds / 60) + "m ago";
|
||||||
|
return (seconds / 3600) + "h " + ((seconds % 3600) / 60) + "m ago";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String truncate(String text, int maxLen) {
|
||||||
|
if (text == null) return "";
|
||||||
|
if (text.length() <= maxLen) return text;
|
||||||
|
return text.substring(0, maxLen - 3) + "...";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package com.claudecode.command.impl;
|
||||||
|
|
||||||
|
import com.claudecode.command.CommandContext;
|
||||||
|
import com.claudecode.command.BaseSlashCommand;
|
||||||
|
import com.claudecode.console.AnsiStyle;
|
||||||
|
import org.springframework.ai.chat.messages.Message;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /branch 命令 —— 管理对话分支。
|
||||||
|
* <p>
|
||||||
|
* 允许用户保存当前对话状态为命名分支,并在不同分支之间切换。
|
||||||
|
* 分支数据存储在静态 Map 中,在 JVM 生命周期内持久化。
|
||||||
|
* <p>
|
||||||
|
* 支持的子命令:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code /branch save <name>} —— 将当前对话保存为分支</li>
|
||||||
|
* <li>{@code /branch load <name>} —— 恢复到指定分支的对话状态</li>
|
||||||
|
* <li>{@code /branch list} —— 列出所有已保存的分支</li>
|
||||||
|
* <li>{@code /branch delete <name>} —— 删除指定分支</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public class BranchCommand extends BaseSlashCommand {
|
||||||
|
|
||||||
|
/** 静态分支存储:分支名称 -> 消息快照 */
|
||||||
|
private static final Map<String, BranchSnapshot> branches = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return "branch";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() {
|
||||||
|
return "Manage conversation branches";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String execute(String args, CommandContext context) {
|
||||||
|
if (requireAgentLoop(context) == null) {
|
||||||
|
return AnsiStyle.red(" ✗ AgentLoop unavailable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
String trimmedArgs = args != null ? args.trim() : "";
|
||||||
|
|
||||||
|
if (trimmedArgs.isEmpty()) {
|
||||||
|
return showUsage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析子命令和参数
|
||||||
|
String[] parts = trimmedArgs.split("\\s+", 2);
|
||||||
|
String subCommand = parts[0].toLowerCase();
|
||||||
|
String branchName = parts.length > 1 ? parts[1].trim() : "";
|
||||||
|
|
||||||
|
return switch (subCommand) {
|
||||||
|
case "save" -> saveBranch(branchName, context);
|
||||||
|
case "load" -> loadBranch(branchName, context);
|
||||||
|
case "list" -> listBranches(context);
|
||||||
|
case "delete" -> deleteBranch(branchName);
|
||||||
|
default -> AnsiStyle.red(" ✗ Unknown subcommand: " + subCommand) + "\n" + showUsage();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存当前对话为命名分支。
|
||||||
|
*
|
||||||
|
* @param branchName 分支名称
|
||||||
|
* @param context 命令上下文
|
||||||
|
* @return 操作结果信息
|
||||||
|
*/
|
||||||
|
private String saveBranch(String branchName, CommandContext context) {
|
||||||
|
if (branchName.isEmpty()) {
|
||||||
|
return AnsiStyle.red(" ✗ Please specify branch name.") + "\n"
|
||||||
|
+ AnsiStyle.dim(" Usage: /branch save <name>");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Message> currentHistory = context.agentLoop().getMessageHistory();
|
||||||
|
|
||||||
|
// 创建消息快照(深拷贝列表引用)
|
||||||
|
List<Message> snapshot = new ArrayList<>(currentHistory);
|
||||||
|
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||||
|
branches.put(branchName, new BranchSnapshot(snapshot, timestamp));
|
||||||
|
|
||||||
|
return AnsiStyle.green(" ✓ Branch saved: ") + AnsiStyle.bold(branchName) + "\n"
|
||||||
|
+ AnsiStyle.dim(" Messages: " + snapshot.size() + " Time: " + timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 恢复到指定分支的对话状态。
|
||||||
|
*
|
||||||
|
* @param branchName 分支名称
|
||||||
|
* @param context 命令上下文
|
||||||
|
* @return 操作结果信息
|
||||||
|
*/
|
||||||
|
private String loadBranch(String branchName, CommandContext context) {
|
||||||
|
if (branchName.isEmpty()) {
|
||||||
|
return AnsiStyle.red(" ✗ Please specify branch name.") + "\n"
|
||||||
|
+ AnsiStyle.dim(" Usage: /branch load <name>");
|
||||||
|
}
|
||||||
|
|
||||||
|
BranchSnapshot snapshot = branches.get(branchName);
|
||||||
|
if (snapshot == null) {
|
||||||
|
return AnsiStyle.red(" ✗ Branch not found: " + branchName) + "\n"
|
||||||
|
+ AnsiStyle.dim(" Use /branch list to see all available branches.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复对话历史
|
||||||
|
context.agentLoop().replaceHistory(new ArrayList<>(snapshot.messages()));
|
||||||
|
|
||||||
|
return AnsiStyle.green(" ✓ Restored to branch: ") + AnsiStyle.bold(branchName) + "\n"
|
||||||
|
+ AnsiStyle.dim(" Loaded " + snapshot.messages().size() + " messages (saved at " + snapshot.timestamp() + ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列出所有已保存的分支。
|
||||||
|
*
|
||||||
|
* @param context 命令上下文
|
||||||
|
* @return 分支列表信息
|
||||||
|
*/
|
||||||
|
private String listBranches(CommandContext context) {
|
||||||
|
if (branches.isEmpty()) {
|
||||||
|
return AnsiStyle.dim(" No saved branches.") + "\n"
|
||||||
|
+ AnsiStyle.dim(" Use /branch save <name> to save current conversation.");
|
||||||
|
}
|
||||||
|
|
||||||
|
int currentSize = context.agentLoop().getMessageHistory().size();
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append(AnsiStyle.bold("\n 🌿 Conversation Branches\n\n"));
|
||||||
|
|
||||||
|
for (Map.Entry<String, BranchSnapshot> entry : branches.entrySet()) {
|
||||||
|
String name = entry.getKey();
|
||||||
|
BranchSnapshot snapshot = entry.getValue();
|
||||||
|
int msgCount = snapshot.messages().size();
|
||||||
|
|
||||||
|
// 标记与当前对话大小相同的分支
|
||||||
|
String marker = (msgCount == currentSize) ? AnsiStyle.green(" ◀ current size") : "";
|
||||||
|
|
||||||
|
sb.append(" • ")
|
||||||
|
.append(AnsiStyle.bold(name))
|
||||||
|
.append(AnsiStyle.dim(" (" + msgCount + " messages, " + snapshot.timestamp() + ")"))
|
||||||
|
.append(marker)
|
||||||
|
.append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append("\n").append(AnsiStyle.dim(" Total " + branches.size() + " branches.")).append("\n");
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除指定分支。
|
||||||
|
*
|
||||||
|
* @param branchName 分支名称
|
||||||
|
* @return 操作结果信息
|
||||||
|
*/
|
||||||
|
private String deleteBranch(String branchName) {
|
||||||
|
if (branchName.isEmpty()) {
|
||||||
|
return AnsiStyle.red(" ✗ Please specify branch name.") + "\n"
|
||||||
|
+ AnsiStyle.dim(" Usage: /branch delete <name>");
|
||||||
|
}
|
||||||
|
|
||||||
|
BranchSnapshot removed = branches.remove(branchName);
|
||||||
|
if (removed == null) {
|
||||||
|
return AnsiStyle.red(" ✗ Branch not found: " + branchName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return AnsiStyle.green(" ✓ Branch deleted: ") + AnsiStyle.bold(branchName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示用法帮助信息。
|
||||||
|
*
|
||||||
|
* @return 用法说明文本
|
||||||
|
*/
|
||||||
|
private String showUsage() {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append(AnsiStyle.bold("\n 🌿 Branch — Conversation branch management\n\n"));
|
||||||
|
sb.append(" ").append(AnsiStyle.cyan("/branch save <name>")).append(" Save current conversation as branch\n");
|
||||||
|
sb.append(" ").append(AnsiStyle.cyan("/branch load <name>")).append(" Restore to specified branch\n");
|
||||||
|
sb.append(" ").append(AnsiStyle.cyan("/branch list")).append(" List all branches\n");
|
||||||
|
sb.append(" ").append(AnsiStyle.cyan("/branch delete <name>")).append(" Delete specified branch\n");
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分支快照记录 —— 保存消息列表和创建时间。
|
||||||
|
*
|
||||||
|
* @param messages 消息快照
|
||||||
|
* @param timestamp 创建时间戳
|
||||||
|
*/
|
||||||
|
private record BranchSnapshot(List<Message> messages, String timestamp) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.claudecode.command.impl;
|
||||||
|
|
||||||
|
import com.claudecode.command.CommandContext;
|
||||||
|
import com.claudecode.command.BaseSlashCommand;
|
||||||
|
import com.claudecode.console.AnsiStyle;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /brief 命令 —— 切换简洁输出模式。
|
||||||
|
*/
|
||||||
|
public class BriefCommand extends BaseSlashCommand {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() { return "brief"; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() { return "Toggle brief output mode"; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String execute(String args, CommandContext context) {
|
||||||
|
if (requireAgentLoop(context) == null) {
|
||||||
|
return noSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
var toolCtx = toolCtx(context);
|
||||||
|
boolean current = Boolean.TRUE.equals(toolCtx.get("BRIEF_MODE"));
|
||||||
|
|
||||||
|
String trimmed = args(args);
|
||||||
|
boolean newMode = switch (trimmed) {
|
||||||
|
case "on", "enable", "true" -> true;
|
||||||
|
case "off", "disable", "false" -> false;
|
||||||
|
default -> !current; // toggle
|
||||||
|
};
|
||||||
|
|
||||||
|
toolCtx.set("BRIEF_MODE", newMode);
|
||||||
|
|
||||||
|
return newMode
|
||||||
|
? AnsiStyle.green(" ✓ Brief mode ON") + " — responses will be concise"
|
||||||
|
: AnsiStyle.green(" ✓ Brief mode OFF") + " — responses will be detailed";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.claudecode.command.impl;
|
||||||
|
|
||||||
|
import com.claudecode.command.CommandContext;
|
||||||
|
import com.claudecode.command.BaseSlashCommand;
|
||||||
|
import com.claudecode.console.AnsiStyle;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /clear 命令 —— 清除对话历史。
|
||||||
|
*/
|
||||||
|
public class ClearCommand extends BaseSlashCommand {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return "clear";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() {
|
||||||
|
return "Clear conversation history";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String execute(String args, CommandContext context) {
|
||||||
|
if (context.agentLoop() != null) {
|
||||||
|
context.agentLoop().reset();
|
||||||
|
}
|
||||||
|
return AnsiStyle.green(" ✓ Conversation history cleared.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package com.claudecode.command.impl;
|
||||||
|
|
||||||
|
import com.claudecode.command.CommandContext;
|
||||||
|
import com.claudecode.command.BaseSlashCommand;
|
||||||
|
import com.claudecode.console.AnsiStyle;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /commit 命令 —— 创建 Git commit。
|
||||||
|
* <p>
|
||||||
|
* 支持多种模式:
|
||||||
|
* <ul>
|
||||||
|
* <li>/commit —— 自动生成 AI commit message(基于 git diff)</li>
|
||||||
|
* <li>/commit [message] —— 使用指定的 commit message</li>
|
||||||
|
* <li>/commit --all —— 添加所有文件并提交</li>
|
||||||
|
* <li>/commit --push —— 提交后自动 push 到远程</li>
|
||||||
|
* <li>/commit --pr —— 提交 + push + 创建 PR(使用 gh CLI)</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public class CommitCommand extends BaseSlashCommand {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return "commit";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() {
|
||||||
|
return "Create a git commit. Options: --all, --push, --pr";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String execute(String args, CommandContext context) {
|
||||||
|
Path projectDir = Path.of(System.getProperty("user.dir"));
|
||||||
|
if (!Files.isDirectory(projectDir.resolve(".git"))) {
|
||||||
|
return AnsiStyle.yellow(" ⚠ Current directory is not a Git repository");
|
||||||
|
}
|
||||||
|
|
||||||
|
args = args(args);
|
||||||
|
|
||||||
|
try {
|
||||||
|
boolean addAll = args.contains("--all") || args.contains("-a");
|
||||||
|
boolean push = args.contains("--push") || args.contains("-p");
|
||||||
|
boolean pr = args.contains("--pr");
|
||||||
|
String message = args.replaceAll("--(all|push|pr)|-[ap]", "").strip();
|
||||||
|
|
||||||
|
// --all: add all files
|
||||||
|
if (addAll) {
|
||||||
|
String addResult = runGit(projectDir, "add", "-A");
|
||||||
|
if (addResult == null) {
|
||||||
|
return AnsiStyle.red(" ✗ git add failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check staged changes
|
||||||
|
String staged = runGit(projectDir, "diff", "--cached", "--stat");
|
||||||
|
if (staged == null || staged.isBlank()) {
|
||||||
|
String status = runGit(projectDir, "status", "--short");
|
||||||
|
if (status != null && !status.isBlank()) {
|
||||||
|
return AnsiStyle.yellow(" ⚠ No staged changes\n")
|
||||||
|
+ AnsiStyle.dim(" Use /commit --all to add all files\n")
|
||||||
|
+ AnsiStyle.dim(" Or run git add manually first");
|
||||||
|
}
|
||||||
|
return AnsiStyle.green(" ✓ Working directory clean, nothing to commit");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate message if not provided
|
||||||
|
if (message.isEmpty()) {
|
||||||
|
message = generateCommitMessage(projectDir, context);
|
||||||
|
if (message == null || message.isBlank()) {
|
||||||
|
return AnsiStyle.red(" ✗ Failed to generate commit message");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute commit
|
||||||
|
String commitResult = runGit(projectDir, "commit", "-m", message);
|
||||||
|
if (commitResult == null) {
|
||||||
|
return AnsiStyle.red(" ✗ git commit failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("\n").append(AnsiStyle.green(" ✓ Commit successful\n"));
|
||||||
|
sb.append(" ").append("─".repeat(50)).append("\n");
|
||||||
|
sb.append(" ").append(AnsiStyle.bold("Message: ")).append(message).append("\n");
|
||||||
|
commitResult.lines().forEach(line -> sb.append(" ").append(AnsiStyle.dim(line)).append("\n"));
|
||||||
|
|
||||||
|
// --push: push to remote
|
||||||
|
if (push || pr) {
|
||||||
|
String branch = runGit(projectDir, "rev-parse", "--abbrev-ref", "HEAD");
|
||||||
|
if (branch == null || branch.isBlank()) branch = "HEAD";
|
||||||
|
|
||||||
|
String pushResult = runGit(projectDir, "push", "origin", branch);
|
||||||
|
if (pushResult != null) {
|
||||||
|
sb.append("\n").append(AnsiStyle.green(" ✓ Pushed to origin/" + branch)).append("\n");
|
||||||
|
} else {
|
||||||
|
// Try push with --set-upstream for new branches
|
||||||
|
pushResult = runGit(projectDir, "push", "--set-upstream", "origin", branch);
|
||||||
|
if (pushResult != null) {
|
||||||
|
sb.append("\n").append(AnsiStyle.green(" ✓ Pushed to origin/" + branch + " (new branch)")).append("\n");
|
||||||
|
} else {
|
||||||
|
sb.append("\n").append(AnsiStyle.red(" ✗ Push failed")).append("\n");
|
||||||
|
push = false;
|
||||||
|
pr = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --pr: create PR using gh CLI
|
||||||
|
if (pr) {
|
||||||
|
String prResult = createPullRequest(projectDir, message);
|
||||||
|
sb.append(prResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
return AnsiStyle.red(" ✗ Commit failed: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String createPullRequest(Path projectDir, String commitMessage) {
|
||||||
|
// Check if gh CLI is available
|
||||||
|
try {
|
||||||
|
String firstLine = commitMessage.lines().findFirst().orElse(commitMessage);
|
||||||
|
String body = commitMessage.lines().skip(1)
|
||||||
|
.reduce("", (a, b) -> a + "\n" + b).strip();
|
||||||
|
|
||||||
|
String ghResult = runCommand(projectDir, "gh", "pr", "create",
|
||||||
|
"--title", firstLine,
|
||||||
|
"--body", body.isEmpty() ? "Auto-generated PR" : body);
|
||||||
|
|
||||||
|
if (ghResult != null && ghResult.contains("http")) {
|
||||||
|
return "\n" + AnsiStyle.green(" ✓ PR created: ") + AnsiStyle.CYAN + ghResult.strip() + AnsiStyle.RESET + "\n";
|
||||||
|
} else if (ghResult != null) {
|
||||||
|
return "\n" + AnsiStyle.green(" ✓ PR: ") + ghResult.strip() + "\n";
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
|
||||||
|
return "\n" + AnsiStyle.yellow(" ⚠ PR creation failed — is 'gh' CLI installed?") + "\n"
|
||||||
|
+ AnsiStyle.dim(" Install: https://cli.github.com/\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Use AI to generate commit message from git diff */
|
||||||
|
private String generateCommitMessage(Path projectDir, CommandContext context) {
|
||||||
|
try {
|
||||||
|
String diff = runGit(projectDir, "diff", "--cached");
|
||||||
|
if (diff == null || diff.isBlank()) return null;
|
||||||
|
|
||||||
|
if (diff.length() > 4000) {
|
||||||
|
diff = diff.substring(0, 4000) + "\n... (diff truncated)";
|
||||||
|
}
|
||||||
|
|
||||||
|
String prompt = """
|
||||||
|
Analyze the following git diff and generate a concise commit message.
|
||||||
|
Requirements:
|
||||||
|
1. Use conventional commits format (feat/fix/docs/refactor/chore prefix)
|
||||||
|
2. First line should not exceed 72 characters
|
||||||
|
3. For multiple changes, add details after a blank line
|
||||||
|
4. Return only the commit message text, no additional explanation
|
||||||
|
|
||||||
|
Git diff:
|
||||||
|
```
|
||||||
|
%s
|
||||||
|
```
|
||||||
|
""".formatted(diff);
|
||||||
|
|
||||||
|
var chatModel = context.agentLoop().getChatModel();
|
||||||
|
var response = chatModel.call(
|
||||||
|
new org.springframework.ai.chat.prompt.Prompt(prompt));
|
||||||
|
|
||||||
|
String generated = response.getResult().getOutput().getText();
|
||||||
|
if (generated != null) {
|
||||||
|
generated = generated.strip()
|
||||||
|
.replaceAll("^[\"'`]+|[\"'`]+$", "")
|
||||||
|
.strip();
|
||||||
|
}
|
||||||
|
return generated;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String runGit(Path dir, String... args) {
|
||||||
|
var command = new java.util.ArrayList<String>();
|
||||||
|
command.add("git");
|
||||||
|
command.add("--no-pager");
|
||||||
|
command.addAll(java.util.List.of(args));
|
||||||
|
return runCommand(dir, command.toArray(new String[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String runCommand(Path dir, String... command) {
|
||||||
|
try {
|
||||||
|
ProcessBuilder pb = new ProcessBuilder(command);
|
||||||
|
pb.directory(dir.toFile());
|
||||||
|
pb.redirectErrorStream(true);
|
||||||
|
|
||||||
|
Process process = pb.start();
|
||||||
|
StringBuilder output = new StringBuilder();
|
||||||
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
output.append(line).append("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean finished = process.waitFor(30, TimeUnit.SECONDS);
|
||||||
|
if (!finished) {
|
||||||
|
process.destroyForcibly();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return process.exitValue() == 0 ? output.toString().stripTrailing() : null;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package com.claudecode.command.impl;
|
||||||
|
|
||||||
|
import com.claudecode.command.CommandContext;
|
||||||
|
import com.claudecode.command.BaseSlashCommand;
|
||||||
|
import com.claudecode.console.AnsiStyle;
|
||||||
|
import com.claudecode.core.TokenTracker;
|
||||||
|
import com.claudecode.core.compact.AutoCompactManager;
|
||||||
|
import com.claudecode.core.compact.FullCompact;
|
||||||
|
import org.springframework.ai.chat.messages.*;
|
||||||
|
import org.springframework.ai.chat.model.ChatModel;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /compact 命令 —— 用 AI 生成摘要来压缩上下文。
|
||||||
|
* <p>
|
||||||
|
* 对应 claude-code/src/commands/compact.ts。
|
||||||
|
* 支持压缩级别和统计显示。
|
||||||
|
* <ul>
|
||||||
|
* <li>/compact —— 标准压缩</li>
|
||||||
|
* <li>/compact --stats —— 仅显示统计信息</li>
|
||||||
|
* <li>/compact --aggressive —— 激进压缩(保留更少上下文)</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public class CompactCommand extends BaseSlashCommand {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return "compact";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() {
|
||||||
|
return "Compact conversation context. Use --stats for info, --aggressive for deeper compaction.";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String execute(String args, CommandContext context) {
|
||||||
|
if (requireAgentLoop(context) == null) {
|
||||||
|
return AnsiStyle.yellow(" ⚠ No active conversation to compact.");
|
||||||
|
}
|
||||||
|
|
||||||
|
String argStr = args(args);
|
||||||
|
|
||||||
|
// --stats: only show statistics
|
||||||
|
if (argStr.equals("--stats") || argStr.equals("-s")) {
|
||||||
|
return showStats(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean aggressive = argStr.equals("--aggressive") || argStr.equals("-a");
|
||||||
|
|
||||||
|
List<Message> history = context.agentLoop().getMessageHistory();
|
||||||
|
int before = history.size();
|
||||||
|
|
||||||
|
if (before <= 3) {
|
||||||
|
return showStats(context) + "\n"
|
||||||
|
+ AnsiStyle.dim(" Context is already small, no compaction needed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
TokenTracker tracker = context.agentLoop().getTokenTracker();
|
||||||
|
long tokensBefore = tracker.getInputTokens() + tracker.getOutputTokens();
|
||||||
|
|
||||||
|
// Use FullCompact from AutoCompactManager
|
||||||
|
FullCompact fullCompact;
|
||||||
|
AutoCompactManager acm = context.agentLoop().getAutoCompactManager();
|
||||||
|
if (acm != null) {
|
||||||
|
fullCompact = acm.getFullCompact();
|
||||||
|
} else {
|
||||||
|
fullCompact = new FullCompact(context.agentLoop().getChatModel());
|
||||||
|
}
|
||||||
|
|
||||||
|
// In aggressive mode, compact more aggressively by keeping fewer messages
|
||||||
|
List<Message> toCompact = new ArrayList<>(history);
|
||||||
|
if (aggressive && toCompact.size() > 5) {
|
||||||
|
// For aggressive mode, only keep the system message and last 2 exchanges
|
||||||
|
List<Message> aggressive_list = new ArrayList<>();
|
||||||
|
// Keep first message (usually system)
|
||||||
|
aggressive_list.add(toCompact.getFirst());
|
||||||
|
// Keep last 4 messages (2 exchanges)
|
||||||
|
int start = Math.max(1, toCompact.size() - 4);
|
||||||
|
aggressive_list.addAll(toCompact.subList(start, toCompact.size()));
|
||||||
|
toCompact = aggressive_list;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Message> compacted = fullCompact.compact(toCompact);
|
||||||
|
|
||||||
|
if (compacted != null) {
|
||||||
|
context.agentLoop().replaceHistory(compacted);
|
||||||
|
int after = compacted.size();
|
||||||
|
|
||||||
|
if (acm != null) {
|
||||||
|
acm.resetCircuitBreaker();
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append(AnsiStyle.green(" ✅ Context compacted")).append(
|
||||||
|
aggressive ? AnsiStyle.yellow(" (aggressive)") : "").append("\n");
|
||||||
|
sb.append(" ").append("─".repeat(40)).append("\n");
|
||||||
|
sb.append(" Messages: ").append(before).append(" → ").append(after);
|
||||||
|
sb.append(" (").append(String.format("%.0f%%", (1.0 - (double) after / before) * 100)).append(" reduction)\n");
|
||||||
|
if (tokensBefore > 0) {
|
||||||
|
sb.append(" Tokens before: ").append(TokenTracker.formatTokens(tokensBefore)).append("\n");
|
||||||
|
}
|
||||||
|
sb.append(AnsiStyle.dim(" 📝 AI summary generated and injected into context"));
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return AnsiStyle.yellow(" ⚠ Compaction failed — AI summary generation failed") + "\n"
|
||||||
|
+ AnsiStyle.dim(" The conversation history was not modified.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String showStats(CommandContext context) {
|
||||||
|
List<Message> history = context.agentLoop().getMessageHistory();
|
||||||
|
TokenTracker tracker = context.agentLoop().getTokenTracker();
|
||||||
|
|
||||||
|
int userMsgs = 0, assistantMsgs = 0, systemMsgs = 0, toolMsgs = 0;
|
||||||
|
for (Message m : history) {
|
||||||
|
if (m instanceof UserMessage) userMsgs++;
|
||||||
|
else if (m instanceof AssistantMessage) assistantMsgs++;
|
||||||
|
else if (m instanceof SystemMessage) systemMsgs++;
|
||||||
|
else toolMsgs++;
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("\n ").append(AnsiStyle.bold("📊 Context Statistics")).append("\n");
|
||||||
|
sb.append(" ").append("─".repeat(40)).append("\n");
|
||||||
|
sb.append(" Total messages: ").append(history.size()).append("\n");
|
||||||
|
sb.append(" User: ").append(userMsgs).append("\n");
|
||||||
|
sb.append(" Assistant: ").append(assistantMsgs).append("\n");
|
||||||
|
sb.append(" System: ").append(systemMsgs).append("\n");
|
||||||
|
if (toolMsgs > 0) {
|
||||||
|
sb.append(" Tool: ").append(toolMsgs).append("\n");
|
||||||
|
}
|
||||||
|
sb.append(" ").append("─".repeat(40)).append("\n");
|
||||||
|
sb.append(" Input tokens: ").append(TokenTracker.formatTokens(tracker.getInputTokens())).append("\n");
|
||||||
|
sb.append(" Output tokens: ").append(TokenTracker.formatTokens(tracker.getOutputTokens())).append("\n");
|
||||||
|
sb.append(" Total tokens: ").append(TokenTracker.formatTokens(
|
||||||
|
tracker.getInputTokens() + tracker.getOutputTokens())).append("\n");
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package com.claudecode.command.impl;
|
||||||
|
|
||||||
|
import com.claudecode.command.CommandContext;
|
||||||
|
import com.claudecode.command.BaseSlashCommand;
|
||||||
|
import com.claudecode.console.AnsiStyle;
|
||||||
|
import com.claudecode.permission.PermissionRuleEngine;
|
||||||
|
import com.claudecode.permission.PermissionSettings;
|
||||||
|
import com.claudecode.permission.PermissionTypes.PermissionMode;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /config 命令 —— 查看和设置应用配置。
|
||||||
|
* <p>
|
||||||
|
* 支持查看当前配置、设置单个配置项,以及权限管理子命令。
|
||||||
|
*/
|
||||||
|
public class ConfigCommand extends BaseSlashCommand {
|
||||||
|
|
||||||
|
/** 支持的配置项及说明 */
|
||||||
|
private static final Map<String, String> CONFIG_KEYS = Map.of(
|
||||||
|
"model", "AI model name (e.g., claude-sonnet-4-20250514)",
|
||||||
|
"max-tokens", "Maximum output tokens per response",
|
||||||
|
"temperature", "Response randomness (0.0-1.0)",
|
||||||
|
"verbose", "Enable verbose logging (true/false)",
|
||||||
|
"auto-compact", "Auto compact when context is large (true/false)",
|
||||||
|
"permission-mode", "Permission mode: default/accept-edits/bypass/dont-ask",
|
||||||
|
"permission-list", "List all saved permission rules",
|
||||||
|
"permission-reset", "Clear all permission rules"
|
||||||
|
);
|
||||||
|
|
||||||
|
private PermissionSettings permissionSettings;
|
||||||
|
|
||||||
|
public ConfigCommand() {}
|
||||||
|
|
||||||
|
public ConfigCommand(PermissionSettings permissionSettings) {
|
||||||
|
this.permissionSettings = permissionSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPermissionSettings(PermissionSettings settings) {
|
||||||
|
this.permissionSettings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return "config";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() {
|
||||||
|
return "View or set configuration";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> aliases() {
|
||||||
|
return List.of("cfg");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String execute(String args, CommandContext context) {
|
||||||
|
if (args == null || args.isBlank()) {
|
||||||
|
return showAllConfig(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
String[] parts = args.strip().split("\\s+", 2);
|
||||||
|
String key = parts[0];
|
||||||
|
|
||||||
|
if (parts.length == 1) {
|
||||||
|
// 显示单个配置项
|
||||||
|
return showConfig(key, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置配置项
|
||||||
|
String value = parts[1];
|
||||||
|
return setConfig(key, value, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String showAllConfig(CommandContext context) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("\n");
|
||||||
|
sb.append(AnsiStyle.bold(" ⚙ Configuration\n"));
|
||||||
|
sb.append(" ").append("─".repeat(40)).append("\n\n");
|
||||||
|
|
||||||
|
// 当前活跃配置
|
||||||
|
String model = context.agentLoop().getTokenTracker().getModelName();
|
||||||
|
sb.append(" ").append(AnsiStyle.bold("model: ")).append(AnsiStyle.cyan(model)).append("\n");
|
||||||
|
|
||||||
|
String apiKey = System.getenv("ANTHROPIC_API_KEY");
|
||||||
|
if (apiKey != null && apiKey.length() > 8) {
|
||||||
|
sb.append(" ").append(AnsiStyle.bold("api-key: ")).append(AnsiStyle.dim(
|
||||||
|
apiKey.substring(0, 8) + "..." + apiKey.substring(apiKey.length() - 4))).append("\n");
|
||||||
|
} else {
|
||||||
|
sb.append(" ").append(AnsiStyle.bold("api-key: ")).append(AnsiStyle.yellow("(not set)")).append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
String baseUrl = System.getenv().getOrDefault("ANTHROPIC_BASE_URL", "https://api.anthropic.com");
|
||||||
|
sb.append(" ").append(AnsiStyle.bold("base-url: ")).append(AnsiStyle.dim(baseUrl)).append("\n");
|
||||||
|
|
||||||
|
sb.append("\n");
|
||||||
|
sb.append(AnsiStyle.dim(" Available keys:\n"));
|
||||||
|
for (var entry : CONFIG_KEYS.entrySet()) {
|
||||||
|
sb.append(AnsiStyle.dim(" " + entry.getKey() + " — " + entry.getValue())).append("\n");
|
||||||
|
}
|
||||||
|
sb.append("\n");
|
||||||
|
sb.append(AnsiStyle.dim(" Usage: /config <key> <value>"));
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String setConfig(String key, String value, CommandContext context) {
|
||||||
|
return switch (key) {
|
||||||
|
case "model" -> {
|
||||||
|
context.agentLoop().getTokenTracker().setModel(value);
|
||||||
|
yield AnsiStyle.green(" ✅ Model set to: " + value) + "\n"
|
||||||
|
+ AnsiStyle.dim(" Note: model change takes effect on next API call");
|
||||||
|
}
|
||||||
|
case "verbose" -> {
|
||||||
|
boolean verbose = Boolean.parseBoolean(value);
|
||||||
|
yield AnsiStyle.green(" ✅ Verbose mode: " + (verbose ? "ON" : "OFF"));
|
||||||
|
}
|
||||||
|
case "permission-mode" -> setPermissionMode(value);
|
||||||
|
case "permission-list" -> listPermissionRules();
|
||||||
|
case "permission-reset" -> resetPermissionRules();
|
||||||
|
default -> {
|
||||||
|
if (!CONFIG_KEYS.containsKey(key)) {
|
||||||
|
yield AnsiStyle.yellow(" ⚠ Unknown config key: " + key);
|
||||||
|
}
|
||||||
|
yield AnsiStyle.yellow(" ⚠ Setting '" + key + "' is not yet supported at runtime") + "\n"
|
||||||
|
+ AnsiStyle.dim(" Set via application.yml or environment variables");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private String showConfig(String key, CommandContext context) {
|
||||||
|
// 无参数的权限子命令
|
||||||
|
if (key.equals("permission-list")) return listPermissionRules();
|
||||||
|
if (key.equals("permission-reset")) return resetPermissionRules();
|
||||||
|
|
||||||
|
if (!CONFIG_KEYS.containsKey(key)) {
|
||||||
|
return AnsiStyle.yellow(" ⚠ Unknown config key: " + key) + "\n"
|
||||||
|
+ AnsiStyle.dim(" Available: " + String.join(", ", CONFIG_KEYS.keySet()));
|
||||||
|
}
|
||||||
|
|
||||||
|
String desc = CONFIG_KEYS.get(key);
|
||||||
|
return " " + AnsiStyle.bold(key) + ": " + AnsiStyle.dim(desc) + "\n"
|
||||||
|
+ AnsiStyle.dim(" Set with: /config " + key + " <value>");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 权限管理子命令 ──
|
||||||
|
|
||||||
|
private String setPermissionMode(String value) {
|
||||||
|
if (permissionSettings == null) {
|
||||||
|
return AnsiStyle.yellow(" ⚠ Permission settings not initialized");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
PermissionMode mode = switch (value.toLowerCase()) {
|
||||||
|
case "default" -> PermissionMode.DEFAULT;
|
||||||
|
case "accept-edits", "acceptedits" -> PermissionMode.ACCEPT_EDITS;
|
||||||
|
case "bypass" -> PermissionMode.BYPASS;
|
||||||
|
case "dont-ask", "dontask" -> PermissionMode.DONT_ASK;
|
||||||
|
case "plan" -> PermissionMode.PLAN;
|
||||||
|
default -> throw new IllegalArgumentException(value);
|
||||||
|
};
|
||||||
|
permissionSettings.setCurrentMode(mode);
|
||||||
|
return AnsiStyle.green(" ✅ Permission mode set to: " + mode);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return AnsiStyle.yellow(" ⚠ Unknown mode: " + value) + "\n"
|
||||||
|
+ AnsiStyle.dim(" Available: default, accept-edits, bypass, dont-ask, plan");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String listPermissionRules() {
|
||||||
|
if (permissionSettings == null) {
|
||||||
|
return AnsiStyle.yellow(" ⚠ Permission settings not initialized");
|
||||||
|
}
|
||||||
|
var rules = permissionSettings.listRules();
|
||||||
|
if (rules.isEmpty()) {
|
||||||
|
return AnsiStyle.dim(" No saved permission rules") + "\n"
|
||||||
|
+ AnsiStyle.dim(" Mode: " + permissionSettings.getCurrentMode());
|
||||||
|
}
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("\n");
|
||||||
|
sb.append(AnsiStyle.bold(" 🔒 Permission Rules")).append("\n");
|
||||||
|
sb.append(" ").append("─".repeat(40)).append("\n");
|
||||||
|
sb.append(" ").append(AnsiStyle.bold("Mode: ")).append(permissionSettings.getCurrentMode()).append("\n\n");
|
||||||
|
for (String rule : rules) {
|
||||||
|
sb.append(" ").append(rule).append("\n");
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resetPermissionRules() {
|
||||||
|
if (permissionSettings == null) {
|
||||||
|
return AnsiStyle.yellow(" ⚠ Permission settings not initialized");
|
||||||
|
}
|
||||||
|
permissionSettings.clearAll();
|
||||||
|
return AnsiStyle.green(" ✅ All permission rules cleared");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package com.claudecode.command.impl;
|
||||||
|
|
||||||
|
import com.claudecode.command.CommandContext;
|
||||||
|
import com.claudecode.command.BaseSlashCommand;
|
||||||
|
import com.claudecode.console.AnsiStyle;
|
||||||
|
import com.claudecode.context.ClaudeMdLoader;
|
||||||
|
import com.claudecode.context.GitContext;
|
||||||
|
import com.claudecode.context.SkillLoader;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /context 命令 —— 显示当前上下文信息。
|
||||||
|
* <p>
|
||||||
|
* 展示已加载的 CLAUDE.md、Skills、Git 上下文和 Token 预算使用情况。
|
||||||
|
*/
|
||||||
|
public class ContextCommand extends BaseSlashCommand {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return "context";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() {
|
||||||
|
return "Show current context (CLAUDE.md, skills, git)";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String execute(String args, CommandContext context) {
|
||||||
|
Path projectDir = Path.of(System.getProperty("user.dir"));
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("\n");
|
||||||
|
sb.append(AnsiStyle.bold(" 📋 Context Overview\n"));
|
||||||
|
sb.append(" ").append("─".repeat(40)).append("\n\n");
|
||||||
|
|
||||||
|
// CLAUDE.md 状态
|
||||||
|
sb.append(AnsiStyle.bold(" CLAUDE.md:\n"));
|
||||||
|
ClaudeMdLoader loader = new ClaudeMdLoader(projectDir);
|
||||||
|
String claudeMd = loader.load();
|
||||||
|
if (claudeMd.isEmpty()) {
|
||||||
|
sb.append(AnsiStyle.dim(" (none loaded) — run /init to create one\n"));
|
||||||
|
} else {
|
||||||
|
// 显示摘要(前 200 字符)
|
||||||
|
int lines = claudeMd.split("\n").length;
|
||||||
|
sb.append(" ").append(AnsiStyle.green(lines + " lines loaded")).append("\n");
|
||||||
|
String preview = claudeMd.length() > 200
|
||||||
|
? claudeMd.substring(0, 200) + "..."
|
||||||
|
: claudeMd;
|
||||||
|
for (String line : preview.split("\n")) {
|
||||||
|
sb.append(AnsiStyle.dim(" │ " + line)).append("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.append("\n");
|
||||||
|
|
||||||
|
// Skills 状态
|
||||||
|
sb.append(AnsiStyle.bold(" Skills:\n"));
|
||||||
|
SkillLoader skillLoader = new SkillLoader(projectDir);
|
||||||
|
var skills = skillLoader.loadAll();
|
||||||
|
if (skills.isEmpty()) {
|
||||||
|
sb.append(AnsiStyle.dim(" (none loaded) — add .md files to .claude/skills/\n"));
|
||||||
|
} else {
|
||||||
|
for (var skill : skills) {
|
||||||
|
sb.append(" • ").append(AnsiStyle.cyan(skill.name()));
|
||||||
|
if (!skill.description().isEmpty()) {
|
||||||
|
sb.append(AnsiStyle.dim(" — " + skill.description()));
|
||||||
|
}
|
||||||
|
sb.append(AnsiStyle.dim(" [" + skill.source() + "]")).append("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.append("\n");
|
||||||
|
|
||||||
|
// Git 上下文
|
||||||
|
sb.append(AnsiStyle.bold(" Git:\n"));
|
||||||
|
GitContext git = new GitContext(projectDir).collect();
|
||||||
|
if (!git.isGitRepo()) {
|
||||||
|
sb.append(AnsiStyle.dim(" (not a git repository)\n"));
|
||||||
|
} else {
|
||||||
|
sb.append(" Branch: ").append(AnsiStyle.cyan(git.getBranch() != null ? git.getBranch() : "unknown")).append("\n");
|
||||||
|
if (git.getStatus() != null) {
|
||||||
|
long modifiedCount = git.getStatus().lines()
|
||||||
|
.filter(l -> !l.startsWith("##"))
|
||||||
|
.count();
|
||||||
|
if (modifiedCount > 0) {
|
||||||
|
sb.append(" Modified: ").append(AnsiStyle.yellow(modifiedCount + " file(s)")).append("\n");
|
||||||
|
} else {
|
||||||
|
sb.append(" Working tree: ").append(AnsiStyle.green("clean")).append("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.append("\n");
|
||||||
|
|
||||||
|
// Token 预算
|
||||||
|
sb.append(AnsiStyle.bold(" System Prompt:\n"));
|
||||||
|
String sysPrompt = context.agentLoop().getSystemPrompt();
|
||||||
|
int charCount = sysPrompt.length();
|
||||||
|
int estimatedTokens = charCount / 4; // 粗略估算
|
||||||
|
sb.append(" Size: ").append(charCount).append(" chars (~").append(estimatedTokens).append(" tokens)\n");
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package com.claudecode.command.impl;
|
||||||
|
|
||||||
|
import com.claudecode.command.CommandContext;
|
||||||
|
import com.claudecode.command.BaseSlashCommand;
|
||||||
|
import com.claudecode.console.AnsiStyle;
|
||||||
|
import com.claudecode.core.TokenEstimationService;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /ctx-viz 命令 —— 上下文可视化(token 分布、消息结构)。
|
||||||
|
*/
|
||||||
|
public class ContextVizCommand extends BaseSlashCommand {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() { return "ctx-viz"; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() { return "Visualize context window token distribution"; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> aliases() { return List.of("context", "ctx"); }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String execute(String args, CommandContext context) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append(header("📊", "Context Window Visualization"));
|
||||||
|
|
||||||
|
if (requireAgentLoop(context) == null) {
|
||||||
|
return sb.append(" No active agent loop\n").toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
var toolCtx = toolCtx(context);
|
||||||
|
|
||||||
|
// Get or create token estimation service
|
||||||
|
TokenEstimationService estimator = new TokenEstimationService();
|
||||||
|
|
||||||
|
// Model context limit
|
||||||
|
String modelName = "claude-sonnet-4-20250514";
|
||||||
|
Object modelObj = toolCtx.get("MODEL_NAME");
|
||||||
|
if (modelObj instanceof String m) modelName = m;
|
||||||
|
|
||||||
|
int contextLimit = getContextLimit(modelName);
|
||||||
|
|
||||||
|
// Estimate system prompt tokens
|
||||||
|
int systemPromptTokens = 0;
|
||||||
|
Object sysMsgObj = toolCtx.get("SYSTEM_PROMPT_CACHE");
|
||||||
|
if (sysMsgObj instanceof String sysMsg) {
|
||||||
|
systemPromptTokens = estimator.estimateTokens(sysMsg);
|
||||||
|
} else {
|
||||||
|
systemPromptTokens = 4000; // typical estimate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool definitions estimate
|
||||||
|
int toolDefTokens = 0;
|
||||||
|
Object toolCountObj = toolCtx.get("TOOL_REGISTRY");
|
||||||
|
if (toolCountObj != null) {
|
||||||
|
toolDefTokens = 2000; // ~65 tokens per tool × 30 tools
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token tracker data
|
||||||
|
long inputTokens = 0;
|
||||||
|
long outputTokens = 0;
|
||||||
|
var tokenTracker = context.agentLoop().getTokenTracker();
|
||||||
|
if (tokenTracker != null) {
|
||||||
|
inputTokens = tokenTracker.getInputTokens();
|
||||||
|
outputTokens = tokenTracker.getOutputTokens();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate remaining
|
||||||
|
long usedTokens = systemPromptTokens + toolDefTokens + inputTokens;
|
||||||
|
long remainingTokens = Math.max(0, contextLimit - usedTokens);
|
||||||
|
double usagePercent = (double) usedTokens / contextLimit * 100;
|
||||||
|
|
||||||
|
// Display context bar
|
||||||
|
sb.append(AnsiStyle.bold(" Context Usage\n"));
|
||||||
|
int barWidth = 40;
|
||||||
|
int filled = (int) (usagePercent / 100 * barWidth);
|
||||||
|
filled = Math.min(filled, barWidth);
|
||||||
|
|
||||||
|
String barColor;
|
||||||
|
if (usagePercent > 90) barColor = AnsiStyle.red("█".repeat(filled));
|
||||||
|
else if (usagePercent > 70) barColor = AnsiStyle.yellow("█".repeat(filled));
|
||||||
|
else barColor = AnsiStyle.green("█".repeat(filled));
|
||||||
|
|
||||||
|
sb.append(" [").append(barColor).append("░".repeat(barWidth - filled)).append("] ");
|
||||||
|
sb.append(String.format("%.1f%%\n\n", usagePercent));
|
||||||
|
|
||||||
|
// Token breakdown
|
||||||
|
sb.append(AnsiStyle.bold(" Token Breakdown\n"));
|
||||||
|
sb.append(" ┌────────────────────────┬────────────┬───────┐\n");
|
||||||
|
sb.append(" │ Component │ Tokens │ % │\n");
|
||||||
|
sb.append(" ├────────────────────────┼────────────┼───────┤\n");
|
||||||
|
|
||||||
|
appendRow(sb, "System Prompt", systemPromptTokens, contextLimit);
|
||||||
|
appendRow(sb, "Tool Definitions", toolDefTokens, contextLimit);
|
||||||
|
appendRow(sb, "Conversation (input)", (int) inputTokens, contextLimit);
|
||||||
|
appendRow(sb, "Generated (output)", (int) outputTokens, contextLimit);
|
||||||
|
sb.append(" ├────────────────────────┼────────────┼───────┤\n");
|
||||||
|
appendRow(sb, "Total Used", (int) usedTokens, contextLimit);
|
||||||
|
appendRow(sb, "Remaining", (int) remainingTokens, contextLimit);
|
||||||
|
sb.append(" └────────────────────────┴────────────┴───────┘\n\n");
|
||||||
|
|
||||||
|
// Model info
|
||||||
|
sb.append(AnsiStyle.bold(" Model Info\n"));
|
||||||
|
sb.append(" Model: ").append(modelName).append("\n");
|
||||||
|
sb.append(" Context: ").append(estimator.formatTokenCount(contextLimit)).append(" tokens\n");
|
||||||
|
sb.append(" Cost est: $").append(String.format("%.4f",
|
||||||
|
estimator.estimateCost(inputTokens, outputTokens, modelName))).append("\n");
|
||||||
|
|
||||||
|
// Recommendations
|
||||||
|
if (usagePercent > 80) {
|
||||||
|
sb.append("\n ⚠️ ").append(AnsiStyle.yellow("Context is getting full. Consider /compact to free space.")).append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendRow(StringBuilder sb, String label, int tokens, int total) {
|
||||||
|
double pct = (double) tokens / total * 100;
|
||||||
|
sb.append(String.format(" │ %-22s │ %10s │ %5.1f │\n",
|
||||||
|
label, formatTokens(tokens), pct));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatTokens(int tokens) {
|
||||||
|
if (tokens >= 1_000_000) return String.format("%.1fM", tokens / 1_000_000.0);
|
||||||
|
if (tokens >= 1_000) return String.format("%.1fK", tokens / 1_000.0);
|
||||||
|
return String.valueOf(tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int getContextLimit(String model) {
|
||||||
|
if (model.contains("opus")) return 200_000;
|
||||||
|
if (model.contains("haiku")) return 200_000;
|
||||||
|
return 200_000; // All Claude 3+ models: 200K
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.claudecode.command.impl;
|
||||||
|
|
||||||
|
import com.claudecode.command.CommandContext;
|
||||||
|
import com.claudecode.command.BaseSlashCommand;
|
||||||
|
import com.claudecode.console.AnsiStyle;
|
||||||
|
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||||
|
import org.springframework.ai.chat.messages.Message;
|
||||||
|
|
||||||
|
import java.awt.Toolkit;
|
||||||
|
import java.awt.datatransfer.StringSelection;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /copy 命令 —— 将最近一次 AI 回复复制到系统剪贴板。
|
||||||
|
* <p>
|
||||||
|
* 从消息历史中提取最后一条 AssistantMessage 的文本内容,
|
||||||
|
* 使用 AWT 剪贴板 API 复制。
|
||||||
|
*/
|
||||||
|
public class CopyCommand extends BaseSlashCommand {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return "copy";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() {
|
||||||
|
return "Copy last AI response to clipboard";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String execute(String args, CommandContext context) {
|
||||||
|
// 从消息历史中查找最后一条助手消息
|
||||||
|
List<Message> history = context.agentLoop().getMessageHistory();
|
||||||
|
String lastResponse = null;
|
||||||
|
|
||||||
|
for (int i = history.size() - 1; i >= 0; i--) {
|
||||||
|
Message msg = history.get(i);
|
||||||
|
if (msg instanceof AssistantMessage assistant) {
|
||||||
|
String text = assistant.getText();
|
||||||
|
if (text != null && !text.isBlank()) {
|
||||||
|
lastResponse = text;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastResponse == null) {
|
||||||
|
return AnsiStyle.yellow(" ⚠ No AI response to copy");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 使用 AWT 剪贴板
|
||||||
|
StringSelection selection = new StringSelection(lastResponse);
|
||||||
|
Toolkit.getDefaultToolkit().getSystemClipboard().setContents(selection, selection);
|
||||||
|
|
||||||
|
int charCount = lastResponse.length();
|
||||||
|
int lineCount = (int) lastResponse.lines().count();
|
||||||
|
return AnsiStyle.green(" ✓ Copied to clipboard")
|
||||||
|
+ AnsiStyle.dim(" (" + charCount + " chars, " + lineCount + " lines)");
|
||||||
|
} catch (java.awt.HeadlessException e) {
|
||||||
|
// 无头环境(如 SSH)无法使用 AWT 剪贴板
|
||||||
|
return AnsiStyle.yellow(" ⚠ Clipboard not supported (headless mode)\n")
|
||||||
|
+ AnsiStyle.dim(" Tip: Run in a graphical terminal to use this feature");
|
||||||
|
} catch (Exception e) {
|
||||||
|
return AnsiStyle.red(" ✗ Copy failed: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.claudecode.command.impl;
|
||||||
|
|
||||||
|
import com.claudecode.command.CommandContext;
|
||||||
|
import com.claudecode.command.CommandUtils;
|
||||||
|
import com.claudecode.command.BaseSlashCommand;
|
||||||
|
import com.claudecode.console.AnsiStyle;
|
||||||
|
import com.claudecode.core.TokenTracker;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /cost 命令 —— 显示 Token 使用量和费用估算。
|
||||||
|
* <p>
|
||||||
|
* 对应 claude-code/src/commands/cost.ts。
|
||||||
|
* 从 AgentLoop 的 TokenTracker 获取真实 Token 统计。
|
||||||
|
*/
|
||||||
|
public class CostCommand extends BaseSlashCommand {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return "cost";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() {
|
||||||
|
return "Show token usage and cost";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String execute(String args, CommandContext context) {
|
||||||
|
TokenTracker tracker = context.agentLoop().getTokenTracker();
|
||||||
|
int msgCount = context.agentLoop().getMessageHistory().size();
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append(header("💰", "Token Usage & Cost"));
|
||||||
|
|
||||||
|
sb.append(" ").append(AnsiStyle.bold("Model: ")).append(AnsiStyle.cyan(tracker.getModelName())).append("\n");
|
||||||
|
sb.append(" ").append(AnsiStyle.bold("API Calls: ")).append(tracker.getApiCallCount()).append("\n");
|
||||||
|
sb.append(" ").append(AnsiStyle.bold("Messages: ")).append(msgCount).append("\n\n");
|
||||||
|
|
||||||
|
sb.append(" ").append(AnsiStyle.bold("Input tokens: ")).append(formatTokenLine(tracker.getInputTokens())).append("\n");
|
||||||
|
sb.append(" ").append(AnsiStyle.bold("Output tokens:")).append(formatTokenLine(tracker.getOutputTokens())).append("\n");
|
||||||
|
|
||||||
|
if (tracker.getCacheReadTokens() > 0) {
|
||||||
|
sb.append(" ").append(AnsiStyle.bold("Cache read: ")).append(formatTokenLine(tracker.getCacheReadTokens())).append("\n");
|
||||||
|
}
|
||||||
|
if (tracker.getCacheCreationTokens() > 0) {
|
||||||
|
sb.append(" ").append(AnsiStyle.bold("Cache create: ")).append(formatTokenLine(tracker.getCacheCreationTokens())).append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.append(CommandUtils.separator(30)).append("\n");
|
||||||
|
sb.append(" ").append(AnsiStyle.bold("Total: ")).append(TokenTracker.formatTokens(tracker.getTotalTokens())).append(" tokens\n");
|
||||||
|
sb.append(" ").append(AnsiStyle.bold("Est. Cost: ")).append(AnsiStyle.green("$" + String.format("%.4f", tracker.estimateCost()))).append("\n");
|
||||||
|
|
||||||
|
if (tracker.getApiCallCount() == 0) {
|
||||||
|
sb.append("\n").append(AnsiStyle.dim(" No API calls yet. Start a conversation to see usage."));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatTokenLine(long tokens) {
|
||||||
|
return " " + TokenTracker.formatTokens(tokens) + AnsiStyle.dim(" (" + tokens + ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package com.claudecode.command.impl;
|
||||||
|
|
||||||
|
import com.claudecode.command.CommandContext;
|
||||||
|
import com.claudecode.command.BaseSlashCommand;
|
||||||
|
import com.claudecode.console.AnsiStyle;
|
||||||
|
import com.claudecode.core.InternalLogger;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /debug 命令 —— 调试模式开关 + 工具调用追踪。
|
||||||
|
*/
|
||||||
|
public class DebugCommand extends BaseSlashCommand {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() { return "debug"; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() { return "Toggle debug mode and view internal logs"; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> aliases() { return List.of("dbg"); }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String execute(String args, CommandContext context) {
|
||||||
|
String trimmed = args(args);
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append(header("🐛", "Debug Mode"));
|
||||||
|
|
||||||
|
if (requireAgentLoop(context) == null) {
|
||||||
|
return sb.append(" No active agent loop\n").toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
var toolCtx = toolCtx(context);
|
||||||
|
|
||||||
|
if (trimmed.equals("on") || trimmed.equals("enable")) {
|
||||||
|
toolCtx.set("DEBUG_MODE", true);
|
||||||
|
sb.append(" Debug mode: ").append(AnsiStyle.green("ENABLED")).append("\n");
|
||||||
|
sb.append(" Tool call tracing is now active.\n");
|
||||||
|
|
||||||
|
// Set InternalLogger to DEBUG level if available
|
||||||
|
Object loggerObj = toolCtx.get("INTERNAL_LOGGER");
|
||||||
|
if (loggerObj instanceof InternalLogger logger) {
|
||||||
|
logger.setLevel(InternalLogger.Level.DEBUG);
|
||||||
|
sb.append(" Internal log level: DEBUG\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (trimmed.equals("off") || trimmed.equals("disable")) {
|
||||||
|
toolCtx.set("DEBUG_MODE", false);
|
||||||
|
sb.append(" Debug mode: ").append(AnsiStyle.red("DISABLED")).append("\n");
|
||||||
|
|
||||||
|
Object loggerObj = toolCtx.get("INTERNAL_LOGGER");
|
||||||
|
if (loggerObj instanceof InternalLogger logger) {
|
||||||
|
logger.setLevel(InternalLogger.Level.NORMAL);
|
||||||
|
sb.append(" Internal log level: NORMAL\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (trimmed.startsWith("log")) {
|
||||||
|
// /debug logs [N] — show recent internal logs
|
||||||
|
int count = 20;
|
||||||
|
String[] parts = trimmed.split("\\s+");
|
||||||
|
if (parts.length > 1) {
|
||||||
|
try { count = Integer.parseInt(parts[1]); } catch (NumberFormatException ignored) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object loggerObj = toolCtx.get("INTERNAL_LOGGER");
|
||||||
|
if (loggerObj instanceof InternalLogger logger) {
|
||||||
|
sb.append(AnsiStyle.bold(" Recent Logs (last " + count + ")\n\n"));
|
||||||
|
String logs = logger.getRecent(count);
|
||||||
|
if (logs.isEmpty()) {
|
||||||
|
sb.append(" No logs recorded yet.\n");
|
||||||
|
} else {
|
||||||
|
sb.append(AnsiStyle.dim(logs));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sb.append(" InternalLogger not available.\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (trimmed.equals("tools")) {
|
||||||
|
// /debug tools — show tool call stats from ToolContext
|
||||||
|
sb.append(AnsiStyle.bold(" Tool Call Tracing\n\n"));
|
||||||
|
Object metrics = toolCtx.get("METRICS_COLLECTOR");
|
||||||
|
if (metrics != null) {
|
||||||
|
sb.append(" Use /performance for detailed tool stats.\n");
|
||||||
|
} else {
|
||||||
|
sb.append(" No metrics collector available.\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Status
|
||||||
|
boolean debugOn = Boolean.TRUE.equals(toolCtx.get("DEBUG_MODE"));
|
||||||
|
sb.append(" Status: ").append(debugOn
|
||||||
|
? AnsiStyle.green("ENABLED") : AnsiStyle.dim("disabled")).append("\n\n");
|
||||||
|
|
||||||
|
sb.append(AnsiStyle.bold(" Subcommands\n"));
|
||||||
|
sb.append(" /debug on Enable debug mode\n");
|
||||||
|
sb.append(" /debug off Disable debug mode\n");
|
||||||
|
sb.append(" /debug logs Show recent internal logs\n");
|
||||||
|
sb.append(" /debug logs 50 Show last 50 log entries\n");
|
||||||
|
sb.append(" /debug tools Show tool call tracing info\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package com.claudecode.command.impl;
|
||||||
|
|
||||||
|
import com.claudecode.command.CommandContext;
|
||||||
|
import com.claudecode.command.BaseSlashCommand;
|
||||||
|
import com.claudecode.console.AnsiStyle;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /diff 命令 —— 显示 Git 未提交的变更。
|
||||||
|
* <p>
|
||||||
|
* 对应 claude-code 的 /diff 命令,展示工作区的变更内容:
|
||||||
|
* <ul>
|
||||||
|
* <li>无参数:显示所有未暂存变更</li>
|
||||||
|
* <li>--staged:显示已暂存变更</li>
|
||||||
|
* <li>--stat:仅显示文件统计(不含详细diff)</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public class DiffCommand extends BaseSlashCommand {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return "diff";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() {
|
||||||
|
return "Show uncommitted git changes";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String execute(String args, CommandContext context) {
|
||||||
|
Path projectDir = Path.of(System.getProperty("user.dir"));
|
||||||
|
if (!Files.isDirectory(projectDir.resolve(".git"))) {
|
||||||
|
return AnsiStyle.yellow(" ⚠ Current directory is not a Git repository");
|
||||||
|
}
|
||||||
|
|
||||||
|
args = args(args);
|
||||||
|
|
||||||
|
try {
|
||||||
|
String diffOutput;
|
||||||
|
String header;
|
||||||
|
|
||||||
|
if (args.contains("--staged")) {
|
||||||
|
diffOutput = runGit(projectDir, "diff", "--staged", "--color=always");
|
||||||
|
header = "Staged Changes";
|
||||||
|
} else if (args.contains("--stat")) {
|
||||||
|
diffOutput = runGit(projectDir, "diff", "--stat", "--color=always");
|
||||||
|
header = "Changes (stat)";
|
||||||
|
} else {
|
||||||
|
// 默认:显示所有变更(未暂存 + 已暂存的统计 + 未跟踪文件)
|
||||||
|
String unstaged = runGit(projectDir, "diff", "--color=always");
|
||||||
|
String staged = runGit(projectDir, "diff", "--staged", "--stat");
|
||||||
|
String untracked = runGit(projectDir, "ls-files", "--others", "--exclude-standard");
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("\n").append(AnsiStyle.bold(" 📋 Git Diff\n"));
|
||||||
|
sb.append(" ").append("─".repeat(50)).append("\n");
|
||||||
|
|
||||||
|
if (!staged.isBlank()) {
|
||||||
|
sb.append("\n").append(AnsiStyle.green(" ▸ Staged:\n"));
|
||||||
|
staged.lines().forEach(l -> sb.append(" ").append(l).append("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!unstaged.isBlank()) {
|
||||||
|
sb.append("\n").append(AnsiStyle.yellow(" ▸ Unstaged changes:\n"));
|
||||||
|
// 限制行数避免输出过长
|
||||||
|
long lineCount = unstaged.lines().count();
|
||||||
|
if (lineCount > 100) {
|
||||||
|
unstaged.lines().limit(100).forEach(l -> sb.append(" ").append(l).append("\n"));
|
||||||
|
sb.append(AnsiStyle.dim(" ... (" + lineCount + " lines total, showing first 100)\n"));
|
||||||
|
} else {
|
||||||
|
unstaged.lines().forEach(l -> sb.append(" ").append(l).append("\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!untracked.isBlank()) {
|
||||||
|
sb.append("\n").append(AnsiStyle.red(" ▸ Untracked files:\n"));
|
||||||
|
untracked.lines().forEach(l -> sb.append(" ").append(l).append("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (staged.isBlank() && unstaged.isBlank() && untracked.isBlank()) {
|
||||||
|
sb.append("\n").append(AnsiStyle.green(" ✓ Working directory clean, no changes\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --staged 或 --stat 模式
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("\n").append(AnsiStyle.bold(" 📋 " + header + "\n"));
|
||||||
|
sb.append(" ").append("─".repeat(50)).append("\n\n");
|
||||||
|
|
||||||
|
if (diffOutput.isBlank()) {
|
||||||
|
sb.append(AnsiStyle.green(" ✓ No changes\n"));
|
||||||
|
} else {
|
||||||
|
long lineCount = diffOutput.lines().count();
|
||||||
|
if (lineCount > 100) {
|
||||||
|
diffOutput.lines().limit(100).forEach(l -> sb.append(" ").append(l).append("\n"));
|
||||||
|
sb.append(AnsiStyle.dim(" ... (" + lineCount + " lines)\n"));
|
||||||
|
} else {
|
||||||
|
diffOutput.lines().forEach(l -> sb.append(" ").append(l).append("\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
return AnsiStyle.red(" ✗ Git diff failed: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String runGit(Path dir, String... args) throws Exception {
|
||||||
|
var command = new java.util.ArrayList<String>();
|
||||||
|
command.add("git");
|
||||||
|
command.add("--no-pager");
|
||||||
|
command.addAll(java.util.List.of(args));
|
||||||
|
|
||||||
|
ProcessBuilder pb = new ProcessBuilder(command);
|
||||||
|
pb.directory(dir.toFile());
|
||||||
|
pb.redirectErrorStream(true);
|
||||||
|
|
||||||
|
Process process = pb.start();
|
||||||
|
StringBuilder output = new StringBuilder();
|
||||||
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
output.append(line).append("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.waitFor(10, TimeUnit.SECONDS);
|
||||||
|
return output.toString().stripTrailing();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package com.claudecode.command.impl;
|
||||||
|
|
||||||
|
import com.claudecode.command.CommandContext;
|
||||||
|
import com.claudecode.command.BaseSlashCommand;
|
||||||
|
import com.claudecode.console.AnsiStyle;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /doctor 命令 —— 环境诊断。
|
||||||
|
* <p>
|
||||||
|
* 对应 claude-code 的 /doctor 命令,检查运行环境是否配置正确。
|
||||||
|
* 检查项:
|
||||||
|
* <ul>
|
||||||
|
* <li>Java 版本</li>
|
||||||
|
* <li>Git 版本和配置</li>
|
||||||
|
* <li>环境变量(API key 等)</li>
|
||||||
|
* <li>配置文件状态</li>
|
||||||
|
* <li>MCP 服务器连接</li>
|
||||||
|
* <li>磁盘空间</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public class DoctorCommand extends BaseSlashCommand {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return "doctor";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() {
|
||||||
|
return "Run environment diagnostics";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> aliases() {
|
||||||
|
return List.of("diag", "check");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String execute(String args, CommandContext context) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("\n");
|
||||||
|
sb.append(AnsiStyle.bold(" 🩺 Environment Diagnostics\n"));
|
||||||
|
sb.append(" ").append("═".repeat(50)).append("\n\n");
|
||||||
|
|
||||||
|
int passed = 0, warned = 0, failed = 0;
|
||||||
|
|
||||||
|
// Java Version
|
||||||
|
String javaVersion = System.getProperty("java.version");
|
||||||
|
String javaVendor = System.getProperty("java.vendor");
|
||||||
|
boolean javaOk = javaVersion != null && (javaVersion.startsWith("21")
|
||||||
|
|| javaVersion.startsWith("22") || javaVersion.startsWith("23")
|
||||||
|
|| javaVersion.startsWith("24") || javaVersion.startsWith("25"));
|
||||||
|
sb.append(check("Java", javaVersion + " (" + javaVendor + ")", javaOk));
|
||||||
|
if (javaOk) passed++; else warned++;
|
||||||
|
|
||||||
|
// OS Info
|
||||||
|
String osName = System.getProperty("os.name");
|
||||||
|
String osVersion = System.getProperty("os.version");
|
||||||
|
String osArch = System.getProperty("os.arch");
|
||||||
|
sb.append(check("OS", osName + " " + osVersion + " (" + osArch + ")", true));
|
||||||
|
passed++;
|
||||||
|
|
||||||
|
// Working Directory
|
||||||
|
String workDir = System.getProperty("user.dir");
|
||||||
|
sb.append(check("Working Dir", workDir, true));
|
||||||
|
passed++;
|
||||||
|
|
||||||
|
// Git
|
||||||
|
String gitVersion = runCommand("git", "--version");
|
||||||
|
boolean gitOk = gitVersion != null && gitVersion.contains("git version");
|
||||||
|
sb.append(check("Git", gitOk ? gitVersion.strip() : "not found", gitOk));
|
||||||
|
if (gitOk) passed++; else failed++;
|
||||||
|
|
||||||
|
// Git config
|
||||||
|
if (gitOk) {
|
||||||
|
String userName = runCommand("git", "config", "user.name");
|
||||||
|
String userEmail = runCommand("git", "config", "user.email");
|
||||||
|
boolean configOk = userName != null && !userName.isBlank()
|
||||||
|
&& userEmail != null && !userEmail.isBlank();
|
||||||
|
String configInfo = configOk
|
||||||
|
? userName.strip() + " <" + userEmail.strip() + ">"
|
||||||
|
: "user.name or user.email not set";
|
||||||
|
sb.append(check("Git Config", configInfo, configOk));
|
||||||
|
if (configOk) passed++; else warned++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Key
|
||||||
|
boolean hasAnthropicKey = System.getenv("ANTHROPIC_API_KEY") != null;
|
||||||
|
boolean hasOpenAiKey = System.getenv("OPENAI_API_KEY") != null;
|
||||||
|
boolean hasApiKey = hasAnthropicKey || hasOpenAiKey;
|
||||||
|
String apiKeyInfo = hasAnthropicKey ? "ANTHROPIC_API_KEY set"
|
||||||
|
: hasOpenAiKey ? "OPENAI_API_KEY set" : "No API key found";
|
||||||
|
sb.append(check("API Key", apiKeyInfo, hasApiKey));
|
||||||
|
if (hasApiKey) passed++; else failed++;
|
||||||
|
|
||||||
|
// CLAUDE.md
|
||||||
|
boolean hasClaudeMd = new File(workDir, "CLAUDE.md").exists();
|
||||||
|
sb.append(check("CLAUDE.md", hasClaudeMd ? "found" : "not found (optional)", true));
|
||||||
|
passed++;
|
||||||
|
|
||||||
|
// Config directory
|
||||||
|
String userHome = System.getProperty("user.home");
|
||||||
|
File configDir = new File(userHome, ".claude-code");
|
||||||
|
boolean hasConfigDir = configDir.exists() && configDir.isDirectory();
|
||||||
|
sb.append(check("Config Dir", configDir.getPath()
|
||||||
|
+ (hasConfigDir ? " (exists)" : " (will be created)"), true));
|
||||||
|
passed++;
|
||||||
|
|
||||||
|
// MCP config
|
||||||
|
boolean hasProjectMcp = new File(workDir, ".mcp.json").exists();
|
||||||
|
boolean hasGlobalMcp = new File(userHome, ".claude-code-java/mcp.json").exists();
|
||||||
|
String mcpInfo = hasProjectMcp ? ".mcp.json found (project)"
|
||||||
|
: hasGlobalMcp ? "mcp.json found (global)" : "no config";
|
||||||
|
sb.append(check("MCP Config", mcpInfo, true));
|
||||||
|
passed++;
|
||||||
|
|
||||||
|
// Disk Space
|
||||||
|
File root = new File(workDir);
|
||||||
|
long freeSpace = root.getFreeSpace();
|
||||||
|
long totalSpace = root.getTotalSpace();
|
||||||
|
double freeGb = freeSpace / (1024.0 * 1024.0 * 1024.0);
|
||||||
|
double totalGb = totalSpace / (1024.0 * 1024.0 * 1024.0);
|
||||||
|
boolean diskOk = freeGb > 1.0;
|
||||||
|
sb.append(check("Disk Space", String.format("%.1f GB free / %.1f GB total", freeGb, totalGb), diskOk));
|
||||||
|
if (diskOk) passed++; else warned++;
|
||||||
|
|
||||||
|
// Memory
|
||||||
|
Runtime runtime = Runtime.getRuntime();
|
||||||
|
long maxMem = runtime.maxMemory() / (1024 * 1024);
|
||||||
|
long usedMem = (runtime.totalMemory() - runtime.freeMemory()) / (1024 * 1024);
|
||||||
|
sb.append(check("JVM Memory", usedMem + " MB used / " + maxMem + " MB max", maxMem > 256));
|
||||||
|
passed++;
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
sb.append("\n ").append("─".repeat(50)).append("\n");
|
||||||
|
sb.append(" ").append(AnsiStyle.bold("Summary: "));
|
||||||
|
sb.append(AnsiStyle.green(passed + " passed"));
|
||||||
|
if (warned > 0) sb.append(", ").append(AnsiStyle.yellow(warned + " warnings"));
|
||||||
|
if (failed > 0) sb.append(", ").append(AnsiStyle.red(failed + " failed"));
|
||||||
|
sb.append("\n");
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String check(String label, String value, boolean ok) {
|
||||||
|
String icon = ok ? AnsiStyle.green("✓") : AnsiStyle.red("✗");
|
||||||
|
return " " + icon + " " + AnsiStyle.bold(String.format("%-14s", label))
|
||||||
|
+ " " + value + "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String runCommand(String... cmd) {
|
||||||
|
try {
|
||||||
|
ProcessBuilder pb = new ProcessBuilder(cmd);
|
||||||
|
pb.redirectErrorStream(true);
|
||||||
|
Process p = pb.start();
|
||||||
|
String output = new String(p.getInputStream().readAllBytes()).strip();
|
||||||
|
int exit = p.waitFor();
|
||||||
|
return exit == 0 ? output : null;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.claudecode.command.impl;
|
||||||
|
|
||||||
|
import com.claudecode.command.CommandContext;
|
||||||
|
import com.claudecode.command.CommandUtils;
|
||||||
|
import com.claudecode.command.BaseSlashCommand;
|
||||||
|
import com.claudecode.console.AnsiStyle;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.TreeMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /env 命令 —— 显示环境变量和配置信息。
|
||||||
|
*/
|
||||||
|
public class EnvCommand extends BaseSlashCommand {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() { return "env"; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() { return "Show environment variables and configuration"; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String execute(String args, CommandContext context) {
|
||||||
|
String trimmed = args(args);
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append(header("🔧", "Environment"));
|
||||||
|
|
||||||
|
sb.append(subtitle("System")).append("\n");
|
||||||
|
sb.append(" OS: ").append(System.getProperty("os.name")).append(" ")
|
||||||
|
.append(System.getProperty("os.version")).append("\n");
|
||||||
|
sb.append(" Java: ").append(System.getProperty("java.version"))
|
||||||
|
.append(" (").append(System.getProperty("java.vendor")).append(")\n");
|
||||||
|
sb.append(" JVM: ").append(System.getProperty("java.vm.name")).append("\n");
|
||||||
|
sb.append(" Heap: ").append(CommandUtils.formatBytes(Runtime.getRuntime().totalMemory()))
|
||||||
|
.append(" / ").append(CommandUtils.formatBytes(Runtime.getRuntime().maxMemory())).append("\n");
|
||||||
|
sb.append(" PID: ").append(ProcessHandle.current().pid()).append("\n\n");
|
||||||
|
|
||||||
|
sb.append(subtitle("Paths")).append("\n");
|
||||||
|
sb.append(" WorkDir: ").append(System.getProperty("user.dir")).append("\n");
|
||||||
|
sb.append(" Home: ").append(System.getProperty("user.home")).append("\n");
|
||||||
|
sb.append(" Config: ").append(System.getProperty("user.home"))
|
||||||
|
.append(File.separator).append(".claude-code-java").append("\n\n");
|
||||||
|
|
||||||
|
sb.append(subtitle("Environment Variables")).append("\n");
|
||||||
|
List<String> relevantVars = List.of(
|
||||||
|
"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "CLAUDE_CODE_",
|
||||||
|
"JAVA_HOME", "PATH", "SHELL", "TERM", "EDITOR"
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, String> env = new TreeMap<>(System.getenv());
|
||||||
|
for (Map.Entry<String, String> entry : env.entrySet()) {
|
||||||
|
String key = entry.getKey();
|
||||||
|
boolean show = relevantVars.stream().anyMatch(p -> key.startsWith(p) || key.equals(p));
|
||||||
|
if (!show && !trimmed.equals("all")) continue;
|
||||||
|
|
||||||
|
String value = entry.getValue();
|
||||||
|
if (key.contains("KEY") || key.contains("SECRET") || key.contains("TOKEN")) {
|
||||||
|
value = value.length() > 8 ? value.substring(0, 4) + "****" + value.substring(value.length() - 4) : "****";
|
||||||
|
}
|
||||||
|
value = CommandUtils.truncate(value, 80);
|
||||||
|
sb.append(" ").append(AnsiStyle.cyan(key)).append("=").append(value).append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trimmed.equals("all")) {
|
||||||
|
sb.append("\n").append(AnsiStyle.dim(" Run /env all to show all environment variables"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.claudecode.command.impl;
|
||||||
|
|
||||||
|
import com.claudecode.command.CommandContext;
|
||||||
|
import com.claudecode.command.BaseSlashCommand;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /exit 命令 —— 退出应用。
|
||||||
|
*/
|
||||||
|
public class ExitCommand extends BaseSlashCommand {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return "exit";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() {
|
||||||
|
return "Exit the application";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> aliases() {
|
||||||
|
return List.of("quit", "q");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String execute(String args, CommandContext context) {
|
||||||
|
if (context.exitCallback() != null) {
|
||||||
|
context.exitCallback().run();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package com.claudecode.command.impl;
|
||||||
|
|
||||||
|
import com.claudecode.command.CommandContext;
|
||||||
|
import com.claudecode.command.BaseSlashCommand;
|
||||||
|
import com.claudecode.console.AnsiStyle;
|
||||||
|
import org.springframework.ai.chat.messages.*;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /export 命令 —— 将对话历史导出为 Markdown 文件。
|
||||||
|
* <p>
|
||||||
|
* 支持格式:
|
||||||
|
* <ul>
|
||||||
|
* <li>/export —— 导出到当前目录的 conversation-时间戳.md</li>
|
||||||
|
* <li>/export [路径] —— 导出到指定路径</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public class ExportCommand extends BaseSlashCommand {
|
||||||
|
|
||||||
|
private static final DateTimeFormatter TIMESTAMP_FMT = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss");
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return "export";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() {
|
||||||
|
return "Export conversation to Markdown file";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String execute(String args, CommandContext context) {
|
||||||
|
List<Message> history = context.agentLoop().getMessageHistory();
|
||||||
|
|
||||||
|
// 至少需要系统提示 + 用户消息 + 助手回复
|
||||||
|
if (history.size() < 3) {
|
||||||
|
return AnsiStyle.yellow(" ⚠ Not enough conversation content to export");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确定输出路径
|
||||||
|
args = args(args);
|
||||||
|
Path outputPath;
|
||||||
|
if (!args.isEmpty()) {
|
||||||
|
outputPath = Path.of(args);
|
||||||
|
if (!outputPath.isAbsolute()) {
|
||||||
|
outputPath = Path.of(System.getProperty("user.dir")).resolve(outputPath);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String timestamp = TIMESTAMP_FMT.format(LocalDateTime.now());
|
||||||
|
outputPath = Path.of(System.getProperty("user.dir"), "conversation-" + timestamp + ".md");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成 Markdown 内容
|
||||||
|
String markdown = generateMarkdown(history);
|
||||||
|
|
||||||
|
try {
|
||||||
|
Files.createDirectories(outputPath.getParent());
|
||||||
|
Files.writeString(outputPath, markdown, StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
int msgCount = history.size();
|
||||||
|
int lineCount = (int) markdown.lines().count();
|
||||||
|
return AnsiStyle.green(" ✓ Conversation exported: " + outputPath)
|
||||||
|
+ AnsiStyle.dim(" (" + msgCount + " messages, " + lineCount + " lines)");
|
||||||
|
} catch (IOException e) {
|
||||||
|
return AnsiStyle.red(" ✗ Export failed: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateMarkdown(List<Message> history) {
|
||||||
|
StringBuilder md = new StringBuilder();
|
||||||
|
md.append("# Claude Code Java - Conversation Export\n\n");
|
||||||
|
md.append("- **Exported at**: ").append(LocalDateTime.now()).append("\n");
|
||||||
|
md.append("- **Working directory**: ").append(System.getProperty("user.dir")).append("\n");
|
||||||
|
md.append("- **Messages**: ").append(history.size()).append("\n\n");
|
||||||
|
md.append("---\n\n");
|
||||||
|
|
||||||
|
for (Message msg : history) {
|
||||||
|
switch (msg) {
|
||||||
|
case SystemMessage sm -> {
|
||||||
|
md.append("## 🔧 System Prompt\n\n");
|
||||||
|
md.append("<details>\n<summary>Click to expand system prompt</summary>\n\n");
|
||||||
|
md.append(sm.getText()).append("\n\n");
|
||||||
|
md.append("</details>\n\n---\n\n");
|
||||||
|
}
|
||||||
|
case UserMessage um -> {
|
||||||
|
md.append("## 👤 User\n\n");
|
||||||
|
md.append(um.getText()).append("\n\n");
|
||||||
|
}
|
||||||
|
case AssistantMessage am -> {
|
||||||
|
md.append("## 🤖 Assistant\n\n");
|
||||||
|
if (am.getText() != null && !am.getText().isBlank()) {
|
||||||
|
md.append(am.getText()).append("\n\n");
|
||||||
|
}
|
||||||
|
if (am.hasToolCalls()) {
|
||||||
|
md.append("### Tool Calls\n\n");
|
||||||
|
for (var tc : am.getToolCalls()) {
|
||||||
|
md.append("- **").append(tc.name()).append("**");
|
||||||
|
if (tc.arguments() != null) {
|
||||||
|
md.append("\n ```json\n ").append(tc.arguments()).append("\n ```");
|
||||||
|
}
|
||||||
|
md.append("\n");
|
||||||
|
}
|
||||||
|
md.append("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case ToolResponseMessage trm -> {
|
||||||
|
md.append("### 🔨 Tool Results\n\n");
|
||||||
|
for (var resp : trm.getResponses()) {
|
||||||
|
md.append("**").append(resp.name()).append("**:\n");
|
||||||
|
String data = resp.responseData();
|
||||||
|
if (data != null) {
|
||||||
|
// 截断过长的工具输出
|
||||||
|
if (data.length() > 2000) {
|
||||||
|
data = data.substring(0, 2000) + "\n... (truncated)";
|
||||||
|
}
|
||||||
|
md.append("```\n").append(data).append("\n```\n\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return md.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.claudecode.command.impl;
|
||||||
|
|
||||||
|
import com.claudecode.command.CommandContext;
|
||||||
|
import com.claudecode.command.BaseSlashCommand;
|
||||||
|
import com.claudecode.console.AnsiStyle;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /feedback 命令 —— 提交反馈(本地保存)。
|
||||||
|
*/
|
||||||
|
public class FeedbackCommand extends BaseSlashCommand {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() { return "feedback"; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() { return "Submit feedback (saved locally)"; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String execute(String args, CommandContext context) {
|
||||||
|
String trimmed = args(args);
|
||||||
|
|
||||||
|
if (trimmed.isEmpty()) {
|
||||||
|
return AnsiStyle.yellow(" Usage: /feedback <your feedback text>")
|
||||||
|
+ "\n" + AnsiStyle.dim(" Feedback is saved locally to ~/.claude-code-java/feedback/");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Path feedbackDir = Path.of(System.getProperty("user.home"),
|
||||||
|
".claude-code-java", "feedback");
|
||||||
|
Files.createDirectories(feedbackDir);
|
||||||
|
|
||||||
|
String timestamp = Instant.now().toString().replaceAll("[:]", "-");
|
||||||
|
Path feedbackFile = feedbackDir.resolve("feedback-" + timestamp + ".txt");
|
||||||
|
|
||||||
|
StringBuilder content = new StringBuilder();
|
||||||
|
content.append("Timestamp: ").append(Instant.now()).append("\n");
|
||||||
|
content.append("OS: ").append(System.getProperty("os.name")).append("\n");
|
||||||
|
content.append("Java: ").append(System.getProperty("java.version")).append("\n");
|
||||||
|
content.append("WorkDir: ").append(System.getProperty("user.dir")).append("\n");
|
||||||
|
content.append("---\n");
|
||||||
|
content.append(trimmed).append("\n");
|
||||||
|
|
||||||
|
Files.writeString(feedbackFile, content.toString());
|
||||||
|
|
||||||
|
return AnsiStyle.green(" ✓ Feedback saved: " + feedbackFile.getFileName())
|
||||||
|
+ "\n" + AnsiStyle.dim(" Thank you for your feedback!");
|
||||||
|
} catch (IOException e) {
|
||||||
|
return AnsiStyle.red(" ✗ Failed to save feedback: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package com.claudecode.command.impl;
|
||||||
|
|
||||||
|
import com.claudecode.command.CommandContext;
|
||||||
|
import com.claudecode.command.BaseSlashCommand;
|
||||||
|
import com.claudecode.console.AnsiStyle;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /files 命令 —— 列出当前工作目录的文件结构。
|
||||||
|
* <p>
|
||||||
|
* 对应 claude-code/src/commands/files.ts。
|
||||||
|
* 显示项目目录树(默认2层深度)。
|
||||||
|
*/
|
||||||
|
public class FilesCommand extends BaseSlashCommand {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() {
|
||||||
|
return "files";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() {
|
||||||
|
return "List project files. Use /files [depth] to control depth (default: 2)";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String execute(String args, CommandContext context) {
|
||||||
|
Path projectDir = Path.of(System.getProperty("user.dir"));
|
||||||
|
int maxDepth = 2;
|
||||||
|
|
||||||
|
if (args != null && !args.isBlank()) {
|
||||||
|
try {
|
||||||
|
maxDepth = Integer.parseInt(args.strip());
|
||||||
|
maxDepth = Math.max(1, Math.min(maxDepth, 5));
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
// Treat as path filter
|
||||||
|
return listFiltered(projectDir, args.strip());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("\n ").append(AnsiStyle.bold("📁 " + projectDir.getFileName())).append("\n");
|
||||||
|
sb.append(" ").append("─".repeat(50)).append("\n");
|
||||||
|
|
||||||
|
try {
|
||||||
|
int[] counts = {0, 0}; // [files, dirs]
|
||||||
|
listDirectory(sb, projectDir, "", maxDepth, 0, counts);
|
||||||
|
sb.append(" ").append("─".repeat(50)).append("\n");
|
||||||
|
sb.append(" ").append(AnsiStyle.dim(counts[1] + " directories, " + counts[0] + " files")).append("\n");
|
||||||
|
} catch (IOException e) {
|
||||||
|
sb.append(" ").append(AnsiStyle.red("Error: " + e.getMessage())).append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void listDirectory(StringBuilder sb, Path dir, String indent, int maxDepth, int depth, int[] counts) throws IOException {
|
||||||
|
if (depth >= maxDepth) return;
|
||||||
|
|
||||||
|
try (Stream<Path> stream = Files.list(dir).sorted()) {
|
||||||
|
var entries = stream
|
||||||
|
.filter(p -> !isHidden(p))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
for (int i = 0; i < entries.size(); i++) {
|
||||||
|
Path entry = entries.get(i);
|
||||||
|
boolean isLast = (i == entries.size() - 1);
|
||||||
|
String connector = isLast ? "└── " : "├── ";
|
||||||
|
String childIndent = indent + (isLast ? " " : "│ ");
|
||||||
|
String name = entry.getFileName().toString();
|
||||||
|
|
||||||
|
if (Files.isDirectory(entry)) {
|
||||||
|
counts[1]++;
|
||||||
|
sb.append(" ").append(indent).append(connector)
|
||||||
|
.append(AnsiStyle.CYAN).append(name).append("/").append(AnsiStyle.RESET).append("\n");
|
||||||
|
listDirectory(sb, entry, childIndent, maxDepth, depth + 1, counts);
|
||||||
|
} else {
|
||||||
|
counts[0]++;
|
||||||
|
String sizeStr = formatSize(Files.size(entry));
|
||||||
|
sb.append(" ").append(indent).append(connector).append(name)
|
||||||
|
.append(AnsiStyle.dim(" (" + sizeStr + ")")).append("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String listFiltered(Path dir, String pattern) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("\n ").append(AnsiStyle.bold("Files matching \"" + pattern + "\":")).append("\n\n");
|
||||||
|
|
||||||
|
try (Stream<Path> walk = Files.walk(dir, 5)) {
|
||||||
|
var matches = walk
|
||||||
|
.filter(Files::isRegularFile)
|
||||||
|
.filter(p -> !isHiddenPath(p, dir))
|
||||||
|
.filter(p -> p.getFileName().toString().contains(pattern))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (matches.isEmpty()) {
|
||||||
|
sb.append(" ").append(AnsiStyle.dim("No files found matching \"" + pattern + "\"")).append("\n");
|
||||||
|
} else {
|
||||||
|
for (Path match : matches) {
|
||||||
|
sb.append(" ").append(dir.relativize(match)).append("\n");
|
||||||
|
}
|
||||||
|
sb.append("\n ").append(AnsiStyle.dim(matches.size() + " file(s) found")).append("\n");
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
sb.append(" ").append(AnsiStyle.red("Error: " + e.getMessage())).append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isHidden(Path p) {
|
||||||
|
String name = p.getFileName().toString();
|
||||||
|
return name.startsWith(".") || name.equals("node_modules") || name.equals("target")
|
||||||
|
|| name.equals("build") || name.equals("__pycache__") || name.equals(".git");
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isHiddenPath(Path p, Path root) {
|
||||||
|
Path rel = root.relativize(p);
|
||||||
|
for (Path part : rel) {
|
||||||
|
if (isHidden(part)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatSize(long bytes) {
|
||||||
|
if (bytes < 1024) return bytes + "B";
|
||||||
|
if (bytes < 1024 * 1024) return String.format("%.1fK", bytes / 1024.0);
|
||||||
|
return String.format("%.1fM", bytes / (1024.0 * 1024));
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user