chore: Spring AI 重构

This commit is contained in:
abel533
2026-03-25 00:15:00 +08:00
parent a9c71002d2
commit 2afa4712cb
124 changed files with 11777 additions and 3530 deletions
+6
View File
@@ -7,6 +7,8 @@ __pycache__/
*.so
# Distribution / packaging
.idea
*.iml
.Python
build/
develop-eggs/
@@ -195,6 +197,8 @@ cython_debug/
.task_outputs/
.tasks/
.teams/
.team/
.worktrees/
# Ruff stuff:
.ruff_cache/
@@ -225,3 +229,5 @@ test_providers.py
# Internal analysis artifacts (not learning material)
analysis/
analysis_progress.md
TODO.md
.test-workspace/
+438
View File
@@ -0,0 +1,438 @@
# 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/learn-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.**
+112 -46
View File
@@ -1,6 +1,6 @@
# Learn Claude Code -- 真の Agent のための Harness Engineering
[English](./README.md) | [中文](./README-zh.md) | [日本語](./README-ja.md)
[English](./README-en.md) | [中文](./README.md) | [日本語](./README-ja.md)
## モデルこそが Agent である
@@ -143,20 +143,20 @@ Claude Code = 一つの agent loop
User --> messages[] --> LLM --> response
|
stop_reason == "tool_use"?
ツール呼び出しリクエストあり?
/ \
yes no
あり なし
| |
execute tools return text
append results
loop back -----------------> messages[]
@Tool メソッドを実行 テキストを返す
結果を返却
ループ継続 -----------------> messages[]
最小ループ。すべての AI Agent にこのループが必要
最小ループ。すべての AI エージェントにこのループが必要。
モデルがツール呼び出しと停止を決める。
コードはモデルの要求を実行するだけ
このリポジトリはこのループを囲むすべて --
Agent を特定ドメインで効果的にする Harness -- の作り方を教える。
Spring AI の ChatClient.call() がこのループを自動駆動する
リポジトリはこのループを囲むすべて --
エージェントを特定ドメインで効果的にする Harness -- の作り方を教える。
```
**12 の段階的セッション、シンプルなループから分離された自律実行まで。**
@@ -164,7 +164,7 @@ Claude Code = 一つの agent loop
> **s01**   *"One loop & Bash is all you need"* — 1つのツール + 1つのループ = エージェント
>
> **s02**   *"ツールを足すなら、ハンドラーを1つ足すだけ"* — ループは変わらない。新ツールは dispatch map に登録するだけ
> **s02**   *"ツールを足すなら、ハンドラーを1つ足すだけ"* — ループは変わらない。新ツールは `@Tool` アノテーション + `defaultTools()` で登録するだけ
>
> **s03**   *"計画のないエージェントは行き当たりばったり"* — まずステップを書き出し、それから実行
>
@@ -190,34 +190,43 @@ Claude Code = 一つの agent loop
## コアパターン
```python
def agent_loop(messages):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM,
messages=messages, tools=TOOLS,
)
messages.append({"role": "assistant",
"content": response.content})
```java
// Spring AI の ChatClient + @Tool 注解实现 Agent 循环
// 模型自动决定何时调用工具、何时返回文本 -- 循环由框架驱动
if response.stop_reason != "tool_use":
return
@SpringBootApplication
public class S01AgentLoop implements CommandLineRunner {
results = []
for block in response.content:
if block.type == "tool_use":
output = TOOL_HANDLERS[block.name](**block.input)
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
@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) {
// 执行命令并返回结果
}
}
```
各セッションはこのループの上に 1 つの Harness メカニズムを重ねる -- ループ自体は変わらない。ループは Agent のもの。メカニズムは Harness のもの。
Spring AI の `ChatClient.call()` は完全なエージェントループを内部にカプセル化:モデル呼び出し → ツール呼び出しリクエスト検出 → `@Tool` メソッド実行 → 結果をモデルに返却 → モデルがテキストを返すまで繰り返し。各セッションはこのループの上に 1 つの Harness メカニズムを重ねる -- ループ自体は変わらない。ループはエージェントのもの。メカニズムは Harness のもの。
## スコープ (重要)
## 範囲説明 (重要)
このリポジトリは Harness 工学の 0->1 学習プロジェクト -- Agent モデルを囲む環境の構築を学ぶ。
学習を優先するため、以下の本番メカニズムは意図的に簡略化または省略している:
@@ -232,15 +241,45 @@ def agent_loop(messages):
## クイックスタート
```sh
git clone https://github.com/shareAI-lab/learn-claude-code
cd learn-claude-code
pip install -r requirements.txt
cp .env.example .env # .env を編集して ANTHROPIC_API_KEY を入力
### 環境要件
python agents/s01_agent_loop.py # ここから開始
python agents/s12_worktree_task_isolation.py # 全セッションの到達点
python agents/s_full.py # 総括: 全メカニズム統合
- **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/learn-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 プラットフォーム
@@ -251,16 +290,26 @@ python agents/s_full.py # 総括: 全メカニズム統合
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]
while + stop_reason TodoManager + nag リマインダー
ChatClient + @Tool TodoManager + nag リマインダー
| |
+-> s02 Tool Use [4] s04 サブエージェント [5]
dispatch map: name->handler 子ごとに新しい messages[]
@Tool で複数ツール登録 各サブエージェントに独立 ChatClient
|
s05 Skills [5]
SKILL.md を tool_result で注入
@@ -274,7 +323,7 @@ s07 タスクシステム [8] s09 エージェントチーム
ファイルベース CRUD + 依存グラフ チームメイト + JSONL メールボックス
| |
s08 バックグラウンドタスク [6] s10 チームプロトコル [12]
デーモンスレッド + 通知キュー シャットダウン + プラン承認 FSM
仮想スレッド + 通知キュー シャットダウン + プラン承認 FSM
|
s11 自律エージェント [14]
アイドルサイクル + 自動クレーム
@@ -290,10 +339,27 @@ s08 バックグラウンドタスク [6] s10 チームプロトコル
```
learn-claude-code/
|
|-- agents/ # Python リファレンス実装 (s01-s12 + s_full 総括)
|-- 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: 型チェック + ビルド
```
-372
View File
@@ -1,372 +0,0 @@
# Learn Claude Code -- 真正的 Agent Harness 工程
[English](./README.md) | [中文](./README-zh.md) | [日本語](./README-ja.md)
## 模型就是 Agent
在讨论代码之前,先把一件事彻底说清楚。
**Agent 是模型。不是框架。不是提示词链。不是拖拽式工作流。**
### Agent 到底是什么
Agent 是一个神经网络 -- Transformer、RNN、一个被训练出来的函数 -- 经过数十亿次梯度更新,在行动序列数据上学会了感知环境、推理目标、采取行动。"Agent" 这个词在 AI 领域从诞生之日起就是这个意思。从来都是。
人类就是 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。** 五个神经网络,在 10 个月内与自己对战了 [45,000 年的 Dota 2](https://openai.com/index/openai-five-defeats-dota-2-world-champions/),在旧金山直播赛上 2-0 击败了 **OG** -- TI8 世界冠军。随后的公开竞技场中,AI 在 42,729 场比赛中胜率 99.4%。没有脚本化的策略。没有元编程的团队协调逻辑。模型完全通过自我对弈学会了团队协作、战术和实时适应。
- **2019 -- DeepMind AlphaStar 制霸星际争霸 II。** AlphaStar 在闭门赛中 [10-1 击败职业选手](https://deepmind.google/blog/alphastar-mastering-the-real-time-strategy-game-starcraft-ii/),随后在欧洲服务器上达到[宗师段位](https://www.nature.com/articles/d41586-019-03298-6) -- 90,000 名玩家中的前 0.15%。一个信息不完全、实时决策、组合动作空间远超国际象棋和围棋的游戏。Agent 是什么?是模型。训练出来的。不是编出来的。
- **2019 -- 腾讯绝悟统治王者荣耀。** 腾讯 AI Lab 的 "绝悟" 于 2019 年 8 月 2 日世冠杯半决赛上[以 5v5 击败 KPL 职业选手](https://www.jiemian.com/article/3371171.html)。在 1v1 模式下,职业选手 [15 场只赢 1 场,最多坚持不到 8 分钟](https://developer.aliyun.com/article/851058)。训练强度:一天等于人类 440 年。到 2021 年,绝悟在全英雄池 BO5 上全面超越 KPL 职业选手水准。没有手工编写的英雄克制表。没有脚本化的阵容编排。一个从零开始通过自我对弈学习整个游戏的模型。
- **2024-2025 -- LLM Agent 重塑软件工程。** Claude、GPT、Gemini -- 在人类全部代码和推理上训练的大语言模型 -- 被部署为编程 agent。它们阅读代码库,编写实现,调试故障,团队协作。架构与之前每一个 agent 完全相同:一个训练好的模型,放入一个环境,给予感知和行动的工具。唯一的不同是它们学到的东西的规模和解决任务的通用性。
每一个里程碑都共享同一个真理:**"Agent" 从来都不是外面那层代码。Agent 永远是模型本身。**
### Agent 不是什么
"Agent" 这个词已经被一整个提示词水管工产业劫持了。
拖拽式工作流构建器。无代码 "AI Agent" 平台。提示词链编排库。它们共享同一个幻觉:把 LLM API 调用用 if-else 分支、节点图、硬编码路由逻辑串在一起就算是 "构建 Agent" 了。
不是的。它们做出来的东西是鲁布·戈德堡机械 -- 一个过度工程化的、脆弱的过程式规则流水线,LLM 被楔在里面当一个美化了的文本补全节点。那不是 Agent。那是一个有着宏大妄想的 shell 脚本。
**提示词水管工式 "Agent" 是不做模型的程序员的意淫。** 他们试图通过堆叠过程式逻辑来暴力模拟智能 -- 庞大的规则树、节点图、链式提示词瀑布流 -- 然后祈祷足够多的胶水代码能涌现出自主行为。不会的。你不可能通过工程手段编码出 agency。Agency 是学出来的,不是编出来的。
那些系统从诞生之日起就已经死了:脆弱、不可扩展、根本不具备泛化能力。它们是 GOFAIGood Old-Fashioned AI,经典符号 AI)的现代还魂 -- 几十年前就被学界抛弃的符号规则系统,现在喷了一层 LLM 的漆又登场了。换了个包装,同一条死路。
### 心智转换:从 "开发 Agent" 到开发 Harness
当一个人说 "我在开发 Agent" 时,他只可能是两个意思之一:
**1. 训练模型。** 通过强化学习、微调、RLHF 或其他基于梯度的方法调整权重。收集任务过程数据 -- 真实领域中感知、推理、行动的实际序列 -- 用它们来塑造模型的行为。这是 DeepMind、OpenAI、腾讯 AI Lab、Anthropic 在做的事。这是最本义的 Agent 开发。
**2. 构建 Harness。** 编写代码,为模型提供一个可操作的环境。这是我们大多数人在做的事,也是本仓库的核心。
Harness 是 agent 在特定领域工作所需要的一切:
```
Harness = Tools + Knowledge + Observation + Action Interfaces + Permissions
Tools: 文件读写、Shell、网络、数据库、浏览器
Knowledge: 产品文档、领域资料、API 规范、风格指南
Observation: git diff、错误日志、浏览器状态、传感器数据
Action: CLI 命令、API 调用、UI 交互
Permissions: 沙箱隔离、审批流程、信任边界
```
模型做决策。Harness 执行。模型做推理。Harness 提供上下文。模型是驾驶者。Harness 是载具。
**编程 agent 的 harness 是它的 IDE、终端和文件系统。** 农业 agent 的 harness 是传感器阵列、灌溉控制和气象数据。酒店 agent 的 harness 是预订系统、客户沟通渠道和设施管理 API。Agent -- 那个智能、那个决策者 -- 永远是模型。Harness 因领域而变。Agent 跨领域泛化。
这个仓库教你造载具。编程用的载具。但设计模式可以泛化到任何领域:庄园管理、农田运营、酒店运作、工厂制造、物流调度、医疗保健、教育培训、科学研究。只要有一个任务需要被感知、推理和执行 -- agent 就需要一个 harness。
### Harness 工程师到底在做什么
如果你在读这个仓库,你很可能是一名 harness 工程师 -- 这是一个强大的身份。以下是你真正的工作:
- **实现工具。** 给 agent 一双手。文件读写、Shell 执行、API 调用、浏览器控制、数据库查询。每个工具都是 agent 在环境中可以采取的一个行动。设计它们时要原子化、可组合、描述清晰。
- **策划知识。** 给 agent 领域专长。产品文档、架构决策记录、风格指南、合规要求。按需加载(s05),不要前置塞入。Agent 应该知道有什么可用,然后自己拉取所需。
- **管理上下文。** 给 agent 干净的记忆。子 agent 隔离(s04)防止噪声泄露。上下文压缩(s06)防止历史淹没。任务系统(s07)让目标持久化到单次对话之外。
- **控制权限。** 给 agent 边界。沙箱化文件访问。对破坏性操作要求审批。在 agent 和外部系统之间实施信任边界。这是安全工程与 harness 工程的交汇点。
- **收集任务过程数据。** Agent 在你的 harness 中执行的每一条行动序列都是训练信号。真实部署中的感知-推理-行动轨迹是微调下一代 agent 模型的原材料。你的 harness 不仅服务于 agent -- 它还可以帮助进化 agent。
你不是在编写智能。你是在构建智能栖居的世界。这个世界的质量 -- agent 能看得多清楚、行动得多精准、可用知识有多丰富 -- 直接决定了智能能多有效地表达自己。
**造好 Harness。Agent 会完成剩下的。**
### 为什么是 Claude Code -- Harness 工程的大师课
为什么这个仓库专门拆解 Claude Code
因为 Claude Code 是我们所见过的最优雅、最完整的 agent harness 实现。不是因为某个巧妙的技巧,而是因为它 *没做* 的事:它没有试图成为 agent 本身。它没有强加僵化的工作流。它没有用精心设计的决策树去替模型做判断。它给模型提供了工具、知识、上下文管理和权限边界 -- 然后让开了。
把 Claude Code 剥到本质来看:
```
Claude Code = 一个 agent loop
+ 工具 (bash, read, write, edit, glob, grep, browser...)
+ 按需 skill 加载
+ 上下文压缩
+ 子 agent 派生
+ 带依赖图的任务系统
+ 异步邮箱的团队协调
+ worktree 隔离的并行执行
+ 权限治理
```
就这些。这就是全部架构。每一个组件都是 harness 机制 -- 为 agent 构建的栖居世界的一部分。Agent 本身呢?是 Claude。一个模型。由 Anthropic 在人类推理和代码的全部广度上训练而成。Harness 没有让 Claude 变聪明。Claude 本来就聪明。Harness 给了 Claude 双手、双眼和一个工作空间。
这就是 Claude Code 作为教学标本的意义:**它展示了当你信任模型、把工程精力集中在 harness 上时会发生什么。** 本仓库的每一个课程(s01-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
|
stop_reason == "tool_use"?
/ \
yes no
| |
execute tools return text
append results
loop back -----------------> messages[]
这是最小循环。每个 AI Agent 都需要这个循环。
模型决定何时调用工具、何时停止。
代码只是执行模型的要求。
本仓库教你构建围绕这个循环的一切 --
让 agent 在特定领域高效工作的 harness。
```
**12 个递进式课程, 从简单循环到隔离化的自治执行。**
**每个课程添加一个 harness 机制。每个机制有一句格言。**
> **s01**   *"One loop & Bash is all you need"* — 一个工具 + 一个循环 = 一个智能体
>
> **s02**   *"加一个工具, 只加一个 handler"* — 循环不用动, 新工具注册进 dispatch map 就行
>
> **s03**   *"没有计划的 agent 走哪算哪"* — 先列步骤再动手, 完成率翻倍
>
> **s04**   *"大任务拆小, 每个小任务干净的上下文"* — 子智能体用独立 messages[], 不污染主对话
>
> **s05**   *"用到什么知识, 临时加载什么知识"* — 通过 tool_result 注入, 不塞 system prompt
>
> **s06**   *"上下文总会满, 要有办法腾地方"* — 三层压缩策略, 换来无限会话
>
> **s07**   *"大目标要拆成小任务, 排好序, 记在磁盘上"* — 文件持久化的任务图, 为多 agent 协作打基础
>
> **s08**   *"慢操作丢后台, agent 继续想下一步"* — 后台线程跑命令, 完成后注入通知
>
> **s09**   *"任务太大一个人干不完, 要能分给队友"* — 持久化队友 + 异步邮箱
>
> **s10**   *"队友之间要有统一的沟通规矩"* — 一个 request-response 模式驱动所有协商
>
> **s11**   *"队友自己看看板, 有活就认领"* — 不需要领导逐个分配, 自组织
>
> **s12**   *"各干各的目录, 互不干扰"* — 任务管目标, worktree 管目录, 按 ID 绑定
---
## 核心模式
```python
def agent_loop(messages):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM,
messages=messages, tools=TOOLS,
)
messages.append({"role": "assistant",
"content": response.content})
if response.stop_reason != "tool_use":
return
results = []
for block in response.content:
if block.type == "tool_use":
output = TOOL_HANDLERS[block.name](**block.input)
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
```
每个课程在这个循环之上叠加一个 harness 机制 -- 循环本身始终不变。循环属于 agent。机制属于 harness。
## 范围说明 (重要)
本仓库是一个 0->1 的 harness 工程学习项目 -- 构建围绕 agent 模型的工作环境。
为保证学习路径清晰,仓库有意简化或省略了部分生产机制:
- 完整事件 / Hook 总线 (例如 PreToolUse、SessionStart/End、ConfigChange)。
s12 仅提供教学用途的最小 append-only 生命周期事件流。
- 基于规则的权限治理与信任流程
- 会话生命周期控制 (resume/fork) 与更完整的 worktree 生命周期控制
- 完整 MCP 运行时细节 (transport/OAuth/资源订阅/轮询)
仓库中的团队 JSONL 邮箱协议是教学实现,不是对任何特定生产内部实现的声明。
## 快速开始
```sh
git clone https://github.com/shareAI-lab/learn-claude-code
cd learn-claude-code
pip install -r requirements.txt
cp .env.example .env # 编辑 .env 填入你的 ANTHROPIC_API_KEY
python agents/s01_agent_loop.py # 从这里开始
python agents/s12_worktree_task_isolation.py # 完整递进终点
python agents/s_full.py # 总纲: 全部机制合一
```
### Web 平台
交互式可视化、分步动画、源码查看器, 以及每个课程的文档。
```sh
cd web && npm install && npm run dev # http://localhost:3000
```
## 学习路径
```
第一阶段: 循环 第二阶段: 规划与知识
================== ==============================
s01 Agent 循环 [1] s03 TodoWrite [5]
while + stop_reason TodoManager + nag 提醒
| |
+-> s02 Tool Use [4] s04 子智能体 [5]
dispatch map: name->handler 每个子智能体独立 messages[]
|
s05 Skills [5]
SKILL.md 通过 tool_result 注入
|
s06 Context Compact [5]
三层上下文压缩
第三阶段: 持久化 第四阶段: 团队
================== =====================
s07 任务系统 [8] s09 智能体团队 [9]
文件持久化 CRUD + 依赖图 队友 + JSONL 邮箱
| |
s08 后台任务 [6] s10 团队协议 [12]
守护线程 + 通知队列 关机 + 计划审批 FSM
|
s11 自治智能体 [14]
空闲轮询 + 自动认领
|
s12 Worktree 隔离 [16]
任务协调 + 按需隔离执行通道
[N] = 工具数量
```
## 项目结构
```
learn-claude-code/
|
|-- agents/ # Python 参考实现 (s01-s12 + s_full 总纲)
|-- docs/{en,zh,ja}/ # 心智模型优先的文档 (3 种语言)
|-- web/ # 交互式学习平台 (Next.js)
|-- skills/ # s05 的 Skill 文件
+-- .github/workflows/ci.yml # CI: 类型检查 + 构建
```
## 文档
心智模型优先: 问题、方案、ASCII 图、最小化代码。
[English](./docs/en/) | [中文](./docs/zh/) | [日本語](./docs/ja/)
| 课程 | 主题 | 格言 |
|------|------|------|
| [s01](./docs/zh/s01-the-agent-loop.md) | Agent 循环 | *One loop & Bash is all you need* |
| [s02](./docs/zh/s02-tool-use.md) | Tool Use | *加一个工具, 只加一个 handler* |
| [s03](./docs/zh/s03-todo-write.md) | TodoWrite | *没有计划的 agent 走哪算哪* |
| [s04](./docs/zh/s04-subagent.md) | 子智能体 | *大任务拆小, 每个小任务干净的上下文* |
| [s05](./docs/zh/s05-skill-loading.md) | Skills | *用到什么知识, 临时加载什么知识* |
| [s06](./docs/zh/s06-context-compact.md) | Context Compact | *上下文总会满, 要有办法腾地方* |
| [s07](./docs/zh/s07-task-system.md) | 任务系统 | *大目标要拆成小任务, 排好序, 记在磁盘上* |
| [s08](./docs/zh/s08-background-tasks.md) | 后台任务 | *慢操作丢后台, agent 继续想下一步* |
| [s09](./docs/zh/s09-agent-teams.md) | 智能体团队 | *任务太大一个人干不完, 要能分给队友* |
| [s10](./docs/zh/s10-team-protocols.md) | 团队协议 | *队友之间要有统一的沟通规矩* |
| [s11](./docs/zh/s11-autonomous-agents.md) | 自治智能体 | *队友自己看看板, 有活就认领* |
| [s12](./docs/zh/s12-worktree-task-isolation.md) | Worktree + 任务隔离 | *各干各的目录, 互不干扰* |
## 学完之后 -- 从理解到落地
12 个课程走完, 你已经从内到外理解了 harness 工程的运作原理。两种方式把知识变成产品:
### Kode Agent CLI -- 开源 Coding Agent CLI
> `npm i -g @shareai-lab/kode`
支持 Skill & LSP, 适配 Windows, 可接 GLM / MiniMax / DeepSeek 等开放模型。装完即用。
GitHub: **[shareAI-lab/Kode-cli](https://github.com/shareAI-lab/Kode-cli)**
### Kode Agent SDK -- 把 Agent 能力嵌入你的应用
官方 Claude Code Agent SDK 底层与完整 CLI 进程通信 -- 每个并发用户 = 一个终端进程。Kode SDK 是独立库, 无 per-user 进程开销, 可嵌入后端、浏览器插件、嵌入式设备等任意运行时。
GitHub: **[shareAI-lab/Kode-agent-sdk](https://github.com/shareAI-lab/Kode-agent-sdk)**
---
## 姊妹教程: 从*被动临时会话*到*主动常驻助手*
本仓库教的 harness 属于 **用完即走** 型 -- 开终端、给 agent 任务、做完关掉, 下次重开是全新会话。Claude Code 就是这种模式。
但 [OpenClaw](https://github.com/openclaw/openclaw) 证明了另一种可能: 在同样的 agent core 之上, 加两个 harness 机制就能让 agent 从 "踹一下动一下" 变成 "自己隔 30 秒醒一次找活干":
- **心跳 (Heartbeat)** -- 每 30 秒 harness 给 agent 发一条消息, 让它检查有没有事可做。没事就继续睡, 有事立刻行动。
- **定时任务 (Cron)** -- agent 可以给自己安排未来要做的事, 到点自动执行。
再加上 IM 多通道路由 (WhatsApp/Telegram/Slack/Discord 等 13+ 平台)、不清空的上下文记忆、Soul 人格系统, agent 就从一个临时工具变成了始终在线的个人 AI 助手。
**[claw0](https://github.com/shareAI-lab/claw0)** 是我们的姊妹教学仓库, 从零拆解这些 harness 机制:
```
claw agent = agent core + heartbeat + cron + IM chat + memory + soul
```
```
learn-claude-code claw0
(agent harness 内核: (主动式常驻 harness:
循环、工具、规划、 心跳、定时任务、IM 通道、
团队、worktree 隔离) 记忆、Soul 人格)
```
## 许可证
MIT
---
**模型就是 Agent。代码是 Harness。造好 HarnessAgent 会完成剩下的。**
**Bash is all you need. Real agents are all the universe needs.**
+263 -202
View File
@@ -1,136 +1,137 @@
[English](./README.md) | [中文](./README-zh.md) | [日本語](./README-ja.md)
# Learn Claude Code -- Harness Engineering for Real Agents
# Learn Claude Code -- 真正的 Agent Harness 工程
## The Model IS the Agent
[English](./README-en.md) | [中文](./README.md) | [日本語](./README-ja.md)
Before we talk about code, let's get one thing absolutely straight.
## 模型就是 Agent
**An agent is a model. Not a framework. Not a prompt chain. Not a drag-and-drop workflow.**
在讨论代码之前,先把一件事彻底说清楚。
### What an Agent IS
**Agent 是模型。不是框架。不是提示词链。不是拖拽式工作流。**
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.
### Agent 到底是什么
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.**
Agent 是一个神经网络 -- Transformer、RNN、一个被训练出来的函数 -- 经过数十亿次梯度更新,在行动序列数据上学会了感知环境、推理目标、采取行动。"Agent" 这个词在 AI 领域从诞生之日起就是这个意思。从来都是。
The proof is written in history:
人类就是 agent。一个由数百万年进化训练出来的生物神经网络,通过感官感知世界,通过大脑推理,通过身体行动。当 DeepMind、OpenAI 或 Anthropic 说 "agent" 时,他们说的和这个领域自诞生以来就一直在说的完全一样:**一个学会了行动的模型。**
- **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.
- **2013 -- DeepMind DQN 玩 Atari。** 一个神经网络,只接收原始像素和游戏分数,学会了 7 款 Atari 2600 游戏 -- 超越所有先前算法,在其中 3 款上击败人类专家。到 2015 年,同一架构扩展到 [49 款游戏,达到职业人类测试员水平](https://www.nature.com/articles/nature14236),论文发表在 *Nature*。没有游戏专属规则。没有决策树。一个模型,从经验中学习。那个模型就是 agent。
- **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 -- OpenAI Five 征服 Dota 2。** 五个神经网络,在 10 个月内与自己对战了 [45,000 年的 Dota 2](https://openai.com/index/openai-five-defeats-dota-2-world-champions/),在旧金山直播赛上 2-0 击败了 **OG** -- TI8 世界冠军。随后的公开竞技场中,AI 在 42,729 场比赛中胜率 99.4%。没有脚本化的策略。没有元编程的团队协调逻辑。模型完全通过自我对弈学会了团队协作、战术和实时适应。
- **2019 -- 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.
- **2019 -- DeepMind AlphaStar 制霸星际争霸 II。** AlphaStar 在闭门赛中 [10-1 击败职业选手](https://deepmind.google/blog/alphastar-mastering-the-real-time-strategy-game-starcraft-ii/),随后在欧洲服务器上达到[宗师段位](https://www.nature.com/articles/d41586-019-03298-6) -- 90,000 名玩家中的前 0.15%。一个信息不完全、实时决策、组合动作空间远超国际象棋和围棋的游戏。Agent 是什么?是模型。训练出来的。不是编出来的。
- **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.
- **2019 -- 腾讯绝悟统治王者荣耀。** 腾讯 AI Lab 的 "绝悟" 于 2019 年 8 月 2 日世冠杯半决赛上[以 5v5 击败 KPL 职业选手](https://www.jiemian.com/article/3371171.html)。在 1v1 模式下,职业选手 [15 场只赢 1 场,最多坚持不到 8 分钟](https://developer.aliyun.com/article/851058)。训练强度:一天等于人类 440 年。到 2021 年,绝悟在全英雄池 BO5 上全面超越 KPL 职业选手水准。没有手工编写的英雄克制表。没有脚本化的阵容编排。一个从零开始通过自我对弈学习整个游戏的模型。
Every one of these milestones shares the same truth: **the "agent" is never the surrounding code. The agent is always the model.**
- **2024-2025 -- LLM Agent 重塑软件工程。** Claude、GPT、Gemini -- 在人类全部代码和推理上训练的大语言模型 -- 被部署为编程 agent。它们阅读代码库,编写实现,调试故障,团队协作。架构与之前每一个 agent 完全相同:一个训练好的模型,放入一个环境,给予感知和行动的工具。唯一的不同是它们学到的东西的规模和解决任务的通用性。
### What an Agent Is NOT
每一个里程碑都共享同一个真理:**"Agent" 从来都不是外面那层代码。Agent 永远是模型本身。**
The word "agent" has been hijacked by an entire cottage industry of prompt plumbing.
### Agent 不是什么
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."
"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.
拖拽式工作流构建器。无代码 "AI Agent" 平台。提示词链编排库。它们共享同一个幻觉:把 LLM API 调用用 if-else 分支、节点图、硬编码路由逻辑串在一起就算是 "构建 Agent" 了。
**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.
不是的。它们做出来的东西是鲁布·戈德堡机械 -- 一个过度工程化的、脆弱的过程式规则流水线,LLM 被楔在里面当一个美化了的文本补全节点。那不是 Agent。那是一个有着宏大妄想的 shell 脚本。
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.
**提示词水管工式 "Agent" 是不做模型的程序员的意淫。** 他们试图通过堆叠过程式逻辑来暴力模拟智能 -- 庞大的规则树、节点图、链式提示词瀑布流 -- 然后祈祷足够多的胶水代码能涌现出自主行为。不会的。你不可能通过工程手段编码出 agency。Agency 是学出来的,不是编出来的。
### The Mind Shift: From "Developing Agents" to Developing Harness
那些系统从诞生之日起就已经死了:脆弱、不可扩展、根本不具备泛化能力。它们是 GOFAIGood Old-Fashioned AI,经典符号 AI)的现代还魂 -- 几十年前就被学界抛弃的符号规则系统,现在喷了一层 LLM 的漆又登场了。换了个包装,同一条死路。
When someone says "I'm developing an agent," they can only mean one of two things:
### 心智转换:从 "开发 Agent" 到开发 Harness
**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.
当一个人说 "我在开发 Agent" 时,他只可能是两个意思之一:
**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.
**1. 训练模型。** 通过强化学习、微调、RLHF 或其他基于梯度的方法调整权重。收集任务过程数据 -- 真实领域中感知、推理、行动的实际序列 -- 用它们来塑造模型的行为。这是 DeepMind、OpenAI、腾讯 AI Lab、Anthropic 在做的事。这是最本义的 Agent 开发。
A harness is everything the agent needs to function in a specific domain:
**2. 构建 Harness。** 编写代码,为模型提供一个可操作的环境。这是我们大多数人在做的事,也是本仓库的核心。
Harness 是 agent 在特定领域工作所需要的一切:
```
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
Tools: 文件读写、Shell、网络、数据库、浏览器
Knowledge: 产品文档、领域资料、API 规范、风格指南
Observation: git diff、错误日志、浏览器状态、传感器数据
Action: CLI 命令、API 调用、UI 交互
Permissions: 沙箱隔离、审批流程、信任边界
```
The model decides. The harness executes. The model reasons. The harness provides context. The model is the driver. The harness is the vehicle.
模型做决策。Harness 执行。模型做推理。Harness 提供上下文。模型是驾驶者。Harness 是载具。
**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.
**编程 agent harness 是它的 IDE、终端和文件系统。** 农业 agent harness 是传感器阵列、灌溉控制和气象数据。酒店 agent harness 是预订系统、客户沟通渠道和设施管理 API。Agent -- 那个智能、那个决策者 -- 永远是模型。Harness 因领域而变。Agent 跨领域泛化。
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.
这个仓库教你造载具。编程用的载具。但设计模式可以泛化到任何领域:庄园管理、农田运营、酒店运作、工厂制造、物流调度、医疗保健、教育培训、科学研究。只要有一个任务需要被感知、推理和执行 -- agent 就需要一个 harness
### What Harness Engineers Actually Do
### Harness 工程师到底在做什么
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:
如果你在读这个仓库,你很可能是一名 harness 工程师 -- 这是一个强大的身份。以下是你真正的工作:
- **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.
- **实现工具。** 给 agent 一双手。文件读写、Shell 执行、API 调用、浏览器控制、数据库查询。每个工具都是 agent 在环境中可以采取的一个行动。设计它们时要原子化、可组合、描述清晰。
- **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.
- **策划知识。** 给 agent 领域专长。产品文档、架构决策记录、风格指南、合规要求。按需加载(s05),不要前置塞入。Agent 应该知道有什么可用,然后自己拉取所需。
- **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.
- **管理上下文。** 给 agent 干净的记忆。子 agent 隔离(s04)防止噪声泄露。上下文压缩(s06)防止历史淹没。任务系统(s07)让目标持久化到单次对话之外。
- **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.
- **控制权限。** 给 agent 边界。沙箱化文件访问。对破坏性操作要求审批。在 agent 和外部系统之间实施信任边界。这是安全工程与 harness 工程的交汇点。
- **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.
- **收集任务过程数据。** Agent 在你的 harness 中执行的每一条行动序列都是训练信号。真实部署中的感知-推理-行动轨迹是微调下一代 agent 模型的原材料。你的 harness 不仅服务于 agent -- 它还可以帮助进化 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.
你不是在编写智能。你是在构建智能栖居的世界。这个世界的质量 -- agent 能看得多清楚、行动得多精准、可用知识有多丰富 -- 直接决定了智能能多有效地表达自己。
**Build great harnesses. The agent will do the rest.**
**造好 Harness。Agent 会完成剩下的。**
### Why Claude Code -- A Masterclass in Harness Engineering
### 为什么是 Claude Code -- Harness 工程的大师课
Why does this repository dissect Claude Code specifically?
为什么这个仓库专门拆解 Claude Code
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.
因为 Claude Code 是我们所见过的最优雅、最完整的 agent harness 实现。不是因为某个巧妙的技巧,而是因为它 *没做* 的事:它没有试图成为 agent 本身。它没有强加僵化的工作流。它没有用精心设计的决策树去替模型做判断。它给模型提供了工具、知识、上下文管理和权限边界 -- 然后让开了。
Look at what Claude Code actually is, stripped to its essence:
Claude Code 剥到本质来看:
```
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
Claude Code = 一个 agent loop
+ 工具 (bash, read, write, edit, glob, grep, browser...)
+ 按需 skill 加载
+ 上下文压缩
+ agent 派生
+ 带依赖图的任务系统
+ 异步邮箱的团队协调
+ worktree 隔离的并行执行
+ 权限治理
```
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.
就这些。这就是全部架构。每一个组件都是 harness 机制 -- 为 agent 构建的栖居世界的一部分。Agent 本身呢?是 Claude。一个模型。由 Anthropic 在人类推理和代码的全部广度上训练而成。Harness 没有让 Claude 变聪明。Claude 本来就聪明。Harness 给了 Claude 双手、双眼和一个工作空间。
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.
这就是 Claude Code 作为教学标本的意义:**它展示了当你信任模型、把工程精力集中在 harness 上时会发生什么。** 本仓库的每一个课程(s01-s12)都在逆向工程 Claude Code 架构中的一个 harness 机制。学完之后,你理解的不只是 Claude Code 怎么工作,而是适用于任何领域、任何 agent 的 harness 工程通用原则。
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.**
启示不是 "复制 Claude Code"。启示是:**最好的 agent 产品,出自那些明白自己的工作是 harness 而非 intelligence 的工程师之手。**
---
## The Vision: Fill the Universe with Real Agents
## 愿景:用真正的 Agent 铺满宇宙
This is not just about coding agents.
这不只关乎编程 agent
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:
每一个人类从事复杂、多步骤、需要判断力的工作的领域,都是 agent 可以运作的领域 -- 只要有对的 harness。本仓库中的模式是通用的:
```
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
庄园管理 agent = 模型 + 物业传感器 + 维护工具 + 租户通信
农业 agent = 模型 + 土壤/气象数据 + 灌溉控制 + 作物知识
酒店运营 agent = 模型 + 预订系统 + 客户渠道 + 设施 API
医学研究 agent = 模型 + 文献检索 + 实验仪器 + 协议文档
制造业 agent = 模型 + 产线传感器 + 质量控制 + 物流系统
教育 agent = 模型 + 课程知识 + 学生进度 + 评估工具
```
The loop is always the same. The tools change. The knowledge changes. The permissions change. The agent -- the model -- generalizes.
循环永远不变。工具在变。知识在变。权限在变。Agent -- 那个模型 -- 泛化一切。
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.
每一个读这个仓库的 harness 工程师都在学习远超软件工程的模式。你在学习为一个智能的、自动化的未来构建基础设施。每一个部署在真实领域的好 harness,都是 agent 能够感知、推理、行动的又一个阵地。
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.**
@@ -142,212 +143,278 @@ First we fill the workshops. Then the farms, the hospitals, the factories. Then
User --> messages[] --> LLM --> response
|
stop_reason == "tool_use"?
还有工具调用请求?
/ \
yes no
有 无
| |
execute tools return text
append results
loop back -----------------> messages[]
执行 @Tool 方法 返回文本
回传结果
继续循环 -----------------> messages[]
That's the minimal loop. Every AI agent needs this loop.
The MODEL decides when to call tools and when to stop.
The CODE just executes what the model asks for.
This repo teaches you to build what surrounds this loop --
the harness that makes the agent effective in a specific domain.
这是最小循环。每个 AI Agent 都需要这个循环。
模型决定何时调用工具、何时停止。
Spring AI 的 ChatClient.call() 自动驱动此循环。
本仓库教你构建围绕这个循环的一切 --
让 agent 在特定领域高效工作的 harness。
```
**12 progressive sessions, from a simple loop to isolated autonomous execution.**
**Each session adds one harness mechanism. Each mechanism has one motto.**
**12 个递进式课程, 从简单循环到隔离化的自治执行。**
**每个课程添加一个 harness 机制。每个机制有一句格言。**
> **s01**   *"One loop & Bash is all you need"* — one tool + one loop = an agent
> **s01**   *"One loop & Bash is all you need"* — 一个工具 + 一个循环 = 一个智能体
>
> **s02**   *"Adding a tool means adding one handler"* — the loop stays the same; new tools register into the dispatch map
> **s02**   *"加一个工具, 只加一个 handler"* — 循环不用动, 新工具用 `@Tool` 注解 + `defaultTools()` 注册就行
>
> **s03**   *"An agent without a plan drifts"* — list the steps first, then execute; completion doubles
> **s03**   *"没有计划的 agent 走哪算哪"* — 先列步骤再动手, 完成率翻倍
>
> **s04**   *"Break big tasks down; each subtask gets a clean context"* — subagents use independent messages[], keeping the main conversation clean
> **s04**   *"大任务拆小, 每个小任务干净的上下文"* — 子智能体用独立 messages[], 不污染主对话
>
> **s05**   *"Load knowledge when you need it, not upfront"* — inject via tool_result, not the system prompt
> **s05**   *"用到什么知识, 临时加载什么知识"* — 通过 tool_result 注入, 不塞 system prompt
>
> **s06**   *"Context will fill up; you need a way to make room"* — three-layer compression strategy for infinite sessions
> **s06**   *"上下文总会满, 要有办法腾地方"* — 三层压缩策略, 换来无限会话
>
> **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
> **s07**   *"大目标要拆成小任务, 排好序, 记在磁盘上"* — 文件持久化的任务图, 为多 agent 协作打基础
>
> **s08**   *"Run slow operations in the background; the agent keeps thinking"* — daemon threads run commands, inject notifications on completion
> **s08**   *"慢操作丢后台, agent 继续想下一步"* — 后台线程跑命令, 完成后注入通知
>
> **s09**   *"When the task is too big for one, delegate to teammates"* — persistent teammates + async mailboxes
> **s09**   *"任务太大一个人干不完, 要能分给队友"* — 持久化队友 + 异步邮箱
>
> **s10**   *"Teammates need shared communication rules"* — one request-response pattern drives all negotiation
> **s10**   *"队友之间要有统一的沟通规矩"* — 一个 request-response 模式驱动所有协商
>
> **s11**   *"Teammates scan the board and claim tasks themselves"* — no need for the lead to assign each one
> **s11**   *"队友自己看看板, 有活就认领"* — 不需要领导逐个分配, 自组织
>
> **s12**   *"Each works in its own directory, no interference"* — tasks manage goals, worktrees manage directories, bound by ID
> **s12**   *"各干各的目录, 互不干扰"* — 任务管目标, worktree 管目录, 按 ID 绑定
---
## The Core Pattern
## 核心模式
```python
def agent_loop(messages):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM,
messages=messages, tools=TOOLS,
)
messages.append({"role": "assistant",
"content": response.content})
```java
// Spring AI 的 ChatClient + @Tool 注解实现 Agent 循环
// 模型自动决定何时调用工具、何时返回文本 -- 循环由框架驱动
if response.stop_reason != "tool_use":
return
@SpringBootApplication
public class S01AgentLoop implements CommandLineRunner {
results = []
for block in response.content:
if block.type == "tool_use":
output = TOOL_HANDLERS[block.name](**block.input)
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
@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) {
// 执行命令并返回结果
}
}
```
Every 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.
Spring AI 的 `ChatClient.call()` 内部封装了完整的 agent 循环:调用大模型 → 检测工具调用请求 → 执行 `@Tool` 方法 → 将结果回传模型 → 重复直到模型返回文本。每个课程在这个循环之上叠加一个 harness 机制 -- 循环本身始终不变。循环属于 agent。机制属于 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:
本仓库是一个 0->1 的 harness 工程学习项目 -- 构建围绕 agent 模型的工作环境。
为保证学习路径清晰,仓库有意简化或省略了部分生产机制:
- 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)
- 完整事件 / Hook 总线 (例如 PreToolUseSessionStart/EndConfigChange)
s12 仅提供教学用途的最小 append-only 生命周期事件流。
- 基于规则的权限治理与信任流程
- 会话生命周期控制 (resume/fork) 与更完整的 worktree 生命周期控制
- 完整 MCP 运行时细节 (transport/OAuth/资源订阅/轮询)
Treat the team JSONL mailbox protocol in this repo as a teaching implementation, not a claim about any specific production internals.
仓库中的团队 JSONL 邮箱协议是教学实现,不是对任何特定生产内部实现的声明。
## Quick Start
## 快速开始
### 环境要求
- **JDK 21+** (推荐 [Eclipse Temurin](https://adoptium.net/) 或 GraalVM)
- **Maven 3.9+**
- 一个兼容 OpenAI 协议的大模型 API Key (DeepSeek、智谱 GLM、通义千问、OpenAI 等)
### 克隆与构建
```sh
git clone https://github.com/shareAI-lab/learn-claude-code
git clone https://github.com/abel533/learn-claude-code
cd learn-claude-code
pip install -r requirements.txt
cp .env.example .env # Edit .env with your ANTHROPIC_API_KEY
python agents/s01_agent_loop.py # Start here
python agents/s12_worktree_task_isolation.py # Full progression endpoint
python agents/s_full.py # Capstone: all mechanisms combined
mvn compile # 编译项目
```
### Web Platform
### 设置环境变量
Interactive visualizations, step-through diagrams, source viewer, and documentation.
```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
```
## Learning Path
### Java 版本特色
本项目使用 **Java 21 + Spring Boot 3.5.7 + Spring AI 1.0.3** 技术栈,相比原始 Python 版本有以下特色:
- **兼容多种大模型服务商** -- 通过 OpenAI 协议适配 DeepSeek、智谱 GLM、通义千问、Moonshot 等国产模型,无需绑定特定厂商
- **`@Tool` 注解自动处理工具调用循环** -- Spring AI 框架自动完成"模型调用 → 工具执行 → 结果回传"的完整循环,无需手写 while 循环
- **Java 21 虚拟线程** -- 轻量级并发实现后台任务与多智能体协作,无需线程池管理开销
- **每节课独立可运行** -- 每个课程都是一个 `@SpringBootApplication` + `CommandLineRunner``mvn exec:java` 一行命令即可启动
- **类型安全** -- Java 强类型系统在编译期捕获错误,IDE 自动补全友好
## 学习路径
```
Phase 1: THE LOOP Phase 2: PLANNING & KNOWLEDGE
第一阶段: 循环 第二阶段: 规划与知识
================== ==============================
s01 The Agent Loop [1] s03 TodoWrite [5]
while + stop_reason TodoManager + nag reminder
s01 Agent 循环 [1] s03 TodoWrite [5]
ChatClient + @Tool TodoManager + nag 提醒
| |
+-> s02 Tool Use [4] s04 Subagents [5]
dispatch map: name->handler fresh messages[] per child
+-> s02 Tool Use [4] s04 子智能体 [5]
@Tool 注册多个工具 每个子智能体独立 ChatClient
|
s05 Skills [5]
SKILL.md via tool_result
SKILL.md 通过 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
s07 任务系统 [8] s09 智能体团队 [9]
文件持久化 CRUD + 依赖图 队友 + JSONL 邮箱
| |
s08 Background Tasks [6] s10 Team Protocols [12]
daemon threads + notify queue shutdown + plan approval FSM
s08 后台任务 [6] s10 团队协议 [12]
虚拟线程 + 通知队列 关机 + 计划审批 FSM
|
s11 Autonomous Agents [14]
idle cycle + auto-claim
s11 自治智能体 [14]
空闲轮询 + 自动认领
|
s12 Worktree Isolation [16]
task coordination + optional isolated execution lanes
s12 Worktree 隔离 [16]
任务协调 + 按需隔离执行通道
[N] = number of tools
[N] = 工具数量
```
## Architecture
## 项目结构
```
learn-claude-code/
|
|-- agents/ # Python reference implementations (s01-s12 + s_full capstone)
|-- docs/{en,zh,ja}/ # Mental-model-first documentation (3 languages)
|-- web/ # Interactive learning platform (Next.js)
|-- skills/ # Skill files for s05
+-- .github/workflows/ci.yml # CI: typecheck + build
|-- src/main/java/io/mybatis/learn/ # Java 实现 (Spring AI + Spring Boot)
| |-- core/ # 公共工具类 (AgentRunner, BashTool, EditFileTool 等)
| |-- s01/ S01AgentLoop.java # 课程 01: Agent 循环
| |-- 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: 类型检查 + 构建
```
## Documentation
## 文档
Mental-model-first: problem, solution, ASCII diagram, minimal code.
Available in [English](./docs/en/) | [中文](./docs/zh/) | [日本語](./docs/ja/).
心智模型优先: 问题、方案、ASCII 图、最小化代码。
[English](./docs/en/) | [中文](./docs/zh/) | [日本語](./docs/ja/)
| Session | Topic | Motto |
|---------|-------|-------|
| [s01](./docs/en/s01-the-agent-loop.md) | The 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) | Tasks | *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* |
| 课程 | 主题 | 格言 |
|------|------|------|
| [s01](./docs/zh/s01-the-agent-loop.md) | Agent 循环 | *One loop & Bash is all you need* |
| [s02](./docs/zh/s02-tool-use.md) | Tool Use | *加一个工具, 只加一个 handler* |
| [s03](./docs/zh/s03-todo-write.md) | TodoWrite | *没有计划的 agent 走哪算哪* |
| [s04](./docs/zh/s04-subagent.md) | 子智能体 | *大任务拆小, 每个小任务干净的上下文* |
| [s05](./docs/zh/s05-skill-loading.md) | Skills | *用到什么知识, 临时加载什么知识* |
| [s06](./docs/zh/s06-context-compact.md) | Context Compact | *上下文总会满, 要有办法腾地方* |
| [s07](./docs/zh/s07-task-system.md) | 任务系统 | *大目标要拆成小任务, 排好序, 记在磁盘上* |
| [s08](./docs/zh/s08-background-tasks.md) | 后台任务 | *慢操作丢后台, agent 继续想下一步* |
| [s09](./docs/zh/s09-agent-teams.md) | 智能体团队 | *任务太大一个人干不完, 要能分给队友* |
| [s10](./docs/zh/s10-team-protocols.md) | 团队协议 | *队友之间要有统一的沟通规矩* |
| [s11](./docs/zh/s11-autonomous-agents.md) | 自治智能体 | *队友自己看看板, 有活就认领* |
| [s12](./docs/zh/s12-worktree-task-isolation.md) | Worktree + 任务隔离 | *各干各的目录, 互不干扰* |
## 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:
12 个课程走完, 你已经从内到外理解了 harness 工程的运作原理。两种方式把知识变成产品:
### Kode Agent CLI -- Open-Source Coding Agent CLI
### Kode Agent CLI -- 开源 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.
支持 Skill & LSP, 适配 Windows, 可接 GLM / MiniMax / DeepSeek 等开放模型。装完即用。
GitHub: **[shareAI-lab/Kode-cli](https://github.com/shareAI-lab/Kode-cli)**
### Kode Agent SDK -- Embed Agent Capabilities in Your App
### Kode Agent SDK -- Agent 能力嵌入你的应用
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.
官方 Claude Code Agent SDK 底层与完整 CLI 进程通信 -- 每个并发用户 = 一个终端进程。Kode SDK 是独立库, 无 per-user 进程开销, 可嵌入后端、浏览器插件、嵌入式设备等任意运行时。
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.
本仓库教的 harness 属于 **用完即走** -- 开终端、给 agent 任务、做完关掉, 下次重开是全新会话。Claude Code 就是这种模式。
[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":
[OpenClaw](https://github.com/openclaw/openclaw) 证明了另一种可能: 在同样的 agent core 之上, 加两个 harness 机制就能让 agent 从 "踹一下动一下" 变成 "自己隔 30 秒醒一次找活干":
- **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.
- **心跳 (Heartbeat)** -- 每 30 秒 harness 给 agent 发一条消息, 让它检查有没有事可做。没事就继续睡, 有事立刻行动。
- **定时任务 (Cron)** -- agent 可以给自己安排未来要做的事, 到点自动执行。
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.
再加上 IM 多通道路由 (WhatsApp/Telegram/Slack/Discord 13+ 平台)、不清空的上下文记忆、Soul 人格系统, agent 就从一个临时工具变成了始终在线的个人 AI 助手。
**[claw0](https://github.com/shareAI-lab/claw0)** is our companion teaching repo that deconstructs these harness mechanisms from scratch:
**[claw0](https://github.com/shareAI-lab/claw0)** 是我们的姊妹教学仓库, 从零拆解这些 harness 机制:
```
claw agent = agent core + heartbeat + cron + IM chat + memory + soul
@@ -355,23 +422,17 @@ 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)
(agent harness 内核: (主动式常驻 harness:
循环、工具、规划、 心跳、定时任务、IM 通道、
团队、worktree 隔离) 记忆、Soul 人格)
```
## About
<img width="260" src="https://github.com/user-attachments/assets/fe8b852b-97da-4061-a467-9694906b5edf" /><br>
Scan with Wechat to follow us,
or follow on X: [shareAI-Lab](https://x.com/baicai003)
## License
## 许可证
MIT
---
**The model is the agent. The code is the harness. Build great harnesses. The agent will do the rest.**
**模型就是 Agent。代码是 Harness。造好 HarnessAgent 会完成剩下的。**
**Bash is all you need. Real agents are all the universe needs.**
+264 -62
View File
@@ -20,97 +20,299 @@ A language model can reason about code, but it can't *touch* the real world -- c
^ |
| tool_result |
+----------------+
(loop until stop_reason != "tool_use")
(ChatClient.call() auto-loops until no tool calls)
```
One exit condition controls the entire flow. The loop runs until the model stops calling tools.
A single `call()` invocation controls the entire flow. Spring AI loops automatically until the model stops calling tools.
## How It Works
1. User prompt becomes the first message.
### 1. Build ChatClient: Inject Model + Register Tools
```python
messages.append({"role": "user", "content": query})
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. Send messages + tool definitions to the LLM.
### 2. `@Tool` Annotation: Declarative Tool Registration
```python
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
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
// ...
}
}
```
3. Append the assistant response. Check `stop_reason` -- if the model didn't call a tool, we're done.
> 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
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
```
4. Execute each tool call, collect results, append as a user message. Loop back to step 2.
```python
results = []
for block in response.content:
if block.type == "tool_use":
output = run_bash(block.input["command"])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
```
Assembled into one function:
```python
def agent_loop(query):
messages = [{"role": "user", "content": query}]
# Python version -- manual loop
def agent_loop(messages):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
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
results = []
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":
output = run_bash(block.input["command"])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
result = TOOL_HANDLERS[block.name](block.input)
messages.append({"role": "user", "content": [{"type": "tool_result", ...}]})
```
That's the entire agent in under 30 lines. Everything else in this course layers on top -- without changing the loop.
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) | `while True` + stop_reason |
| Tools | (none) | `bash` (one tool) |
| Messages | (none) | Accumulating list |
| Control flow | (none) | `stop_reason != "tool_use"` |
| 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
python agents/s01_agent_loop.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s01.S01AgentLoop
```
1. `Create a file called hello.py that prints "Hello, World!"`
2. `List all Python files in this directory`
> 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`
+99 -59
View File
@@ -2,98 +2,138 @@
`s01 > [ s02 ] s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"Adding a tool means adding one handler"* -- the loop stays the same; new tools register into the dispatch map.
> *"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 like `read_file` and `write_file` let you enforce path sandboxing at the tool level.
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 | ---> | Tool Dispatch |
| prompt | | | | { |
+--------+ +---+---+ | bash: run_bash |
^ | read: run_read |
| | write: run_wr |
+-----------+ edit: run_edit |
tool_result | } |
+------------------+
+--------+ +-------+ +--------------------+
| User | ---> | LLM | ---> | defaultTools() |
| prompt | | | | { |
+--------+ +---+---+ | BashTool |
^ | ReadFileTool |
| | WriteFileTool |
+-----------+ EditFileTool |
tool_result | } |
+--------------------+
The dispatch map is a dict: {tool_name: handler_function}.
One lookup replaces any if/elif chain.
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 gets a handler function. Path sandboxing prevents workspace escape.
1. Each tool is a standalone class declared with `@Tool` annotation. `PathValidator` provides path sandboxing to prevent workspace escape.
```python
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
```java
// PathValidator -- corresponds to the Python version's safe_path() function
public class PathValidator {
private final Path workDir;
def run_read(path: str, limit: int = None) -> str:
text = safe_path(path).read_text()
lines = text.splitlines()
if limit and limit < len(lines):
lines = lines[:limit]
return "\n".join(lines)[:50000]
```
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;
}
}
2. The dispatch map links tool names to handlers.
// ReadFileTool -- corresponds to the Python version's run_read() function
public class ReadFileTool {
private final PathValidator pathValidator;
```python
TOOL_HANDLERS = {
"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"],
kw["new_text"]),
@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);
}
}
```
3. In the loop, look up the handler by name. The loop body itself is unchanged from s01.
2. Tool registration simply passes objects to `defaultTools()`. Spring AI scans `@Tool` annotated methods and automatically handles name mapping and parameter binding.
```python
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler \
else f"Unknown tool: {block.name}"
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
```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();
```
Add a tool = add a handler + add a schema entry. The loop never changes.
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 (bash only) | 4 (bash, read, write, edit)|
| Dispatch | Hardcoded bash call | `TOOL_HANDLERS` dict |
| Path safety | None | `safe_path()` sandbox |
| Agent loop | Unchanged | Unchanged |
| 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
python agents/s02_tool_use.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s02.S02ToolUse
```
1. `Read the file requirements.txt`
2. `Create a file called greet.py with a greet(name) function`
3. `Edit greet.py to add a docstring to the function`
4. `Read greet.py to verify the edit worked`
> 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`
+68 -44
View File
@@ -2,13 +2,13 @@
`s01 > s02 > [ s03 ] s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"An agent without a plan drifts"* -- list the steps first, then execute.
> *"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. It repeats work, skips steps, or wanders off. Long conversations make this worse -- the system prompt fades as tool results fill the context. A 10-step refactoring might complete steps 1-3, then the model starts improvising because it forgot steps 4-10.
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
@@ -28,69 +28,93 @@ On multi-step tasks, the model loses track. It repeats work, skips steps, or wan
| [x] task C |
+-----------------------+
|
if rounds_since_todo >= 3:
inject <reminder> into tool_result
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.
```python
class TodoManager:
def update(self, items: list) -> str:
validated, in_progress_count = [], 0
for item in items:
status = item.get("status", "pending")
if status == "in_progress":
in_progress_count += 1
validated.append({"id": item["id"], "text": item["text"],
"status": status})
if in_progress_count > 1:
raise ValueError("Only one task can be in_progress")
self.items = validated
return self.render()
```
```java
public class TodoManager {
2. The `todo` tool goes into the dispatch map like any other tool.
public record TodoItem(String id, String text, String status) {}
```python
TOOL_HANDLERS = {
# ...base tools...
"todo": lambda **kw: TODO.update(kw["items"]),
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();
}
}
```
3. A nag reminder injects a nudge if the model goes 3+ rounds without calling `todo`.
2. `TodoManager` is registered via `defaultTools()`; the `@Tool` annotated method is automatically exposed as a tool.
```python
if rounds_since_todo >= 3 and messages:
last = messages[-1]
if last["role"] == "user" and isinstance(last.get("content"), list):
last["content"].insert(0, {
"type": "text",
"text": "<reminder>Update your todos.</reminder>",
})
```java
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem(system)
.defaultTools(
new BashTool(),
new ReadFileTool(),
new WriteFileTool(),
new EditFileTool(),
todoManager // @Tool annotated method auto-registered
)
.build();
```
The "one in_progress at a time" constraint forces sequential focus. The nag reminder creates accountability.
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 (+todo) |
| Planning | None | TodoManager with statuses |
| Nag injection | None | `<reminder>` after 3 rounds|
| Agent loop | Simple dispatch | + rounds_since_todo counter|
| 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
python agents/s03_todo_write.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s03.S03TodoWrite
```
1. `Refactor the file hello.py: add type hints, docstrings, and a main guard`
2. `Create a Python package with __init__.py, utils.py, and tests/test_utils.py`
3. `Review all Python files and fix any style issues`
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`
+57 -49
View File
@@ -8,7 +8,7 @@
## Problem
As the agent works, its messages array grows. Every file read, every bash output stays in context permanently. "What testing framework does this project use?" might require reading 5 files, but the parent only needs the answer: "pytest."
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
@@ -28,67 +28,75 @@ Parent context stays clean. Subagent context is discarded.
## How It Works
1. The parent gets a `task` tool. The child gets all base tools except `task` (no recursive spawning).
1. The parent agent has a `task` tool. The subagent gets all base tools except `task` (no recursive spawning).
```python
PARENT_TOOLS = CHILD_TOOLS + [
{"name": "task",
"description": "Spawn a subagent with fresh context.",
"input_schema": {
"type": "object",
"properties": {"prompt": {"type": "string"}},
"required": ["prompt"],
}},
]
```
2. The subagent starts with `messages=[]` and runs its own loop. Only the final text returns to the parent.
```python
def run_subagent(prompt: str) -> str:
sub_messages = [{"role": "user", "content": prompt}]
for _ in range(30): # safety limit
response = client.messages.create(
model=MODEL, system=SUBAGENT_SYSTEM,
messages=sub_messages,
tools=CHILD_TOOLS, max_tokens=8000,
```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
)
sub_messages.append({"role": "assistant",
"content": response.content})
if response.stop_reason != "tool_use":
break
results = []
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input)
results.append({"type": "tool_result",
"tool_use_id": block.id,
"content": str(output)[:50000]})
sub_messages.append({"role": "user", "content": results})
return "".join(
b.text for b in response.content if hasattr(b, "text")
) or "(no summary)"
.build();
```
The child's entire message history (possibly 30+ tool calls) is discarded. The parent receives a one-paragraph summary as a normal `tool_result`.
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) + task (parent) |
| Context | Single shared | Parent + child isolation |
| Subagent | None | `run_subagent()` function |
| Return value | N/A | Summary text only |
| 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
python agents/s04_subagent.py
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 .py files and summarize what each one does`
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`
+85 -38
View File
@@ -8,7 +8,7 @@
## Problem
You want the agent to follow domain-specific workflows: git conventions, testing patterns, code review checklists. Putting everything in the system prompt wastes tokens on unused skills. 10 skills at 2000 tokens each = 20,000 tokens, most of which are irrelevant to any given task.
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
@@ -35,7 +35,7 @@ Layer 1: skill *names* in system prompt (cheap). Layer 2: full *body* via tool_r
## How It Works
1. Each skill is a directory containing a `SKILL.md` with YAML frontmatter.
1. Each skill is a directory containing a `SKILL.md` file with YAML frontmatter.
```
skills/
@@ -45,42 +45,87 @@ skills/
SKILL.md # ---\n name: code-review\n description: Review code\n ---\n ...
```
2. SkillLoader scans for `SKILL.md` files, uses the directory name as the skill identifier.
2. SkillLoader recursively scans for `SKILL.md` files, using the directory name as the skill identifier.
```python
class SkillLoader:
def __init__(self, skills_dir: Path):
self.skills = {}
for f in sorted(skills_dir.rglob("SKILL.md")):
text = f.read_text()
meta, body = self._parse_frontmatter(text)
name = meta.get("name", f.parent.name)
self.skills[name] = {"meta": meta, "body": body}
```java
public class SkillLoader {
def get_descriptions(self) -> str:
lines = []
for name, skill in self.skills.items():
desc = skill["meta"].get("description", "")
lines.append(f" - {name}: {desc}")
return "\n".join(lines)
private static final Pattern FRONTMATTER_PATTERN =
Pattern.compile("^---\\n(.*?)\\n---\\n(.*)", Pattern.DOTALL);
def get_content(self, name: str) -> str:
skill = self.skills.get(name)
if not skill:
return f"Error: Unknown skill '{name}'."
return f"<skill name=\"{name}\">\n{skill['body']}\n</skill>"
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 just another tool handler.
3. Layer 1 goes into the system prompt. Layer 2 is loaded on demand via the `@Tool` annotated method on SkillLoader.
```python
SYSTEM = f"""You are a coding agent at {WORKDIR}.
Skills available:
{SKILL_LOADER.get_descriptions()}"""
```java
public S05SkillLoading(ChatModel chatModel) {
Path skillsDir = Path.of(System.getProperty("user.dir"), "skills");
SkillLoader skillLoader = new SkillLoader(skillsDir);
TOOL_HANDLERS = {
# ...base tools...
"load_skill": lambda **kw: SKILL_LOADER.get_content(kw["name"]),
// 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();
}
```
@@ -88,20 +133,22 @@ The model learns what skills exist (cheap) and loads them when relevant (expensi
## 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)|
| Component | Before (s04) | After (s05) |
|----------------|------------------|--------------------------------|
| Tools | 5 (base + task) | 5 (base + load_skill) |
| System prompt | Static string | + skill descriptions |
| Knowledge | None | skills/\*/SKILL.md files |
| Injection | None | Two-layer (system + result) |
## Try It
```sh
cd learn-claude-code
python agents/s05_skill_loading.py
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`
+118 -58
View File
@@ -8,7 +8,7 @@
## Problem
The context window is finite. A single `read_file` on a 1000-line file costs ~4000 tokens. After reading 30 files and running 20 bash commands, you hit 100,000+ tokens. The agent cannot work on large codebases without compression.
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
@@ -44,82 +44,142 @@ continue [Layer 2: auto_compact]
## How It Works
1. **Layer 1 -- micro_compact**: Before each LLM call, replace old tool results with placeholders.
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.
```python
def micro_compact(messages: list) -> list:
tool_results = []
for i, msg in enumerate(messages):
if msg["role"] == "user" and isinstance(msg.get("content"), list):
for j, part in enumerate(msg["content"]):
if isinstance(part, dict) and part.get("type") == "tool_result":
tool_results.append((i, j, part))
if len(tool_results) <= KEEP_RECENT:
return messages
for _, _, part in tool_results[:-KEEP_RECENT]:
if len(part.get("content", "")) > 100:
part["content"] = f"[Previous: used {tool_name}]"
return messages
```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 threshold, save full transcript to disk, then ask the LLM to summarize.
2. **Layer 2 -- auto_compact**: When tokens exceed the threshold, save the full conversation to disk and have the LLM summarize it.
```python
def auto_compact(messages: list) -> list:
# Save transcript for recovery
transcript_path = TRANSCRIPT_DIR / f"transcript_{int(time.time())}.jsonl"
with open(transcript_path, "w") as f:
for msg in messages:
f.write(json.dumps(msg, default=str) + "\n")
# LLM summarizes
response = client.messages.create(
model=MODEL,
messages=[{"role": "user", "content":
"Summarize this conversation for continuity..."
+ json.dumps(messages, default=str)[:80000]}],
max_tokens=2000,
)
return [
{"role": "user", "content": f"[Compressed]\n\n{response.content[0].text}"},
{"role": "assistant", "content": "Understood. Continuing."},
]
```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 `compact` tool triggers the same summarization on demand.
3. **Layer 3 -- manual compact**: The `CompactTool` triggers the same summarization mechanism on demand.
4. The loop integrates all three:
```java
public class CompactTool {
private final ContextCompactor compactor;
```python
def agent_loop(messages: list):
while True:
micro_compact(messages) # Layer 1
if estimate_tokens(messages) > THRESHOLD:
messages[:] = auto_compact(messages) # Layer 2
response = client.messages.create(...)
# ... tool execution ...
if manual_compact:
messages[:] = auto_compact(messages) # Layer 3
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.";
}
}
```
Transcripts preserve full history on disk. Nothing is truly lost -- just moved out of active context.
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 |
| Micro-compact | None | Old results -> placeholders|
| Auto-compact | None | Token threshold trigger |
| Transcripts | None | Saved to .transcripts/ |
| 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
python agents/s06_context_compact.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s06.S06ContextCompact
```
1. `Read every Python file in the agents/ directory one by one` (watch micro-compact replace old results)
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`
+84 -41
View File
@@ -10,7 +10,7 @@
s03's TodoManager is a flat checklist in memory: no ordering, no dependencies, no status beyond done-or-not. Real goals have structure -- task B depends on task A, tasks C and D can run in parallel, task E waits for both C and D.
Without explicit relationships, the agent can't tell what's ready, what's blocked, or what can run concurrently. And because the list lives only in memory, context compression (s06) wipes it clean.
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
@@ -48,57 +48,98 @@ This task graph becomes the coordination backbone for everything after s07: back
## How It Works
1. **TaskManager**: one JSON file per task, CRUD with dependency graph.
1. **TaskManager**: one JSON file per task, CRUD with dependency graph. Uses Jackson `ObjectMapper` for JSON serialization.
```python
class TaskManager:
def __init__(self, tasks_dir: Path):
self.dir = tasks_dir
self.dir.mkdir(exist_ok=True)
self._next_id = self._max_id() + 1
```java
public class TaskManager {
private static final ObjectMapper MAPPER = new ObjectMapper();
private final Path dir;
private int nextId;
def create(self, subject, description=""):
task = {"id": self._next_id, "subject": subject,
"status": "pending", "blockedBy": [],
"blocks": [], "owner": ""}
self._save(task)
self._next_id += 1
return json.dumps(task, indent=2)
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.
```python
def _clear_dependency(self, completed_id):
for f in self.dir.glob("task_*.json"):
task = json.loads(f.read_text())
if completed_id in task.get("blockedBy", []):
task["blockedBy"].remove(completed_id)
self._save(task)
```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 + dependency wiring**: `update` handles transitions and dependency edges.
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.
```python
def update(self, task_id, status=None,
add_blocked_by=None, add_blocks=None):
task = self._load(task_id)
if status:
task["status"] = status
if status == "completed":
self._clear_dependency(task_id)
self._save(task)
```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. Four task tools go into the dispatch map.
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.
```python
TOOL_HANDLERS = {
# ...base tools...
"task_create": lambda **kw: TASKS.create(kw["subject"]),
"task_update": lambda **kw: TASKS.update(kw["task_id"], kw.get("status")),
"task_list": lambda **kw: TASKS.list_all(),
"task_get": lambda **kw: TASKS.get(kw["task_id"]),
```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();
}
}
```
@@ -118,9 +159,11 @@ From s07 onward, the task graph is the default for multi-step work. s03's Todo r
```sh
cd learn-claude-code
python agents/s07_task_system.py
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`
+79 -50
View File
@@ -2,7 +2,7 @@
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > [ s08 ] s09 > s10 > s11 > s12`
> *"Run slow operations in the background; the agent keeps thinking"* -- daemon threads run commands, inject notifications on completion.
> *"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.
@@ -32,78 +32,107 @@ Agent --[spawn A]--[spawn B]--[other work]----
## How It Works
1. BackgroundManager tracks tasks with a thread-safe notification queue.
1. BackgroundManager tracks tasks with thread-safe concurrent containers. Java uses `ConcurrentHashMap` and `CopyOnWriteArrayList` instead of Python's manual locking.
```python
class BackgroundManager:
def __init__(self):
self.tasks = {}
self._notification_queue = []
self._lock = threading.Lock()
```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. `run()` starts a daemon thread and returns immediately.
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.
```python
def run(self, command: str) -> str:
task_id = str(uuid.uuid4())[:8]
self.tasks[task_id] = {"status": "running", "command": command}
thread = threading.Thread(
target=self._execute, args=(task_id, command), daemon=True)
thread.start()
return f"Background task {task_id} started"
```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, its result goes into the notification queue.
3. When the subprocess finishes, the result goes into the notification queue. Uses `ProcessBuilder` for command execution with timeout control.
```python
def _execute(self, task_id, command):
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=300)
output = (r.stdout + r.stderr).strip()[:50000]
except subprocess.TimeoutExpired:
output = "Error: Timeout (300s)"
with self._lock:
self._notification_queue.append({
"task_id": task_id, "result": output[:500]})
```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. The agent loop drains notifications before each LLM call.
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.
```python
def agent_loop(messages: list):
while True:
notifs = BG.drain_notifications()
if notifs:
notif_text = "\n".join(
f"[bg:{n['task_id']}] {n['result']}" for n in notifs)
messages.append({"role": "user",
"content": f"<background-results>\n{notif_text}\n"
f"</background-results>"})
messages.append({"role": "assistant",
"content": "Noted background results."})
response = client.messages.create(...)
```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 + background_run + check)|
| Execution | Blocking only | Blocking + background threads|
| Notification | None | Queue drained per loop |
| Concurrency | None | Daemon threads |
| 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
python agents/s08_background_tasks.py
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`
+109 -61
View File
@@ -2,7 +2,7 @@
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > [ s09 ] s10 > s11 > s12`
> *"When the task is too big for one, delegate to teammates"* -- persistent teammates + async mailboxes.
> *"When the task is too big for one, delegate to teammates"* -- persistent teammates + JSONL mailboxes.
>
> **Harness layer**: Team mailboxes -- multiple models, coordinated through files.
@@ -10,7 +10,7 @@
Subagents (s04) are disposable: spawn, work, return summary, die. No identity, no memory between invocations. Background tasks (s08) run shell commands but can't make LLM-guided decisions.
Real teamwork needs: (1) persistent agents that outlive a single prompt, (2) identity and lifecycle management, (3) a communication channel between agents.
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
@@ -37,89 +37,137 @@ Communication:
## How It Works
1. TeammateManager maintains config.json with the team roster.
1. TeammateManager maintains the team roster via config.json.
```python
class TeammateManager:
def __init__(self, team_dir: Path):
self.dir = team_dir
self.dir.mkdir(exist_ok=True)
self.config_path = self.dir / "config.json"
self.config = self._load_config()
self.threads = {}
```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.
```python
def spawn(self, name: str, role: str, prompt: str) -> str:
member = {"name": name, "role": role, "status": "working"}
self.config["members"].append(member)
self._save_config()
thread = threading.Thread(
target=self._teammate_loop,
args=(name, role, prompt), daemon=True)
thread.start()
return f"Spawned teammate '{name}' (role: {role})"
```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.
```python
class MessageBus:
def send(self, sender, to, content, msg_type="message", extra=None):
msg = {"type": msg_type, "from": sender,
"content": content, "timestamp": time.time()}
if extra:
msg.update(extra)
with open(self.dir / f"{to}.jsonl", "a") as f:
f.write(json.dumps(msg) + "\n")
```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();
def read_inbox(self, name):
path = self.dir / f"{name}.jsonl"
if not path.exists(): return "[]"
msgs = [json.loads(l) for l in path.read_text().strip().splitlines() if l]
path.write_text("") # drain
return json.dumps(msgs, indent=2)
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 before every LLM call, injecting received messages into context.
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"`).
```python
def _teammate_loop(self, name, role, prompt):
messages = [{"role": "user", "content": prompt}]
for _ in range(50):
inbox = BUS.read_inbox(name)
if inbox != "[]":
messages.append({"role": "user",
"content": f"<inbox>{inbox}</inbox>"})
messages.append({"role": "assistant",
"content": "Noted inbox messages."})
response = client.messages.create(...)
if response.stop_reason != "tool_use":
break
# execute tools, append results...
self._find_member(name)["status"] = "idle"
```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 |
| Component | Before (s08) | After (s09) |
|----------------|------------------|------------------------------------|
| Tools | 6 | 9 (+spawn/send/read_inbox) |
| Agents | Single | Lead + N teammates |
| Persistence | None | config.json + JSONL inboxes |
| Threads | Background cmds | Full agent loops per thread |
| Lifecycle | Fire-and-forget | idle -> working -> idle |
| Communication | None | message + broadcast |
## Try It
```sh
cd learn-claude-code
python agents/s09_agent_teams.py
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`
+61 -33
View File
@@ -10,7 +10,7 @@
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).
**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.
@@ -44,61 +44,89 @@ Trackers:
1. The lead initiates shutdown by generating a request_id and sending through the inbox.
```python
shutdown_requests = {}
```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<>();
def handle_shutdown_request(teammate: str) -> str:
req_id = str(uuid.uuid4())[:8]
shutdown_requests[req_id] = {"target": teammate, "status": "pending"}
BUS.send("lead", teammate, "Please shut down gracefully.",
"shutdown_request", {"request_id": req_id})
return f"Shutdown request {req_id} sent (status: pending)"
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.
```python
if tool_name == "shutdown_response":
req_id = args["request_id"]
approve = args["approve"]
shutdown_requests[req_id]["status"] = "approved" if approve else "rejected"
BUS.send(sender, "lead", args.get("reason", ""),
"shutdown_response",
{"request_id": req_id, "approve": approve})
```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).
```python
plan_requests = {}
```java
// ProtocolTracker - same request_id correlation pattern, two use cases
private final ConcurrentHashMap<String, Map<String, String>> planRequests
= new ConcurrentHashMap<>();
def handle_plan_review(request_id, approve, feedback=""):
req = plan_requests[request_id]
req["status"] = "approved" if approve else "rejected"
BUS.send("lead", req["from"], feedback,
"plan_approval_response",
{"request_id": request_id, "approve": approve})
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 |
| Component | Before (s09) | After (s10) |
|----------------|------------------|--------------------------------------|
| Tools | 9 | 12 (+shutdown_req/resp +plan) |
| Shutdown | Natural exit only| Request-response handshake |
| Plan gating | None | Submit/review with approval |
| Correlation | None | request_id per request |
| FSM | None | pending -> approved/rejected |
## Try It
```sh
cd learn-claude-code
python agents/s10_team_protocols.py
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.`
+120 -69
View File
@@ -2,17 +2,17 @@
`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.
> *"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 spawn each one with a specific prompt. 10 unclaimed tasks on the board? The lead assigns each one manually. Doesn't scale.
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 compression (s06), the agent might forget who it is. Identity re-injection fixes this.
One subtlety: after context compaction (s06), the agent might forget who it is. Identity re-injection fixes this.
## Solution
@@ -40,101 +40,152 @@ Teammate lifecycle with idle cycle:
|
+---> 60s timeout ----------------------> SHUTDOWN
Identity re-injection after compression:
if len(messages) <= 3:
messages.insert(0, identity_block)
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.
```python
def _loop(self, name, role, prompt):
while True:
# -- WORK PHASE --
messages = [{"role": "user", "content": prompt}]
for _ in range(50):
response = client.messages.create(...)
if response.stop_reason != "tool_use":
break
# execute tools...
if idle_requested:
break
```java
// src/main/java/io/mybatis/learn/s11/S11AutonomousAgents.java
// AutonomousTeammateManager.autonomousLoop()
# -- IDLE PHASE --
self._set_status(name, "idle")
resume = self._idle_poll(name, messages)
if not resume:
self._set_status(name, "shutdown")
return
self._set_status(name, "working")
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.
```python
def _idle_poll(self, name, messages):
for _ in range(IDLE_TIMEOUT // POLL_INTERVAL): # 60s / 5s = 12
time.sleep(POLL_INTERVAL)
inbox = BUS.read_inbox(name)
if inbox:
messages.append({"role": "user",
"content": f"<inbox>{inbox}</inbox>"})
return True
unclaimed = scan_unclaimed_tasks()
if unclaimed:
claim_task(unclaimed[0]["id"], name)
messages.append({"role": "user",
"content": f"<auto-claimed>Task #{unclaimed[0]['id']}: "
f"{unclaimed[0]['subject']}</auto-claimed>"})
return True
return False # timeout -> shutdown
```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.
```python
def scan_unclaimed_tasks() -> list:
unclaimed = []
for f in sorted(TASKS_DIR.glob("task_*.json")):
task = json.loads(f.read_text())
if (task.get("status") == "pending"
and not task.get("owner")
and not task.get("blockedBy")):
unclaimed.append(task)
return unclaimed
```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 re-injection: when context is too short (compression happened), insert an identity block.
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.
```python
if len(messages) <= 3:
messages.insert(0, {"role": "user",
"content": f"<identity>You are '{name}', role: {role}, "
f"team: {team_name}. Continue your work.</identity>"})
messages.insert(1, {"role": "assistant",
"content": f"I am {name}. Continuing."})
```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 compress|
| Timeout | None | 60s idle -> auto shutdown |
| 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
python agents/s11_autonomous_agents.py
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.`
+51 -26
View File
@@ -8,7 +8,7 @@
## Problem
By s11, agents can claim and complete tasks autonomously. But every task runs in one shared directory. Two agents refactoring different modules at the same time will collide: agent A edits `config.py`, agent B edits `config.py`, unstaged changes mix, and neither can roll back cleanly.
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.
@@ -38,48 +38,71 @@ State machines:
1. **Create a task.** Persist the goal first.
```python
TASKS.create("Implement auth refactor")
# -> .tasks/task_1.json status=pending worktree=""
```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`.
```python
WORKTREES.create("auth-refactor", task_id=1)
# -> git worktree add -b wt/auth-refactor .worktrees/auth-refactor HEAD
# -> index.json gets new entry, task_1.json gets worktree="auth-refactor"
```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:
```python
def bind_worktree(self, task_id, worktree):
task = self._load(task_id)
task["worktree"] = worktree
if task["status"] == "pending":
task["status"] = "in_progress"
self._save(task)
```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.
```python
subprocess.run(command, shell=True, cwd=worktree_path,
capture_output=True, text=True, timeout=300)
```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.
```python
def remove(self, name, force=False, complete_task=False):
self._run_git(["worktree", "remove", wt["path"]])
if complete_task and wt.get("task_id") is not None:
self.tasks.update(wt["task_id"], status="completed")
self.tasks.unbind_worktree(wt["task_id"])
self.events.emit("task.completed", ...)
```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`:
@@ -111,9 +134,11 @@ After a crash, state reconstructs from `.tasks/` + `.worktrees/index.json` on di
```sh
cd learn-claude-code
python agents/s12_worktree_task_isolation.py
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".`
+266 -64
View File
@@ -1,4 +1,4 @@
# s01: The Agent Loop
# s01: The Agent Loop (エージェントループ)
`[ s01 ] s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
@@ -8,7 +8,7 @@
## 問題
言語モデルはコードについて推論できるが、現実世界に触れられないファイルを読めず、テストを実行できず、エラーを確認できない。ループがなければ、ツール呼び出しのたびにユーザーが手動で結果をコピーペーストする必要がある。つまりユーザー自身がループになる。
言語モデルはコードについて推論できるが、現実世界に触れられない -- ファイルを読めず、テストを実行できず、エラーを確認できない。ループがなければ、ツール呼び出しのたびに手動で結果を貼り戻す必要がある。あなた自身がそのループになる。
## 解決策
@@ -20,97 +20,299 @@
^ |
| tool_result |
+----------------+
(loop until stop_reason != "tool_use")
(ChatClient.call() がツール呼び出しがなくなるまで自動ループ)
```
1つの終了条件がフロー全体を制御する。モデルがツール呼び出しを止めるまでループが回り続ける。
1つの `call()` 呼び出しがフロー全体を制御する。Spring AI が自動的にループし、モデルがツール呼び出しを止めるまで続ける。
## 仕組み
1. ユーザーのプロンプトが最初のメッセージになる。
### 1. ChatClient の構築:モデル注入 + ツール登録
```python
messages.append({"role": "user", "content": query})
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. メッセージとツール定義をLLMに送信する。
### 2. `@Tool` アノテーション:宣言的ツール登録
```python
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
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 実行 + タイムアウト制御 + 出力切り詰め
// ...
}
}
```
3. アシスタントのレスポンスを追加し、`stop_reason`を確認する。ツールが呼ばれなければ終了。
> Python の手動登録方式との比較:
> - Python: `TOOLS = [{"name": "bash", "input_schema": {...}}]` + `TOOL_HANDLERS = {"bash": run_bash}`
> - Java: `@Tool` + `@ToolParam` アノテーションだけで、フレームワークがスキーマ生成とメソッドディスパッチを自動化
### 3. Spring AI 内部自動ループ:`call()` の内部実装
**これが Java 版と Python 版の最も重要な違いだ。** Python 版ではツール呼び出しを駆動するために手書きの while ループが必要:
```python
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
```
4. 各ツール呼び出しを実行し、結果を収集してuserメッセージとして追加。ステップ2に戻る。
```python
results = []
for block in response.content:
if block.type == "tool_use":
output = run_bash(block.input["command"])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
```
1つの関数にまとめると:
```python
def agent_loop(query):
messages = [{"role": "user", "content": query}]
# Python 版 -- 手動ループ
def agent_loop(messages):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
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
results = []
return response # モデルがツールを呼ばなくなった、ループ終了
# ツールを実行して結果を返送
for block in response.content:
if block.type == "tool_use":
output = run_bash(block.input["command"])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
result = TOOL_HANDLERS[block.name](block.input)
messages.append({"role": "user", "content": [{"type": "tool_result", ...}]})
```
これでエージェント全体が30行未満に収まる。本コースの残りはすべてこのループの上に積み重なる -- ループ自体は変わらない。
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` は全レッスン共通の REPLRead-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章はすべてこのループの上にメカニズムを積み重ねる -- ループ自体は決して変わらない。
## 変更点
| Component | Before | After |
|---------------|------------|--------------------------------|
| Agent loop | (none) | `while True` + stop_reason |
| Tools | (none) | `bash` (one tool) |
| Messages | (none) | Accumulating list |
| Control flow | (none) | `stop_reason != "tool_use"` |
| コンポーネント | 変更前 | 変更後 |
|---------------|------------|--------------------------------------------------|
| 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
python agents/s01_agent_loop.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s01.S01AgentLoop
```
1. `Create a file called hello.py that prints "Hello, World!"`
2. `List all Python files in this directory`
> 実行前に環境変数の設定が必要: `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` を変更するだけで接続でき、プロトコル切り替えは不要。
#### 方式 2Anthropic プロトコル(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`
+102 -62
View File
@@ -1,99 +1,139 @@
# s02: Tool Use
# s02: Tool Use (ツール使用)
`s01 > [ s02 ] s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"ツールを足すなら、ハンドラーを1つ足すだけ"* -- ループは変わらない。新ツールは dispatch map に登録するだけ。
> *"ツールを足すなら、@Tool メソッドを1つ足すだけ"* -- ループは変わらない。新ツールは `defaultTools()` に渡すだけ。
>
> **Harness 層**: ツール分配 -- モデルが届く範囲を広げる。
## 問題
`bash`だけでは、エージェントは何でもシェル経由で行う`cat`は予測不能に切り詰め、`sed`は特殊文字で壊れ、すべてのbash呼び出しが制約のないセキュリティ面になる。`read_file``write_file`のような専用ツールならツールレベルでパスのサンドボックス化を強制できる。
`bash` だけでは、すべての操作がシェル経由になる`cat` は予測不能に切り詰め、`sed` は特殊文字で壊れ、すべての bash 呼び出しが制約のないセキュリティ面になる。専用ツール (`read_file`, `write_file`) ならツールレベルでパスのサンドボックス化を強制できる。
重要な: ツールを追加してもループの変更は不要。
重要な洞察: ツールを追加してもループの変更は不要。
## 解決策
```
+--------+ +-------+ +------------------+
| User | ---> | LLM | ---> | Tool Dispatch |
| prompt | | | | { |
+--------+ +---+---+ | bash: run_bash |
^ | read: run_read |
| | write: run_wr |
+-----------+ edit: run_edit |
tool_result | } |
+------------------+
+--------+ +-------+ +--------------------+
| User | ---> | LLM | ---> | defaultTools() |
| prompt | | | | { |
+--------+ +---+---+ | BashTool |
^ | ReadFileTool |
| | WriteFileTool |
+-----------+ EditFileTool |
tool_result | } |
+--------------------+
The dispatch map is a dict: {tool_name: handler_function}.
One lookup replaces any if/elif chain.
Spring AI が @Tool アノテーションで自動的に登録・分配する。
手書きの dispatch map は不要、フレームワークがツールオブジェクトのアノテーションメソッドをスキャンする。
```
## 仕組み
1. 各ツールにハンドラ関数を定義する。パスサンドボックスでワークスペース外への脱出を防ぐ。
1. 各ツールは独立したクラスで、`@Tool` アノテーションで宣言する。`PathValidator`パスサンドボックスでワークスペース外への脱出を防ぐ。
```python
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
```java
// PathValidator -- Python 版の safe_path() 関数に相当
public class PathValidator {
private final Path workDir;
def run_read(path: str, limit: int = None) -> str:
text = safe_path(path).read_text()
lines = text.splitlines()
if limit and limit < len(lines):
lines = lines[:limit]
return "\n".join(lines)[:50000]
```
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;
}
}
2. ディスパッチマップがツール名とハンドラを結びつける。
// ReadFileTool -- Python 版の run_read() 関数に相当
public class ReadFileTool {
private final PathValidator pathValidator;
```python
TOOL_HANDLERS = {
"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"],
kw["new_text"]),
@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);
}
}
```
3. ループ内で名前によりハンドラをルックアップする。ループ本体はs01から不変
2. ツール登録は `defaultTools()` に渡すだけ。Spring AI が `@Tool` アノテーションメソッドをスキャンし、名前マッピングとパラメータバインディングを自動的に行う
```python
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler \
else f"Unknown tool: {block.name}"
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
```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 と完全に同一。ループはフレームワークが管理し、開発者はツール実装だけに集中する
## s01からの変更点
```java
// s01 との違いは defaultTools() に3つのツールオブジェクトが追加されたこと
// ループコードは完全に同一 -- これが s02 の核心的な洞察
AgentRunner.interactive("s02", userMessage ->
chatClient.prompt()
.user(userMessage)
.call()
.content()
);
```
| Component | Before (s01) | After (s02) |
|----------------|--------------------|----------------------------|
| Tools | 1 (bash only) | 4 (bash, read, write, edit)|
| Dispatch | Hardcoded bash call | `TOOL_HANDLERS` dict |
| Path safety | None | `safe_path()` sandbox |
| Agent loop | Unchanged | Unchanged |
ツール追加 = `@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
python agents/s02_tool_use.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s02.S02ToolUse
```
1. `Read the file requirements.txt`
2. `Create a file called greet.py with a greet(name) function`
3. `Edit greet.py to add a docstring to the function`
4. `Read greet.py to verify the edit worked`
> 実行前に環境変数の設定が必要: `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`
+70 -47
View File
@@ -1,14 +1,14 @@
# s03: TodoWrite
# s03: TodoWrite (Todo書き込み)
`s01 > s02 > [ s03 ] s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"計画のないエージェントは行き当たりばったり"* -- まずステップを書き出し、それから実行。
> *"計画のないエージェントは行き当たりばったり"* -- まずステップを書き出し、それから実行。完了率は倍増する。
>
> **Harness 層**: 計画 -- 航路を描かずにモデルを軌道に乗せる。
## 問題
マルチステップのタスクで、モデルは途中で迷子になる。作業を繰り返したり、ステップを飛ばしたり、脱線したりする。長い会話になるほど悪化する -- ツール結果がコンテキストを埋めるにつれ、システムプロンプトの影響力が薄れる。10ステップのリファクタリングでステップ1-3を完了した後、残りを忘れて即興を始めてしまう。
マルチステップのタスクで、モデルは進捗を見失う -- 既にやったことを繰り返したり、ステップを飛ばしたり、脱線したりする。会話が長くなるほど悪化する: ツール結果がコンテキストを埋め尽くし、システムプロンプトの影響力が徐々に薄れる。10ステップのリファクタリングでステップ1-3を完了した後、即興を始めてしまう。ステップ4-10はもう注意の外だ。
## 解決策
@@ -28,69 +28,92 @@
| [x] task C |
+-----------------------+
|
if rounds_since_todo >= 3:
inject <reminder> into tool_result
毎回のリクエスト時に defaultSystem() で
最新の todo 状態をシステムプロンプトに注入
```
## 仕組み
1. TodoManagerはアイテムのリストをステータス付き保持する。`in_progress`にできるのは同時に1つだけ。
1. TodoManager はステータス付きアイテムを保持する。同時に `in_progress` にできるのは1つだけ。
```python
class TodoManager:
def update(self, items: list) -> str:
validated, in_progress_count = [], 0
for item in items:
status = item.get("status", "pending")
if status == "in_progress":
in_progress_count += 1
validated.append({"id": item["id"], "text": item["text"],
"status": status})
if in_progress_count > 1:
raise ValueError("Only one task can be in_progress")
self.items = validated
return self.render()
```
```java
public class TodoManager {
2. `todo`ツールは他のツールと同様にディスパッチマップに追加される。
public record TodoItem(String id, String text, String status) {}
```python
TOOL_HANDLERS = {
# ...base tools...
"todo": lambda **kw: TODO.update(kw["items"]),
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();
}
}
```
3. nagリマインダーが、モデルが3ラウンド以上`todo`を呼ばなかった場合にナッジを注入する。
2. `TodoManager``defaultTools()` で登録し、`@Tool` アノテーションメソッドが自動的にツールとして公開される。
```python
if rounds_since_todo >= 3 and messages:
last = messages[-1]
if last["role"] == "user" and isinstance(last.get("content"), list):
last["content"].insert(0, {
"type": "text",
"text": "<reminder>Update your todos.</reminder>",
})
```java
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem(system)
.defaultTools(
new BashTool(),
new ReadFileTool(),
new WriteFileTool(),
new EditFileTool(),
todoManager // @Tool アノテーションメソッドが自動登録
)
.build();
```
「一度にin_progressは1つだけ」の制約が逐次的な集中を強制し、nagリマインダーが説明責任を生む
3. システムプロンプト注入: ユーザー入力のたびに、最新の todo 状態をシステムプロンプトに注入し、更新指示を強調する
## s02からの変更点
```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>";
```
| Component | Before (s02) | After (s03) |
|----------------|------------------|----------------------------|
| Tools | 4 | 5 (+todo) |
| Planning | None | TodoManager with statuses |
| Nag injection | None | `<reminder>` after 3 rounds|
| Agent loop | Simple dispatch | + rounds_since_todo counter|
「同時に 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
python agents/s03_todo_write.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s03.S03TodoWrite
```
1. `Refactor the file hello.py: add type hints, docstrings, and a main guard`
2. `Create a Python package with __init__.py, utils.py, and tests/test_utils.py`
3. `Review all Python files and fix any style issues`
以下のプロンプトを試してみよう (英語プロンプトの方が 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`
+59 -51
View File
@@ -1,4 +1,4 @@
# s04: Subagents
# s04: Subagents (サブエージェント)
`s01 > s02 > s03 > [ s04 ] s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
@@ -8,7 +8,7 @@
## 問題
エージェントが作業するにつれ、messages配列は膨張し続ける。すべてのファイル読み取り、すべてのbash出力がコンテキストに永久に残る。「このプロジェクトはどのテストフレームワークを使っているか」という質問は5つのファイルを読む必要があるかもしれないが、親に必要なのは「pytest」という答えだけだ。
エージェントが作業するにつれ、messages 配列は膨張し続ける。すべてのファイル読み取り、すべてのコマンド出力がコンテキストに永久に残る。「このプロジェクトはどのテストフレームワークを使っているか」という質問は5つのファイルを読む必要があるかもしれないが、親エージェントに必要なのは「pytest」という一言だけだ。
## 解決策
@@ -28,67 +28,75 @@ Parent context stays clean. Subagent context is discarded.
## 仕組み
1.`task`ツールを追加する。子は`task`を除くすべての基本ツールを取得する(再帰的な生成は不可)
1.エージェントに `task` ツールを持たせる。子は `task` を除くすべての基本ツールを持つ(再帰的な生成は不可
```python
PARENT_TOOLS = CHILD_TOOLS + [
{"name": "task",
"description": "Spawn a subagent with fresh context.",
"input_schema": {
"type": "object",
"properties": {"prompt": {"type": "string"}},
"required": ["prompt"],
}},
]
```
2. サブエージェントは`messages=[]`で開始し、自身のループを実行する。最終テキストだけが親に返る。
```python
def run_subagent(prompt: str) -> str:
sub_messages = [{"role": "user", "content": prompt}]
for _ in range(30): # safety limit
response = client.messages.create(
model=MODEL, system=SUBAGENT_SYSTEM,
messages=sub_messages,
tools=CHILD_TOOLS, max_tokens=8000,
```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 専用
)
sub_messages.append({"role": "assistant",
"content": response.content})
if response.stop_reason != "tool_use":
break
results = []
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input)
results.append({"type": "tool_result",
"tool_use_id": block.id,
"content": str(output)[:50000]})
sub_messages.append({"role": "user", "content": results})
return "".join(
b.text for b in response.content if hasattr(b, "text")
) or "(no summary)"
.build();
```
子のメッセージ履歴全体(30回以上のツール呼び出し)は破棄される。親は1段落の要約を通常の`tool_result`として受け取る。
2. サブエージェントは新しい `ChatClient` で起動し、独立したコンテキストを持つ。最終テキストだけが親に返る。
## s03からの変更点
```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) {
| Component | Before (s03) | After (s04) |
|----------------|------------------|---------------------------|
| Tools | 5 | 5 (base) + task (parent) |
| Context | Single shared | Parent + child isolation |
| Subagent | None | `run_subagent()` function |
| Return value | N/A | Summary text only |
// 新しい 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
python agents/s04_subagent.py
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 .py files and summarize what each one does`
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`
+90 -43
View File
@@ -1,4 +1,4 @@
# s05: Skills
# s05: Skills (スキルローディング)
`s01 > s02 > s03 > s04 > [ s05 ] s06 | s07 > s08 > s09 > s10 > s11 > s12`
@@ -8,7 +8,7 @@
## 問題
エージェントにドメイン固有のワークフローを遵守させたい: gitの規約、テストパターン、コードレビューチェックリスト。すべてをシステムプロンプトに入れると、使われないスキルにトークン浪費する。10スキル x 2000トークン = 20,000トークン、ほとんどが任意のタスク無関係
エージェントにドメイン固有のワークフローを遵守させたい: git の規約、テストパターン、コードレビューチェックリスト。すべてをシステムプロンプトに入れるとトークン浪費だ -- 10スキル x 2000トークン = 20,000トークン、大半が当面のタスクとは無関係。
## 解決策
@@ -31,11 +31,11 @@ When model calls load_skill("git"):
+--------------------------------------+
```
第1層: スキル*名*をシステムプロンプトに(低コスト)。第2層: スキル*本体*をtool_resultに(オンデマンド)
第1層: スキルをシステムプロンプトに低コスト。第2層: 完全なコンテンツを tool_resultオンデマンド配信
## 仕組み
1. 各スキルは `SKILL.md` ファイルを含むディレクトリとして配置される
1. 各スキルは `SKILL.md` ファイルを含むディレクトリで、YAML frontmatter 付き
```
skills/
@@ -45,63 +45,110 @@ skills/
SKILL.md # ---\n name: code-review\n description: Review code\n ---\n ...
```
2. SkillLoaderが `SKILL.md` を再帰的に探索し、ディレクトリ名をスキル識別子として使用する。
2. SkillLoader `SKILL.md` を再帰的にスキャンし、ディレクトリ名をスキル識別子として使用する。
```python
class SkillLoader:
def __init__(self, skills_dir: Path):
self.skills = {}
for f in sorted(skills_dir.rglob("SKILL.md")):
text = f.read_text()
meta, body = self._parse_frontmatter(text)
name = meta.get("name", f.parent.name)
self.skills[name] = {"meta": meta, "body": body}
```java
public class SkillLoader {
def get_descriptions(self) -> str:
lines = []
for name, skill in self.skills.items():
desc = skill["meta"].get("description", "")
lines.append(f" - {name}: {desc}")
return "\n".join(lines)
private static final Pattern FRONTMATTER_PATTERN =
Pattern.compile("^---\\n(.*?)\\n---\\n(.*)", Pattern.DOTALL);
def get_content(self, name: str) -> str:
skill = self.skills.get(name)
if not skill:
return f"Error: Unknown skill '{name}'."
return f"<skill name=\"{name}\">\n{skill['body']}\n</skill>"
```
private final Map<String, SkillInfo> skills = new LinkedHashMap<>();
3. 第1層はシステムプロンプトに配置。第2層は通常のツールハンドラ。
record SkillInfo(Map<String, String> meta, String body, String path) {}
```python
SYSTEM = f"""You are a coding agent at {WORKDIR}.
Skills available:
{SKILL_LOADER.get_descriptions()}"""
public SkillLoader(Path skillsDir) {
loadAll(skillsDir);
}
TOOL_HANDLERS = {
# ...base tools...
"load_skill": lambda **kw: SKILL_LOADER.get_content(kw["name"]),
/** 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` アノテーションメソッドでオンデマンド読み込み
## s04からの変更点
```java
public S05SkillLoading(ChatModel chatModel) {
Path skillsDir = Path.of(System.getProperty("user.dir"), "skills");
SkillLoader skillLoader = new SkillLoader(skillsDir);
| 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)|
// 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
python agents/s05_skill_loading.py
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`
+120 -60
View File
@@ -1,4 +1,4 @@
# s06: Context Compact
# s06: Context Compact (コンテキスト圧縮)
`s01 > s02 > s03 > s04 > s05 > [ s06 ] | s07 > s08 > s09 > s10 > s11 > s12`
@@ -8,7 +8,7 @@
## 問題
コンテキストウィンドウは有限だ。1000行のファイルに対する`read_file`1回で約4000トークンを消費する。30ファイルを読み20回のbashコマンドを実行すると、100,000トークン超。圧縮なしでは、エージェントは大規模コードベースで作業できない。
コンテキストウィンドウは有限だ。1000行のファイルを読むだけで約4000トークンを消費する。30ファイルを読み20回のコマンドを実行すると、100,000トークン超。圧縮なしでは、エージェントは大規模プロジェクトで作業できない。
## 解決策
@@ -44,82 +44,142 @@ continue [Layer 2: auto_compact]
## 仕組み
1. **第1層 -- micro_compact**: 各LLM呼び出しの前に、古いツール結果をプレースホルダーに置換する。
1. **第1層 -- コンテキストウィンドウ管理**: Spring AI の ChatClient は内部でツールループを自動管理するため、ループ内に圧縮を挿入できない。Java 版では、システムプロンプトに注入する会話ターン数を制限し(最近の N ターンのみ保持)、コンテンツを切り詰めることで同等の効果を実現する。
```python
def micro_compact(messages: list) -> list:
tool_results = []
for i, msg in enumerate(messages):
if msg["role"] == "user" and isinstance(msg.get("content"), list):
for j, part in enumerate(msg["content"]):
if isinstance(part, dict) and part.get("type") == "tool_result":
tool_results.append((i, j, part))
if len(tool_results) <= KEEP_RECENT:
return messages
for _, _, part in tool_results[:-KEEP_RECENT]:
if len(part.get("content", "")) > 100:
part["content"] = f"[Previous: used {tool_name}]"
return messages
```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に要約を依頼する。
2. **第2層 -- auto_compact**: トークンが閾値を超えたら、完全な会話をディスクに保存し、LLM に要約を依頼する。
```python
def auto_compact(messages: list) -> list:
# Save transcript for recovery
transcript_path = TRANSCRIPT_DIR / f"transcript_{int(time.time())}.jsonl"
with open(transcript_path, "w") as f:
for msg in messages:
f.write(json.dumps(msg, default=str) + "\n")
# LLM summarizes
response = client.messages.create(
model=MODEL,
messages=[{"role": "user", "content":
"Summarize this conversation for continuity..."
+ json.dumps(messages, default=str)[:80000]}],
max_tokens=2000,
)
return [
{"role": "user", "content": f"[Compressed]\n\n{response.content[0].text}"},
{"role": "assistant", "content": "Understood. Continuing."},
]
```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**: `compact`ツールが同じ要約処理をオンデマンドでトリガーする。
3. **第3層 -- manual compact**: `CompactTool` ツールが同じ要約メカニズムをオンデマンドでトリガーする。
4. ループが3層すべてを統合する:
```java
public class CompactTool {
private final ContextCompactor compactor;
```python
def agent_loop(messages: list):
while True:
micro_compact(messages) # Layer 1
if estimate_tokens(messages) > THRESHOLD:
messages[:] = auto_compact(messages) # Layer 2
response = client.messages.create(...)
# ... tool execution ...
if manual_compact:
messages[:] = auto_compact(messages) # Layer 3
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 が内部でツールループを自動管理するため、圧縮はユーザーメッセージレベルでトリガーされる):
## s05からの変更点
```java
AgentRunner.interactive("s06", userMessage -> {
// Layer 2: 自動圧縮チェック(毎回のユーザー入力前)
if (compactor.needsAutoCompact()) {
System.out.println("[auto_compact triggered]");
compactor.compact();
}
compactor.addTurn("user", userMessage);
| Component | Before (s05) | After (s06) |
|----------------|------------------|----------------------------|
| Tools | 5 | 5 (base + compact) |
| Context mgmt | None | Three-layer compression |
| Micro-compact | None | Old results -> placeholders|
| Auto-compact | None | Token threshold trigger |
| Transcripts | None | Saved to .transcripts/ |
// 動的システムプロンプト: 会話コンテキストサマリーを含む
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
python agents/s06_context_compact.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s06.S06ContextCompact
```
1. `Read every Python file in the agents/ directory one by one` (micro-compactが古い結果を置換するのを観察する)
以下のプロンプトを試してみよう (英語プロンプトの方が 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`
+103 -60
View File
@@ -1,4 +1,4 @@
# s07: Task System
# s07: Task System (タスクシステム)
`s01 > s02 > s03 > s04 > s05 > s06 | [ s07 ] s08 > s09 > s10 > s11 > s12`
@@ -8,17 +8,17 @@
## 問題
s03TodoManagerはメモリ上のフラットなチェックリストに過ぎない: 順序なし、依存関係なし、ステータスは完了か未完了のみ。実際の目標には構造がある -- タスクBはタスクAに依存し、タスクCとDは並行実行でき、タスクEはCとDの両方を待つ。
s03TodoManager はメモリ上のフラットなチェックリストに過ぎない: 順序なし、依存関係なし、ステータスは完了か未完了のみ。実際の目標には構造がある -- タスク B はタスク A に依存し、タスク C と D は並行実行でき、タスク E は C と D の両方を待つ。
明示的な関係がなければ、エージェントは何が実行可能で、何がブロックされ、何が同時に走れるかを判断できない。しかもリストはメモリ上にしかないため、コンテキスト圧縮(s06)で消える。
明示的な関係がなければ、エージェントは何が実行可能で、何がブロックされ、何が同時に走れるかを判断できない。しかもリストはメモリ上にしかないため、コンテキスト圧縮 (s06) で消える。
## 解決策
フラットなチェックリストをディスクに永続化する**タスクグラフ**に昇格させる。各タスクは1つのJSONファイルで、ステータス・前方依存(`blockedBy`)・後方依存(`blocks`)を持つ。タスクグラフは常に3つの問いに答える:
フラットなチェックリストをディスクに永続化する**タスクグラフ**に昇格させる。各タスクは1つの JSON ファイルで、ステータス・前方依存 (`blockedBy`)・後方依存 (`blocks`) を持つ。タスクグラフは常に3つの問いに答える:
- **何が実行可能か?** -- `pending`ステータスで`blockedBy`が空のタスク。
- **何が実行可能か?** -- `pending` ステータスで `blockedBy` が空のタスク。
- **何がブロックされているか?** -- 未完了の依存を待つタスク。
- **何が完了したか?** -- `completed`のタスク。完了時に後続タスクを自動的にアンブロックする。
- **何が完了したか?** -- `completed` のタスク。完了時に後続タスクを自動的にアンブロックする。
```
.tasks/
@@ -44,72 +44,113 @@ s03のTodoManagerはメモリ上のフラットなチェックリストに過ぎ
ステータス: pending -> in_progress -> completed
```
このタスクグラフは s07 以降の全メカニズムの協調バックボーンとなる: バックグラウンド実行(s08)、マルチエージェントチーム(s09+)、worktree分離(s12)はすべてこの同じ構造を読み書きする。
このタスクグラフは s07 以降の全メカニズムの協調バックボーンとなる: バックグラウンド実行 (s08)、マルチエージェントチーム (s09+)、worktree 分離 (s12) はすべてこの同じ構造を読み書きする。
## 仕組み
1. **TaskManager**: タスクごとに1つのJSONファイル、依存グラフ付きCRUD。
1. **TaskManager**: タスクごとに1つの JSON ファイル、依存グラフ付き CRUD。Jackson `ObjectMapper` で JSON シリアライゼーションを行う。
```python
class TaskManager:
def __init__(self, tasks_dir: Path):
self.dir = tasks_dir
self.dir.mkdir(exist_ok=True)
self._next_id = self._max_id() + 1
```java
public class TaskManager {
private static final ObjectMapper MAPPER = new ObjectMapper();
private final Path dir;
private int nextId;
def create(self, subject, description=""):
task = {"id": self._next_id, "subject": subject,
"status": "pending", "blockedBy": [],
"blocks": [], "owner": ""}
self._save(task)
self._next_id += 1
return json.dumps(task, indent=2)
```
public TaskManager(Path tasksDir) {
this.dir = tasksDir;
Files.createDirectories(dir);
this.nextId = maxId() + 1;
}
2. **依存解除**: タスク完了時に、他タスクの`blockedBy`リストから完了IDを除去し、後続タスクをアンブロックする。
```python
def _clear_dependency(self, completed_id):
for f in self.dir.glob("task_*.json"):
task = json.loads(f.read_text())
if completed_id in task.get("blockedBy", []):
task["blockedBy"].remove(completed_id)
self._save(task)
```
3. **ステータス遷移 + 依存配線**: `update`がステータス変更と依存エッジを担う。
```python
def update(self, task_id, status=None,
add_blocked_by=None, add_blocks=None):
task = self._load(task_id)
if status:
task["status"] = status
if status == "completed":
self._clear_dependency(task_id)
self._save(task)
```
4. 4つのタスクツールをディスパッチマップに追加する。
```python
TOOL_HANDLERS = {
# ...base tools...
"task_create": lambda **kw: TASKS.create(kw["subject"]),
"task_update": lambda **kw: TASKS.update(kw["task_id"], kw.get("status")),
"task_list": lambda **kw: TASKS.list_all(),
"task_get": lambda **kw: TASKS.get(kw["task_id"]),
@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);
}
}
```
s07以降、タスクグラフがマルチステップ作業のデフォルト。s03のTodoは軽量な単一セッション用チェックリストとして残る。
2. **依存解除**: タスク完了時に、他タスクの `blockedBy` リストから完了 ID を除去し、後続タスクをアンブロックする。
## s06からの変更点
```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);
}
});
}
}
```
| コンポーネント | Before (s06) | After (s07) |
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` |
| 永続性 | 圧縮で消失 | 圧縮・再起動後も存続 |
@@ -118,9 +159,11 @@ s07以降、タスクグラフがマルチステップ作業のデフォルト
```sh
cd learn-claude-code
python agents/s07_task_system.py
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`
+83 -54
View File
@@ -1,14 +1,14 @@
# s08: Background Tasks
# s08: Background Tasks (バックグラウンドタスク)
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > [ s08 ] s09 > s10 > s11 > s12`
> *"遅い操作はバックグラウンドへ、エージェントは次を考え続ける"* -- デーモンスレッドがコマンド実行、完了後に通知を注入。
> *"遅い操作はバックグラウンドへ、エージェントは次を考え続ける"* -- バックグラウンドスレッドがコマンド実行、完了後に通知を注入。
>
> **Harness 層**: バックグラウンド実行 -- モデルが考え続ける間、Harness が待つ。
## 問題
一部のコマンドは数分かかる: `npm install``pytest``docker build`。ブロッキングループでは、モデルはサブプロセスの完了を待って座っている。ユーザーが「依存関係をインストールして、その間にconfigファイルを作って」と言っても、エージェントは並列ではなく逐次的に処理する
一部のコマンドは数分かかる: `npm install``pytest``docker build`。ブロッキングループでは、モデルは待つしかない。ユーザーが「依存関係をインストールして、その間に config ファイルを作って」と言っても、エージェントは1つずつしか処理できない
## 解決策
@@ -32,78 +32,107 @@ Agent --[spawn A]--[spawn B]--[other work]----
## 仕組み
1. BackgroundManagerがスレッドセーフな通知キューでタスクを追跡する。
1. BackgroundManager がスレッドセーフな並行コンテナでタスクを追跡する。Java では `ConcurrentHashMap``CopyOnWriteArrayList` を使用し、Python の手動ロックを置き換える。
```python
class BackgroundManager:
def __init__(self):
self.tasks = {}
self._notification_queue = []
self._lock = threading.Lock()
```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. `run()`がデーモンスレッドを開始し、即座にリターンする。
2. `backgroundRun()` が仮想スレッド (Java 21) に投入し、即座にリターンする。Python の `daemon=True` スレッドに比べ、仮想スレッドはより軽量で JVM がスケジュールする。
```python
def run(self, command: str) -> str:
task_id = str(uuid.uuid4())[:8]
self.tasks[task_id] = {"status": "running", "command": command}
thread = threading.Thread(
target=self._execute, args=(task_id, command), daemon=True)
thread.start()
return f"Background task {task_id} started"
```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. サブプロセス完了時に、結果通知キュー
3. サブプロセス完了時に、結果通知キューに入る。`ProcessBuilder` でコマンドを実行し、タイムアウト制御をサポート
```python
def _execute(self, task_id, command):
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=300)
output = (r.stdout + r.stderr).strip()[:50000]
except subprocess.TimeoutExpired:
output = "Error: Timeout (300s)"
with self._lock:
self._notification_queue.append({
"task_id": task_id, "result": output[:500]})
```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. エージェントループが各LLM呼び出しの前に通知をドレインする
4. 毎回のユーザー入力時に通知キューをドレインし、システムプロンプトに注入する。Spring AI の `ChatClient` が内部ツールループを管理するため、毎回のユーザー入力時にドレイン+システムプロンプト構築に変更。核心的なコンセプトは同じ: fire and forget
```python
def agent_loop(messages: list):
while True:
notifs = BG.drain_notifications()
if notifs:
notif_text = "\n".join(
f"[bg:{n['task_id']}] {n['result']}" for n in notifs)
messages.append({"role": "user",
"content": f"<background-results>\n{notif_text}\n"
f"</background-results>"})
messages.append({"role": "assistant",
"content": "Noted background results."})
response = client.messages.create(...)
```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だけが並列化される。
ループはシングルスレッドのまま。サブプロセス I/O だけが並列化される。
## s07からの変更点
## s07 からの変更点
| Component | Before (s07) | After (s08) |
|----------------|------------------|----------------------------|
| Tools | 8 | 6 (base + background_run + check)|
| Execution | Blocking only | Blocking + background threads|
| Notification | None | Queue drained per loop |
| Concurrency | None | Daemon threads |
| コンポーネント | 変更前 (s07) | 変更後 (s08) |
|----------------|------------------|------------------------------------|
| Tools | 8 | 6 (基本 + backgroundRun + check) |
| 実行方式 | ブロッキングのみ | ブロッキング + 仮想スレッド (Java 21) |
| 通知メカニズム | なし | 毎ターンドレインの ConcurrentLinkedQueue |
| 並行性 | なし | 仮想スレッド (より軽量、JVM スケジュール) |
## 試してみる
```sh
cd learn-claude-code
python agents/s08_background_tasks.py
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`
+116 -68
View File
@@ -1,16 +1,16 @@
# s09: Agent Teams
# s09: Agent Teams (エージェントチーム)
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > [ s09 ] s10 > s11 > s12`
> *"一人で終わらないなら、チームメイトに任せる"* -- 永続チームメイト + 非同期メールボックス。
> *"一人で終わらないなら、チームメイトに任せる"* -- 永続チームメイト + JSONL メールボックス。
>
> **Harness 層**: チームメールボックス -- 複数モデルをファイルで協調。
## 問題
サブエージェント(s04)は使い捨てだ: 生成し、作業し、要約を返し、消滅する。アイデンティティもなく、呼び出し間の記憶もない。バックグラウンドタスク(s08)はシェルコマンドを実行するが、LLM誘導の意思決定はできない。
サブエージェント (s04) は使い捨てだ: 生成し、作業し、要約を返し、消滅する。アイデンティティもなく、呼び出し間の記憶もない。バックグラウンドタスク (s08) はシェルコマンドを実行するが、LLM 誘導の意思決定はできない。
本物のチームワークには: (1)単一プロンプトを超えて存続する永続エージェント、(2)アイデンティティとライフサイクル管理、(3)エージェント間の通信チャネルが必要だ
本物のチームワークには3つが必要: (1) 複数ターンの会話を超えて存続する永続エージェント、(2) アイデンティティとライフサイクル管理、(3) エージェント間の通信チャネル。
## 解決策
@@ -37,91 +37,139 @@ Communication:
## 仕組み
1. TeammateManagerconfig.jsonでチーム名簿を管理する。
1. TeammateManagerconfig.json でチーム名簿を管理する。
```python
class TeammateManager:
def __init__(self, team_dir: Path):
self.dir = team_dir
self.dir.mkdir(exist_ok=True)
self.config_path = self.dir / "config.json"
self.config = self._load_config()
self.threads = {}
```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()`がチームメイトを作成し、そのエージェントループをスレッドで開始する。
2. `spawn()` がチームメイトを作成し、スレッド内でエージェントループを開始する。
```python
def spawn(self, name: str, role: str, prompt: str) -> str:
member = {"name": name, "role": role, "status": "working"}
self.config["members"].append(member)
self._save_config()
thread = threading.Thread(
target=self._teammate_loop,
args=(name, role, prompt), daemon=True)
thread.start()
return f"Spawned teammate '{name}' (role: {role})"
```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()`がJSON行を追記し、`read_inbox()`がすべて読み取ってドレインする。
3. MessageBus: 追記専用の JSONL インボックス。`send()` が1行を追記し、`read_inbox()` がすべて読み取ってドレインする。
```python
class MessageBus:
def send(self, sender, to, content, msg_type="message", extra=None):
msg = {"type": msg_type, "from": sender,
"content": content, "timestamp": time.time()}
if extra:
msg.update(extra)
with open(self.dir / f"{to}.jsonl", "a") as f:
f.write(json.dumps(msg) + "\n")
```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();
def read_inbox(self, name):
path = self.dir / f"{name}.jsonl"
if not path.exists(): return "[]"
msgs = [json.loads(l) for l in path.read_text().strip().splitlines() if l]
path.write_text("") # drain
return json.dumps(msgs, indent=2)
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. 各チームメイトは各LLM呼び出しの前にインボックスを確認し、受信メッセージをコンテキストに注入する。
4. 各チームメイトは `call()` 呼び出し間でインボックスをチェックし、メッセージをコンテキストに注入する。ChatClient の `call()` は Python の完全なツールループ(`stop_reason != "tool_use"` まで繰り返す)に相当する。
```python
def _teammate_loop(self, name, role, prompt):
messages = [{"role": "user", "content": prompt}]
for _ in range(50):
inbox = BUS.read_inbox(name)
if inbox != "[]":
messages.append({"role": "user",
"content": f"<inbox>{inbox}</inbox>"})
messages.append({"role": "assistant",
"content": "Noted inbox messages."})
response = client.messages.create(...)
if response.stop_reason != "tool_use":
break
# execute tools, append results...
self._find_member(name)["status"] = "idle"
```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 からの変更点
| 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 |
| コンポーネント | 変更前 (s08) | 変更後 (s09) |
|----------------|------------------|------------------------------------|
| Tools | 6 | 9 (+spawn/send/read_inbox) |
| エージェント数 | 単一 | リーダー + N チームメイト |
| 永続化 | なし | config.json + JSONL インボックス |
| スレッド | バックグラウンドコマンド | 各スレッドで完全なエージェントループ |
| ライフサイクル | 使い捨て | idle -> working -> idle |
| 通信 | なし | message + broadcast |
## 試してみる
```sh
cd learn-claude-code
python agents/s09_agent_teams.py
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`と入力してリーダーのインボックスを手動確認する
4. `/team` と入力してチーム名簿とステータスを確認する
5. `/inbox` と入力してリーダーのインボックスを手動確認する
+70 -42
View File
@@ -1,4 +1,4 @@
# s10: Team Protocols
# s10: Team Protocols (チームプロトコル)
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > [ s10 ] s11 > s12`
@@ -8,13 +8,13 @@
## 問題
s09ではチームメイトが作業し通信するが、構造化された協調がない:
s09 ではチームメイトが作業し通信するが、構造化された協調がない:
**シャットダウン**: スレッドを強制終了するとファイルが中途半端に書かれ、config.jsonが不正な状態になる。ハンドシェイクが必要 -- リーダーが要求し、チームメイトが承認(完了して退出)か拒否(作業継続)する。
**シャットダウン**: スレッドを強制終了するとファイルが中途半端に書かれ、config.json が不正な状態になる。ハンドシェイクが必要 -- リーダーが要求し、チームメイトが承認完了して退出か拒否作業継続する。
**プラン承認**: リーダーが「認証モジュールをリファクタリングして」と言うと、チームメイトは即座に開始する。リスクの高い変更では、実行前にリーダーが計画をレビューすべきだ。
**プラン承認**: リーダーが「認証モジュールをリファクタリングして」と言うと、チームメイトは即座に開始する。リスクの高い変更では、実行前にレビューすべきだ。
両方とも同じ構造: 一方がユニークIDを持つリクエストを送り、他方がそのIDで応答する。
両方とも同じ構造: 一方がユニーク ID を持つリクエストを送り、他方がその ID で応答する。
## 解決策
@@ -42,65 +42,93 @@ Trackers:
## 仕組み
1. リーダーがrequest_idを生成し、インボックス経由でシャットダウンを開始する。
1. リーダーが request_id を生成し、インボックス経由でシャットダウンを開始する。
```python
shutdown_requests = {}
```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<>();
def handle_shutdown_request(teammate: str) -> str:
req_id = str(uuid.uuid4())[:8]
shutdown_requests[req_id] = {"target": teammate, "status": "pending"}
BUS.send("lead", teammate, "Please shut down gracefully.",
"shutdown_request", {"request_id": req_id})
return f"Shutdown request {req_id} sent (status: pending)"
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. チームメイトがリクエストを受信し、承認または拒否で応答する。
```python
if tool_name == "shutdown_response":
req_id = args["request_id"]
approve = args["approve"]
shutdown_requests[req_id]["status"] = "approved" if approve else "rejected"
BUS.send(sender, "lead", args.get("reason", ""),
"shutdown_response",
{"request_id": req_id, "approve": approve})
```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を参照)
3. プラン承認もまったく同じパターン。チームメイトがプランを提出request_id を生成、リーダーがレビュー同じ request_id を参照
```python
plan_requests = {}
```java
// ProtocolTracker - 同じ request_id 関連パターン、2つの用途
private final ConcurrentHashMap<String, Map<String, String>> planRequests
= new ConcurrentHashMap<>();
def handle_plan_review(request_id, approve, feedback=""):
req = plan_requests[request_id]
req["status"] = "approved" if approve else "rejected"
BUS.send("lead", req["from"], feedback,
"plan_approval_response",
{"request_id": request_id, "approve": approve})
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`状態機械が、あらゆるリクエスト-レスポンスプロトコルに適用できる。
1つの FSM、2つの用。同じ `pending -> approved | rejected` 状態機械が、あらゆるリクエスト-レスポンスプロトコルに適用できる。
## s09からの変更点
## 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 |
| コンポーネント | 変更前 (s09) | 変更後 (s10) |
|----------------|------------------|--------------------------------------|
| Tools | 9 | 12 (+shutdown_req/resp +plan) |
| シャットダウン | 自然終了のみ | リクエスト-レスポンスハンドシェイク |
| プランゲーティング | なし | 提出/レビューと承認 |
| 関連付け | なし | リクエストごとに request_id |
| FSM | なし | pending -> approved/rejected |
## 試してみる
```sh
cd learn-claude-code
python agents/s10_team_protocols.py
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`と入力してステータスを監視する
5. `/team` と入力してステータスを監視する
+128 -77
View File
@@ -1,18 +1,18 @@
# s11: Autonomous Agents
# s11: Autonomous Agents (自律エージェント)
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > [ s11 ] s12`
> *"チームメイトが自らボードを見て、仕事を取る"* -- リーダーが逐一割り振る必要はない。
> *"チームメイトが自らボードを見て、仕事を取る"* -- リーダーが逐一割り振る必要はない、自己組織化
>
> **Harness 層**: 自律 -- 指示なしで仕事を見つけるモデル。
## 問題
s09-s10では、チームメイトは明示的に指示された時のみ作業する。リーダーは各チームメイトを特定のプロンプトでspawnしなければならない。タスクボードに未割り当てタスクが10個あっても、リーダーが手動で各タスクを割り当てる。これはスケールしない。
s09-s10 では、チームメイトは明示的に指示された時のみ作業する。リーダーは各チームメイトプロンプトを書き、タスクボード上の10個の未割り当てタスクを手動で割り当てる。これはスケールしない。
真の自律性とは、チームメイトが自分で作業を見つけること: タスクボードをスキャンし、未確保のタスクを確保し、作業し、完了したら次を探す。
真の自律性: チームメイトが自分でタスクボードをスキャンし、未確保のタスクを確保し、完了したら次を探す。
もう1つの問題: コンテキスト圧縮(s06)後にエージェントが自分の正体を忘れる可能性がある。アイデンティティ再注入がこれを解決する。
もう1つの問題: コンテキスト圧縮 (s06) 後にエージェントが自分の正体を忘れる可能性がある。アイデンティティ再注入がこれを解決する。
## 解決策
@@ -40,103 +40,154 @@ Teammate lifecycle with idle cycle:
|
+---> 60s timeout ----------------------> SHUTDOWN
Identity re-injection after compression:
if len(messages) <= 3:
messages.insert(0, identity_block)
Identity via system prompt (always present):
ChatClient.builder(chatModel)
.defaultSystem(identityPrompt) // 毎回の呼び出しで自動付与
```
## 仕組み
1. チームメイトのループはWORKIDLEの2フェーズ。LLMがツール呼び出しを止めた時(または`idle`ツールを呼んだ時)、IDLEフェーズに入る。
1. チームメイトのループは WORKIDLE の2フェーズ。LLM がツール呼び出しを止めた時または `idle` ツールを呼んだ時、IDLE フェーズに入る。
```python
def _loop(self, name, role, prompt):
while True:
# -- WORK PHASE --
messages = [{"role": "user", "content": prompt}]
for _ in range(50):
response = client.messages.create(...)
if response.stop_reason != "tool_use":
break
# execute tools...
if idle_requested:
break
```java
// src/main/java/io/mybatis/learn/s11/S11AutonomousAgents.java
// AutonomousTeammateManager.autonomousLoop()
# -- IDLE PHASE --
self._set_status(name, "idle")
resume = self._idle_poll(name, messages)
if not resume:
self._set_status(name, "shutdown")
return
self._set_status(name, "working")
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フェーズがインボックスとタスクボードをポーリングする。
2. IDLE フェーズがインボックスとタスクボードをポーリングする。
```python
def _idle_poll(self, name, messages):
for _ in range(IDLE_TIMEOUT // POLL_INTERVAL): # 60s / 5s = 12
time.sleep(POLL_INTERVAL)
inbox = BUS.read_inbox(name)
if inbox:
messages.append({"role": "user",
"content": f"<inbox>{inbox}</inbox>"})
return True
unclaimed = scan_unclaimed_tasks()
if unclaimed:
claim_task(unclaimed[0]["id"], name)
messages.append({"role": "user",
"content": f"<auto-claimed>Task #{unclaimed[0]['id']}: "
f"{unclaimed[0]['subject']}</auto-claimed>"})
return True
return False # timeout -> shutdown
```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かつ未割り当てかつブロックされていないタスクを探す。
3. タスクボードスキャン: pending ステータスかつ owner なしかつブロックされていないタスクを探す。
```python
def scan_unclaimed_tasks() -> list:
unclaimed = []
for f in sorted(TASKS_DIR.glob("task_*.json")):
task = json.loads(f.read_text())
if (task.get("status") == "pending"
and not task.get("owner")
and not task.get("blockedBy")):
unclaimed.append(task)
return unclaimed
```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. アイデンティティ再注入: コンテキストが短すぎる(圧縮が起きた)場合にアイデンティティブロックを挿入する
4. アイデンティティ保持: Java/Spring AI の `ChatClient.defaultSystem()` は毎回の呼び出しで自動的にシステムプロンプトを付与するため、アイデンティティ情報は常に存在する。Python 版のように圧縮後に手動で再注入する必要はない
```python
if len(messages) <= 3:
messages.insert(0, {"role": "user",
"content": f"<identity>You are '{name}', role: {role}, "
f"team: {team_name}. Continue your work.</identity>"})
messages.insert(1, {"role": "assistant",
"content": f"I am {name}. Continuing."})
```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 からの変更点
| Component | Before (s10) | After (s11) |
|----------------|------------------|----------------------------|
| Tools | 12 | 14 (+idle, +claim_task) |
| Autonomy | Lead-directed | Self-organizing |
| Idle phase | None | Poll inbox + task board |
| Task claiming | Manual only | Auto-claim unclaimed tasks |
| Identity | System prompt | + re-injection after compress|
| Timeout | None | 60s idle -> auto shutdown |
| コンポーネント | 変更前 (s10) | 変更後 (s11) |
|----------------|------------------|----------------------------------|
| Tools | 12 | 14 (+idle, +claim_task) |
| 自律性 | リーダー指示 | 自己組織化 |
| IDLE フェーズ | なし | インボックス + タスクボードをポーリング |
| タスク確保 | 手動のみ | 未割り当てタスクの自動確保 |
| アイデンティティ | システムプロンプト | + 圧縮後の再注入 |
| タイムアウト | なし | 60秒 IDLE → 自動シャットダウン |
## 試してみる
```sh
cd learn-claude-code
python agents/s11_autonomous_agents.py
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`と入力してオーナー付きのタスクボードを確認する
5. `/team`と入力して誰が作業中でアイドルかを監視する
4. `/tasks` と入力して owner 付きのタスクボードを確認する
5. `/team` と入力して誰が作業中でアイドルかを監視する
+66 -41
View File
@@ -1,16 +1,16 @@
# s12: Worktree + Task Isolation
# s12: Worktree + Task Isolation (Worktree タスク隔離)
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > [ s12 ]`
> *"各自のディレクトリで作業し、互いに干渉しない"* -- タスクは目標を管理、worktree はディレクトリを管理、IDで紐付け。
> *"各自のディレクトリで作業し、互いに干渉しない"* -- タスクは目標を管理、worktree はディレクトリを管理、ID で紐付け。
>
> **Harness 層**: ディレクトリ隔離 -- 決して衝突しない並列実行レーン。
## 問題
s11までにエージェントはタスクを自律的に確保して完了できるようになった。しかし全タスクが1つの共有ディレクトリで走る。2つのエージェントが同時に異なるモジュールをリファクタリングすると衝突する: 片方が`config.py`を編集し、もう片方も`config.py`を編集し、未コミットの変更が混ざり合い、どちらもクリーンにロールバックできない。
s11 までにエージェントはタスクを自律的に確保して完了できるようになった。しかし全タスクが1つの共有ディレクトリで走る。2つのエージェントが同時に異なるモジュールをリファクタリングすると -- A が `Config.java` を編集し、B も `Config.java` を編集し、未コミットの変更が互いに汚染し、どちらもクリーンにロールバックできない。
タスクボードは*何をやるか*を追跡するが、*どこでやるか*には関知しない。解決策: 各タスクに専用のgit worktreeディレクトリを与える。タスクが目標を管理し、worktreeが実行コンテキストを管理する。タスクIDで紐付ける。
タスクボードは何をやるかを追跡するがどこでやるかには関知しない。解決策: 各タスクに独立した git worktree ディレクトリを与えタスク ID で両者を関連付ける。
## 解決策
@@ -38,51 +38,74 @@ State machines:
1. **タスクを作成する。** まず目標を永続化する。
```python
TASKS.create("Implement auth refactor")
# -> .tasks/task_1.json status=pending worktree=""
```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`に遷移する。
2. **worktree を作成してタスクに紐付ける。** `task_id` を渡すと、タスクが自動的に `in_progress` に遷移する。
```python
WORKTREES.create("auth-refactor", task_id=1)
# -> git worktree add -b wt/auth-refactor .worktrees/auth-refactor HEAD
# -> index.json gets new entry, task_1.json gets worktree="auth-refactor"
```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"
```
紐付けは両側に状態を書き込む:
```python
def bind_worktree(self, task_id, worktree):
task = self._load(task_id)
task["worktree"] = worktree
if task["status"] == "pending":
task["status"] = "in_progress"
self._save(task)
```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`が分離ディレクトリを指す。
3. **worktree 内でコマンドを実行する。** `cwd` が隔離ディレクトリを指す。
```python
subprocess.run(command, shell=True, cwd=worktree_path,
capture_output=True, text=True, timeout=300)
```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回の呼び出しで後片付けと完了を処理する。
```python
def remove(self, name, force=False, complete_task=False):
self._run_git(["worktree", "remove", wt["path"]])
if complete_task and wt.get("task_id") is not None:
self.tasks.update(wt["task_id"], status="completed")
self.tasks.unbind_worktree(wt["task_id"])
self.events.emit("task.completed", ...)
```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`に記録される:
5. **イベントストリーム。** ライフサイクルの各ステップが `.worktrees/events.jsonl` に記録される:
```json
{
@@ -93,27 +116,29 @@ def remove(self, name, force=False, complete_task=False):
}
```
発行されるイベント: `worktree.create.before/after/failed`, `worktree.remove.before/after/failed`, `worktree.keep`, `task.completed`
イベントタイプ: `worktree.create.before/after/failed`, `worktree.remove.before/after/failed`, `worktree.keep`, `task.completed`
クラッシュ後も`.tasks/` + `.worktrees/index.json`から状態を再構築できる。会話メモリは揮発性だが、ファイル状態は永続的だ。
クラッシュ後も `.tasks/` + `.worktrees/index.json` から状態を再構築できる。会話メモリは揮発性だが、ディスク状態は永続的だ。
## s11からの変更点
## s11 からの変更点
| Component | Before (s11) | After (s12) |
| コンポーネント | 変更前 (s11) | 変更後 (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` |
| 協調 | タスクボード (owner/status) | タスクボード + worktree 明示的紐付け |
| 実行スコープ | 共有ディレクトリ | タスクごとの隔離ディレクトリ |
| 復旧可能性 | タスクステータスのみ | タスクステータス + worktree インデックス |
| 終了処理 | タスク完了 | タスク完了 + 明示的 keep/remove |
| ライフサイクル可視性 | ログ内に暗黙的 | `.worktrees/events.jsonl` で明示的イベントストリーム |
## 試してみる
```sh
cd learn-claude-code
python agents/s12_worktree_task_isolation.py
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".`
+263 -63
View File
@@ -20,99 +20,299 @@
^ |
| tool_result |
+----------------+
(loop until stop_reason != "tool_use")
(ChatClient.call() 自动循环直到无工具调用)
```
一个退出条件控制整个流程。循环持续运行, 直到模型不再调用工具。
一个 `call()` 调用控制整个流程。Spring AI 自动循环, 直到模型不再调用工具。
## 工作原理
1. 用户 prompt 作为第一条消息。
### 1. 构建 ChatClient:注入模型 + 注册工具
```python
messages.append({"role": "user", "content": query})
通过 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. 将消息和工具定义一起发给 LLM。
### 2. `@Tool` 注解:声明式工具注册
```python
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
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 执行 + 超时控制 + 输出截断
// ...
}
}
```
3. 追加助手响应。检查 `stop_reason` -- 如果模型没有调用工具, 结束。
> 对比 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
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
```
4. 执行每个工具调用, 收集结果, 作为 user 消息追加。回到第 2 步。
```python
results = []
for block in response.content:
if block.type == "tool_use":
output = run_bash(block.input["command"])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
```
组装为一个完整函数:
```python
def agent_loop(query):
messages = [{"role": "user", "content": query}]
# Python 版 —— 手动循环
def agent_loop(messages):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
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
results = []
return response # 模型不再调用工具,退出循环
# 执行工具并回传结果
for block in response.content:
if block.type == "tool_use":
output = run_bash(block.input["command"])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
result = TOOL_HANDLERS[block.name](block.input)
messages.append({"role": "user", "content": [{"type": "tool_result", ...}]})
```
不到 30 行, 这就是整个智能体。后面 11 个章节都在这个循环上叠加机制 -- 循环本身始终不变。
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` 是所有课程共用的交互式 REPLRead-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 | (无) | `while True` + stop_reason |
| Tools | (无) | `bash` (单一工具) |
| Messages | (无) | 累积式消息列表 |
| Control flow | (无) | `stop_reason != "tool_use"` |
| 组件 | 之前 | 之后 |
|---------------|------------|--------------------------------------------------|
| 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
python agents/s01_agent_loop.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s01.S01AgentLoop
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
> 运行前需设置环境变量: `AI_API_KEY`, `AI_BASE_URL`, `AI_MODEL`
>
> **当前默认使用 OpenAI 协议**(兼容所有 OpenAI API 格式的服务,包括 OpenAI 官方、Azure OpenAI、各类第三方大模型服务的 OpenAI 兼容接口等)。
> 如需使用 Anthropic 协议(Claude 系列模型原生接口),请展开下方「切换 AI 协议」。
1. `Create a file called hello.py that prints "Hello, World!"`
2. `List all Python files in this directory`
<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`
+96 -58
View File
@@ -2,7 +2,7 @@
`s01 > [ s02 ] s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"加一个工具, 只加一个 handler"* -- 循环不用动, 新工具注册进 dispatch map 就行。
> *"加一个工具, 只加一个 @Tool 方法"* -- 循环不用动, 新工具传入 `defaultTools()` 就行。
>
> **Harness 层**: 工具分发 -- 扩展模型能触达的边界。
@@ -15,87 +15,125 @@
## 解决方案
```
+--------+ +-------+ +------------------+
| User | ---> | LLM | ---> | Tool Dispatch |
| prompt | | | | { |
+--------+ +---+---+ | bash: run_bash |
^ | read: run_read |
| | write: run_wr |
+-----------+ edit: run_edit |
tool_result | } |
+------------------+
+--------+ +-------+ +--------------------+
| User | ---> | LLM | ---> | defaultTools() |
| prompt | | | | { |
+--------+ +---+---+ | BashTool |
^ | ReadFileTool |
| | WriteFileTool |
+-----------+ EditFileTool |
tool_result | } |
+--------------------+
The dispatch map is a dict: {tool_name: handler_function}.
One lookup replaces any if/elif chain.
Spring AI 通过 @Tool 注解自动注册和分派。
无需手写 dispatch map,框架扫描工具对象的注解方法即可。
```
## 工作原理
1. 每个工具一个处理函数。路径沙箱防止逃逸工作区。
1. 每个工具一个独立的类,用 `@Tool` 注解声明。`PathValidator`路径沙箱防止逃逸工作区。
```python
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
```java
// PathValidator —— 对应 Python 版的 safe_path() 函数
public class PathValidator {
private final Path workDir;
def run_read(path: str, limit: int = None) -> str:
text = safe_path(path).read_text()
lines = text.splitlines()
if limit and limit < len(lines):
lines = lines[:limit]
return "\n".join(lines)[:50000]
```
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;
}
}
2. dispatch map 将工具名映射到处理函数。
// ReadFileTool —— 对应 Python 版的 run_read() 函数
public class ReadFileTool {
private final PathValidator pathValidator;
```python
TOOL_HANDLERS = {
"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"],
kw["new_text"]),
@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);
}
}
```
3. 循环中按名称查找处理函数。循环体本身与 s01 完全一致
2. 工具注册只需传入 `defaultTools()`。Spring AI 扫描 `@Tool` 注解方法,自动完成名称映射和参数绑定
```python
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler \
else f"Unknown tool: {block.name}"
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
```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();
```
加工具 = 加 handler + 加 schema。循环永远不变
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 (仅 bash) | 4 (bash, read, write, edit) |
| Dispatch | 硬编码 bash 调用 | `TOOL_HANDLERS` 字典 |
| 路径安全 | 无 | `safe_path()` 沙箱 |
| Agent loop | 不变 | 不变 |
| 组件 | 之前 (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
python agents/s02_tool_use.py
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 requirements.txt`
2. `Create a file called greet.py with a greet(name) function`
3. `Edit greet.py to add a docstring to the function`
4. `Read greet.py to verify the edit worked`
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`
+63 -42
View File
@@ -28,71 +28,92 @@
| [x] task C |
+-----------------------+
|
if rounds_since_todo >= 3:
inject <reminder> into tool_result
每次请求时通过 defaultSystem()
注入最新 todo 状态到系统提示
```
## 工作原理
1. TodoManager 存储带状态的项目。同一时间只允许一个 `in_progress`
```python
class TodoManager:
def update(self, items: list) -> str:
validated, in_progress_count = [], 0
for item in items:
status = item.get("status", "pending")
if status == "in_progress":
in_progress_count += 1
validated.append({"id": item["id"], "text": item["text"],
"status": status})
if in_progress_count > 1:
raise ValueError("Only one task can be in_progress")
self.items = validated
return self.render()
```
```java
public class TodoManager {
2. `todo` 工具和其他工具一样加入 dispatch map。
public record TodoItem(String id, String text, String status) {}
```python
TOOL_HANDLERS = {
# ...base tools...
"todo": lambda **kw: TODO.update(kw["items"]),
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();
}
}
```
3. nag reminder: 模型连续 3 轮以上不调用 `todo` 时注入提醒
2. `TodoManager` 通过 `defaultTools()` 注册, `@Tool` 注解方法自动暴露为工具
```python
if rounds_since_todo >= 3 and messages:
last = messages[-1]
if last["role"] == "user" and isinstance(last.get("content"), list):
last["content"].insert(0, {
"type": "text",
"text": "<reminder>Update your todos.</reminder>",
})
```java
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem(system)
.defaultTools(
new BashTool(),
new ReadFileTool(),
new WriteFileTool(),
new EditFileTool(),
todoManager // @Tool 注解方法自动注册
)
.build();
```
"同时只能有一个 in_progress" 强制顺序聚焦。nag reminder 制造问责压力 -- 你不更新计划, 系统就追着你问
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 (+todo) |
| 规划 | 无 | 带状态的 TodoManager |
| Nag 注入 | 无 | 3 轮后注入 `<reminder>` |
| Agent loop | 简单分发 | + rounds_since_todo 计数器 |
| 组件 | 之前 (s02) | 之后 (s03) |
|----------------|------------------|--------------------------------------|
| Tools | 4 | 5 (+TodoManager `@Tool`) |
| 规划 | 无 | 带状态的 TodoManager |
| 状态注入 | 无 | 系统提示注入 `<current-todos>` |
| ChatClient | 固定系统提示 | 每轮重建, 动态注入 todo 状态 |
## 试一试
```sh
cd learn-claude-code
python agents/s03_todo_write.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s03.S03TodoWrite
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
1. `Refactor the file hello.py: add type hints, docstrings, and a main guard`
2. `Create a Python package with __init__.py, utils.py, and tests/test_utils.py`
3. `Review all Python files and fix any style issues`
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`
+53 -47
View File
@@ -30,67 +30,73 @@ Parent context stays clean. Subagent context is discarded.
1. 父智能体有一个 `task` 工具。子智能体拥有除 `task` 外的所有基础工具 (禁止递归生成)。
```python
PARENT_TOOLS = CHILD_TOOLS + [
{"name": "task",
"description": "Spawn a subagent with fresh context.",
"input_schema": {
"type": "object",
"properties": {"prompt": {"type": "string"}},
"required": ["prompt"],
}},
]
```
2. 子智能体以 `messages=[]` 启动, 运行自己的循环。只有最终文本返回给父智能体。
```python
def run_subagent(prompt: str) -> str:
sub_messages = [{"role": "user", "content": prompt}]
for _ in range(30): # safety limit
response = client.messages.create(
model=MODEL, system=SUBAGENT_SYSTEM,
messages=sub_messages,
tools=CHILD_TOOLS, max_tokens=8000,
```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 独有
)
sub_messages.append({"role": "assistant",
"content": response.content})
if response.stop_reason != "tool_use":
break
results = []
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input)
results.append({"type": "tool_result",
"tool_use_id": block.id,
"content": str(output)[:50000]})
sub_messages.append({"role": "user", "content": results})
return "".join(
b.text for b in response.content if hasattr(b, "text")
) or "(no summary)"
.build();
```
子智能体可能跑了 30+ 次工具调用, 但整个消息历史直接丢弃。父智能体收到的只是一段摘要文本, 作为普通 `tool_result` 返回
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 (基础) + task (仅父端) |
| 上下文 | 单一共享 | 父 + 子隔离 |
| Subagent | 无 | `run_subagent()` 函数 |
| 返回值 | 不适用 | 仅摘要文本 |
| 组件 | 之前 (s03) | 之后 (s04) |
|----------------|------------------|---------------------------------------|
| Tools | 5 | 5 (基础) + SubagentTool (仅父端) |
| 上下文 | 单一共享 | 父 + 子隔离 (独立 ChatClient) |
| Subagent | 无 | `SubagentTool.task()` 方法 |
| 返回值 | 不适用 | 仅摘要文本 |
## 试一试
```sh
cd learn-claude-code
python agents/s04_subagent.py
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 .py files and summarize what each one does`
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`
+74 -29
View File
@@ -47,40 +47,85 @@ skills/
2. SkillLoader 递归扫描 `SKILL.md` 文件, 用目录名作为技能标识。
```python
class SkillLoader:
def __init__(self, skills_dir: Path):
self.skills = {}
for f in sorted(skills_dir.rglob("SKILL.md")):
text = f.read_text()
meta, body = self._parse_frontmatter(text)
name = meta.get("name", f.parent.name)
self.skills[name] = {"meta": meta, "body": body}
```java
public class SkillLoader {
def get_descriptions(self) -> str:
lines = []
for name, skill in self.skills.items():
desc = skill["meta"].get("description", "")
lines.append(f" - {name}: {desc}")
return "\n".join(lines)
private static final Pattern FRONTMATTER_PATTERN =
Pattern.compile("^---\\n(.*?)\\n---\\n(.*)", Pattern.DOTALL);
def get_content(self, name: str) -> str:
skill = self.skills.get(name)
if not skill:
return f"Error: Unknown skill '{name}'."
return f"<skill name=\"{name}\">\n{skill['body']}\n</skill>"
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. 第一层写入系统提示。第二层不过是 dispatch map 中的又一个工具
3. 第一层写入系统提示。第二层通过 SkillLoader 上的 `@Tool` 注解方法按需加载
```python
SYSTEM = f"""You are a coding agent at {WORKDIR}.
Skills available:
{SKILL_LOADER.get_descriptions()}"""
```java
public S05SkillLoading(ChatModel chatModel) {
Path skillsDir = Path.of(System.getProperty("user.dir"), "skills");
SkillLoader skillLoader = new SkillLoader(skillsDir);
TOOL_HANDLERS = {
# ...base tools...
"load_skill": lambda **kw: SKILL_LOADER.get_content(kw["name"]),
// 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();
}
```
@@ -99,7 +144,7 @@ TOOL_HANDLERS = {
```sh
cd learn-claude-code
python agents/s05_skill_loading.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s05.S05SkillLoading
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
+107 -49
View File
@@ -44,61 +44,119 @@ continue [Layer 2: auto_compact]
## 工作原理
1. **第一层 -- micro_compact**: 每次 LLM 调用前, 将旧的 tool result 替换为占位符
1. **第一层 -- 上下文窗口管理**: Spring AI 的 ChatClient 自动管理工具循环, 无法在循环内插入压缩。Java 版通过限制注入系统提示的对话轮数(仅保留最近 N 轮)并截断内容来实现等价效果
```python
def micro_compact(messages: list) -> list:
tool_results = []
for i, msg in enumerate(messages):
if msg["role"] == "user" and isinstance(msg.get("content"), list):
for j, part in enumerate(msg["content"]):
if isinstance(part, dict) and part.get("type") == "tool_result":
tool_results.append((i, j, part))
if len(tool_results) <= KEEP_RECENT:
return messages
for _, _, part in tool_results[:-KEEP_RECENT]:
if len(part.get("content", "")) > 100:
part["content"] = f"[Previous: used {tool_name}]"
return messages
```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 做摘要。
```python
def auto_compact(messages: list) -> list:
# Save transcript for recovery
transcript_path = TRANSCRIPT_DIR / f"transcript_{int(time.time())}.jsonl"
with open(transcript_path, "w") as f:
for msg in messages:
f.write(json.dumps(msg, default=str) + "\n")
# LLM summarizes
response = client.messages.create(
model=MODEL,
messages=[{"role": "user", "content":
"Summarize this conversation for continuity..."
+ json.dumps(messages, default=str)[:80000]}],
max_tokens=2000,
)
return [
{"role": "user", "content": f"[Compressed]\n\n{response.content[0].text}"},
{"role": "assistant", "content": "Understood. Continuing."},
]
```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**: `compact` 工具按需触发同样的摘要机制。
3. **第三层 -- manual compact**: `CompactTool` 工具按需触发同样的摘要机制。
4. 循环整合三层:
```java
public class CompactTool {
private final ContextCompactor compactor;
```python
def agent_loop(messages: list):
while True:
micro_compact(messages) # Layer 1
if estimate_tokens(messages) > THRESHOLD:
messages[:] = auto_compact(messages) # Layer 2
response = client.messages.create(...)
# ... tool execution ...
if manual_compact:
messages[:] = auto_compact(messages) # Layer 3
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 保存在磁盘上。信息没有真正丢失, 只是移出了活跃上下文。
@@ -109,7 +167,7 @@ def agent_loop(messages: list):
|----------------|------------------|--------------------------------|
| Tools | 5 | 5 (基础 + compact) |
| 上下文管理 | 无 | 三层压缩 |
| Micro-compact | 无 | 旧结果 -> 占位符 |
| 上下文窗口管理 | 无 | 限制注入轮数 + 内容截断 |
| Auto-compact | 无 | token 阈值触发 |
| Transcripts | 无 | 保存到 .transcripts/ |
@@ -117,11 +175,11 @@ def agent_loop(messages: list):
```sh
cd learn-claude-code
python agents/s06_context_compact.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s06.S06ContextCompact
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
1. `Read every Python file in the agents/ directory one by one` (观察 micro-compact 替换旧结果)
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`
+81 -40
View File
@@ -48,57 +48,98 @@ s03 的 TodoManager 只是内存中的扁平清单: 没有顺序、没有依赖
## 工作原理
1. **TaskManager**: 每个任务一个 JSON 文件, CRUD + 依赖图。
1. **TaskManager**: 每个任务一个 JSON 文件, CRUD + 依赖图。使用 Jackson `ObjectMapper` 做 JSON 序列化。
```python
class TaskManager:
def __init__(self, tasks_dir: Path):
self.dir = tasks_dir
self.dir.mkdir(exist_ok=True)
self._next_id = self._max_id() + 1
```java
public class TaskManager {
private static final ObjectMapper MAPPER = new ObjectMapper();
private final Path dir;
private int nextId;
def create(self, subject, description=""):
task = {"id": self._next_id, "subject": subject,
"status": "pending", "blockedBy": [],
"blocks": [], "owner": ""}
self._save(task)
self._next_id += 1
return json.dumps(task, indent=2)
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` 中移除, 解锁后续任务。
```python
def _clear_dependency(self, completed_id):
for f in self.dir.glob("task_*.json"):
task = json.loads(f.read_text())
if completed_id in task.get("blockedBy", []):
task["blockedBy"].remove(completed_id)
self._save(task)
```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. **状态变更 + 依赖关联**: `update` 处理状态转换和依赖边。
3. **状态变更 + 依赖关联**: `taskUpdate` 处理状态转换和依赖边。当 status 变为 `completed` 时自动调用 `clearDependency``blockedBy`/`blocks` 是双向关系。
```python
def update(self, task_id, status=None,
add_blocked_by=None, add_blocks=None):
task = self._load(task_id)
if status:
task["status"] = status
if status == "completed":
self._clear_dependency(task_id)
self._save(task)
```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. 四个任务工具加入 dispatch map。
4. **Spring AI 自动注册工具**: 将 `TaskManager` 作为 `defaultTools` 传入 `ChatClient`Spring AI 自动识别 `@Tool` 注解方法,无需手动 dispatch map。
```python
TOOL_HANDLERS = {
# ...base tools...
"task_create": lambda **kw: TASKS.create(kw["subject"]),
"task_update": lambda **kw: TASKS.update(kw["task_id"], kw.get("status")),
"task_list": lambda **kw: TASKS.list_all(),
"task_get": lambda **kw: TASKS.get(kw["task_id"]),
```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();
}
}
```
@@ -118,7 +159,7 @@ TOOL_HANDLERS = {
```sh
cd learn-claude-code
python agents/s07_task_system.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s07.S07TaskSystem
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
+74 -47
View File
@@ -32,58 +32,85 @@ Agent --[spawn A]--[spawn B]--[other work]----
## 工作原理
1. BackgroundManager 用线程安全的通知队列追踪任务
1. BackgroundManager 用线程安全的并发容器追踪任务。Java 使用 `ConcurrentHashMap``CopyOnWriteArrayList` 代替 Python 的手动加锁
```python
class BackgroundManager:
def __init__(self):
self.tasks = {}
self._notification_queue = []
self._lock = threading.Lock()
```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. `run()` 启动守护线程, 立即返回
2. `backgroundRun()` 提交虚拟线程 (Java 21), 立即返回。相比 Python 的 `daemon=True` 线程,虚拟线程更轻量、由 JVM 调度
```python
def run(self, command: str) -> str:
task_id = str(uuid.uuid4())[:8]
self.tasks[task_id] = {"status": "running", "command": command}
thread = threading.Thread(
target=self._execute, args=(task_id, command), daemon=True)
thread.start()
return f"Background task {task_id} started"
```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. 子进程完成后, 结果进入通知队列。
3. 子进程完成后, 结果进入通知队列。使用 `ProcessBuilder` 执行命令,支持超时控制。
```python
def _execute(self, task_id, command):
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=300)
output = (r.stdout + r.stderr).strip()[:50000]
except subprocess.TimeoutExpired:
output = "Error: Timeout (300s)"
with self._lock:
self._notification_queue.append({
"task_id": task_id, "result": output[:500]})
```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. 每次 LLM 调用前排空通知队列
4. 每次用户输入时排空通知队列, 注入系统提示。Spring AI 的 `ChatClient` 管理内部工具循环, 因此改为在每次用户输入时 drain 通知并构建系统提示, 核心概念不变: fire and forget
```python
def agent_loop(messages: list):
while True:
notifs = BG.drain_notifications()
if notifs:
notif_text = "\n".join(
f"[bg:{n['task_id']}] {n['result']}" for n in notifs)
messages.append({"role": "user",
"content": f"<background-results>\n{notif_text}\n"
f"</background-results>"})
messages.append({"role": "assistant",
"content": "Noted background results."})
response = client.messages.create(...)
```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 被并行化。
@@ -92,16 +119,16 @@ def agent_loop(messages: list):
| 组件 | 之前 (s07) | 之后 (s08) |
|----------------|------------------|------------------------------------|
| Tools | 8 | 6 (基础 + background_run + check) |
| 执行方式 | 仅阻塞 | 阻塞 + 后台线程 |
| 通知机制 | 无 | 每轮排空的队列 |
| 并发 | 无 | 守护线程 |
| Tools | 8 | 6 (基础 + backgroundRun + check) |
| 执行方式 | 仅阻塞 | 阻塞 + 虚拟线程 (Java 21) |
| 通知机制 | 无 | 每轮排空的 ConcurrentLinkedQueue |
| 并发 | 无 | 虚拟线程 (更轻量, JVM 调度) |
## 试一试
```sh
cd learn-claude-code
python agents/s08_background_tasks.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s08.S08BackgroundTasks
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
+96 -50
View File
@@ -39,67 +39,113 @@ Communication:
1. TeammateManager 通过 config.json 维护团队名册。
```python
class TeammateManager:
def __init__(self, team_dir: Path):
self.dir = team_dir
self.dir.mkdir(exist_ok=True)
self.config_path = self.dir / "config.json"
self.config = self._load_config()
self.threads = {}
```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。
```python
def spawn(self, name: str, role: str, prompt: str) -> str:
member = {"name": name, "role": role, "status": "working"}
self.config["members"].append(member)
self._save_config()
thread = threading.Thread(
target=self._teammate_loop,
args=(name, role, prompt), daemon=True)
thread.start()
return f"Spawned teammate '{name}' (role: {role})"
```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()` 读取全部并清空。
```python
class MessageBus:
def send(self, sender, to, content, msg_type="message", extra=None):
msg = {"type": msg_type, "from": sender,
"content": content, "timestamp": time.time()}
if extra:
msg.update(extra)
with open(self.dir / f"{to}.jsonl", "a") as f:
f.write(json.dumps(msg) + "\n")
```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();
def read_inbox(self, name):
path = self.dir / f"{name}.jsonl"
if not path.exists(): return "[]"
msgs = [json.loads(l) for l in path.read_text().strip().splitlines() if l]
path.write_text("") # drain
return json.dumps(msgs, indent=2)
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. 每个队友在每次 LLM 调用检查收件箱, 将消息注入上下文。
4. 每个队友在每次 `call()` 调用检查收件箱, 将消息注入上下文。ChatClient 的 `call()` 等价于 Python 的完整工具循环(循环到 `stop_reason != "tool_use"` 为止)。
```python
def _teammate_loop(self, name, role, prompt):
messages = [{"role": "user", "content": prompt}]
for _ in range(50):
inbox = BUS.read_inbox(name)
if inbox != "[]":
messages.append({"role": "user",
"content": f"<inbox>{inbox}</inbox>"})
messages.append({"role": "assistant",
"content": "Noted inbox messages."})
response = client.messages.create(...)
if response.stop_reason != "tool_use":
break
# execute tools, append results...
self._find_member(name)["status"] = "idle"
```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 的变更
@@ -117,7 +163,7 @@ def _teammate_loop(self, name, role, prompt):
```sh
cd learn-claude-code
python agents/s09_agent_teams.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s09.S09AgentTeams
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
+51 -25
View File
@@ -44,40 +44,66 @@ Trackers:
1. 领导生成 request_id, 通过收件箱发起关机请求。
```python
shutdown_requests = {}
```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<>();
def handle_shutdown_request(teammate: str) -> str:
req_id = str(uuid.uuid4())[:8]
shutdown_requests[req_id] = {"target": teammate, "status": "pending"}
BUS.send("lead", teammate, "Please shut down gracefully.",
"shutdown_request", {"request_id": req_id})
return f"Shutdown request {req_id} sent (status: pending)"
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 响应。
```python
if tool_name == "shutdown_response":
req_id = args["request_id"]
approve = args["approve"]
shutdown_requests[req_id]["status"] = "approved" if approve else "rejected"
BUS.send(sender, "lead", args.get("reason", ""),
"shutdown_response",
{"request_id": req_id, "approve": approve})
```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)。
```python
plan_requests = {}
```java
// ProtocolTracker - 同样的request_id关联模式,两种用途
private final ConcurrentHashMap<String, Map<String, String>> planRequests
= new ConcurrentHashMap<>();
def handle_plan_review(request_id, approve, feedback=""):
req = plan_requests[request_id]
req["status"] = "approved" if approve else "rejected"
BUS.send("lead", req["from"], feedback,
"plan_approval_response",
{"request_id": request_id, "approve": approve})
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` 状态机可以套用到任何请求-响应协议上。
@@ -96,7 +122,7 @@ def handle_plan_review(request_id, approve, feedback=""):
```sh
cd learn-claude-code
python agents/s10_team_protocols.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s10.S10TeamProtocols
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
+107 -58
View File
@@ -40,81 +40,130 @@ Teammate lifecycle with idle cycle:
|
+---> 60s timeout ----------------------> SHUTDOWN
Identity re-injection after compression:
if len(messages) <= 3:
messages.insert(0, identity_block)
Identity via system prompt (always present):
ChatClient.builder(chatModel)
.defaultSystem(identityPrompt) // 每次调用自动携带
```
## 工作原理
1. 队友循环分两个阶段: WORK 和 IDLE。LLM 停止调用工具 (或调用了 `idle`) 时, 进入 IDLE。
```python
def _loop(self, name, role, prompt):
while True:
# -- WORK PHASE --
messages = [{"role": "user", "content": prompt}]
for _ in range(50):
response = client.messages.create(...)
if response.stop_reason != "tool_use":
break
# execute tools...
if idle_requested:
break
```java
// src/main/java/io/mybatis/learn/s11/S11AutonomousAgents.java
// AutonomousTeammateManager.autonomousLoop()
# -- IDLE PHASE --
self._set_status(name, "idle")
resume = self._idle_poll(name, messages)
if not resume:
self._set_status(name, "shutdown")
return
self._set_status(name, "working")
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. 空闲阶段循环轮询收件箱和任务看板。
```python
def _idle_poll(self, name, messages):
for _ in range(IDLE_TIMEOUT // POLL_INTERVAL): # 60s / 5s = 12
time.sleep(POLL_INTERVAL)
inbox = BUS.read_inbox(name)
if inbox:
messages.append({"role": "user",
"content": f"<inbox>{inbox}</inbox>"})
return True
unclaimed = scan_unclaimed_tasks()
if unclaimed:
claim_task(unclaimed[0]["id"], name)
messages.append({"role": "user",
"content": f"<auto-claimed>Task #{unclaimed[0]['id']}: "
f"{unclaimed[0]['subject']}</auto-claimed>"})
return True
return False # timeout -> shutdown
```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、未被阻塞的任务。
```python
def scan_unclaimed_tasks() -> list:
unclaimed = []
for f in sorted(TASKS_DIR.glob("task_*.json")):
task = json.loads(f.read_text())
if (task.get("status") == "pending"
and not task.get("owner")
and not task.get("blockedBy")):
unclaimed.append(task)
return unclaimed
```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. 身份重注入: 上下文过短 (说明发生了压缩) 时, 在开头插入身份块
4. 身份保持: Java/Spring AI 的 `ChatClient.defaultSystem()` 在每次调用时自动携带系统提示, 身份信息始终存在, 无需像 Python 版本那样在压缩后手动重注入
```python
if len(messages) <= 3:
messages.insert(0, {"role": "user",
"content": f"<identity>You are '{name}', role: {role}, "
f"team: {team_name}. Continue your work.</identity>"})
messages.insert(1, {"role": "assistant",
"content": f"I am {name}. Continuing."})
```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 的变更
@@ -132,7 +181,7 @@ if len(messages) <= 3:
```sh
cd learn-claude-code
python agents/s11_autonomous_agents.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s11.S11AutonomousAgents
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
+49 -26
View File
@@ -8,7 +8,7 @@
## 问题
到 s11, 智能体已经能自主认领和完成任务。但所有任务共享一个目录。两个智能体同时重构不同模块 -- A 改 `config.py`, B 也改 `config.py`, 未提交的改动互相污染, 谁也没法干净回滚。
到 s11, 智能体已经能自主认领和完成任务。但所有任务共享一个目录。两个智能体同时重构不同模块 -- A 改 `Config.java`, B 也改 `Config.java`, 未提交的改动互相污染, 谁也没法干净回滚。
任务板管 "做什么" 但不管 "在哪做"。解法: 给每个任务一个独立的 git worktree 目录, 用任务 ID 把两边关联起来。
@@ -38,48 +38,71 @@ State machines:
1. **创建任务。** 先把目标持久化。
```python
TASKS.create("Implement auth refactor")
# -> .tasks/task_1.json status=pending worktree=""
```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`
```python
WORKTREES.create("auth-refactor", task_id=1)
# -> git worktree add -b wt/auth-refactor .worktrees/auth-refactor HEAD
# -> index.json gets new entry, task_1.json gets worktree="auth-refactor"
```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"
```
绑定同时写入两侧状态:
```python
def bind_worktree(self, task_id, worktree):
task = self._load(task_id)
task["worktree"] = worktree
if task["status"] == "pending":
task["status"] = "in_progress"
self._save(task)
```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` 指向隔离目录。
```python
subprocess.run(command, shell=True, cwd=worktree_path,
capture_output=True, text=True, timeout=300)
```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)` -- 删除目录, 完成绑定任务, 发出事件。一个调用搞定拆除 + 完成。
```python
def remove(self, name, force=False, complete_task=False):
self._run_git(["worktree", "remove", wt["path"]])
if complete_task and wt.get("task_id") is not None:
self.tasks.update(wt["task_id"], status="completed")
self.tasks.unbind_worktree(wt["task_id"])
self.events.emit("task.completed", ...)
```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`:
@@ -111,7 +134,7 @@ def remove(self, name, force=False, complete_task=False):
```sh
cd learn-claude-code
python agents/s12_worktree_task_isolation.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s12.S12WorktreeIsolation
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
+70
View File
@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.7</version>
<relativePath/>
</parent>
<groupId>io.mybatis</groupId>
<artifactId>learn-claude-code</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>spring-ai-learn-claude-code</name>
<description>Spring AI 实现的 learn-claude-code</description>
<packaging>jar</packaging>
<properties>
<java.version>21</java.version>
<spring-ai.version>1.0.3</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Web (Servlet 同步调用) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- WebFlux (流式输出 Streaming 必须) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- Spring AI OpenAI Starter (Spring AI 1.0.0 新命名规范) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
+4 -4
View File
@@ -109,12 +109,12 @@ The pattern is universal. Only the capabilities change.
- `references/agent-philosophy.md` - Deep dive into why agents work
**Implementation**:
- `references/minimal-agent.py` - Complete working agent (~80 lines)
- `references/tool-templates.py` - Capability definitions
- `references/subagent-pattern.py` - Context isolation
- `references/MinimalAgent.java` - Complete working agent (基于 Spring AI ChatClient)
- `references/ToolTemplates.java` - Capability definitions (使用 `@Tool` 注解)
- `references/SubagentPattern.java` - Context isolation
**Scaffolding**:
- `scripts/init_agent.py` - Generate new agent projects
- `scripts/init-agent.sh` - Generate new Spring Boot agent project via `mvn archetype:generate`
## The Agent Mindset
+29 -24
View File
@@ -17,14 +17,14 @@ Check for:
- [ ] **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 `npm audit`, `pip-audit`)
- [ ] **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
pip-audit # Python
cargo audit # Rust
grep -r "password\|secret\|api_key" --include="*.py" --include="*.js"
grep -r "password\|secret\|api_key" --include="*.java" --include="*.properties" --include="*.yml"
```
### 2. Correctness
@@ -89,23 +89,27 @@ Check for:
## Common Patterns to Flag
### Python
```python
# Bad: SQL injection
cursor.execute(f"SELECT * FROM users WHERE id = {user_id}")
# Good:
cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,))
### 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
os.system(f"ls {user_input}")
# Good:
subprocess.run(["ls", user_input], check=True)
// Bad: Command injection
Runtime.getRuntime().exec("ls " + userInput);
// Good: 使用 ProcessBuilder 分离命令和参数
new ProcessBuilder("ls", userInput).start();
# Bad: Mutable default argument
def append(item, lst=[]): # Bug: shared mutable default
# Good:
def append(item, lst=None):
lst = lst or []
// Bad: 未关闭资源
InputStream is = new FileInputStream("data.txt");
// Good: 使用 try-with-resources 自动关闭
try (InputStream is = new FileInputStream("data.txt")) {
// ...
}
```
### JavaScript/TypeScript
@@ -136,14 +140,15 @@ git log --oneline -10
# Find potential issues
grep -rn "TODO\|FIXME\|HACK\|XXX" .
grep -rn "password\|secret\|token" . --include="*.py"
grep -rn "password\|secret\|token" . --include="*.java" --include="*.properties"
# Check complexity (Python)
pip install radon && radon cc . -a
# Check complexity (Java)
mvn pmd:pmd # 静态代码分析
mvn spotbugs:check # Bug 检测
# Check dependencies
npm outdated # Node
pip list --outdated # Python
mvn versions:display-dependency-updates # 检查过期依赖
mvn dependency-check:check # OWASP 安全扫描
```
## Review Workflow
+125 -81
View File
@@ -14,60 +14,83 @@ MCP servers expose:
- **Resources**: Data Claude can read (like files or database records)
- **Prompts**: Pre-built prompt templates
## Quick Start: Python MCP Server
## Quick Start: Java/Spring AI MCP Server
### 1. Project Setup
```bash
# Create project
# 使用 Spring Initializr 创建项目
# 或通过 Maven 手动创建
mkdir my-mcp-server && cd my-mcp-server
python3 -m venv venv && source venv/bin/activate
mvn archetype:generate -DgroupId=com.example -DartifactId=my-mcp-server \
-DarchetypeArtifactId=maven-archetype-quickstart -DinteractiveMode=false
```
# Install MCP SDK
pip install mcp
`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
```python
#!/usr/bin/env python3
"""my_server.py - A simple MCP server"""
```java
// src/main/java/com/example/McpServerApplication.java
package com.example;
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent
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;
# Create server instance
server = Server("my-server")
@SpringBootApplication
public class McpServerApplication {
# Define a tool
@server.tool()
async def hello(name: str) -> str:
"""Say hello to someone.
public static void main(String[] args) {
SpringApplication.run(McpServerApplication.class, args);
}
Args:
name: The name to greet
"""
return f"Hello, {name}!"
@Bean
public MyTools myTools() {
return new MyTools();
}
}
@server.tool()
async def add_numbers(a: int, b: int) -> str:
"""Add two numbers together.
// 定义工具类
class MyTools {
Args:
a: First number
b: Second number
"""
return str(a + b)
@Tool(description = "Say hello to someone")
public String hello(@ToolParam(description = "The name to greet") String name) {
return "Hello, " + name + "!";
}
# Run server
async def main():
async with stdio_server() as (read, write):
await server.run(read, write)
@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);
}
}
```
if __name__ == "__main__":
import asyncio
asyncio.run(main())
配置 `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
@@ -77,8 +100,8 @@ Add to `~/.claude/mcp.json`:
{
"mcpServers": {
"my-server": {
"command": "python3",
"args": ["/path/to/my_server.py"]
"command": "java",
"args": ["-jar", "/path/to/my-mcp-server/target/my-mcp-server.jar"]
}
}
}
@@ -140,74 +163,95 @@ server.connect(transport);
### External API Integration
```python
import httpx
from mcp.server import Server
```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;
server = Server("weather-server")
public class WeatherTools {
@server.tool()
async def get_weather(city: str) -> str:
"""Get current weather for a city."""
async with httpx.AsyncClient() as client:
resp = await client.get(
f"https://api.weatherapi.com/v1/current.json",
params={"key": "YOUR_API_KEY", "q": city}
)
data = resp.json()
return f"{city}: {data['current']['temp_c']}C, {data['current']['condition']['text']}"
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
```python
import sqlite3
from mcp.server import Server
```java
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.jdbc.core.JdbcTemplate;
server = Server("db-server")
public class DatabaseTools {
@server.tool()
async def query_db(sql: str) -> str:
"""Execute a read-only SQL query."""
if not sql.strip().upper().startswith("SELECT"):
return "Error: Only SELECT queries allowed"
private final JdbcTemplate jdbcTemplate;
conn = sqlite3.connect("data.db")
cursor = conn.execute(sql)
rows = cursor.fetchall()
conn.close()
return str(rows)
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)
```python
@server.resource("config://settings")
async def get_settings() -> str:
"""Application settings."""
return open("settings.json").read()
```java
import org.springframework.ai.tool.annotation.Tool;
import java.nio.file.Files;
import java.nio.file.Path;
@server.resource("file://{path}")
async def read_file(path: str) -> str:
"""Read a file from the workspace."""
return open(path).read()
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 python3 my_server.py
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"}' | python3 my_server.py
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | java -jar target/my-mcp-server.jar
```
## Best Practices
1. **Clear tool descriptions**: Claude uses these to decide when to call tools
2. **Input validation**: Always validate and sanitize inputs
3. **Error handling**: Return meaningful error messages
4. **Async by default**: Use async/await for I/O operations
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
+81 -55
View File
@@ -14,28 +14,27 @@ You now have expertise in PDF manipulation. Follow these workflows:
# Using pdftotext (poppler-utils)
pdftotext input.pdf - # Output to stdout
pdftotext input.pdf output.txt # Output to file
# If pdftotext not available, try:
python3 -c "
import fitz # PyMuPDF
doc = fitz.open('input.pdf')
for page in doc:
print(page.get_text())
"
```
**Option 2: Page-by-page with metadata**
```python
import fitz # pip install pymupdf
**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;
doc = fitz.open("input.pdf")
print(f"Pages: {len(doc)}")
print(f"Metadata: {doc.metadata}")
PDDocument doc = PDDocument.load(new File("input.pdf"));
System.out.println("Pages: " + doc.getNumberOfPages());
System.out.println("Metadata: " + doc.getDocumentInformation().getTitle());
for i, page in enumerate(doc):
text = page.get_text()
print(f"--- Page {i+1} ---")
print(text)
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
@@ -49,64 +48,91 @@ pandoc input.md -o output.pdf
pandoc input.md -o output.pdf --pdf-engine=xelatex -V geometry:margin=1in
```
**Option 2: Programmatically**
```python
from reportlab.lib.pagesizes import letter
from reportlab.pdfgen import canvas
**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;
c = canvas.Canvas("output.pdf", pagesize=letter)
c.drawString(100, 750, "Hello, PDF!")
c.save()
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**
```bash
# Using wkhtmltopdf
wkhtmltopdf input.html output.pdf
**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;
# Or with Python
python3 -c "
import pdfkit
pdfkit.from_file('input.html', 'output.pdf')
"
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
```python
import fitz
```java
import org.apache.pdfbox.multipdf.PDFMergerUtility;
import java.io.File;
result = fitz.open()
for pdf_path in ["file1.pdf", "file2.pdf", "file3.pdf"]:
doc = fitz.open(pdf_path)
result.insert_pdf(doc)
result.save("merged.pdf")
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
```python
import fitz
```java
import org.apache.pdfbox.multipdf.Splitter;
import org.apache.pdfbox.pdmodel.PDDocument;
import java.io.File;
import java.util.List;
doc = fitz.open("input.pdf")
for i in range(len(doc)):
single = fitz.open()
single.insert_pdf(doc, from_page=i, to_page=i)
single.save(f"page_{i+1}.pdf")
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 | Install |
|------|---------|---------|
| Read/Write/Merge | PyMuPDF | `pip install pymupdf` |
| Create from scratch | ReportLab | `pip install reportlab` |
| HTML to PDF | pdfkit | `pip install pdfkit` + wkhtmltopdf |
| 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
4. **OCR for scanned PDFs**: Use `pytesseract` if text extraction returns empty
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,82 @@
package io.mybatis.learn.core;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Scanner;
import java.util.function.Function;
/**
* Agent 交互式 REPLRead-Eval-Print Loop)工具类。
* <p>
* TIP: 对应 Python 中每个 session 的 {@code if __name__ == "__main__"} 交互循环。
* Python 使用 {@code input()} 读取输入,Java 使用 {@code Scanner}。
*/
public class AgentRunner {
private static final Logger log = LoggerFactory.getLogger(AgentRunner.class);
private static final String ANSI_CYAN = "\033[36m";
private static final String ANSI_GREEN = "\033[32m";
private static final String ANSI_RED = "\033[31m";
private static final String ANSI_RESET = "\033[0m";
/**
* 启动交互式 REPL 循环。
*
* @param prefix 提示符前缀(如 "s01"
* @param handler 处理用户输入并返回 Agent 响应的函数
*/
public static void interactive(String prefix, Function<String, String> handler) {
log.info("启动交互循环,prefix={}", prefix);
Scanner scanner = new Scanner(System.in);
System.out.println("输入 'q' 或 'exit' 退出");
System.out.println();
while (true) {
System.out.print(ANSI_CYAN + prefix + " >> " + ANSI_RESET);
String input;
try {
if (!scanner.hasNextLine()) break;
input = scanner.nextLine().trim();
} catch (Exception e) {
if (log.isDebugEnabled()) {
System.out.printf("💭 读取输入异常,结束交互循环: prefix=%s, error=%s%n", prefix, e.getMessage());
}
break;
}
if (input.isEmpty() || "exit".equalsIgnoreCase(input) || "q".equalsIgnoreCase(input)) {
log.info("收到退出指令,结束交互循环,prefix={}", prefix);
break;
}
try {
String response = handler.apply(input);
if (response != null && !response.isBlank()) {
printAssistantResponse(response);
}
} catch (Exception e) {
log.warn("处理输入失败,prefix={}, error={}", prefix, e.getMessage());
printAssistantError(e.getMessage());
}
System.out.println();
}
log.info("交互循环结束,prefix={}", prefix);
System.out.println("Bye!");
}
private static void printAssistantResponse(String response) {
System.out.println(ANSI_GREEN + "assistant >>" + ANSI_RESET);
System.out.println(ANSI_CYAN + response + ANSI_RESET);
}
private static void printAssistantError(String errorMessage) {
System.out.println(ANSI_RED + "assistant !!" + ANSI_RESET);
System.out.println(ANSI_RED + "Error: " + errorMessage + ANSI_RESET);
}
/**
* 截断过长输出。
* TIP: 对应 Python 中 {@code out[:50000]} 的截断逻辑。
*/
public static String truncate(String text, int maxLen) {
if (text == null) return "";
return text.length() > maxLen ? text.substring(0, maxLen) + "...(truncated)" : text;
}
}
@@ -0,0 +1,121 @@
package io.mybatis.learn.core.team;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.*;
/**
* JSONL消息队列 - 每个teammate一个收件箱文件
*
* TIPS: 对应Python的MessageBus类(s09第78-118行)。
* Python用 open("a") 追加JSONL行,Java用 Files.writeString(APPEND)。
* Python靠GIL隐式保证线程安全,Java用 synchronized 显式保证。
*/
public class MessageBus {
private static final Logger log = LoggerFactory.getLogger(MessageBus.class);
public static final Set<String> VALID_MSG_TYPES = Set.of(
"message", "broadcast", "shutdown_request",
"shutdown_response", "plan_approval_response"
);
private final Path inboxDir;
private final ObjectMapper mapper = new ObjectMapper();
public MessageBus(Path inboxDir) {
this.inboxDir = inboxDir;
try {
Files.createDirectories(inboxDir);
if (log.isDebugEnabled()) {
System.out.printf("🚀 消息收件箱就绪: %s%n", inboxDir);
}
} catch (IOException e) {
log.error("创建消息收件箱目录失败: {}, error={}", inboxDir, e.getMessage());
throw new UncheckedIOException(e);
}
}
/**
* 发送消息到指定teammate的收件箱(追加JSONL行)
*/
public synchronized String send(String sender, String to, String content,
String msgType, Map<String, Object> extra) {
if (!VALID_MSG_TYPES.contains(msgType)) {
log.warn("发送消息失败,消息类型非法: type={}", msgType);
return "Error: Invalid type '" + msgType + "'. Valid: " + VALID_MSG_TYPES;
}
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);
try {
Path inbox = inboxDir.resolve(to + ".jsonl");
Files.writeString(inbox, mapper.writeValueAsString(msg) + "\n",
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
if (log.isDebugEnabled()) {
System.out.printf("📨 消息已发送: %s → %s (%s)%n", sender, to, msgType);
}
return "Sent " + msgType + " to " + to;
} catch (IOException e) {
log.warn("消息发送失败: from={}, to={}, type={}, error={}", sender, to, msgType, e.getMessage());
return "Error: " + e.getMessage();
}
}
public String send(String sender, String to, String content) {
return send(sender, to, content, "message", null);
}
/**
* 读取并清空指定teammate的收件箱(drain模式)
*/
@SuppressWarnings("unchecked")
public synchronized List<Map<String, Object>> readInbox(String name) {
Path inbox = inboxDir.resolve(name + ".jsonl");
if (!Files.exists(inbox)) return List.of();
try {
List<Map<String, Object>> messages = new ArrayList<>();
for (String line : Files.readAllLines(inbox)) {
if (!line.isBlank()) {
messages.add(mapper.readValue(line,
new TypeReference<Map<String, Object>>() {}));
}
}
Files.writeString(inbox, "");
if (!messages.isEmpty()) {
if (log.isDebugEnabled()) {
System.out.printf("📬 收件箱已读取: %s (%d 条消息)%n", name, messages.size());
}
}
return messages;
} catch (IOException e) {
log.warn("读取收件箱失败: name={}, error={}", name, e.getMessage());
return List.of();
}
}
/**
* 广播消息给所有teammates(排除发送者自己)
*/
public String broadcast(String sender, String content, List<String> teammates) {
int count = 0;
for (String name : teammates) {
if (!name.equals(sender)) {
send(sender, name, content, "broadcast", null);
count++;
}
}
return "Broadcast to " + count + " teammates";
}
}
@@ -0,0 +1,93 @@
package io.mybatis.learn.core.tools;
import io.mybatis.learn.core.AgentRunner;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* Shell 命令执行工具。
* <p>
* TIP: 对应 Python {@code s01_agent_loop.py} 中的 {@code run_bash(command)} 函数。
* Python 使用 {@code subprocess.run()}Java 使用 {@code ProcessBuilder}。
* 安全机制相同:危险命令过滤 + 超时控制 + 输出截断。
*/
public class BashTool {
private static final Logger log = LoggerFactory.getLogger(BashTool.class);
private static final int TIMEOUT_SECONDS = 120;
private static final int MAX_OUTPUT_LENGTH = 50000;
private static final List<String> DANGEROUS_PATTERNS = List.of(
"rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"
);
private final String workDir;
public BashTool() {
this.workDir = System.getProperty("user.dir");
}
public BashTool(String workDir) {
this.workDir = workDir;
}
@Tool(description = "Run a shell command and return stdout + stderr")
public String bash(@ToolParam(description = "The shell command to execute") String command) {
if (log.isDebugEnabled()) {
System.out.printf("🔧 调用工具 [Bash] workDir=%s, command=%s%n", workDir,
command.substring(0, Math.min(80, command.length())));
}
// 危险命令检查
for (String pattern : DANGEROUS_PATTERNS) {
if (command.contains(pattern)) {
log.warn("拦截危险命令,pattern={}", pattern);
return "Error: Dangerous command blocked";
}
}
try {
ProcessBuilder pb = new ProcessBuilder();
if (System.getProperty("os.name").toLowerCase().contains("win")) {
pb.command("cmd", "/c", command);
} else {
pb.command("sh", "-c", command);
}
pb.directory(new java.io.File(workDir));
pb.redirectErrorStream(true);
Process process = pb.start();
// 读取输出
String output;
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
output = reader.lines().collect(Collectors.joining("\n"));
}
boolean finished = process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS);
if (!finished) {
process.destroyForcibly();
log.warn("命令执行超时,timeout={}s", TIMEOUT_SECONDS);
return "Error: Timeout (" + TIMEOUT_SECONDS + "s)";
}
output = output.trim();
if (log.isDebugEnabled()) {
System.out.printf("✅ 工具完成 [Bash] 输出 %d 字符%n", output.length());
}
if (output.isEmpty()) return "(no output)";
return AgentRunner.truncate(output, MAX_OUTPUT_LENGTH);
} catch (Exception e) {
log.warn("命令执行异常,error={}", e.getMessage());
return "Error: " + e.getMessage();
}
}
}
@@ -0,0 +1,62 @@
package io.mybatis.learn.core.tools;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* 文件编辑工具(查找替换)。
* <p>
* TIP: 对应 Python {@code s02_tool_use.py} 中的 {@code run_edit(path, old_text, new_text)} 函数。
* Python 使用 {@code str.replace(old, new, 1)}Java 使用 {@code String.replaceFirst()}。
* 只替换第一次出现的匹配文本。
*/
public class EditFileTool {
private static final Logger log = LoggerFactory.getLogger(EditFileTool.class);
private final PathValidator pathValidator;
public EditFileTool() {
this.pathValidator = new PathValidator();
}
public EditFileTool(PathValidator pathValidator) {
this.pathValidator = pathValidator;
}
@Tool(description = "Replace exact text in a file. Only the first occurrence is replaced.")
public String editFile(
@ToolParam(description = "Relative path to the file") String path,
@ToolParam(description = "The exact text to find") String oldText,
@ToolParam(description = "The replacement text") String newText) {
if (log.isDebugEnabled()) {
System.out.printf("✏️ 调用工具 [EditFile] path=%s (替换 %d→%d 字符)%n",
path, oldText == null ? 0 : oldText.length(), newText == null ? 0 : newText.length());
}
try {
Path filePath = pathValidator.resolve(path);
String content = Files.readString(filePath);
if (!content.contains(oldText)) {
log.warn("编辑文件失败,未找到目标文本: path={}", path);
return "Error: Text not found in " + path;
}
// 只替换第一次出现(使用 indexOf + substring 避免正则转义问题)
int index = content.indexOf(oldText);
String updated = content.substring(0, index) + newText + content.substring(index + oldText.length());
Files.writeString(filePath, updated);
if (log.isDebugEnabled()) {
System.out.printf("✅ 工具完成 [EditFile] %s%n", path);
}
return "Edited " + path;
} catch (Exception e) {
log.warn("编辑文件异常: path={}, error={}", path, e.getMessage());
return "Error: " + e.getMessage();
}
}
}
@@ -0,0 +1,41 @@
package io.mybatis.learn.core.tools;
import java.nio.file.Path;
/**
* 路径安全校验工具。
* <p>
* TIP: 对应 Python {@code s02_tool_use.py} 中的 {@code safe_path()} 函数。
* 防止路径逃逸(path traversal),确保所有文件操作都在工作目录范围内。
*/
public class PathValidator {
private final Path workDir;
public PathValidator() {
this.workDir = Path.of(System.getProperty("user.dir")).toAbsolutePath().normalize();
}
public PathValidator(Path workDir) {
this.workDir = workDir.toAbsolutePath().normalize();
}
/**
* 校验并解析路径,确保不逃逸出工作目录。
*
* @param relativePath 相对路径字符串
* @return 解析后的绝对路径
* @throws IllegalArgumentException 路径逃逸时抛出
*/
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;
}
public Path getWorkDir() {
return workDir;
}
}
@@ -0,0 +1,61 @@
package io.mybatis.learn.core.tools;
import io.mybatis.learn.core.AgentRunner;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
/**
* 文件读取工具。
* <p>
* TIP: 对应 Python {@code s02_tool_use.py} 中的 {@code run_read(path, limit)} 函数。
* Python 使用 {@code Path.read_text()}Java 使用 {@code Files.readString()}。
* 支持可选的行数限制参数。
*/
public class ReadFileTool {
private static final Logger log = LoggerFactory.getLogger(ReadFileTool.class);
private static final int MAX_OUTPUT_LENGTH = 50000;
private final PathValidator pathValidator;
public ReadFileTool() {
this.pathValidator = new PathValidator();
}
public ReadFileTool(PathValidator pathValidator) {
this.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 (0 or negative for all)", required = false) Integer limit) {
if (log.isDebugEnabled()) {
System.out.printf("📖 调用工具 [ReadFile] path=%s, limit=%s%n", path, limit);
}
try {
Path filePath = pathValidator.resolve(path);
List<String> lines = Files.readAllLines(filePath);
if (limit != null && limit > 0 && limit < lines.size()) {
lines = lines.subList(0, limit);
lines.add("... (" + (Files.readAllLines(filePath).size() - limit) + " more lines)");
}
String content = String.join("\n", lines);
if (log.isDebugEnabled()) {
System.out.printf("✅ 工具完成 [ReadFile] %s 共 %d 行%n", path, lines.size());
}
return AgentRunner.truncate(content, MAX_OUTPUT_LENGTH);
} catch (Exception e) {
log.warn("读取文件失败: path={}, error={}", path, e.getMessage());
return "Error: " + e.getMessage();
}
}
}
@@ -0,0 +1,51 @@
package io.mybatis.learn.core.tools;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* 文件写入工具。
* <p>
* TIP: 对应 Python {@code s02_tool_use.py} 中的 {@code run_write(path, content)} 函数。
* Python 使用 {@code Path.write_text()}Java 使用 {@code Files.writeString()}。
* 自动创建父目录。
*/
public class WriteFileTool {
private static final Logger log = LoggerFactory.getLogger(WriteFileTool.class);
private final PathValidator pathValidator;
public WriteFileTool() {
this.pathValidator = new PathValidator();
}
public WriteFileTool(PathValidator pathValidator) {
this.pathValidator = pathValidator;
}
@Tool(description = "Write content to a file. Creates parent directories if needed.")
public String writeFile(
@ToolParam(description = "Relative path to the file") String path,
@ToolParam(description = "Content to write") String content) {
if (log.isDebugEnabled()) {
System.out.printf("✏️ 调用工具 [WriteFile] path=%s (%d 字符)%n", path, content == null ? 0 : content.length());
}
try {
Path filePath = pathValidator.resolve(path);
Files.createDirectories(filePath.getParent());
Files.writeString(filePath, content);
if (log.isDebugEnabled()) {
System.out.printf("✅ 工具完成 [WriteFile] %s (%d 字节)%n", path, content.length());
}
return "Wrote " + content.length() + " bytes to " + path;
} catch (Exception e) {
log.warn("写文件失败: path={}, error={}", path, e.getMessage());
return "Error: " + e.getMessage();
}
}
}
@@ -0,0 +1,373 @@
package io.mybatis.learn.full;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.mybatis.learn.core.AgentRunner;
import io.mybatis.learn.core.team.MessageBus;
import io.mybatis.learn.core.tools.*;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* 综合版 TeammateManager - 融合S09(团队) + S10(协议) + S11(自治) 全部特性
*
* TIPS: 对应Python s_full.py的TeammateManager(第400-543行)。
* 特性汇总:
* S09: 虚拟线程 + JSONL收件箱通信
* S10: shutdown_request 检测 + 优雅退出
* S11: idle轮询 + 自动认领未分配任务
*
* Python队友执行50轮LLM调用的手动工具循环;
* Java队友用ChatClient.prompt().call()自动处理工具循环,每次call()相当于Python整个while循环。
*/
public class FullTeammateManager {
private static final int POLL_INTERVAL = 5;
private static final int IDLE_TIMEOUT = 60;
private static final int MAX_WORK_PHASES = 3;
private final ChatModel chatModel;
private final MessageBus bus;
private final Path teamDir;
private final Path configPath;
private final Path tasksDir;
private final ObjectMapper mapper = new ObjectMapper();
private Map<String, Object> config;
private final Map<String, Thread> threads = new ConcurrentHashMap<>();
public FullTeammateManager(ChatModel chatModel, MessageBus bus,
Path teamDir, Path tasksDir) {
this.chatModel = chatModel;
this.bus = bus;
this.teamDir = teamDir;
this.configPath = teamDir.resolve("config.json");
this.tasksDir = tasksDir;
try { Files.createDirectories(teamDir); } catch (IOException e) { throw new RuntimeException(e); }
this.config = loadConfig();
}
@SuppressWarnings("unchecked")
private Map<String, Object> loadConfig() {
if (Files.exists(configPath)) {
try { return mapper.readValue(configPath.toFile(), new TypeReference<>() {}); }
catch (IOException e) { /* ignore */ }
}
Map<String, Object> def = new LinkedHashMap<>();
def.put("team_name", "default");
def.put("members", new ArrayList<>());
return def;
}
private synchronized void saveConfig() {
try { mapper.writerWithDefaultPrettyPrinter().writeValue(configPath.toFile(), config); }
catch (IOException e) { System.err.println("Config save error: " + e.getMessage()); }
}
@SuppressWarnings("unchecked")
private void setStatus(String name, String status) {
var members = (List<Map<String, Object>>) config.get("members");
for (var m : members) {
if (name.equals(m.get("name"))) m.put("status", status);
}
saveConfig();
}
// ---- 公开方法 ----
@SuppressWarnings("unchecked")
public String spawn(String name, String role, String prompt) {
var members = (List<Map<String, Object>>) config.get("members");
members.removeIf(m -> name.equals(m.get("name")));
Map<String, Object> member = new LinkedHashMap<>();
member.put("name", name);
member.put("role", role);
member.put("status", "working");
members.add(member);
saveConfig();
Thread t = Thread.startVirtualThread(() -> {
try { teammateLoop(name, role, prompt); }
catch (Exception e) {
System.err.println(" [" + name + "] Fatal: " + e.getMessage());
setStatus(name, "error");
}
});
threads.put(name, t);
return "Spawned '" + name + "' (role: " + role + ")";
}
@SuppressWarnings("unchecked")
public String listAll() {
var members = (List<Map<String, Object>>) config.get("members");
if (members.isEmpty()) return "No teammates.";
StringBuilder sb = new StringBuilder();
for (var m : members) {
sb.append(String.format("[%s] %s (role: %s)%n",
m.getOrDefault("status", "unknown"), m.get("name"), m.get("role")));
}
return sb.toString().trim();
}
@SuppressWarnings("unchecked")
public List<String> memberNames() {
var members = (List<Map<String, Object>>) config.get("members");
return members.stream().map(m -> (String) m.get("name")).toList();
}
// ---- 队友主循环:工作 → 空闲 → 自动认领/恢复/关闭 ----
private void teammateLoop(String name, String role, String initialPrompt) {
String workDir = System.getProperty("user.dir");
String sysPrompt = String.format(
"You are '%s', role: %s, at %s. "
+ "Send messages with send_message. Call idle when you have no more work. "
+ "Call claim_task to pick up unassigned tasks from the shared board.",
name, role, workDir);
AtomicBoolean idleFlag = new AtomicBoolean(false);
var msgTool = new TeammateMessageTool(bus, name);
var idleTool = new IdleTool(idleFlag);
var claimTool = new ClaimTaskTool(tasksDir, name);
ChatClient client = ChatClient.builder(chatModel)
.defaultSystem(sysPrompt)
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(),
msgTool, idleTool, claimTool)
.build();
String nextInput = initialPrompt;
for (int phase = 0; phase < MAX_WORK_PHASES; phase++) {
// ---- 工作阶段 ----
setStatus(name, "working");
idleFlag.set(false);
for (int round = 0; round < 50; round++) {
// 检查收件箱
var inbox = bus.readInbox(name);
StringBuilder input = new StringBuilder();
if (nextInput != null) { input.append(nextInput); nextInput = null; }
boolean shutdownRequested = false;
for (var msg : inbox) {
if ("shutdown_request".equals(msg.get("type"))) {
shutdownRequested = true;
bus.send(name, "lead", "Acknowledged shutdown.", "shutdown_response", null);
} else {
input.append("\n<inbox>").append(toJson(msg)).append("</inbox>");
}
}
if (shutdownRequested) {
System.out.println(" [" + name + "] Shutdown acknowledged.");
setStatus(name, "shutdown");
return;
}
String trimmedInput = input.toString().trim();
if (trimmedInput.isEmpty()) {
if (round > 0) break; // 没有更多输入,结束工作阶段
continue;
}
try {
String response = client.prompt(trimmedInput).call().content();
System.out.println(" [" + name + "] " + AgentRunner.truncate(response, 120));
} catch (Exception e) {
System.err.println(" [" + name + "] LLM error: " + e.getMessage());
break;
}
if (idleFlag.get()) break;
try { Thread.sleep(1000); } catch (InterruptedException e) { return; }
}
// ---- 空闲阶段 (S11) ----
setStatus(name, "idle");
System.out.println(" [" + name + "] Entering idle phase...");
boolean resume = false;
long deadline = System.currentTimeMillis() + IDLE_TIMEOUT * 1000L;
while (System.currentTimeMillis() < deadline) {
try { Thread.sleep(POLL_INTERVAL * 1000L); } catch (InterruptedException e) { return; }
// 轮询收件箱
var inbox = bus.readInbox(name);
if (!inbox.isEmpty()) {
StringBuilder sb = new StringBuilder();
boolean shutdown = false;
for (var msg : inbox) {
if ("shutdown_request".equals(msg.get("type"))) {
shutdown = true;
} else {
sb.append("<inbox>").append(toJson(msg)).append("</inbox>\n");
}
}
if (shutdown) {
bus.send(name, "lead", "Acknowledged shutdown.", "shutdown_response", null);
setStatus(name, "shutdown");
return;
}
String inboxInput = sb.toString().trim();
if (!inboxInput.isEmpty()) {
nextInput = inboxInput;
resume = true;
break;
}
}
// 自动认领未分配任务 (S11)
var unclaimed = scanUnclaimedTasks();
if (!unclaimed.isEmpty()) {
var task = unclaimed.get(0);
claimTaskFile(task, name);
nextInput = "<auto-claimed>Task #" + task.get("id") + ": "
+ task.get("subject") + "\n"
+ task.getOrDefault("description", "") + "</auto-claimed>";
resume = true;
break;
}
}
if (!resume) {
System.out.println(" [" + name + "] Idle timeout. Shutting down.");
setStatus(name, "shutdown");
return;
}
}
setStatus(name, "idle");
}
// ---- 辅助方法 ----
@SuppressWarnings("unchecked")
private List<Map<String, Object>> scanUnclaimedTasks() {
if (tasksDir == null || !Files.exists(tasksDir)) return List.of();
try (var files = Files.list(tasksDir)) {
List<Map<String, Object>> result = new ArrayList<>();
files.filter(f -> f.getFileName().toString().matches("task_\\d+\\.json"))
.forEach(f -> {
try {
Map<String, Object> task = mapper.readValue(f.toFile(), new TypeReference<>() {});
if ("pending".equals(task.get("status"))
&& (task.get("owner") == null || task.get("owner").toString().isEmpty())) {
var blocked = (List<?>) task.getOrDefault("blockedBy", List.of());
if (blocked == null || blocked.isEmpty()) result.add(task);
}
} catch (IOException e) { /* skip */ }
});
return result;
} catch (IOException e) { return List.of(); }
}
private void claimTaskFile(Map<String, Object> task, String owner) {
int taskId = ((Number) task.get("id")).intValue();
task.put("owner", owner);
task.put("status", "in_progress");
task.put("updated_at", System.currentTimeMillis() / 1000.0);
try {
mapper.writerWithDefaultPrettyPrinter().writeValue(
tasksDir.resolve("task_" + taskId + ".json").toFile(), task);
} catch (IOException e) { /* ignore */ }
}
private String toJson(Object obj) {
try { return mapper.writeValueAsString(obj); }
catch (Exception e) { return obj.toString(); }
}
// ---- 参数化工具对象(每个队友独立实例) ----
/**
* TIPS: 队友的消息工具。每个实例绑定特定的发送者名称(sender identity)。
* 对应Python中 TOOL_HANDLERS["send_message"] 和 TOOL_HANDLERS["read_inbox"]。
*/
public static class TeammateMessageTool {
private final MessageBus bus;
private final String name;
private final ObjectMapper mapper = new ObjectMapper();
public TeammateMessageTool(MessageBus bus, String name) {
this.bus = bus;
this.name = name;
}
@Tool(description = "Send message to another teammate or lead")
public String sendMessage(
@ToolParam(description = "Recipient name") String to,
@ToolParam(description = "Message content") String content) {
return bus.send(name, to, content);
}
@Tool(description = "Read and drain your inbox")
public String readInbox() {
try { return mapper.writeValueAsString(bus.readInbox(name)); }
catch (Exception e) { return "[]"; }
}
}
/**
* TIPS: 空闲信号工具。通过AtomicBoolean标志跨线程通信。
* Python中idle()直接break循环;Java中ChatClient自动处理工具循环,
* idle设置标志后,等call()返回再检查标志来退出外层循环。
*/
public static class IdleTool {
private final AtomicBoolean flag;
public IdleTool(AtomicBoolean flag) { this.flag = flag; }
@Tool(description = "Signal that you have completed current work and entering idle state")
public String idle() {
flag.set(true);
return "Entering idle phase. Will poll for new tasks.";
}
}
/**
* TIPS: 任务认领工具。对应Python的claim_tasks_full第513-530行)。
* 直接读写 .tasks/task_N.json 文件,将owner设为当前队友名。
*/
public static class ClaimTaskTool {
private final Path tasksDir;
private final String owner;
private final ObjectMapper mapper = new ObjectMapper();
public ClaimTaskTool(Path tasksDir, String owner) {
this.tasksDir = tasksDir;
this.owner = owner;
}
@SuppressWarnings("unchecked")
@Tool(description = "Claim an unclaimed task from the shared task board")
public String claimTask(@ToolParam(description = "Task ID to claim") int taskId) {
try {
Path path = tasksDir.resolve("task_" + taskId + ".json");
if (!Files.exists(path)) return "Error: Task " + taskId + " not found";
Map<String, Object> task = mapper.readValue(path.toFile(), new TypeReference<>() {});
if (!"pending".equals(task.get("status")))
return "Error: Task " + taskId + " is not pending (status: " + task.get("status") + ")";
task.put("owner", owner);
task.put("status", "in_progress");
task.put("updated_at", System.currentTimeMillis() / 1000.0);
mapper.writerWithDefaultPrettyPrinter().writeValue(path.toFile(), task);
return "Claimed task " + taskId + ": " + task.get("subject");
} catch (IOException e) {
return "Error: " + e.getMessage();
}
}
}
}
@@ -0,0 +1,267 @@
package io.mybatis.learn.full;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.mybatis.learn.core.AgentRunner;
import io.mybatis.learn.core.team.MessageBus;
import io.mybatis.learn.core.tools.*;
import io.mybatis.learn.s03.TodoManager;
import io.mybatis.learn.s04.SubagentTool;
import io.mybatis.learn.s05.SkillLoader;
import io.mybatis.learn.s07.TaskManager;
import io.mybatis.learn.s08.BackgroundManager;
import io.mybatis.learn.s10.ProtocolTracker;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import java.nio.file.Path;
import java.util.*;
/**
* Full Agent - 融合 S01-S11 全部机制的综合型Agent
*
* TIPS: 对应Python s_full.py(全部700+行)。
* 组合了12节课的所有核心概念:
*
* S01: ChatClient基础循环 → AgentRunner.interactive()
* S02: 4个基础工具 → BashTool, ReadFileTool, WriteFileTool, EditFileTool
* S03: TodoManager + 催促提醒 → todos.render() 注入系统提示 + nag counter
* S04: 子Agent委派 → SubagentTool
* S05: 技能加载 → SkillLoader
* S06: 上下文压缩(简化版) → /compact 命令
* S07: 持久化任务板 → TaskManager
* S08: 后台任务 + 通知排空 → BackgroundManager.drainNotifications()
* S09: 团队消息总线 → MessageBus + FullTeammateManager
* S10: 关闭/计划协议 → ProtocolTracker
* S11: 自治队友(空闲轮询+自动认领)→ FullTeammateManager idle+autoclaim
*
* 每次REPL调用前的流水线:
* 1. 排空后台通知 → 注入 <background-results>
* 2. 排空Lead收件箱 → 注入 <inbox>
* 3. Todo催促检查 → 注入 <reminder>
* 4. 重建ChatClient(注入当前todo状态到系统提示)
* 5. 调用LLM
*
* REPL命令:/compact, /tasks, /team, /inbox
* 工具总数:~20个(4基础 + TodoWrite + task + load_skill + 4任务 + 2后台 + 7团队协议)
*/
@SpringBootApplication(scanBasePackages = "io.mybatis.learn.core")
public class SFullAgent implements CommandLineRunner {
@Autowired
private ChatModel chatModel;
@Override
public void run(String... args) throws Exception {
Path workDir = Path.of(System.getProperty("user.dir"));
Path teamDir = workDir.resolve(".team");
// ---- 初始化所有组件 ----
// S03: 内存中的todo列表
TodoManager todos = new TodoManager();
// S04: 子Agent委派
SubagentTool subagent = new SubagentTool(chatModel);
// S05: 技能加载
SkillLoader skills = new SkillLoader(workDir.resolve("skills"));
// S07: 持久化任务板
TaskManager taskMgr = new TaskManager(workDir.resolve(".tasks"));
// S08: 后台命令执行
BackgroundManager bgMgr = new BackgroundManager();
// S09: 团队消息总线
MessageBus bus = new MessageBus(teamDir.resolve("inbox"));
// S10: 协议追踪
ProtocolTracker protocols = new ProtocolTracker(bus);
// S09+S10+S11: 自治队友管理
FullTeammateManager team = new FullTeammateManager(
chatModel, bus, teamDir, workDir.resolve(".tasks"));
// Lead专用工具
LeadTools leadTools = new LeadTools(bus, team, protocols, taskMgr);
// ---- 系统提示 ----
String baseSystem = "You are a coding agent at " + workDir + ". Use tools to solve tasks.\n"
+ "Prefer task_create/task_update/task_list for multi-step work. "
+ "Use updateTodos for short checklists.\n"
+ "Use task for subagent delegation. Use load_skill for specialized knowledge.\n"
+ "Skills: " + skills.getDescriptions();
// S03: 催促计数器
final int[] roundsWithoutTodo = {0};
ObjectMapper mapper = new ObjectMapper();
System.out.println("Full Agent initialized. Components: todo, subagent, skills, "
+ "tasks, background, team, protocols");
System.out.println("Commands: /compact, /tasks, /team, /inbox, q/exit");
// ---- REPL ----
AgentRunner.interactive("s_full", input -> {
// REPL命令
if ("/compact".equals(input)) {
return "[compact] 无状态模式,无会话历史可压缩。"
+ "每次调用独立进行(TIPS: Python版维护messages列表并在超过100K token时自动压缩)。";
}
if ("/tasks".equals(input)) return taskMgr.taskList();
if ("/team".equals(input)) return team.listAll();
if ("/inbox".equals(input)) {
try { return mapper.writeValueAsString(bus.readInbox("lead")); }
catch (Exception e) { return "[]"; }
}
// ---- 调用前流水线 ----
StringBuilder prefix = new StringBuilder();
// S08: 排空后台通知
var notifications = bgMgr.drainNotifications();
if (!notifications.isEmpty()) {
prefix.append("<background-results>\n");
for (var n : notifications) {
prefix.append(String.format("[bg:%s] %s: %s%n",
n.taskId(), n.status(),
AgentRunner.truncate(n.result() != null ? n.result() : "", 500)));
}
prefix.append("</background-results>\n");
}
// S09: 排空Lead收件箱
var inbox = bus.readInbox("lead");
if (!inbox.isEmpty()) {
try {
prefix.append("<inbox>")
.append(mapper.writeValueAsString(inbox))
.append("</inbox>\n");
} catch (Exception e) { /* ignore */ }
}
// S03: Todo催促(连续3轮未更新todo时提醒)
String rendered = todos.render();
boolean hasOpenTodos = rendered.contains("[ ]") || rendered.contains("[>");
if (hasOpenTodos && roundsWithoutTodo[0] >= 3) {
prefix.append("<reminder>You have open todos. Please update them.</reminder>\n");
}
roundsWithoutTodo[0]++;
String fullInput = prefix.length() > 0 ? prefix + "\n" + input : input;
// 每次重建ChatClient,注入最新todo状态到系统提示
String currentSystem = baseSystem + "\n\nCurrent todos:\n" + rendered;
ChatClient client = ChatClient.builder(chatModel)
.defaultSystem(currentSystem)
.defaultTools(
new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(),
todos, subagent, skills,
taskMgr, bgMgr, leadTools)
.build();
String response = client.prompt(fullInput).call().content();
// 简易启发式:如果响应提及"todo",重置催促计数器
if (response != null && response.toLowerCase().contains("todo")) {
roundsWithoutTodo[0] = 0;
}
return response;
});
}
// ---- Lead专用工具集:团队管理 + 协议 + 任务认领 ----
/**
* TIPS: 对应Python s_full.py中Lead可用的团队/协议工具。
* 7个工具:spawn_teammate, list_teammates, send_message, read_inbox, broadcast,
* shutdown_request, plan_approval
*
* Python中这些是TOOL_HANDLERS字典里的独立函数;
* Java中聚合为一个@Tool对象,通过ChatClient.defaultTools()注册。
*/
public static class LeadTools {
private final MessageBus bus;
private final FullTeammateManager team;
private final ProtocolTracker protocols;
private final TaskManager taskMgr;
public LeadTools(MessageBus bus, FullTeammateManager team,
ProtocolTracker protocols, TaskManager taskMgr) {
this.bus = bus;
this.team = team;
this.protocols = protocols;
this.taskMgr = taskMgr;
}
@Tool(description = "Spawn an autonomous teammate that runs in its own thread with idle+auto-claim")
public String spawnTeammate(
@ToolParam(description = "Unique teammate name") String name,
@ToolParam(description = "Role description") String role,
@ToolParam(description = "Initial task prompt") String prompt) {
return team.spawn(name, role, prompt);
}
@Tool(description = "List all teammates with name, role, status")
public String listTeammates() {
return team.listAll();
}
@Tool(description = "Send message to a teammate's inbox")
public String sendMessage(
@ToolParam(description = "Recipient name") String to,
@ToolParam(description = "Message content") String content) {
return bus.send("lead", to, content);
}
@Tool(description = "Read and drain the lead's inbox")
public String readInbox() {
try { return new ObjectMapper().writeValueAsString(bus.readInbox("lead")); }
catch (Exception e) { return "[]"; }
}
@Tool(description = "Broadcast message to all teammates")
public String broadcast(
@ToolParam(description = "Message content") String content) {
return bus.broadcast("lead", content, team.memberNames());
}
@Tool(description = "Request a teammate to shutdown gracefully")
public String shutdownRequest(
@ToolParam(description = "Teammate name to shutdown") String teammate) {
return protocols.handleShutdownRequest(teammate);
}
@Tool(description = "Approve or reject a teammate's plan submission")
public String planApproval(
@ToolParam(description = "Plan request ID") String requestId,
@ToolParam(description = "Teammate who submitted the plan") String teammate,
@ToolParam(description = "Whether to approve the plan") boolean approve,
@ToolParam(description = "Feedback message") String feedback) {
String result = protocols.reviewPlan(requestId, approve, feedback);
String status = approve ? "approved" : "rejected";
bus.send("lead", teammate, feedback != null ? feedback : status,
"plan_approval_response",
Map.of("request_id", requestId, "status", status));
return result;
}
@Tool(description = "Claim an unclaimed task for the lead agent")
public String claimTask(
@ToolParam(description = "Task ID to claim") int taskId) {
return taskMgr.taskUpdate(taskId, "in_progress", null, null);
}
}
public static void main(String[] args) {
SpringApplication.run(SFullAgent.class, args);
}
}
@@ -0,0 +1,82 @@
package io.mybatis.learn.s01;
import io.mybatis.learn.core.AgentRunner;
import io.mybatis.learn.core.tools.BashTool;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* S01 - Agent 循环:AI 编码 Agent 的全部秘密。
* <p>
* 格言: "One loop & Bash is all you need"
* <p>
* 核心模式:
* <pre>
* +----------+ +-------+ +---------+
* | User | ---> | LLM | ---> | Tool |
* | prompt | | | | execute |
* +----------+ +---+---+ +----+----+
* ^ |
* | tool_result |
* +---------------+
* (loop continues)
* </pre>
* <p>
* TIP: 对应 Python {@code agents/s01_agent_loop.py}。
* Python 版本手动实现 while 循环 + tool dispatch。
* Spring AI 的 {@link ChatClient} 内置了工具调用循环,
* 调用 {@code .tools(...).call()} 时自动完成:发送请求 → 检测 tool_use → 执行工具 → 回传结果 → 重复。
* 因此 Java 版本无需手写 while 循环,一行 {@code chatClient.prompt()...call()} 即可。
*/
@SpringBootApplication(scanBasePackages = "io.mybatis.learn.core")
public class S01AgentLoop implements CommandLineRunner {
private final ChatClient chatClient;
/**
* TIP: Python 版在模块级创建 client = Anthropic() 和 MODEL。
* Spring AI 通过自动配置注入 ChatModel,再用 builder 构建 ChatClient。
*/
public S01AgentLoop(ChatModel chatModel) {
// TIP: Python 的 SYSTEM prompt 对应 ChatClient 的 defaultSystem
// TIP: Python 的 TOOLS 数组 + TOOL_HANDLERS 对应 Spring AI 的 @Tool 注解 + defaultTools
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())
.build();
}
@Override
public void run(String... args) {
/*
* TIP: 对应 Python 的 agent_loop(messages) 函数。
*
* Python 版需要手动实现:
* while True:
* response = client.messages.create(model, messages, tools)
* if response.stop_reason != "tool_use": return
* 执行工具, 收集结果
* messages.append(tool_results)
*
* Spring AI 的 ChatClient.call() 内部已封装此循环,
* 包括工具调用检测、自动执行、结果回传,直到模型返回最终文本。
*/
AgentRunner.interactive("s01", userMessage ->
chatClient.prompt()
.user(userMessage)
.call()
.content()
);
}
public static void main(String[] args) {
SpringApplication app = new SpringApplication(S01AgentLoop.class);
app.setWebApplicationType(WebApplicationType.NONE);
app.run(args);
}
}
@@ -0,0 +1,81 @@
package io.mybatis.learn.s02;
import io.mybatis.learn.core.AgentRunner;
import io.mybatis.learn.core.tools.BashTool;
import io.mybatis.learn.core.tools.EditFileTool;
import io.mybatis.learn.core.tools.ReadFileTool;
import io.mybatis.learn.core.tools.WriteFileTool;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* S02 - 工具使用:扩展 Agent 能触达的范围。
* <p>
* 格言: "加一个工具, 只加一个 handler"
* <p>
* 核心变化:
* <pre>
* +----------+ +-------+ +------------------+
* | User | ---> | LLM | ---> | Tool Dispatch |
* | prompt | | | | { |
* +----------+ +---+---+ | bash |
* ^ | read_file |
* | | write_file |
* +----------+ edit_file |
* tool_result| } |
* +------------------+
* </pre>
* <p>
* TIP: 对应 Python {@code agents/s02_tool_use.py}。
* Python 版通过 {@code TOOL_HANDLERS = {"bash": fn, "read_file": fn, ...}} 字典进行分派。
* Spring AI 通过 {@code @Tool} 注解声明工具,传入 {@code defaultTools()} 即可自动注册和分派。
* 关键洞察: "循环一点没变,只是多加了工具" —— 在 Spring AI 中更加明显,
* 因为循环由框架管理,开发者只需关注工具的实现。
*/
@SpringBootApplication(scanBasePackages = "io.mybatis.learn.core")
public class S02ToolUse implements CommandLineRunner {
private final ChatClient chatClient;
/**
* TIP: Python 版在模块级定义 4 个工具函数 + TOOL_HANDLERS 字典。
* Spring AI 只需创建工具对象并传入 defaultTools(),框架自动完成注册和分派。
*/
public S02ToolUse(ChatModel chatModel) {
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent at " + System.getProperty("user.dir")
+ ". Use tools to solve tasks. Act, don't explain.")
.defaultTools(
new BashTool(),
new ReadFileTool(),
new WriteFileTool(),
new EditFileTool()
)
.build();
}
@Override
public void run(String... args) {
/*
* TIP: 对比 Python 的 agent_loop(),循环代码完全相同——只是 TOOLS 数组多了 3 个工具。
* 在 Spring AI 中,循环代码也完全相同——只是 defaultTools() 多传了 3 个对象。
* 这正是 s02 的核心洞察: 扩展能力不需要修改循环。
*/
AgentRunner.interactive("s02", userMessage ->
chatClient.prompt()
.user(userMessage)
.call()
.content()
);
}
public static void main(String[] args) {
SpringApplication app = new SpringApplication(S02ToolUse.class);
app.setWebApplicationType(WebApplicationType.NONE);
app.run(args);
}
}
@@ -0,0 +1,102 @@
package io.mybatis.learn.s03;
import io.mybatis.learn.core.AgentRunner;
import io.mybatis.learn.core.tools.BashTool;
import io.mybatis.learn.core.tools.EditFileTool;
import io.mybatis.learn.core.tools.ReadFileTool;
import io.mybatis.learn.core.tools.WriteFileTool;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* S03 - TodoWrite:让 Agent 追踪自己的进度。
* <p>
* 格言: "没有计划的 agent 走哪算哪"
* <p>
* 核心模式:
* <pre>
* +----------+ +-------+ +---------+
* | User | ---> | LLM | ---> | Tools |
* | prompt | | | | + todo |
* +----------+ +---+---+ +----+----+
* ^ |
* | tool_result |
* +---------------+
* |
* +-----------+-----------+
* | TodoManager state |
* | [ ] task A |
* | [>] task B |
* | [x] task C |
* +-----------------------+
* </pre>
* <p>
* TIP: 对应 Python {@code agents/s03_todo_write.py}。
* Python 版在工具循环内追踪 {@code rounds_since_todo}
* 连续 3 轮未调用 todo 时注入 {@code <reminder>Update your todos.</reminder>} 文本。
* Spring AI 的 ChatClient 自动管理工具循环,无法在循环内注入文本,
* 因此改用系统提示注入当前 todo 状态 + 强调更新指令的方式实现同等效果。
* 如需精确的轮次追踪,可实现自定义 {@code Advisor}。
*/
@SpringBootApplication(scanBasePackages = "io.mybatis.learn.core")
public class S03TodoWrite implements CommandLineRunner {
private final ChatModel chatModel;
private final TodoManager todoManager = new TodoManager();
public S03TodoWrite(ChatModel chatModel) {
this.chatModel = chatModel;
}
@Override
public void run(String... args) {
String workDir = System.getProperty("user.dir");
/*
* TIP: 每次用户输入时重建 ChatClient,将最新的 todo 状态注入系统提示。
* 这对应 Python 版在 agent_loop() 中每轮都能看到 TodoManager 状态的效果。
*/
AgentRunner.interactive("s03", userMessage -> {
// 动态系统提示:包含当前 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"
+ "Prefer tools over prose.\n"
+ "IMPORTANT: You MUST call updateTodos regularly to track your progress.\n\n"
+ "<current-todos>\n" + todoManager.render() + "\n</current-todos>";
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem(system)
.defaultTools(
new BashTool(),
new ReadFileTool(),
new WriteFileTool(),
new EditFileTool(),
todoManager
)
.build();
String response = chatClient.prompt()
.user(userMessage)
.call()
.content();
// 每次回复后打印当前 todo 状态
System.out.println("\n--- Todo Status ---");
System.out.println(todoManager.render());
System.out.println("-------------------");
return response;
});
}
public static void main(String[] args) {
SpringApplication app = new SpringApplication(S03TodoWrite.class);
app.setWebApplicationType(WebApplicationType.NONE);
app.run(args);
}
}
@@ -0,0 +1,102 @@
package io.mybatis.learn.s03;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import java.util.ArrayList;
import java.util.List;
/**
* Todo 任务管理器 —— Agent 追踪自身进度的结构化状态。
* <p>
* TIP: 对应 Python {@code agents/s03_todo_write.py} 中的 {@code TodoManager} 类。
* Python 版通过 {@code TOOL_HANDLERS["todo"]} 注册,
* Spring AI 使用 {@code @Tool} 注解直接在方法上声明。
* <p>
* 验证规则:
* <ul>
* <li>最多 20 个 todo</li>
* <li>最多 1 个 in_progress 状态</li>
* <li>text 不能为空</li>
* <li>status 只能是 pending / in_progress / completed</li>
* </ul>
*/
public class TodoManager {
/**
* Todo 项数据结构。
* TIP: 对应 Python 中 todo item 字典 {@code {"id": ..., "text": ..., "status": ...}}。
*/
public record TodoItem(String id, String text, String status) {
}
private static final List<String> VALID_STATUSES = List.of("pending", "in_progress", "completed");
private List<TodoItem> items = new ArrayList<>();
/**
* 更新整个 todo 列表。
* TIP: 对应 Python {@code TodoManager.update(items)},验证逻辑完全一致。
*/
@Tool(description = "Update the full task list to track progress on multi-step tasks. "
+ "Each item must have id, text, and 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 (int i = 0; i < items.size(); i++) {
TodoItem item = items.get(i);
String id = (item.id() != null && !item.id().isBlank()) ? item.id() : String.valueOf(i + 1);
String text = item.text();
String status = (item.status() != null) ? item.status().toLowerCase() : "pending";
if (text == null || text.isBlank()) {
return "Error: Item " + id + ": text required";
}
if (!VALID_STATUSES.contains(status)) {
return "Error: Item " + id + ": invalid status '" + status + "'";
}
if ("in_progress".equals(status)) {
inProgressCount++;
}
validated.add(new TodoItem(id, text.trim(), status));
}
if (inProgressCount > 1) {
return "Error: Only one task can be in_progress at a time";
}
this.items = validated;
return render();
}
/**
* 渲染当前 todo 列表为可读文本。
* TIP: 对应 Python {@code TodoManager.render()},输出格式完全一致。
*/
public String render() {
if (items.isEmpty()) {
return "No todos.";
}
StringBuilder sb = new StringBuilder();
long done = 0;
for (TodoItem item : items) {
String marker = switch (item.status()) {
case "in_progress" -> "[>]";
case "completed" -> "[x]";
default -> "[ ]";
};
sb.append(marker).append(" #").append(item.id()).append(": ").append(item.text()).append("\n");
if ("completed".equals(item.status())) done++;
}
sb.append("\n(").append(done).append("/").append(items.size()).append(" completed)");
return sb.toString();
}
}
@@ -0,0 +1,74 @@
package io.mybatis.learn.s04;
import io.mybatis.learn.core.AgentRunner;
import io.mybatis.learn.core.tools.BashTool;
import io.mybatis.learn.core.tools.EditFileTool;
import io.mybatis.learn.core.tools.ReadFileTool;
import io.mybatis.learn.core.tools.WriteFileTool;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* S04 - 子 Agent:上下文隔离,保护主 Agent 的思维清晰。
* <p>
* 格言: "大任务拆小, 每个小任务干净的上下文"
* <p>
* 核心模式:
* <pre>
* Parent agent Subagent
* +------------------+ +------------------+
* | messages=[...] | | messages=[] | ← fresh
* | | dispatch | |
* | tool: task | --------> | ChatClient.call |
* | prompt="..." | | execute tools |
* | | summary | |
* | result = "..." | <-------- | return last text |
* +------------------+ +------------------+
* |
* Parent context stays clean.
* Subagent context is discarded.
* </pre>
* <p>
* TIP: 对应 Python {@code agents/s04_subagent.py}。
* Python 版手动创建空的 messages 列表实现隔离。
* Spring AI 通过创建独立的 {@link ChatClient} 实例实现相同效果 —— 更加自然。
*/
@SpringBootApplication(scanBasePackages = "io.mybatis.learn.core")
public class S04Subagent implements CommandLineRunner {
private final ChatClient chatClient;
public S04Subagent(ChatModel chatModel) {
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent at " + System.getProperty("user.dir")
+ ". Use the task tool to delegate exploration or subtasks.")
.defaultTools(
new BashTool(),
new ReadFileTool(),
new WriteFileTool(),
new EditFileTool(),
new SubagentTool(chatModel) // 父 Agent 独有的 task 工具
)
.build();
}
@Override
public void run(String... args) {
AgentRunner.interactive("s04", userMessage ->
chatClient.prompt()
.user(userMessage)
.call()
.content()
);
}
public static void main(String[] args) {
SpringApplication app = new SpringApplication(S04Subagent.class);
app.setWebApplicationType(WebApplicationType.NONE);
app.run(args);
}
}
@@ -0,0 +1,75 @@
package io.mybatis.learn.s04;
import io.mybatis.learn.core.tools.BashTool;
import io.mybatis.learn.core.tools.EditFileTool;
import io.mybatis.learn.core.tools.ReadFileTool;
import io.mybatis.learn.core.tools.WriteFileTool;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
/**
* 子 Agent 工具 —— 生成具有独立上下文的子 Agent 执行任务。
* <p>
* TIP: 对应 Python {@code agents/s04_subagent.py} 中的 {@code run_subagent(prompt)} 函数。
* Python 版创建 {@code sub_messages = []} 实现上下文隔离,
* Spring AI 通过创建全新的 {@link ChatClient} 实例实现相同效果。
* 子 Agent 获得基础工具但没有 task 工具(防止递归生成)。
* 只有最终文本返回给父 Agent,子 Agent 的对话历史被完全丢弃。
*/
public class SubagentTool {
private static final Logger log = LoggerFactory.getLogger(SubagentTool.class);
private final ChatModel chatModel;
private final String workDir;
public SubagentTool(ChatModel chatModel) {
this.chatModel = chatModel;
this.workDir = System.getProperty("user.dir");
}
/**
* TIP: 对应 Python {@code run_subagent(prompt)}。
* Python 版在独立线程中运行子 Agent 的 while 循环(最多30次迭代)。
* Spring AI 的 ChatClient.call() 内部管理循环,无需手动限制迭代次数。
*/
@Tool(description = "Spawn a subagent with fresh context. "
+ "It shares the filesystem but not conversation history. "
+ "Use for exploration or subtasks that might pollute the main context.")
public String task(
@ToolParam(description = "The task prompt for the subagent") String prompt,
@ToolParam(description = "Short description of the task", required = false) String description) {
String desc = (description != null && !description.isBlank()) ? description : "subtask";
if (log.isDebugEnabled()) {
System.out.printf("🤖 启动子代理「%s」: %s%n", desc, prompt.substring(0, Math.min(80, prompt.length())));
}
// 创建全新的 ChatClient —— 这就是"上下文隔离"的全部
// TIP: 对应 Python 的 sub_messages = [] —— 空的消息列表就是隔离
ChatClient subClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding subagent at " + workDir
+ ". Complete the given task, then summarize your findings.")
.defaultTools(
new BashTool(),
new ReadFileTool(),
new WriteFileTool(),
new EditFileTool()
)
.build();
String result = subClient.prompt()
.user(prompt)
.call()
.content();
if (log.isDebugEnabled()) {
System.out.printf("✅ 子代理「%s」完成,返回 %d 字符%n", desc, result == null ? 0 : result.length());
}
// 只返回最终文本,子 Agent 上下文被丢弃
return (result != null && !result.isBlank()) ? result : "(no summary)";
}
}
@@ -0,0 +1,87 @@
package io.mybatis.learn.s05;
import io.mybatis.learn.core.AgentRunner;
import io.mybatis.learn.core.tools.BashTool;
import io.mybatis.learn.core.tools.EditFileTool;
import io.mybatis.learn.core.tools.ReadFileTool;
import io.mybatis.learn.core.tools.WriteFileTool;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import java.nio.file.Path;
/**
* S05 - 技能加载:用到什么知识,临时加载什么知识。
* <p>
* 格言: "用到什么知识, 临时加载什么知识"
* <p>
* 两层注入:
* <pre>
* System prompt:
* +--------------------------------------+
* | You are a coding agent. |
* | Skills available: |
* | - pdf: Process PDF files... | ← Layer 1: 仅元数据 (~100 tokens)
* | - code-review: Review code... |
* +--------------------------------------+
*
* When model calls loadSkill("pdf"):
* +--------------------------------------+
* | tool_result: |
* | &lt;skill&gt; |
* | Full PDF processing instructions | ← Layer 2: 完整内容
* | Step 1: ... |
* | &lt;/skill&gt; |
* +--------------------------------------+
* </pre>
* <p>
* TIP: 对应 Python {@code agents/s05_skill_loading.py}。
* 核心洞察: "不要把所有东西塞进系统提示。按需加载。"
*/
@SpringBootApplication(scanBasePackages = "io.mybatis.learn.core")
public class S05SkillLoading implements CommandLineRunner {
private final ChatClient chatClient;
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 before tackling unfamiliar topics.\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();
}
@Override
public void run(String... args) {
AgentRunner.interactive("s05", userMessage ->
chatClient.prompt()
.user(userMessage)
.call()
.content()
);
}
public static void main(String[] args) {
SpringApplication app = new SpringApplication(S05SkillLoading.class);
app.setWebApplicationType(WebApplicationType.NONE);
app.run(args);
}
}
@@ -0,0 +1,118 @@
package io.mybatis.learn.s05;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
/**
* 技能加载器 —— 按需注入领域知识,避免系统提示膨胀。
* <p>
* 两层技能注入系统:
* <pre>
* Layer 1 (cheap): 技能名称和描述注入系统提示 (~100 tokens/skill)
* Layer 2 (on demand): 完整技能内容通过 tool_result 返回
* </pre>
* <p>
* TIP: 对应 Python {@code agents/s05_skill_loading.py} 中的 {@code SkillLoader} 类。
* 扫描 {@code skills/<name>/SKILL.md},解析 YAML front matter 提取元数据。
*/
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);
}
/**
* TIP: 对应 Python {@code SkillLoader._load_all()}。
* 递归扫描 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 -> {
try {
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()));
} catch (IOException e) {
System.err.println("Warning: Failed to load skill from " + p + ": " + e.getMessage());
}
});
} catch (IOException e) {
System.err.println("Warning: Failed to scan skills directory: " + e.getMessage());
}
}
record ParsedSkill(Map<String, String> meta, String body) {
}
/**
* 解析 YAML front matter。
* TIP: 对应 Python {@code SkillLoader._parse_frontmatter(text)}。
*/
private ParsedSkill parseFrontmatter(String text) {
Matcher m = FRONTMATTER_PATTERN.matcher(text);
if (!m.matches()) {
return new ParsedSkill(Map.of(), text);
}
Map<String, String> meta = new LinkedHashMap<>();
for (String line : m.group(1).strip().split("\n")) {
int colonIdx = line.indexOf(':');
if (colonIdx > 0) {
meta.put(line.substring(0, colonIdx).strip(), line.substring(colonIdx + 1).strip());
}
}
return new ParsedSkill(meta, m.group(2).strip());
}
/**
* Layer 1: 获取所有技能的简短描述(用于系统提示注入)。
* TIP: 对应 Python {@code SkillLoader.get_descriptions()}。
*/
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");
String tags = entry.getValue().meta().getOrDefault("tags", "");
sb.append(" - ").append(entry.getKey()).append(": ").append(desc);
if (!tags.isEmpty()) sb.append(" [").append(tags).append("]");
sb.append("\n");
}
return sb.toString().stripTrailing();
}
/**
* Layer 2: 加载指定技能的完整内容。
* TIP: 对应 Python {@code SkillLoader.get_content(name)}
* 同时也是 {@code TOOL_HANDLERS["load_skill"]} 的处理函数。
*/
@Tool(description = "Load specialized knowledge by name. "
+ "Call this before tackling unfamiliar topics to get domain-specific instructions.")
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>";
}
}
@@ -0,0 +1,27 @@
package io.mybatis.learn.s06;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
/**
* 手动压缩工具(Layer 3)。
* <p>
* TIP: 对应 Python {@code TOOL_HANDLERS["compact"]}。
* 当 Agent 调用此工具时,触发上下文压缩。
*/
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. "
+ "Use when the conversation is getting long or you want to start fresh with a summary.")
public String compact(
@ToolParam(description = "What to focus on preserving in the summary", required = false) String focus) {
compactor.requestCompact();
return "Compression triggered. Context will be summarized.";
}
}
@@ -0,0 +1,154 @@
package io.mybatis.learn.s06;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/**
* 上下文压缩器 —— 三层压缩管线,让 Agent 可以无限工作。
* <p>
* TIP: 对应 Python {@code agents/s06_context_compact.py} 中的压缩函数。
* <pre>
* Layer 1 (Micro): 替换旧工具结果为占位符 "[Previous: used {tool}]"
* Layer 2 (Auto): tokens 超阈值时自动保存 transcript 并压缩
* Layer 3 (Manual): Agent 主动调用 compact 工具触发压缩
* </pre>
* <p>
* 在 Spring AI 中,ChatClient 内部管理工具调用循环,无法直接在循环内插入压缩逻辑。
* 因此压缩在用户消息级别触发(每次用户输入前检查),效果等价。
* 如需更精细的循环内压缩,可实现自定义 {@code Advisor}。
*/
public class ContextCompactor {
private static final int TOKEN_THRESHOLD = 50000;
private static final int KEEP_RECENT = 3;
private final Path transcriptDir;
private final ChatModel chatModel;
private final ObjectMapper objectMapper = new ObjectMapper();
/**
* 对话历史(用户级别),在 ChatClient 调用之外维护。
*/
private final List<ConversationTurn> history = new ArrayList<>();
private boolean compactRequested = false;
public record ConversationTurn(String role, String content) {
}
public ContextCompactor(ChatModel chatModel, Path workDir) {
this.chatModel = chatModel;
this.transcriptDir = workDir.resolve(".transcripts");
}
/**
* 估算 token 数量。
* TIP: 对应 Python {@code estimate_tokens(messages)},粗略估计 4 字符 ≈ 1 token。
*/
public int estimateTokens() {
int chars = history.stream().mapToInt(t -> t.content().length()).sum();
return chars / 4;
}
/**
* 添加一轮对话记录。
*/
public void addTurn(String role, String content) {
history.add(new ConversationTurn(role, content));
}
/**
* 获取对话历史的摘要(用于注入系统提示)。
*/
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();
}
/**
* 检查是否需要自动压缩(Layer 2)。
* TIP: 对应 Python 中 {@code if estimate_tokens(messages) > THRESHOLD} 的检查。
*/
public boolean needsAutoCompact() {
return estimateTokens() > TOKEN_THRESHOLD;
}
/**
* 标记手动压缩请求(Layer 3)。
*/
public void requestCompact() {
this.compactRequested = true;
}
public boolean isCompactRequested() {
boolean val = compactRequested;
compactRequested = false;
return val;
}
/**
* 执行压缩:保存 transcript 并用 LLM 生成摘要替换历史。
* TIP: 对应 Python {@code auto_compact(messages)}。
* 保存完整 transcript 到 .transcripts/ 目录,然后让 LLM 生成摘要。
*/
public String compact() {
// 保存 transcript
try {
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();
}
}
System.out.println("[transcript saved: " + transcriptPath + "]");
// 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 made. "
+ "Be concise but preserve critical details.\n\n" + conversationText)
.call()
.content();
// 用摘要替换历史
history.clear();
history.add(new ConversationTurn("system",
"[Conversation compressed. Transcript: " + transcriptPath + "]\n\n" + summary));
return summary;
} catch (IOException e) {
return "Error during compaction: " + e.getMessage();
}
}
public List<ConversationTurn> getHistory() {
return history;
}
}
@@ -0,0 +1,108 @@
package io.mybatis.learn.s06;
import io.mybatis.learn.core.AgentRunner;
import io.mybatis.learn.core.tools.BashTool;
import io.mybatis.learn.core.tools.EditFileTool;
import io.mybatis.learn.core.tools.ReadFileTool;
import io.mybatis.learn.core.tools.WriteFileTool;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import java.nio.file.Path;
/**
* S06 - 上下文压缩:三层压缩管线,让 Agent 永远不会"忘记"。
* <p>
* 格言: "上下文总会满, 要有办法腾地方"
* <p>
* 三层压缩:
* <pre>
* [Layer 1: micro_compact] 每轮调用前,替换旧工具结果为占位符
* |
* [Check: tokens > 50000?]
* | |
* no yes
* | |
* continue [Layer 2: auto_compact]
* 保存 transcript → LLM 生成摘要 → 替换 messages
* |
* [Layer 3: compact tool]
* Agent 主动调用 → 立即压缩
* </pre>
* <p>
* TIP: 对应 Python {@code agents/s06_context_compact.py}。
* Python 版在工具循环内(每次 LLM 调用前)执行 micro_compact 和 auto_compact。
* Spring AI 的 ChatClient 自动管理工具循环,因此压缩在用户消息级别触发。
* 核心教学概念不变: Agent 可以策略性地"遗忘",然后继续无限工作。
* 如需循环内精细压缩,可实现自定义 {@code CallAroundAdvisor}。
*/
@SpringBootApplication(scanBasePackages = "io.mybatis.learn.core")
public class S06ContextCompact implements CommandLineRunner {
private final ChatModel chatModel;
public S06ContextCompact(ChatModel chatModel) {
this.chatModel = chatModel;
}
@Override
public void run(String... args) {
Path workDir = Path.of(System.getProperty("user.dir"));
ContextCompactor compactor = new ContextCompactor(chatModel, workDir);
CompactTool compactTool = new CompactTool(compactor);
String baseSystem = "You are a coding agent at " + workDir + ". Use tools to solve tasks.";
AgentRunner.interactive("s06", userMessage -> {
// Layer 2: 自动压缩检查(每次用户输入前)
if (compactor.needsAutoCompact()) {
System.out.println("[auto_compact triggered - " + compactor.estimateTokens() + " tokens]");
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();
// 记录 Agent 回复
compactor.addTurn("assistant", response != null ? response : "");
// Layer 3: 手动压缩(如果 Agent 调用了 compact 工具)
if (compactor.isCompactRequested()) {
System.out.println("[manual compact triggered]");
String summary = compactor.compact();
System.out.println("[compressed to summary: " + summary.length() + " chars]");
}
return response;
});
}
public static void main(String[] args) {
SpringApplication app = new SpringApplication(S06ContextCompact.class);
app.setWebApplicationType(WebApplicationType.NONE);
app.run(args);
}
}
@@ -0,0 +1,63 @@
package io.mybatis.learn.s07;
import io.mybatis.learn.core.AgentRunner;
import io.mybatis.learn.core.tools.BashTool;
import io.mybatis.learn.core.tools.EditFileTool;
import io.mybatis.learn.core.tools.ReadFileTool;
import io.mybatis.learn.core.tools.WriteFileTool;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import java.nio.file.Path;
/**
* S07 - 任务系统:大目标拆成小任务,排好序,记在磁盘上。
* <p>
* 格言: "大目标要拆成小任务, 排好序, 记在磁盘上"
* <p>
* TIP: 对应 Python {@code agents/s07_task_system.py}。
* 核心洞察: 任务状态持久化在磁盘({@code .tasks/task_*.json}),
* 不受上下文压缩影响 —— 因为它在对话之外。
*/
@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 at " + System.getProperty("user.dir")
+ ". Use task tools to plan and track work.")
.defaultTools(
new BashTool(),
new ReadFileTool(),
new WriteFileTool(),
new EditFileTool(),
taskManager
)
.build();
}
@Override
public void run(String... args) {
AgentRunner.interactive("s07", userMessage ->
chatClient.prompt()
.user(userMessage)
.call()
.content()
);
}
public static void main(String[] args) {
SpringApplication app = new SpringApplication(S07TaskSystem.class);
app.setWebApplicationType(WebApplicationType.NONE);
app.run(args);
}
}
@@ -0,0 +1,266 @@
package io.mybatis.learn.s07;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.stream.Stream;
/**
* 持久化任务管理器 —— 任务状态存储在磁盘上,不受上下文压缩影响。
* <p>
* TIP: 对应 Python {@code agents/s07_task_system.py} 中的 {@code TaskManager} 类。
* Python 使用 {@code json.loads/json.dumps}Java 使用 Jackson {@code ObjectMapper}。
* <pre>
* .tasks/
* task_1.json {"id":1, "subject":"...", "status":"completed", ...}
* task_2.json {"id":2, "blockedBy":[1], "status":"pending", ...}
*
* 依赖解析:
* task 1 (complete) --> task 2 (blocked) --> task 3 (blocked)
* | ^
* +--- completing task 1 removes it from task 2's blockedBy
* </pre>
*/
public class TaskManager {
private static final Logger log = LoggerFactory.getLogger(TaskManager.class);
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final List<String> VALID_STATUSES = List.of("pending", "in_progress", "completed");
private final Path dir;
private int nextId;
public TaskManager(Path tasksDir) {
this.dir = tasksDir;
try {
Files.createDirectories(dir);
if (log.isDebugEnabled()) {
System.out.printf("🚀 任务目录就绪: %s%n", dir);
}
} catch (IOException e) {
log.error("创建任务目录失败: {}, error={}", dir, e.getMessage());
throw new RuntimeException("Cannot create tasks directory: " + e.getMessage(), e);
}
this.nextId = maxId() + 1;
log.info("TaskManager 初始化完成,nextId={}, dir={}", nextId, dir);
}
private int maxId() {
try (Stream<Path> files = Files.list(dir)) {
return files.filter(f -> f.getFileName().toString().matches("task_\\d+\\.json"))
.mapToInt(f -> {
String name = f.getFileName().toString();
return Integer.parseInt(name.substring(5, name.length() - 5));
})
.max().orElse(0);
} catch (IOException e) {
return 0;
}
}
private Map<String, Object> load(int taskId) throws IOException {
Path path = dir.resolve("task_" + taskId + ".json");
if (!Files.exists(path)) {
throw new IllegalArgumentException("Task " + taskId + " not found");
}
return MAPPER.readValue(Files.readString(path), new TypeReference<>() {
});
}
private void save(Map<String, Object> task) throws IOException {
Path path = dir.resolve("task_" + task.get("id") + ".json");
Files.writeString(path, MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(task));
}
@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) {
if (log.isDebugEnabled()) {
System.out.printf("📋 创建任务: %s%n", subject);
}
try {
Map<String, Object> task = new LinkedHashMap<>();
task.put("id", nextId);
task.put("subject", subject);
task.put("description", description != null ? description : "");
task.put("status", "pending");
task.put("blockedBy", new ArrayList<>());
task.put("blocks", new ArrayList<>());
task.put("owner", "");
save(task);
nextId++;
if (log.isDebugEnabled()) {
System.out.printf("✅ 任务 #%s 已创建 (nextId=%d)%n", task.get("id"), nextId);
}
return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(task);
} catch (Exception e) {
log.warn("任务创建失败: subject={}, error={}", subject, e.getMessage());
return "Error: " + e.getMessage();
}
}
@Tool(description = "Get full details of a task by ID")
public String taskGet(@ToolParam(description = "Task ID") int taskId) {
if (log.isDebugEnabled()) {
System.out.printf("🔍 查询任务 #%d%n", taskId);
}
try {
return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(load(taskId));
} catch (Exception e) {
log.warn("查询任务详情失败: taskId={}, error={}", taskId, e.getMessage());
return "Error: " + e.getMessage();
}
}
/**
* TIP: 对应 Python {@code TaskManager.update()}。
* 当 status 变为 "completed" 时自动清除依赖(调用 clearDependency)。
* blockedBy/blocks 是双向关系:添加 blocks 时也更新被阻塞任务的 blockedBy。
*/
@Tool(description = "Update a task's status or dependencies. "
+ "Status: pending/in_progress/completed. "
+ "Use addBlockedBy/addBlocks to manage dependency graph.")
@SuppressWarnings("unchecked")
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) {
if (log.isDebugEnabled()) {
System.out.printf("🔄 更新任务 #%d (status=%s, +blockedBy=%d, +blocks=%d)%n",
taskId, status, addBlockedBy == null ? 0 : addBlockedBy.size(), addBlocks == null ? 0 : addBlocks.size());
}
try {
Map<String, Object> task = load(taskId);
if (status != null) {
if (!VALID_STATUSES.contains(status)) {
log.warn("非法状态更新: taskId={}, status={}", taskId, status);
return "Error: Invalid status: " + status;
}
task.put("status", status);
if ("completed".equals(status)) {
clearDependency(taskId);
}
}
if (addBlockedBy != null && !addBlockedBy.isEmpty()) {
List<Integer> current = (List<Integer>) task.get("blockedBy");
Set<Integer> merged = new LinkedHashSet<>(current);
merged.addAll(addBlockedBy);
task.put("blockedBy", new ArrayList<>(merged));
}
if (addBlocks != null && !addBlocks.isEmpty()) {
List<Integer> current = (List<Integer>) task.get("blocks");
Set<Integer> merged = new LinkedHashSet<>(current);
merged.addAll(addBlocks);
task.put("blocks", new ArrayList<>(merged));
// 双向关系:更新被阻塞任务的 blockedBy
for (int blockedId : addBlocks) {
try {
Map<String, Object> blocked = load(blockedId);
List<Integer> blockedBy = (List<Integer>) blocked.get("blockedBy");
if (!blockedBy.contains(taskId)) {
blockedBy.add(taskId);
save(blocked);
}
} catch (Exception ignored) {
}
}
}
save(task);
if (log.isDebugEnabled()) {
System.out.printf("✅ 任务 #%d 已更新%n", taskId);
}
return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(task);
} catch (Exception e) {
log.warn("任务更新失败: taskId={}, error={}", taskId, e.getMessage());
return "Error: " + e.getMessage();
}
}
/**
* TIP: 对应 Python {@code TaskManager._clear_dependency(completed_id)}。
* 完成任务后,从所有其他任务的 blockedBy 列表中移除该任务 ID。
*/
@SuppressWarnings("unchecked")
private void clearDependency(int completedId) {
if (log.isDebugEnabled()) {
System.out.printf("💭 清理依赖关系: 已完成任务 #%d%n", completedId);
}
try (Stream<Path> files = Files.list(dir)) {
files.filter(f -> f.getFileName().toString().matches("task_\\d+\\.json"))
.forEach(f -> {
try {
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);
if (log.isDebugEnabled()) {
System.out.printf("🔗 已解除依赖: #%d ← %s%n", completedId, f.getFileName());
}
}
} catch (Exception ignored) {
}
});
} catch (IOException ignored) {
}
}
@Tool(description = "List all tasks with status summary")
public String taskList() {
if (log.isDebugEnabled()) {
System.out.printf("📋 列出所有任务%n");
}
try (Stream<Path> files = Files.list(dir)) {
List<Map<String, Object>> tasks = files
.filter(f -> f.getFileName().toString().matches("task_\\d+\\.json"))
.sorted()
.map(f -> {
try {
return MAPPER.readValue(Files.readString(f),
new TypeReference<Map<String, Object>>() {
});
} catch (IOException e) {
return null;
}
})
.filter(Objects::nonNull)
.toList();
if (tasks.isEmpty()) return "No tasks.";
StringBuilder sb = new StringBuilder();
for (Map<String, Object> t : tasks) {
String marker = switch (String.valueOf(t.get("status"))) {
case "in_progress" -> "[>]";
case "completed" -> "[x]";
default -> "[ ]";
};
sb.append(marker).append(" #").append(t.get("id")).append(": ").append(t.get("subject"));
List<?> blockedBy = (List<?>) t.get("blockedBy");
if (blockedBy != null && !blockedBy.isEmpty()) {
sb.append(" (blocked by: ").append(blockedBy).append(")");
}
sb.append("\n");
}
return sb.toString().stripTrailing();
} catch (IOException e) {
log.warn("列出任务失败: error={}", e.getMessage());
return "Error: " + e.getMessage();
}
}
}
@@ -0,0 +1,160 @@
package io.mybatis.learn.s08;
import io.mybatis.learn.core.AgentRunner;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.Collectors;
/**
* 后台任务管理器 —— 慢操作丢后台,Agent 继续想下一步。
* <p>
* TIP: 对应 Python {@code agents/s08_background_tasks.py} 中的 {@code BackgroundManager} 类。
* Python 使用 {@code threading.Thread(daemon=True)}
* Java 使用 {@link ExecutorService} + 虚拟线程(Java 21)。
* <pre>
* Main thread Background thread
* +-----------------+ +-----------------+
* | agent loop | | task executes |
* | ... | | ... |
* | [LLM call] <--+------- | notify(result) |
* | ^drain queue | +-----------------+
* +-----------------+
* </pre>
*/
public class BackgroundManager {
private static final Logger log = LoggerFactory.getLogger(BackgroundManager.class);
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();
private final String workDir;
record TaskInfo(String status, String result, String command) {
}
public record Notification(String taskId, String status, String command, String result) {
}
public BackgroundManager() {
this.workDir = System.getProperty("user.dir");
log.info("BackgroundManager 初始化,workDir={}", workDir);
}
@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));
log.info("后台任务已提交: taskId={}, command={}", taskId, command.substring(0, Math.min(80, command.length())));
executor.submit(() -> execute(taskId, command));
return "Background task " + taskId + " started: "
+ command.substring(0, Math.min(80, command.length()));
}
private void execute(String taskId, String command) {
if (log.isDebugEnabled()) {
System.out.printf("⏳ 后台任务 %s 开始执行%n", taskId);
}
String status;
String output;
try {
ProcessBuilder pb = new ProcessBuilder();
if (System.getProperty("os.name").toLowerCase().contains("win")) {
pb.command("cmd", "/c", command);
} else {
pb.command("sh", "-c", command);
}
pb.directory(new java.io.File(workDir));
pb.redirectErrorStream(true);
Process process = pb.start();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
output = reader.lines().collect(Collectors.joining("\n"));
}
boolean finished = process.waitFor(TIMEOUT_SECONDS, java.util.concurrent.TimeUnit.SECONDS);
if (!finished) {
process.destroyForcibly();
output = "Error: Timeout (" + TIMEOUT_SECONDS + "s)";
status = "timeout";
log.warn("后台任务超时: taskId={}, timeout={}s", taskId, TIMEOUT_SECONDS);
} else {
output = output.trim();
status = "completed";
if (log.isDebugEnabled()) {
System.out.printf("✅ 后台任务 %s 执行完成%n", taskId);
}
}
} catch (Exception e) {
output = "Error: " + e.getMessage();
status = "error";
log.warn("后台任务执行异常: taskId={}, error={}", taskId, e.getMessage());
}
String finalOutput = AgentRunner.truncate(output.isEmpty() ? "(no output)" : output, 50000);
tasks.put(taskId, new TaskInfo(status, finalOutput, command));
log.info("后台任务状态更新: taskId={}, status={}", taskId, status);
notificationQueue.add(new Notification(
taskId, status,
command.substring(0, Math.min(80, command.length())),
finalOutput.substring(0, Math.min(500, finalOutput.length()))
));
}
@Tool(description = "Check background task status. Omit taskId to list all tasks.")
public String checkBackground(
@ToolParam(description = "Task ID to check (omit for all)", required = false) String taskId) {
if (log.isDebugEnabled()) {
System.out.printf("🔍 查询后台任务: %s%n", taskId);
}
if (taskId != null && !taskId.isBlank()) {
TaskInfo t = tasks.get(taskId);
if (t == null) return "Error: Unknown task " + taskId;
return "[" + t.status() + "] "
+ t.command().substring(0, Math.min(60, t.command().length())) + "\n"
+ (t.result() != null ? t.result() : "(running)");
}
if (tasks.isEmpty()) return "No background tasks.";
StringBuilder sb = new StringBuilder();
tasks.forEach((tid, t) ->
sb.append(tid).append(": [").append(t.status()).append("] ")
.append(t.command(), 0, Math.min(60, t.command().length()))
.append("\n"));
return sb.toString().stripTrailing();
}
/**
* 排空通知队列,返回所有已完成的后台任务结果。
* TIP: 对应 Python {@code BackgroundManager.drain_notifications()}。
*/
public List<Notification> drainNotifications() {
List<Notification> drained = new ArrayList<>(notificationQueue);
notificationQueue.clear();
if (!drained.isEmpty()) {
if (log.isDebugEnabled()) {
System.out.printf("📬 排空通知队列 (%d 条)%n", drained.size());
}
}
return drained;
}
}
@@ -0,0 +1,79 @@
package io.mybatis.learn.s08;
import io.mybatis.learn.core.AgentRunner;
import io.mybatis.learn.core.tools.BashTool;
import io.mybatis.learn.core.tools.EditFileTool;
import io.mybatis.learn.core.tools.ReadFileTool;
import io.mybatis.learn.core.tools.WriteFileTool;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import java.util.stream.Collectors;
/**
* S08 - 后台任务:慢操作丢后台,Agent 继续想下一步。
* <p>
* 格言: "慢操作丢后台, agent 继续想下一步"
* <p>
* TIP: 对应 Python {@code agents/s08_background_tasks.py}。
* Python 在每次 LLM 调用前 drain 通知队列并注入 {@code <background-results>}。
* Spring AI 的 ChatClient 管理内部循环,因此改为在每次用户输入时
* drain 通知并注入系统提示。核心概念不变: fire and forget。
*/
@SpringBootApplication(scanBasePackages = "io.mybatis.learn.core")
public class S08BackgroundTasks implements CommandLineRunner {
private final ChatModel chatModel;
public S08BackgroundTasks(ChatModel chatModel) {
this.chatModel = chatModel;
}
@Override
public void run(String... args) {
BackgroundManager bgManager = new BackgroundManager();
String workDir = System.getProperty("user.dir");
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>";
System.out.println("[Background tasks completed: " + notifs.size() + "]");
}
String system = "You are a coding agent at " + workDir
+ ". 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();
});
}
public static void main(String[] args) {
SpringApplication app = new SpringApplication(S08BackgroundTasks.class);
app.setWebApplicationType(WebApplicationType.NONE);
app.run(args);
}
}
@@ -0,0 +1,137 @@
package io.mybatis.learn.s09;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.mybatis.learn.core.AgentRunner;
import io.mybatis.learn.core.team.MessageBus;
import io.mybatis.learn.core.tools.*;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import java.nio.file.Path;
import java.util.List;
/**
* S09 - Agent Teams:持久化命名agent + JSONL收件箱通信
*
* TIPS: 对应Python s09_agent_teams.py。
* Python用 TOOL_HANDLERS 字典 + 手动while循环分发9个工具;
* Java用 ChatClient + @Tool 自动处理,Lead工具封装在 LeadTools 内部类。
*
* 核心概念:
* Subagent (s04): spawn → execute → return summary → destroyed
* Teammate (s09): spawn → work → idle → work → ... → shutdown
*
* 关键差异:Python队友在每次LLM调用前检查收件箱(s09第174行),
* Java队友在每次完整工具链(call())之间检查。这是Spring AI的自然适配。
*/
@SpringBootApplication(scanBasePackages = "io.mybatis.learn.core")
public class S09AgentTeams implements CommandLineRunner {
@Autowired
private ChatModel chatModel;
@Override
public void run(String... args) throws Exception {
Path workDir = Path.of(System.getProperty("user.dir"));
Path teamDir = workDir.resolve(".team");
MessageBus bus = new MessageBus(teamDir.resolve("inbox"));
TeammateManager team = new TeammateManager(chatModel, bus, teamDir);
LeadTools leadTools = new LeadTools(bus, team);
ObjectMapper mapper = new ObjectMapper();
String systemPrompt = "You are a team lead at " + workDir
+ ". Spawn teammates and communicate via inboxes.";
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem(systemPrompt)
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(), leadTools)
.build();
AgentRunner.interactive("s09", input -> {
if ("/team".equals(input)) return team.listAll();
if ("/inbox".equals(input)) {
try {
return mapper.writeValueAsString(bus.readInbox("lead"));
} catch (Exception e) {
return "Error: " + e.getMessage();
}
}
// 每次LLM调用前检查lead收件箱
var inbox = bus.readInbox("lead");
String fullInput = input;
if (!inbox.isEmpty()) {
try {
fullInput = "<inbox>" + mapper.writeValueAsString(inbox)
+ "</inbox>\n\n" + input;
} catch (Exception e) { /* ignore */ }
}
return chatClient.prompt(fullInput).call().content();
});
}
/**
* Lead专用工具集(5个团队管理工具)
*
* TIPS: 对应Python TOOL_HANDLERS中的5个lead工具(s09第310-319行)。
* Python用lambda + 字典映射;Java用@Tool注解的方法。
*/
public static class LeadTools {
private final MessageBus bus;
private final TeammateManager team;
private final ObjectMapper mapper = new ObjectMapper();
public LeadTools(MessageBus bus, TeammateManager team) {
this.bus = bus;
this.team = team;
}
@Tool(description = "Spawn a persistent teammate that runs in its own thread")
public String spawnTeammate(
@ToolParam(description = "Teammate name") String name,
@ToolParam(description = "Role description") String role,
@ToolParam(description = "Initial task prompt") String prompt) {
return team.spawn(name, role, prompt);
}
@Tool(description = "List all teammates with name, role, status")
public String listTeammates() {
return team.listAll();
}
@Tool(description = "Send a message to a teammate's inbox")
public String sendMessage(
@ToolParam(description = "Recipient teammate name") String to,
@ToolParam(description = "Message content") String content) {
return bus.send("lead", to, content);
}
@Tool(description = "Read and drain the lead's inbox")
public String readInbox() {
try {
return mapper.writeValueAsString(bus.readInbox("lead"));
} catch (Exception e) {
return "[]";
}
}
@Tool(description = "Send a message to all teammates")
public String broadcast(
@ToolParam(description = "Message content") String content) {
return bus.broadcast("lead", content, team.memberNames());
}
}
public static void main(String[] args) {
SpringApplication.run(S09AgentTeams.class, args);
}
}
@@ -0,0 +1,240 @@
package io.mybatis.learn.s09;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.mybatis.learn.core.AgentRunner;
import io.mybatis.learn.core.team.MessageBus;
import io.mybatis.learn.core.tools.BashTool;
import io.mybatis.learn.core.tools.EditFileTool;
import io.mybatis.learn.core.tools.ReadFileTool;
import io.mybatis.learn.core.tools.WriteFileTool;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 团队管理器 - 持久化命名agent,通过JSONL收件箱通信
*
* TIPS: 对应Python TeammateManager类(s09第124-248行)。
* Python用 threading.Thread 创建队友线程,Java用虚拟线程(Thread.startVirtualThread)。
* Python队友手动执行 while/tool_use 循环 + _exec分发;
* Java队友用 ChatClient + @Tool 自动处理工具循环(一次 call() = 完整工具链)。
* 每次 call() 等价于Python的「循环直到 stop_reason != tool_use」。
*/
public class TeammateManager {
private static final Logger log = LoggerFactory.getLogger(TeammateManager.class);
private final ChatModel chatModel;
private final MessageBus bus;
private final Path configPath;
private final ObjectMapper mapper = new ObjectMapper();
private Map<String, Object> config;
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");
try {
Files.createDirectories(teamDir);
} catch (IOException e) {
log.error("创建团队目录失败: {}, error={}", teamDir, e.getMessage());
throw new RuntimeException(e);
}
this.config = loadConfig();
log.info("TeammateManager 初始化完成,configPath={}", configPath);
}
// ---- 配置持久化 ----
@SuppressWarnings("unchecked")
private Map<String, Object> loadConfig() {
if (Files.exists(configPath)) {
try {
if (log.isDebugEnabled()) {
System.out.printf("💭 加载团队配置: %s%n", configPath);
}
return mapper.readValue(configPath.toFile(), Map.class);
} catch (IOException e) { /* ignore */ }
}
Map<String, Object> cfg = new LinkedHashMap<>();
cfg.put("team_name", "default");
cfg.put("members", new ArrayList<>());
return cfg;
}
private synchronized void saveConfig() {
try {
mapper.writerWithDefaultPrettyPrinter().writeValue(configPath.toFile(), config);
if (log.isDebugEnabled()) {
System.out.printf("✅ 团队配置已保存: %s%n", configPath);
}
} catch (IOException e) {
log.warn("保存团队配置失败: error={}", e.getMessage());
System.err.println("Error saving config: " + e.getMessage());
}
}
@SuppressWarnings("unchecked")
private Map<String, Object> findMember(String name) {
List<Map<String, Object>> members = (List<Map<String, Object>>) config.get("members");
for (Map<String, Object> m : members) {
if (name.equals(m.get("name"))) return m;
}
return null;
}
protected synchronized void setStatus(String name, String status) {
Map<String, Object> member = findMember(name);
if (member != null) {
member.put("status", status);
saveConfig();
}
}
// ---- Spawn ----
@SuppressWarnings("unchecked")
public synchronized String spawn(String name, String role, String prompt) {
log.info("请求启动队友: name={}, role={}", name, role);
Map<String, Object> member = findMember(name);
if (member != null) {
String status = (String) member.get("status");
if (!"idle".equals(status) && !"shutdown".equals(status)) {
return "Error: '" + name + "' is currently " + status;
}
member.put("status", "working");
member.put("role", role);
} else {
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();
Thread thread = Thread.startVirtualThread(() -> teammateLoop(name, role, prompt));
threads.put(name, thread);
log.info("队友已启动: name={}, role={}", name, role);
return "Spawned '" + name + "' (role: " + role + ")";
}
// ---- 队友循环 ----
/**
* TIPS: Python队友循环(s09第166-204行)在range(50)内逐次调用LLM。
* Java用ChatClient.prompt().call()一次完成整个工具链,
* 等价于Python循环到 stop_reason != "tool_use" 为止。
* 收件箱检查在每次call()之间进行(而非Python的每次LLM调用之间)。
*/
protected void teammateLoop(String name, String role, String initialPrompt) {
log.info("队友工作循环开始: name={}, role={}", name, role);
String workDir = System.getProperty("user.dir");
String sysPrompt = String.format(
"You are '%s', role: %s, at %s. "
+ "Use send_message to communicate. Complete your task.",
name, role, workDir);
var messageTool = new TeammateMessageTool(bus, name);
ChatClient client = ChatClient.builder(chatModel)
.defaultSystem(sysPrompt)
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(), messageTool)
.build();
try {
// 初始工作
String response = client.prompt(initialPrompt).call().content();
System.out.println(" [" + name + "] " + AgentRunner.truncate(response, 120));
if (log.isDebugEnabled()) {
System.out.printf("✅ 队友「%s」初始响应完成%n", name);
}
// 等待收件箱消息(每2秒检查一次,最多50轮)
for (int round = 0; round < 50; round++) {
Thread.sleep(2000);
var inbox = bus.readInbox(name);
if (inbox.isEmpty()) break;
if (log.isDebugEnabled()) {
System.out.printf("📨 队友「%s」收到 %d 条收件箱消息%n", name, inbox.size());
}
String inboxJson = mapper.writeValueAsString(inbox);
response = client.prompt("<inbox>" + inboxJson + "</inbox>").call().content();
System.out.println(" [" + name + "] " + AgentRunner.truncate(response, 120));
}
} catch (Exception e) {
log.warn("队友执行异常: name={}, error={}", name, e.getMessage());
System.err.println(" [" + name + "] Error: " + e.getMessage());
}
setStatus(name, "idle");
log.info("队友工作循环结束: name={}", name);
}
// ---- 查询 ----
@SuppressWarnings("unchecked")
public String listAll() {
List<Map<String, Object>> members = (List<Map<String, Object>>) config.get("members");
if (members.isEmpty()) return "No teammates.";
StringBuilder sb = new StringBuilder("Team: " + config.get("team_name"));
for (Map<String, Object> m : members) {
sb.append("\n ").append(m.get("name"))
.append(" (").append(m.get("role")).append("): ").append(m.get("status"));
}
return sb.toString();
}
@SuppressWarnings("unchecked")
public List<String> memberNames() {
List<Map<String, Object>> members = (List<Map<String, Object>>) config.get("members");
return members.stream().map(m -> (String) m.get("name")).toList();
}
// ---- 队友专用消息工具(参数化sender名称) ----
/**
* TIPS: Python队友通过 _exec() 分发工具调用(s09第206-220行),
* send_message和read_inbox使用队友自己的名字作为sender。
* Java用参数化工具类:构造时绑定sender,@Tool方法自动注入。
*/
public static class TeammateMessageTool {
private final MessageBus bus;
private final String name;
private final ObjectMapper mapper = new ObjectMapper();
public TeammateMessageTool(MessageBus bus, String name) {
this.bus = bus;
this.name = name;
}
@Tool(description = "Send message to a teammate")
public String sendMessage(
@ToolParam(description = "Recipient name") String to,
@ToolParam(description = "Message content") String content) {
return bus.send(name, to, content);
}
@Tool(description = "Read and drain your inbox")
public String readInbox() {
try {
return mapper.writeValueAsString(bus.readInbox(name));
} catch (Exception e) {
return "[]";
}
}
}
}
@@ -0,0 +1,107 @@
package io.mybatis.learn.s10;
import io.mybatis.learn.core.team.MessageBus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* 协议追踪器 - 关闭协议 + 计划审批协议(请求-响应FSM)
*
* TIPS: 对应Python全局变量 shutdown_requests、plan_requests 和 _tracker_locks10第82-84行)。
* Python用字典 + threading.LockJava用 ConcurrentHashMap 天然线程安全。
* 两种协议使用相同的 request_id 关联模式:pending → approved | rejected。
*/
public class ProtocolTracker {
private static final Logger log = LoggerFactory.getLogger(ProtocolTracker.class);
// {request_id: {"target": name, "status": "pending|approved|rejected"}}
private final ConcurrentHashMap<String, Map<String, String>> shutdownRequests = new ConcurrentHashMap<>();
// {request_id: {"from": name, "plan": text, "status": "pending|approved|rejected"}}
private final ConcurrentHashMap<String, Map<String, String>> planRequests = new ConcurrentHashMap<>();
private final MessageBus bus;
public ProtocolTracker(MessageBus bus) {
this.bus = bus;
}
// ---- 关闭协议(Lead端) ----
/**
* Lead发起关闭请求 → 生成request_id → 发送shutdown_request消息
*/
public String handleShutdownRequest(String teammate) {
String reqId = UUID.randomUUID().toString().substring(0, 8);
shutdownRequests.put(reqId, new ConcurrentHashMap<>(Map.of(
"target", teammate, "status", "pending")));
log.info("发起关闭请求: requestId={}, target={}", reqId, teammate);
bus.send("lead", teammate, "Please shut down gracefully.",
"shutdown_request", Map.of("request_id", reqId));
return "Shutdown request " + reqId + " sent to '" + teammate + "' (status: pending)";
}
/**
* Lead查询关闭状态
*/
public String checkShutdownStatus(String requestId) {
if (log.isDebugEnabled()) {
System.out.printf("🔍 查询关闭请求状态: %s%n", requestId);
}
var req = shutdownRequests.get(requestId);
return req != null ? req.toString() : "{\"error\": \"not found\"}";
}
// ---- 关闭协议(Teammate端) ----
/**
* Teammate响应关闭请求 → 更新追踪器 → 发送shutdown_response消息
*/
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");
}
log.info("响应关闭请求: sender={}, requestId={}, approve={}", sender, requestId, approve);
bus.send(sender, "lead", reason != null ? reason : "",
"shutdown_response", Map.of("request_id", requestId, "approve", approve));
return "Shutdown " + (approve ? "approved" : "rejected");
}
// ---- 计划审批协议(Teammate端) ----
/**
* Teammate提交计划 → 生成request_id → 发送plan_approval_response消息
*/
public String submitPlan(String sender, String planText) {
String reqId = UUID.randomUUID().toString().substring(0, 8);
planRequests.put(reqId, new ConcurrentHashMap<>(Map.of(
"from", sender, "plan", planText, "status", "pending")));
log.info("提交计划审批: requestId={}, sender={}", reqId, sender);
bus.send(sender, "lead", planText, "plan_approval_response",
Map.of("request_id", reqId, "plan", planText));
return "Plan submitted (request_id=" + reqId + "). Waiting for lead approval.";
}
// ---- 计划审批协议(Lead端) ----
/**
* Lead审批计划 → 更新追踪器 → 发送plan_approval_response消息
*/
public String reviewPlan(String requestId, boolean approve, String feedback) {
var req = planRequests.get(requestId);
if (req == null) {
log.warn("审批计划失败,请求不存在: requestId={}", requestId);
return "Error: Unknown plan request_id '" + requestId + "'";
}
req.put("status", approve ? "approved" : "rejected");
log.info("审批计划: requestId={}, from={}, approve={}", requestId, req.get("from"), approve);
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") + "'";
}
}
@@ -0,0 +1,315 @@
package io.mybatis.learn.s10;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.mybatis.learn.core.AgentRunner;
import io.mybatis.learn.core.team.MessageBus;
import io.mybatis.learn.core.tools.*;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/**
* S10 - Team Protocols:关闭协议 + 计划审批协议
*
* TIPS: 对应Python s10_team_protocols.py。
* 在S09基础上新增3个Lead工具和2个Teammate工具。
*
* 关闭协议FSM: pending → approved | rejected
* Lead调用 shutdown_request → Teammate收到 shutdown_request 消息
* → Teammate调用 shutdown_response → Lead收件箱收到响应
*
* 计划审批FSM: pending → approved | rejected
* Teammate调用 plan_approval → Lead收件箱收到计划
* → Lead调用 plan_approval(review) → Teammate收到审批结果
*
* 关键洞察:"Same request_id correlation pattern, two domains."
*/
@SpringBootApplication(scanBasePackages = "io.mybatis.learn.core")
public class S10TeamProtocols implements CommandLineRunner {
@Autowired
private ChatModel chatModel;
@Override
public void run(String... args) throws Exception {
Path workDir = Path.of(System.getProperty("user.dir"));
Path teamDir = workDir.resolve(".team");
MessageBus bus = new MessageBus(teamDir.resolve("inbox"));
ProtocolTracker tracker = new ProtocolTracker(bus);
S10TeammateManager team = new S10TeammateManager(chatModel, bus, tracker, teamDir);
S10LeadTools leadTools = new S10LeadTools(bus, team, tracker);
ObjectMapper mapper = new ObjectMapper();
String systemPrompt = "You are a team lead at " + workDir
+ ". Manage teammates with shutdown and plan approval protocols.";
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem(systemPrompt)
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(), leadTools)
.build();
AgentRunner.interactive("s10", input -> {
if ("/team".equals(input)) return team.listAll();
if ("/inbox".equals(input)) {
try { return mapper.writeValueAsString(bus.readInbox("lead")); }
catch (Exception e) { return "Error: " + e.getMessage(); }
}
var inbox = bus.readInbox("lead");
String fullInput = input;
if (!inbox.isEmpty()) {
try {
fullInput = "<inbox>" + mapper.writeValueAsString(inbox)
+ "</inbox>\n\n" + input;
} catch (Exception e) { /* ignore */ }
}
return chatClient.prompt(fullInput).call().content();
});
}
// ---- 队友管理器(增加协议处理) ----
/**
* TIPS: 对应Python s10第134-290行的TeammateManager。
* 相比S09增加了:
* 1. should_exit标志 - 队友批准关闭后退出循环
* 2. shutdown_response工具 - 更新tracker + 发送响应消息
* 3. plan_approval工具 - 提交计划到lead收件箱
*/
static class S10TeammateManager {
private final ChatModel chatModel;
private final MessageBus bus;
private final ProtocolTracker tracker;
private final Path configPath;
private final ObjectMapper mapper = new ObjectMapper();
private Map<String, Object> config;
S10TeammateManager(ChatModel chatModel, MessageBus bus,
ProtocolTracker tracker, Path teamDir) {
this.chatModel = chatModel;
this.bus = bus;
this.tracker = tracker;
this.configPath = teamDir.resolve("config.json");
try { Files.createDirectories(teamDir); } catch (IOException e) { throw new RuntimeException(e); }
this.config = loadConfig();
}
@SuppressWarnings("unchecked")
private Map<String, Object> loadConfig() {
if (Files.exists(configPath)) {
try { return mapper.readValue(configPath.toFile(), Map.class); } catch (IOException e) { /* ignore */ }
}
Map<String, Object> cfg = new LinkedHashMap<>();
cfg.put("team_name", "default");
cfg.put("members", new ArrayList<>());
return cfg;
}
private synchronized void saveConfig() {
try { mapper.writerWithDefaultPrettyPrinter().writeValue(configPath.toFile(), config); }
catch (IOException e) { /* ignore */ }
}
@SuppressWarnings("unchecked")
private Map<String, Object> findMember(String name) {
for (Map<String, Object> m : (List<Map<String, Object>>) config.get("members")) {
if (name.equals(m.get("name"))) return m;
}
return null;
}
private synchronized void setStatus(String name, String status) {
Map<String, Object> member = findMember(name);
if (member != null) { member.put("status", status); saveConfig(); }
}
@SuppressWarnings("unchecked")
public synchronized String spawn(String name, String role, String prompt) {
Map<String, Object> member = findMember(name);
if (member != null) {
String status = (String) member.get("status");
if (!"idle".equals(status) && !"shutdown".equals(status))
return "Error: '" + name + "' is currently " + status;
member.put("status", "working");
member.put("role", role);
} else {
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();
Thread.startVirtualThread(() -> teammateLoop(name, role, prompt));
return "Spawned '" + name + "' (role: " + role + ")";
}
private void teammateLoop(String name, String role, String initialPrompt) {
String workDir = System.getProperty("user.dir");
String sysPrompt = String.format(
"You are '%s', role: %s, at %s. "
+ "Submit plans via plan_approval before major work. "
+ "Respond to shutdown_request with shutdown_response.",
name, role, workDir);
// 队友协议工具
var protocolTool = new TeammateProtocolTool(bus, tracker, name);
var messageTool = new io.mybatis.learn.s09.TeammateManager.TeammateMessageTool(bus, name);
ChatClient client = ChatClient.builder(chatModel)
.defaultSystem(sysPrompt)
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(),
messageTool, protocolTool)
.build();
try {
client.prompt(initialPrompt).call().content();
for (int round = 0; round < 50; round++) {
Thread.sleep(2000);
var inbox = bus.readInbox(name);
if (inbox.isEmpty()) break;
// 检查是否有关闭请求
boolean hasShutdown = inbox.stream()
.anyMatch(m -> "shutdown_request".equals(m.get("type")));
String inboxJson = mapper.writeValueAsString(inbox);
client.prompt("<inbox>" + inboxJson + "</inbox>").call().content();
if (hasShutdown) {
setStatus(name, "shutdown");
return;
}
}
} catch (Exception e) {
System.err.println(" [" + name + "] Error: " + e.getMessage());
}
setStatus(name, "idle");
}
@SuppressWarnings("unchecked")
public String listAll() {
List<Map<String, Object>> members = (List<Map<String, Object>>) config.get("members");
if (members.isEmpty()) return "No teammates.";
StringBuilder sb = new StringBuilder("Team: " + config.get("team_name"));
for (Map<String, Object> m : members)
sb.append("\n ").append(m.get("name"))
.append(" (").append(m.get("role")).append("): ").append(m.get("status"));
return sb.toString();
}
@SuppressWarnings("unchecked")
public List<String> memberNames() {
return ((List<Map<String, Object>>) config.get("members")).stream()
.map(m -> (String) m.get("name")).toList();
}
}
// ---- 队友协议工具 ----
public static class TeammateProtocolTool {
private final MessageBus bus;
private final ProtocolTracker tracker;
private final String name;
public TeammateProtocolTool(MessageBus bus, ProtocolTracker tracker, String name) {
this.bus = bus;
this.tracker = tracker;
this.name = name;
}
@Tool(description = "Respond to a shutdown request. Approve to shut down, reject to keep working.")
public String shutdownResponse(
@ToolParam(description = "The request_id from shutdown request") String requestId,
@ToolParam(description = "true to approve shutdown") boolean approve,
@ToolParam(description = "Reason for decision") String reason) {
return tracker.respondToShutdown(name, requestId, approve, reason);
}
@Tool(description = "Submit a plan for lead approval before major work")
public String planApproval(
@ToolParam(description = "Plan text to submit") String plan) {
return tracker.submitPlan(name, plan);
}
}
// ---- Lead工具集(12个工具 = S09的9个 + 3个协议工具) ----
public static class S10LeadTools {
private final MessageBus bus;
private final S10TeammateManager team;
private final ProtocolTracker tracker;
private final ObjectMapper mapper = new ObjectMapper();
public S10LeadTools(MessageBus bus, S10TeammateManager team, ProtocolTracker tracker) {
this.bus = bus;
this.team = team;
this.tracker = tracker;
}
@Tool(description = "Spawn a persistent teammate")
public String spawnTeammate(String name, String role, String prompt) {
return team.spawn(name, role, prompt);
}
@Tool(description = "List all teammates")
public String listTeammates() { return team.listAll(); }
@Tool(description = "Send a message to a teammate's inbox")
public String sendMessage(String to, String content) {
return bus.send("lead", to, content);
}
@Tool(description = "Read and drain the lead's inbox")
public String readInbox() {
try { return mapper.writeValueAsString(bus.readInbox("lead")); }
catch (Exception e) { return "[]"; }
}
@Tool(description = "Broadcast message to all teammates")
public String broadcast(String content) {
return bus.broadcast("lead", content, team.memberNames());
}
@Tool(description = "Request a teammate to shut down gracefully. Returns request_id for tracking.")
public String shutdownRequest(
@ToolParam(description = "Teammate name to shut down") String teammate) {
return tracker.handleShutdownRequest(teammate);
}
@Tool(description = "Check shutdown request status by request_id")
public String shutdownResponse(
@ToolParam(description = "The request_id to check") String requestId) {
return tracker.checkShutdownStatus(requestId);
}
@Tool(description = "Approve or reject a teammate's plan")
public String planApproval(
@ToolParam(description = "Plan request_id") String requestId,
@ToolParam(description = "true to approve") boolean approve,
@ToolParam(description = "Feedback text") String feedback) {
return tracker.reviewPlan(requestId, approve, feedback);
}
}
public static void main(String[] args) {
SpringApplication.run(S10TeamProtocols.class, args);
}
}
@@ -0,0 +1,413 @@
package io.mybatis.learn.s11;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.mybatis.learn.core.AgentRunner;
import io.mybatis.learn.core.team.MessageBus;
import io.mybatis.learn.core.tools.*;
import io.mybatis.learn.s10.ProtocolTracker;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* S11 - Autonomous Agents:自主agent + 空闲轮询 + 任务认领
*
* TIPS: 对应Python s11_autonomous_agents.py。
* 在S10基础上新增:
* 1. idle工具 - 队友主动进入空闲阶段
* 2. 空闲轮询 - 每5秒检查收件箱和任务板
* 3. 自动认领 - 发现未分配任务时自动claim
* 4. 身份重注入 - 上下文压缩后恢复身份信息
*
* 队友生命周期:
* spawn → WORK → idle → IDLE POLL → (message|task) → WORK → ...
* IDLE超时(60s) → shutdown
*
* 关键洞察:"The agent finds work itself."
*/
@SpringBootApplication(scanBasePackages = {"io.mybatis.learn.core", "io.mybatis.learn.s10"})
public class S11AutonomousAgents implements CommandLineRunner {
private static final int POLL_INTERVAL = 5;
private static final int IDLE_TIMEOUT = 60;
@Autowired
private ChatModel chatModel;
@Override
public void run(String... args) throws Exception {
Path workDir = Path.of(System.getProperty("user.dir"));
Path teamDir = workDir.resolve(".team");
Path tasksDir = workDir.resolve(".tasks");
MessageBus bus = new MessageBus(teamDir.resolve("inbox"));
ProtocolTracker tracker = new ProtocolTracker(bus);
AutonomousTeammateManager team = new AutonomousTeammateManager(
chatModel, bus, tracker, teamDir, tasksDir);
AutonomousLeadTools leadTools = new AutonomousLeadTools(bus, team, tracker, tasksDir);
ObjectMapper mapper = new ObjectMapper();
String systemPrompt = "You are a team lead at " + workDir
+ ". Teammates are autonomous -- they find work themselves.";
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem(systemPrompt)
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(), leadTools)
.build();
AgentRunner.interactive("s11", input -> {
if ("/team".equals(input)) return team.listAll();
if ("/inbox".equals(input)) {
try { return mapper.writeValueAsString(bus.readInbox("lead")); }
catch (Exception e) { return "Error: " + e.getMessage(); }
}
var inbox = bus.readInbox("lead");
String fullInput = input;
if (!inbox.isEmpty()) {
try { fullInput = "<inbox>" + mapper.writeValueAsString(inbox) + "</inbox>\n\n" + input; }
catch (Exception e) { /* ignore */ }
}
return chatClient.prompt(fullInput).call().content();
});
}
// ---- 任务板扫描(全局函数) ----
/**
* TIPS: 对应Python scan_unclaimed_tasks()s11第127-136行)。
* 扫描 .tasks/task_*.json,找到 status=pending 且无 owner 的任务。
*/
@SuppressWarnings("unchecked")
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 -> {
try {
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);
}
} catch (IOException e) { /* skip */ }
});
} catch (IOException e) { /* ignore */ }
return unclaimed;
}
/**
* TIPS: 对应Python claim_task()s11第139-148行)。
* 原子操作:标记任务被认领,使用 synchronized 保证互斥。
*/
@SuppressWarnings("unchecked")
static synchronized String claimTask(Path tasksDir, int taskId, String owner) {
Path path = tasksDir.resolve("task_" + taskId + ".json");
if (!Files.exists(path)) return "Error: Task " + taskId + " not found";
ObjectMapper mapper = new ObjectMapper();
try {
Map<String, Object> task = mapper.readValue(path.toFile(), Map.class);
task.put("owner", owner);
task.put("status", "in_progress");
mapper.writerWithDefaultPrettyPrinter().writeValue(path.toFile(), task);
return "Claimed task #" + taskId + " for " + owner;
} catch (IOException e) {
return "Error: " + e.getMessage();
}
}
// ---- 自治队友管理器 ----
/**
* TIPS: 对应Python TeammateManagers11第160-367行)。
* 核心差异:双阶段循环(WORK → IDLE)。
*
* Python: WORK阶段逐次LLM调用(range(50)),idle工具触发进入IDLE阶段;
* IDLE阶段每5秒轮询收件箱+任务板,超时60秒→shutdown。
* Java: ChatClient.prompt().call()自动完成WORK阶段的整个工具链,
* idle工具通过AtomicBoolean标志通知外部循环进入IDLE阶段。
*/
static class AutonomousTeammateManager {
private final ChatModel chatModel;
private final MessageBus bus;
private final ProtocolTracker tracker;
private final Path configPath;
private final Path tasksDir;
private final ObjectMapper mapper = new ObjectMapper();
private Map<String, Object> config;
AutonomousTeammateManager(ChatModel chatModel, MessageBus bus,
ProtocolTracker tracker, Path teamDir, Path tasksDir) {
this.chatModel = chatModel;
this.bus = bus;
this.tracker = tracker;
this.tasksDir = tasksDir;
this.configPath = teamDir.resolve("config.json");
try { Files.createDirectories(teamDir); } catch (IOException e) { throw new RuntimeException(e); }
this.config = loadConfig();
}
@SuppressWarnings("unchecked")
private Map<String, Object> loadConfig() {
if (Files.exists(configPath)) {
try { return mapper.readValue(configPath.toFile(), Map.class); }
catch (IOException e) { /* ignore */ }
}
Map<String, Object> cfg = new LinkedHashMap<>();
cfg.put("team_name", "default");
cfg.put("members", new ArrayList<>());
return cfg;
}
private synchronized void saveConfig() {
try { mapper.writerWithDefaultPrettyPrinter().writeValue(configPath.toFile(), config); }
catch (IOException e) { /* ignore */ }
}
@SuppressWarnings("unchecked")
private Map<String, Object> findMember(String name) {
for (Map<String, Object> m : (List<Map<String, Object>>) config.get("members")) {
if (name.equals(m.get("name"))) return m;
}
return null;
}
private synchronized void setStatus(String name, String status) {
Map<String, Object> member = findMember(name);
if (member != null) { member.put("status", status); saveConfig(); }
}
@SuppressWarnings("unchecked")
public synchronized String spawn(String name, String role, String prompt) {
Map<String, Object> member = findMember(name);
if (member != null) {
String status = (String) member.get("status");
if (!"idle".equals(status) && !"shutdown".equals(status))
return "Error: '" + name + "' is currently " + status;
member.put("status", "working");
member.put("role", role);
} else {
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();
Thread.startVirtualThread(() -> autonomousLoop(name, role, prompt));
return "Spawned '" + name + "' (role: " + role + ")";
}
/**
* 自治循环:WORK → IDLE → WORK → ... → shutdown
*/
private void autonomousLoop(String name, String role, String initialPrompt) {
String teamName = (String) config.get("team_name");
String workDir = System.getProperty("user.dir");
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);
// idle标志:工具调用时设置,外部循环检测
AtomicBoolean idleRequested = new AtomicBoolean(false);
var messageTool = new io.mybatis.learn.s09.TeammateManager.TeammateMessageTool(bus, name);
var protocolTool = new io.mybatis.learn.s10.S10TeamProtocols.TeammateProtocolTool(bus, tracker, name);
var idleTool = new IdleTool(idleRequested);
var claimTool = new ClaimTaskTool(tasksDir, name);
ChatClient client = ChatClient.builder(chatModel)
.defaultSystem(sysPrompt)
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(),
messageTool, protocolTool, idleTool, claimTool)
.build();
try {
while (true) {
// -- WORK PHASE --
String nextMsg = initialPrompt;
for (int round = 0; round < 50 && nextMsg != null; round++) {
// 检查收件箱
var inbox = bus.readInbox(name);
StringBuilder sb = new StringBuilder(nextMsg);
for (var msg : inbox) {
if ("shutdown_request".equals(msg.get("type"))) {
setStatus(name, "shutdown");
return;
}
sb.append("\n").append(mapper.writeValueAsString(msg));
}
idleRequested.set(false);
String response = client.prompt(sb.toString()).call().content();
System.out.println(" [" + name + "] "
+ AgentRunner.truncate(response, 120));
if (idleRequested.get()) break;
nextMsg = null; // 后续轮次靠inbox驱动
Thread.sleep(1000);
var newInbox = bus.readInbox(name);
if (newInbox.isEmpty()) break;
nextMsg = "<inbox>" + mapper.writeValueAsString(newInbox) + "</inbox>";
}
// -- IDLE PHASE: 轮询收件箱 + 任务板 --
setStatus(name, "idle");
boolean resume = false;
int polls = IDLE_TIMEOUT / Math.max(POLL_INTERVAL, 1);
for (int p = 0; p < polls; p++) {
Thread.sleep(POLL_INTERVAL * 1000L);
// 检查收件箱
var inbox = bus.readInbox(name);
if (!inbox.isEmpty()) {
for (var msg : inbox) {
if ("shutdown_request".equals(msg.get("type"))) {
setStatus(name, "shutdown");
return;
}
}
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");
}
} catch (Exception e) {
System.err.println(" [" + name + "] Error: " + e.getMessage());
setStatus(name, "shutdown");
}
}
@SuppressWarnings("unchecked")
public String listAll() {
List<Map<String, Object>> members = (List<Map<String, Object>>) config.get("members");
if (members.isEmpty()) return "No teammates.";
StringBuilder sb = new StringBuilder("Team: " + config.get("team_name"));
for (Map<String, Object> m : members)
sb.append("\n ").append(m.get("name"))
.append(" (").append(m.get("role")).append("): ").append(m.get("status"));
return sb.toString();
}
@SuppressWarnings("unchecked")
public List<String> memberNames() {
return ((List<Map<String, Object>>) config.get("members")).stream()
.map(m -> (String) m.get("name")).toList();
}
}
// ---- idle工具 ----
/**
* TIPS: 对应Python idle工具(s11第352-353行)。
* Python在_exec中直接返回字符串并设置idle_requested标志;
* Java通过AtomicBoolean实现跨线程通信:
* 工具执行时设置标志 → ChatClient完成工具链后 → 外部循环检测标志。
*/
public static class IdleTool {
private final AtomicBoolean idleFlag;
public IdleTool(AtomicBoolean idleFlag) {
this.idleFlag = idleFlag;
}
@Tool(description = "Signal that you have no more work. Enters idle polling phase.")
public String idle() {
idleFlag.set(true);
return "Entering idle phase. Will poll for new tasks.";
}
}
// ---- claim_task工具 ----
public static class ClaimTaskTool {
private final Path tasksDir;
private final String owner;
public ClaimTaskTool(Path tasksDir, String owner) {
this.tasksDir = tasksDir;
this.owner = owner;
}
@Tool(description = "Claim a task from the task board by ID")
public String claimTask(
@ToolParam(description = "Task ID to claim") int taskId) {
return S11AutonomousAgents.claimTask(tasksDir, taskId, owner);
}
}
// ---- Lead工具集(14个工具 = S10的12个 + idle + claim_task ----
public static class AutonomousLeadTools {
private final MessageBus bus;
private final AutonomousTeammateManager team;
private final ProtocolTracker tracker;
private final Path tasksDir;
private final ObjectMapper mapper = new ObjectMapper();
public AutonomousLeadTools(MessageBus bus, AutonomousTeammateManager team,
ProtocolTracker tracker, Path tasksDir) {
this.bus = bus;
this.team = team;
this.tracker = tracker;
this.tasksDir = tasksDir;
}
@Tool(description = "Spawn an autonomous teammate") public String spawnTeammate(String name, String role, String prompt) { return team.spawn(name, role, prompt); }
@Tool(description = "List all teammates") public String listTeammates() { return team.listAll(); }
@Tool(description = "Send message to teammate") public String sendMessage(String to, String content) { return bus.send("lead", to, content); }
@Tool(description = "Read lead's inbox") public String readInbox() { try { return mapper.writeValueAsString(bus.readInbox("lead")); } catch (Exception e) { return "[]"; } }
@Tool(description = "Broadcast to all teammates") public String broadcast(String content) { return bus.broadcast("lead", content, team.memberNames()); }
@Tool(description = "Request teammate shutdown") public String shutdownRequest(String teammate) { return tracker.handleShutdownRequest(teammate); }
@Tool(description = "Check shutdown status") public String shutdownResponse(String requestId) { return tracker.checkShutdownStatus(requestId); }
@Tool(description = "Approve/reject plan") public String planApproval(String requestId, boolean approve, String feedback) { return tracker.reviewPlan(requestId, approve, feedback); }
@Tool(description = "Claim a task from the board") public String claimTask(int taskId) { return S11AutonomousAgents.claimTask(tasksDir, taskId, "lead"); }
}
public static void main(String[] args) {
SpringApplication.run(S11AutonomousAgents.class, args);
}
}
@@ -0,0 +1,93 @@
package io.mybatis.learn.s12;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 事件总线 - 追加式JSONL生命周期事件日志
*
* TIPS: 对应Python EventBus类(s12第83-118行)。
* 记录 worktree 和 task 的生命周期事件,用于可观测性。
* 事件类型:worktree.create.before/after/failed, worktree.remove.*, worktree.keep, task.completed
*/
public class EventBus {
private static final Logger log = LoggerFactory.getLogger(EventBus.class);
private final Path logPath;
private final ObjectMapper mapper = new ObjectMapper();
public EventBus(Path logPath) {
this.logPath = logPath;
try {
Files.createDirectories(logPath.getParent());
if (!Files.exists(logPath)) Files.writeString(logPath, "");
if (log.isDebugEnabled()) {
System.out.printf("🚀 事件日志就绪: %s%n", logPath);
}
} catch (IOException e) {
log.error("初始化事件日志失败: {}, error={}", logPath, e.getMessage());
throw new RuntimeException(e);
}
}
public synchronized void emit(String event, Map<String, Object> task,
Map<String, Object> worktree, String error) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("event", event);
payload.put("ts", System.currentTimeMillis() / 1000.0);
payload.put("task", task != null ? task : Map.of());
payload.put("worktree", worktree != null ? worktree : Map.of());
if (error != null) payload.put("error", error);
try {
Files.writeString(logPath, mapper.writeValueAsString(payload) + "\n",
StandardOpenOption.APPEND);
if (log.isDebugEnabled()) {
System.out.printf("📡 事件已记录: %s%n", event);
}
} catch (IOException e) {
log.warn("事件写入失败: event={}, error={}", event, e.getMessage());
System.err.println("EventBus emit error: " + e.getMessage());
}
}
public void emit(String event) {
emit(event, null, null, null);
}
@SuppressWarnings("unchecked")
public String listRecent(int limit) {
int n = Math.max(1, Math.min(limit, 200));
if (log.isDebugEnabled()) {
System.out.printf("🔍 读取最近 %d 条事件 (请求 %d)%n", n, limit);
}
try {
List<String> lines = Files.readAllLines(logPath);
List<String> recent = lines.subList(Math.max(0, lines.size() - n), lines.size());
List<Object> items = new ArrayList<>();
for (String line : recent) {
if (!line.isBlank()) {
try {
items.add(mapper.readValue(line, Map.class));
} catch (Exception e) {
items.add(Map.of("event", "parse_error", "raw", line));
}
}
}
return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(items);
} catch (IOException e) {
log.warn("读取事件失败: error={}", e.getMessage());
return "Error: " + e.getMessage();
}
}
}
@@ -0,0 +1,191 @@
package io.mybatis.learn.s12;
import io.mybatis.learn.core.AgentRunner;
import io.mybatis.learn.core.tools.*;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import java.nio.file.Path;
/**
* S12 - Worktree + Task Isolation:目录级隔离的并行任务执行
*
* TIPS: 对应Python s12_worktree_task_isolation.py。
* 与S09-S11的团队通信路线不同,S12走「目录隔离」路线:
* 任务(控制平面)+ Worktree(执行平面)。
*
* S09-S11: 多个agent → 消息队列通信 → 协议协调
* S12: 单个agent → 多个worktree → 按目录隔离 → 按task ID协调
*
* 17个工具:base(4) + task(5) + worktree(8)
*
* 关键洞察:"Isolate by directory, coordinate by task ID."
*/
@SpringBootApplication(scanBasePackages = "io.mybatis.learn.core")
public class S12WorktreeIsolation implements CommandLineRunner {
@Autowired
private ChatModel chatModel;
@Override
public void run(String... args) throws Exception {
Path workDir = Path.of(System.getProperty("user.dir"));
// 检测git仓库根目录
Path repoRoot = detectRepoRoot(workDir);
if (repoRoot == null) repoRoot = workDir;
WorktreeTaskManager tasks = new WorktreeTaskManager(repoRoot.resolve(".tasks"));
EventBus events = new EventBus(repoRoot.resolve(".worktrees").resolve("events.jsonl"));
WorktreeManager worktrees = new WorktreeManager(repoRoot, tasks, events);
TaskTools taskTools = new TaskTools(tasks);
WorktreeTools wtTools = new WorktreeTools(worktrees, events);
String systemPrompt = "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.";
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem(systemPrompt)
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(),
taskTools, wtTools)
.build();
System.out.println("Repo root for s12: " + repoRoot);
if (!worktrees.isGitAvailable()) {
System.out.println("Note: Not in a git repo. worktree_* tools will return errors.");
}
AgentRunner.interactive("s12", input ->
chatClient.prompt(input).call().content());
}
private static Path detectRepoRoot(Path cwd) {
try {
ProcessBuilder pb = new ProcessBuilder("git", "rev-parse", "--show-toplevel");
pb.directory(cwd.toFile());
pb.redirectErrorStream(true);
Process p = pb.start();
String out = new String(p.getInputStream().readAllBytes()).trim();
if (p.waitFor() == 0 && !out.isEmpty()) {
Path root = Path.of(out);
return root.toFile().exists() ? root : null;
}
} catch (Exception e) { /* ignore */ }
return null;
}
// ---- 任务工具集(5个) ----
public static class TaskTools {
private final WorktreeTaskManager tasks;
public TaskTools(WorktreeTaskManager tasks) { this.tasks = tasks; }
@Tool(description = "Create a new task on the shared task board")
public String taskCreate(
@ToolParam(description = "Task subject") String subject,
@ToolParam(description = "Task description") String description) {
return tasks.create(subject, description);
}
@Tool(description = "List all tasks with status, owner, and worktree binding")
public String taskList() { return tasks.listAll(); }
@Tool(description = "Get task details by ID")
public String taskGet(@ToolParam(description = "Task ID") int taskId) {
return tasks.get(taskId);
}
@Tool(description = "Update task status or owner")
public String taskUpdate(
@ToolParam(description = "Task ID") int taskId,
@ToolParam(description = "New status: pending/in_progress/completed") String status,
@ToolParam(description = "New owner name") String owner) {
return tasks.update(taskId, status, owner);
}
@Tool(description = "Bind a task to a worktree name")
public String taskBindWorktree(
@ToolParam(description = "Task ID") int taskId,
@ToolParam(description = "Worktree name") String worktree,
@ToolParam(description = "Owner name") String owner) {
return tasks.bindWorktree(taskId, worktree, owner);
}
}
// ---- Worktree工具集(8个) ----
/**
* TIPS: 对应Python TOOL_HANDLERS中的8个worktree工具(s12第546-552行)。
* 每个工具委托给WorktreeManager执行实际操作。
* 安全检查:worktree名称正则验证(1-40字符),危险命令阻止。
*/
public static class WorktreeTools {
private final WorktreeManager worktrees;
private final EventBus events;
public WorktreeTools(WorktreeManager worktrees, EventBus events) {
this.worktrees = worktrees;
this.events = events;
}
@Tool(description = "Create a git worktree and optionally bind it to a task")
public String worktreeCreate(
@ToolParam(description = "Worktree name (1-40 chars: letters, numbers, ., _, -)") String name,
@ToolParam(description = "Task ID to bind (optional)") Integer taskId,
@ToolParam(description = "Base git ref (default: HEAD)") String baseRef) {
return worktrees.create(name, taskId, baseRef);
}
@Tool(description = "List worktrees tracked in .worktrees/index.json")
public String worktreeList() { return worktrees.listAll(); }
@Tool(description = "Show git status for one worktree")
public String worktreeStatus(
@ToolParam(description = "Worktree name") String name) {
return worktrees.status(name);
}
@Tool(description = "Run a shell command in a named worktree directory")
public String worktreeRun(
@ToolParam(description = "Worktree name") String name,
@ToolParam(description = "Shell command to run") String command) {
return worktrees.run(name, command);
}
@Tool(description = "Remove a worktree and optionally mark its bound task completed")
public String worktreeRemove(
@ToolParam(description = "Worktree name") String name,
@ToolParam(description = "Force remove") boolean force,
@ToolParam(description = "Mark bound task as completed") boolean completeTask) {
return worktrees.remove(name, force, completeTask);
}
@Tool(description = "Mark a worktree as kept in lifecycle state without removing it")
public String worktreeKeep(
@ToolParam(description = "Worktree name") String name) {
return worktrees.keep(name);
}
@Tool(description = "List recent worktree/task lifecycle events")
public String worktreeEvents(
@ToolParam(description = "Number of events to show (default 20)") int limit) {
return events.listRecent(limit > 0 ? limit : 20);
}
}
public static void main(String[] args) {
SpringApplication.run(S12WorktreeIsolation.class, args);
}
}
@@ -0,0 +1,339 @@
package io.mybatis.learn.s12;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.regex.Pattern;
/**
* Worktree管理器 - git worktree 创建/运行/删除 + 生命周期索引
*
* TIPS: 对应Python WorktreeManager类(s12第225-471行)。
* Python用 subprocess.run(["git", ...]) 执行git命令;
* Java用 ProcessBuilder 并检测 Windows/Unix 环境。
* 索引文件 .worktrees/index.json 跟踪所有worktree的状态。
*/
public class WorktreeManager {
private static final Logger log = LoggerFactory.getLogger(WorktreeManager.class);
private static final Pattern NAME_PATTERN = Pattern.compile("[A-Za-z0-9._-]{1,40}");
private final Path repoRoot;
private final WorktreeTaskManager tasks;
private final EventBus events;
private final Path worktreeDir;
private final Path indexPath;
private final ObjectMapper mapper = new ObjectMapper();
private final boolean gitAvailable;
public WorktreeManager(Path repoRoot, WorktreeTaskManager tasks, EventBus events) {
this.repoRoot = repoRoot;
this.tasks = tasks;
this.events = events;
this.worktreeDir = repoRoot.resolve(".worktrees");
this.indexPath = worktreeDir.resolve("index.json");
try {
Files.createDirectories(worktreeDir);
if (!Files.exists(indexPath)) {
Files.writeString(indexPath, "{\"worktrees\": []}");
}
if (log.isDebugEnabled()) {
System.out.printf("🚀 worktree目录与索引就绪: dir=%s, index=%s%n", worktreeDir, indexPath);
}
} catch (IOException e) {
log.error("初始化worktree目录失败: error={}", e.getMessage());
throw new RuntimeException(e);
}
this.gitAvailable = isGitRepo();
log.info("WorktreeManager 初始化完成,repoRoot={}, gitAvailable={}", repoRoot, gitAvailable);
}
private boolean isGitRepo() {
try {
ProcessBuilder pb = new ProcessBuilder("git", "rev-parse", "--is-inside-work-tree");
pb.directory(repoRoot.toFile());
pb.redirectErrorStream(true);
Process p = pb.start();
int code = p.waitFor();
return code == 0;
} catch (Exception e) { return false; }
}
private String runGit(String... args) throws IOException {
if (!gitAvailable) throw new IOException("Not in a git repository. worktree tools require git.");
List<String> cmd = new ArrayList<>();
cmd.add("git");
cmd.addAll(Arrays.asList(args));
ProcessBuilder pb = new ProcessBuilder(cmd);
pb.directory(repoRoot.toFile());
pb.redirectErrorStream(true);
try {
Process p = pb.start();
String out = new String(p.getInputStream().readAllBytes()).trim();
int code = p.waitFor();
if (code != 0) throw new IOException(out.isEmpty() ? "git " + String.join(" ", args) + " failed" : out);
if (log.isDebugEnabled()) {
System.out.printf("✅ git 命令完成: %s%n", String.join(" ", args));
}
return out.isEmpty() ? "(no output)" : out;
} catch (InterruptedException e) {
throw new IOException("git command interrupted", e);
}
}
@SuppressWarnings("unchecked")
private Map<String, Object> loadIndex() throws IOException {
return mapper.readValue(indexPath.toFile(), new TypeReference<>() {});
}
private void saveIndex(Map<String, Object> index) throws IOException {
mapper.writerWithDefaultPrettyPrinter().writeValue(indexPath.toFile(), index);
}
@SuppressWarnings("unchecked")
private Map<String, Object> findWorktree(String name) throws IOException {
var index = loadIndex();
for (var wt : (List<Map<String, Object>>) index.get("worktrees")) {
if (name.equals(wt.get("name"))) return wt;
}
return null;
}
private void validateName(String name) {
if (name == null || !NAME_PATTERN.matcher(name).matches()) {
throw new IllegalArgumentException(
"Invalid worktree name. Use 1-40 chars: letters, numbers, ., _, -");
}
}
// ---- 创建 ----
@SuppressWarnings("unchecked")
public String create(String name, Integer taskId, String baseRef) {
log.info("创建worktree请求: name={}, taskId={}, baseRef={}", name, taskId, baseRef);
validateName(name);
try {
if (findWorktree(name) != null)
return "Error: Worktree '" + name + "' already exists in index";
if (taskId != null && !tasks.exists(taskId))
return "Error: Task " + taskId + " not found";
Path path = worktreeDir.resolve(name);
String branch = "wt/" + name;
String ref = baseRef != null ? baseRef : "HEAD";
events.emit("worktree.create.before",
taskId != null ? Map.of("id", taskId) : Map.of(),
Map.of("name", name, "base_ref", ref), null);
runGit("worktree", "add", "-b", branch, path.toString(), ref);
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("name", name);
entry.put("path", path.toString());
entry.put("branch", branch);
entry.put("task_id", taskId);
entry.put("status", "active");
entry.put("created_at", System.currentTimeMillis() / 1000.0);
var index = loadIndex();
((List<Map<String, Object>>) index.get("worktrees")).add(entry);
saveIndex(index);
if (taskId != null) tasks.bindWorktree(taskId, name, "");
events.emit("worktree.create.after",
taskId != null ? Map.of("id", taskId) : Map.of(),
Map.of("name", name, "path", path.toString(),
"branch", branch, "status", "active"), null);
log.info("创建worktree成功: name={}, branch={}, path={}", name, branch, path);
return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(entry);
} catch (Exception e) {
log.warn("创建worktree失败: name={}, error={}", name, e.getMessage());
events.emit("worktree.create.failed", Map.of(), Map.of("name", name), e.getMessage());
return "Error: " + e.getMessage();
}
}
// ---- 列表 ----
@SuppressWarnings("unchecked")
public String listAll() {
if (log.isDebugEnabled()) {
System.out.printf("📋 列出所有worktree%n");
}
try {
var index = loadIndex();
var wts = (List<Map<String, Object>>) index.get("worktrees");
if (wts.isEmpty()) return "No worktrees in index.";
StringBuilder sb = new StringBuilder();
for (var wt : wts) {
String suffix = wt.get("task_id") != null ? " task=" + wt.get("task_id") : "";
sb.append(String.format("[%s] %s -> %s (%s)%s%n",
wt.getOrDefault("status", "unknown"), wt.get("name"),
wt.get("path"), wt.getOrDefault("branch", "-"), suffix));
}
return sb.toString().trim();
} catch (IOException e) {
log.warn("列出worktree失败: error={}", e.getMessage());
return "Error: " + e.getMessage();
}
}
// ---- 状态 ----
public String status(String name) {
if (log.isDebugEnabled()) {
System.out.printf("🔍 查询worktree状态: %s%n", name);
}
try {
var wt = findWorktree(name);
if (wt == null) return "Error: Unknown worktree '" + name + "'";
Path path = Path.of((String) wt.get("path"));
if (!Files.exists(path)) return "Error: Worktree path missing: " + path;
ProcessBuilder pb = new ProcessBuilder("git", "status", "--short", "--branch");
pb.directory(path.toFile());
pb.redirectErrorStream(true);
Process p = pb.start();
String out = new String(p.getInputStream().readAllBytes()).trim();
return out.isEmpty() ? "Clean worktree" : out;
} catch (Exception e) {
log.warn("查询worktree状态失败: name={}, error={}", name, e.getMessage());
return "Error: " + e.getMessage();
}
}
// ---- 在worktree中运行命令 ----
public String run(String name, String command) {
if (log.isDebugEnabled()) {
System.out.printf("🔧 在 %s 中执行命令: %s%n", name,
command.substring(0, Math.min(80, command.length())));
}
String[] dangerous = {"rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"};
for (String d : dangerous) {
if (command.contains(d)) {
log.warn("拦截worktree危险命令: name={}, pattern={}", name, d);
return "Error: Dangerous command blocked";
}
}
try {
var wt = findWorktree(name);
if (wt == null) return "Error: Unknown worktree '" + name + "'";
Path path = Path.of((String) wt.get("path"));
if (!Files.exists(path)) return "Error: Worktree path missing: " + path;
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);
if (!finished) {
p.destroyForcibly();
log.warn("worktree命令超时: name={}, timeout=300s", name);
return "Error: Timeout (300s)";
}
return out.isEmpty() ? "(no output)" : out.substring(0, Math.min(out.length(), 50000));
} catch (Exception e) {
log.warn("执行worktree命令失败: name={}, error={}", name, e.getMessage());
return "Error: " + e.getMessage();
}
}
// ---- 删除 ----
@SuppressWarnings("unchecked")
public String remove(String name, boolean force, boolean completeTask) {
log.info("删除worktree请求: name={}, force={}, completeTask={}", name, force, completeTask);
try {
var wt = findWorktree(name);
if (wt == null) return "Error: Unknown worktree '" + name + "'";
events.emit("worktree.remove.before",
wt.get("task_id") != null ? Map.of("id", wt.get("task_id")) : Map.of(),
Map.of("name", name, "path", wt.getOrDefault("path", "")), null);
List<String> args = new ArrayList<>(List.of("worktree", "remove"));
if (force) args.add("--force");
args.add((String) wt.get("path"));
runGit(args.toArray(String[]::new));
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);
}
var index = loadIndex();
for (var item : (List<Map<String, Object>>) index.get("worktrees")) {
if (name.equals(item.get("name"))) {
item.put("status", "removed");
item.put("removed_at", System.currentTimeMillis() / 1000.0);
}
}
saveIndex(index);
events.emit("worktree.remove.after",
wt.get("task_id") != null ? Map.of("id", wt.get("task_id")) : Map.of(),
Map.of("name", name, "status", "removed"), null);
log.info("删除worktree成功: name={}", name);
return "Removed worktree '" + name + "'";
} catch (Exception e) {
log.warn("删除worktree失败: name={}, error={}", name, e.getMessage());
events.emit("worktree.remove.failed", Map.of(), Map.of("name", name), e.getMessage());
return "Error: " + e.getMessage();
}
}
// ---- 保留 ----
@SuppressWarnings("unchecked")
public String keep(String name) {
log.info("保留worktree请求: name={}", name);
try {
var wt = findWorktree(name);
if (wt == null) return "Error: Unknown worktree '" + name + "'";
var index = loadIndex();
Map<String, Object> kept = null;
for (var item : (List<Map<String, Object>>) index.get("worktrees")) {
if (name.equals(item.get("name"))) {
item.put("status", "kept");
item.put("kept_at", System.currentTimeMillis() / 1000.0);
kept = item;
}
}
saveIndex(index);
events.emit("worktree.keep",
wt.get("task_id") != null ? Map.of("id", wt.get("task_id")) : Map.of(),
Map.of("name", name, "status", "kept"), null);
log.info("保留worktree成功: name={}", name);
return kept != null
? mapper.writerWithDefaultPrettyPrinter().writeValueAsString(kept)
: "Error: Unknown worktree '" + name + "'";
} catch (Exception e) {
log.warn("保留worktree失败: name={}, error={}", name, e.getMessage());
return "Error: " + e.getMessage();
}
}
public boolean isGitAvailable() { return gitAvailable; }
}
@@ -0,0 +1,195 @@
package io.mybatis.learn.s12;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
/**
* Worktree任务管理器 - 持久化任务板 + worktree绑定
*
* TIPS: 对应Python TaskManager类(s12第122-217行)。
* 与S07的TaskManager不同,此版本增加了 worktree 字段用于目录隔离绑定。
* 任务数据结构:{id, subject, description, status, owner, worktree, blockedBy, created_at, updated_at}
*/
public class WorktreeTaskManager {
private static final Logger log = LoggerFactory.getLogger(WorktreeTaskManager.class);
private final Path tasksDir;
private final ObjectMapper mapper = new ObjectMapper();
private int nextId;
public WorktreeTaskManager(Path tasksDir) {
this.tasksDir = tasksDir;
try { Files.createDirectories(tasksDir); } catch (IOException e) {
log.error("创建worktree任务目录失败: {}, error={}", tasksDir, e.getMessage());
throw new RuntimeException(e);
}
this.nextId = maxId() + 1;
log.info("WorktreeTaskManager 初始化完成,nextId={}, dir={}", nextId, tasksDir);
}
private int maxId() {
try (var files = Files.list(tasksDir)) {
return files.filter(f -> f.getFileName().toString().matches("task_\\d+\\.json"))
.mapToInt(f -> {
String name = f.getFileName().toString();
return Integer.parseInt(name.substring(5, name.length() - 5));
})
.max().orElse(0);
} catch (IOException e) { return 0; }
}
private Path taskPath(int taskId) {
return tasksDir.resolve("task_" + taskId + ".json");
}
@SuppressWarnings("unchecked")
private Map<String, Object> load(int taskId) throws IOException {
Path path = taskPath(taskId);
if (!Files.exists(path)) throw new IOException("Task " + taskId + " not found");
return mapper.readValue(path.toFile(), Map.class);
}
private void save(Map<String, Object> task) throws IOException {
mapper.writerWithDefaultPrettyPrinter().writeValue(
taskPath(((Number) task.get("id")).intValue()).toFile(), task);
}
public boolean exists(int taskId) {
return Files.exists(taskPath(taskId));
}
public synchronized String create(String subject, String description) {
if (log.isDebugEnabled()) {
System.out.printf("📋 创建worktree任务: %s%n", subject);
}
Map<String, Object> task = new LinkedHashMap<>();
task.put("id", nextId);
task.put("subject", subject);
task.put("description", description != null ? description : "");
task.put("status", "pending");
task.put("owner", "");
task.put("worktree", "");
task.put("blockedBy", new ArrayList<>());
task.put("created_at", System.currentTimeMillis() / 1000.0);
task.put("updated_at", System.currentTimeMillis() / 1000.0);
try {
save(task);
nextId++;
if (log.isDebugEnabled()) {
System.out.printf("✅ worktree任务 #%s 已创建 (nextId=%d)%n", task.get("id"), nextId);
}
return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(task);
} catch (IOException e) {
log.warn("worktree任务创建失败: subject={}, error={}", subject, e.getMessage());
return "Error: " + e.getMessage();
}
}
public String get(int taskId) {
try {
return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(load(taskId));
} catch (IOException e) {
return "Error: " + e.getMessage();
}
}
public String update(int taskId, String status, String owner) {
if (log.isDebugEnabled()) {
System.out.printf("🔄 更新worktree任务 #%d (status=%s, owner=%s)%n", taskId, status, owner);
}
try {
var task = load(taskId);
if (status != null) {
if (!Set.of("pending", "in_progress", "completed").contains(status))
return "Error: Invalid status: " + status;
task.put("status", status);
}
if (owner != null) task.put("owner", owner);
task.put("updated_at", System.currentTimeMillis() / 1000.0);
save(task);
if (log.isDebugEnabled()) {
System.out.printf("✅ worktree任务 #%d 已更新%n", taskId);
}
return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(task);
} catch (IOException e) {
log.warn("worktree任务更新失败: taskId={}, error={}", taskId, e.getMessage());
return "Error: " + e.getMessage();
}
}
/**
* 绑定worktree到任务(如果任务是pending则自动变为in_progress
*/
public String bindWorktree(int taskId, String worktree, String owner) {
if (log.isDebugEnabled()) {
System.out.printf("🔗 绑定worktree: #%d → %s (owner=%s)%n", taskId, worktree, owner);
}
try {
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);
} catch (IOException e) {
log.warn("绑定worktree失败: taskId={}, worktree={}, error={}", taskId, worktree, e.getMessage());
return "Error: " + e.getMessage();
}
}
public String unbindWorktree(int taskId) {
if (log.isDebugEnabled()) {
System.out.printf("🔗 解绑worktree: #%d%n", taskId);
}
try {
var task = load(taskId);
task.put("worktree", "");
task.put("updated_at", System.currentTimeMillis() / 1000.0);
save(task);
return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(task);
} catch (IOException e) {
log.warn("解绑worktree失败: taskId={}, error={}", taskId, e.getMessage());
return "Error: " + e.getMessage();
}
}
@SuppressWarnings("unchecked")
public String listAll() {
try (var files = Files.list(tasksDir)) {
List<Map<String, Object>> tasks = new ArrayList<>();
files.filter(f -> f.getFileName().toString().matches("task_\\d+\\.json"))
.sorted()
.forEach(f -> {
try { tasks.add(mapper.readValue(f.toFile(), Map.class)); }
catch (IOException e) { /* skip */ }
});
if (tasks.isEmpty()) return "No tasks.";
StringBuilder sb = new StringBuilder();
for (var t : tasks) {
String status = (String) t.get("status");
String marker = switch (status) {
case "pending" -> "[ ]";
case "in_progress" -> "[>]";
case "completed" -> "[x]";
default -> "[?]";
};
String owner = t.get("owner") != null && !t.get("owner").toString().isEmpty()
? " owner=" + t.get("owner") : "";
String wt = t.get("worktree") != null && !t.get("worktree").toString().isEmpty()
? " wt=" + t.get("worktree") : "";
sb.append(String.format("%s #%s: %s%s%s%n",
marker, t.get("id"), t.get("subject"), owner, wt));
}
return sb.toString().trim();
} catch (IOException e) {
return "Error: " + e.getMessage();
}
}
}
+19
View File
@@ -0,0 +1,19 @@
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}
# 启用虚拟线程(Java 21
threads:
virtual:
enabled: true
# 日志级别
logging:
level:
io.mybatis.learn: DEBUG
org.springframework.ai: INFO
+198 -52
View File
@@ -12,6 +12,7 @@ import { VERSION_META, VERSION_ORDER, LEARNING_PATH } from "../src/lib/constants
const WEB_DIR = path.resolve(__dirname, "..");
const REPO_ROOT = path.resolve(WEB_DIR, "..");
const AGENTS_DIR = path.join(REPO_ROOT, "agents");
const JAVA_SRC_DIR = path.join(REPO_ROOT, "src", "main", "java", "io", "mybatis", "learn");
const DOCS_DIR = path.join(REPO_ROOT, "docs");
const OUT_DIR = path.join(WEB_DIR, "src", "data", "generated");
@@ -91,12 +92,101 @@ function extractTools(source: string): string[] {
return Array.from(tools);
}
// Count non-blank, non-comment lines
// Extract classes/interfaces/records/enums from Java source
function extractJavaClasses(
lines: string[]
): { name: string; startLine: number; endLine: number }[] {
const classes: { name: string; startLine: number; endLine: number }[] = [];
const classPattern = /^(?:public\s+)?(?:class|interface|record|enum)\s+(\w+)/;
for (let i = 0; i < lines.length; i++) {
const m = lines[i].match(classPattern);
if (m) {
const name = m[1];
const startLine = i + 1;
let endLine = lines.length;
for (let j = i + 1; j < lines.length; j++) {
if (lines[j].match(/^(?:public\s+)?(?:class|interface|record|enum)\s/)) {
endLine = j;
break;
}
}
classes.push({ name, startLine, endLine });
}
}
return classes;
}
// Extract methods from Java source (skipping constructors)
function extractJavaMethods(
lines: string[]
): { name: string; signature: string; startLine: number }[] {
const methods: { name: string; signature: string; startLine: number }[] = [];
const methodPattern =
/^\s+(?:(?:public|private|protected|static|default|abstract|final|synchronized|native)\s+)*(?:\w+(?:<[^>]*>)?(?:\[\])?)\s+(\w+)\s*\(/;
const classNames = new Set<string>();
const classPattern = /^(?:public\s+)?(?:class|interface|record|enum)\s+(\w+)/;
for (const line of lines) {
const m = line.match(classPattern);
if (m) classNames.add(m[1]);
}
for (let i = 0; i < lines.length; i++) {
const m = lines[i].match(methodPattern);
if (m) {
const name = m[1];
if (classNames.has(name)) continue;
const sig = lines[i].trim().replace(/\{?\s*$/, "").trim();
methods.push({ name, signature: sig, startLine: i + 1 });
}
}
return methods;
}
// Extract tool names from Java @Tool annotations
function extractJavaTools(lines: string[]): string[] {
const tools: string[] = [];
const toolAnnotation = /^\s*@Tool\b/;
const methodPattern =
/^\s+(?:(?:public|private|protected|static|default|abstract|final|synchronized|native)\s+)*(?:\w+(?:<[^>]*>)?(?:\[\])?)\s+(\w+)\s*\(/;
for (let i = 0; i < lines.length; i++) {
if (toolAnnotation.test(lines[i])) {
for (let j = i + 1; j < Math.min(i + 10, lines.length); j++) {
const m = lines[j].match(methodPattern);
if (m) {
tools.push(m[1]);
break;
}
}
}
}
return tools;
}
// Count non-blank, non-comment lines (supports Python # and Java // /* */ comments)
function countLoc(lines: string[]): number {
return lines.filter((line) => {
let inBlockComment = false;
let count = 0;
for (const line of lines) {
const trimmed = line.trim();
return trimmed !== "" && !trimmed.startsWith("#");
}).length;
if (trimmed === "") continue;
if (inBlockComment) {
if (trimmed.includes("*/")) inBlockComment = false;
continue;
}
if (trimmed.startsWith("/*")) {
if (!trimmed.includes("*/")) inBlockComment = true;
continue;
}
if (trimmed.startsWith("#") || trimmed.startsWith("//")) continue;
count++;
}
return count;
}
// Detect locale from subdirectory path
@@ -119,60 +209,116 @@ function extractDocVersion(filename: string): string | null {
function main() {
console.log("Extracting content from agents and docs...");
console.log(` Repo root: ${REPO_ROOT}`);
console.log(` Agents dir: ${AGENTS_DIR}`);
console.log(` Java src dir: ${JAVA_SRC_DIR}`);
console.log(` Agents dir (fallback): ${AGENTS_DIR}`);
console.log(` Docs dir: ${DOCS_DIR}`);
// Skip extraction if source directories don't exist (e.g. Vercel build).
// Pre-committed generated data will be used instead.
if (!fs.existsSync(AGENTS_DIR)) {
console.log(" Agents directory not found, skipping extraction.");
const versions: AgentVersion[] = [];
const useJava = fs.existsSync(JAVA_SRC_DIR);
if (useJava) {
// 1. Read Java sources from s01~s12 + full subdirectories
const subdirs = fs
.readdirSync(JAVA_SRC_DIR)
.filter((d) => /^s\d+$/.test(d) || d === "full")
.filter((d) => fs.statSync(path.join(JAVA_SRC_DIR, d)).isDirectory());
console.log(` Found ${subdirs.length} Java lesson directories`);
for (const dir of subdirs) {
const versionId = dir;
const dirPath = path.join(JAVA_SRC_DIR, dir);
const javaFiles = fs.readdirSync(dirPath).filter((f) => f.endsWith(".java"));
if (javaFiles.length === 0) continue;
// 主类文件排在最前(如 S01AgentLoop.java
const mainPrefix = dir === "full" ? "SFull" : "S" + dir.substring(1);
javaFiles.sort((a, b) => {
const aMain = a.startsWith(mainPrefix) ? 0 : 1;
const bMain = b.startsWith(mainPrefix) ? 0 : 1;
if (aMain !== bMain) return aMain - bMain;
return a.localeCompare(b);
});
const mainFile = javaFiles[0];
// 拼接目录下所有 Java 文件,文件间用分隔符标记
const sourceParts: string[] = [];
for (const jf of javaFiles) {
const content = fs.readFileSync(path.join(dirPath, jf), "utf-8");
sourceParts.push(`// === ${jf} ===\n${content}`);
}
const source = sourceParts.join("\n\n");
const lines = source.split("\n");
const meta = VERSION_META[versionId];
const classes = extractJavaClasses(lines);
const methods = extractJavaMethods(lines);
const tools = extractJavaTools(lines);
const loc = countLoc(lines);
versions.push({
id: versionId,
filename: mainFile,
title: meta?.title ?? versionId,
subtitle: meta?.subtitle ?? "",
loc,
tools,
newTools: [],
coreAddition: meta?.coreAddition ?? "",
keyInsight: meta?.keyInsight ?? "",
classes,
functions: methods,
layer: meta?.layer ?? "tools",
source,
});
}
} else if (fs.existsSync(AGENTS_DIR)) {
// 回退:从 agents/ 目录读取 Python 源码
const agentFiles = fs
.readdirSync(AGENTS_DIR)
.filter((f) => f.startsWith("s") && f.endsWith(".py"));
console.log(` Found ${agentFiles.length} agent files (Python fallback)`);
for (const filename of agentFiles) {
const versionId = filenameToVersionId(filename);
if (!versionId) {
console.warn(` Skipping ${filename}: could not determine version ID`);
continue;
}
const filePath = path.join(AGENTS_DIR, filename);
const source = fs.readFileSync(filePath, "utf-8");
const lines = source.split("\n");
const meta = VERSION_META[versionId];
const classes = extractClasses(lines);
const functions = extractFunctions(lines);
const tools = extractTools(source);
const loc = countLoc(lines);
versions.push({
id: versionId,
filename,
title: meta?.title ?? versionId,
subtitle: meta?.subtitle ?? "",
loc,
tools,
newTools: [],
coreAddition: meta?.coreAddition ?? "",
keyInsight: meta?.keyInsight ?? "",
classes,
functions,
layer: meta?.layer ?? "tools",
source,
});
}
} else {
console.log(" Source directories not found, skipping extraction.");
console.log(" Using pre-committed generated data.");
return;
}
// 1. Read all agent files
const agentFiles = fs
.readdirSync(AGENTS_DIR)
.filter((f) => f.startsWith("s") && f.endsWith(".py"));
console.log(` Found ${agentFiles.length} agent files`);
const versions: AgentVersion[] = [];
for (const filename of agentFiles) {
const versionId = filenameToVersionId(filename);
if (!versionId) {
console.warn(` Skipping ${filename}: could not determine version ID`);
continue;
}
const filePath = path.join(AGENTS_DIR, filename);
const source = fs.readFileSync(filePath, "utf-8");
const lines = source.split("\n");
const meta = VERSION_META[versionId];
const classes = extractClasses(lines);
const functions = extractFunctions(lines);
const tools = extractTools(source);
const loc = countLoc(lines);
versions.push({
id: versionId,
filename,
title: meta?.title ?? versionId,
subtitle: meta?.subtitle ?? "",
loc,
tools,
newTools: [], // computed after all versions are loaded
coreAddition: meta?.coreAddition ?? "",
keyInsight: meta?.keyInsight ?? "",
classes,
functions,
layer: meta?.layer ?? "tools",
source,
});
}
// Sort versions according to VERSION_ORDER
const orderMap = new Map(VERSION_ORDER.map((v, i) => [v, i]));
versions.sort(
@@ -2,7 +2,7 @@
import { useMemo } from "react";
import Link from "next/link";
import { useLocale } from "@/lib/i18n";
import { useLocale, useTranslations } from "@/lib/i18n";
import { VERSION_META } from "@/lib/constants";
import { Card, CardHeader, CardTitle } from "@/components/ui/card";
import { LayerBadge } from "@/components/ui/badge";
@@ -19,6 +19,8 @@ interface DiffPageContentProps {
export function DiffPageContent({ version }: DiffPageContentProps) {
const locale = useLocale();
const tMeta = useTranslations("version_meta");
const td = useTranslations("diff");
const meta = VERSION_META[version];
const { currentVersion, prevVersion, diff } = useMemo(() => {
@@ -32,9 +34,9 @@ export function DiffPageContent({ version }: DiffPageContentProps) {
if (!meta || !currentVersion) {
return (
<div className="py-12 text-center">
<p className="text-zinc-500">Version not found.</p>
<p className="text-zinc-500">{td("version_not_found")}</p>
<Link href={`/${locale}/timeline`} className="mt-4 inline-block text-sm text-blue-600 hover:underline">
Back to timeline
{td("back_to_timeline")}
</Link>
</div>
);
@@ -48,11 +50,11 @@ export function DiffPageContent({ version }: DiffPageContentProps) {
className="mb-6 inline-flex items-center gap-1 text-sm text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
>
<ArrowLeft size={14} />
Back to {meta.title}
{td("back_to")} {meta.title}
</Link>
<h1 className="text-3xl font-bold">{meta.title}</h1>
<p className="mt-4 text-zinc-500">
This is the first version -- there is no previous version to compare against.
{td("first_version_hint")}
</p>
</div>
);
@@ -67,7 +69,7 @@ export function DiffPageContent({ version }: DiffPageContentProps) {
className="mb-6 inline-flex items-center gap-1 text-sm text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
>
<ArrowLeft size={14} />
Back to {meta.title}
{td("back_to")} {meta.title}
</Link>
{/* Header */}
@@ -86,14 +88,14 @@ export function DiffPageContent({ version }: DiffPageContentProps) {
<CardHeader>
<div className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400">
<FileCode size={16} />
<span className="text-sm">LOC Delta</span>
<span className="text-sm">{td("loc_delta")}</span>
</div>
</CardHeader>
<CardTitle>
<span className={diff.locDelta >= 0 ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"}>
{diff.locDelta >= 0 ? "+" : ""}{diff.locDelta}
</span>
<span className="ml-2 text-sm font-normal text-zinc-500">lines</span>
<span className="ml-2 text-sm font-normal text-zinc-500">{td("lines")}</span>
</CardTitle>
</Card>
@@ -101,7 +103,7 @@ export function DiffPageContent({ version }: DiffPageContentProps) {
<CardHeader>
<div className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400">
<Wrench size={16} />
<span className="text-sm">New Tools</span>
<span className="text-sm">{td("new_tools")}</span>
</div>
</CardHeader>
<CardTitle>
@@ -122,7 +124,7 @@ export function DiffPageContent({ version }: DiffPageContentProps) {
<CardHeader>
<div className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400">
<Box size={16} />
<span className="text-sm">New Classes</span>
<span className="text-sm">{td("new_classes")}</span>
</div>
</CardHeader>
<CardTitle>
@@ -143,7 +145,7 @@ export function DiffPageContent({ version }: DiffPageContentProps) {
<CardHeader>
<div className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400">
<FunctionSquare size={16} />
<span className="text-sm">New Functions</span>
<span className="text-sm">{td("new_functions")}</span>
</div>
</CardHeader>
<CardTitle>
@@ -166,7 +168,7 @@ export function DiffPageContent({ version }: DiffPageContentProps) {
<Card className="border-l-4 border-l-red-300 dark:border-l-red-700">
<CardHeader>
<CardTitle>{prevMeta?.title || prevVersion.id}</CardTitle>
<p className="text-sm text-zinc-500">{prevMeta?.subtitle}</p>
<p className="text-sm text-zinc-500">{tMeta(`${prevVersion.id}.subtitle`)}</p>
</CardHeader>
<div className="space-y-1 text-sm text-zinc-600 dark:text-zinc-400">
<p>{prevVersion.loc} LOC</p>
@@ -177,7 +179,7 @@ export function DiffPageContent({ version }: DiffPageContentProps) {
<Card className="border-l-4 border-l-green-300 dark:border-l-green-700">
<CardHeader>
<CardTitle>{meta.title}</CardTitle>
<p className="text-sm text-zinc-500">{meta.subtitle}</p>
<p className="text-sm text-zinc-500">{tMeta(`${version}.subtitle`)}</p>
</CardHeader>
<div className="space-y-1 text-sm text-zinc-600 dark:text-zinc-400">
<p>{currentVersion.loc} LOC</p>
@@ -189,7 +191,7 @@ export function DiffPageContent({ version }: DiffPageContentProps) {
{/* Code Diff */}
<div>
<h2 className="mb-4 text-xl font-semibold">Source Code Diff</h2>
<h2 className="mb-4 text-xl font-semibold">{td("source_diff")}</h2>
<CodeDiff
oldSource={prevVersion.source}
newSource={currentVersion.source}
@@ -21,9 +21,10 @@ export default async function VersionPage({
const diff = versionsData.diffs.find((d) => d.to === version) ?? null;
if (!versionData || !meta) {
const td = getTranslations(locale, "diff");
return (
<div className="py-20 text-center">
<h1 className="text-2xl font-bold">Version not found</h1>
<h1 className="text-2xl font-bold">{td("version_not_found")}</h1>
<p className="mt-2 text-zinc-500">{version}</p>
</div>
);
@@ -32,6 +33,7 @@ export default async function VersionPage({
const t = getTranslations(locale, "version");
const tSession = getTranslations(locale, "sessions");
const tLayer = getTranslations(locale, "layer_labels");
const tMeta = getTranslations(locale, "version_meta");
const layer = LAYERS.find((l) => l.id === meta.layer);
const pathIndex = LEARNING_PATH.indexOf(version as typeof LEARNING_PATH[number]);
@@ -55,20 +57,20 @@ export default async function VersionPage({
)}
</div>
<p className="text-lg text-zinc-500 dark:text-zinc-400">
{meta.subtitle}
{tMeta(`${version}.subtitle`)}
</p>
<div className="flex flex-wrap items-center gap-4 text-sm text-zinc-500 dark:text-zinc-400">
<span className="font-mono">{versionData.loc} LOC</span>
<span>{versionData.tools.length} {t("tools")}</span>
{meta.coreAddition && (
<span className="rounded-full bg-zinc-100 px-2.5 py-0.5 text-xs dark:bg-zinc-800">
{meta.coreAddition}
{tMeta(`${version}.coreAddition`)}
</span>
)}
</div>
{meta.keyInsight && (
<blockquote className="border-l-4 border-zinc-300 pl-4 text-sm italic text-zinc-500 dark:border-zinc-600 dark:text-zinc-400">
{meta.keyInsight}
{tMeta(`${version}.keyInsight`)}
</blockquote>
)}
</header>
@@ -15,6 +15,7 @@ const data = versionData as VersionIndex;
export default function ComparePage() {
const t = useTranslations("compare");
const tMeta = useTranslations("version_meta");
const locale = useLocale();
const [versionA, setVersionA] = useState<string>("");
const [versionB, setVersionB] = useState<string>("");
@@ -68,7 +69,7 @@ export default function ComparePage() {
onChange={(e) => setVersionA(e.target.value)}
className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200"
>
<option value="">-- select --</option>
<option value="">{t("select_placeholder")}</option>
{LEARNING_PATH.map((v) => (
<option key={v} value={v}>
{v} - {VERSION_META[v]?.title}
@@ -88,7 +89,7 @@ export default function ComparePage() {
onChange={(e) => setVersionB(e.target.value)}
className="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-200"
>
<option value="">-- select --</option>
<option value="">{t("select_placeholder")}</option>
{LEARNING_PATH.map((v) => (
<option key={v} value={v}>
{v} - {VERSION_META[v]?.title}
@@ -106,7 +107,7 @@ export default function ComparePage() {
<Card>
<CardHeader>
<CardTitle>{metaA?.title || versionA}</CardTitle>
<p className="text-sm text-zinc-500">{metaA?.subtitle}</p>
<p className="text-sm text-zinc-500">{versionA ? tMeta(`${versionA}.subtitle`) : ""}</p>
</CardHeader>
<div className="space-y-2 text-sm text-zinc-600 dark:text-zinc-400">
<p>{infoA.loc} LOC</p>
@@ -117,7 +118,7 @@ export default function ComparePage() {
<Card>
<CardHeader>
<CardTitle>{metaB?.title || versionB}</CardTitle>
<p className="text-sm text-zinc-500">{metaB?.subtitle}</p>
<p className="text-sm text-zinc-500">{versionB ? tMeta(`${versionB}.subtitle`) : ""}</p>
</CardHeader>
<div className="space-y-2 text-sm text-zinc-600 dark:text-zinc-400">
<p>{infoB.loc} LOC</p>
+5 -3
View File
@@ -30,6 +30,8 @@ const LAYER_HEADER_BG: Record<string, string> = {
export default function LayersPage() {
const t = useTranslations("layers");
const tLayer = useTranslations("layer_labels");
const tMeta = useTranslations("version_meta");
const locale = useLocale();
return (
@@ -63,7 +65,7 @@ export default function LayersPage() {
<h2 className="text-xl font-bold">
<span className="text-zinc-400 dark:text-zinc-600">L{index + 1}</span>
{" "}
{layer.label}
{tLayer(layer.id)}
</h2>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
{t(layer.id)}
@@ -92,7 +94,7 @@ export default function LayersPage() {
</h3>
{meta?.subtitle && (
<p className="mt-0.5 text-xs text-zinc-500 dark:text-zinc-400">
{meta.subtitle}
{tMeta(`${id}.subtitle`)}
</p>
)}
</div>
@@ -107,7 +109,7 @@ export default function LayersPage() {
</div>
{meta?.keyInsight && (
<p className="mt-2 text-xs leading-relaxed text-zinc-500 dark:text-zinc-400 line-clamp-2">
{meta.keyInsight}
{tMeta(`${id}.keyInsight`)}
</p>
)}
</Card>
+1 -1
View File
@@ -19,7 +19,7 @@ export async function generateMetadata({
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const messages = metaMessages[locale] || metaMessages.en;
const messages = metaMessages[locale] || metaMessages.zh;
return {
title: messages.meta?.title || "Learn Claude Code",
description: messages.meta?.description || "Build an AI coding agent from scratch, one concept at a time",
+57 -37
View File
@@ -39,6 +39,7 @@ function getVersionData(id: string) {
export default function HomePage() {
const t = useTranslations("home");
const tMeta = useTranslations("version_meta");
const locale = useLocale();
return (
@@ -75,51 +76,65 @@ export default function HomePage() {
<span className="h-3 w-3 rounded-full bg-red-500/70" />
<span className="h-3 w-3 rounded-full bg-yellow-500/70" />
<span className="h-3 w-3 rounded-full bg-green-500/70" />
<span className="ml-3 text-xs text-zinc-500">agent_loop.py</span>
<span className="ml-3 text-xs text-zinc-500">S01AgentLoop.java</span>
</div>
<pre className="overflow-x-auto p-4 text-sm leading-relaxed">
<code>
<span className="text-purple-400">while</span>
<span className="text-zinc-500">{"// Spring AI ChatClient 自动处理工具调用循环"}</span>
{"\n"}
<span className="text-orange-300">ChatClient</span>
<span className="text-zinc-300"> chatClient = </span>
<span className="text-orange-300">ChatClient</span>
<span className="text-zinc-500">.</span>
<span className="text-blue-400">builder</span>
<span className="text-zinc-500">(</span>
<span className="text-zinc-300">chatModel</span>
<span className="text-zinc-500">)</span>
{"\n"}
<span className="text-zinc-300">{" "}</span>
<span className="text-zinc-500">.</span>
<span className="text-blue-400">defaultSystem</span>
<span className="text-zinc-500">(</span>
<span className="text-green-400">&quot;You are a coding agent.&quot;</span>
<span className="text-zinc-500">)</span>
{"\n"}
<span className="text-zinc-300">{" "}</span>
<span className="text-zinc-500">.</span>
<span className="text-blue-400">defaultTools</span>
<span className="text-zinc-500">(</span>
<span className="text-purple-400">new</span>
<span className="text-zinc-300"> </span>
<span className="text-orange-300">True</span>
<span className="text-zinc-500">:</span>
<span className="text-orange-300">BashTool</span>
<span className="text-zinc-500">())</span>
{"\n"}
<span className="text-zinc-300">{" "}response = client.messages.</span>
<span className="text-blue-400">create</span>
<span className="text-zinc-300">{" "}</span>
<span className="text-zinc-500">.</span>
<span className="text-blue-400">build</span>
<span className="text-zinc-500">();</span>
{"\n\n"}
<span className="text-zinc-500">{"// 一次 call() 即可: 调用模型→检测工具→执行→回传"}</span>
{"\n"}
<span className="text-orange-300">String</span>
<span className="text-zinc-300"> result = chatClient.</span>
<span className="text-blue-400">prompt</span>
<span className="text-zinc-500">()</span>
{"\n"}
<span className="text-zinc-300">{" "}</span>
<span className="text-zinc-500">.</span>
<span className="text-blue-400">user</span>
<span className="text-zinc-500">(</span>
<span className="text-zinc-300">messages=</span>
<span className="text-zinc-300">messages</span>
<span className="text-zinc-500">,</span>
<span className="text-zinc-300"> tools=</span>
<span className="text-zinc-300">tools</span>
<span className="text-zinc-300">userMessage</span>
<span className="text-zinc-500">)</span>
{"\n"}
<span className="text-purple-400">{" "}if</span>
<span className="text-zinc-300"> response.stop_reason != </span>
<span className="text-green-400">&quot;tool_use&quot;</span>
<span className="text-zinc-500">:</span>
<span className="text-zinc-300">{" "}</span>
<span className="text-zinc-500">.</span>
<span className="text-blue-400">call</span>
<span className="text-zinc-500">()</span>
{"\n"}
<span className="text-purple-400">{" "}break</span>
{"\n"}
<span className="text-purple-400">{" "}for</span>
<span className="text-zinc-300"> tool_call </span>
<span className="text-purple-400">in</span>
<span className="text-zinc-300"> response.content</span>
<span className="text-zinc-500">:</span>
{"\n"}
<span className="text-zinc-300">{" "}result = </span>
<span className="text-blue-400">execute_tool</span>
<span className="text-zinc-500">(</span>
<span className="text-zinc-300">tool_call.name</span>
<span className="text-zinc-500">,</span>
<span className="text-zinc-300"> tool_call.input</span>
<span className="text-zinc-500">)</span>
{"\n"}
<span className="text-zinc-300">{" "}messages.</span>
<span className="text-blue-400">append</span>
<span className="text-zinc-500">(</span>
<span className="text-zinc-300">result</span>
<span className="text-zinc-500">)</span>
<span className="text-zinc-300">{" "}</span>
<span className="text-zinc-500">.</span>
<span className="text-blue-400">content</span>
<span className="text-zinc-500">();</span>
</code>
</pre>
</div>
@@ -172,8 +187,13 @@ export default function HomePage() {
<h3 className="mt-3 text-sm font-semibold group-hover:underline">
{meta.title}
</h3>
{tMeta(`${versionId}.title_local`) !== meta.title && (
<p className="text-xs text-[var(--color-text-secondary)]">
{tMeta(`${versionId}.title_local`)}
</p>
)}
<p className="mt-1 text-xs text-[var(--color-text-secondary)]">
{meta.keyInsight}
{tMeta(`${versionId}.keyInsight`)}
</p>
</Card>
</Link>
+1 -1
View File
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
export default function RootPage() {
redirect("/en/");
redirect("/zh/");
}
@@ -24,8 +24,8 @@ interface Decision {
title: string;
description: string;
alternatives: string;
zh?: { title: string; description: string };
ja?: { title: string; description: string };
zh?: { title: string; description: string; alternatives?: string };
ja?: { title: string; description: string; alternatives?: string };
}
interface AnnotationFile {
@@ -63,10 +63,11 @@ function DecisionCard({
const t = useTranslations("version");
const localized =
locale !== "en" ? (decision as unknown as Record<string, unknown>)[locale] as { title?: string; description?: string } | undefined : undefined;
locale !== "en" ? (decision as unknown as Record<string, unknown>)[locale] as { title?: string; description?: string; alternatives?: string } | undefined : undefined;
const title = localized?.title || decision.title;
const description = localized?.description || decision.description;
const alternatives = localized?.alternatives || decision.alternatives;
return (
<div className="rounded-lg border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900">
@@ -100,13 +101,13 @@ function DecisionCard({
{description}
</p>
{decision.alternatives && (
{alternatives && (
<div className="mt-3">
<h4 className="text-xs font-medium uppercase tracking-wide text-zinc-400 dark:text-zinc-500">
{t("alternatives")}
</h4>
<p className="mt-1 text-sm leading-relaxed text-zinc-500 dark:text-zinc-400">
{decision.alternatives}
{alternatives}
</p>
</div>
)}
+67 -2
View File
@@ -7,7 +7,7 @@ interface SourceViewerProps {
filename: string;
}
function highlightLine(line: string): React.ReactNode[] {
function highlightPythonLine(line: string): React.ReactNode[] {
const trimmed = line.trimStart();
if (trimmed.startsWith("#")) {
return [
@@ -68,7 +68,72 @@ function highlightLine(line: string): React.ReactNode[] {
});
}
function highlightJavaLine(line: string): React.ReactNode[] {
const trimmed = line.trimStart();
// 单行注释
if (trimmed.startsWith("//")) {
return [
<span key={0} className="text-zinc-400 italic">
{line}
</span>,
];
}
// 块注释行 (/* ... */, * ..., */)
if (trimmed.startsWith("/*") || trimmed.startsWith("*") || trimmed.startsWith("*/")) {
return [
<span key={0} className="text-zinc-400 italic">
{line}
</span>,
];
}
const javaKeywords = new Set([
"abstract", "assert", "boolean", "break", "byte", "case", "catch", "char",
"class", "const", "continue", "default", "do", "double", "else", "enum",
"extends", "final", "finally", "float", "for", "if", "implements", "import",
"instanceof", "int", "interface", "long", "native", "new", "package",
"private", "protected", "public", "record", "return", "short", "static",
"strictfp", "super", "switch", "synchronized", "throw", "throws",
"transient", "try", "var", "void", "volatile", "while", "yield",
"true", "false", "null",
]);
const parts = line.split(
/(\b(?:abstract|assert|boolean|break|byte|case|catch|char|class|const|continue|default|do|double|else|enum|extends|final|finally|float|for|if|implements|import|instanceof|int|interface|long|native|new|package|private|protected|public|record|return|short|static|strictfp|super|switch|synchronized|this|throw|throws|transient|try|var|void|volatile|while|yield|true|false|null)\b|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\/\/.*$|@\w+|\b\d+(?:\.\d+)?[dDfFlL]?\b)/
);
return parts.map((part, idx) => {
if (!part) return null;
if (part === "this") {
return <span key={idx} className="text-purple-400">{part}</span>;
}
if (javaKeywords.has(part)) {
return <span key={idx} className="text-blue-400 font-medium">{part}</span>;
}
if (part.startsWith("//")) {
return <span key={idx} className="text-zinc-400 italic">{part}</span>;
}
if (part.startsWith("@")) {
return <span key={idx} className="text-amber-400">{part}</span>;
}
if (
(part.startsWith('"') && part.endsWith('"')) ||
(part.startsWith("'") && part.endsWith("'"))
) {
return <span key={idx} className="text-emerald-500">{part}</span>;
}
if (/^\d+(?:\.\d+)?[dDfFlL]?$/.test(part)) {
return <span key={idx} className="text-orange-400">{part}</span>;
}
return <span key={idx}>{part}</span>;
});
}
export function SourceViewer({ source, filename }: SourceViewerProps) {
const isJava = filename.endsWith(".java");
const highlighter = isJava ? highlightJavaLine : highlightPythonLine;
const lines = useMemo(() => source.split("\n"), [source]);
return (
@@ -90,7 +155,7 @@ export function SourceViewer({ source, filename }: SourceViewerProps) {
{i + 1}
</span>
<span className="text-zinc-200">
{highlightLine(line)}
{highlighter(line)}
</span>
</div>
))}
+1 -1
View File
@@ -122,7 +122,7 @@ export function WhatsNew({ diff }: WhatsNewProps) {
{td("loc_delta")}
</h3>
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400">
+{diff.locDelta} lines
+{diff.locDelta} {td("lines")}
</p>
</div>
</Card>
+2 -2
View File
@@ -95,7 +95,7 @@ export function Header() {
</button>
<a
href="https://github.com/shareAI-lab/learn-claude-code"
href="https://github.com/abel533/learn-claude-code"
target="_blank"
rel="noopener"
className="text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-white"
@@ -151,7 +151,7 @@ export function Header() {
{dark ? <Sun size={18} /> : <Moon size={18} />}
</button>
<a
href="https://github.com/shareAI-lab/learn-claude-code"
href="https://github.com/abel533/learn-claude-code"
target="_blank"
rel="noopener"
className="flex min-h-[44px] min-w-[44px] items-center justify-center text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-white"
+1 -1
View File
@@ -16,7 +16,7 @@ const LAYER_DOT_BG: Record<string, string> = {
export function Sidebar() {
const pathname = usePathname();
const locale = pathname.split("/")[1] || "en";
const locale = pathname.split("/")[1] || "zh";
const t = useTranslations("sessions");
const tLayer = useTranslations("layer_labels");
+6 -4
View File
@@ -45,6 +45,8 @@ const MAX_LOC = Math.max(
export function Timeline() {
const t = useTranslations("timeline");
const tv = useTranslations("version");
const tMeta = useTranslations("version_meta");
const tLayer = useTranslations("layer_labels");
const locale = useLocale();
return (
@@ -60,7 +62,7 @@ export function Timeline() {
<span
className={cn("h-3 w-3 rounded-full", LAYER_DOT_BG[layer.id])}
/>
<span className="text-xs font-medium">{layer.label}</span>
<span className="text-xs font-medium">{tLayer(layer.id)}</span>
</div>
))}
</div>
@@ -114,14 +116,14 @@ export function Timeline() {
<div className="flex flex-wrap items-start gap-2">
<LayerBadge layer={meta.layer}>{versionId}</LayerBadge>
<span className="text-xs text-[var(--color-text-secondary)]">
{meta.coreAddition}
{tMeta(`${versionId}.coreAddition`)}
</span>
</div>
<h3 className="mt-2 text-base font-semibold sm:text-lg">
{meta.title}
<span className="ml-2 text-sm font-normal text-[var(--color-text-secondary)]">
{meta.subtitle}
{tMeta(`${versionId}.subtitle`)}
</span>
</h3>
@@ -149,7 +151,7 @@ export function Timeline() {
{/* Key insight */}
{meta.keyInsight && (
<p className="mt-3 text-sm italic text-[var(--color-text-secondary)]">
&ldquo;{meta.keyInsight}&rdquo;
&ldquo;{tMeta(`${versionId}.keyInsight`)}&rdquo;
</p>
)}
@@ -20,7 +20,7 @@ interface FlowNode {
const NODES: FlowNode[] = [
{ id: "start", label: "Start", x: 160, y: 30, w: 120, h: 40, type: "rect" },
{ id: "api_call", label: "API Call", x: 160, y: 110, w: 120, h: 40, type: "rect" },
{ id: "check", label: "stop_reason?", x: 160, y: 200, w: 140, h: 50, type: "diamond" },
{ id: "check", label: "Tool calls?", x: 160, y: 200, w: 140, h: 50, type: "diamond" },
{ id: "execute", label: "Execute Tool", x: 160, y: 300, w: 120, h: 40, type: "rect" },
{ id: "append", label: "Append Result", x: 160, y: 380, w: 120, h: 40, type: "rect" },
{ id: "end", label: "Break / Done", x: 380, y: 200, w: 120, h: 40, type: "rect" },
@@ -36,10 +36,10 @@ interface FlowEdge {
const EDGES: FlowEdge[] = [
{ from: "start", to: "api_call" },
{ from: "api_call", to: "check" },
{ from: "check", to: "execute", label: "tool_use" },
{ from: "check", to: "execute", label: "Yes" },
{ from: "execute", to: "append" },
{ from: "append", to: "api_call" },
{ from: "check", to: "end", label: "end_turn" },
{ from: "check", to: "end", label: "No" },
];
// Which nodes light up at each step
@@ -91,10 +91,10 @@ const STEP_INFO = [
{ title: "The While Loop", desc: "Every agent is a while loop that keeps calling the model until it says 'stop'." },
{ title: "User Input", desc: "The loop starts when the user sends a message." },
{ title: "Call the Model", desc: "Send all messages to the LLM. It sees everything and decides what to do." },
{ title: "stop_reason: tool_use", desc: "The model wants to use a tool. The loop continues." },
{ title: "Tool call detected", desc: "The model wants to use a tool. The loop continues." },
{ title: "Execute & Append", desc: "Run the tool, append the result to messages[]. Feed it back." },
{ title: "Loop Again", desc: "Same code path, second iteration. The model decides to edit a file." },
{ title: "stop_reason: end_turn", desc: "The model is done. Loop exits. That's the entire agent." },
{ title: "No tool calls → return text", desc: "The model is done. Loop exits. That's the entire agent." },
];
// -- Helpers --
@@ -171,7 +171,7 @@ export default function AgentLoop({ title }: { title?: string }) {
{/* Left panel: SVG Flowchart (60%) */}
<div className="w-full lg:w-[60%]">
<div className="mb-2 font-mono text-xs text-zinc-400 dark:text-zinc-500">
while (stop_reason === "tool_use")
chatClient.prompt().call().content()
</div>
<svg
viewBox="0 0 500 440"
@@ -66,12 +66,12 @@ const REQUEST_PER_STEP: (string | null)[] = [
// Step annotations
const STEP_INFO = [
{ title: "The Dispatch Map", desc: "A dictionary maps tool names to handler functions. The loop code never changes." },
{ title: "Route: bash", desc: "tool_call.name -> handlers['bash'](input). Name-based routing." },
{ title: "Route: read_file", desc: "Same pattern, different handler. Validate input, execute, return result." },
{ title: "Route: write_file", desc: "Every tool returns a tool_result that goes back into messages[]." },
{ title: "Route: edit_file", desc: "Adding a new tool = adding one entry to the dispatch map." },
{ title: "The Key Insight", desc: "The while loop stays the same. You only grow the dispatch map. That's it." },
{ title: "The @Tool Registry", desc: "@Tool annotated methods are registered via defaultTools(). The loop code never changes." },
{ title: "Route: bash", desc: "ChatClient detects tool_call → invokes @Tool method. Name-based routing." },
{ title: "Route: read_file", desc: "Same pattern, different @Tool method. Validate input, execute, return result." },
{ title: "Route: write_file", desc: "Every @Tool returns a result that Spring AI feeds back to the model." },
{ title: "Route: edit_file", desc: "Adding a new tool = adding one @Tool annotated class to defaultTools()." },
{ title: "The Key Insight", desc: "The call() loop stays the same. You only grow the @Tool registry. That's it." },
];
// SVG layout constants
@@ -46,7 +46,7 @@ const STEPS: StepState[] = [
],
worktrees: [],
lanes: [
{ name: "main", files: ["auth/service.py", "ui/Login.tsx"], highlight: true },
{ name: "main", files: ["auth/AuthService.java", "ui/Login.tsx"], highlight: true },
{ name: "wt/auth-refactor", files: [] },
{ name: "wt/ui-login", files: [] },
],
@@ -64,7 +64,7 @@ const STEPS: StepState[] = [
],
lanes: [
{ name: "main", files: ["ui/Login.tsx"] },
{ name: "wt/auth-refactor", files: ["auth/service.py"], highlight: true },
{ name: "wt/auth-refactor", files: ["auth/AuthService.java"], highlight: true },
{ name: "wt/ui-login", files: [] },
],
},
@@ -82,7 +82,7 @@ const STEPS: StepState[] = [
],
lanes: [
{ name: "main", files: [] },
{ name: "wt/auth-refactor", files: ["auth/service.py"] },
{ name: "wt/auth-refactor", files: ["auth/AuthService.java"] },
{ name: "wt/ui-login", files: ["ui/Login.tsx"], highlight: true },
],
},
@@ -100,7 +100,7 @@ const STEPS: StepState[] = [
],
lanes: [
{ name: "main", files: [] },
{ name: "wt/auth-refactor", files: ["auth/service.py", "tests/auth/test_login.py"], highlight: true },
{ name: "wt/auth-refactor", files: ["auth/AuthService.java", "tests/auth/AuthServiceTest.java"], highlight: true },
{ name: "wt/ui-login", files: ["ui/Login.tsx", "ui/Login.css"] },
],
},
+13 -7
View File
@@ -8,25 +8,29 @@
"alternatives": "We could have started with a richer toolset (file I/O, HTTP, database), but that would obscure the core insight: an LLM with a shell is already a general-purpose agent. Starting minimal also makes it obvious what each subsequent version actually adds.",
"zh": {
"title": "为什么仅靠 Bash 就够了",
"description": "Bash 能读写文件、运行任意程序、在进程间传递数据、管理文件系统。任何额外的工具(read_file、write_file 等)都只是 bash 已有能力的子集。增加工具并不会解锁新能力,只会增加模型需要理解的接口。模型只需学习一个工具的 schema,实现代码不超过 100 行。这就是最小可行 agent:一个工具,一个循环。"
"description": "Bash 能读写文件、运行任意程序、在进程间传递数据、管理文件系统。任何额外的工具(read_file、write_file 等)都只是 bash 已有能力的子集。增加工具并不会解锁新能力,只会增加模型需要理解的接口。模型只需学习一个工具的 schema,实现代码不超过 100 行。这就是最小可行 agent:一个工具,一个循环。",
"alternatives": "我们可以从更丰富的工具集开始(文件 I/O、HTTP、数据库),但那会掩盖核心洞察:一个配备 shell 的 LLM 已经是一个通用 agent。从最小化开始,也让每个后续版本真正新增了什么变得一目了然。"
},
"ja": {
"title": "Bash だけで十分な理由",
"description": "Bash はファイルの読み書き、任意のプログラムの実行、プロセス間のデータパイプ、ファイルシステムの管理が可能です。追加のツール(read_file、write_file など)は bash が既に提供している機能の部分集合に過ぎません。ツールを増やしても新しい能力は得られず、モデルが理解すべきインターフェースが増えるだけです。モデルが学習するスキーマは1つだけで、実装は100行以内に収まります。これが最小限の実用的エージェント:1つのツール、1つのループです。"
"description": "Bash はファイルの読み書き、任意のプログラムの実行、プロセス間のデータパイプ、ファイルシステムの管理が可能です。追加のツール(read_file、write_file など)は bash が既に提供している機能の部分集合に過ぎません。ツールを増やしても新しい能力は得られず、モデルが理解すべきインターフェースが増えるだけです。モデルが学習するスキーマは1つだけで、実装は100行以内に収まります。これが最小限の実用的エージェント:1つのツール、1つのループです。",
"alternatives": "より豊富なツールセット(ファイル I/O、HTTP、データベース)から始めることもできましたが、それではコアの洞察が曖昧になります:シェルを持つ LLM は既に汎用エージェントです。最小限から始めることで、後続の各バージョンが実際に何を追加しているかが明確になります。"
}
},
{
"id": "process-as-subagent",
"title": "Recursive Process Spawning as Subagent Mechanism",
"description": "When the agent runs `python v0.py \"subtask\"`, it spawns a completely new process with a fresh LLM context. This child process is effectively a subagent: it has its own system prompt, its own conversation history, and its own task focus. When it finishes, the parent gets the stdout result. This is subagent delegation without any framework -- just Unix process semantics. Each child process naturally isolates concerns because it literally cannot see the parent's context.",
"description": "When the agent runs `java -jar agent.jar \"subtask\"`, it uses ProcessBuilder to spawn a completely new JVM process with a fresh LLM context. This child process is effectively a subagent: it has its own system prompt, its own conversation history, and its own task focus. When it finishes, the parent gets the stdout result. This is subagent delegation without any framework -- just process semantics. Each child process naturally isolates concerns because it literally cannot see the parent's context.",
"alternatives": "A framework-level subagent system (like v3's Task tool) gives more control over what tools the subagent can access and how results are returned. But at v0, the point is to show that process spawning is the most primitive form of agent delegation -- no shared memory, no message passing, just stdin/stdout.",
"zh": {
"title": "用递归进程创建实现子代理机制",
"description": "当 agent 执行 `python v0.py \"subtask\"` 时,它会创建一个全新的进程,拥有全新的 LLM 上下文。这个子进程实际上就是一个子代理:有自己的系统提示词、对话历史和任务焦点。子进程完成后,父进程通过 stdout 获取结果。这就是不依赖任何框架的子代理委派——纯粹的 Unix 进程语义。每个子进程天然隔离关注点,因为它根本看不到父进程的上下文。"
"description": "当 agent 执行 `java -jar agent.jar \"subtask\"` 时,它通过 ProcessBuilder 创建一个全新的 JVM 进程,拥有全新的 LLM 上下文。这个子进程实际上就是一个子代理:有自己的系统提示词、对话历史和任务焦点。子进程完成后,父进程通过 stdout 获取结果。这就是不依赖任何框架的子代理委派——纯粹的进程语义。每个子进程天然隔离关注点,因为它根本看不到父进程的上下文。",
"alternatives": "框架级的子代理系统(如 v3 的 Task 工具)可以更精确地控制子代理能访问哪些工具以及结果如何返回。但在 v0 阶段,关键是展示进程创建是最原始的代理委派形式——没有共享内存,没有消息传递,只有 stdin/stdout。"
},
"ja": {
"title": "再帰プロセス生成によるサブエージェント機構",
"description": "エージェントが `python v0.py \"subtask\"` を実行すると、新しい LLM コンテキストを持つ完全に新しいプロセスが生成されます。この子プロセスは事実上サブエージェントです:独自のシステムプロンプト、会話履歴、タスクフォーカスを持ちます。完了すると、親プロセスは stdout で結果を受け取ります。これはフレームワークなしのサブエージェント委任です——共有メモリもメッセージパッシングもなく、stdin/stdout だけです。各子プロセスは親のコンテキストを参照できないため、関心の分離が自然に実現されます。"
"description": "エージェントが `java -jar agent.jar \"subtask\"` を実行すると、ProcessBuilder により新しい LLM コンテキストを持つ完全に新しい JVM プロセスが生成されます。この子プロセスは事実上サブエージェントです:独自のシステムプロンプト、会話履歴、タスクフォーカスを持ちます。完了すると、親プロセスは stdout で結果を受け取ります。これはフレームワークなしのサブエージェント委任です——共有メモリもメッセージパッシングもなく、stdin/stdout だけです。各子プロセスは親のコンテキストを参照できないため、関心の分離が自然に実現されます。",
"alternatives": "フレームワークレベルのサブエージェントシステム(v3 の Task ツールなど)は、サブエージェントがアクセスできるツールや結果の返し方をより細かく制御できます。しかし v0 の段階では、プロセス生成がエージェント委任の最も原始的な形態であることを示すのがポイントです——共有メモリもメッセージパッシングもなく、stdin/stdout だけです。"
}
},
{
@@ -36,11 +40,13 @@
"alternatives": "Later versions (v2) add explicit planning via TodoWrite. But v0 proves that implicit planning through the model's reasoning is sufficient for many tasks. The planning framework only becomes necessary when you need external visibility into the agent's intentions.",
"zh": {
"title": "没有规划框架——由模型自行决策",
"description": "没有规划器,没有任务队列,没有状态机。系统提示词告诉模型如何处理问题,模型根据对话历史决定下一步执行什么 bash 命令。这是有意为之的:在这个层级,添加规划层属于过早抽象。模型的思维链本身就是计划。agent 循环只是不断询问模型下一步做什么,直到模型不再请求工具为止。"
"description": "没有规划器,没有任务队列,没有状态机。系统提示词告诉模型如何处理问题,模型根据对话历史决定下一步执行什么 bash 命令。这是有意为之的:在这个层级,添加规划层属于过早抽象。模型的思维链本身就是计划。agent 循环只是不断询问模型下一步做什么,直到模型不再请求工具为止。",
"alternatives": "后续版本(v2)通过 TodoWrite 添加了显式规划。但 v0 证明了通过模型推理进行的隐式规划对许多任务已经足够。只有当你需要从外部观察 agent 的意图时,规划框架才变得必要。"
},
"ja": {
"title": "計画フレームワークなし——モデルが全てを決定",
"description": "プランナーもタスクキューも状態マシンもありません。システムプロンプトがモデルに問題の取り組み方を伝え、モデルがこれまでの会話に基づいて次に実行する bash コマンドを決定します。これは意図的な設計です:このレベルでは計画レイヤーの追加は時期尚早な抽象化です。モデルの思考の連鎖そのものが計画です。エージェントループはモデルがツールの呼び出しを止めるまで、次の行動を問い続けるだけです。"
"description": "プランナーもタスクキューも状態マシンもありません。システムプロンプトがモデルに問題の取り組み方を伝え、モデルがこれまでの会話に基づいて次に実行する bash コマンドを決定します。これは意図的な設計です:このレベルでは計画レイヤーの追加は時期尚早な抽象化です。モデルの思考の連鎖そのものが計画です。エージェントループはモデルがツールの呼び出しを止めるまで、次の行動を問い続けるだけです。",
"alternatives": "後のバージョン(v2)では TodoWrite による明示的な計画が追加されます。しかし v0 は、モデルの推論による暗黙的な計画が多くのタスクに十分であることを証明しています。計画フレームワークが必要になるのは、エージェントの意図を外部から可視化する必要がある場合だけです。"
}
}
]
+12 -6
View File
@@ -8,11 +8,13 @@
"alternatives": "We could add specialized tools (list_directory, search_files, http_request), and later versions do. But at this stage, bash already covers those use cases. The split from v0's single tool to v1's four tools is specifically about giving the model structured I/O for file operations, where bash's quoting and escaping often trips up the model.",
"zh": {
"title": "为什么恰好四个工具",
"description": "四个工具分别是 bash、read_file、write_file 和 edit_file,覆盖了大约 95% 的编程任务。Bash 处理执行和任意命令;read_file 提供带行号的精确文件读取;write_file 创建或覆盖文件;edit_file 做精确的字符串替换。工具越多,模型的认知负担越重——它必须在更多选项中做选择,选错的概率也随之增加。更少的工具也意味着更少的 schema 需要维护、更少的边界情况需要处理。"
"description": "四个工具分别是 bash、read_file、write_file 和 edit_file,覆盖了大约 95% 的编程任务。Bash 处理执行和任意命令;read_file 提供带行号的精确文件读取;write_file 创建或覆盖文件;edit_file 做精确的字符串替换。工具越多,模型的认知负担越重——它必须在更多选项中做选择,选错的概率也随之增加。更少的工具也意味着更少的 schema 需要维护、更少的边界情况需要处理。",
"alternatives": "我们可以添加专用工具(list_directory、search_files、http_request),后续版本确实这么做了。但在当前阶段,bash 已经覆盖了这些用例。从 v0 的单一工具拆分为 v1 的四个工具,专门是为了给模型提供结构化的文件 I/O 操作,因为 bash 的引号和转义经常让模型出错。"
},
"ja": {
"title": "なぜ正確に4つのツールなのか",
"description": "4つのツールは bash、read_file、write_file、edit_file です。これらでコーディングタスクの約95%をカバーします。Bash は実行と任意のコマンドを処理し、read_file は行番号付きの正確なファイル読み取りを提供し、write_file はファイルの作成・上書きを行い、edit_file は外科的な文字列置換を行います。ツールが増えるとモデルの認知負荷が増大し、どのツールを使うかの判断でミスが増えます。ツールが少ないことは、メンテナンスすべきスキーマとエッジケースの削減も意味します。"
"description": "4つのツールは bash、read_file、write_file、edit_file です。これらでコーディングタスクの約95%をカバーします。Bash は実行と任意のコマンドを処理し、read_file は行番号付きの正確なファイル読み取りを提供し、write_file はファイルの作成・上書きを行い、edit_file は外科的な文字列置換を行います。ツールが増えるとモデルの認知負荷が増大し、どのツールを使うかの判断でミスが増えます。ツールが少ないことは、メンテナンスすべきスキーマとエッジケースの削減も意味します。",
"alternatives": "専用ツール(list_directory、search_files、http_request)を追加することもでき、後のバージョンではそうしています。しかしこの段階では bash が既にそれらのユースケースをカバーしています。v0 の単一ツールから v1 の4つのツールへの分割は、特にファイル操作に構造化された I/O を提供するためです。bash のクォーティングとエスケープはモデルを頻繁につまずかせるからです。"
}
},
{
@@ -22,11 +24,13 @@
"alternatives": "Many agent frameworks add elaborate orchestration layers: ReAct loops with explicit Thought/Action/Observation parsing, LangChain-style chains, AutoGPT-style goal decomposition. These frameworks assume the model needs scaffolding to behave as an agent. Our approach assumes the model already knows how to be an agent -- it just needs tools to act on the world.",
"zh": {
"title": "模型本身就是代理",
"description": "核心 agent 循环极其简单:不断调用 LLM,如果返回 tool_use 块就执行并回传结果,如果只返回文本就停止。没有路由器,没有决策树,没有工作流引擎。模型自己决定做什么、何时停止、如何从错误中恢复。代码只是连接模型和工具的管道。这是一种设计哲学:agent 行为从模型中涌现,而非由框架定义。"
"description": "核心 agent 循环极其简单:不断调用 LLM,如果返回 tool_use 块就执行并回传结果,如果只返回文本就停止。没有路由器,没有决策树,没有工作流引擎。模型自己决定做什么、何时停止、如何从错误中恢复。代码只是连接模型和工具的管道。这是一种设计哲学:agent 行为从模型中涌现,而非由框架定义。",
"alternatives": "许多 agent 框架添加了复杂的编排层:带有显式 Thought/Action/Observation 解析的 ReAct 循环、LangChain 风格的链、AutoGPT 风格的目标分解。这些框架假设模型需要脚手架才能作为 agent 运行。我们的方法假设模型已经知道如何成为 agent——它只需要工具来作用于世界。"
},
"ja": {
"title": "モデルそのものがエージェント",
"description": "コアのエージェントループは極めてシンプルです:LLM を呼び出し続け、tool_use ブロックが返されればそれを実行して結果をフィードバックし、テキストのみが返されれば停止します。ルーターも決定木もワークフローエンジンもありません。モデル自体が何をすべきか、いつ停止するか、エラーからどう回復するかを決定します。コードはモデルとツールを接続する配管に過ぎません。これは設計思想です:エージェントの振る舞いはフレームワークではなくモデルから創発するものです。"
"description": "コアのエージェントループは極めてシンプルです:LLM を呼び出し続け、tool_use ブロックが返されればそれを実行して結果をフィードバックし、テキストのみが返されれば停止します。ルーターも決定木もワークフローエンジンもありません。モデル自体が何をすべきか、いつ停止するか、エラーからどう回復するかを決定します。コードはモデルとツールを接続する配管に過ぎません。これは設計思想です:エージェントの振る舞いはフレームワークではなくモデルから創発するものです。",
"alternatives": "多くのエージェントフレームワークは精巧なオーケストレーション層を追加します:明示的な Thought/Action/Observation パースを持つ ReAct ループ、LangChain スタイルのチェーン、AutoGPT スタイルの目標分解。これらのフレームワークはモデルがエージェントとして振る舞うためにスキャフォールディングが必要だと仮定しています。我々のアプローチはモデルが既にエージェントの振る舞い方を知っていると仮定します——必要なのは世界に作用するためのツールだけです。"
}
},
{
@@ -36,11 +40,13 @@
"alternatives": "Some agent systems let the model output free-form text that gets parsed with regex or heuristics (e.g., extracting code from markdown blocks). This is fragile -- the model might format output slightly differently and break the parser. JSON schemas trade flexibility for reliability.",
"zh": {
"title": "每个工具都有 JSON Schema",
"description": "每个工具都为输入参数定义了严格的 JSON schema。例如,edit_file 要求 old_string 和 new_string 是精确的字符串,而非正则表达式。这消除了一整类错误:模型无法传递格式错误的输入,因为 API 会在执行前校验 schema。这也使模型的意图变得明确——当它用特定字符串调用 edit_file 时,不存在关于它想修改什么的解析歧义。"
"description": "每个工具都为输入参数定义了严格的 JSON schema。例如,edit_file 要求 old_string 和 new_string 是精确的字符串,而非正则表达式。这消除了一整类错误:模型无法传递格式错误的输入,因为 API 会在执行前校验 schema。这也使模型的意图变得明确——当它用特定字符串调用 edit_file 时,不存在关于它想修改什么的解析歧义。",
"alternatives": "一些 agent 系统让模型输出自由格式的文本,然后用正则表达式或启发式方法解析(例如从 markdown 块中提取代码)。这很脆弱——模型可能稍微改变输出格式就会破坏解析器。JSON Schema 用灵活性换取了可靠性。"
},
"ja": {
"title": "全ツールに JSON Schema を定義",
"description": "各ツールは入力パラメータに対して厳密な JSON Schema を定義しています。例えば edit_file は old_string と new_string を正確な文字列として要求し、正規表現は使いません。これにより一連のバグを排除できます:API がスキーマに対して実行前にバリデーションを行うため、モデルは不正な入力を渡せません。モデルの意図も明確になります――特定の文字列で edit_file を呼び出す際、何を変更したいかについて解析の曖昧さがありません。"
"description": "各ツールは入力パラメータに対して厳密な JSON Schema を定義しています。例えば edit_file は old_string と new_string を正確な文字列として要求し、正規表現は使いません。これにより一連のバグを排除できます:API がスキーマに対して実行前にバリデーションを行うため、モデルは不正な入力を渡せません。モデルの意図も明確になります――特定の文字列で edit_file を呼び出す際、何を変更したいかについて解析の曖昧さがありません。",
"alternatives": "一部のエージェントシステムはモデルに自由形式のテキストを出力させ、正規表現やヒューリスティクスで解析します(例:markdown ブロックからコードを抽出)。これは脆弱です——モデルが出力を微妙に異なるフォーマットにするとパーサーが壊れます。JSON Schema は柔軟性を犠牲にして信頼性を得ています。"
}
}
]
+12 -6
View File
@@ -8,11 +8,13 @@
"alternatives": "The model could plan internally via chain-of-thought reasoning (as it does in v0/v1). Internal planning works but is invisible and ephemeral -- once the thinking scrolls out of context, the plan is lost. Claude's extended thinking is another option, but it's not inspectable by the user or by downstream tools.",
"zh": {
"title": "通过 TodoWrite 让计划可见",
"description": "我们不让模型在思维链中默默规划,而是强制通过 TodoWrite 工具将计划外化。每个计划项都有可追踪的状态(pending、in_progress、completed)。这有三个好处:(1) 用户可以在执行前看到 agent 打算做什么;(2) 开发者可以通过检查计划状态来调试 agent 行为;(3) agent 自身可以在后续轮次中引用计划,即使早期上下文已经滚出窗口。"
"description": "我们不让模型在思维链中默默规划,而是强制通过 TodoWrite 工具将计划外化。每个计划项都有可追踪的状态(pending、in_progress、completed)。这有三个好处:(1) 用户可以在执行前看到 agent 打算做什么;(2) 开发者可以通过检查计划状态来调试 agent 行为;(3) agent 自身可以在后续轮次中引用计划,即使早期上下文已经滚出窗口。",
"alternatives": "模型可以通过思维链推理进行内部规划(如 v0/v1 中那样)。内部规划可行,但不可见且转瞬即逝——一旦思考内容滚出上下文窗口,计划就丢失了。Claude 的扩展思考是另一个选项,但用户和下游工具无法检查它。"
},
"ja": {
"title": "TodoWrite による計画の可視化",
"description": "モデルが思考の連鎖の中で黙って計画するのではなく、TodoWrite ツールを通じて計画を外部化することを強制します。各計画項目には追跡可能なステータス(pending、in_progress、completed)があります。利点は3つ:(1) ユーザーがエージェントの意図を実行前に確認できる、(2) 開発者が計画状態を検査してデバッグできる、(3) エージェント自身が以前のコンテキストがスクロールアウトした後でも計画を参照できる。"
"description": "モデルが思考の連鎖の中で黙って計画するのではなく、TodoWrite ツールを通じて計画を外部化することを強制します。各計画項目には追跡可能なステータス(pending、in_progress、completed)があります。利点は3つ:(1) ユーザーがエージェントの意図を実行前に確認できる、(2) 開発者が計画状態を検査してデバッグできる、(3) エージェント自身が以前のコンテキストがスクロールアウトした後でも計画を参照できる。",
"alternatives": "モデルは v0/v1 のように思考の連鎖による内部計画が可能です。内部計画は機能しますが、不可視で一時的です——思考がコンテキストからスクロールアウトすると計画は失われます。Claude の拡張思考は別の選択肢ですが、ユーザーや下流ツールからは検査できません。"
}
},
{
@@ -22,11 +24,13 @@
"alternatives": "Allowing multiple in-progress items would let the agent context-switch between tasks, which seems more flexible. In practice, LLMs handle context-switching poorly -- they lose track of which task they were working on and mix up details between tasks. The single-focus constraint is a guardrail that improves output quality.",
"zh": {
"title": "同一时间只允许一个任务进行中",
"description": "TodoWrite 工具强制要求任何时候最多只能有一个任务处于 in_progress 状态。如果模型想开始第二个任务,必须先完成或放弃当前任务。这个约束防止了一种隐蔽的失败模式:试图通过交替处理多个项目来'多任务'的模型,往往会丢失状态并产出半成品。顺序执行的专注度远高于并行切换。"
"description": "TodoWrite 工具强制要求任何时候最多只能有一个任务处于 in_progress 状态。如果模型想开始第二个任务,必须先完成或放弃当前任务。这个约束防止了一种隐蔽的失败模式:试图通过交替处理多个项目来'多任务'的模型,往往会丢失状态并产出半成品。顺序执行的专注度远高于并行切换。",
"alternatives": "允许多个进行中的项目可以让 agent 在任务间切换,看似更灵活。但实际上,LLM 处理上下文切换很差——它们会忘记自己在处理哪个任务,并混淆不同任务间的细节。单一焦点约束是一个提升输出质量的护栏。"
},
"ja": {
"title": "同時に進行中にできるタスクは1つだけ",
"description": "TodoWrite ツールは、同時に 'in_progress' 状態のタスクを最大1つに制限します。モデルが2つ目のタスクを開始しようとする場合、まず現在のタスクを完了または中断する必要があります。この制約は微妙な失敗モードを防ぎます:複数の項目を交互に処理して「マルチタスク」しようとするモデルは、状態を見失い中途半端な結果を生みがちです。逐次的な集中は並行的な切り替えよりも高品質な出力を生み出します。"
"description": "TodoWrite ツールは、同時に 'in_progress' 状態のタスクを最大1つに制限します。モデルが2つ目のタスクを開始しようとする場合、まず現在のタスクを完了または中断する必要があります。この制約は微妙な失敗モードを防ぎます:複数の項目を交互に処理して「マルチタスク」しようとするモデルは、状態を見失い中途半端な結果を生みがちです。逐次的な集中は並行的な切り替えよりも高品質な出力を生み出します。",
"alternatives": "複数の進行中項目を許可すればエージェントがタスク間を切り替えられ、より柔軟に見えます。しかし実際には、LLM はコンテキストスイッチングを苦手とし、どのタスクに取り組んでいたかを見失い、タスク間の詳細を混同します。単一フォーカスの制約は出力品質を向上させるガードレールです。"
}
},
{
@@ -36,11 +40,13 @@
"alternatives": "No cap would give the model full flexibility, but in practice leads to absurdly detailed plans. A dynamic cap (proportional to task complexity) would be smarter but adds complexity. The fixed cap of 20 is a simple heuristic that works well empirically -- most real coding tasks can be expressed in 5-15 meaningful steps.",
"zh": {
"title": "计划项上限为 20 条",
"description": "TodoWrite 将计划项限制在 20 条以内。这是对过度规划的刻意约束。不加限制时,模型倾向于将任务分解成越来越细粒度的步骤,产出 50 条的计划,每一步都微不足道。冗长的计划很脆弱:如果第 15 步失败,剩下的 35 步可能全部作废。20 条以内的短计划保持在正确的抽象层级,更容易在现实偏离计划时做出调整。"
"description": "TodoWrite 将计划项限制在 20 条以内。这是对过度规划的刻意约束。不加限制时,模型倾向于将任务分解成越来越细粒度的步骤,产出 50 条的计划,每一步都微不足道。冗长的计划很脆弱:如果第 15 步失败,剩下的 35 步可能全部作废。20 条以内的短计划保持在正确的抽象层级,更容易在现实偏离计划时做出调整。",
"alternatives": "不设上限可以给模型完全的灵活性,但实际上会导致荒谬的详细计划。动态上限(与任务复杂度成比例)更聪明但增加了复杂性。固定的 20 条上限是一个简单的启发式规则,在实践中效果很好——大多数真实编程任务可以用 5-15 个有意义的步骤来表达。"
},
"ja": {
"title": "計画項目の上限は20個",
"description": "TodoWrite は計画を20項目に制限します。これは過度な計画に対する意図的な制約です。制約がないとモデルはタスクをどんどん細かいステップに分解し、各ステップが些末な50項目の計画を作りがちです。長い計画は脆弱です:ステップ15が失敗すると残りの35ステップは全て無効になりかねません。20項目以内の短い計画は適切な抽象度を保ち、現実が計画から逸脱した際の適応が容易です。"
"description": "TodoWrite は計画を20項目に制限します。これは過度な計画に対する意図的な制約です。制約がないとモデルはタスクをどんどん細かいステップに分解し、各ステップが些末な50項目の計画を作りがちです。長い計画は脆弱です:ステップ15が失敗すると残りの35ステップは全て無効になりかねません。20項目以内の短い計画は適切な抽象度を保ち、現実が計画から逸脱した際の適応が容易です。",
"alternatives": "上限なしはモデルに完全な柔軟性を与えますが、実際には非現実的に詳細な計画につながります。動的な上限(タスクの複雑さに比例)はより賢明ですが複雑さが増します。20の固定上限はシンプルなヒューリスティクスで、経験的にうまく機能します——ほとんどの実際のコーディングタスクは5〜15の意味あるステップで表現できます。"
}
}
]

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