chore: Spring AI 重构
This commit is contained in:
+266
-64
@@ -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` は全レッスン共通の REPL(Read-Eval-Print Loop)ユーティリティクラスで、Python の `if __name__ == "__main__"` 内の `input()` ループに相当する。
|
||||
|
||||
```java
|
||||
public class AgentRunner {
|
||||
/**
|
||||
* インタラクティブ REPL ループを開始。
|
||||
* @param prefix プロンプトプレフィックス(例: "s01")
|
||||
* @param handler ユーザー入力を処理し Agent レスポンスを返す関数
|
||||
*/
|
||||
public static void interactive(String prefix, Function<String, String> handler) {
|
||||
Scanner scanner = new Scanner(System.in);
|
||||
System.out.println("'q' または 'exit' で終了");
|
||||
while (true) {
|
||||
System.out.print("\033[36m" + prefix + " >> \033[0m"); // カラープロンプト
|
||||
String input;
|
||||
try {
|
||||
if (!scanner.hasNextLine()) break;
|
||||
input = scanner.nextLine().trim();
|
||||
} catch (Exception e) {
|
||||
break;
|
||||
}
|
||||
if (input.isEmpty() || "exit".equalsIgnoreCase(input) || "q".equalsIgnoreCase(input)) {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
String response = handler.apply(input); // Agent ハンドラーを呼び出し
|
||||
if (response != null && !response.isBlank()) {
|
||||
System.out.println(response);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.err.println("Error: " + e.getMessage());
|
||||
}
|
||||
System.out.println();
|
||||
}
|
||||
System.out.println("Bye!");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
ワークフロー:`Scanner` で入力読み取り → `handler.apply()` で Agent に送信 → レスポンス出力 → ループ。`handler` は関数型インターフェースで、各レッスンが自分の Agent 呼び出しロジックを渡す。
|
||||
|
||||
### 5. 完全な Agent クラスとして組み立て
|
||||
|
||||
```java
|
||||
@SpringBootApplication(scanBasePackages = "io.mybatis.learn.core")
|
||||
public class S01AgentLoop implements CommandLineRunner {
|
||||
|
||||
private final ChatClient chatClient;
|
||||
|
||||
public S01AgentLoop(ChatModel chatModel) {
|
||||
this.chatClient = ChatClient.builder(chatModel)
|
||||
.defaultSystem("You are a coding agent at ...")
|
||||
.defaultTools(new BashTool())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
AgentRunner.interactive("s01", userMessage ->
|
||||
chatClient.prompt()
|
||||
.user(userMessage)
|
||||
.call() // ← この1つの呼び出し = Python の while ループ全体
|
||||
.content()
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **TIPS — Python → Java 主要な適応ポイント:**
|
||||
> - Python の `while True` + `stop_reason` 手動ループ → Spring AI `ChatClient.call()` 内蔵自動ループ
|
||||
> - Python の `TOOLS` 配列 + `TOOL_HANDLERS` 辞書 → `@Tool` アノテーション + `defaultTools()` 自動登録とリフレクションディスパッチ
|
||||
> - Python の `client = Anthropic()` → Spring Boot 自動設定で `ChatModel` を注入
|
||||
> - Python の `input()` インタラクション → `AgentRunner.interactive()` が Scanner REPL + 関数型インターフェースをカプセル化
|
||||
|
||||
コアコード40行未満、これがエージェント全体だ。残り11章はすべてこのループの上にメカニズムを積み重ねる -- ループ自体は決して変わらない。
|
||||
|
||||
## 変更点
|
||||
|
||||
| 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` を変更するだけで接続でき、プロトコル切り替えは不要。
|
||||
|
||||
#### 方式 2:Anthropic プロトコル(Claude ネイティブ API)
|
||||
|
||||
**ステップ 1**:`pom.xml` を編集 — OpenAI starter を Anthropic starter に置き換え:
|
||||
|
||||
```xml
|
||||
<!-- OpenAI starter をコメントアウトまたは削除 -->
|
||||
<!-- <dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-starter-model-openai</artifactId>
|
||||
</dependency> -->
|
||||
|
||||
<!-- Anthropic starter を追加 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-starter-model-anthropic</artifactId>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
**ステップ 2**:`application.yml` を編集 — `spring.ai.openai` を `spring.ai.anthropic` に置き換え:
|
||||
|
||||
```yaml
|
||||
spring:
|
||||
ai:
|
||||
anthropic:
|
||||
api-key: ${AI_API_KEY}
|
||||
base-url: ${AI_BASE_URL:https://api.anthropic.com}
|
||||
chat:
|
||||
options:
|
||||
model: ${AI_MODEL:claude-sonnet-4-20250514}
|
||||
```
|
||||
|
||||
**ステップ 3**:環境変数を設定:
|
||||
|
||||
```sh
|
||||
export AI_API_KEY=sk-ant-xxxxxxxx
|
||||
export AI_BASE_URL=https://api.anthropic.com
|
||||
export AI_MODEL=claude-sonnet-4-20250514
|
||||
```
|
||||
|
||||
#### 切り替えの仕組み
|
||||
|
||||
Spring AI の `ChatModel` は統一された抽象インターフェース。異なる Starter が異なる実装を提供する:
|
||||
|
||||
| Starter 依存 | 自動注入される ChatModel 実装 | 設定プレフィックス |
|
||||
|---|---|---|
|
||||
| `spring-ai-starter-model-openai` | `OpenAiChatModel` | `spring.ai.openai.*` |
|
||||
| `spring-ai-starter-model-anthropic` | `AnthropicChatModel` | `spring.ai.anthropic.*` |
|
||||
|
||||
ビジネスコードは常に `ChatModel` インターフェースに対してプログラムする。プロトコル切り替えには依存と設定の変更だけが必要で、Java コードの変更は不要。
|
||||
|
||||
</details>
|
||||
|
||||
以下のプロンプトを試してみよう(英語プロンプトの方が LLM に効果的だが、日本語でも可):
|
||||
|
||||
1. `Create a file called Hello.java that prints "Hello, World!"`
|
||||
2. `List all Java files in this directory`
|
||||
3. `What is the current git branch?`
|
||||
4. `Create a directory called test_output and write 3 files in it`
|
||||
|
||||
+102
-62
@@ -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
@@ -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
@@ -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`
|
||||
|
||||
@@ -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
@@ -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
@@ -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 @@
|
||||
|
||||
## 問題
|
||||
|
||||
s03のTodoManagerはメモリ上のフラットなチェックリストに過ぎない: 順序なし、依存関係なし、ステータスは完了か未完了のみ。実際の目標には構造がある -- タスクBはタスクAに依存し、タスクCとDは並行実行でき、タスクEはCとDの両方を待つ。
|
||||
s03 の TodoManager はメモリ上のフラットなチェックリストに過ぎない: 順序なし、依存関係なし、ステータスは完了か未完了のみ。実際の目標には構造がある -- タスク 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`
|
||||
|
||||
@@ -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
@@ -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. TeammateManagerがconfig.jsonでチーム名簿を管理する。
|
||||
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()`がチームメイトを作成し、そのエージェントループをスレッドで開始する。
|
||||
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` と入力してリーダーのインボックスを手動確認する
|
||||
|
||||
@@ -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` と入力してステータスを監視する
|
||||
|
||||
@@ -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. チームメイトのループはWORKとIDLEの2フェーズ。LLMがツール呼び出しを止めた時(または`idle`ツールを呼んだ時)、IDLEフェーズに入る。
|
||||
1. チームメイトのループは WORK と IDLE の2フェーズ。LLM がツール呼び出しを止めた時(または `idle` ツールを呼んだ時)、IDLE フェーズに入る。
|
||||
|
||||
```python
|
||||
def _loop(self, name, role, prompt):
|
||||
while True:
|
||||
# -- WORK PHASE --
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
for _ in range(50):
|
||||
response = client.messages.create(...)
|
||||
if response.stop_reason != "tool_use":
|
||||
break
|
||||
# execute tools...
|
||||
if idle_requested:
|
||||
break
|
||||
```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` と入力して誰が作業中でアイドルかを監視する
|
||||
|
||||
@@ -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".`
|
||||
|
||||
Reference in New Issue
Block a user