chore: Spring AI 重构

This commit is contained in:
abel533
2026-03-25 00:15:00 +08:00
parent a9c71002d2
commit 2afa4712cb
124 changed files with 11777 additions and 3530 deletions
+264 -62
View File
@@ -20,97 +20,299 @@ A language model can reason about code, but it can't *touch* the real world -- c
^ |
| tool_result |
+----------------+
(loop until stop_reason != "tool_use")
(ChatClient.call() auto-loops until no tool calls)
```
One exit condition controls the entire flow. The loop runs until the model stops calling tools.
A single `call()` invocation controls the entire flow. Spring AI loops automatically until the model stops calling tools.
## How It Works
1. User prompt becomes the first message.
### 1. Build ChatClient: Inject Model + Register Tools
```python
messages.append({"role": "user", "content": query})
Inject `ChatModel` via Spring Boot auto-configuration, build the client with `ChatClient.builder()`, set the system prompt and tools.
```java
// TIP: The Python version creates client = Anthropic() and MODEL at module level.
// Spring AI injects ChatModel via auto-configuration, then builds ChatClient with builder.
public S01AgentLoop(ChatModel chatModel) {
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent at " + System.getProperty("user.dir")
+ ". Use bash to solve tasks. Act, don't explain.")
.defaultTools(new BashTool()) // Tool object with @Tool annotation
.build();
}
```
2. Send messages + tool definitions to the LLM.
### 2. `@Tool` Annotation: Declarative Tool Registration
```python
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
Spring AI automatically discovers and registers tools via the `@Tool` annotation. At startup, the framework scans objects passed to `defaultTools()`, extracts all `@Tool` method signatures and descriptions, generates the tool schema the LLM needs (name, parameters, description), and automatically includes it in every `call()` request.
```java
// BashTool -- corresponds to the Python version's run_bash() function
public class BashTool {
@Tool(description = "Run a shell command and return stdout + stderr")
public String bash(@ToolParam(description = "The shell command to execute")
String command) {
// Dangerous command check + ProcessBuilder execution + timeout control + output truncation
// ...
}
}
```
3. Append the assistant response. Check `stop_reason` -- if the model didn't call a tool, we're done.
> Comparison with Python's manual registration:
> - Python: `TOOLS = [{"name": "bash", "input_schema": {...}}]` + `TOOL_HANDLERS = {"bash": run_bash}`
> - Java: Just `@Tool` + `@ToolParam` annotations; the framework auto-generates schemas and dispatches methods
### 3. Spring AI Internal Auto-Loop: How `call()` Works Under the Hood
**This is the most critical difference between the Java and Python versions.** The Python version requires a hand-written while loop to drive tool calls:
```python
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
```
4. Execute each tool call, collect results, append as a user message. Loop back to step 2.
```python
results = []
for block in response.content:
if block.type == "tool_use":
output = run_bash(block.input["command"])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
```
Assembled into one function:
```python
def agent_loop(query):
messages = [{"role": "user", "content": query}]
# Python version -- manual loop
def agent_loop(messages):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
response = client.messages.create(model=MODEL, messages=messages, tools=TOOLS)
# Collect assistant message
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
return response # Model no longer calling tools, exit loop
# Execute tools and feed back results
for block in response.content:
if block.type == "tool_use":
output = run_bash(block.input["command"])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
result = TOOL_HANDLERS[block.name](block.input)
messages.append({"role": "user", "content": [{"type": "tool_result", ...}]})
```
That's the entire agent in under 30 lines. Everything else in this course layers on top -- without changing the loop.
Spring AI's `ChatClient.call()` **encapsulates fully equivalent logic internally**:
```
call() internal flow:
┌─────────────────────────────────────────────────────┐
│ 1. Assemble request: system prompt + user msg + tools │
│ 2. Send to LLM │
│ 3. Parse response │
│ ├── Has tool_use? ──→ Yes: │
│ │ a. Extract tool name and arguments │
│ │ b. Invoke corresponding @Tool method via reflection │
│ │ c. Append tool_result to message list │
│ │ d. Go back to step 2 (auto-loop) │
│ └── No ──→ Return final text │
└─────────────────────────────────────────────────────┘
```
Key points:
- **Tool detection**: Spring AI checks if the response contains `tool_use` content blocks (equivalent to Python's `stop_reason == "tool_use"`)
- **Reflection dispatch**: The framework uses Java reflection to find and invoke the `@Tool` method matching the tool name returned by the LLM (equivalent to Python's `TOOL_HANDLERS[block.name]`)
- **Result feedback**: Tool execution results are automatically wrapped as `tool_result` messages and appended to the conversation (equivalent to Python's manual `tool_result` content block construction)
- **Loop termination**: When the model returns pure text (no tool calls), `call()` returns the final result
Thus, Python's ~15-line while loop is condensed into a single `.call()` in Java.
### 4. `AgentRunner.interactive()`: The REPL Interaction Loop
`AgentRunner` is a shared REPL (Read-Eval-Print Loop) utility class used across all lessons, corresponding to the `input()` loop in Python's `if __name__ == "__main__"` block.
```java
public class AgentRunner {
/**
* Start an interactive REPL loop.
* @param prefix Prompt prefix (e.g., "s01")
* @param handler Function that processes user input and returns Agent response
*/
public static void interactive(String prefix, Function<String, String> handler) {
Scanner scanner = new Scanner(System.in);
System.out.println("Type 'q' or 'exit' to quit");
while (true) {
System.out.print("\033[36m" + prefix + " >> \033[0m"); // Colored prompt
String input;
try {
if (!scanner.hasNextLine()) break;
input = scanner.nextLine().trim();
} catch (Exception e) {
break;
}
if (input.isEmpty() || "exit".equalsIgnoreCase(input) || "q".equalsIgnoreCase(input)) {
break;
}
try {
String response = handler.apply(input); // Call Agent handler
if (response != null && !response.isBlank()) {
System.out.println(response);
}
} catch (Exception e) {
System.err.println("Error: " + e.getMessage());
}
System.out.println();
}
System.out.println("Bye!");
}
}
```
Workflow: `Scanner` reads input → `handler.apply()` sends to Agent → print response → loop. The `handler` is a functional interface; each lesson passes in its own Agent invocation logic.
### 5. Assembled into a Complete Agent Class
```java
@SpringBootApplication(scanBasePackages = "io.mybatis.learn.core")
public class S01AgentLoop implements CommandLineRunner {
private final ChatClient chatClient;
public S01AgentLoop(ChatModel chatModel) {
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent at ...")
.defaultTools(new BashTool())
.build();
}
@Override
public void run(String... args) {
AgentRunner.interactive("s01", userMessage ->
chatClient.prompt()
.user(userMessage)
.call() // ← This single call = Python's entire while loop
.content()
);
}
}
```
> **TIPS — Key Python → Java Adaptations:**
> - Python's `while True` + `stop_reason` manual loop → Spring AI `ChatClient.call()` built-in auto-loop
> - Python's `TOOLS` array + `TOOL_HANDLERS` dict → `@Tool` annotation + `defaultTools()` auto-registration with reflection dispatch
> - Python's `client = Anthropic()` → Spring Boot auto-configured `ChatModel` injection
> - Python's `input()` interaction → `AgentRunner.interactive()` wrapping Scanner REPL + functional interface
Under 40 lines of core code, and that's the entire agent. The next 11 chapters all layer mechanisms on top of this loop -- the loop itself never changes.
## What Changed
| Component | Before | After |
|---------------|------------|--------------------------------|
| Agent loop | (none) | `while True` + stop_reason |
| Tools | (none) | `bash` (one tool) |
| Messages | (none) | Accumulating list |
| Control flow | (none) | `stop_reason != "tool_use"` |
| Component | Before | After |
|---------------|------------|-------------------------------------------------|
| Agent loop | (none) | `ChatClient.call()` built-in tool loop |
| Tools | (none) | `BashTool` (single `@Tool` tool) |
| Messages | (none) | Managed internally by Spring AI |
| Control flow | (none) | Framework auto-detects: returns final text when no tool calls |
```java
// Core code -- build + call
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent ...")
.defaultTools(new BashTool())
.build();
AgentRunner.interactive("s01", userMessage ->
chatClient.prompt().user(userMessage).call().content()
);
```
## Try It
```sh
cd learn-claude-code
python agents/s01_agent_loop.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s01.S01AgentLoop
```
1. `Create a file called hello.py that prints "Hello, World!"`
2. `List all Python files in this directory`
> Set environment variables before running: `AI_API_KEY`, `AI_BASE_URL`, `AI_MODEL`
>
> **The default protocol is OpenAI** (compatible with all OpenAI API-format services, including OpenAI official, Azure OpenAI, and any third-party model services offering an OpenAI-compatible interface).
> To use the Anthropic protocol (Claude native API), expand the section below.
<details>
<summary><strong>Switching AI Protocols (OpenAI ↔ Anthropic)</strong></summary>
This project switches the underlying protocol via **Spring AI Starter dependency + configuration file**. Java business code (`ChatModel`, `ChatClient`) **requires no changes**.
#### Option 1: OpenAI Protocol (Default)
`pom.xml` dependency:
```xml
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
```
`application.yml` configuration:
```yaml
spring:
ai:
openai:
api-key: ${AI_API_KEY:sk-xxx}
base-url: ${AI_BASE_URL:https://api.openai.com}
chat:
options:
model: ${AI_MODEL:gpt-4o}
```
Environment variable example:
```sh
export AI_API_KEY=sk-proj-xxxxxxxx
export AI_BASE_URL=https://api.openai.com # Replace with any OpenAI-compatible endpoint
export AI_MODEL=gpt-4o
```
> **TIP**: Many third-party model services (e.g., DeepSeek, Mistral, Qwen) provide OpenAI-compatible APIs. Simply change `AI_BASE_URL` and `AI_MODEL` to connect — no protocol switch needed.
#### Option 2: Anthropic Protocol (Claude Native API)
**Step 1**: Edit `pom.xml` — replace the OpenAI starter with the Anthropic starter:
```xml
<!-- Comment out or remove the OpenAI starter -->
<!-- <dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency> -->
<!-- Add the Anthropic starter -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-anthropic</artifactId>
</dependency>
```
**Step 2**: Edit `application.yml` — replace `spring.ai.openai` with `spring.ai.anthropic`:
```yaml
spring:
ai:
anthropic:
api-key: ${AI_API_KEY}
base-url: ${AI_BASE_URL:https://api.anthropic.com}
chat:
options:
model: ${AI_MODEL:claude-sonnet-4-20250514}
```
**Step 3**: Set environment variables:
```sh
export AI_API_KEY=sk-ant-xxxxxxxx
export AI_BASE_URL=https://api.anthropic.com
export AI_MODEL=claude-sonnet-4-20250514
```
#### How Switching Works
Spring AI's `ChatModel` is a unified abstraction interface. Different Starters provide different implementations:
| Starter Dependency | Auto-injected ChatModel | Config Prefix |
|---|---|---|
| `spring-ai-starter-model-openai` | `OpenAiChatModel` | `spring.ai.openai.*` |
| `spring-ai-starter-model-anthropic` | `AnthropicChatModel` | `spring.ai.anthropic.*` |
Business code always programs against the `ChatModel` interface. Switching protocols only requires changing the dependency and configuration — no Java code changes needed.
</details>
Try these prompts(English prompts work better with LLMs, but Chinese also works):
1. `Create a file called Hello.java that prints "Hello, World!"`
2. `List all Java files in this directory`
3. `What is the current git branch?`
4. `Create a directory called test_output and write 3 files in it`
+99 -59
View File
@@ -2,98 +2,138 @@
`s01 > [ s02 ] s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"Adding a tool means adding one handler"* -- the loop stays the same; new tools register into the dispatch map.
> *"Adding a tool means adding one @Tool method"* -- the loop stays the same; new tools are passed into `defaultTools()`.
>
> **Harness layer**: Tool dispatch -- expanding what the model can reach.
## Problem
With only `bash`, the agent shells out for everything. `cat` truncates unpredictably, `sed` fails on special characters, and every bash call is an unconstrained security surface. Dedicated tools like `read_file` and `write_file` let you enforce path sandboxing at the tool level.
With only `bash`, the agent shells out for everything. `cat` truncates unpredictably, `sed` fails on special characters, and every bash call is an unconstrained security surface. Dedicated tools (`read_file`, `write_file`) let you enforce path sandboxing at the tool level.
The key insight: adding tools does not require changing the loop.
## Solution
```
+--------+ +-------+ +------------------+
| User | ---> | LLM | ---> | Tool Dispatch |
| prompt | | | | { |
+--------+ +---+---+ | bash: run_bash |
^ | read: run_read |
| | write: run_wr |
+-----------+ edit: run_edit |
tool_result | } |
+------------------+
+--------+ +-------+ +--------------------+
| User | ---> | LLM | ---> | defaultTools() |
| prompt | | | | { |
+--------+ +---+---+ | BashTool |
^ | ReadFileTool |
| | WriteFileTool |
+-----------+ EditFileTool |
tool_result | } |
+--------------------+
The dispatch map is a dict: {tool_name: handler_function}.
One lookup replaces any if/elif chain.
Spring AI auto-registers and dispatches via @Tool annotations.
No hand-written dispatch map needed -- the framework scans annotated methods on tool objects.
```
## How It Works
1. Each tool gets a handler function. Path sandboxing prevents workspace escape.
1. Each tool is a standalone class declared with `@Tool` annotation. `PathValidator` provides path sandboxing to prevent workspace escape.
```python
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
```java
// PathValidator -- corresponds to the Python version's safe_path() function
public class PathValidator {
private final Path workDir;
def run_read(path: str, limit: int = None) -> str:
text = safe_path(path).read_text()
lines = text.splitlines()
if limit and limit < len(lines):
lines = lines[:limit]
return "\n".join(lines)[:50000]
```
public Path resolve(String relativePath) {
Path resolved = workDir.resolve(relativePath).toAbsolutePath().normalize();
if (!resolved.startsWith(workDir)) {
throw new IllegalArgumentException("Path escapes workspace: " + relativePath);
}
return resolved;
}
}
2. The dispatch map links tool names to handlers.
// ReadFileTool -- corresponds to the Python version's run_read() function
public class ReadFileTool {
private final PathValidator pathValidator;
```python
TOOL_HANDLERS = {
"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"],
kw["new_text"]),
@Tool(description = "Read file contents. Optionally limit the number of lines returned.")
public String readFile(
@ToolParam(description = "Relative path to the file") String path,
@ToolParam(description = "Maximum number of lines to read", required = false) Integer limit) {
Path filePath = pathValidator.resolve(path);
List<String> lines = Files.readAllLines(filePath);
if (limit != null && limit > 0 && limit < lines.size()) {
lines = lines.subList(0, limit);
}
return String.join("\n", lines);
}
}
```
3. In the loop, look up the handler by name. The loop body itself is unchanged from s01.
2. Tool registration simply passes objects to `defaultTools()`. Spring AI scans `@Tool` annotated methods and automatically handles name mapping and parameter binding.
```python
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler \
else f"Unknown tool: {block.name}"
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
```java
// Corresponds to the Python version's TOOL_HANDLERS dict
// Python: TOOL_HANDLERS = {"bash": fn, "read_file": fn, "write_file": fn, "edit_file": fn}
// Java: Just pass tool objects; @Tool annotations handle auto-registration
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent ...")
.defaultTools(
new BashTool(), // bash command execution
new ReadFileTool(), // file reading
new WriteFileTool(), // file writing
new EditFileTool() // file editing (find & replace)
)
.build();
```
Add a tool = add a handler + add a schema entry. The loop never changes.
3. The calling code is identical to s01. The loop is managed by the framework; developers only focus on tool implementation.
```java
// Compared to s01, the only change is that defaultTools() receives 3 more tool objects
// The loop code is exactly the same -- this is the core insight of s02
AgentRunner.interactive("s02", userMessage ->
chatClient.prompt()
.user(userMessage)
.call()
.content()
);
```
Add a tool = add a `@Tool` class + pass it to `defaultTools()`. The loop never changes.
> **TIPS — Key Python → Java Adaptations:**
> - Python's `TOOL_HANDLERS` dict → Spring AI `@Tool` annotation + `defaultTools()` auto-registration and dispatch
> - Python's `safe_path()` function → `PathValidator` class (same path escape check logic)
> - Python's `lambda **kw` parameter unpacking → `@ToolParam` annotation auto-binds parameters
> - Python's `block.type == "tool_use"` check → Spring AI handles detection and dispatch internally
## What Changed From s01
| Component | Before (s01) | After (s02) |
|----------------|--------------------|----------------------------|
| Tools | 1 (bash only) | 4 (bash, read, write, edit)|
| Dispatch | Hardcoded bash call | `TOOL_HANDLERS` dict |
| Path safety | None | `safe_path()` sandbox |
| Agent loop | Unchanged | Unchanged |
| Component | Before (s01) | After (s02) |
|----------------|-----------------------|------------------------------------------------|
| Tools | 1 (`BashTool`) | 4 (`Bash`, `ReadFile`, `WriteFile`, `EditFile`) |
| Dispatch | `defaultTools(bash)` | `defaultTools(bash, read, write, edit)` |
| Path safety | None | `PathValidator` sandbox |
| Agent loop | Unchanged | Unchanged |
```java
// s01 → s02 only change: defaultTools() receives 3 more tool objects
.defaultTools(
new BashTool(),
new ReadFileTool(), // +new
new WriteFileTool(), // +new
new EditFileTool() // +new
)
```
## Try It
```sh
cd learn-claude-code
python agents/s02_tool_use.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s02.S02ToolUse
```
1. `Read the file requirements.txt`
2. `Create a file called greet.py with a greet(name) function`
3. `Edit greet.py to add a docstring to the function`
4. `Read greet.py to verify the edit worked`
> Set environment variables before running: `AI_API_KEY`, `AI_BASE_URL`, `AI_MODEL`
Try these prompts (English prompts work better with LLMs, but Chinese also works):
1. `Read the file pom.xml`
2. `Create a file called Greet.java with a greet(name) method`
3. `Edit Greet.java to add a Javadoc comment to the method`
4. `Read Greet.java to verify the edit worked`
+68 -44
View File
@@ -2,13 +2,13 @@
`s01 > s02 > [ s03 ] s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"An agent without a plan drifts"* -- list the steps first, then execute.
> *"An agent without a plan drifts"* -- list the steps first, then execute. Doubles the completion rate.
>
> **Harness layer**: Planning -- keeping the model on course without scripting the route.
## Problem
On multi-step tasks, the model loses track. It repeats work, skips steps, or wanders off. Long conversations make this worse -- the system prompt fades as tool results fill the context. A 10-step refactoring might complete steps 1-3, then the model starts improvising because it forgot steps 4-10.
On multi-step tasks, the model loses track -- repeats work, skips steps, or wanders off. Long conversations make this worse: tool results keep filling the context, gradually diluting the system prompt's influence. A 10-step refactoring might complete steps 1-3, then the model starts improvising because steps 4-10 have been pushed out of attention.
## Solution
@@ -28,69 +28,93 @@ On multi-step tasks, the model loses track. It repeats work, skips steps, or wan
| [x] task C |
+-----------------------+
|
if rounds_since_todo >= 3:
inject <reminder> into tool_result
Inject latest todo state into
system prompt via defaultSystem()
on each request
```
## How It Works
1. TodoManager stores items with statuses. Only one item can be `in_progress` at a time.
```python
class TodoManager:
def update(self, items: list) -> str:
validated, in_progress_count = [], 0
for item in items:
status = item.get("status", "pending")
if status == "in_progress":
in_progress_count += 1
validated.append({"id": item["id"], "text": item["text"],
"status": status})
if in_progress_count > 1:
raise ValueError("Only one task can be in_progress")
self.items = validated
return self.render()
```
```java
public class TodoManager {
2. The `todo` tool goes into the dispatch map like any other tool.
public record TodoItem(String id, String text, String status) {}
```python
TOOL_HANDLERS = {
# ...base tools...
"todo": lambda **kw: TODO.update(kw["items"]),
private List<TodoItem> items = new ArrayList<>();
@Tool(description = "Update the full task list to track progress. "
+ "Each item must have id, text, status (pending/in_progress/completed). "
+ "Only one task can be in_progress at a time. Max 20 items.")
public String updateTodos(
@ToolParam(description = "The complete list of todo items")
List<TodoItem> items) {
if (items.size() > 20) return "Error: Max 20 todos allowed";
List<TodoItem> validated = new ArrayList<>();
int inProgressCount = 0;
for (TodoItem item : items) {
String status = (item.status() != null)
? item.status().toLowerCase() : "pending";
if ("in_progress".equals(status)) inProgressCount++;
validated.add(new TodoItem(item.id(), item.text().trim(), status));
}
if (inProgressCount > 1)
return "Error: Only one task can be in_progress at a time";
this.items = validated;
return render();
}
}
```
3. A nag reminder injects a nudge if the model goes 3+ rounds without calling `todo`.
2. `TodoManager` is registered via `defaultTools()`; the `@Tool` annotated method is automatically exposed as a tool.
```python
if rounds_since_todo >= 3 and messages:
last = messages[-1]
if last["role"] == "user" and isinstance(last.get("content"), list):
last["content"].insert(0, {
"type": "text",
"text": "<reminder>Update your todos.</reminder>",
})
```java
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem(system)
.defaultTools(
new BashTool(),
new ReadFileTool(),
new WriteFileTool(),
new EditFileTool(),
todoManager // @Tool annotated method auto-registered
)
.build();
```
The "one in_progress at a time" constraint forces sequential focus. The nag reminder creates accountability.
3. System prompt injection: on each user input, inject the latest todo state into the system prompt with emphasis on update instructions.
```java
// Dynamic system prompt: includes current todo state
String system = "You are a coding agent at " + workDir + ".\n"
+ "Use the todo tool to plan multi-step tasks. "
+ "Mark in_progress before starting, completed when done.\n"
+ "IMPORTANT: You MUST call updateTodos regularly.\n\n"
+ "<current-todos>\n" + todoManager.render() + "\n</current-todos>";
```
The "only one in_progress at a time" constraint forces sequential focus. Continuously injecting todo state into the system prompt creates accountability pressure -- the model sees its own plan every turn and won't forget to update it.
> **TIP**: The Python version tracks `rounds_since_todo` inside the tool loop and injects `<reminder>` text after 3 consecutive rounds without a todo call. Spring AI's ChatClient manages the tool loop automatically and doesn't allow mid-loop injection, so system prompt injection is used instead to achieve the same effect.
## What Changed From s02
| Component | Before (s02) | After (s03) |
|----------------|------------------|----------------------------|
| Tools | 4 | 5 (+todo) |
| Planning | None | TodoManager with statuses |
| Nag injection | None | `<reminder>` after 3 rounds|
| Agent loop | Simple dispatch | + rounds_since_todo counter|
| Component | Before (s02) | After (s03) |
|----------------|------------------|--------------------------------------|
| Tools | 4 | 5 (+TodoManager `@Tool`) |
| Planning | None | TodoManager with statuses |
| State injection| None | System prompt injection `<current-todos>` |
| ChatClient | Fixed system prompt | Rebuilt each turn, dynamic todo state injection |
## Try It
```sh
cd learn-claude-code
python agents/s03_todo_write.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s03.S03TodoWrite
```
1. `Refactor the file hello.py: add type hints, docstrings, and a main guard`
2. `Create a Python package with __init__.py, utils.py, and tests/test_utils.py`
3. `Review all Python files and fix any style issues`
Try these prompts (English prompts work better with LLMs, but Chinese also works):
1. `Refactor the file Hello.java: add JavaDoc, improve naming, and keep main method behavior unchanged`
2. `Create a Java package with utils and tests`
3. `Review all Java files and fix any style issues`
+57 -49
View File
@@ -8,7 +8,7 @@
## Problem
As the agent works, its messages array grows. Every file read, every bash output stays in context permanently. "What testing framework does this project use?" might require reading 5 files, but the parent only needs the answer: "pytest."
As the agent works, its messages array grows. Every file read, every bash output stays in context permanently. "What testing framework does this project use?" might require reading 5 files, but the parent only needs one word: "pytest."
## Solution
@@ -28,67 +28,75 @@ Parent context stays clean. Subagent context is discarded.
## How It Works
1. The parent gets a `task` tool. The child gets all base tools except `task` (no recursive spawning).
1. The parent agent has a `task` tool. The subagent gets all base tools except `task` (no recursive spawning).
```python
PARENT_TOOLS = CHILD_TOOLS + [
{"name": "task",
"description": "Spawn a subagent with fresh context.",
"input_schema": {
"type": "object",
"properties": {"prompt": {"type": "string"}},
"required": ["prompt"],
}},
]
```
2. The subagent starts with `messages=[]` and runs its own loop. Only the final text returns to the parent.
```python
def run_subagent(prompt: str) -> str:
sub_messages = [{"role": "user", "content": prompt}]
for _ in range(30): # safety limit
response = client.messages.create(
model=MODEL, system=SUBAGENT_SYSTEM,
messages=sub_messages,
tools=CHILD_TOOLS, max_tokens=8000,
```java
// Parent Agent: has base tools + SubagentTool
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent. "
+ "Use the task tool to delegate subtasks.")
.defaultTools(
new BashTool(),
new ReadFileTool(),
new WriteFileTool(),
new EditFileTool(),
new SubagentTool(chatModel) // Parent Agent exclusive
)
sub_messages.append({"role": "assistant",
"content": response.content})
if response.stop_reason != "tool_use":
break
results = []
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input)
results.append({"type": "tool_result",
"tool_use_id": block.id,
"content": str(output)[:50000]})
sub_messages.append({"role": "user", "content": results})
return "".join(
b.text for b in response.content if hasattr(b, "text")
) or "(no summary)"
.build();
```
The child's entire message history (possibly 30+ tool calls) is discarded. The parent receives a one-paragraph summary as a normal `tool_result`.
2. The subagent starts with a brand new `ChatClient` and an independent context. Only the final text returns to the parent.
```java
@Tool(description = "Spawn a subagent with fresh context. "
+ "Use for exploration or subtasks that might pollute the main context.")
public String task(
@ToolParam(description = "The task prompt") String prompt,
@ToolParam(description = "Short description", required = false)
String description) {
// Create a brand new ChatClient -- this IS "context isolation"
ChatClient subClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding subagent. "
+ "Complete the task, then summarize findings.")
.defaultTools( // Base tools, no task (prevents recursion)
new BashTool(),
new ReadFileTool(),
new WriteFileTool(),
new EditFileTool()
)
.build();
String result = subClient.prompt()
.user(prompt)
.call()
.content();
// Only the final text is returned; subagent context is discarded
return (result != null) ? result : "(no summary)";
}
```
The subagent may have run multiple tool calls, but its entire message history is discarded. The parent receives only a summary text, returned as a normal `tool_result`. Spring AI's `ChatClient.call()` manages the tool loop internally -- no need to manually limit iteration count.
## What Changed From s03
| Component | Before (s03) | After (s04) |
|----------------|------------------|---------------------------|
| Tools | 5 | 5 (base) + task (parent) |
| Context | Single shared | Parent + child isolation |
| Subagent | None | `run_subagent()` function |
| Return value | N/A | Summary text only |
| Component | Before (s03) | After (s04) |
|----------------|------------------|---------------------------------------|
| Tools | 5 | 5 (base) + SubagentTool (parent only) |
| Context | Single shared | Parent + child isolation (independent ChatClient) |
| Subagent | None | `SubagentTool.task()` method |
| Return value | N/A | Summary text only |
## Try It
```sh
cd learn-claude-code
python agents/s04_subagent.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s04.S04Subagent
```
Try these prompts (English prompts work better with LLMs, but Chinese also works):
1. `Use a subtask to find what testing framework this project uses`
2. `Delegate: read all .py files and summarize what each one does`
2. `Delegate: read all .java files and summarize what each one does`
3. `Use a task to create a new module, then verify it from here`
+85 -38
View File
@@ -8,7 +8,7 @@
## Problem
You want the agent to follow domain-specific workflows: git conventions, testing patterns, code review checklists. Putting everything in the system prompt wastes tokens on unused skills. 10 skills at 2000 tokens each = 20,000 tokens, most of which are irrelevant to any given task.
You want the agent to follow domain-specific workflows: git conventions, testing patterns, code review checklists. Putting everything in the system prompt wastes tokens -- 10 skills at 2000 tokens each = 20,000 tokens, most of which are irrelevant to any given task.
## Solution
@@ -35,7 +35,7 @@ Layer 1: skill *names* in system prompt (cheap). Layer 2: full *body* via tool_r
## How It Works
1. Each skill is a directory containing a `SKILL.md` with YAML frontmatter.
1. Each skill is a directory containing a `SKILL.md` file with YAML frontmatter.
```
skills/
@@ -45,42 +45,87 @@ skills/
SKILL.md # ---\n name: code-review\n description: Review code\n ---\n ...
```
2. SkillLoader scans for `SKILL.md` files, uses the directory name as the skill identifier.
2. SkillLoader recursively scans for `SKILL.md` files, using the directory name as the skill identifier.
```python
class SkillLoader:
def __init__(self, skills_dir: Path):
self.skills = {}
for f in sorted(skills_dir.rglob("SKILL.md")):
text = f.read_text()
meta, body = self._parse_frontmatter(text)
name = meta.get("name", f.parent.name)
self.skills[name] = {"meta": meta, "body": body}
```java
public class SkillLoader {
def get_descriptions(self) -> str:
lines = []
for name, skill in self.skills.items():
desc = skill["meta"].get("description", "")
lines.append(f" - {name}: {desc}")
return "\n".join(lines)
private static final Pattern FRONTMATTER_PATTERN =
Pattern.compile("^---\\n(.*?)\\n---\\n(.*)", Pattern.DOTALL);
def get_content(self, name: str) -> str:
skill = self.skills.get(name)
if not skill:
return f"Error: Unknown skill '{name}'."
return f"<skill name=\"{name}\">\n{skill['body']}\n</skill>"
private final Map<String, SkillInfo> skills = new LinkedHashMap<>();
record SkillInfo(Map<String, String> meta, String body, String path) {}
public SkillLoader(Path skillsDir) {
loadAll(skillsDir);
}
/** Recursively scan all SKILL.md files under the skills directory */
private void loadAll(Path skillsDir) {
if (!Files.exists(skillsDir)) return;
try (Stream<Path> paths = Files.walk(skillsDir)) {
paths.filter(p -> p.getFileName().toString().equals("SKILL.md"))
.sorted()
.forEach(p -> {
String text = Files.readString(p);
var parsed = parseFrontmatter(text);
String name = parsed.meta().getOrDefault("name",
p.getParent().getFileName().toString());
skills.put(name, new SkillInfo(
parsed.meta(), parsed.body(), p.toString()));
});
}
}
/** Layer 1: Get short descriptions of all skills (for system prompt injection) */
public String getDescriptions() {
if (skills.isEmpty()) return "(no skills available)";
StringBuilder sb = new StringBuilder();
for (var entry : skills.entrySet()) {
String desc = entry.getValue().meta()
.getOrDefault("description", "No description");
sb.append(" - ").append(entry.getKey())
.append(": ").append(desc).append("\n");
}
return sb.toString().stripTrailing();
}
/** Layer 2: Load full content of a specified skill (as @Tool method) */
@Tool(description = "Load specialized knowledge by name.")
public String loadSkill(
@ToolParam(description = "Skill name to load") String name) {
SkillInfo skill = skills.get(name);
if (skill == null)
return "Error: Unknown skill '" + name + "'. Available: "
+ String.join(", ", skills.keySet());
return "<skill name=\"" + name + "\">\n"
+ skill.body() + "\n</skill>";
}
}
```
3. Layer 1 goes into the system prompt. Layer 2 is just another tool handler.
3. Layer 1 goes into the system prompt. Layer 2 is loaded on demand via the `@Tool` annotated method on SkillLoader.
```python
SYSTEM = f"""You are a coding agent at {WORKDIR}.
Skills available:
{SKILL_LOADER.get_descriptions()}"""
```java
public S05SkillLoading(ChatModel chatModel) {
Path skillsDir = Path.of(System.getProperty("user.dir"), "skills");
SkillLoader skillLoader = new SkillLoader(skillsDir);
TOOL_HANDLERS = {
# ...base tools...
"load_skill": lambda **kw: SKILL_LOADER.get_content(kw["name"]),
// Layer 1: Skill metadata injected into system prompt
String system = "You are a coding agent at " + System.getProperty("user.dir") + ".\n"
+ "Use loadSkill to access specialized knowledge.\n\n"
+ "Skills available:\n"
+ skillLoader.getDescriptions();
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem(system)
.defaultTools(
new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(),
skillLoader // Layer 2: loadSkill @Tool method
)
.build();
}
```
@@ -88,20 +133,22 @@ The model learns what skills exist (cheap) and loads them when relevant (expensi
## What Changed From s04
| Component | Before (s04) | After (s05) |
|----------------|------------------|----------------------------|
| Tools | 5 (base + task) | 5 (base + load_skill) |
| System prompt | Static string | + skill descriptions |
| Knowledge | None | skills/\*/SKILL.md files |
| Injection | None | Two-layer (system + result)|
| Component | Before (s04) | After (s05) |
|----------------|------------------|--------------------------------|
| Tools | 5 (base + task) | 5 (base + load_skill) |
| System prompt | Static string | + skill descriptions |
| Knowledge | None | skills/\*/SKILL.md files |
| Injection | None | Two-layer (system + result) |
## Try It
```sh
cd learn-claude-code
python agents/s05_skill_loading.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s05.S05SkillLoading
```
Try these prompts (English prompts work better with LLMs, but Chinese also works):
1. `What skills are available?`
2. `Load the agent-builder skill and follow its instructions`
3. `I need to do a code review -- load the relevant skill first`
+118 -58
View File
@@ -8,7 +8,7 @@
## Problem
The context window is finite. A single `read_file` on a 1000-line file costs ~4000 tokens. After reading 30 files and running 20 bash commands, you hit 100,000+ tokens. The agent cannot work on large codebases without compression.
The context window is finite. A single `read_file` on a 1000-line file costs ~4000 tokens; after reading 30 files and running 20 commands, you easily blow past 100k tokens. Without compression, the agent simply cannot work on large codebases.
## Solution
@@ -44,82 +44,142 @@ continue [Layer 2: auto_compact]
## How It Works
1. **Layer 1 -- micro_compact**: Before each LLM call, replace old tool results with placeholders.
1. **Layer 1 -- Context window management**: Spring AI's ChatClient manages the tool loop automatically and doesn't allow mid-loop compression injection. The Java version achieves an equivalent effect by limiting the number of conversation turns injected into the system prompt (keeping only the most recent N turns) and truncating content.
```python
def micro_compact(messages: list) -> list:
tool_results = []
for i, msg in enumerate(messages):
if msg["role"] == "user" and isinstance(msg.get("content"), list):
for j, part in enumerate(msg["content"]):
if isinstance(part, dict) and part.get("type") == "tool_result":
tool_results.append((i, j, part))
if len(tool_results) <= KEEP_RECENT:
return messages
for _, _, part in tool_results[:-KEEP_RECENT]:
if len(part.get("content", "")) > 100:
part["content"] = f"[Previous: used {tool_name}]"
return messages
```java
/** Estimate token count: rough estimate of 4 chars ≈ 1 token */
public int estimateTokens() {
int chars = history.stream().mapToInt(t -> t.content().length()).sum();
return chars / 4;
}
/** Get conversation history summary (for system prompt injection, keeping only recent turns) */
public String getContextSummary() {
if (history.isEmpty()) return "";
StringBuilder sb = new StringBuilder("\n<conversation-context>\n");
int start = Math.max(0, history.size() - KEEP_RECENT * 2);
for (int i = start; i < history.size(); i++) {
ConversationTurn turn = history.get(i);
sb.append("[").append(turn.role()).append("]: ")
.append(turn.content(), 0, Math.min(500, turn.content().length()))
.append("\n");
}
sb.append("</conversation-context>");
return sb.toString();
}
```
2. **Layer 2 -- auto_compact**: When tokens exceed threshold, save full transcript to disk, then ask the LLM to summarize.
2. **Layer 2 -- auto_compact**: When tokens exceed the threshold, save the full conversation to disk and have the LLM summarize it.
```python
def auto_compact(messages: list) -> list:
# Save transcript for recovery
transcript_path = TRANSCRIPT_DIR / f"transcript_{int(time.time())}.jsonl"
with open(transcript_path, "w") as f:
for msg in messages:
f.write(json.dumps(msg, default=str) + "\n")
# LLM summarizes
response = client.messages.create(
model=MODEL,
messages=[{"role": "user", "content":
"Summarize this conversation for continuity..."
+ json.dumps(messages, default=str)[:80000]}],
max_tokens=2000,
)
return [
{"role": "user", "content": f"[Compressed]\n\n{response.content[0].text}"},
{"role": "assistant", "content": "Understood. Continuing."},
]
```java
public String compact() {
// Save transcript to disk (full history is not lost)
Files.createDirectories(transcriptDir);
Path transcriptPath = transcriptDir.resolve(
"transcript_" + System.currentTimeMillis() + ".jsonl");
try (BufferedWriter writer = Files.newBufferedWriter(transcriptPath)) {
for (ConversationTurn turn : history) {
writer.write(objectMapper.writeValueAsString(turn));
writer.newLine();
}
}
// LLM generates summary
String conversationText = history.stream()
.map(t -> t.role() + ": " + t.content())
.reduce("", (a, b) -> a + "\n" + b);
if (conversationText.length() > 80000) {
conversationText = conversationText.substring(0, 80000);
}
ChatClient summaryClient = ChatClient.builder(chatModel).build();
String summary = summaryClient.prompt()
.user("Summarize this conversation for continuity. Include: "
+ "1) What was accomplished, 2) Current state, "
+ "3) Key decisions.\n\n" + conversationText)
.call().content();
// Replace history with summary
history.clear();
history.add(new ConversationTurn("system",
"[Conversation compressed. Transcript: " + transcriptPath
+ "]\n\n" + summary));
return summary;
}
```
3. **Layer 3 -- manual compact**: The `compact` tool triggers the same summarization on demand.
3. **Layer 3 -- manual compact**: The `CompactTool` triggers the same summarization mechanism on demand.
4. The loop integrates all three:
```java
public class CompactTool {
private final ContextCompactor compactor;
```python
def agent_loop(messages: list):
while True:
micro_compact(messages) # Layer 1
if estimate_tokens(messages) > THRESHOLD:
messages[:] = auto_compact(messages) # Layer 2
response = client.messages.create(...)
# ... tool execution ...
if manual_compact:
messages[:] = auto_compact(messages) # Layer 3
public CompactTool(ContextCompactor compactor) {
this.compactor = compactor;
}
@Tool(description = "Trigger manual conversation compression to free up context space.")
public String compact(
@ToolParam(description = "What to preserve in summary",
required = false) String focus) {
compactor.requestCompact();
return "Compression triggered. Context will be summarized.";
}
}
```
Transcripts preserve full history on disk. Nothing is truly lost -- just moved out of active context.
4. The REPL layer integrates all three layers (Spring AI's ChatClient manages the tool loop automatically; compression is triggered at the user message level):
```java
AgentRunner.interactive("s06", userMessage -> {
// Layer 2: Auto-compact check (before each user input)
if (compactor.needsAutoCompact()) {
System.out.println("[auto_compact triggered]");
compactor.compact();
}
compactor.addTurn("user", userMessage);
// Dynamic system prompt: includes conversation context summary
String system = baseSystem + compactor.getContextSummary();
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem(system)
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(), compactTool)
.build();
String response = chatClient.prompt()
.user(userMessage).call().content();
compactor.addTurn("assistant", response != null ? response : "");
// Layer 3: Manual compact (if the agent called the compact tool)
if (compactor.isCompactRequested()) {
compactor.compact();
}
return response;
});
```
Full history is preserved on disk via transcripts. Nothing is truly lost -- just moved out of active context.
## What Changed From s05
| Component | Before (s05) | After (s06) |
|----------------|------------------|----------------------------|
| Tools | 5 | 5 (base + compact) |
| Context mgmt | None | Three-layer compression |
| Micro-compact | None | Old results -> placeholders|
| Auto-compact | None | Token threshold trigger |
| Transcripts | None | Saved to .transcripts/ |
| Component | Before (s05) | After (s06) |
|----------------|------------------|--------------------------------|
| Tools | 5 | 5 (base + compact) |
| Context mgmt | None | Three-layer compression |
| Context window mgmt | None | Limited turn injection + content truncation |
| Auto-compact | None | Token threshold trigger |
| Transcripts | None | Saved to .transcripts/ |
## Try It
```sh
cd learn-claude-code
python agents/s06_context_compact.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s06.S06ContextCompact
```
1. `Read every Python file in the agents/ directory one by one` (watch micro-compact replace old results)
Try these prompts (English prompts work better with LLMs, but Chinese also works):
1. `Read every Java file in the src/ directory one by one` (observe context window management)
2. `Keep reading files until compression triggers automatically`
3. `Use the compact tool to manually compress the conversation`
+84 -41
View File
@@ -10,7 +10,7 @@
s03's TodoManager is a flat checklist in memory: no ordering, no dependencies, no status beyond done-or-not. Real goals have structure -- task B depends on task A, tasks C and D can run in parallel, task E waits for both C and D.
Without explicit relationships, the agent can't tell what's ready, what's blocked, or what can run concurrently. And because the list lives only in memory, context compression (s06) wipes it clean.
Without explicit relationships, the agent can't tell what's ready, what's blocked, or what can run concurrently. And because the list lives only in memory, context compaction (s06) wipes it clean.
## Solution
@@ -48,57 +48,98 @@ This task graph becomes the coordination backbone for everything after s07: back
## How It Works
1. **TaskManager**: one JSON file per task, CRUD with dependency graph.
1. **TaskManager**: one JSON file per task, CRUD with dependency graph. Uses Jackson `ObjectMapper` for JSON serialization.
```python
class TaskManager:
def __init__(self, tasks_dir: Path):
self.dir = tasks_dir
self.dir.mkdir(exist_ok=True)
self._next_id = self._max_id() + 1
```java
public class TaskManager {
private static final ObjectMapper MAPPER = new ObjectMapper();
private final Path dir;
private int nextId;
def create(self, subject, description=""):
task = {"id": self._next_id, "subject": subject,
"status": "pending", "blockedBy": [],
"blocks": [], "owner": ""}
self._save(task)
self._next_id += 1
return json.dumps(task, indent=2)
public TaskManager(Path tasksDir) {
this.dir = tasksDir;
Files.createDirectories(dir);
this.nextId = maxId() + 1;
}
@Tool(description = "Create a new task with subject and optional description")
public String taskCreate(
@ToolParam(description = "Short subject of the task") String subject,
@ToolParam(description = "Detailed description", required = false) String description) {
Map<String, Object> task = new LinkedHashMap<>();
task.put("id", nextId);
task.put("subject", subject);
task.put("status", "pending");
task.put("blockedBy", new ArrayList<>());
task.put("blocks", new ArrayList<>());
save(task);
nextId++;
return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(task);
}
}
```
2. **Dependency resolution**: completing a task clears its ID from every other task's `blockedBy` list, automatically unblocking dependents.
```python
def _clear_dependency(self, completed_id):
for f in self.dir.glob("task_*.json"):
task = json.loads(f.read_text())
if completed_id in task.get("blockedBy", []):
task["blockedBy"].remove(completed_id)
self._save(task)
```java
private void clearDependency(int completedId) {
try (Stream<Path> files = Files.list(dir)) {
files.filter(f -> f.getFileName().toString().matches("task_\\d+\\.json"))
.forEach(f -> {
Map<String, Object> task = MAPPER.readValue(
Files.readString(f), new TypeReference<>() {});
List<Integer> blockedBy = (List<Integer>) task.get("blockedBy");
if (blockedBy != null && blockedBy.remove(Integer.valueOf(completedId))) {
save(task);
}
});
}
}
```
3. **Status + dependency wiring**: `update` handles transitions and dependency edges.
3. **Status transitions + dependency wiring**: `taskUpdate` handles status transitions and dependency edges. When status changes to `completed`, it automatically calls `clearDependency`; `blockedBy`/`blocks` are bidirectional relationships.
```python
def update(self, task_id, status=None,
add_blocked_by=None, add_blocks=None):
task = self._load(task_id)
if status:
task["status"] = status
if status == "completed":
self._clear_dependency(task_id)
self._save(task)
```java
@Tool(description = "Update a task's status or dependencies.")
public String taskUpdate(
@ToolParam(description = "Task ID") int taskId,
@ToolParam(description = "New status", required = false) String status,
@ToolParam(description = "Task IDs that block this task", required = false) List<Integer> addBlockedBy,
@ToolParam(description = "Task IDs that this task blocks", required = false) List<Integer> addBlocks) {
Map<String, Object> task = load(taskId);
if (status != null) {
task.put("status", status);
if ("completed".equals(status)) {
clearDependency(taskId);
}
}
// Handle addBlockedBy / addBlocks bidirectional dependencies ...
save(task);
return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(task);
}
```
4. Four task tools go into the dispatch map.
4. **Spring AI auto-registers tools**: Pass `TaskManager` as a `defaultTools` argument to `ChatClient`. Spring AI automatically recognizes `@Tool` annotated methods -- no manual dispatch map needed.
```python
TOOL_HANDLERS = {
# ...base tools...
"task_create": lambda **kw: TASKS.create(kw["subject"]),
"task_update": lambda **kw: TASKS.update(kw["task_id"], kw.get("status")),
"task_list": lambda **kw: TASKS.list_all(),
"task_get": lambda **kw: TASKS.get(kw["task_id"]),
```java
@SpringBootApplication(scanBasePackages = "io.mybatis.learn.core")
public class S07TaskSystem implements CommandLineRunner {
private final ChatClient chatClient;
public S07TaskSystem(ChatModel chatModel) {
Path tasksDir = Path.of(System.getProperty("user.dir"), ".tasks");
TaskManager taskManager = new TaskManager(tasksDir);
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent. Use task tools to plan and track work.")
.defaultTools(
new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(),
taskManager // @Tool methods in TaskManager are auto-registered
)
.build();
}
}
```
@@ -118,9 +159,11 @@ From s07 onward, the task graph is the default for multi-step work. s03's Todo r
```sh
cd learn-claude-code
python agents/s07_task_system.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s07.S07TaskSystem
```
Try these prompts (English prompts work better with LLMs, but Chinese also works):
1. `Create 3 tasks: "Setup project", "Write code", "Write tests". Make them depend on each other in order.`
2. `List all tasks and show the dependency graph`
3. `Complete task 1 and then list tasks to see task 2 unblocked`
+79 -50
View File
@@ -2,7 +2,7 @@
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > [ s08 ] s09 > s10 > s11 > s12`
> *"Run slow operations in the background; the agent keeps thinking"* -- daemon threads run commands, inject notifications on completion.
> *"Run slow operations in the background; the agent keeps thinking"* -- background threads run commands, inject notifications on completion.
>
> **Harness layer**: Background execution -- the model thinks while the harness waits.
@@ -32,78 +32,107 @@ Agent --[spawn A]--[spawn B]--[other work]----
## How It Works
1. BackgroundManager tracks tasks with a thread-safe notification queue.
1. BackgroundManager tracks tasks with thread-safe concurrent containers. Java uses `ConcurrentHashMap` and `CopyOnWriteArrayList` instead of Python's manual locking.
```python
class BackgroundManager:
def __init__(self):
self.tasks = {}
self._notification_queue = []
self._lock = threading.Lock()
```java
public class BackgroundManager {
private static final int TIMEOUT_SECONDS = 300;
private final Map<String, TaskInfo> tasks = new ConcurrentHashMap<>();
private final List<Notification> notificationQueue = new CopyOnWriteArrayList<>();
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
record TaskInfo(String status, String result, String command) {}
public record Notification(String taskId, String status, String command, String result) {}
}
```
2. `run()` starts a daemon thread and returns immediately.
2. `backgroundRun()` submits a virtual thread (Java 21) and returns immediately. Compared to Python's `daemon=True` threads, virtual threads are lighter and scheduled by the JVM.
```python
def run(self, command: str) -> str:
task_id = str(uuid.uuid4())[:8]
self.tasks[task_id] = {"status": "running", "command": command}
thread = threading.Thread(
target=self._execute, args=(task_id, command), daemon=True)
thread.start()
return f"Background task {task_id} started"
```java
@Tool(description = "Run a command in a background thread. Returns task_id immediately without waiting.")
public String backgroundRun(
@ToolParam(description = "The shell command to run in background") String command) {
String taskId = UUID.randomUUID().toString().substring(0, 8);
tasks.put(taskId, new TaskInfo("running", null, command));
executor.submit(() -> execute(taskId, command));
return "Background task " + taskId + " started: "
+ command.substring(0, Math.min(80, command.length()));
}
```
3. When the subprocess finishes, its result goes into the notification queue.
3. When the subprocess finishes, the result goes into the notification queue. Uses `ProcessBuilder` for command execution with timeout control.
```python
def _execute(self, task_id, command):
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=300)
output = (r.stdout + r.stderr).strip()[:50000]
except subprocess.TimeoutExpired:
output = "Error: Timeout (300s)"
with self._lock:
self._notification_queue.append({
"task_id": task_id, "result": output[:500]})
```java
private void execute(String taskId, String command) {
String status, output;
try {
ProcessBuilder pb = new ProcessBuilder("sh", "-c", command);
pb.redirectErrorStream(true);
Process process = pb.start();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream()))) {
output = reader.lines().collect(Collectors.joining("\n"));
}
boolean finished = process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS);
if (!finished) { process.destroyForcibly(); status = "timeout"; }
else { status = "completed"; }
} catch (Exception e) { output = "Error: " + e.getMessage(); status = "error"; }
tasks.put(taskId, new TaskInfo(status, output, command));
notificationQueue.add(new Notification(taskId, status, command, output));
}
```
4. The agent loop drains notifications before each LLM call.
4. Drain the notification queue on each user input and inject into the system prompt. Spring AI's `ChatClient` manages the internal tool loop, so notifications are drained and built into the system prompt on each user input instead -- the core concept remains the same: fire and forget.
```python
def agent_loop(messages: list):
while True:
notifs = BG.drain_notifications()
if notifs:
notif_text = "\n".join(
f"[bg:{n['task_id']}] {n['result']}" for n in notifs)
messages.append({"role": "user",
"content": f"<background-results>\n{notif_text}\n"
f"</background-results>"})
messages.append({"role": "assistant",
"content": "Noted background results."})
response = client.messages.create(...)
```java
AgentRunner.interactive("s08", userMessage -> {
// Drain background task notifications (corresponds to Python's pre-loop drain_notifications)
var notifs = bgManager.drainNotifications();
String bgContext = "";
if (!notifs.isEmpty()) {
String notifText = notifs.stream()
.map(n -> "[bg:" + n.taskId() + "] " + n.status() + ": " + n.result())
.collect(Collectors.joining("\n"));
bgContext = "\n\n<background-results>\n" + notifText + "\n</background-results>";
}
String system = "You are a coding agent. Use backgroundRun for long-running commands."
+ bgContext;
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem(system)
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(), bgManager)
.build();
return chatClient.prompt().user(userMessage).call().content();
});
```
The loop stays single-threaded. Only subprocess I/O is parallelized.
## What Changed From s07
| Component | Before (s07) | After (s08) |
|----------------|------------------|----------------------------|
| Tools | 8 | 6 (base + background_run + check)|
| Execution | Blocking only | Blocking + background threads|
| Notification | None | Queue drained per loop |
| Concurrency | None | Daemon threads |
| Component | Before (s07) | After (s08) |
|----------------|------------------|------------------------------------|
| Tools | 8 | 6 (base + backgroundRun + check) |
| Execution | Blocking only | Blocking + virtual threads (Java 21)|
| Notification | None | ConcurrentLinkedQueue drained per turn |
| Concurrency | None | Virtual threads (lighter, JVM-scheduled) |
## Try It
```sh
cd learn-claude-code
python agents/s08_background_tasks.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s08.S08BackgroundTasks
```
Try these prompts (English prompts work better with LLMs, but Chinese also works):
1. `Run "sleep 5 && echo done" in the background, then create a file while it runs`
2. `Start 3 background tasks: "sleep 2", "sleep 4", "sleep 6". Check their status.`
3. `Run pytest in the background and keep working on other things`
+109 -61
View File
@@ -2,7 +2,7 @@
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > [ s09 ] s10 > s11 > s12`
> *"When the task is too big for one, delegate to teammates"* -- persistent teammates + async mailboxes.
> *"When the task is too big for one, delegate to teammates"* -- persistent teammates + JSONL mailboxes.
>
> **Harness layer**: Team mailboxes -- multiple models, coordinated through files.
@@ -10,7 +10,7 @@
Subagents (s04) are disposable: spawn, work, return summary, die. No identity, no memory between invocations. Background tasks (s08) run shell commands but can't make LLM-guided decisions.
Real teamwork needs: (1) persistent agents that outlive a single prompt, (2) identity and lifecycle management, (3) a communication channel between agents.
Real teamwork needs three things: (1) persistent agents that outlive a single prompt, (2) identity and lifecycle management, (3) a communication channel between agents.
## Solution
@@ -37,89 +37,137 @@ Communication:
## How It Works
1. TeammateManager maintains config.json with the team roster.
1. TeammateManager maintains the team roster via config.json.
```python
class TeammateManager:
def __init__(self, team_dir: Path):
self.dir = team_dir
self.dir.mkdir(exist_ok=True)
self.config_path = self.dir / "config.json"
self.config = self._load_config()
self.threads = {}
```java
// src/main/java/io/mybatis/learn/s09/TeammateManager.java
public class TeammateManager {
private final ChatModel chatModel;
private final MessageBus bus;
private final Path configPath;
private final ObjectMapper mapper = new ObjectMapper();
private Map<String, Object> config;
// Python uses threading.Thread + dict; Java uses ConcurrentHashMap for natural thread safety
private final Map<String, Thread> threads = new ConcurrentHashMap<>();
public TeammateManager(ChatModel chatModel, MessageBus bus, Path teamDir) {
this.chatModel = chatModel;
this.bus = bus;
this.configPath = teamDir.resolve("config.json");
Files.createDirectories(teamDir);
this.config = loadConfig();
}
```
2. `spawn()` creates a teammate and starts its agent loop in a thread.
```python
def spawn(self, name: str, role: str, prompt: str) -> str:
member = {"name": name, "role": role, "status": "working"}
self.config["members"].append(member)
self._save_config()
thread = threading.Thread(
target=self._teammate_loop,
args=(name, role, prompt), daemon=True)
thread.start()
return f"Spawned teammate '{name}' (role: {role})"
```java
// Python uses threading.Thread; Java uses Thread.startVirtualThread() for virtual threads
public synchronized String spawn(String name, String role, String prompt) {
Map<String, Object> member = new LinkedHashMap<>();
member.put("name", name);
member.put("role", role);
member.put("status", "working");
((List<Map<String, Object>>) config.get("members")).add(member);
saveConfig();
// Virtual thread: lightweight, JVM-scheduled, doesn't occupy OS threads
Thread thread = Thread.startVirtualThread(
() -> teammateLoop(name, role, prompt));
threads.put(name, thread);
return "Spawned '" + name + "' (role: " + role + ")";
}
```
3. MessageBus: append-only JSONL inboxes. `send()` appends a JSON line; `read_inbox()` reads all and drains.
```python
class MessageBus:
def send(self, sender, to, content, msg_type="message", extra=None):
msg = {"type": msg_type, "from": sender,
"content": content, "timestamp": time.time()}
if extra:
msg.update(extra)
with open(self.dir / f"{to}.jsonl", "a") as f:
f.write(json.dumps(msg) + "\n")
```java
// src/main/java/io/mybatis/learn/core/team/MessageBus.java
// Python relies on GIL for implicit thread safety; Java uses synchronized for explicit safety
public class MessageBus {
private final Path inboxDir;
private final ObjectMapper mapper = new ObjectMapper();
def read_inbox(self, name):
path = self.dir / f"{name}.jsonl"
if not path.exists(): return "[]"
msgs = [json.loads(l) for l in path.read_text().strip().splitlines() if l]
path.write_text("") # drain
return json.dumps(msgs, indent=2)
public synchronized String send(String sender, String to, String content,
String msgType, Map<String, Object> extra) {
Map<String, Object> msg = new LinkedHashMap<>();
msg.put("type", msgType);
msg.put("from", sender);
msg.put("content", content);
msg.put("timestamp", System.currentTimeMillis() / 1000.0);
if (extra != null) msg.putAll(extra);
Path inbox = inboxDir.resolve(to + ".jsonl");
Files.writeString(inbox, mapper.writeValueAsString(msg) + "\n",
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
return "Sent " + msgType + " to " + to;
}
public synchronized List<Map<String, Object>> readInbox(String name) {
Path inbox = inboxDir.resolve(name + ".jsonl");
if (!Files.exists(inbox)) return List.of();
List<Map<String, Object>> messages = new ArrayList<>();
for (String line : Files.readAllLines(inbox)) {
if (!line.isBlank())
messages.add(mapper.readValue(line, new TypeReference<>() {}));
}
Files.writeString(inbox, ""); // drain
return messages;
}
}
```
4. Each teammate checks its inbox before every LLM call, injecting received messages into context.
4. Each teammate checks its inbox between `call()` invocations, injecting messages into context. ChatClient's `call()` is equivalent to Python's full tool loop (looping until `stop_reason != "tool_use"`).
```python
def _teammate_loop(self, name, role, prompt):
messages = [{"role": "user", "content": prompt}]
for _ in range(50):
inbox = BUS.read_inbox(name)
if inbox != "[]":
messages.append({"role": "user",
"content": f"<inbox>{inbox}</inbox>"})
messages.append({"role": "assistant",
"content": "Noted inbox messages."})
response = client.messages.create(...)
if response.stop_reason != "tool_use":
break
# execute tools, append results...
self._find_member(name)["status"] = "idle"
```java
// Python teammates check inbox before each LLM call; Java checks between each call()
protected void teammateLoop(String name, String role, String initialPrompt) {
String sysPrompt = String.format(
"You are '%s', role: %s. Use send_message to communicate.",
name, role);
var messageTool = new TeammateMessageTool(bus, name);
ChatClient client = ChatClient.builder(chatModel)
.defaultSystem(sysPrompt)
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(), messageTool)
.build();
// Initial work (call() = full tool chain, equivalent to Python loop until stop_reason != "tool_use")
String response = client.prompt(initialPrompt).call().content();
// Check inbox between each call() (vs. Python's between each LLM call)
for (int round = 0; round < 50; round++) {
Thread.sleep(2000);
var inbox = bus.readInbox(name);
if (inbox.isEmpty()) break;
String inboxJson = mapper.writeValueAsString(inbox);
response = client.prompt("<inbox>" + inboxJson + "</inbox>").call().content();
}
setStatus(name, "idle");
}
```
## What Changed From s08
| Component | Before (s08) | After (s09) |
|----------------|------------------|----------------------------|
| Tools | 6 | 9 (+spawn/send/read_inbox) |
| Agents | Single | Lead + N teammates |
| Persistence | None | config.json + JSONL inboxes|
| Threads | Background cmds | Full agent loops per thread|
| Lifecycle | Fire-and-forget | idle -> working -> idle |
| Communication | None | message + broadcast |
| Component | Before (s08) | After (s09) |
|----------------|------------------|------------------------------------|
| Tools | 6 | 9 (+spawn/send/read_inbox) |
| Agents | Single | Lead + N teammates |
| Persistence | None | config.json + JSONL inboxes |
| Threads | Background cmds | Full agent loops per thread |
| Lifecycle | Fire-and-forget | idle -> working -> idle |
| Communication | None | message + broadcast |
## Try It
```sh
cd learn-claude-code
python agents/s09_agent_teams.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s09.S09AgentTeams
```
Try these prompts (English prompts work better with LLMs, but Chinese also works):
1. `Spawn alice (coder) and bob (tester). Have alice send bob a message.`
2. `Broadcast "status update: phase 1 complete" to all teammates`
3. `Check the lead inbox for any messages`
+61 -33
View File
@@ -10,7 +10,7 @@
In s09, teammates work and communicate but lack structured coordination:
**Shutdown**: Killing a thread leaves files half-written and config.json stale. You need a handshake: the lead requests, the teammate approves (finish and exit) or rejects (keep working).
**Shutdown**: Killing a thread leaves files half-written and config.json stale. You need a handshake -- the lead requests, the teammate approves (finish and exit) or rejects (keep working).
**Plan approval**: When the lead says "refactor the auth module," the teammate starts immediately. For high-risk changes, the lead should review the plan first.
@@ -44,61 +44,89 @@ Trackers:
1. The lead initiates shutdown by generating a request_id and sending through the inbox.
```python
shutdown_requests = {}
```java
// src/main/java/io/mybatis/learn/s10/ProtocolTracker.java
// Python uses dict + threading.Lock; Java uses ConcurrentHashMap for natural thread safety
private final ConcurrentHashMap<String, Map<String, String>> shutdownRequests
= new ConcurrentHashMap<>();
def handle_shutdown_request(teammate: str) -> str:
req_id = str(uuid.uuid4())[:8]
shutdown_requests[req_id] = {"target": teammate, "status": "pending"}
BUS.send("lead", teammate, "Please shut down gracefully.",
"shutdown_request", {"request_id": req_id})
return f"Shutdown request {req_id} sent (status: pending)"
public String handleShutdownRequest(String teammate) {
String reqId = UUID.randomUUID().toString().substring(0, 8);
shutdownRequests.put(reqId, new ConcurrentHashMap<>(Map.of(
"target", teammate, "status", "pending")));
bus.send("lead", teammate, "Please shut down gracefully.",
"shutdown_request", Map.of("request_id", reqId));
return "Shutdown request " + reqId + " sent to '" + teammate
+ "' (status: pending)";
}
```
2. The teammate receives the request and responds with approve/reject.
```python
if tool_name == "shutdown_response":
req_id = args["request_id"]
approve = args["approve"]
shutdown_requests[req_id]["status"] = "approved" if approve else "rejected"
BUS.send(sender, "lead", args.get("reason", ""),
"shutdown_response",
{"request_id": req_id, "approve": approve})
```java
// TeammateProtocolTool - teammates respond to shutdown requests via @Tool annotation
@Tool(description = "Respond to a shutdown request")
public String shutdownResponse(
@ToolParam(description = "The request_id") String requestId,
@ToolParam(description = "true to approve") boolean approve,
@ToolParam(description = "Reason for decision") String reason) {
return tracker.respondToShutdown(name, requestId, approve, reason);
}
// ProtocolTracker - updates tracker + sends response message
public String respondToShutdown(String sender, String requestId,
boolean approve, String reason) {
var req = shutdownRequests.get(requestId);
if (req != null) {
req.put("status", approve ? "approved" : "rejected");
}
bus.send(sender, "lead", reason != null ? reason : "",
"shutdown_response",
Map.of("request_id", requestId, "approve", approve));
return "Shutdown " + (approve ? "approved" : "rejected");
}
```
3. Plan approval follows the identical pattern. The teammate submits a plan (generating a request_id), the lead reviews (referencing the same request_id).
```python
plan_requests = {}
```java
// ProtocolTracker - same request_id correlation pattern, two use cases
private final ConcurrentHashMap<String, Map<String, String>> planRequests
= new ConcurrentHashMap<>();
def handle_plan_review(request_id, approve, feedback=""):
req = plan_requests[request_id]
req["status"] = "approved" if approve else "rejected"
BUS.send("lead", req["from"], feedback,
"plan_approval_response",
{"request_id": request_id, "approve": approve})
public String reviewPlan(String requestId, boolean approve, String feedback) {
var req = planRequests.get(requestId);
if (req == null) return "Error: Unknown plan request_id '" + requestId + "'";
req.put("status", approve ? "approved" : "rejected");
bus.send("lead", req.get("from"), feedback != null ? feedback : "",
"plan_approval_response",
Map.of("request_id", requestId, "approve", approve,
"feedback", feedback != null ? feedback : ""));
return "Plan " + req.get("status") + " for '" + req.get("from") + "'";
}
```
One FSM, two applications. The same `pending -> approved | rejected` state machine handles any request-response protocol.
## What Changed From s09
| Component | Before (s09) | After (s10) |
|----------------|------------------|------------------------------|
| Tools | 9 | 12 (+shutdown_req/resp +plan)|
| Shutdown | Natural exit only| Request-response handshake |
| Plan gating | None | Submit/review with approval |
| Correlation | None | request_id per request |
| FSM | None | pending -> approved/rejected |
| Component | Before (s09) | After (s10) |
|----------------|------------------|--------------------------------------|
| Tools | 9 | 12 (+shutdown_req/resp +plan) |
| Shutdown | Natural exit only| Request-response handshake |
| Plan gating | None | Submit/review with approval |
| Correlation | None | request_id per request |
| FSM | None | pending -> approved/rejected |
## Try It
```sh
cd learn-claude-code
python agents/s10_team_protocols.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s10.S10TeamProtocols
```
Try these prompts (English prompts work better with LLMs, but Chinese also works):
1. `Spawn alice as a coder. Then request her shutdown.`
2. `List teammates to see alice's status after shutdown approval`
3. `Spawn bob with a risky refactoring task. Review and reject his plan.`
+120 -69
View File
@@ -2,17 +2,17 @@
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > [ s11 ] s12`
> *"Teammates scan the board and claim tasks themselves"* -- no need for the lead to assign each one.
> *"Teammates scan the board and claim tasks themselves"* -- no need for the lead to assign each one. Self-organizing.
>
> **Harness layer**: Autonomy -- models that find work without being told.
## Problem
In s09-s10, teammates only work when explicitly told to. The lead must spawn each one with a specific prompt. 10 unclaimed tasks on the board? The lead assigns each one manually. Doesn't scale.
In s09-s10, teammates only work when explicitly told to. The lead must write a prompt for each teammate. 10 unclaimed tasks on the board? The lead assigns each one manually. Doesn't scale.
True autonomy: teammates scan the task board themselves, claim unclaimed tasks, work on them, then look for more.
One subtlety: after context compression (s06), the agent might forget who it is. Identity re-injection fixes this.
One subtlety: after context compaction (s06), the agent might forget who it is. Identity re-injection fixes this.
## Solution
@@ -40,101 +40,152 @@ Teammate lifecycle with idle cycle:
|
+---> 60s timeout ----------------------> SHUTDOWN
Identity re-injection after compression:
if len(messages) <= 3:
messages.insert(0, identity_block)
Identity via system prompt (always present):
ChatClient.builder(chatModel)
.defaultSystem(identityPrompt) // automatically included in every call
```
## How It Works
1. The teammate loop has two phases: WORK and IDLE. When the LLM stops calling tools (or calls `idle`), the teammate enters IDLE.
```python
def _loop(self, name, role, prompt):
while True:
# -- WORK PHASE --
messages = [{"role": "user", "content": prompt}]
for _ in range(50):
response = client.messages.create(...)
if response.stop_reason != "tool_use":
break
# execute tools...
if idle_requested:
break
```java
// src/main/java/io/mybatis/learn/s11/S11AutonomousAgents.java
// AutonomousTeammateManager.autonomousLoop()
# -- IDLE PHASE --
self._set_status(name, "idle")
resume = self._idle_poll(name, messages)
if not resume:
self._set_status(name, "shutdown")
return
self._set_status(name, "working")
private void autonomousLoop(String name, String role, String initialPrompt) {
// idle flag: set by tool call, detected by outer loop
AtomicBoolean idleRequested = new AtomicBoolean(false);
var idleTool = new IdleTool(idleRequested);
ChatClient client = ChatClient.builder(chatModel)
.defaultSystem(sysPrompt)
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(),
messageTool, protocolTool, idleTool, claimTool)
.build();
while (true) {
// -- WORK PHASE --
String nextMsg = initialPrompt;
for (int round = 0; round < 50 && nextMsg != null; round++) {
var inbox = bus.readInbox(name);
// ... merge inbox messages into nextMsg ...
idleRequested.set(false);
String response = client.prompt(sb.toString()).call().content();
if (idleRequested.get()) break; // idle tool was called
nextMsg = null; // subsequent rounds are inbox-driven
}
// -- IDLE PHASE --
setStatus(name, "idle");
// ... poll inbox + task board (see below) ...
if (!resume) { setStatus(name, "shutdown"); return; }
setStatus(name, "working");
}
}
```
2. The idle phase polls inbox and task board in a loop.
```python
def _idle_poll(self, name, messages):
for _ in range(IDLE_TIMEOUT // POLL_INTERVAL): # 60s / 5s = 12
time.sleep(POLL_INTERVAL)
inbox = BUS.read_inbox(name)
if inbox:
messages.append({"role": "user",
"content": f"<inbox>{inbox}</inbox>"})
return True
unclaimed = scan_unclaimed_tasks()
if unclaimed:
claim_task(unclaimed[0]["id"], name)
messages.append({"role": "user",
"content": f"<auto-claimed>Task #{unclaimed[0]['id']}: "
f"{unclaimed[0]['subject']}</auto-claimed>"})
return True
return False # timeout -> shutdown
```java
// IDLE PHASE: poll inbox + task board
setStatus(name, "idle");
boolean resume = false;
int polls = IDLE_TIMEOUT / Math.max(POLL_INTERVAL, 1); // 60/5 = 12
for (int p = 0; p < polls; p++) {
Thread.sleep(POLL_INTERVAL * 1000L);
// Check inbox
var inbox = bus.readInbox(name);
if (!inbox.isEmpty()) {
initialPrompt = "<inbox>" + mapper.writeValueAsString(inbox) + "</inbox>";
resume = true;
break;
}
// Scan task board
var unclaimed = scanUnclaimedTasks(tasksDir);
if (!unclaimed.isEmpty()) {
var task = unclaimed.get(0);
int taskId = ((Number) task.get("id")).intValue();
claimTask(tasksDir, taskId, name);
initialPrompt = String.format(
"<auto-claimed>Task #%d: %s\n%s</auto-claimed>",
taskId, task.get("subject"),
task.getOrDefault("description", ""));
resume = true;
break;
}
}
if (!resume) { setStatus(name, "shutdown"); return; }
setStatus(name, "working");
```
3. Task board scanning: find pending, unowned, unblocked tasks.
```python
def scan_unclaimed_tasks() -> list:
unclaimed = []
for f in sorted(TASKS_DIR.glob("task_*.json")):
task = json.loads(f.read_text())
if (task.get("status") == "pending"
and not task.get("owner")
and not task.get("blockedBy")):
unclaimed.append(task)
return unclaimed
```java
static List<Map<String, Object>> scanUnclaimedTasks(Path tasksDir) {
if (!Files.exists(tasksDir)) return List.of();
List<Map<String, Object>> unclaimed = new ArrayList<>();
ObjectMapper mapper = new ObjectMapper();
try (var files = Files.list(tasksDir)) {
files.filter(f -> f.getFileName().toString().startsWith("task_")
&& f.getFileName().toString().endsWith(".json"))
.sorted()
.forEach(f -> {
Map<String, Object> task = mapper.readValue(f.toFile(), Map.class);
if ("pending".equals(task.get("status"))
&& (task.get("owner") == null || "".equals(task.get("owner")))
&& (task.get("blockedBy") == null
|| ((List<?>) task.get("blockedBy")).isEmpty())) {
unclaimed.add(task);
}
});
}
return unclaimed;
}
```
4. Identity re-injection: when context is too short (compression happened), insert an identity block.
4. Identity persistence: Java/Spring AI's `ChatClient.defaultSystem()` automatically includes the system prompt in every call, so identity is always present -- no need to manually re-inject after compaction as in the Python version.
```python
if len(messages) <= 3:
messages.insert(0, {"role": "user",
"content": f"<identity>You are '{name}', role: {role}, "
f"team: {team_name}. Continue your work.</identity>"})
messages.insert(1, {"role": "assistant",
"content": f"I am {name}. Continuing."})
```java
// Identity is injected via defaultSystem at build time, automatically included in every prompt
String sysPrompt = String.format(
"You are '%s', role: %s, team: %s, at %s. "
+ "Use idle tool when you have no more work. You will auto-claim new tasks.",
name, role, teamName, workDir);
ChatClient client = ChatClient.builder(chatModel)
.defaultSystem(sysPrompt) // Identity always present in system prompt
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(),
messageTool, protocolTool, idleTool, claimTool)
.build();
```
## What Changed From s10
| Component | Before (s10) | After (s11) |
|----------------|------------------|----------------------------|
| Tools | 12 | 14 (+idle, +claim_task) |
| Autonomy | Lead-directed | Self-organizing |
| Idle phase | None | Poll inbox + task board |
| Task claiming | Manual only | Auto-claim unclaimed tasks |
| Identity | System prompt | + re-injection after compress|
| Timeout | None | 60s idle -> auto shutdown |
| Component | Before (s10) | After (s11) |
|----------------|------------------|----------------------------------|
| Tools | 12 | 14 (+idle, +claim_task) |
| Autonomy | Lead-directed | Self-organizing |
| Idle phase | None | Poll inbox + task board |
| Task claiming | Manual only | Auto-claim unclaimed tasks |
| Identity | System prompt | + re-injection after compaction |
| Timeout | None | 60s idle -> auto shutdown |
## Try It
```sh
cd learn-claude-code
python agents/s11_autonomous_agents.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s11.S11AutonomousAgents
```
Try these prompts (English prompts work better with LLMs, but Chinese also works):
1. `Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim.`
2. `Spawn a coder teammate and let it find work from the task board itself`
3. `Create tasks with dependencies. Watch teammates respect the blocked order.`
+51 -26
View File
@@ -8,7 +8,7 @@
## Problem
By s11, agents can claim and complete tasks autonomously. But every task runs in one shared directory. Two agents refactoring different modules at the same time will collide: agent A edits `config.py`, agent B edits `config.py`, unstaged changes mix, and neither can roll back cleanly.
By s11, agents can claim and complete tasks autonomously. But every task runs in one shared directory. Two agents refactoring different modules at the same time will collide -- agent A edits `Config.java`, agent B also edits `Config.java`, unstaged changes mix, and neither can roll back cleanly.
The task board tracks *what to do* but has no opinion about *where to do it*. The fix: give each task its own git worktree directory. Tasks manage goals, worktrees manage execution context. Bind them by task ID.
@@ -38,48 +38,71 @@ State machines:
1. **Create a task.** Persist the goal first.
```python
TASKS.create("Implement auth refactor")
# -> .tasks/task_1.json status=pending worktree=""
```java
// src/main/java/io/mybatis/learn/s12/WorktreeTaskManager.java
tasks.create("Implement auth refactor", "");
// -> .tasks/task_1.json status=pending worktree=""
```
2. **Create a worktree and bind to the task.** Passing `task_id` auto-advances the task to `in_progress`.
```python
WORKTREES.create("auth-refactor", task_id=1)
# -> git worktree add -b wt/auth-refactor .worktrees/auth-refactor HEAD
# -> index.json gets new entry, task_1.json gets worktree="auth-refactor"
```java
// src/main/java/io/mybatis/learn/s12/WorktreeManager.java
worktrees.create("auth-refactor", 1, "HEAD");
// -> git worktree add -b wt/auth-refactor .worktrees/auth-refactor HEAD
// -> index.json gets new entry, task_1.json gets worktree="auth-refactor"
```
The binding writes state to both sides:
```python
def bind_worktree(self, task_id, worktree):
task = self._load(task_id)
task["worktree"] = worktree
if task["status"] == "pending":
task["status"] = "in_progress"
self._save(task)
```java
// src/main/java/io/mybatis/learn/s12/WorktreeTaskManager.java
public String bindWorktree(int taskId, String worktree, String owner) {
var task = load(taskId);
task.put("worktree", worktree);
if (owner != null && !owner.isEmpty()) task.put("owner", owner);
if ("pending".equals(task.get("status"))) task.put("status", "in_progress");
task.put("updated_at", System.currentTimeMillis() / 1000.0);
save(task);
return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(task);
}
```
3. **Run commands in the worktree.** `cwd` points to the isolated directory.
```python
subprocess.run(command, shell=True, cwd=worktree_path,
capture_output=True, text=True, timeout=300)
```java
// src/main/java/io/mybatis/learn/s12/WorktreeManager.java - run()
boolean isWindows = System.getProperty("os.name").toLowerCase().contains("win");
ProcessBuilder pb = isWindows
? new ProcessBuilder("cmd", "/c", command)
: new ProcessBuilder("sh", "-c", command);
pb.directory(path.toFile());
pb.redirectErrorStream(true);
Process p = pb.start();
String out = new String(p.getInputStream().readAllBytes()).trim();
boolean finished = p.waitFor(300, java.util.concurrent.TimeUnit.SECONDS);
```
4. **Close out.** Two choices:
- `worktree_keep(name)` -- preserve the directory for later.
- `worktree_remove(name, complete_task=True)` -- remove directory, complete the bound task, emit event. One call handles teardown + completion.
```python
def remove(self, name, force=False, complete_task=False):
self._run_git(["worktree", "remove", wt["path"]])
if complete_task and wt.get("task_id") is not None:
self.tasks.update(wt["task_id"], status="completed")
self.tasks.unbind_worktree(wt["task_id"])
self.events.emit("task.completed", ...)
```java
// src/main/java/io/mybatis/learn/s12/WorktreeManager.java
public String remove(String name, boolean force, boolean completeTask) {
var wt = findWorktree(name);
events.emit("worktree.remove.before", ...);
runGit("worktree", "remove", wt.get("path").toString());
if (completeTask && wt.get("task_id") != null) {
int taskId = ((Number) wt.get("task_id")).intValue();
tasks.update(taskId, "completed", null);
tasks.unbindWorktree(taskId);
events.emit("task.completed",
Map.of("id", taskId, "status", "completed"),
Map.of("name", name), null);
}
// Update index.json: status -> "removed"
}
```
5. **Event stream.** Every lifecycle step emits to `.worktrees/events.jsonl`:
@@ -111,9 +134,11 @@ After a crash, state reconstructs from `.tasks/` + `.worktrees/index.json` on di
```sh
cd learn-claude-code
python agents/s12_worktree_task_isolation.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s12.S12WorktreeIsolation
```
Try these prompts (English prompts work better with LLMs, but Chinese also works):
1. `Create tasks for backend auth and frontend login page, then list tasks.`
2. `Create worktree "auth-refactor" for task 1, then bind task 2 to a new worktree "ui-login".`
3. `Run "git status --short" in worktree "auth-refactor".`
+266 -64
View File
@@ -1,4 +1,4 @@
# s01: The Agent Loop
# s01: The Agent Loop (エージェントループ)
`[ s01 ] s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
@@ -8,7 +8,7 @@
## 問題
言語モデルはコードについて推論できるが、現実世界に触れられないファイルを読めず、テストを実行できず、エラーを確認できない。ループがなければ、ツール呼び出しのたびにユーザーが手動で結果をコピーペーストする必要がある。つまりユーザー自身がループになる。
言語モデルはコードについて推論できるが、現実世界に触れられない -- ファイルを読めず、テストを実行できず、エラーを確認できない。ループがなければ、ツール呼び出しのたびに手動で結果を貼り戻す必要がある。あなた自身がそのループになる。
## 解決策
@@ -20,97 +20,299 @@
^ |
| tool_result |
+----------------+
(loop until stop_reason != "tool_use")
(ChatClient.call() がツール呼び出しがなくなるまで自動ループ)
```
1つの終了条件がフロー全体を制御する。モデルがツール呼び出しを止めるまでループが回り続ける。
1つの `call()` 呼び出しがフロー全体を制御する。Spring AI が自動的にループし、モデルがツール呼び出しを止めるまで続ける。
## 仕組み
1. ユーザーのプロンプトが最初のメッセージになる。
### 1. ChatClient の構築:モデル注入 + ツール登録
```python
messages.append({"role": "user", "content": query})
Spring Boot の自動設定で `ChatModel` を注入し、`ChatClient.builder()` でクライアントを構築、システムプロンプトとツールを設定する。
```java
// TIP: Python 版ではモジュールレベルで client = Anthropic() と MODEL を作成。
// Spring AI は自動設定で ChatModel を注入し、builder で ChatClient を構築する。
public S01AgentLoop(ChatModel chatModel) {
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent at " + System.getProperty("user.dir")
+ ". Use bash to solve tasks. Act, don't explain.")
.defaultTools(new BashTool()) // @Tool アノテーション付きツールオブジェクト
.build();
}
```
2. メッセージとツール定義をLLMに送信する。
### 2. `@Tool` アノテーション:宣言的ツール登録
```python
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
Spring AI は `@Tool` アノテーションでツールを自動的に検出・登録する。起動時にフレームワークが `defaultTools()` に渡されたオブジェクトをスキャンし、すべての `@Tool` メソッドのシグネチャと説明を抽出し、LLM が必要とするツールスキーマ(名前、パラメータ、説明)を生成して、毎回の `call()` リクエストに自動的に含める。
```java
// BashTool -- Python 版の run_bash() 関数に相当
public class BashTool {
@Tool(description = "Run a shell command and return stdout + stderr")
public String bash(@ToolParam(description = "The shell command to execute")
String command) {
// 危険コマンドチェック + ProcessBuilder 実行 + タイムアウト制御 + 出力切り詰め
// ...
}
}
```
3. アシスタントのレスポンスを追加し、`stop_reason`を確認する。ツールが呼ばれなければ終了。
> Python の手動登録方式との比較:
> - Python: `TOOLS = [{"name": "bash", "input_schema": {...}}]` + `TOOL_HANDLERS = {"bash": run_bash}`
> - Java: `@Tool` + `@ToolParam` アノテーションだけで、フレームワークがスキーマ生成とメソッドディスパッチを自動化
### 3. Spring AI 内部自動ループ:`call()` の内部実装
**これが Java 版と Python 版の最も重要な違いだ。** Python 版ではツール呼び出しを駆動するために手書きの while ループが必要:
```python
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
```
4. 各ツール呼び出しを実行し、結果を収集してuserメッセージとして追加。ステップ2に戻る。
```python
results = []
for block in response.content:
if block.type == "tool_use":
output = run_bash(block.input["command"])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
```
1つの関数にまとめると:
```python
def agent_loop(query):
messages = [{"role": "user", "content": query}]
# Python 版 -- 手動ループ
def agent_loop(messages):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
response = client.messages.create(model=MODEL, messages=messages, tools=TOOLS)
# assistant メッセージを収集
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
return response # モデルがツールを呼ばなくなった、ループ終了
# ツールを実行して結果を返送
for block in response.content:
if block.type == "tool_use":
output = run_bash(block.input["command"])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
result = TOOL_HANDLERS[block.name](block.input)
messages.append({"role": "user", "content": [{"type": "tool_result", ...}]})
```
これでエージェント全体が30行未満に収まる。本コースの残りはすべてこのループの上に積み重なる -- ループ自体は変わらない。
Spring AI の `ChatClient.call()` は**完全に等価なロジックを内部にカプセル化**している:
```
call() 内部フロー:
┌─────────────────────────────────────────────────────┐
│ 1. リクエスト組み立て: system prompt + user msg + tools │
│ 2. LLM に送信 │
│ 3. レスポンス解析 │
│ ├── tool_use あり? ──→ はい: │
│ │ a. ツール名と引数を抽出 │
│ │ b. リフレクションで対応する @Tool メソッドを呼出 │
│ │ c. tool_result をメッセージリストに追加 │
│ │ d. ステップ 2 に戻る(自動ループ) │
│ └── いいえ ──→ 最終テキストを返す │
└─────────────────────────────────────────────────────┘
```
キーポイント:
- **ツール検出**: Spring AI はレスポンスに `tool_use` タイプのコンテンツブロックがあるかチェック(Python の `stop_reason == "tool_use"` に相当)
- **リフレクションディスパッチ**: フレームワークが Java リフレクションで、LLM が返したツール名に対応する `@Tool` メソッドを見つけて呼び出す(Python の `TOOL_HANDLERS[block.name]` に相当)
- **結果返送**: ツール実行結果は自動的に `tool_result` メッセージとして会話に追加(Python が手動で `tool_result` コンテンツブロックを構築するのに相当)
- **ループ終了**: モデルが純粋なテキスト(ツール呼び出しなし)を返すと、`call()` が最終結果を返す
従って、Python 版の約15行の while ループは、Java 版では1行の `.call()` に凝縮される。
### 4. `AgentRunner.interactive()`REPL インタラクションループ
`AgentRunner` は全レッスン共通の REPLRead-Eval-Print Loop)ユーティリティクラスで、Python の `if __name__ == "__main__"` 内の `input()` ループに相当する。
```java
public class AgentRunner {
/**
* インタラクティブ REPL ループを開始。
* @param prefix プロンプトプレフィックス(例: "s01"
* @param handler ユーザー入力を処理し Agent レスポンスを返す関数
*/
public static void interactive(String prefix, Function<String, String> handler) {
Scanner scanner = new Scanner(System.in);
System.out.println("'q' または 'exit' で終了");
while (true) {
System.out.print("\033[36m" + prefix + " >> \033[0m"); // カラープロンプト
String input;
try {
if (!scanner.hasNextLine()) break;
input = scanner.nextLine().trim();
} catch (Exception e) {
break;
}
if (input.isEmpty() || "exit".equalsIgnoreCase(input) || "q".equalsIgnoreCase(input)) {
break;
}
try {
String response = handler.apply(input); // Agent ハンドラーを呼び出し
if (response != null && !response.isBlank()) {
System.out.println(response);
}
} catch (Exception e) {
System.err.println("Error: " + e.getMessage());
}
System.out.println();
}
System.out.println("Bye!");
}
}
```
ワークフロー:`Scanner` で入力読み取り → `handler.apply()` で Agent に送信 → レスポンス出力 → ループ。`handler` は関数型インターフェースで、各レッスンが自分の Agent 呼び出しロジックを渡す。
### 5. 完全な Agent クラスとして組み立て
```java
@SpringBootApplication(scanBasePackages = "io.mybatis.learn.core")
public class S01AgentLoop implements CommandLineRunner {
private final ChatClient chatClient;
public S01AgentLoop(ChatModel chatModel) {
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent at ...")
.defaultTools(new BashTool())
.build();
}
@Override
public void run(String... args) {
AgentRunner.interactive("s01", userMessage ->
chatClient.prompt()
.user(userMessage)
.call() // ← この1つの呼び出し = Python の while ループ全体
.content()
);
}
}
```
> **TIPS — Python → Java 主要な適応ポイント:**
> - Python の `while True` + `stop_reason` 手動ループ → Spring AI `ChatClient.call()` 内蔵自動ループ
> - Python の `TOOLS` 配列 + `TOOL_HANDLERS` 辞書 → `@Tool` アノテーション + `defaultTools()` 自動登録とリフレクションディスパッチ
> - Python の `client = Anthropic()` → Spring Boot 自動設定で `ChatModel` を注入
> - Python の `input()` インタラクション → `AgentRunner.interactive()` が Scanner REPL + 関数型インターフェースをカプセル化
コアコード40行未満、これがエージェント全体だ。残り11章はすべてこのループの上にメカニズムを積み重ねる -- ループ自体は決して変わらない。
## 変更点
| Component | Before | After |
|---------------|------------|--------------------------------|
| Agent loop | (none) | `while True` + stop_reason |
| Tools | (none) | `bash` (one tool) |
| Messages | (none) | Accumulating list |
| Control flow | (none) | `stop_reason != "tool_use"` |
| コンポーネント | 変更前 | 変更後 |
|---------------|------------|--------------------------------------------------|
| Agent loop | (なし) | `ChatClient.call()` 内蔵ツールループ |
| Tools | (なし) | `BashTool` (単一の `@Tool` ツール) |
| Messages | (なし) | Spring AI が内部でメッセージリストを管理 |
| Control flow | (なし) | フレームワークが自動判定: ツール呼び出しなしで最終テキストを返す |
```java
// コアコード -- 構築 + 呼び出し
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent ...")
.defaultTools(new BashTool())
.build();
AgentRunner.interactive("s01", userMessage ->
chatClient.prompt().user(userMessage).call().content()
);
```
## 試してみる
```sh
cd learn-claude-code
python agents/s01_agent_loop.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s01.S01AgentLoop
```
1. `Create a file called hello.py that prints "Hello, World!"`
2. `List all Python files in this directory`
> 実行前に環境変数の設定が必要: `AI_API_KEY`, `AI_BASE_URL`, `AI_MODEL`
>
> **デフォルトプロトコルは OpenAI**OpenAI 公式、Azure OpenAI、OpenAI 互換インターフェースを提供するサードパーティモデルサービスなど、すべての OpenAI API 形式のサービスに対応)。
> Anthropic プロトコル(Claude ネイティブ API)を使用する場合は、以下のセクションを展開してください。
<details>
<summary><strong>AI プロトコルの切り替え(OpenAI ↔ Anthropic</strong></summary>
このプロジェクトは **Spring AI の Starter 依存 + 設定ファイル** で基盤プロトコルを切り替える。Java ビジネスコード(`ChatModel``ChatClient`)は**変更不要**。
#### 方式 1:OpenAI プロトコル(デフォルト)
`pom.xml` の依存:
```xml
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
```
`application.yml` の設定:
```yaml
spring:
ai:
openai:
api-key: ${AI_API_KEY:sk-xxx}
base-url: ${AI_BASE_URL:https://api.openai.com}
chat:
options:
model: ${AI_MODEL:gpt-4o}
```
環境変数の例:
```sh
export AI_API_KEY=sk-proj-xxxxxxxx
export AI_BASE_URL=https://api.openai.com # 任意の OpenAI 互換エンドポイントに変更可
export AI_MODEL=gpt-4o
```
> **TIP**: 多くのサードパーティモデルサービス(DeepSeek、Mistral、Qwen など)が OpenAI 互換 API を提供している。`AI_BASE_URL` と `AI_MODEL` を変更するだけで接続でき、プロトコル切り替えは不要。
#### 方式 2Anthropic プロトコル(Claude ネイティブ API)
**ステップ 1**`pom.xml` を編集 — OpenAI starter を Anthropic starter に置き換え:
```xml
<!-- OpenAI starter をコメントアウトまたは削除 -->
<!-- <dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency> -->
<!-- Anthropic starter を追加 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-anthropic</artifactId>
</dependency>
```
**ステップ 2**`application.yml` を編集 — `spring.ai.openai``spring.ai.anthropic` に置き換え:
```yaml
spring:
ai:
anthropic:
api-key: ${AI_API_KEY}
base-url: ${AI_BASE_URL:https://api.anthropic.com}
chat:
options:
model: ${AI_MODEL:claude-sonnet-4-20250514}
```
**ステップ 3**:環境変数を設定:
```sh
export AI_API_KEY=sk-ant-xxxxxxxx
export AI_BASE_URL=https://api.anthropic.com
export AI_MODEL=claude-sonnet-4-20250514
```
#### 切り替えの仕組み
Spring AI の `ChatModel` は統一された抽象インターフェース。異なる Starter が異なる実装を提供する:
| Starter 依存 | 自動注入される ChatModel 実装 | 設定プレフィックス |
|---|---|---|
| `spring-ai-starter-model-openai` | `OpenAiChatModel` | `spring.ai.openai.*` |
| `spring-ai-starter-model-anthropic` | `AnthropicChatModel` | `spring.ai.anthropic.*` |
ビジネスコードは常に `ChatModel` インターフェースに対してプログラムする。プロトコル切り替えには依存と設定の変更だけが必要で、Java コードの変更は不要。
</details>
以下のプロンプトを試してみよう(英語プロンプトの方が LLM に効果的だが、日本語でも可):
1. `Create a file called Hello.java that prints "Hello, World!"`
2. `List all Java files in this directory`
3. `What is the current git branch?`
4. `Create a directory called test_output and write 3 files in it`
+102 -62
View File
@@ -1,99 +1,139 @@
# s02: Tool Use
# s02: Tool Use (ツール使用)
`s01 > [ s02 ] s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"ツールを足すなら、ハンドラーを1つ足すだけ"* -- ループは変わらない。新ツールは dispatch map に登録するだけ。
> *"ツールを足すなら、@Tool メソッドを1つ足すだけ"* -- ループは変わらない。新ツールは `defaultTools()` に渡すだけ。
>
> **Harness 層**: ツール分配 -- モデルが届く範囲を広げる。
## 問題
`bash`だけでは、エージェントは何でもシェル経由で行う`cat`は予測不能に切り詰め、`sed`は特殊文字で壊れ、すべてのbash呼び出しが制約のないセキュリティ面になる。`read_file``write_file`のような専用ツールならツールレベルでパスのサンドボックス化を強制できる。
`bash` だけでは、すべての操作がシェル経由になる`cat` は予測不能に切り詰め、`sed` は特殊文字で壊れ、すべての bash 呼び出しが制約のないセキュリティ面になる。専用ツール (`read_file`, `write_file`) ならツールレベルでパスのサンドボックス化を強制できる。
重要な: ツールを追加してもループの変更は不要。
重要な洞察: ツールを追加してもループの変更は不要。
## 解決策
```
+--------+ +-------+ +------------------+
| User | ---> | LLM | ---> | Tool Dispatch |
| prompt | | | | { |
+--------+ +---+---+ | bash: run_bash |
^ | read: run_read |
| | write: run_wr |
+-----------+ edit: run_edit |
tool_result | } |
+------------------+
+--------+ +-------+ +--------------------+
| User | ---> | LLM | ---> | defaultTools() |
| prompt | | | | { |
+--------+ +---+---+ | BashTool |
^ | ReadFileTool |
| | WriteFileTool |
+-----------+ EditFileTool |
tool_result | } |
+--------------------+
The dispatch map is a dict: {tool_name: handler_function}.
One lookup replaces any if/elif chain.
Spring AI が @Tool アノテーションで自動的に登録・分配する。
手書きの dispatch map は不要、フレームワークがツールオブジェクトのアノテーションメソッドをスキャンする。
```
## 仕組み
1. 各ツールにハンドラ関数を定義する。パスサンドボックスでワークスペース外への脱出を防ぐ。
1. 各ツールは独立したクラスで、`@Tool` アノテーションで宣言する。`PathValidator`パスサンドボックスでワークスペース外への脱出を防ぐ。
```python
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
```java
// PathValidator -- Python 版の safe_path() 関数に相当
public class PathValidator {
private final Path workDir;
def run_read(path: str, limit: int = None) -> str:
text = safe_path(path).read_text()
lines = text.splitlines()
if limit and limit < len(lines):
lines = lines[:limit]
return "\n".join(lines)[:50000]
```
public Path resolve(String relativePath) {
Path resolved = workDir.resolve(relativePath).toAbsolutePath().normalize();
if (!resolved.startsWith(workDir)) {
throw new IllegalArgumentException("Path escapes workspace: " + relativePath);
}
return resolved;
}
}
2. ディスパッチマップがツール名とハンドラを結びつける。
// ReadFileTool -- Python 版の run_read() 関数に相当
public class ReadFileTool {
private final PathValidator pathValidator;
```python
TOOL_HANDLERS = {
"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"],
kw["new_text"]),
@Tool(description = "Read file contents. Optionally limit the number of lines returned.")
public String readFile(
@ToolParam(description = "Relative path to the file") String path,
@ToolParam(description = "Maximum number of lines to read", required = false) Integer limit) {
Path filePath = pathValidator.resolve(path);
List<String> lines = Files.readAllLines(filePath);
if (limit != null && limit > 0 && limit < lines.size()) {
lines = lines.subList(0, limit);
}
return String.join("\n", lines);
}
}
```
3. ループ内で名前によりハンドラをルックアップする。ループ本体はs01から不変
2. ツール登録は `defaultTools()` に渡すだけ。Spring AI が `@Tool` アノテーションメソッドをスキャンし、名前マッピングとパラメータバインディングを自動的に行う
```python
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler \
else f"Unknown tool: {block.name}"
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
```java
// Python 版の TOOL_HANDLERS 辞書に相当
// Python: TOOL_HANDLERS = {"bash": fn, "read_file": fn, "write_file": fn, "edit_file": fn}
// Java: ツールオブジェクトを渡すだけ、@Tool アノテーションで自動登録
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent ...")
.defaultTools(
new BashTool(), // bash コマンド実行
new ReadFileTool(), // ファイル読み取り
new WriteFileTool(), // ファイル書き込み
new EditFileTool() // ファイル編集(検索置換)
)
.build();
```
ツール追加 = ハンドラ追加 + スキーマ追加。ループは決して変わらない
3. 呼び出しコードは s01 と完全に同一。ループはフレームワークが管理し、開発者はツール実装だけに集中する
## s01からの変更点
```java
// s01 との違いは defaultTools() に3つのツールオブジェクトが追加されたこと
// ループコードは完全に同一 -- これが s02 の核心的な洞察
AgentRunner.interactive("s02", userMessage ->
chatClient.prompt()
.user(userMessage)
.call()
.content()
);
```
| Component | Before (s01) | After (s02) |
|----------------|--------------------|----------------------------|
| Tools | 1 (bash only) | 4 (bash, read, write, edit)|
| Dispatch | Hardcoded bash call | `TOOL_HANDLERS` dict |
| Path safety | None | `safe_path()` sandbox |
| Agent loop | Unchanged | Unchanged |
ツール追加 = `@Tool` クラスを1つ追加 + `defaultTools()` に渡す。ループは決して変わらない。
> **TIPS — Python → Java 主要な適応ポイント:**
> - Python の `TOOL_HANDLERS` 辞書 → Spring AI `@Tool` アノテーション + `defaultTools()` 自動登録・分配
> - Python の `safe_path()` 関数 → `PathValidator` クラス(同じパス脱出チェックロジック)
> - Python の `lambda **kw` パラメータ展開 → `@ToolParam` アノテーションで自動バインディング
> - Python の `block.type == "tool_use"` 判定 → Spring AI が内部で自動検出・分配
## s01 からの変更点
| コンポーネント | 変更前 (s01) | 変更後 (s02) |
|----------------|-----------------------|----------------------------------------|
| Tools | 1 (`BashTool`) | 4 (`Bash`, `ReadFile`, `WriteFile`, `EditFile`) |
| Dispatch | `defaultTools(bash)` | `defaultTools(bash, read, write, edit)` |
| パス安全性 | なし | `PathValidator` サンドボックス |
| Agent loop | 不変 | 不変 |
```java
// s01 → s02 唯一の変更: defaultTools() に3つのツールオブジェクトを追加
.defaultTools(
new BashTool(),
new ReadFileTool(), // +新規追加
new WriteFileTool(), // +新規追加
new EditFileTool() // +新規追加
)
```
## 試してみる
```sh
cd learn-claude-code
python agents/s02_tool_use.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s02.S02ToolUse
```
1. `Read the file requirements.txt`
2. `Create a file called greet.py with a greet(name) function`
3. `Edit greet.py to add a docstring to the function`
4. `Read greet.py to verify the edit worked`
> 実行前に環境変数の設定が必要: `AI_API_KEY`, `AI_BASE_URL`, `AI_MODEL`
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
1. `Read the file pom.xml`
2. `Create a file called Greet.java with a greet(name) method`
3. `Edit Greet.java to add a Javadoc comment to the method`
4. `Read Greet.java to verify the edit worked`
+70 -47
View File
@@ -1,14 +1,14 @@
# s03: TodoWrite
# s03: TodoWrite (Todo書き込み)
`s01 > s02 > [ s03 ] s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"計画のないエージェントは行き当たりばったり"* -- まずステップを書き出し、それから実行。
> *"計画のないエージェントは行き当たりばったり"* -- まずステップを書き出し、それから実行。完了率は倍増する。
>
> **Harness 層**: 計画 -- 航路を描かずにモデルを軌道に乗せる。
## 問題
マルチステップのタスクで、モデルは途中で迷子になる。作業を繰り返したり、ステップを飛ばしたり、脱線したりする。長い会話になるほど悪化する -- ツール結果がコンテキストを埋めるにつれ、システムプロンプトの影響力が薄れる。10ステップのリファクタリングでステップ1-3を完了した後、残りを忘れて即興を始めてしまう。
マルチステップのタスクで、モデルは進捗を見失う -- 既にやったことを繰り返したり、ステップを飛ばしたり、脱線したりする。会話が長くなるほど悪化する: ツール結果がコンテキストを埋め尽くし、システムプロンプトの影響力が徐々に薄れる。10ステップのリファクタリングでステップ1-3を完了した後、即興を始めてしまう。ステップ4-10はもう注意の外だ。
## 解決策
@@ -28,69 +28,92 @@
| [x] task C |
+-----------------------+
|
if rounds_since_todo >= 3:
inject <reminder> into tool_result
毎回のリクエスト時に defaultSystem() で
最新の todo 状態をシステムプロンプトに注入
```
## 仕組み
1. TodoManagerはアイテムのリストをステータス付き保持する。`in_progress`にできるのは同時に1つだけ。
1. TodoManager はステータス付きアイテムを保持する。同時に `in_progress` にできるのは1つだけ。
```python
class TodoManager:
def update(self, items: list) -> str:
validated, in_progress_count = [], 0
for item in items:
status = item.get("status", "pending")
if status == "in_progress":
in_progress_count += 1
validated.append({"id": item["id"], "text": item["text"],
"status": status})
if in_progress_count > 1:
raise ValueError("Only one task can be in_progress")
self.items = validated
return self.render()
```
```java
public class TodoManager {
2. `todo`ツールは他のツールと同様にディスパッチマップに追加される。
public record TodoItem(String id, String text, String status) {}
```python
TOOL_HANDLERS = {
# ...base tools...
"todo": lambda **kw: TODO.update(kw["items"]),
private List<TodoItem> items = new ArrayList<>();
@Tool(description = "Update the full task list to track progress. "
+ "Each item must have id, text, status (pending/in_progress/completed). "
+ "Only one task can be in_progress at a time. Max 20 items.")
public String updateTodos(
@ToolParam(description = "The complete list of todo items")
List<TodoItem> items) {
if (items.size() > 20) return "Error: Max 20 todos allowed";
List<TodoItem> validated = new ArrayList<>();
int inProgressCount = 0;
for (TodoItem item : items) {
String status = (item.status() != null)
? item.status().toLowerCase() : "pending";
if ("in_progress".equals(status)) inProgressCount++;
validated.add(new TodoItem(item.id(), item.text().trim(), status));
}
if (inProgressCount > 1)
return "Error: Only one task can be in_progress at a time";
this.items = validated;
return render();
}
}
```
3. nagリマインダーが、モデルが3ラウンド以上`todo`を呼ばなかった場合にナッジを注入する。
2. `TodoManager``defaultTools()` で登録し、`@Tool` アノテーションメソッドが自動的にツールとして公開される。
```python
if rounds_since_todo >= 3 and messages:
last = messages[-1]
if last["role"] == "user" and isinstance(last.get("content"), list):
last["content"].insert(0, {
"type": "text",
"text": "<reminder>Update your todos.</reminder>",
})
```java
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem(system)
.defaultTools(
new BashTool(),
new ReadFileTool(),
new WriteFileTool(),
new EditFileTool(),
todoManager // @Tool アノテーションメソッドが自動登録
)
.build();
```
「一度にin_progressは1つだけ」の制約が逐次的な集中を強制し、nagリマインダーが説明責任を生む
3. システムプロンプト注入: ユーザー入力のたびに、最新の todo 状態をシステムプロンプトに注入し、更新指示を強調する
## s02からの変更点
```java
// 動的システムプロンプト: 現在の todo 状態を含む
String system = "You are a coding agent at " + workDir + ".\n"
+ "Use the todo tool to plan multi-step tasks. "
+ "Mark in_progress before starting, completed when done.\n"
+ "IMPORTANT: You MUST call updateTodos regularly.\n\n"
+ "<current-todos>\n" + todoManager.render() + "\n</current-todos>";
```
| Component | Before (s02) | After (s03) |
|----------------|------------------|----------------------------|
| Tools | 4 | 5 (+todo) |
| Planning | None | TodoManager with statuses |
| Nag injection | None | `<reminder>` after 3 rounds|
| Agent loop | Simple dispatch | + rounds_since_todo counter|
「同時に in_progress は1つだけ」の制約が逐次的な集中を強制する。システムプロンプトへの todo 状態の継続的な注入が説明責任を生む -- モデルは毎回自分の計画を見るため、更新を忘れない。
> **TIP**: Python 版ではツールループ内で `rounds_since_todo` を追跡し、3ラウンド連続で todo を呼ばなかった場合に `<reminder>` テキストを注入する。Spring AI の ChatClient は内部でツールループを自動管理するため、ループ内での注入はできない。そのため、システムプロンプト注入方式で同等の効果を実現している。
## s02 からの変更点
| コンポーネント | 変更前 (s02) | 変更後 (s03) |
|----------------|------------------|--------------------------------------|
| Tools | 4 | 5 (+TodoManager `@Tool`) |
| 計画 | なし | ステータス付き TodoManager |
| 状態注入 | なし | システムプロンプトに `<current-todos>` を注入 |
| ChatClient | 固定システムプロンプト | 毎ターン再構築、動的に todo 状態を注入 |
## 試してみる
```sh
cd learn-claude-code
python agents/s03_todo_write.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s03.S03TodoWrite
```
1. `Refactor the file hello.py: add type hints, docstrings, and a main guard`
2. `Create a Python package with __init__.py, utils.py, and tests/test_utils.py`
3. `Review all Python files and fix any style issues`
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
1. `Refactor the file Hello.java: add JavaDoc, improve naming, and keep main method behavior unchanged`
2. `Create a Java package with utils and tests`
3. `Review all Java files and fix any style issues`
+59 -51
View File
@@ -1,4 +1,4 @@
# s04: Subagents
# s04: Subagents (サブエージェント)
`s01 > s02 > s03 > [ s04 ] s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
@@ -8,7 +8,7 @@
## 問題
エージェントが作業するにつれ、messages配列は膨張し続ける。すべてのファイル読み取り、すべてのbash出力がコンテキストに永久に残る。「このプロジェクトはどのテストフレームワークを使っているか」という質問は5つのファイルを読む必要があるかもしれないが、親に必要なのは「pytest」という答えだけだ。
エージェントが作業するにつれ、messages 配列は膨張し続ける。すべてのファイル読み取り、すべてのコマンド出力がコンテキストに永久に残る。「このプロジェクトはどのテストフレームワークを使っているか」という質問は5つのファイルを読む必要があるかもしれないが、親エージェントに必要なのは「pytest」という一言だけだ。
## 解決策
@@ -28,67 +28,75 @@ Parent context stays clean. Subagent context is discarded.
## 仕組み
1.`task`ツールを追加する。子は`task`を除くすべての基本ツールを取得する(再帰的な生成は不可)
1.エージェントに `task` ツールを持たせる。子は `task` を除くすべての基本ツールを持つ(再帰的な生成は不可
```python
PARENT_TOOLS = CHILD_TOOLS + [
{"name": "task",
"description": "Spawn a subagent with fresh context.",
"input_schema": {
"type": "object",
"properties": {"prompt": {"type": "string"}},
"required": ["prompt"],
}},
]
```
2. サブエージェントは`messages=[]`で開始し、自身のループを実行する。最終テキストだけが親に返る。
```python
def run_subagent(prompt: str) -> str:
sub_messages = [{"role": "user", "content": prompt}]
for _ in range(30): # safety limit
response = client.messages.create(
model=MODEL, system=SUBAGENT_SYSTEM,
messages=sub_messages,
tools=CHILD_TOOLS, max_tokens=8000,
```java
// 親 Agent: 基本ツール + SubagentTool を持つ
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent. "
+ "Use the task tool to delegate subtasks.")
.defaultTools(
new BashTool(),
new ReadFileTool(),
new WriteFileTool(),
new EditFileTool(),
new SubagentTool(chatModel) // 親 Agent 専用
)
sub_messages.append({"role": "assistant",
"content": response.content})
if response.stop_reason != "tool_use":
break
results = []
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input)
results.append({"type": "tool_result",
"tool_use_id": block.id,
"content": str(output)[:50000]})
sub_messages.append({"role": "user", "content": results})
return "".join(
b.text for b in response.content if hasattr(b, "text")
) or "(no summary)"
.build();
```
子のメッセージ履歴全体(30回以上のツール呼び出し)は破棄される。親は1段落の要約を通常の`tool_result`として受け取る。
2. サブエージェントは新しい `ChatClient` で起動し、独立したコンテキストを持つ。最終テキストだけが親に返る。
## s03からの変更点
```java
@Tool(description = "Spawn a subagent with fresh context. "
+ "Use for exploration or subtasks that might pollute the main context.")
public String task(
@ToolParam(description = "The task prompt") String prompt,
@ToolParam(description = "Short description", required = false)
String description) {
| Component | Before (s03) | After (s04) |
|----------------|------------------|---------------------------|
| Tools | 5 | 5 (base) + task (parent) |
| Context | Single shared | Parent + child isolation |
| Subagent | None | `run_subagent()` function |
| Return value | N/A | Summary text only |
// 新しい ChatClient を作成 -- これが「コンテキスト隔離」のすべて
ChatClient subClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding subagent. "
+ "Complete the task, then summarize findings.")
.defaultTools( // 基本ツール、task なし(再帰防止)
new BashTool(),
new ReadFileTool(),
new WriteFileTool(),
new EditFileTool()
)
.build();
String result = subClient.prompt()
.user(prompt)
.call()
.content();
// 最終テキストだけを返し、子 Agent のコンテキストは破棄
return (result != null) ? result : "(no summary)";
}
```
サブエージェントは複数回のツール呼び出しを実行するかもしれないが、メッセージ履歴全体は破棄される。親が受け取るのは要約テキストだけで、通常の `tool_result` として返される。Spring AI の `ChatClient.call()` が内部でツールループを管理するため、手動でイテレーション回数を制限する必要はない。
## s03 からの変更点
| コンポーネント | 変更前 (s03) | 変更後 (s04) |
|----------------|------------------|---------------------------------------|
| Tools | 5 | 5 (基本) + SubagentTool (親側のみ) |
| コンテキスト | 単一共有 | 親 + 子隔離 (独立した ChatClient) |
| Subagent | なし | `SubagentTool.task()` メソッド |
| 戻り値 | 該当なし | 要約テキストのみ |
## 試してみる
```sh
cd learn-claude-code
python agents/s04_subagent.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s04.S04Subagent
```
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
1. `Use a subtask to find what testing framework this project uses`
2. `Delegate: read all .py files and summarize what each one does`
2. `Delegate: read all .java files and summarize what each one does`
3. `Use a task to create a new module, then verify it from here`
+90 -43
View File
@@ -1,4 +1,4 @@
# s05: Skills
# s05: Skills (スキルローディング)
`s01 > s02 > s03 > s04 > [ s05 ] s06 | s07 > s08 > s09 > s10 > s11 > s12`
@@ -8,7 +8,7 @@
## 問題
エージェントにドメイン固有のワークフローを遵守させたい: gitの規約、テストパターン、コードレビューチェックリスト。すべてをシステムプロンプトに入れると、使われないスキルにトークン浪費する。10スキル x 2000トークン = 20,000トークン、ほとんどが任意のタスク無関係
エージェントにドメイン固有のワークフローを遵守させたい: git の規約、テストパターン、コードレビューチェックリスト。すべてをシステムプロンプトに入れるとトークン浪費だ -- 10スキル x 2000トークン = 20,000トークン、大半が当面のタスクとは無関係。
## 解決策
@@ -31,11 +31,11 @@ When model calls load_skill("git"):
+--------------------------------------+
```
第1層: スキル*名*をシステムプロンプトに(低コスト)。第2層: スキル*本体*をtool_resultに(オンデマンド)
第1層: スキルをシステムプロンプトに低コスト。第2層: 完全なコンテンツを tool_resultオンデマンド配信
## 仕組み
1. 各スキルは `SKILL.md` ファイルを含むディレクトリとして配置される
1. 各スキルは `SKILL.md` ファイルを含むディレクトリで、YAML frontmatter 付き
```
skills/
@@ -45,63 +45,110 @@ skills/
SKILL.md # ---\n name: code-review\n description: Review code\n ---\n ...
```
2. SkillLoaderが `SKILL.md` を再帰的に探索し、ディレクトリ名をスキル識別子として使用する。
2. SkillLoader `SKILL.md` を再帰的にスキャンし、ディレクトリ名をスキル識別子として使用する。
```python
class SkillLoader:
def __init__(self, skills_dir: Path):
self.skills = {}
for f in sorted(skills_dir.rglob("SKILL.md")):
text = f.read_text()
meta, body = self._parse_frontmatter(text)
name = meta.get("name", f.parent.name)
self.skills[name] = {"meta": meta, "body": body}
```java
public class SkillLoader {
def get_descriptions(self) -> str:
lines = []
for name, skill in self.skills.items():
desc = skill["meta"].get("description", "")
lines.append(f" - {name}: {desc}")
return "\n".join(lines)
private static final Pattern FRONTMATTER_PATTERN =
Pattern.compile("^---\\n(.*?)\\n---\\n(.*)", Pattern.DOTALL);
def get_content(self, name: str) -> str:
skill = self.skills.get(name)
if not skill:
return f"Error: Unknown skill '{name}'."
return f"<skill name=\"{name}\">\n{skill['body']}\n</skill>"
```
private final Map<String, SkillInfo> skills = new LinkedHashMap<>();
3. 第1層はシステムプロンプトに配置。第2層は通常のツールハンドラ。
record SkillInfo(Map<String, String> meta, String body, String path) {}
```python
SYSTEM = f"""You are a coding agent at {WORKDIR}.
Skills available:
{SKILL_LOADER.get_descriptions()}"""
public SkillLoader(Path skillsDir) {
loadAll(skillsDir);
}
TOOL_HANDLERS = {
# ...base tools...
"load_skill": lambda **kw: SKILL_LOADER.get_content(kw["name"]),
/** skills ディレクトリ配下のすべての SKILL.md ファイルを再帰スキャン */
private void loadAll(Path skillsDir) {
if (!Files.exists(skillsDir)) return;
try (Stream<Path> paths = Files.walk(skillsDir)) {
paths.filter(p -> p.getFileName().toString().equals("SKILL.md"))
.sorted()
.forEach(p -> {
String text = Files.readString(p);
var parsed = parseFrontmatter(text);
String name = parsed.meta().getOrDefault("name",
p.getParent().getFileName().toString());
skills.put(name, new SkillInfo(
parsed.meta(), parsed.body(), p.toString()));
});
}
}
/** Layer 1: 全スキルの短い説明を取得(システムプロンプト注入用) */
public String getDescriptions() {
if (skills.isEmpty()) return "(no skills available)";
StringBuilder sb = new StringBuilder();
for (var entry : skills.entrySet()) {
String desc = entry.getValue().meta()
.getOrDefault("description", "No description");
sb.append(" - ").append(entry.getKey())
.append(": ").append(desc).append("\n");
}
return sb.toString().stripTrailing();
}
/** Layer 2: 指定スキルの完全なコンテンツを読み込む(@Tool メソッドとして) */
@Tool(description = "Load specialized knowledge by name.")
public String loadSkill(
@ToolParam(description = "Skill name to load") String name) {
SkillInfo skill = skills.get(name);
if (skill == null)
return "Error: Unknown skill '" + name + "'. Available: "
+ String.join(", ", skills.keySet());
return "<skill name=\"" + name + "\">\n"
+ skill.body() + "\n</skill>";
}
}
```
モデルはどのスキルが存在するかを知り(低コスト)、関連する時にだけ読み込む(高コスト)
3. 第1層はシステムプロンプトに配置。第2層は SkillLoader 上の `@Tool` アノテーションメソッドでオンデマンド読み込み
## s04からの変更点
```java
public S05SkillLoading(ChatModel chatModel) {
Path skillsDir = Path.of(System.getProperty("user.dir"), "skills");
SkillLoader skillLoader = new SkillLoader(skillsDir);
| Component | Before (s04) | After (s05) |
|----------------|------------------|----------------------------|
| Tools | 5 (base + task) | 5 (base + load_skill) |
| System prompt | Static string | + skill descriptions |
| Knowledge | None | skills/\*/SKILL.md files |
| Injection | None | Two-layer (system + result)|
// Layer 1: スキルメタデータをシステムプロンプトに注入
String system = "You are a coding agent at " + System.getProperty("user.dir") + ".\n"
+ "Use loadSkill to access specialized knowledge.\n\n"
+ "Skills available:\n"
+ skillLoader.getDescriptions();
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem(system)
.defaultTools(
new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(),
skillLoader // Layer 2: loadSkill @Tool メソッド
)
.build();
}
```
モデルはどのスキルが存在するかを知り(低コスト)、必要な時にだけ完全なコンテンツを読み込む(高コスト)。
## s04 からの変更点
| コンポーネント | 変更前 (s04) | 変更後 (s05) |
|----------------|------------------|--------------------------------|
| Tools | 5 (基本 + task) | 5 (基本 + load_skill) |
| システムプロンプト | 静的文字列 | + スキル説明リスト |
| 知識ベース | なし | skills/\*/SKILL.md ファイル |
| 注入方式 | なし | 二層構造 (システムプロンプト + result) |
## 試してみる
```sh
cd learn-claude-code
python agents/s05_skill_loading.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s05.S05SkillLoading
```
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
1. `What skills are available?`
2. `Load the agent-builder skill and follow its instructions`
3. `I need to do a code review -- load the relevant skill first`
+120 -60
View File
@@ -1,4 +1,4 @@
# s06: Context Compact
# s06: Context Compact (コンテキスト圧縮)
`s01 > s02 > s03 > s04 > s05 > [ s06 ] | s07 > s08 > s09 > s10 > s11 > s12`
@@ -8,7 +8,7 @@
## 問題
コンテキストウィンドウは有限だ。1000行のファイルに対する`read_file`1回で約4000トークンを消費する。30ファイルを読み20回のbashコマンドを実行すると、100,000トークン超。圧縮なしでは、エージェントは大規模コードベースで作業できない。
コンテキストウィンドウは有限だ。1000行のファイルを読むだけで約4000トークンを消費する。30ファイルを読み20回のコマンドを実行すると、100,000トークン超。圧縮なしでは、エージェントは大規模プロジェクトで作業できない。
## 解決策
@@ -44,82 +44,142 @@ continue [Layer 2: auto_compact]
## 仕組み
1. **第1層 -- micro_compact**: 各LLM呼び出しの前に、古いツール結果をプレースホルダーに置換する。
1. **第1層 -- コンテキストウィンドウ管理**: Spring AI の ChatClient は内部でツールループを自動管理するため、ループ内に圧縮を挿入できない。Java 版では、システムプロンプトに注入する会話ターン数を制限し(最近の N ターンのみ保持)、コンテンツを切り詰めることで同等の効果を実現する。
```python
def micro_compact(messages: list) -> list:
tool_results = []
for i, msg in enumerate(messages):
if msg["role"] == "user" and isinstance(msg.get("content"), list):
for j, part in enumerate(msg["content"]):
if isinstance(part, dict) and part.get("type") == "tool_result":
tool_results.append((i, j, part))
if len(tool_results) <= KEEP_RECENT:
return messages
for _, _, part in tool_results[:-KEEP_RECENT]:
if len(part.get("content", "")) > 100:
part["content"] = f"[Previous: used {tool_name}]"
return messages
```java
/** トークン数の推定: 粗い見積もりで 4文字 ≈ 1トークン */
public int estimateTokens() {
int chars = history.stream().mapToInt(t -> t.content().length()).sum();
return chars / 4;
}
/** 会話履歴のサマリーを取得(システムプロンプト注入用、最近数ターンのみ保持) */
public String getContextSummary() {
if (history.isEmpty()) return "";
StringBuilder sb = new StringBuilder("\n<conversation-context>\n");
int start = Math.max(0, history.size() - KEEP_RECENT * 2);
for (int i = start; i < history.size(); i++) {
ConversationTurn turn = history.get(i);
sb.append("[").append(turn.role()).append("]: ")
.append(turn.content(), 0, Math.min(500, turn.content().length()))
.append("\n");
}
sb.append("</conversation-context>");
return sb.toString();
}
```
2. **第2層 -- auto_compact**: トークンが閾値を超えたら、完全なトランスクリプトをディスクに保存し、LLMに要約を依頼する。
2. **第2層 -- auto_compact**: トークンが閾値を超えたら、完全な会話をディスクに保存し、LLM に要約を依頼する。
```python
def auto_compact(messages: list) -> list:
# Save transcript for recovery
transcript_path = TRANSCRIPT_DIR / f"transcript_{int(time.time())}.jsonl"
with open(transcript_path, "w") as f:
for msg in messages:
f.write(json.dumps(msg, default=str) + "\n")
# LLM summarizes
response = client.messages.create(
model=MODEL,
messages=[{"role": "user", "content":
"Summarize this conversation for continuity..."
+ json.dumps(messages, default=str)[:80000]}],
max_tokens=2000,
)
return [
{"role": "user", "content": f"[Compressed]\n\n{response.content[0].text}"},
{"role": "assistant", "content": "Understood. Continuing."},
]
```java
public String compact() {
// トランスクリプトをディスクに保存(完全な履歴は失われない)
Files.createDirectories(transcriptDir);
Path transcriptPath = transcriptDir.resolve(
"transcript_" + System.currentTimeMillis() + ".jsonl");
try (BufferedWriter writer = Files.newBufferedWriter(transcriptPath)) {
for (ConversationTurn turn : history) {
writer.write(objectMapper.writeValueAsString(turn));
writer.newLine();
}
}
// LLM が要約を生成
String conversationText = history.stream()
.map(t -> t.role() + ": " + t.content())
.reduce("", (a, b) -> a + "\n" + b);
if (conversationText.length() > 80000) {
conversationText = conversationText.substring(0, 80000);
}
ChatClient summaryClient = ChatClient.builder(chatModel).build();
String summary = summaryClient.prompt()
.user("Summarize this conversation for continuity. Include: "
+ "1) What was accomplished, 2) Current state, "
+ "3) Key decisions.\n\n" + conversationText)
.call().content();
// 要約で履歴を置換
history.clear();
history.add(new ConversationTurn("system",
"[Conversation compressed. Transcript: " + transcriptPath
+ "]\n\n" + summary));
return summary;
}
```
3. **第3層 -- manual compact**: `compact`ツールが同じ要約処理をオンデマンドでトリガーする。
3. **第3層 -- manual compact**: `CompactTool` ツールが同じ要約メカニズムをオンデマンドでトリガーする。
4. ループが3層すべてを統合する:
```java
public class CompactTool {
private final ContextCompactor compactor;
```python
def agent_loop(messages: list):
while True:
micro_compact(messages) # Layer 1
if estimate_tokens(messages) > THRESHOLD:
messages[:] = auto_compact(messages) # Layer 2
response = client.messages.create(...)
# ... tool execution ...
if manual_compact:
messages[:] = auto_compact(messages) # Layer 3
public CompactTool(ContextCompactor compactor) {
this.compactor = compactor;
}
@Tool(description = "Trigger manual conversation compression to free up context space.")
public String compact(
@ToolParam(description = "What to preserve in summary",
required = false) String focus) {
compactor.requestCompact();
return "Compression triggered. Context will be summarized.";
}
}
```
トランスクリプトがディスク上に完全な履歴を保持する。何も真に失われず、アクティブなコンテキストの外に移動されるだけ。
4. REPL 層が3層すべてを統合する(Spring AI の ChatClient が内部でツールループを自動管理するため、圧縮はユーザーメッセージレベルでトリガーされる):
## s05からの変更点
```java
AgentRunner.interactive("s06", userMessage -> {
// Layer 2: 自動圧縮チェック(毎回のユーザー入力前)
if (compactor.needsAutoCompact()) {
System.out.println("[auto_compact triggered]");
compactor.compact();
}
compactor.addTurn("user", userMessage);
| Component | Before (s05) | After (s06) |
|----------------|------------------|----------------------------|
| Tools | 5 | 5 (base + compact) |
| Context mgmt | None | Three-layer compression |
| Micro-compact | None | Old results -> placeholders|
| Auto-compact | None | Token threshold trigger |
| Transcripts | None | Saved to .transcripts/ |
// 動的システムプロンプト: 会話コンテキストサマリーを含む
String system = baseSystem + compactor.getContextSummary();
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem(system)
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(), compactTool)
.build();
String response = chatClient.prompt()
.user(userMessage).call().content();
compactor.addTurn("assistant", response != null ? response : "");
// Layer 3: 手動圧縮(Agent が compact ツールを呼び出した場合)
if (compactor.isCompactRequested()) {
compactor.compact();
}
return response;
});
```
完全な履歴はトランスクリプトとしてディスク上に保存される。情報は真に失われるのではなく、アクティブなコンテキストの外に移動されるだけだ。
## s05 からの変更点
| コンポーネント | 変更前 (s05) | 変更後 (s06) |
|----------------|------------------|--------------------------------|
| Tools | 5 | 5 (基本 + compact) |
| コンテキスト管理 | なし | 三層圧縮 |
| コンテキストウィンドウ管理 | なし | 注入ターン数制限 + コンテンツ切り詰め |
| Auto-compact | なし | トークン閾値トリガー |
| Transcripts | なし | .transcripts/ に保存 |
## 試してみる
```sh
cd learn-claude-code
python agents/s06_context_compact.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s06.S06ContextCompact
```
1. `Read every Python file in the agents/ directory one by one` (micro-compactが古い結果を置換するのを観察する)
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
1. `Read every Java file in the src/ directory one by one` (コンテキストウィンドウ管理の効果を観察する)
2. `Keep reading files until compression triggers automatically`
3. `Use the compact tool to manually compress the conversation`
+103 -60
View File
@@ -1,4 +1,4 @@
# s07: Task System
# s07: Task System (タスクシステム)
`s01 > s02 > s03 > s04 > s05 > s06 | [ s07 ] s08 > s09 > s10 > s11 > s12`
@@ -8,17 +8,17 @@
## 問題
s03TodoManagerはメモリ上のフラットなチェックリストに過ぎない: 順序なし、依存関係なし、ステータスは完了か未完了のみ。実際の目標には構造がある -- タスクBはタスクAに依存し、タスクCとDは並行実行でき、タスクEはCとDの両方を待つ。
s03TodoManager はメモリ上のフラットなチェックリストに過ぎない: 順序なし、依存関係なし、ステータスは完了か未完了のみ。実際の目標には構造がある -- タスク B はタスク A に依存し、タスク C と D は並行実行でき、タスク E は C と D の両方を待つ。
明示的な関係がなければ、エージェントは何が実行可能で、何がブロックされ、何が同時に走れるかを判断できない。しかもリストはメモリ上にしかないため、コンテキスト圧縮(s06)で消える。
明示的な関係がなければ、エージェントは何が実行可能で、何がブロックされ、何が同時に走れるかを判断できない。しかもリストはメモリ上にしかないため、コンテキスト圧縮 (s06) で消える。
## 解決策
フラットなチェックリストをディスクに永続化する**タスクグラフ**に昇格させる。各タスクは1つのJSONファイルで、ステータス・前方依存(`blockedBy`)・後方依存(`blocks`)を持つ。タスクグラフは常に3つの問いに答える:
フラットなチェックリストをディスクに永続化する**タスクグラフ**に昇格させる。各タスクは1つの JSON ファイルで、ステータス・前方依存 (`blockedBy`)・後方依存 (`blocks`) を持つ。タスクグラフは常に3つの問いに答える:
- **何が実行可能か?** -- `pending`ステータスで`blockedBy`が空のタスク。
- **何が実行可能か?** -- `pending` ステータスで `blockedBy` が空のタスク。
- **何がブロックされているか?** -- 未完了の依存を待つタスク。
- **何が完了したか?** -- `completed`のタスク。完了時に後続タスクを自動的にアンブロックする。
- **何が完了したか?** -- `completed` のタスク。完了時に後続タスクを自動的にアンブロックする。
```
.tasks/
@@ -44,72 +44,113 @@ s03のTodoManagerはメモリ上のフラットなチェックリストに過ぎ
ステータス: pending -> in_progress -> completed
```
このタスクグラフは s07 以降の全メカニズムの協調バックボーンとなる: バックグラウンド実行(s08)、マルチエージェントチーム(s09+)、worktree分離(s12)はすべてこの同じ構造を読み書きする。
このタスクグラフは s07 以降の全メカニズムの協調バックボーンとなる: バックグラウンド実行 (s08)、マルチエージェントチーム (s09+)、worktree 分離 (s12) はすべてこの同じ構造を読み書きする。
## 仕組み
1. **TaskManager**: タスクごとに1つのJSONファイル、依存グラフ付きCRUD。
1. **TaskManager**: タスクごとに1つの JSON ファイル、依存グラフ付き CRUD。Jackson `ObjectMapper` で JSON シリアライゼーションを行う。
```python
class TaskManager:
def __init__(self, tasks_dir: Path):
self.dir = tasks_dir
self.dir.mkdir(exist_ok=True)
self._next_id = self._max_id() + 1
```java
public class TaskManager {
private static final ObjectMapper MAPPER = new ObjectMapper();
private final Path dir;
private int nextId;
def create(self, subject, description=""):
task = {"id": self._next_id, "subject": subject,
"status": "pending", "blockedBy": [],
"blocks": [], "owner": ""}
self._save(task)
self._next_id += 1
return json.dumps(task, indent=2)
```
public TaskManager(Path tasksDir) {
this.dir = tasksDir;
Files.createDirectories(dir);
this.nextId = maxId() + 1;
}
2. **依存解除**: タスク完了時に、他タスクの`blockedBy`リストから完了IDを除去し、後続タスクをアンブロックする。
```python
def _clear_dependency(self, completed_id):
for f in self.dir.glob("task_*.json"):
task = json.loads(f.read_text())
if completed_id in task.get("blockedBy", []):
task["blockedBy"].remove(completed_id)
self._save(task)
```
3. **ステータス遷移 + 依存配線**: `update`がステータス変更と依存エッジを担う。
```python
def update(self, task_id, status=None,
add_blocked_by=None, add_blocks=None):
task = self._load(task_id)
if status:
task["status"] = status
if status == "completed":
self._clear_dependency(task_id)
self._save(task)
```
4. 4つのタスクツールをディスパッチマップに追加する。
```python
TOOL_HANDLERS = {
# ...base tools...
"task_create": lambda **kw: TASKS.create(kw["subject"]),
"task_update": lambda **kw: TASKS.update(kw["task_id"], kw.get("status")),
"task_list": lambda **kw: TASKS.list_all(),
"task_get": lambda **kw: TASKS.get(kw["task_id"]),
@Tool(description = "Create a new task with subject and optional description")
public String taskCreate(
@ToolParam(description = "Short subject of the task") String subject,
@ToolParam(description = "Detailed description", required = false) String description) {
Map<String, Object> task = new LinkedHashMap<>();
task.put("id", nextId);
task.put("subject", subject);
task.put("status", "pending");
task.put("blockedBy", new ArrayList<>());
task.put("blocks", new ArrayList<>());
save(task);
nextId++;
return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(task);
}
}
```
s07以降、タスクグラフがマルチステップ作業のデフォルト。s03のTodoは軽量な単一セッション用チェックリストとして残る。
2. **依存解除**: タスク完了時に、他タスクの `blockedBy` リストから完了 ID を除去し、後続タスクをアンブロックする。
## s06からの変更点
```java
private void clearDependency(int completedId) {
try (Stream<Path> files = Files.list(dir)) {
files.filter(f -> f.getFileName().toString().matches("task_\\d+\\.json"))
.forEach(f -> {
Map<String, Object> task = MAPPER.readValue(
Files.readString(f), new TypeReference<>() {});
List<Integer> blockedBy = (List<Integer>) task.get("blockedBy");
if (blockedBy != null && blockedBy.remove(Integer.valueOf(completedId))) {
save(task);
}
});
}
}
```
| コンポーネント | Before (s06) | After (s07) |
3. **ステータス遷移 + 依存配線**: `taskUpdate` がステータス変更と依存エッジを担う。status が `completed` になると自動的に `clearDependency` を呼び出す。`blockedBy`/`blocks` は双方向の関係。
```java
@Tool(description = "Update a task's status or dependencies.")
public String taskUpdate(
@ToolParam(description = "Task ID") int taskId,
@ToolParam(description = "New status", required = false) String status,
@ToolParam(description = "Task IDs that block this task", required = false) List<Integer> addBlockedBy,
@ToolParam(description = "Task IDs that this task blocks", required = false) List<Integer> addBlocks) {
Map<String, Object> task = load(taskId);
if (status != null) {
task.put("status", status);
if ("completed".equals(status)) {
clearDependency(taskId);
}
}
// addBlockedBy / addBlocks の双方向依存を処理 ...
save(task);
return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(task);
}
```
4. **Spring AI 自動ツール登録**: `TaskManager``defaultTools` として `ChatClient` に渡すと、Spring AI が `@Tool` アノテーションメソッドを自動認識する。手動 dispatch map は不要。
```java
@SpringBootApplication(scanBasePackages = "io.mybatis.learn.core")
public class S07TaskSystem implements CommandLineRunner {
private final ChatClient chatClient;
public S07TaskSystem(ChatModel chatModel) {
Path tasksDir = Path.of(System.getProperty("user.dir"), ".tasks");
TaskManager taskManager = new TaskManager(tasksDir);
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent. Use task tools to plan and track work.")
.defaultTools(
new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(),
taskManager // TaskManager 内の @Tool メソッドが自動登録
)
.build();
}
}
```
s07 以降、タスクグラフがマルチステップ作業のデフォルト。s03 の Todo は軽量な単一セッション用チェックリストとして残る。
## s06 からの変更点
| コンポーネント | 変更前 (s06) | 変更後 (s07) |
|---|---|---|
| Tools | 5 | 8 (`task_create/update/list/get`) |
| 計画モデル | フラットチェックリスト (メモリ) | 依存関係付きタスクグラフ (ディスク) |
| 計画モデル | フラットチェックリスト (メモリのみ) | 依存関係付きタスクグラフ (ディスク) |
| 関係 | なし | `blockedBy` + `blocks` エッジ |
| ステータス追跡 | 完了か未完了 | `pending` -> `in_progress` -> `completed` |
| 永続性 | 圧縮で消失 | 圧縮・再起動後も存続 |
@@ -118,9 +159,11 @@ s07以降、タスクグラフがマルチステップ作業のデフォルト
```sh
cd learn-claude-code
python agents/s07_task_system.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s07.S07TaskSystem
```
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
1. `Create 3 tasks: "Setup project", "Write code", "Write tests". Make them depend on each other in order.`
2. `List all tasks and show the dependency graph`
3. `Complete task 1 and then list tasks to see task 2 unblocked`
+83 -54
View File
@@ -1,14 +1,14 @@
# s08: Background Tasks
# s08: Background Tasks (バックグラウンドタスク)
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > [ s08 ] s09 > s10 > s11 > s12`
> *"遅い操作はバックグラウンドへ、エージェントは次を考え続ける"* -- デーモンスレッドがコマンド実行、完了後に通知を注入。
> *"遅い操作はバックグラウンドへ、エージェントは次を考え続ける"* -- バックグラウンドスレッドがコマンド実行、完了後に通知を注入。
>
> **Harness 層**: バックグラウンド実行 -- モデルが考え続ける間、Harness が待つ。
## 問題
一部のコマンドは数分かかる: `npm install``pytest``docker build`。ブロッキングループでは、モデルはサブプロセスの完了を待って座っている。ユーザーが「依存関係をインストールして、その間にconfigファイルを作って」と言っても、エージェントは並列ではなく逐次的に処理する
一部のコマンドは数分かかる: `npm install``pytest``docker build`。ブロッキングループでは、モデルは待つしかない。ユーザーが「依存関係をインストールして、その間に config ファイルを作って」と言っても、エージェントは1つずつしか処理できない
## 解決策
@@ -32,78 +32,107 @@ Agent --[spawn A]--[spawn B]--[other work]----
## 仕組み
1. BackgroundManagerがスレッドセーフな通知キューでタスクを追跡する。
1. BackgroundManager がスレッドセーフな並行コンテナでタスクを追跡する。Java では `ConcurrentHashMap``CopyOnWriteArrayList` を使用し、Python の手動ロックを置き換える。
```python
class BackgroundManager:
def __init__(self):
self.tasks = {}
self._notification_queue = []
self._lock = threading.Lock()
```java
public class BackgroundManager {
private static final int TIMEOUT_SECONDS = 300;
private final Map<String, TaskInfo> tasks = new ConcurrentHashMap<>();
private final List<Notification> notificationQueue = new CopyOnWriteArrayList<>();
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
record TaskInfo(String status, String result, String command) {}
public record Notification(String taskId, String status, String command, String result) {}
}
```
2. `run()`がデーモンスレッドを開始し、即座にリターンする。
2. `backgroundRun()` が仮想スレッド (Java 21) に投入し、即座にリターンする。Python の `daemon=True` スレッドに比べ、仮想スレッドはより軽量で JVM がスケジュールする。
```python
def run(self, command: str) -> str:
task_id = str(uuid.uuid4())[:8]
self.tasks[task_id] = {"status": "running", "command": command}
thread = threading.Thread(
target=self._execute, args=(task_id, command), daemon=True)
thread.start()
return f"Background task {task_id} started"
```java
@Tool(description = "Run a command in a background thread. Returns task_id immediately without waiting.")
public String backgroundRun(
@ToolParam(description = "The shell command to run in background") String command) {
String taskId = UUID.randomUUID().toString().substring(0, 8);
tasks.put(taskId, new TaskInfo("running", null, command));
executor.submit(() -> execute(taskId, command));
return "Background task " + taskId + " started: "
+ command.substring(0, Math.min(80, command.length()));
}
```
3. サブプロセス完了時に、結果通知キュー
3. サブプロセス完了時に、結果通知キューに入る。`ProcessBuilder` でコマンドを実行し、タイムアウト制御をサポート
```python
def _execute(self, task_id, command):
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=300)
output = (r.stdout + r.stderr).strip()[:50000]
except subprocess.TimeoutExpired:
output = "Error: Timeout (300s)"
with self._lock:
self._notification_queue.append({
"task_id": task_id, "result": output[:500]})
```java
private void execute(String taskId, String command) {
String status, output;
try {
ProcessBuilder pb = new ProcessBuilder("sh", "-c", command);
pb.redirectErrorStream(true);
Process process = pb.start();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream()))) {
output = reader.lines().collect(Collectors.joining("\n"));
}
boolean finished = process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS);
if (!finished) { process.destroyForcibly(); status = "timeout"; }
else { status = "completed"; }
} catch (Exception e) { output = "Error: " + e.getMessage(); status = "error"; }
tasks.put(taskId, new TaskInfo(status, output, command));
notificationQueue.add(new Notification(taskId, status, command, output));
}
```
4. エージェントループが各LLM呼び出しの前に通知をドレインする
4. 毎回のユーザー入力時に通知キューをドレインし、システムプロンプトに注入する。Spring AI の `ChatClient` が内部ツールループを管理するため、毎回のユーザー入力時にドレイン+システムプロンプト構築に変更。核心的なコンセプトは同じ: fire and forget
```python
def agent_loop(messages: list):
while True:
notifs = BG.drain_notifications()
if notifs:
notif_text = "\n".join(
f"[bg:{n['task_id']}] {n['result']}" for n in notifs)
messages.append({"role": "user",
"content": f"<background-results>\n{notif_text}\n"
f"</background-results>"})
messages.append({"role": "assistant",
"content": "Noted background results."})
response = client.messages.create(...)
```java
AgentRunner.interactive("s08", userMessage -> {
// バックグラウンドタスク通知をドレイン(Python のループ前 drain_notifications に相当)
var notifs = bgManager.drainNotifications();
String bgContext = "";
if (!notifs.isEmpty()) {
String notifText = notifs.stream()
.map(n -> "[bg:" + n.taskId() + "] " + n.status() + ": " + n.result())
.collect(Collectors.joining("\n"));
bgContext = "\n\n<background-results>\n" + notifText + "\n</background-results>";
}
String system = "You are a coding agent. Use backgroundRun for long-running commands."
+ bgContext;
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem(system)
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(), bgManager)
.build();
return chatClient.prompt().user(userMessage).call().content();
});
```
ループはシングルスレッドのまま。サブプロセスI/Oだけが並列化される。
ループはシングルスレッドのまま。サブプロセス I/O だけが並列化される。
## s07からの変更点
## s07 からの変更点
| Component | Before (s07) | After (s08) |
|----------------|------------------|----------------------------|
| Tools | 8 | 6 (base + background_run + check)|
| Execution | Blocking only | Blocking + background threads|
| Notification | None | Queue drained per loop |
| Concurrency | None | Daemon threads |
| コンポーネント | 変更前 (s07) | 変更後 (s08) |
|----------------|------------------|------------------------------------|
| Tools | 8 | 6 (基本 + backgroundRun + check) |
| 実行方式 | ブロッキングのみ | ブロッキング + 仮想スレッド (Java 21) |
| 通知メカニズム | なし | 毎ターンドレインの ConcurrentLinkedQueue |
| 並行性 | なし | 仮想スレッド (より軽量、JVM スケジュール) |
## 試してみる
```sh
cd learn-claude-code
python agents/s08_background_tasks.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s08.S08BackgroundTasks
```
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
1. `Run "sleep 5 && echo done" in the background, then create a file while it runs`
2. `Start 3 background tasks: "sleep 2", "sleep 4", "sleep 6". Check their status.`
3. `Run pytest in the background and keep working on other things`
+116 -68
View File
@@ -1,16 +1,16 @@
# s09: Agent Teams
# s09: Agent Teams (エージェントチーム)
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > [ s09 ] s10 > s11 > s12`
> *"一人で終わらないなら、チームメイトに任せる"* -- 永続チームメイト + 非同期メールボックス。
> *"一人で終わらないなら、チームメイトに任せる"* -- 永続チームメイト + JSONL メールボックス。
>
> **Harness 層**: チームメールボックス -- 複数モデルをファイルで協調。
## 問題
サブエージェント(s04)は使い捨てだ: 生成し、作業し、要約を返し、消滅する。アイデンティティもなく、呼び出し間の記憶もない。バックグラウンドタスク(s08)はシェルコマンドを実行するが、LLM誘導の意思決定はできない。
サブエージェント (s04) は使い捨てだ: 生成し、作業し、要約を返し、消滅する。アイデンティティもなく、呼び出し間の記憶もない。バックグラウンドタスク (s08) はシェルコマンドを実行するが、LLM 誘導の意思決定はできない。
本物のチームワークには: (1)単一プロンプトを超えて存続する永続エージェント、(2)アイデンティティとライフサイクル管理、(3)エージェント間の通信チャネルが必要だ
本物のチームワークには3つが必要: (1) 複数ターンの会話を超えて存続する永続エージェント、(2) アイデンティティとライフサイクル管理、(3) エージェント間の通信チャネル。
## 解決策
@@ -37,91 +37,139 @@ Communication:
## 仕組み
1. TeammateManagerconfig.jsonでチーム名簿を管理する。
1. TeammateManagerconfig.json でチーム名簿を管理する。
```python
class TeammateManager:
def __init__(self, team_dir: Path):
self.dir = team_dir
self.dir.mkdir(exist_ok=True)
self.config_path = self.dir / "config.json"
self.config = self._load_config()
self.threads = {}
```java
// src/main/java/io/mybatis/learn/s09/TeammateManager.java
public class TeammateManager {
private final ChatModel chatModel;
private final MessageBus bus;
private final Path configPath;
private final ObjectMapper mapper = new ObjectMapper();
private Map<String, Object> config;
// Python は threading.Thread + dict を使用、Java は ConcurrentHashMap で天然スレッドセーフ
private final Map<String, Thread> threads = new ConcurrentHashMap<>();
public TeammateManager(ChatModel chatModel, MessageBus bus, Path teamDir) {
this.chatModel = chatModel;
this.bus = bus;
this.configPath = teamDir.resolve("config.json");
Files.createDirectories(teamDir);
this.config = loadConfig();
}
```
2. `spawn()`がチームメイトを作成し、そのエージェントループをスレッドで開始する。
2. `spawn()` がチームメイトを作成し、スレッド内でエージェントループを開始する。
```python
def spawn(self, name: str, role: str, prompt: str) -> str:
member = {"name": name, "role": role, "status": "working"}
self.config["members"].append(member)
self._save_config()
thread = threading.Thread(
target=self._teammate_loop,
args=(name, role, prompt), daemon=True)
thread.start()
return f"Spawned teammate '{name}' (role: {role})"
```java
// Python は threading.Thread を使用、Java は Thread.startVirtualThread() 仮想スレッドを使用
public synchronized String spawn(String name, String role, String prompt) {
Map<String, Object> member = new LinkedHashMap<>();
member.put("name", name);
member.put("role", role);
member.put("status", "working");
((List<Map<String, Object>>) config.get("members")).add(member);
saveConfig();
// 仮想スレッド: 軽量、JVM スケジュール、OS スレッドを占有しない
Thread thread = Thread.startVirtualThread(
() -> teammateLoop(name, role, prompt));
threads.put(name, thread);
return "Spawned '" + name + "' (role: " + role + ")";
}
```
3. MessageBus: 追記専用のJSONLインボックス。`send()`がJSON行を追記し、`read_inbox()`がすべて読み取ってドレインする。
3. MessageBus: 追記専用の JSONL インボックス。`send()` が1行を追記し、`read_inbox()` がすべて読み取ってドレインする。
```python
class MessageBus:
def send(self, sender, to, content, msg_type="message", extra=None):
msg = {"type": msg_type, "from": sender,
"content": content, "timestamp": time.time()}
if extra:
msg.update(extra)
with open(self.dir / f"{to}.jsonl", "a") as f:
f.write(json.dumps(msg) + "\n")
```java
// src/main/java/io/mybatis/learn/core/team/MessageBus.java
// Python は GIL で暗黙的にスレッドセーフ、Java は synchronized で明示的に保証
public class MessageBus {
private final Path inboxDir;
private final ObjectMapper mapper = new ObjectMapper();
def read_inbox(self, name):
path = self.dir / f"{name}.jsonl"
if not path.exists(): return "[]"
msgs = [json.loads(l) for l in path.read_text().strip().splitlines() if l]
path.write_text("") # drain
return json.dumps(msgs, indent=2)
public synchronized String send(String sender, String to, String content,
String msgType, Map<String, Object> extra) {
Map<String, Object> msg = new LinkedHashMap<>();
msg.put("type", msgType);
msg.put("from", sender);
msg.put("content", content);
msg.put("timestamp", System.currentTimeMillis() / 1000.0);
if (extra != null) msg.putAll(extra);
Path inbox = inboxDir.resolve(to + ".jsonl");
Files.writeString(inbox, mapper.writeValueAsString(msg) + "\n",
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
return "Sent " + msgType + " to " + to;
}
public synchronized List<Map<String, Object>> readInbox(String name) {
Path inbox = inboxDir.resolve(name + ".jsonl");
if (!Files.exists(inbox)) return List.of();
List<Map<String, Object>> messages = new ArrayList<>();
for (String line : Files.readAllLines(inbox)) {
if (!line.isBlank())
messages.add(mapper.readValue(line, new TypeReference<>() {}));
}
Files.writeString(inbox, ""); // drain
return messages;
}
}
```
4. 各チームメイトは各LLM呼び出しの前にインボックスを確認し、受信メッセージをコンテキストに注入する。
4. 各チームメイトは `call()` 呼び出し間でインボックスをチェックし、メッセージをコンテキストに注入する。ChatClient の `call()` は Python の完全なツールループ(`stop_reason != "tool_use"` まで繰り返す)に相当する。
```python
def _teammate_loop(self, name, role, prompt):
messages = [{"role": "user", "content": prompt}]
for _ in range(50):
inbox = BUS.read_inbox(name)
if inbox != "[]":
messages.append({"role": "user",
"content": f"<inbox>{inbox}</inbox>"})
messages.append({"role": "assistant",
"content": "Noted inbox messages."})
response = client.messages.create(...)
if response.stop_reason != "tool_use":
break
# execute tools, append results...
self._find_member(name)["status"] = "idle"
```java
// Python のチームメイトは毎回の LLM 呼び出し前にインボックスをチェック、Java は毎回の call() 呼び出し間でチェック
protected void teammateLoop(String name, String role, String initialPrompt) {
String sysPrompt = String.format(
"You are '%s', role: %s. Use send_message to communicate.",
name, role);
var messageTool = new TeammateMessageTool(bus, name);
ChatClient client = ChatClient.builder(chatModel)
.defaultSystem(sysPrompt)
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(), messageTool)
.build();
// 初期作業(call() = 完全なツールチェーン、Python の stop_reason != "tool_use" までのループに相当)
String response = client.prompt(initialPrompt).call().content();
// 毎回の call() 間でインボックスをチェック(Python の毎回の LLM 呼び出し間ではなく)
for (int round = 0; round < 50; round++) {
Thread.sleep(2000);
var inbox = bus.readInbox(name);
if (inbox.isEmpty()) break;
String inboxJson = mapper.writeValueAsString(inbox);
response = client.prompt("<inbox>" + inboxJson + "</inbox>").call().content();
}
setStatus(name, "idle");
}
```
## s08からの変更点
## s08 からの変更点
| Component | Before (s08) | After (s09) |
|----------------|------------------|----------------------------|
| Tools | 6 | 9 (+spawn/send/read_inbox) |
| Agents | Single | Lead + N teammates |
| Persistence | None | config.json + JSONL inboxes|
| Threads | Background cmds | Full agent loops per thread|
| Lifecycle | Fire-and-forget | idle -> working -> idle |
| Communication | None | message + broadcast |
| コンポーネント | 変更前 (s08) | 変更後 (s09) |
|----------------|------------------|------------------------------------|
| Tools | 6 | 9 (+spawn/send/read_inbox) |
| エージェント数 | 単一 | リーダー + N チームメイト |
| 永続化 | なし | config.json + JSONL インボックス |
| スレッド | バックグラウンドコマンド | 各スレッドで完全なエージェントループ |
| ライフサイクル | 使い捨て | idle -> working -> idle |
| 通信 | なし | message + broadcast |
## 試してみる
```sh
cd learn-claude-code
python agents/s09_agent_teams.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s09.S09AgentTeams
```
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
1. `Spawn alice (coder) and bob (tester). Have alice send bob a message.`
2. `Broadcast "status update: phase 1 complete" to all teammates`
3. `Check the lead inbox for any messages`
4. `/team`と入力してステータス付きのチーム名簿を確認する
5. `/inbox`と入力してリーダーのインボックスを手動確認する
4. `/team` と入力してチーム名簿とステータスを確認する
5. `/inbox` と入力してリーダーのインボックスを手動確認する
+70 -42
View File
@@ -1,4 +1,4 @@
# s10: Team Protocols
# s10: Team Protocols (チームプロトコル)
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > [ s10 ] s11 > s12`
@@ -8,13 +8,13 @@
## 問題
s09ではチームメイトが作業し通信するが、構造化された協調がない:
s09 ではチームメイトが作業し通信するが、構造化された協調がない:
**シャットダウン**: スレッドを強制終了するとファイルが中途半端に書かれ、config.jsonが不正な状態になる。ハンドシェイクが必要 -- リーダーが要求し、チームメイトが承認(完了して退出)か拒否(作業継続)する。
**シャットダウン**: スレッドを強制終了するとファイルが中途半端に書かれ、config.json が不正な状態になる。ハンドシェイクが必要 -- リーダーが要求し、チームメイトが承認完了して退出か拒否作業継続する。
**プラン承認**: リーダーが「認証モジュールをリファクタリングして」と言うと、チームメイトは即座に開始する。リスクの高い変更では、実行前にリーダーが計画をレビューすべきだ。
**プラン承認**: リーダーが「認証モジュールをリファクタリングして」と言うと、チームメイトは即座に開始する。リスクの高い変更では、実行前にレビューすべきだ。
両方とも同じ構造: 一方がユニークIDを持つリクエストを送り、他方がそのIDで応答する。
両方とも同じ構造: 一方がユニーク ID を持つリクエストを送り、他方がその ID で応答する。
## 解決策
@@ -42,65 +42,93 @@ Trackers:
## 仕組み
1. リーダーがrequest_idを生成し、インボックス経由でシャットダウンを開始する。
1. リーダーが request_id を生成し、インボックス経由でシャットダウンを開始する。
```python
shutdown_requests = {}
```java
// src/main/java/io/mybatis/learn/s10/ProtocolTracker.java
// Python は辞書 + threading.Lock を使用、Java は ConcurrentHashMap で天然スレッドセーフ
private final ConcurrentHashMap<String, Map<String, String>> shutdownRequests
= new ConcurrentHashMap<>();
def handle_shutdown_request(teammate: str) -> str:
req_id = str(uuid.uuid4())[:8]
shutdown_requests[req_id] = {"target": teammate, "status": "pending"}
BUS.send("lead", teammate, "Please shut down gracefully.",
"shutdown_request", {"request_id": req_id})
return f"Shutdown request {req_id} sent (status: pending)"
public String handleShutdownRequest(String teammate) {
String reqId = UUID.randomUUID().toString().substring(0, 8);
shutdownRequests.put(reqId, new ConcurrentHashMap<>(Map.of(
"target", teammate, "status", "pending")));
bus.send("lead", teammate, "Please shut down gracefully.",
"shutdown_request", Map.of("request_id", reqId));
return "Shutdown request " + reqId + " sent to '" + teammate
+ "' (status: pending)";
}
```
2. チームメイトがリクエストを受信し、承認または拒否で応答する。
```python
if tool_name == "shutdown_response":
req_id = args["request_id"]
approve = args["approve"]
shutdown_requests[req_id]["status"] = "approved" if approve else "rejected"
BUS.send(sender, "lead", args.get("reason", ""),
"shutdown_response",
{"request_id": req_id, "approve": approve})
```java
// TeammateProtocolTool - チームメイトが @Tool アノテーションでシャットダウン要求に応答
@Tool(description = "Respond to a shutdown request")
public String shutdownResponse(
@ToolParam(description = "The request_id") String requestId,
@ToolParam(description = "true to approve") boolean approve,
@ToolParam(description = "Reason for decision") String reason) {
return tracker.respondToShutdown(name, requestId, approve, reason);
}
// ProtocolTracker - トラッカー更新 + レスポンスメッセージ送信
public String respondToShutdown(String sender, String requestId,
boolean approve, String reason) {
var req = shutdownRequests.get(requestId);
if (req != null) {
req.put("status", approve ? "approved" : "rejected");
}
bus.send(sender, "lead", reason != null ? reason : "",
"shutdown_response",
Map.of("request_id", requestId, "approve", approve));
return "Shutdown " + (approve ? "approved" : "rejected");
}
```
3. プラン承認も同一パターン。チームメイトがプランを提出(request_idを生成)、リーダーがレビュー(同じrequest_idを参照)
3. プラン承認もまったく同じパターン。チームメイトがプランを提出request_id を生成、リーダーがレビュー同じ request_id を参照
```python
plan_requests = {}
```java
// ProtocolTracker - 同じ request_id 関連パターン、2つの用途
private final ConcurrentHashMap<String, Map<String, String>> planRequests
= new ConcurrentHashMap<>();
def handle_plan_review(request_id, approve, feedback=""):
req = plan_requests[request_id]
req["status"] = "approved" if approve else "rejected"
BUS.send("lead", req["from"], feedback,
"plan_approval_response",
{"request_id": request_id, "approve": approve})
public String reviewPlan(String requestId, boolean approve, String feedback) {
var req = planRequests.get(requestId);
if (req == null) return "Error: Unknown plan request_id '" + requestId + "'";
req.put("status", approve ? "approved" : "rejected");
bus.send("lead", req.get("from"), feedback != null ? feedback : "",
"plan_approval_response",
Map.of("request_id", requestId, "approve", approve,
"feedback", feedback != null ? feedback : ""));
return "Plan " + req.get("status") + " for '" + req.get("from") + "'";
}
```
1つのFSM、2つの用。同じ`pending -> approved | rejected`状態機械が、あらゆるリクエスト-レスポンスプロトコルに適用できる。
1つの FSM、2つの用。同じ `pending -> approved | rejected` 状態機械が、あらゆるリクエスト-レスポンスプロトコルに適用できる。
## s09からの変更点
## s09 からの変更点
| Component | Before (s09) | After (s10) |
|----------------|------------------|------------------------------|
| Tools | 9 | 12 (+shutdown_req/resp +plan)|
| Shutdown | Natural exit only| Request-response handshake |
| Plan gating | None | Submit/review with approval |
| Correlation | None | request_id per request |
| FSM | None | pending -> approved/rejected |
| コンポーネント | 変更前 (s09) | 変更後 (s10) |
|----------------|------------------|--------------------------------------|
| Tools | 9 | 12 (+shutdown_req/resp +plan) |
| シャットダウン | 自然終了のみ | リクエスト-レスポンスハンドシェイク |
| プランゲーティング | なし | 提出/レビューと承認 |
| 関連付け | なし | リクエストごとに request_id |
| FSM | なし | pending -> approved/rejected |
## 試してみる
```sh
cd learn-claude-code
python agents/s10_team_protocols.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s10.S10TeamProtocols
```
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
1. `Spawn alice as a coder. Then request her shutdown.`
2. `List teammates to see alice's status after shutdown approval`
3. `Spawn bob with a risky refactoring task. Review and reject his plan.`
4. `Spawn charlie, have him submit a plan, then approve it.`
5. `/team`と入力してステータスを監視する
5. `/team` と入力してステータスを監視する
+128 -77
View File
@@ -1,18 +1,18 @@
# s11: Autonomous Agents
# s11: Autonomous Agents (自律エージェント)
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > [ s11 ] s12`
> *"チームメイトが自らボードを見て、仕事を取る"* -- リーダーが逐一割り振る必要はない。
> *"チームメイトが自らボードを見て、仕事を取る"* -- リーダーが逐一割り振る必要はない、自己組織化
>
> **Harness 層**: 自律 -- 指示なしで仕事を見つけるモデル。
## 問題
s09-s10では、チームメイトは明示的に指示された時のみ作業する。リーダーは各チームメイトを特定のプロンプトでspawnしなければならない。タスクボードに未割り当てタスクが10個あっても、リーダーが手動で各タスクを割り当てる。これはスケールしない。
s09-s10 では、チームメイトは明示的に指示された時のみ作業する。リーダーは各チームメイトプロンプトを書き、タスクボード上の10個の未割り当てタスクを手動で割り当てる。これはスケールしない。
真の自律性とは、チームメイトが自分で作業を見つけること: タスクボードをスキャンし、未確保のタスクを確保し、作業し、完了したら次を探す。
真の自律性: チームメイトが自分でタスクボードをスキャンし、未確保のタスクを確保し、完了したら次を探す。
もう1つの問題: コンテキスト圧縮(s06)後にエージェントが自分の正体を忘れる可能性がある。アイデンティティ再注入がこれを解決する。
もう1つの問題: コンテキスト圧縮 (s06) 後にエージェントが自分の正体を忘れる可能性がある。アイデンティティ再注入がこれを解決する。
## 解決策
@@ -40,103 +40,154 @@ Teammate lifecycle with idle cycle:
|
+---> 60s timeout ----------------------> SHUTDOWN
Identity re-injection after compression:
if len(messages) <= 3:
messages.insert(0, identity_block)
Identity via system prompt (always present):
ChatClient.builder(chatModel)
.defaultSystem(identityPrompt) // 毎回の呼び出しで自動付与
```
## 仕組み
1. チームメイトのループはWORKIDLEの2フェーズ。LLMがツール呼び出しを止めた時(または`idle`ツールを呼んだ時)、IDLEフェーズに入る。
1. チームメイトのループは WORKIDLE の2フェーズ。LLM がツール呼び出しを止めた時または `idle` ツールを呼んだ時、IDLE フェーズに入る。
```python
def _loop(self, name, role, prompt):
while True:
# -- WORK PHASE --
messages = [{"role": "user", "content": prompt}]
for _ in range(50):
response = client.messages.create(...)
if response.stop_reason != "tool_use":
break
# execute tools...
if idle_requested:
break
```java
// src/main/java/io/mybatis/learn/s11/S11AutonomousAgents.java
// AutonomousTeammateManager.autonomousLoop()
# -- IDLE PHASE --
self._set_status(name, "idle")
resume = self._idle_poll(name, messages)
if not resume:
self._set_status(name, "shutdown")
return
self._set_status(name, "working")
private void autonomousLoop(String name, String role, String initialPrompt) {
// idle フラグ: ツール呼び出し時に設定、外部ループが検出
AtomicBoolean idleRequested = new AtomicBoolean(false);
var idleTool = new IdleTool(idleRequested);
ChatClient client = ChatClient.builder(chatModel)
.defaultSystem(sysPrompt)
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(),
messageTool, protocolTool, idleTool, claimTool)
.build();
while (true) {
// -- WORK PHASE --
String nextMsg = initialPrompt;
for (int round = 0; round < 50 && nextMsg != null; round++) {
var inbox = bus.readInbox(name);
// ... インボックスメッセージを nextMsg にマージ ...
idleRequested.set(false);
String response = client.prompt(sb.toString()).call().content();
if (idleRequested.get()) break; // idle ツールが呼ばれた
nextMsg = null; // 以降のラウンドは inbox 駆動
}
// -- IDLE PHASE --
setStatus(name, "idle");
// ... インボックス + タスクボードをポーリング(下記参照) ...
if (!resume) { setStatus(name, "shutdown"); return; }
setStatus(name, "working");
}
}
```
2. IDLEフェーズがインボックスとタスクボードをポーリングする。
2. IDLE フェーズがインボックスとタスクボードをポーリングする。
```python
def _idle_poll(self, name, messages):
for _ in range(IDLE_TIMEOUT // POLL_INTERVAL): # 60s / 5s = 12
time.sleep(POLL_INTERVAL)
inbox = BUS.read_inbox(name)
if inbox:
messages.append({"role": "user",
"content": f"<inbox>{inbox}</inbox>"})
return True
unclaimed = scan_unclaimed_tasks()
if unclaimed:
claim_task(unclaimed[0]["id"], name)
messages.append({"role": "user",
"content": f"<auto-claimed>Task #{unclaimed[0]['id']}: "
f"{unclaimed[0]['subject']}</auto-claimed>"})
return True
return False # timeout -> shutdown
```java
// IDLE PHASE: インボックス + タスクボードをポーリング
setStatus(name, "idle");
boolean resume = false;
int polls = IDLE_TIMEOUT / Math.max(POLL_INTERVAL, 1); // 60/5 = 12
for (int p = 0; p < polls; p++) {
Thread.sleep(POLL_INTERVAL * 1000L);
// インボックスをチェック
var inbox = bus.readInbox(name);
if (!inbox.isEmpty()) {
initialPrompt = "<inbox>" + mapper.writeValueAsString(inbox) + "</inbox>";
resume = true;
break;
}
// タスクボードをスキャン
var unclaimed = scanUnclaimedTasks(tasksDir);
if (!unclaimed.isEmpty()) {
var task = unclaimed.get(0);
int taskId = ((Number) task.get("id")).intValue();
claimTask(tasksDir, taskId, name);
initialPrompt = String.format(
"<auto-claimed>Task #%d: %s\n%s</auto-claimed>",
taskId, task.get("subject"),
task.getOrDefault("description", ""));
resume = true;
break;
}
}
if (!resume) { setStatus(name, "shutdown"); return; }
setStatus(name, "working");
```
3. タスクボードスキャン: pendingかつ未割り当てかつブロックされていないタスクを探す。
3. タスクボードスキャン: pending ステータスかつ owner なしかつブロックされていないタスクを探す。
```python
def scan_unclaimed_tasks() -> list:
unclaimed = []
for f in sorted(TASKS_DIR.glob("task_*.json")):
task = json.loads(f.read_text())
if (task.get("status") == "pending"
and not task.get("owner")
and not task.get("blockedBy")):
unclaimed.append(task)
return unclaimed
```java
static List<Map<String, Object>> scanUnclaimedTasks(Path tasksDir) {
if (!Files.exists(tasksDir)) return List.of();
List<Map<String, Object>> unclaimed = new ArrayList<>();
ObjectMapper mapper = new ObjectMapper();
try (var files = Files.list(tasksDir)) {
files.filter(f -> f.getFileName().toString().startsWith("task_")
&& f.getFileName().toString().endsWith(".json"))
.sorted()
.forEach(f -> {
Map<String, Object> task = mapper.readValue(f.toFile(), Map.class);
if ("pending".equals(task.get("status"))
&& (task.get("owner") == null || "".equals(task.get("owner")))
&& (task.get("blockedBy") == null
|| ((List<?>) task.get("blockedBy")).isEmpty())) {
unclaimed.add(task);
}
});
}
return unclaimed;
}
```
4. アイデンティティ再注入: コンテキストが短すぎる(圧縮が起きた)場合にアイデンティティブロックを挿入する
4. アイデンティティ保持: Java/Spring AI の `ChatClient.defaultSystem()` は毎回の呼び出しで自動的にシステムプロンプトを付与するため、アイデンティティ情報は常に存在する。Python 版のように圧縮後に手動で再注入する必要はない
```python
if len(messages) <= 3:
messages.insert(0, {"role": "user",
"content": f"<identity>You are '{name}', role: {role}, "
f"team: {team_name}. Continue your work.</identity>"})
messages.insert(1, {"role": "assistant",
"content": f"I am {name}. Continuing."})
```java
// アイデンティティ情報は defaultSystem で構築時に注入、毎回の prompt で自動付与
String sysPrompt = String.format(
"You are '%s', role: %s, team: %s, at %s. "
+ "Use idle tool when you have no more work. You will auto-claim new tasks.",
name, role, teamName, workDir);
ChatClient client = ChatClient.builder(chatModel)
.defaultSystem(sysPrompt) // アイデンティティは常にシステムプロンプトに存在
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(),
messageTool, protocolTool, idleTool, claimTool)
.build();
```
## s10からの変更点
## s10 からの変更点
| Component | Before (s10) | After (s11) |
|----------------|------------------|----------------------------|
| Tools | 12 | 14 (+idle, +claim_task) |
| Autonomy | Lead-directed | Self-organizing |
| Idle phase | None | Poll inbox + task board |
| Task claiming | Manual only | Auto-claim unclaimed tasks |
| Identity | System prompt | + re-injection after compress|
| Timeout | None | 60s idle -> auto shutdown |
| コンポーネント | 変更前 (s10) | 変更後 (s11) |
|----------------|------------------|----------------------------------|
| Tools | 12 | 14 (+idle, +claim_task) |
| 自律性 | リーダー指示 | 自己組織化 |
| IDLE フェーズ | なし | インボックス + タスクボードをポーリング |
| タスク確保 | 手動のみ | 未割り当てタスクの自動確保 |
| アイデンティティ | システムプロンプト | + 圧縮後の再注入 |
| タイムアウト | なし | 60秒 IDLE → 自動シャットダウン |
## 試してみる
```sh
cd learn-claude-code
python agents/s11_autonomous_agents.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s11.S11AutonomousAgents
```
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
1. `Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim.`
2. `Spawn a coder teammate and let it find work from the task board itself`
3. `Create tasks with dependencies. Watch teammates respect the blocked order.`
4. `/tasks`と入力してオーナー付きのタスクボードを確認する
5. `/team`と入力して誰が作業中でアイドルかを監視する
4. `/tasks` と入力して owner 付きのタスクボードを確認する
5. `/team` と入力して誰が作業中でアイドルかを監視する
+66 -41
View File
@@ -1,16 +1,16 @@
# s12: Worktree + Task Isolation
# s12: Worktree + Task Isolation (Worktree タスク隔離)
`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > [ s12 ]`
> *"各自のディレクトリで作業し、互いに干渉しない"* -- タスクは目標を管理、worktree はディレクトリを管理、IDで紐付け。
> *"各自のディレクトリで作業し、互いに干渉しない"* -- タスクは目標を管理、worktree はディレクトリを管理、ID で紐付け。
>
> **Harness 層**: ディレクトリ隔離 -- 決して衝突しない並列実行レーン。
## 問題
s11までにエージェントはタスクを自律的に確保して完了できるようになった。しかし全タスクが1つの共有ディレクトリで走る。2つのエージェントが同時に異なるモジュールをリファクタリングすると衝突する: 片方が`config.py`を編集し、もう片方も`config.py`を編集し、未コミットの変更が混ざり合い、どちらもクリーンにロールバックできない。
s11 までにエージェントはタスクを自律的に確保して完了できるようになった。しかし全タスクが1つの共有ディレクトリで走る。2つのエージェントが同時に異なるモジュールをリファクタリングすると -- A が `Config.java` を編集し、B も `Config.java` を編集し、未コミットの変更が互いに汚染し、どちらもクリーンにロールバックできない。
タスクボードは*何をやるか*を追跡するが、*どこでやるか*には関知しない。解決策: 各タスクに専用のgit worktreeディレクトリを与える。タスクが目標を管理し、worktreeが実行コンテキストを管理する。タスクIDで紐付ける。
タスクボードは何をやるかを追跡するがどこでやるかには関知しない。解決策: 各タスクに独立した git worktree ディレクトリを与えタスク ID で両者を関連付ける。
## 解決策
@@ -38,51 +38,74 @@ State machines:
1. **タスクを作成する。** まず目標を永続化する。
```python
TASKS.create("Implement auth refactor")
# -> .tasks/task_1.json status=pending worktree=""
```java
// src/main/java/io/mybatis/learn/s12/WorktreeTaskManager.java
tasks.create("Implement auth refactor", "");
// -> .tasks/task_1.json status=pending worktree=""
```
2. **worktreeを作成してタスクに紐付ける。** `task_id`を渡すと、タスクが自動的に`in_progress`に遷移する。
2. **worktree を作成してタスクに紐付ける。** `task_id` を渡すと、タスクが自動的に `in_progress` に遷移する。
```python
WORKTREES.create("auth-refactor", task_id=1)
# -> git worktree add -b wt/auth-refactor .worktrees/auth-refactor HEAD
# -> index.json gets new entry, task_1.json gets worktree="auth-refactor"
```java
// src/main/java/io/mybatis/learn/s12/WorktreeManager.java
worktrees.create("auth-refactor", 1, "HEAD");
// -> git worktree add -b wt/auth-refactor .worktrees/auth-refactor HEAD
// -> index.json gets new entry, task_1.json gets worktree="auth-refactor"
```
紐付けは両側に状態を書き込む:
```python
def bind_worktree(self, task_id, worktree):
task = self._load(task_id)
task["worktree"] = worktree
if task["status"] == "pending":
task["status"] = "in_progress"
self._save(task)
```java
// src/main/java/io/mybatis/learn/s12/WorktreeTaskManager.java
public String bindWorktree(int taskId, String worktree, String owner) {
var task = load(taskId);
task.put("worktree", worktree);
if (owner != null && !owner.isEmpty()) task.put("owner", owner);
if ("pending".equals(task.get("status"))) task.put("status", "in_progress");
task.put("updated_at", System.currentTimeMillis() / 1000.0);
save(task);
return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(task);
}
```
3. **worktree内でコマンドを実行する。** `cwd`が分離ディレクトリを指す。
3. **worktree 内でコマンドを実行する。** `cwd` が隔離ディレクトリを指す。
```python
subprocess.run(command, shell=True, cwd=worktree_path,
capture_output=True, text=True, timeout=300)
```java
// src/main/java/io/mybatis/learn/s12/WorktreeManager.java - run()
boolean isWindows = System.getProperty("os.name").toLowerCase().contains("win");
ProcessBuilder pb = isWindows
? new ProcessBuilder("cmd", "/c", command)
: new ProcessBuilder("sh", "-c", command);
pb.directory(path.toFile());
pb.redirectErrorStream(true);
Process p = pb.start();
String out = new String(p.getInputStream().readAllBytes()).trim();
boolean finished = p.waitFor(300, java.util.concurrent.TimeUnit.SECONDS);
```
4. **終了処理。** 2つの選択肢:
- `worktree_keep(name)` -- ディレクトリを保持する。
- `worktree_remove(name, complete_task=True)` -- ディレクトリを削除し、紐付けられたタスクを完了し、イベントを発行する。1回の呼び出しで後片付けと完了を処理する。
```python
def remove(self, name, force=False, complete_task=False):
self._run_git(["worktree", "remove", wt["path"]])
if complete_task and wt.get("task_id") is not None:
self.tasks.update(wt["task_id"], status="completed")
self.tasks.unbind_worktree(wt["task_id"])
self.events.emit("task.completed", ...)
```java
// src/main/java/io/mybatis/learn/s12/WorktreeManager.java
public String remove(String name, boolean force, boolean completeTask) {
var wt = findWorktree(name);
events.emit("worktree.remove.before", ...);
runGit("worktree", "remove", wt.get("path").toString());
if (completeTask && wt.get("task_id") != null) {
int taskId = ((Number) wt.get("task_id")).intValue();
tasks.update(taskId, "completed", null);
tasks.unbindWorktree(taskId);
events.emit("task.completed",
Map.of("id", taskId, "status", "completed"),
Map.of("name", name), null);
}
// index.json を更新: status -> "removed"
}
```
5. **イベントストリーム。** ライフサイクルの各ステップが`.worktrees/events.jsonl`に記録される:
5. **イベントストリーム。** ライフサイクルの各ステップが `.worktrees/events.jsonl` に記録される:
```json
{
@@ -93,27 +116,29 @@ def remove(self, name, force=False, complete_task=False):
}
```
発行されるイベント: `worktree.create.before/after/failed`, `worktree.remove.before/after/failed`, `worktree.keep`, `task.completed`
イベントタイプ: `worktree.create.before/after/failed`, `worktree.remove.before/after/failed`, `worktree.keep`, `task.completed`
クラッシュ後も`.tasks/` + `.worktrees/index.json`から状態を再構築できる。会話メモリは揮発性だが、ファイル状態は永続的だ。
クラッシュ後も `.tasks/` + `.worktrees/index.json` から状態を再構築できる。会話メモリは揮発性だが、ディスク状態は永続的だ。
## s11からの変更点
## s11 からの変更点
| Component | Before (s11) | After (s12) |
| コンポーネント | 変更前 (s11) | 変更後 (s12) |
|--------------------|----------------------------|----------------------------------------------|
| Coordination | Task board (owner/status) | Task board + explicit worktree binding |
| Execution scope | Shared directory | Task-scoped isolated directory |
| Recoverability | Task status only | Task status + worktree index |
| Teardown | Task completion | Task completion + explicit keep/remove |
| Lifecycle visibility | Implicit in logs | Explicit events in `.worktrees/events.jsonl` |
| 協調 | タスクボード (owner/status) | タスクボード + worktree 明示的紐付け |
| 実行スコープ | 共有ディレクトリ | タスクごとの隔離ディレクトリ |
| 復旧可能性 | タスクステータスのみ | タスクステータス + worktree インデックス |
| 終了処理 | タスク完了 | タスク完了 + 明示的 keep/remove |
| ライフサイクル可視性 | ログ内に暗黙的 | `.worktrees/events.jsonl` で明示的イベントストリーム |
## 試してみる
```sh
cd learn-claude-code
python agents/s12_worktree_task_isolation.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s12.S12WorktreeIsolation
```
以下のプロンプトを試してみよう (英語プロンプトの方が LLM に効果的だが、日本語でも可):
1. `Create tasks for backend auth and frontend login page, then list tasks.`
2. `Create worktree "auth-refactor" for task 1, then bind task 2 to a new worktree "ui-login".`
3. `Run "git status --short" in worktree "auth-refactor".`
+263 -63
View File
@@ -20,99 +20,299 @@
^ |
| tool_result |
+----------------+
(loop until stop_reason != "tool_use")
(ChatClient.call() 自动循环直到无工具调用)
```
一个退出条件控制整个流程。循环持续运行, 直到模型不再调用工具。
一个 `call()` 调用控制整个流程。Spring AI 自动循环, 直到模型不再调用工具。
## 工作原理
1. 用户 prompt 作为第一条消息。
### 1. 构建 ChatClient:注入模型 + 注册工具
```python
messages.append({"role": "user", "content": query})
通过 Spring Boot 自动配置注入 `ChatModel`,用 `ChatClient.builder()` 构建客户端,设置系统提示和工具。
```java
// TIP: Python 版在模块级创建 client = Anthropic() 和 MODEL。
// Spring AI 通过自动配置注入 ChatModel,再用 builder 构建 ChatClient。
public S01AgentLoop(ChatModel chatModel) {
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent at " + System.getProperty("user.dir")
+ ". Use bash to solve tasks. Act, don't explain.")
.defaultTools(new BashTool()) // @Tool 注解的工具对象
.build();
}
```
2. 将消息和工具定义一起发给 LLM。
### 2. `@Tool` 注解:声明式工具注册
```python
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
Spring AI 通过 `@Tool` 注解自动发现和注册工具。框架在启动时扫描 `defaultTools()` 传入的对象,提取所有 `@Tool` 方法的签名和描述,生成 LLM 需要的 tool schema(名称、参数、描述),然后在每次 `call()` 请求中自动携带。
```java
// BashTool —— 对应 Python 版的 run_bash() 函数
public class BashTool {
@Tool(description = "Run a shell command and return stdout + stderr")
public String bash(@ToolParam(description = "The shell command to execute")
String command) {
// 危险命令检查 + ProcessBuilder 执行 + 超时控制 + 输出截断
// ...
}
}
```
3. 追加助手响应。检查 `stop_reason` -- 如果模型没有调用工具, 结束。
> 对比 Python 版的手动注册方式:
> - Python: `TOOLS = [{"name": "bash", "input_schema": {...}}]` + `TOOL_HANDLERS = {"bash": run_bash}`
> - Java: 只需 `@Tool` + `@ToolParam` 注解,框架自动完成 schema 生成和方法分派
### 3. Spring AI 内部自动循环:`call()` 的底层实现
**这是理解 Java 版与 Python 版最关键的区别。** Python 版本需要手写 while 循环来驱动工具调用:
```python
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
```
4. 执行每个工具调用, 收集结果, 作为 user 消息追加。回到第 2 步。
```python
results = []
for block in response.content:
if block.type == "tool_use":
output = run_bash(block.input["command"])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
```
组装为一个完整函数:
```python
def agent_loop(query):
messages = [{"role": "user", "content": query}]
# Python 版 —— 手动循环
def agent_loop(messages):
while True:
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)
response = client.messages.create(model=MODEL, messages=messages, tools=TOOLS)
# 收集 assistant 消息
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return
results = []
return response # 模型不再调用工具,退出循环
# 执行工具并回传结果
for block in response.content:
if block.type == "tool_use":
output = run_bash(block.input["command"])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
result = TOOL_HANDLERS[block.name](block.input)
messages.append({"role": "user", "content": [{"type": "tool_result", ...}]})
```
不到 30 行, 这就是整个智能体。后面 11 个章节都在这个循环上叠加机制 -- 循环本身始终不变。
Spring AI 的 `ChatClient.call()` **内部封装了完全等价的逻辑**
```
call() 内部流程:
┌─────────────────────────────────────────────────────┐
│ 1. 组装请求: system prompt + user message + tools │
│ 2. 发送给 LLM │
│ 3. 解析响应 │
│ ├── 有 tool_use? ──→ 是: │
│ │ a. 提取工具名和参数 │
│ │ b. 通过反射调用对应的 @Tool 方法 │
│ │ c. 将 tool_result 追加到消息列表 │
│ │ d. 回到步骤 2(自动循环) │
│ └── 否 ──→ 返回最终文本 │
└─────────────────────────────────────────────────────┘
```
关键点:
- **工具检测**: Spring AI 检查响应中是否有 `tool_use` 类型的 content block(对应 Python 的 `stop_reason == "tool_use"`
- **反射分派**: 框架通过 Java 反射机制,根据 LLM 返回的工具名称找到对应的 `@Tool` 方法并调用(对应 Python 的 `TOOL_HANDLERS[block.name]`
- **结果回传**: 工具执行结果自动包装为 `tool_result` 消息追加到对话(对应 Python 手动构造 `tool_result` content block
- **循环终止**: 当模型返回纯文本(无工具调用)时,`call()` 返回最终结果
因此,Python 版约 15 行的 while 循环,在 Java 版中浓缩为一行 `.call()`
### 4. `AgentRunner.interactive()`REPL 交互循环
`AgentRunner` 是所有课程共用的交互式 REPLRead-Eval-Print Loop)工具类,对应 Python 版 `if __name__ == "__main__"` 中的 `input()` 循环。
```java
public class AgentRunner {
/**
* 启动交互式 REPL 循环。
* @param prefix 提示符前缀(如 "s01"
* @param handler 处理用户输入并返回 Agent 响应的函数
*/
public static void interactive(String prefix, Function<String, String> handler) {
Scanner scanner = new Scanner(System.in);
System.out.println("输入 'q' 或 'exit' 退出");
while (true) {
System.out.print("\033[36m" + prefix + " >> \033[0m"); // 彩色提示符
String input;
try {
if (!scanner.hasNextLine()) break;
input = scanner.nextLine().trim();
} catch (Exception e) {
break;
}
if (input.isEmpty() || "exit".equalsIgnoreCase(input) || "q".equalsIgnoreCase(input)) {
break;
}
try {
String response = handler.apply(input); // 调用 Agent 处理
if (response != null && !response.isBlank()) {
System.out.println(response);
}
} catch (Exception e) {
System.err.println("Error: " + e.getMessage());
}
System.out.println();
}
System.out.println("Bye!");
}
}
```
工作流程:`Scanner` 读取输入 → `handler.apply()` 发给 Agent → 打印响应 → 循环。`handler` 是一个函数式接口,每个课程传入自己的 Agent 调用逻辑。
### 5. 组装为完整的 Agent 类
```java
@SpringBootApplication(scanBasePackages = "io.mybatis.learn.core")
public class S01AgentLoop implements CommandLineRunner {
private final ChatClient chatClient;
public S01AgentLoop(ChatModel chatModel) {
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent at ...")
.defaultTools(new BashTool())
.build();
}
@Override
public void run(String... args) {
AgentRunner.interactive("s01", userMessage ->
chatClient.prompt()
.user(userMessage)
.call() // ← 这一个调用 = Python 的整个 while 循环
.content()
);
}
}
```
> **TIPS — Python → Java 关键适配点:**
> - Python 的 `while True` + `stop_reason` 手动循环 → Spring AI `ChatClient.call()` 内置自动循环
> - Python 的 `TOOLS` 数组 + `TOOL_HANDLERS` 字典 → `@Tool` 注解 + `defaultTools()` 自动注册与反射分派
> - Python 的 `client = Anthropic()` → Spring Boot 自动配置注入 `ChatModel`
> - Python 的 `input()` 交互 → `AgentRunner.interactive()` 封装 Scanner REPL + 函数式接口
不到 40 行核心代码, 这就是整个智能体。后面 11 个章节都在这个循环上叠加机制 -- 循环本身始终不变。
## 变更内容
| 组件 | 之前 | 之后 |
|---------------|------------|--------------------------------|
| Agent loop | (无) | `while True` + stop_reason |
| Tools | (无) | `bash` (单一工具) |
| Messages | (无) | 累积式消息列表 |
| Control flow | (无) | `stop_reason != "tool_use"` |
| 组件 | 之前 | 之后 |
|---------------|------------|--------------------------------------------------|
| Agent loop | (无) | `ChatClient.call()` 内置工具循环 |
| Tools | (无) | `BashTool` (单一 `@Tool` 工具) |
| Messages | (无) | Spring AI 内部管理消息列表 |
| Control flow | (无) | 框架自动判断: 无工具调用时返回最终文本 |
```java
// 核心代码 —— 构建 + 调用
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent ...")
.defaultTools(new BashTool())
.build();
AgentRunner.interactive("s01", userMessage ->
chatClient.prompt().user(userMessage).call().content()
);
```
## 试一试
```sh
cd learn-claude-code
python agents/s01_agent_loop.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s01.S01AgentLoop
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
> 运行前需设置环境变量: `AI_API_KEY`, `AI_BASE_URL`, `AI_MODEL`
>
> **当前默认使用 OpenAI 协议**(兼容所有 OpenAI API 格式的服务,包括 OpenAI 官方、Azure OpenAI、各类第三方大模型服务的 OpenAI 兼容接口等)。
> 如需使用 Anthropic 协议(Claude 系列模型原生接口),请展开下方「切换 AI 协议」。
1. `Create a file called hello.py that prints "Hello, World!"`
2. `List all Python files in this directory`
<details>
<summary><strong>切换 AI 协议(OpenAI ↔ Anthropic</strong></summary>
本项目通过 Spring AI 的 **Starter 依赖 + 配置文件** 来切换底层协议,Java 业务代码(`ChatModel``ChatClient`**无需任何修改**。
#### 方式一:OpenAI 协议(默认)
`pom.xml` 依赖:
```xml
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
```
`application.yml` 配置:
```yaml
spring:
ai:
openai:
api-key: ${AI_API_KEY:sk-xxx}
base-url: ${AI_BASE_URL:https://api.openai.com}
chat:
options:
model: ${AI_MODEL:gpt-4o}
```
环境变量示例(以 OpenAI 官方为例):
```sh
export AI_API_KEY=sk-proj-xxxxxxxx
export AI_BASE_URL=https://api.openai.com # 可替换为任何 OpenAI 兼容接口
export AI_MODEL=gpt-4o
```
> **TIP**: 许多第三方大模型服务(如 DeepSeek、Mistral、通义千问等)提供了 OpenAI 兼容接口,只需修改 `AI_BASE_URL` 和 `AI_MODEL` 即可接入,无需切换协议。
#### 方式二:Anthropic 协议(Claude 原生接口)
**第 1 步**:修改 `pom.xml`,将 OpenAI starter 替换为 Anthropic starter
```xml
<!-- 注释或删除 OpenAI starter -->
<!-- <dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency> -->
<!-- 添加 Anthropic starter -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-anthropic</artifactId>
</dependency>
```
**第 2 步**:修改 `application.yml`,将 `spring.ai.openai` 替换为 `spring.ai.anthropic`
```yaml
spring:
ai:
anthropic:
api-key: ${AI_API_KEY}
base-url: ${AI_BASE_URL:https://api.anthropic.com}
chat:
options:
model: ${AI_MODEL:claude-sonnet-4-20250514}
```
**第 3 步**:设置环境变量:
```sh
export AI_API_KEY=sk-ant-xxxxxxxx
export AI_BASE_URL=https://api.anthropic.com
export AI_MODEL=claude-sonnet-4-20250514
```
#### 切换原理
Spring AI 的设计使得 `ChatModel` 是一个统一的抽象接口。不同的 Starter 提供不同的实现:
| Starter 依赖 | 自动注入的 ChatModel 实现 | 配置前缀 |
|---|---|---|
| `spring-ai-starter-model-openai` | `OpenAiChatModel` | `spring.ai.openai.*` |
| `spring-ai-starter-model-anthropic` | `AnthropicChatModel` | `spring.ai.anthropic.*` |
业务代码始终面向 `ChatModel` 接口编程,切换协议只需替换依赖和配置,无需改动任何 Java 代码。
</details>
试试这些 prompt(英文 prompt 对 LLM 效果更好, 也可以用中文):
1. `Create a file called Hello.java that prints "Hello, World!"`
2. `List all Java files in this directory`
3. `What is the current git branch?`
4. `Create a directory called test_output and write 3 files in it`
+96 -58
View File
@@ -2,7 +2,7 @@
`s01 > [ s02 ] s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`
> *"加一个工具, 只加一个 handler"* -- 循环不用动, 新工具注册进 dispatch map 就行。
> *"加一个工具, 只加一个 @Tool 方法"* -- 循环不用动, 新工具传入 `defaultTools()` 就行。
>
> **Harness 层**: 工具分发 -- 扩展模型能触达的边界。
@@ -15,87 +15,125 @@
## 解决方案
```
+--------+ +-------+ +------------------+
| User | ---> | LLM | ---> | Tool Dispatch |
| prompt | | | | { |
+--------+ +---+---+ | bash: run_bash |
^ | read: run_read |
| | write: run_wr |
+-----------+ edit: run_edit |
tool_result | } |
+------------------+
+--------+ +-------+ +--------------------+
| User | ---> | LLM | ---> | defaultTools() |
| prompt | | | | { |
+--------+ +---+---+ | BashTool |
^ | ReadFileTool |
| | WriteFileTool |
+-----------+ EditFileTool |
tool_result | } |
+--------------------+
The dispatch map is a dict: {tool_name: handler_function}.
One lookup replaces any if/elif chain.
Spring AI 通过 @Tool 注解自动注册和分派。
无需手写 dispatch map,框架扫描工具对象的注解方法即可。
```
## 工作原理
1. 每个工具一个处理函数。路径沙箱防止逃逸工作区。
1. 每个工具一个独立的类,用 `@Tool` 注解声明。`PathValidator`路径沙箱防止逃逸工作区。
```python
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
```java
// PathValidator —— 对应 Python 版的 safe_path() 函数
public class PathValidator {
private final Path workDir;
def run_read(path: str, limit: int = None) -> str:
text = safe_path(path).read_text()
lines = text.splitlines()
if limit and limit < len(lines):
lines = lines[:limit]
return "\n".join(lines)[:50000]
```
public Path resolve(String relativePath) {
Path resolved = workDir.resolve(relativePath).toAbsolutePath().normalize();
if (!resolved.startsWith(workDir)) {
throw new IllegalArgumentException("Path escapes workspace: " + relativePath);
}
return resolved;
}
}
2. dispatch map 将工具名映射到处理函数。
// ReadFileTool —— 对应 Python 版的 run_read() 函数
public class ReadFileTool {
private final PathValidator pathValidator;
```python
TOOL_HANDLERS = {
"bash": lambda **kw: run_bash(kw["command"]),
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit")),
"write_file": lambda **kw: run_write(kw["path"], kw["content"]),
"edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"],
kw["new_text"]),
@Tool(description = "Read file contents. Optionally limit the number of lines returned.")
public String readFile(
@ToolParam(description = "Relative path to the file") String path,
@ToolParam(description = "Maximum number of lines to read", required = false) Integer limit) {
Path filePath = pathValidator.resolve(path);
List<String> lines = Files.readAllLines(filePath);
if (limit != null && limit > 0 && limit < lines.size()) {
lines = lines.subList(0, limit);
}
return String.join("\n", lines);
}
}
```
3. 循环中按名称查找处理函数。循环体本身与 s01 完全一致
2. 工具注册只需传入 `defaultTools()`。Spring AI 扫描 `@Tool` 注解方法,自动完成名称映射和参数绑定
```python
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler \
else f"Unknown tool: {block.name}"
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
```java
// 对应 Python 版的 TOOL_HANDLERS 字典
// Python: TOOL_HANDLERS = {"bash": fn, "read_file": fn, "write_file": fn, "edit_file": fn}
// Java: 只需传入工具对象,@Tool 注解自动注册
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent ...")
.defaultTools(
new BashTool(), // bash 命令执行
new ReadFileTool(), // 文件读取
new WriteFileTool(), // 文件写入
new EditFileTool() // 文件编辑(查找替换)
)
.build();
```
加工具 = 加 handler + 加 schema。循环永远不变
3. 调用代码与 s01 完全一致。循环由框架管理,开发者只需关注工具实现
```java
// 对比 s01,唯一变化是 defaultTools() 多传了 3 个工具对象
// 循环代码完全相同 —— 这正是 s02 的核心洞察
AgentRunner.interactive("s02", userMessage ->
chatClient.prompt()
.user(userMessage)
.call()
.content()
);
```
加工具 = 加一个 `@Tool` 类 + 传入 `defaultTools()`。循环永远不变。
> **TIPS — Python → Java 关键适配点:**
> - Python 的 `TOOL_HANDLERS` 字典 → Spring AI `@Tool` 注解 + `defaultTools()` 自动注册分派
> - Python 的 `safe_path()` 函数 → `PathValidator` 类(相同的路径逃逸检查逻辑)
> - Python 的 `lambda **kw` 参数解包 → `@ToolParam` 注解自动绑定参数
> - Python 的 `block.type == "tool_use"` 判断 → Spring AI 内部自动检测和分派
## 相对 s01 的变更
| 组件 | 之前 (s01) | 之后 (s02) |
|----------------|--------------------|--------------------------------|
| Tools | 1 (仅 bash) | 4 (bash, read, write, edit) |
| Dispatch | 硬编码 bash 调用 | `TOOL_HANDLERS` 字典 |
| 路径安全 | 无 | `safe_path()` 沙箱 |
| Agent loop | 不变 | 不变 |
| 组件 | 之前 (s01) | 之后 (s02) |
|----------------|-----------------------|----------------------------------------|
| Tools | 1 (`BashTool`) | 4 (`Bash`, `ReadFile`, `WriteFile`, `EditFile`) |
| Dispatch | `defaultTools(bash)` | `defaultTools(bash, read, write, edit)` |
| 路径安全 | 无 | `PathValidator` 沙箱 |
| Agent loop | 不变 | 不变 |
```java
// s01 → s02 唯一变化: defaultTools() 多传了 3 个工具对象
.defaultTools(
new BashTool(),
new ReadFileTool(), // +新增
new WriteFileTool(), // +新增
new EditFileTool() // +新增
)
```
## 试一试
```sh
cd learn-claude-code
python agents/s02_tool_use.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s02.S02ToolUse
```
> 运行前需设置环境变量: `AI_API_KEY`, `AI_BASE_URL`, `AI_MODEL`
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
1. `Read the file requirements.txt`
2. `Create a file called greet.py with a greet(name) function`
3. `Edit greet.py to add a docstring to the function`
4. `Read greet.py to verify the edit worked`
1. `Read the file pom.xml`
2. `Create a file called Greet.java with a greet(name) method`
3. `Edit Greet.java to add a Javadoc comment to the method`
4. `Read Greet.java to verify the edit worked`
+63 -42
View File
@@ -28,71 +28,92 @@
| [x] task C |
+-----------------------+
|
if rounds_since_todo >= 3:
inject <reminder> into tool_result
每次请求时通过 defaultSystem()
注入最新 todo 状态到系统提示
```
## 工作原理
1. TodoManager 存储带状态的项目。同一时间只允许一个 `in_progress`
```python
class TodoManager:
def update(self, items: list) -> str:
validated, in_progress_count = [], 0
for item in items:
status = item.get("status", "pending")
if status == "in_progress":
in_progress_count += 1
validated.append({"id": item["id"], "text": item["text"],
"status": status})
if in_progress_count > 1:
raise ValueError("Only one task can be in_progress")
self.items = validated
return self.render()
```
```java
public class TodoManager {
2. `todo` 工具和其他工具一样加入 dispatch map。
public record TodoItem(String id, String text, String status) {}
```python
TOOL_HANDLERS = {
# ...base tools...
"todo": lambda **kw: TODO.update(kw["items"]),
private List<TodoItem> items = new ArrayList<>();
@Tool(description = "Update the full task list to track progress. "
+ "Each item must have id, text, status (pending/in_progress/completed). "
+ "Only one task can be in_progress at a time. Max 20 items.")
public String updateTodos(
@ToolParam(description = "The complete list of todo items")
List<TodoItem> items) {
if (items.size() > 20) return "Error: Max 20 todos allowed";
List<TodoItem> validated = new ArrayList<>();
int inProgressCount = 0;
for (TodoItem item : items) {
String status = (item.status() != null)
? item.status().toLowerCase() : "pending";
if ("in_progress".equals(status)) inProgressCount++;
validated.add(new TodoItem(item.id(), item.text().trim(), status));
}
if (inProgressCount > 1)
return "Error: Only one task can be in_progress at a time";
this.items = validated;
return render();
}
}
```
3. nag reminder: 模型连续 3 轮以上不调用 `todo` 时注入提醒
2. `TodoManager` 通过 `defaultTools()` 注册, `@Tool` 注解方法自动暴露为工具
```python
if rounds_since_todo >= 3 and messages:
last = messages[-1]
if last["role"] == "user" and isinstance(last.get("content"), list):
last["content"].insert(0, {
"type": "text",
"text": "<reminder>Update your todos.</reminder>",
})
```java
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem(system)
.defaultTools(
new BashTool(),
new ReadFileTool(),
new WriteFileTool(),
new EditFileTool(),
todoManager // @Tool 注解方法自动注册
)
.build();
```
"同时只能有一个 in_progress" 强制顺序聚焦。nag reminder 制造问责压力 -- 你不更新计划, 系统就追着你问
3. 系统提示注入: 每次用户输入时, 将最新 todo 状态注入系统提示, 并强调更新指令
```java
// 动态系统提示:包含当前 todo 状态
String system = "You are a coding agent at " + workDir + ".\n"
+ "Use the todo tool to plan multi-step tasks. "
+ "Mark in_progress before starting, completed when done.\n"
+ "IMPORTANT: You MUST call updateTodos regularly.\n\n"
+ "<current-todos>\n" + todoManager.render() + "\n</current-todos>";
```
"同时只能有一个 in_progress" 强制顺序聚焦。系统提示中持续注入 todo 状态制造问责压力 -- 模型每次都能看到自己的计划, 不会忘记更新。
> **TIP**: Python 版在工具循环内追踪 `rounds_since_todo`, 连续 3 轮未调用 todo 时注入 `<reminder>` 文本。Spring AI 的 ChatClient 自动管理工具循环, 无法在循环内注入, 因此改用系统提示注入的方式实现同等效果。
## 相对 s02 的变更
| 组件 | 之前 (s02) | 之后 (s03) |
|----------------|------------------|--------------------------------|
| Tools | 4 | 5 (+todo) |
| 规划 | 无 | 带状态的 TodoManager |
| Nag 注入 | 无 | 3 轮后注入 `<reminder>` |
| Agent loop | 简单分发 | + rounds_since_todo 计数器 |
| 组件 | 之前 (s02) | 之后 (s03) |
|----------------|------------------|--------------------------------------|
| Tools | 4 | 5 (+TodoManager `@Tool`) |
| 规划 | 无 | 带状态的 TodoManager |
| 状态注入 | 无 | 系统提示注入 `<current-todos>` |
| ChatClient | 固定系统提示 | 每轮重建, 动态注入 todo 状态 |
## 试一试
```sh
cd learn-claude-code
python agents/s03_todo_write.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s03.S03TodoWrite
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
1. `Refactor the file hello.py: add type hints, docstrings, and a main guard`
2. `Create a Python package with __init__.py, utils.py, and tests/test_utils.py`
3. `Review all Python files and fix any style issues`
1. `Refactor the file Hello.java: add JavaDoc, improve naming, and keep main method behavior unchanged`
2. `Create a Java package with utils and tests`
3. `Review all Java files and fix any style issues`
+53 -47
View File
@@ -30,67 +30,73 @@ Parent context stays clean. Subagent context is discarded.
1. 父智能体有一个 `task` 工具。子智能体拥有除 `task` 外的所有基础工具 (禁止递归生成)。
```python
PARENT_TOOLS = CHILD_TOOLS + [
{"name": "task",
"description": "Spawn a subagent with fresh context.",
"input_schema": {
"type": "object",
"properties": {"prompt": {"type": "string"}},
"required": ["prompt"],
}},
]
```
2. 子智能体以 `messages=[]` 启动, 运行自己的循环。只有最终文本返回给父智能体。
```python
def run_subagent(prompt: str) -> str:
sub_messages = [{"role": "user", "content": prompt}]
for _ in range(30): # safety limit
response = client.messages.create(
model=MODEL, system=SUBAGENT_SYSTEM,
messages=sub_messages,
tools=CHILD_TOOLS, max_tokens=8000,
```java
// 父 Agent:拥有基础工具 + SubagentTool
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent. "
+ "Use the task tool to delegate subtasks.")
.defaultTools(
new BashTool(),
new ReadFileTool(),
new WriteFileTool(),
new EditFileTool(),
new SubagentTool(chatModel) // 父 Agent 独有
)
sub_messages.append({"role": "assistant",
"content": response.content})
if response.stop_reason != "tool_use":
break
results = []
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input)
results.append({"type": "tool_result",
"tool_use_id": block.id,
"content": str(output)[:50000]})
sub_messages.append({"role": "user", "content": results})
return "".join(
b.text for b in response.content if hasattr(b, "text")
) or "(no summary)"
.build();
```
子智能体可能跑了 30+ 次工具调用, 但整个消息历史直接丢弃。父智能体收到的只是一段摘要文本, 作为普通 `tool_result` 返回
2. 子智能体以全新的 `ChatClient` 启动, 拥有独立上下文。只有最终文本返回给父智能体
```java
@Tool(description = "Spawn a subagent with fresh context. "
+ "Use for exploration or subtasks that might pollute the main context.")
public String task(
@ToolParam(description = "The task prompt") String prompt,
@ToolParam(description = "Short description", required = false)
String description) {
// 创建全新的 ChatClient —— 这就是"上下文隔离"的全部
ChatClient subClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding subagent. "
+ "Complete the task, then summarize findings.")
.defaultTools( // 基础工具, 没有 task (防止递归)
new BashTool(),
new ReadFileTool(),
new WriteFileTool(),
new EditFileTool()
)
.build();
String result = subClient.prompt()
.user(prompt)
.call()
.content();
// 只返回最终文本, 子 Agent 上下文被丢弃
return (result != null) ? result : "(no summary)";
}
```
子智能体可能跑了多次工具调用, 但整个消息历史直接丢弃。父智能体收到的只是一段摘要文本, 作为普通 `tool_result` 返回。Spring AI 的 `ChatClient.call()` 内部管理工具循环, 无需手动限制迭代次数。
## 相对 s03 的变更
| 组件 | 之前 (s03) | 之后 (s04) |
|----------------|------------------|-------------------------------|
| Tools | 5 | 5 (基础) + task (仅父端) |
| 上下文 | 单一共享 | 父 + 子隔离 |
| Subagent | 无 | `run_subagent()` 函数 |
| 返回值 | 不适用 | 仅摘要文本 |
| 组件 | 之前 (s03) | 之后 (s04) |
|----------------|------------------|---------------------------------------|
| Tools | 5 | 5 (基础) + SubagentTool (仅父端) |
| 上下文 | 单一共享 | 父 + 子隔离 (独立 ChatClient) |
| Subagent | 无 | `SubagentTool.task()` 方法 |
| 返回值 | 不适用 | 仅摘要文本 |
## 试一试
```sh
cd learn-claude-code
python agents/s04_subagent.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s04.S04Subagent
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
1. `Use a subtask to find what testing framework this project uses`
2. `Delegate: read all .py files and summarize what each one does`
2. `Delegate: read all .java files and summarize what each one does`
3. `Use a task to create a new module, then verify it from here`
+74 -29
View File
@@ -47,40 +47,85 @@ skills/
2. SkillLoader 递归扫描 `SKILL.md` 文件, 用目录名作为技能标识。
```python
class SkillLoader:
def __init__(self, skills_dir: Path):
self.skills = {}
for f in sorted(skills_dir.rglob("SKILL.md")):
text = f.read_text()
meta, body = self._parse_frontmatter(text)
name = meta.get("name", f.parent.name)
self.skills[name] = {"meta": meta, "body": body}
```java
public class SkillLoader {
def get_descriptions(self) -> str:
lines = []
for name, skill in self.skills.items():
desc = skill["meta"].get("description", "")
lines.append(f" - {name}: {desc}")
return "\n".join(lines)
private static final Pattern FRONTMATTER_PATTERN =
Pattern.compile("^---\\n(.*?)\\n---\\n(.*)", Pattern.DOTALL);
def get_content(self, name: str) -> str:
skill = self.skills.get(name)
if not skill:
return f"Error: Unknown skill '{name}'."
return f"<skill name=\"{name}\">\n{skill['body']}\n</skill>"
private final Map<String, SkillInfo> skills = new LinkedHashMap<>();
record SkillInfo(Map<String, String> meta, String body, String path) {}
public SkillLoader(Path skillsDir) {
loadAll(skillsDir);
}
/** 递归扫描 skills 目录下所有 SKILL.md 文件 */
private void loadAll(Path skillsDir) {
if (!Files.exists(skillsDir)) return;
try (Stream<Path> paths = Files.walk(skillsDir)) {
paths.filter(p -> p.getFileName().toString().equals("SKILL.md"))
.sorted()
.forEach(p -> {
String text = Files.readString(p);
var parsed = parseFrontmatter(text);
String name = parsed.meta().getOrDefault("name",
p.getParent().getFileName().toString());
skills.put(name, new SkillInfo(
parsed.meta(), parsed.body(), p.toString()));
});
}
}
/** Layer 1: 获取所有技能的简短描述(用于系统提示注入) */
public String getDescriptions() {
if (skills.isEmpty()) return "(no skills available)";
StringBuilder sb = new StringBuilder();
for (var entry : skills.entrySet()) {
String desc = entry.getValue().meta()
.getOrDefault("description", "No description");
sb.append(" - ").append(entry.getKey())
.append(": ").append(desc).append("\n");
}
return sb.toString().stripTrailing();
}
/** Layer 2: 加载指定技能的完整内容(作为 @Tool 方法) */
@Tool(description = "Load specialized knowledge by name.")
public String loadSkill(
@ToolParam(description = "Skill name to load") String name) {
SkillInfo skill = skills.get(name);
if (skill == null)
return "Error: Unknown skill '" + name + "'. Available: "
+ String.join(", ", skills.keySet());
return "<skill name=\"" + name + "\">\n"
+ skill.body() + "\n</skill>";
}
}
```
3. 第一层写入系统提示。第二层不过是 dispatch map 中的又一个工具
3. 第一层写入系统提示。第二层通过 SkillLoader 上的 `@Tool` 注解方法按需加载
```python
SYSTEM = f"""You are a coding agent at {WORKDIR}.
Skills available:
{SKILL_LOADER.get_descriptions()}"""
```java
public S05SkillLoading(ChatModel chatModel) {
Path skillsDir = Path.of(System.getProperty("user.dir"), "skills");
SkillLoader skillLoader = new SkillLoader(skillsDir);
TOOL_HANDLERS = {
# ...base tools...
"load_skill": lambda **kw: SKILL_LOADER.get_content(kw["name"]),
// Layer 1: 技能元数据注入系统提示
String system = "You are a coding agent at " + System.getProperty("user.dir") + ".\n"
+ "Use loadSkill to access specialized knowledge.\n\n"
+ "Skills available:\n"
+ skillLoader.getDescriptions();
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem(system)
.defaultTools(
new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(),
skillLoader // Layer 2: loadSkill @Tool 方法
)
.build();
}
```
@@ -99,7 +144,7 @@ TOOL_HANDLERS = {
```sh
cd learn-claude-code
python agents/s05_skill_loading.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s05.S05SkillLoading
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
+107 -49
View File
@@ -44,61 +44,119 @@ continue [Layer 2: auto_compact]
## 工作原理
1. **第一层 -- micro_compact**: 每次 LLM 调用前, 将旧的 tool result 替换为占位符
1. **第一层 -- 上下文窗口管理**: Spring AI 的 ChatClient 自动管理工具循环, 无法在循环内插入压缩。Java 版通过限制注入系统提示的对话轮数(仅保留最近 N 轮)并截断内容来实现等价效果
```python
def micro_compact(messages: list) -> list:
tool_results = []
for i, msg in enumerate(messages):
if msg["role"] == "user" and isinstance(msg.get("content"), list):
for j, part in enumerate(msg["content"]):
if isinstance(part, dict) and part.get("type") == "tool_result":
tool_results.append((i, j, part))
if len(tool_results) <= KEEP_RECENT:
return messages
for _, _, part in tool_results[:-KEEP_RECENT]:
if len(part.get("content", "")) > 100:
part["content"] = f"[Previous: used {tool_name}]"
return messages
```java
/** 估算 token 数量: 粗略估计 4 字符 ≈ 1 token */
public int estimateTokens() {
int chars = history.stream().mapToInt(t -> t.content().length()).sum();
return chars / 4;
}
/** 获取对话历史的摘要(用于注入系统提示, 仅保留最近几轮) */
public String getContextSummary() {
if (history.isEmpty()) return "";
StringBuilder sb = new StringBuilder("\n<conversation-context>\n");
int start = Math.max(0, history.size() - KEEP_RECENT * 2);
for (int i = start; i < history.size(); i++) {
ConversationTurn turn = history.get(i);
sb.append("[").append(turn.role()).append("]: ")
.append(turn.content(), 0, Math.min(500, turn.content().length()))
.append("\n");
}
sb.append("</conversation-context>");
return sb.toString();
}
```
2. **第二层 -- auto_compact**: token 超过阈值时, 保存完整对话到磁盘, 让 LLM 做摘要。
```python
def auto_compact(messages: list) -> list:
# Save transcript for recovery
transcript_path = TRANSCRIPT_DIR / f"transcript_{int(time.time())}.jsonl"
with open(transcript_path, "w") as f:
for msg in messages:
f.write(json.dumps(msg, default=str) + "\n")
# LLM summarizes
response = client.messages.create(
model=MODEL,
messages=[{"role": "user", "content":
"Summarize this conversation for continuity..."
+ json.dumps(messages, default=str)[:80000]}],
max_tokens=2000,
)
return [
{"role": "user", "content": f"[Compressed]\n\n{response.content[0].text}"},
{"role": "assistant", "content": "Understood. Continuing."},
]
```java
public String compact() {
// 保存 transcript 到磁盘(完整历史不丢失)
Files.createDirectories(transcriptDir);
Path transcriptPath = transcriptDir.resolve(
"transcript_" + System.currentTimeMillis() + ".jsonl");
try (BufferedWriter writer = Files.newBufferedWriter(transcriptPath)) {
for (ConversationTurn turn : history) {
writer.write(objectMapper.writeValueAsString(turn));
writer.newLine();
}
}
// LLM 生成摘要
String conversationText = history.stream()
.map(t -> t.role() + ": " + t.content())
.reduce("", (a, b) -> a + "\n" + b);
if (conversationText.length() > 80000) {
conversationText = conversationText.substring(0, 80000);
}
ChatClient summaryClient = ChatClient.builder(chatModel).build();
String summary = summaryClient.prompt()
.user("Summarize this conversation for continuity. Include: "
+ "1) What was accomplished, 2) Current state, "
+ "3) Key decisions.\n\n" + conversationText)
.call().content();
// 用摘要替换历史
history.clear();
history.add(new ConversationTurn("system",
"[Conversation compressed. Transcript: " + transcriptPath
+ "]\n\n" + summary));
return summary;
}
```
3. **第三层 -- manual compact**: `compact` 工具按需触发同样的摘要机制。
3. **第三层 -- manual compact**: `CompactTool` 工具按需触发同样的摘要机制。
4. 循环整合三层:
```java
public class CompactTool {
private final ContextCompactor compactor;
```python
def agent_loop(messages: list):
while True:
micro_compact(messages) # Layer 1
if estimate_tokens(messages) > THRESHOLD:
messages[:] = auto_compact(messages) # Layer 2
response = client.messages.create(...)
# ... tool execution ...
if manual_compact:
messages[:] = auto_compact(messages) # Layer 3
public CompactTool(ContextCompactor compactor) {
this.compactor = compactor;
}
@Tool(description = "Trigger manual conversation compression to free up context space.")
public String compact(
@ToolParam(description = "What to preserve in summary",
required = false) String focus) {
compactor.requestCompact();
return "Compression triggered. Context will be summarized.";
}
}
```
4. REPL 层整合三层 (Spring AI 的 ChatClient 自动管理工具循环, 压缩在用户消息级别触发):
```java
AgentRunner.interactive("s06", userMessage -> {
// Layer 2: 自动压缩检查(每次用户输入前)
if (compactor.needsAutoCompact()) {
System.out.println("[auto_compact triggered]");
compactor.compact();
}
compactor.addTurn("user", userMessage);
// 动态系统提示:包含对话上下文摘要
String system = baseSystem + compactor.getContextSummary();
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem(system)
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(), compactTool)
.build();
String response = chatClient.prompt()
.user(userMessage).call().content();
compactor.addTurn("assistant", response != null ? response : "");
// Layer 3: 手动压缩(如果 Agent 调用了 compact 工具)
if (compactor.isCompactRequested()) {
compactor.compact();
}
return response;
});
```
完整历史通过 transcript 保存在磁盘上。信息没有真正丢失, 只是移出了活跃上下文。
@@ -109,7 +167,7 @@ def agent_loop(messages: list):
|----------------|------------------|--------------------------------|
| Tools | 5 | 5 (基础 + compact) |
| 上下文管理 | 无 | 三层压缩 |
| Micro-compact | 无 | 旧结果 -> 占位符 |
| 上下文窗口管理 | 无 | 限制注入轮数 + 内容截断 |
| Auto-compact | 无 | token 阈值触发 |
| Transcripts | 无 | 保存到 .transcripts/ |
@@ -117,11 +175,11 @@ def agent_loop(messages: list):
```sh
cd learn-claude-code
python agents/s06_context_compact.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s06.S06ContextCompact
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
1. `Read every Python file in the agents/ directory one by one` (观察 micro-compact 替换旧结果)
1. `Read every Java file in the src/ directory one by one` (观察上下文窗口管理效果)
2. `Keep reading files until compression triggers automatically`
3. `Use the compact tool to manually compress the conversation`
+81 -40
View File
@@ -48,57 +48,98 @@ s03 的 TodoManager 只是内存中的扁平清单: 没有顺序、没有依赖
## 工作原理
1. **TaskManager**: 每个任务一个 JSON 文件, CRUD + 依赖图。
1. **TaskManager**: 每个任务一个 JSON 文件, CRUD + 依赖图。使用 Jackson `ObjectMapper` 做 JSON 序列化。
```python
class TaskManager:
def __init__(self, tasks_dir: Path):
self.dir = tasks_dir
self.dir.mkdir(exist_ok=True)
self._next_id = self._max_id() + 1
```java
public class TaskManager {
private static final ObjectMapper MAPPER = new ObjectMapper();
private final Path dir;
private int nextId;
def create(self, subject, description=""):
task = {"id": self._next_id, "subject": subject,
"status": "pending", "blockedBy": [],
"blocks": [], "owner": ""}
self._save(task)
self._next_id += 1
return json.dumps(task, indent=2)
public TaskManager(Path tasksDir) {
this.dir = tasksDir;
Files.createDirectories(dir);
this.nextId = maxId() + 1;
}
@Tool(description = "Create a new task with subject and optional description")
public String taskCreate(
@ToolParam(description = "Short subject of the task") String subject,
@ToolParam(description = "Detailed description", required = false) String description) {
Map<String, Object> task = new LinkedHashMap<>();
task.put("id", nextId);
task.put("subject", subject);
task.put("status", "pending");
task.put("blockedBy", new ArrayList<>());
task.put("blocks", new ArrayList<>());
save(task);
nextId++;
return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(task);
}
}
```
2. **依赖解除**: 完成任务时, 自动将其 ID 从其他任务的 `blockedBy` 中移除, 解锁后续任务。
```python
def _clear_dependency(self, completed_id):
for f in self.dir.glob("task_*.json"):
task = json.loads(f.read_text())
if completed_id in task.get("blockedBy", []):
task["blockedBy"].remove(completed_id)
self._save(task)
```java
private void clearDependency(int completedId) {
try (Stream<Path> files = Files.list(dir)) {
files.filter(f -> f.getFileName().toString().matches("task_\\d+\\.json"))
.forEach(f -> {
Map<String, Object> task = MAPPER.readValue(
Files.readString(f), new TypeReference<>() {});
List<Integer> blockedBy = (List<Integer>) task.get("blockedBy");
if (blockedBy != null && blockedBy.remove(Integer.valueOf(completedId))) {
save(task);
}
});
}
}
```
3. **状态变更 + 依赖关联**: `update` 处理状态转换和依赖边。
3. **状态变更 + 依赖关联**: `taskUpdate` 处理状态转换和依赖边。当 status 变为 `completed` 时自动调用 `clearDependency``blockedBy`/`blocks` 是双向关系。
```python
def update(self, task_id, status=None,
add_blocked_by=None, add_blocks=None):
task = self._load(task_id)
if status:
task["status"] = status
if status == "completed":
self._clear_dependency(task_id)
self._save(task)
```java
@Tool(description = "Update a task's status or dependencies.")
public String taskUpdate(
@ToolParam(description = "Task ID") int taskId,
@ToolParam(description = "New status", required = false) String status,
@ToolParam(description = "Task IDs that block this task", required = false) List<Integer> addBlockedBy,
@ToolParam(description = "Task IDs that this task blocks", required = false) List<Integer> addBlocks) {
Map<String, Object> task = load(taskId);
if (status != null) {
task.put("status", status);
if ("completed".equals(status)) {
clearDependency(taskId);
}
}
// 处理 addBlockedBy / addBlocks 双向依赖 ...
save(task);
return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(task);
}
```
4. 四个任务工具加入 dispatch map。
4. **Spring AI 自动注册工具**: 将 `TaskManager` 作为 `defaultTools` 传入 `ChatClient`Spring AI 自动识别 `@Tool` 注解方法,无需手动 dispatch map。
```python
TOOL_HANDLERS = {
# ...base tools...
"task_create": lambda **kw: TASKS.create(kw["subject"]),
"task_update": lambda **kw: TASKS.update(kw["task_id"], kw.get("status")),
"task_list": lambda **kw: TASKS.list_all(),
"task_get": lambda **kw: TASKS.get(kw["task_id"]),
```java
@SpringBootApplication(scanBasePackages = "io.mybatis.learn.core")
public class S07TaskSystem implements CommandLineRunner {
private final ChatClient chatClient;
public S07TaskSystem(ChatModel chatModel) {
Path tasksDir = Path.of(System.getProperty("user.dir"), ".tasks");
TaskManager taskManager = new TaskManager(tasksDir);
this.chatClient = ChatClient.builder(chatModel)
.defaultSystem("You are a coding agent. Use task tools to plan and track work.")
.defaultTools(
new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(),
taskManager // TaskManager 中的 @Tool 方法自动注册
)
.build();
}
}
```
@@ -118,7 +159,7 @@ TOOL_HANDLERS = {
```sh
cd learn-claude-code
python agents/s07_task_system.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s07.S07TaskSystem
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
+74 -47
View File
@@ -32,58 +32,85 @@ Agent --[spawn A]--[spawn B]--[other work]----
## 工作原理
1. BackgroundManager 用线程安全的通知队列追踪任务
1. BackgroundManager 用线程安全的并发容器追踪任务。Java 使用 `ConcurrentHashMap``CopyOnWriteArrayList` 代替 Python 的手动加锁
```python
class BackgroundManager:
def __init__(self):
self.tasks = {}
self._notification_queue = []
self._lock = threading.Lock()
```java
public class BackgroundManager {
private static final int TIMEOUT_SECONDS = 300;
private final Map<String, TaskInfo> tasks = new ConcurrentHashMap<>();
private final List<Notification> notificationQueue = new CopyOnWriteArrayList<>();
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
record TaskInfo(String status, String result, String command) {}
public record Notification(String taskId, String status, String command, String result) {}
}
```
2. `run()` 启动守护线程, 立即返回
2. `backgroundRun()` 提交虚拟线程 (Java 21), 立即返回。相比 Python 的 `daemon=True` 线程,虚拟线程更轻量、由 JVM 调度
```python
def run(self, command: str) -> str:
task_id = str(uuid.uuid4())[:8]
self.tasks[task_id] = {"status": "running", "command": command}
thread = threading.Thread(
target=self._execute, args=(task_id, command), daemon=True)
thread.start()
return f"Background task {task_id} started"
```java
@Tool(description = "Run a command in a background thread. Returns task_id immediately without waiting.")
public String backgroundRun(
@ToolParam(description = "The shell command to run in background") String command) {
String taskId = UUID.randomUUID().toString().substring(0, 8);
tasks.put(taskId, new TaskInfo("running", null, command));
executor.submit(() -> execute(taskId, command));
return "Background task " + taskId + " started: "
+ command.substring(0, Math.min(80, command.length()));
}
```
3. 子进程完成后, 结果进入通知队列。
3. 子进程完成后, 结果进入通知队列。使用 `ProcessBuilder` 执行命令,支持超时控制。
```python
def _execute(self, task_id, command):
try:
r = subprocess.run(command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=300)
output = (r.stdout + r.stderr).strip()[:50000]
except subprocess.TimeoutExpired:
output = "Error: Timeout (300s)"
with self._lock:
self._notification_queue.append({
"task_id": task_id, "result": output[:500]})
```java
private void execute(String taskId, String command) {
String status, output;
try {
ProcessBuilder pb = new ProcessBuilder("sh", "-c", command);
pb.redirectErrorStream(true);
Process process = pb.start();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream()))) {
output = reader.lines().collect(Collectors.joining("\n"));
}
boolean finished = process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS);
if (!finished) { process.destroyForcibly(); status = "timeout"; }
else { status = "completed"; }
} catch (Exception e) { output = "Error: " + e.getMessage(); status = "error"; }
tasks.put(taskId, new TaskInfo(status, output, command));
notificationQueue.add(new Notification(taskId, status, command, output));
}
```
4. 每次 LLM 调用前排空通知队列
4. 每次用户输入时排空通知队列, 注入系统提示。Spring AI 的 `ChatClient` 管理内部工具循环, 因此改为在每次用户输入时 drain 通知并构建系统提示, 核心概念不变: fire and forget
```python
def agent_loop(messages: list):
while True:
notifs = BG.drain_notifications()
if notifs:
notif_text = "\n".join(
f"[bg:{n['task_id']}] {n['result']}" for n in notifs)
messages.append({"role": "user",
"content": f"<background-results>\n{notif_text}\n"
f"</background-results>"})
messages.append({"role": "assistant",
"content": "Noted background results."})
response = client.messages.create(...)
```java
AgentRunner.interactive("s08", userMessage -> {
// Drain 后台任务通知(对应 Python 中循环前的 drain_notifications
var notifs = bgManager.drainNotifications();
String bgContext = "";
if (!notifs.isEmpty()) {
String notifText = notifs.stream()
.map(n -> "[bg:" + n.taskId() + "] " + n.status() + ": " + n.result())
.collect(Collectors.joining("\n"));
bgContext = "\n\n<background-results>\n" + notifText + "\n</background-results>";
}
String system = "You are a coding agent. Use backgroundRun for long-running commands."
+ bgContext;
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultSystem(system)
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(), bgManager)
.build();
return chatClient.prompt().user(userMessage).call().content();
});
```
循环保持单线程。只有子进程 I/O 被并行化。
@@ -92,16 +119,16 @@ def agent_loop(messages: list):
| 组件 | 之前 (s07) | 之后 (s08) |
|----------------|------------------|------------------------------------|
| Tools | 8 | 6 (基础 + background_run + check) |
| 执行方式 | 仅阻塞 | 阻塞 + 后台线程 |
| 通知机制 | 无 | 每轮排空的队列 |
| 并发 | 无 | 守护线程 |
| Tools | 8 | 6 (基础 + backgroundRun + check) |
| 执行方式 | 仅阻塞 | 阻塞 + 虚拟线程 (Java 21) |
| 通知机制 | 无 | 每轮排空的 ConcurrentLinkedQueue |
| 并发 | 无 | 虚拟线程 (更轻量, JVM 调度) |
## 试一试
```sh
cd learn-claude-code
python agents/s08_background_tasks.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s08.S08BackgroundTasks
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
+96 -50
View File
@@ -39,67 +39,113 @@ Communication:
1. TeammateManager 通过 config.json 维护团队名册。
```python
class TeammateManager:
def __init__(self, team_dir: Path):
self.dir = team_dir
self.dir.mkdir(exist_ok=True)
self.config_path = self.dir / "config.json"
self.config = self._load_config()
self.threads = {}
```java
// src/main/java/io/mybatis/learn/s09/TeammateManager.java
public class TeammateManager {
private final ChatModel chatModel;
private final MessageBus bus;
private final Path configPath;
private final ObjectMapper mapper = new ObjectMapper();
private Map<String, Object> config;
// Python用threading.Thread + dict; Java用ConcurrentHashMap天然线程安全
private final Map<String, Thread> threads = new ConcurrentHashMap<>();
public TeammateManager(ChatModel chatModel, MessageBus bus, Path teamDir) {
this.chatModel = chatModel;
this.bus = bus;
this.configPath = teamDir.resolve("config.json");
Files.createDirectories(teamDir);
this.config = loadConfig();
}
```
2. `spawn()` 创建队友并在线程中启动 agent loop。
```python
def spawn(self, name: str, role: str, prompt: str) -> str:
member = {"name": name, "role": role, "status": "working"}
self.config["members"].append(member)
self._save_config()
thread = threading.Thread(
target=self._teammate_loop,
args=(name, role, prompt), daemon=True)
thread.start()
return f"Spawned teammate '{name}' (role: {role})"
```java
// Python用threading.Thread; Java用Thread.startVirtualThread()虚拟线程
public synchronized String spawn(String name, String role, String prompt) {
Map<String, Object> member = new LinkedHashMap<>();
member.put("name", name);
member.put("role", role);
member.put("status", "working");
((List<Map<String, Object>>) config.get("members")).add(member);
saveConfig();
// 虚拟线程:轻量级,由JVM调度,不占用OS线程
Thread thread = Thread.startVirtualThread(
() -> teammateLoop(name, role, prompt));
threads.put(name, thread);
return "Spawned '" + name + "' (role: " + role + ")";
}
```
3. MessageBus: append-only 的 JSONL 收件箱。`send()` 追加一行; `read_inbox()` 读取全部并清空。
```python
class MessageBus:
def send(self, sender, to, content, msg_type="message", extra=None):
msg = {"type": msg_type, "from": sender,
"content": content, "timestamp": time.time()}
if extra:
msg.update(extra)
with open(self.dir / f"{to}.jsonl", "a") as f:
f.write(json.dumps(msg) + "\n")
```java
// src/main/java/io/mybatis/learn/core/team/MessageBus.java
// Python靠GIL隐式保证线程安全; Java用synchronized显式保证
public class MessageBus {
private final Path inboxDir;
private final ObjectMapper mapper = new ObjectMapper();
def read_inbox(self, name):
path = self.dir / f"{name}.jsonl"
if not path.exists(): return "[]"
msgs = [json.loads(l) for l in path.read_text().strip().splitlines() if l]
path.write_text("") # drain
return json.dumps(msgs, indent=2)
public synchronized String send(String sender, String to, String content,
String msgType, Map<String, Object> extra) {
Map<String, Object> msg = new LinkedHashMap<>();
msg.put("type", msgType);
msg.put("from", sender);
msg.put("content", content);
msg.put("timestamp", System.currentTimeMillis() / 1000.0);
if (extra != null) msg.putAll(extra);
Path inbox = inboxDir.resolve(to + ".jsonl");
Files.writeString(inbox, mapper.writeValueAsString(msg) + "\n",
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
return "Sent " + msgType + " to " + to;
}
public synchronized List<Map<String, Object>> readInbox(String name) {
Path inbox = inboxDir.resolve(name + ".jsonl");
if (!Files.exists(inbox)) return List.of();
List<Map<String, Object>> messages = new ArrayList<>();
for (String line : Files.readAllLines(inbox)) {
if (!line.isBlank())
messages.add(mapper.readValue(line, new TypeReference<>() {}));
}
Files.writeString(inbox, ""); // drain
return messages;
}
}
```
4. 每个队友在每次 LLM 调用检查收件箱, 将消息注入上下文。
4. 每个队友在每次 `call()` 调用检查收件箱, 将消息注入上下文。ChatClient 的 `call()` 等价于 Python 的完整工具循环(循环到 `stop_reason != "tool_use"` 为止)。
```python
def _teammate_loop(self, name, role, prompt):
messages = [{"role": "user", "content": prompt}]
for _ in range(50):
inbox = BUS.read_inbox(name)
if inbox != "[]":
messages.append({"role": "user",
"content": f"<inbox>{inbox}</inbox>"})
messages.append({"role": "assistant",
"content": "Noted inbox messages."})
response = client.messages.create(...)
if response.stop_reason != "tool_use":
break
# execute tools, append results...
self._find_member(name)["status"] = "idle"
```java
// Python队友在每次LLM调用前检查收件箱; Java在每次call()调用间检查
protected void teammateLoop(String name, String role, String initialPrompt) {
String sysPrompt = String.format(
"You are '%s', role: %s. Use send_message to communicate.",
name, role);
var messageTool = new TeammateMessageTool(bus, name);
ChatClient client = ChatClient.builder(chatModel)
.defaultSystem(sysPrompt)
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(), messageTool)
.build();
// 初始工作(call() = 完整工具链,等价于Python循环到stop_reason != "tool_use"
String response = client.prompt(initialPrompt).call().content();
// 每次call()之间检查收件箱(而非Python的每次LLM调用之间)
for (int round = 0; round < 50; round++) {
Thread.sleep(2000);
var inbox = bus.readInbox(name);
if (inbox.isEmpty()) break;
String inboxJson = mapper.writeValueAsString(inbox);
response = client.prompt("<inbox>" + inboxJson + "</inbox>").call().content();
}
setStatus(name, "idle");
}
```
## 相对 s08 的变更
@@ -117,7 +163,7 @@ def _teammate_loop(self, name, role, prompt):
```sh
cd learn-claude-code
python agents/s09_agent_teams.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s09.S09AgentTeams
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
+51 -25
View File
@@ -44,40 +44,66 @@ Trackers:
1. 领导生成 request_id, 通过收件箱发起关机请求。
```python
shutdown_requests = {}
```java
// src/main/java/io/mybatis/learn/s10/ProtocolTracker.java
// Python用字典 + threading.Lock; Java用ConcurrentHashMap天然线程安全
private final ConcurrentHashMap<String, Map<String, String>> shutdownRequests
= new ConcurrentHashMap<>();
def handle_shutdown_request(teammate: str) -> str:
req_id = str(uuid.uuid4())[:8]
shutdown_requests[req_id] = {"target": teammate, "status": "pending"}
BUS.send("lead", teammate, "Please shut down gracefully.",
"shutdown_request", {"request_id": req_id})
return f"Shutdown request {req_id} sent (status: pending)"
public String handleShutdownRequest(String teammate) {
String reqId = UUID.randomUUID().toString().substring(0, 8);
shutdownRequests.put(reqId, new ConcurrentHashMap<>(Map.of(
"target", teammate, "status", "pending")));
bus.send("lead", teammate, "Please shut down gracefully.",
"shutdown_request", Map.of("request_id", reqId));
return "Shutdown request " + reqId + " sent to '" + teammate
+ "' (status: pending)";
}
```
2. 队友收到请求后, 用 approve/reject 响应。
```python
if tool_name == "shutdown_response":
req_id = args["request_id"]
approve = args["approve"]
shutdown_requests[req_id]["status"] = "approved" if approve else "rejected"
BUS.send(sender, "lead", args.get("reason", ""),
"shutdown_response",
{"request_id": req_id, "approve": approve})
```java
// TeammateProtocolTool - 队友用@Tool注解响应关闭请求
@Tool(description = "Respond to a shutdown request")
public String shutdownResponse(
@ToolParam(description = "The request_id") String requestId,
@ToolParam(description = "true to approve") boolean approve,
@ToolParam(description = "Reason for decision") String reason) {
return tracker.respondToShutdown(name, requestId, approve, reason);
}
// ProtocolTracker - 更新追踪器 + 发送响应消息
public String respondToShutdown(String sender, String requestId,
boolean approve, String reason) {
var req = shutdownRequests.get(requestId);
if (req != null) {
req.put("status", approve ? "approved" : "rejected");
}
bus.send(sender, "lead", reason != null ? reason : "",
"shutdown_response",
Map.of("request_id", requestId, "approve", approve));
return "Shutdown " + (approve ? "approved" : "rejected");
}
```
3. 计划审批遵循完全相同的模式。队友提交计划 (生成 request_id), 领导审查 (引用同一个 request_id)。
```python
plan_requests = {}
```java
// ProtocolTracker - 同样的request_id关联模式,两种用途
private final ConcurrentHashMap<String, Map<String, String>> planRequests
= new ConcurrentHashMap<>();
def handle_plan_review(request_id, approve, feedback=""):
req = plan_requests[request_id]
req["status"] = "approved" if approve else "rejected"
BUS.send("lead", req["from"], feedback,
"plan_approval_response",
{"request_id": request_id, "approve": approve})
public String reviewPlan(String requestId, boolean approve, String feedback) {
var req = planRequests.get(requestId);
if (req == null) return "Error: Unknown plan request_id '" + requestId + "'";
req.put("status", approve ? "approved" : "rejected");
bus.send("lead", req.get("from"), feedback != null ? feedback : "",
"plan_approval_response",
Map.of("request_id", requestId, "approve", approve,
"feedback", feedback != null ? feedback : ""));
return "Plan " + req.get("status") + " for '" + req.get("from") + "'";
}
```
一个 FSM, 两种用途。同样的 `pending -> approved | rejected` 状态机可以套用到任何请求-响应协议上。
@@ -96,7 +122,7 @@ def handle_plan_review(request_id, approve, feedback=""):
```sh
cd learn-claude-code
python agents/s10_team_protocols.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s10.S10TeamProtocols
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
+107 -58
View File
@@ -40,81 +40,130 @@ Teammate lifecycle with idle cycle:
|
+---> 60s timeout ----------------------> SHUTDOWN
Identity re-injection after compression:
if len(messages) <= 3:
messages.insert(0, identity_block)
Identity via system prompt (always present):
ChatClient.builder(chatModel)
.defaultSystem(identityPrompt) // 每次调用自动携带
```
## 工作原理
1. 队友循环分两个阶段: WORK 和 IDLE。LLM 停止调用工具 (或调用了 `idle`) 时, 进入 IDLE。
```python
def _loop(self, name, role, prompt):
while True:
# -- WORK PHASE --
messages = [{"role": "user", "content": prompt}]
for _ in range(50):
response = client.messages.create(...)
if response.stop_reason != "tool_use":
break
# execute tools...
if idle_requested:
break
```java
// src/main/java/io/mybatis/learn/s11/S11AutonomousAgents.java
// AutonomousTeammateManager.autonomousLoop()
# -- IDLE PHASE --
self._set_status(name, "idle")
resume = self._idle_poll(name, messages)
if not resume:
self._set_status(name, "shutdown")
return
self._set_status(name, "working")
private void autonomousLoop(String name, String role, String initialPrompt) {
// idle标志:工具调用时设置,外部循环检测
AtomicBoolean idleRequested = new AtomicBoolean(false);
var idleTool = new IdleTool(idleRequested);
ChatClient client = ChatClient.builder(chatModel)
.defaultSystem(sysPrompt)
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(),
messageTool, protocolTool, idleTool, claimTool)
.build();
while (true) {
// -- WORK PHASE --
String nextMsg = initialPrompt;
for (int round = 0; round < 50 && nextMsg != null; round++) {
var inbox = bus.readInbox(name);
// ... 合并收件箱消息到 nextMsg ...
idleRequested.set(false);
String response = client.prompt(sb.toString()).call().content();
if (idleRequested.get()) break; // idle工具被调用
nextMsg = null; // 后续轮次靠inbox驱动
}
// -- IDLE PHASE --
setStatus(name, "idle");
// ... 轮询收件箱 + 任务板(见下文) ...
if (!resume) { setStatus(name, "shutdown"); return; }
setStatus(name, "working");
}
}
```
2. 空闲阶段循环轮询收件箱和任务看板。
```python
def _idle_poll(self, name, messages):
for _ in range(IDLE_TIMEOUT // POLL_INTERVAL): # 60s / 5s = 12
time.sleep(POLL_INTERVAL)
inbox = BUS.read_inbox(name)
if inbox:
messages.append({"role": "user",
"content": f"<inbox>{inbox}</inbox>"})
return True
unclaimed = scan_unclaimed_tasks()
if unclaimed:
claim_task(unclaimed[0]["id"], name)
messages.append({"role": "user",
"content": f"<auto-claimed>Task #{unclaimed[0]['id']}: "
f"{unclaimed[0]['subject']}</auto-claimed>"})
return True
return False # timeout -> shutdown
```java
// IDLE PHASE: 轮询收件箱 + 任务板
setStatus(name, "idle");
boolean resume = false;
int polls = IDLE_TIMEOUT / Math.max(POLL_INTERVAL, 1); // 60/5 = 12
for (int p = 0; p < polls; p++) {
Thread.sleep(POLL_INTERVAL * 1000L);
// 检查收件箱
var inbox = bus.readInbox(name);
if (!inbox.isEmpty()) {
initialPrompt = "<inbox>" + mapper.writeValueAsString(inbox) + "</inbox>";
resume = true;
break;
}
// 扫描任务板
var unclaimed = scanUnclaimedTasks(tasksDir);
if (!unclaimed.isEmpty()) {
var task = unclaimed.get(0);
int taskId = ((Number) task.get("id")).intValue();
claimTask(tasksDir, taskId, name);
initialPrompt = String.format(
"<auto-claimed>Task #%d: %s\n%s</auto-claimed>",
taskId, task.get("subject"),
task.getOrDefault("description", ""));
resume = true;
break;
}
}
if (!resume) { setStatus(name, "shutdown"); return; }
setStatus(name, "working");
```
3. 任务看板扫描: 找 pending 状态、无 owner、未被阻塞的任务。
```python
def scan_unclaimed_tasks() -> list:
unclaimed = []
for f in sorted(TASKS_DIR.glob("task_*.json")):
task = json.loads(f.read_text())
if (task.get("status") == "pending"
and not task.get("owner")
and not task.get("blockedBy")):
unclaimed.append(task)
return unclaimed
```java
static List<Map<String, Object>> scanUnclaimedTasks(Path tasksDir) {
if (!Files.exists(tasksDir)) return List.of();
List<Map<String, Object>> unclaimed = new ArrayList<>();
ObjectMapper mapper = new ObjectMapper();
try (var files = Files.list(tasksDir)) {
files.filter(f -> f.getFileName().toString().startsWith("task_")
&& f.getFileName().toString().endsWith(".json"))
.sorted()
.forEach(f -> {
Map<String, Object> task = mapper.readValue(f.toFile(), Map.class);
if ("pending".equals(task.get("status"))
&& (task.get("owner") == null || "".equals(task.get("owner")))
&& (task.get("blockedBy") == null
|| ((List<?>) task.get("blockedBy")).isEmpty())) {
unclaimed.add(task);
}
});
}
return unclaimed;
}
```
4. 身份重注入: 上下文过短 (说明发生了压缩) 时, 在开头插入身份块
4. 身份保持: Java/Spring AI 的 `ChatClient.defaultSystem()` 在每次调用时自动携带系统提示, 身份信息始终存在, 无需像 Python 版本那样在压缩后手动重注入
```python
if len(messages) <= 3:
messages.insert(0, {"role": "user",
"content": f"<identity>You are '{name}', role: {role}, "
f"team: {team_name}. Continue your work.</identity>"})
messages.insert(1, {"role": "assistant",
"content": f"I am {name}. Continuing."})
```java
// 身份信息通过 defaultSystem 在构建时注入, 每次 prompt 自动携带
String sysPrompt = String.format(
"You are '%s', role: %s, team: %s, at %s. "
+ "Use idle tool when you have no more work. You will auto-claim new tasks.",
name, role, teamName, workDir);
ChatClient client = ChatClient.builder(chatModel)
.defaultSystem(sysPrompt) // 身份始终存在于系统提示中
.defaultTools(new BashTool(), new ReadFileTool(),
new WriteFileTool(), new EditFileTool(),
messageTool, protocolTool, idleTool, claimTool)
.build();
```
## 相对 s10 的变更
@@ -132,7 +181,7 @@ if len(messages) <= 3:
```sh
cd learn-claude-code
python agents/s11_autonomous_agents.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s11.S11AutonomousAgents
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):
+49 -26
View File
@@ -8,7 +8,7 @@
## 问题
到 s11, 智能体已经能自主认领和完成任务。但所有任务共享一个目录。两个智能体同时重构不同模块 -- A 改 `config.py`, B 也改 `config.py`, 未提交的改动互相污染, 谁也没法干净回滚。
到 s11, 智能体已经能自主认领和完成任务。但所有任务共享一个目录。两个智能体同时重构不同模块 -- A 改 `Config.java`, B 也改 `Config.java`, 未提交的改动互相污染, 谁也没法干净回滚。
任务板管 "做什么" 但不管 "在哪做"。解法: 给每个任务一个独立的 git worktree 目录, 用任务 ID 把两边关联起来。
@@ -38,48 +38,71 @@ State machines:
1. **创建任务。** 先把目标持久化。
```python
TASKS.create("Implement auth refactor")
# -> .tasks/task_1.json status=pending worktree=""
```java
// src/main/java/io/mybatis/learn/s12/WorktreeTaskManager.java
tasks.create("Implement auth refactor", "");
// -> .tasks/task_1.json status=pending worktree=""
```
2. **创建 worktree 并绑定任务。** 传入 `task_id` 自动将任务推进到 `in_progress`
```python
WORKTREES.create("auth-refactor", task_id=1)
# -> git worktree add -b wt/auth-refactor .worktrees/auth-refactor HEAD
# -> index.json gets new entry, task_1.json gets worktree="auth-refactor"
```java
// src/main/java/io/mybatis/learn/s12/WorktreeManager.java
worktrees.create("auth-refactor", 1, "HEAD");
// -> git worktree add -b wt/auth-refactor .worktrees/auth-refactor HEAD
// -> index.json gets new entry, task_1.json gets worktree="auth-refactor"
```
绑定同时写入两侧状态:
```python
def bind_worktree(self, task_id, worktree):
task = self._load(task_id)
task["worktree"] = worktree
if task["status"] == "pending":
task["status"] = "in_progress"
self._save(task)
```java
// src/main/java/io/mybatis/learn/s12/WorktreeTaskManager.java
public String bindWorktree(int taskId, String worktree, String owner) {
var task = load(taskId);
task.put("worktree", worktree);
if (owner != null && !owner.isEmpty()) task.put("owner", owner);
if ("pending".equals(task.get("status"))) task.put("status", "in_progress");
task.put("updated_at", System.currentTimeMillis() / 1000.0);
save(task);
return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(task);
}
```
3. **在 worktree 中执行命令。** `cwd` 指向隔离目录。
```python
subprocess.run(command, shell=True, cwd=worktree_path,
capture_output=True, text=True, timeout=300)
```java
// src/main/java/io/mybatis/learn/s12/WorktreeManager.java - run()
boolean isWindows = System.getProperty("os.name").toLowerCase().contains("win");
ProcessBuilder pb = isWindows
? new ProcessBuilder("cmd", "/c", command)
: new ProcessBuilder("sh", "-c", command);
pb.directory(path.toFile());
pb.redirectErrorStream(true);
Process p = pb.start();
String out = new String(p.getInputStream().readAllBytes()).trim();
boolean finished = p.waitFor(300, java.util.concurrent.TimeUnit.SECONDS);
```
4. **收尾。** 两种选择:
- `worktree_keep(name)` -- 保留目录供后续使用。
- `worktree_remove(name, complete_task=True)` -- 删除目录, 完成绑定任务, 发出事件。一个调用搞定拆除 + 完成。
```python
def remove(self, name, force=False, complete_task=False):
self._run_git(["worktree", "remove", wt["path"]])
if complete_task and wt.get("task_id") is not None:
self.tasks.update(wt["task_id"], status="completed")
self.tasks.unbind_worktree(wt["task_id"])
self.events.emit("task.completed", ...)
```java
// src/main/java/io/mybatis/learn/s12/WorktreeManager.java
public String remove(String name, boolean force, boolean completeTask) {
var wt = findWorktree(name);
events.emit("worktree.remove.before", ...);
runGit("worktree", "remove", wt.get("path").toString());
if (completeTask && wt.get("task_id") != null) {
int taskId = ((Number) wt.get("task_id")).intValue();
tasks.update(taskId, "completed", null);
tasks.unbindWorktree(taskId);
events.emit("task.completed",
Map.of("id", taskId, "status", "completed"),
Map.of("name", name), null);
}
// 更新 index.json: status -> "removed"
}
```
5. **事件流。** 每个生命周期步骤写入 `.worktrees/events.jsonl`:
@@ -111,7 +134,7 @@ def remove(self, name, force=False, complete_task=False):
```sh
cd learn-claude-code
python agents/s12_worktree_task_isolation.py
mvn exec:java -Dexec.mainClass=io.mybatis.learn.s12.S12WorktreeIsolation
```
试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):